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. ...@@ -7,6 +7,9 @@ the top. Include a label indicating the component affected.
Studio: Move Peer Assessment into advanced problems menu. 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: Add context-aware video index. BLD-933
Blades: Fix bug with incorrect link format and redirection. BLD-1049 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): ...@@ -180,7 +180,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
xblock = store.get_item(usage_key) xblock = store.get_item(usage_key)
is_read_only = _is_xblock_read_only(xblock) is_read_only = _is_xblock_read_only(xblock)
container_views = ['container_preview', 'reorderable_container_child_preview'] 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 # wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly # can bind to it correctly
...@@ -213,7 +213,6 @@ def xblock_view_handler(request, usage_key_string, view_name): ...@@ -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 # Note: this special case logic can be removed once the unit page is replaced
# with the new container view. # with the new container view.
context = { context = {
'runtime_type': 'studio',
'container_view': is_container_view, 'container_view': is_container_view,
'read_only': is_read_only, 'read_only': is_read_only,
'root_xblock': xblock if (view_name == 'container_preview') else None, 'root_xblock': xblock if (view_name == 'container_preview') else None,
......
...@@ -21,13 +21,14 @@ from xblock.exceptions import NoSuchHandlerError ...@@ -21,13 +21,14 @@ from xblock.exceptions import NoSuchHandlerError
from xblock.fragment import Fragment from xblock.fragment import Fragment
from lms.lib.xblock.field_data import LmsFieldData 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 cms.lib.xblock.runtime import local_resource_url
from util.sandboxing import can_execute_unsafe_code from util.sandboxing import can_execute_unsafe_code
import static_replace import static_replace
from .session_kv_store import SessionKeyValueStore 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 from contentstore.views.access import get_user_role
...@@ -143,15 +144,20 @@ def _preview_module_system(request, descriptor): ...@@ -143,15 +144,20 @@ def _preview_module_system(request, descriptor):
def _load_preview_module(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 request: The active django request
descriptor: An XModuleDescriptor descriptor: An XModuleDescriptor
""" """
student_data = KvsFieldData(SessionKeyValueStore(request)) 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( descriptor.bind_for_student(
_preview_module_system(request, descriptor), _preview_module_system(request, descriptor),
LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access field_data
) )
return descriptor return descriptor
...@@ -169,7 +175,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -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. 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. # 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') root_xblock = context.get('root_xblock')
is_root = root_xblock and xblock.location == root_xblock.location is_root = root_xblock and xblock.location == root_xblock.location
is_reorderable = _is_xblock_reorderable(xblock, context) is_reorderable = _is_xblock_reorderable(xblock, context)
...@@ -187,14 +193,25 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -187,14 +193,25 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
def get_preview_fragment(request, descriptor, context): 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. specified by the descriptor and idx.
""" """
module = _load_preview_module(request, descriptor) module = _load_preview_module(request, descriptor)
preview_view = 'author_view' if _has_author_view(module) else 'student_view'
try: try:
fragment = module.render("student_view", context) fragment = module.render(preview_view, context)
except Exception as exc: # pylint: disable=W0703 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)})) fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
return fragment 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): ...@@ -106,7 +106,7 @@ class GetItem(ItemTest):
self.assertNotIn('wrapper-xblock', html) self.assertNotIn('wrapper-xblock', html)
# Verify that the header and article tags are still added # 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) self.assertIn('<article class="xblock-render">', html)
def test_get_container_fragment(self): def test_get_container_fragment(self):
...@@ -122,7 +122,7 @@ class GetItem(ItemTest): ...@@ -122,7 +122,7 @@ class GetItem(ItemTest):
# Verify that the Studio nesting wrapper has been added # Verify that the Studio nesting wrapper has been added
self.assertIn('level-nesting', html) 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) self.assertIn('<article class="xblock-render">', html)
# Verify that the Studio element wrapper has been added # Verify that the Studio element wrapper has been added
...@@ -811,7 +811,15 @@ class TestEditSplitModule(ItemTest): ...@@ -811,7 +811,15 @@ class TestEditSplitModule(ItemTest):
self.assertEqual(partition_id, split_test.user_partition_id) self.assertEqual(partition_id, split_test.user_partition_id)
return split_test 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 Test that verticals are created for the experiment groups when
a spit test module is edited. a spit test module is edited.
...@@ -833,16 +841,14 @@ class TestEditSplitModule(ItemTest): ...@@ -833,16 +841,14 @@ class TestEditSplitModule(ItemTest):
self.assertEqual("alpha", vertical_0.display_name) self.assertEqual("alpha", vertical_0.display_name)
self.assertEqual("beta", vertical_1.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)) self.assertEqual(2, len(split_test.group_id_to_child))
split_test.group_id_to_child['0'] = vertical_0.location self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
split_test.group_id_to_child['1'] = vertical_1.location 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. 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. # Set to first experiment.
split_test = self._update_partition_id(0) split_test = self._update_partition_id(0)
...@@ -852,21 +858,23 @@ class TestEditSplitModule(ItemTest): ...@@ -852,21 +858,23 @@ class TestEditSplitModule(ItemTest):
# Set to second experiment # Set to second experiment
split_test = self._update_partition_id(1) 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(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_0 = self.get_item_from_modulestore(split_test.children[2], True)
vertical_1 = self.get_item_from_modulestore(split_test.children[3], 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) vertical_2 = self.get_item_from_modulestore(split_test.children[4], True)
# Verify that the group_id_to child mapping is correct. # Verify that the group_id_to child mapping is correct.
self.assertEqual(3, len(split_test.group_id_to_child)) self.assertEqual(3, len(split_test.group_id_to_child))
split_test.group_id_to_child['0'] = vertical_0.location self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
split_test.group_id_to_child['1'] = vertical_1.location self.assertEqual(vertical_1.location, split_test.group_id_to_child['1'])
split_test.group_id_to_child['2'] = vertical_2.location 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_0_location, vertical_0.location)
self.assertNotEqual(initial_vertical_1_location, vertical_1.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. Test that nothing happens when the user_partition_id is set to the same value twice.
""" """
...@@ -880,7 +888,7 @@ class TestEditSplitModule(ItemTest): ...@@ -880,7 +888,7 @@ class TestEditSplitModule(ItemTest):
self.assertEqual(2, len(split_test.children)) self.assertEqual(2, len(split_test.children))
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child) 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. 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): ...@@ -896,6 +904,80 @@ class TestEditSplitModule(ItemTest):
self.assertEqual(2, len(split_test.children)) self.assertEqual(2, len(split_test.children))
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child) 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 @ddt.ddt
class TestComponentHandler(TestCase): 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([ ...@@ -233,6 +233,8 @@ define([
"js/spec/views/modals/base_modal_spec", "js/spec/views/modals/base_modal_spec",
"js/spec/views/modals/edit_xblock_spec", "js/spec/views/modals/edit_xblock_spec",
"js/spec/xblock/cms.runtime.v1_spec",
# these tests are run separately in the cms-squire suite, due to process # these tests are run separately in the cms-squire suite, due to process
# isolation issues with Squire.js # isolation issues with Squire.js
# "coffee/spec/views/assets_spec" # "coffee/spec/views/assets_spec"
......
define [ define [
"jquery", "xblock/runtime.v1", "URI", "gettext", "jquery", "backbone", "xblock/runtime.v1", "URI", "gettext",
"js/utils/modal", "js/views/feedback_notification" "js/utils/modal", "js/views/feedback_notification"
], ($, XBlock, URI, gettext, ModalUtils, NotificationView) -> ], ($, Backbone, XBlock, URI, gettext, ModalUtils, NotificationView) ->
@PreviewRuntime = {}
@BaseRuntime = {}
class PreviewRuntime.v1 extends XBlock.Runtime.v1 class BaseRuntime.v1 extends XBlock.Runtime.v1
handlerUrl: (element, handlerName, suffix, query, thirdparty) -> 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('handler')
.segment(handlerName) .segment(handlerName)
if suffix? then uri.segment(suffix) if suffix? then uri.segment(suffix)
if query? then uri.search(query) if query? then uri.search(query)
uri.toString() uri.toString()
@StudioRuntime = {}
class StudioRuntime.v1 extends XBlock.Runtime.v1
constructor: () -> constructor: () ->
super() super()
@savingNotification = new NotificationView.Mini @dispatcher = _.clone(Backbone.Events)
title: gettext('Saving&hellip;') @listenTo('save', @_handleSave)
@alert = new NotificationView.Error @listenTo('cancel', @_handleCancel)
title: "OpenAssessment Save Error", @listenTo('error', @_handleError)
closeIcon: false, @listenTo('modal-shown', (data) ->
shown: false @modal = data)
@listenTo('modal-hidden', () ->
@modal = null)
@listenTo('page-shown', (data) ->
@page = data)
handlerUrl: (element, handlerName, suffix, query, thirdparty) -> # Notify the Studio client-side runtime of an event so that it can update the UI in a consistent way.
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: (name, data) -> notify: (name, data) ->
if name == 'save' @dispatcher.trigger(name, data)
if 'state' of data
# Listen to a Studio event and invoke the specified callback when it is triggered.
listenTo: (name, callback) ->
@dispatcher.bind(name, callback, this)
# Refresh the view for the xblock represented by the specified element.
refreshXBlock: (element) ->
if @page
@page.refreshXBlock(element)
# Starting to save, so show the "Saving..." notification _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()
_handleSave: (data) ->
# Starting to save, so show a notification
if data.state == 'start' if data.state == 'start'
@savingNotification.show() message = data.message || gettext('Saving&hellip;')
@notification = new NotificationView.Mini
title: message
@notification.show()
# Finished saving, so hide the "Saving..." notification # Finished saving, so hide the notification and refresh appropriately
else if data.state == 'end' else if data.state == 'end'
@_hideAlerts() @_hideAlerts()
# Notify the modal that the save has completed so that it can hide itself # Notify the modal that the save has completed so that it can hide itself
# and then refresh the xblock. # and then refresh the xblock.
if @modal if @modal and @modal.onSave
@modal.onSave() @modal.onSave()
# ... else ask it to refresh the newly saved xblock
else if data.element
@refreshXBlock(data.element)
@savingNotification.hide() @notification.hide()
else if name == 'edit-modal-shown'
@modal = data
else if name == 'edit-modal-hidden' _handleCancel: () ->
@modal = null
else if name == 'cancel'
@_hideAlerts() @_hideAlerts()
if @modal if @modal
@modal.cancel() @modal.cancel()
@notify('modal-hidden')
else if name == 'error'
if 'msg' of data
@alert.options.message = data.msg
@alert.show()
_hideAlerts: () -> _hideAlerts: () ->
# Hide any alerts that are being shown # Hide any alerts that are being shown
if @alert.options.shown if @alert && @alert.options.shown
@alert.hide() @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"], "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() { describe("ContainerPage", function() {
var lastRequest, renderContainerPage, expectComponents, respondWithHtml, var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
...@@ -96,7 +96,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -96,7 +96,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
editButtons.first().click(); editButtons.first().click();
// Expect a request to be made to show the studio view for the container // 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, { create_sinon.respondWithJson(requests, {
html: mockContainerXBlockHtml, html: mockContainerXBlockHtml,
resources: [] resources: []
...@@ -112,7 +112,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -112,7 +112,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
expect(edit_helpers.isShowingModal()).toBeFalsy(); expect(edit_helpers.isShowingModal()).toBeFalsy();
// Expect the last request be to refresh the container page // 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, { create_sinon.respondWithJson(requests, {
html: mockUpdatedContainerXBlockHtml, html: mockUpdatedContainerXBlockHtml,
resources: [] resources: []
...@@ -149,7 +149,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -149,7 +149,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
expect(editButtons.length).toBe(6); expect(editButtons.length).toBe(6);
editButtons[0].click(); editButtons[0].click();
// Make sure that the correct xblock is requested to be edited // 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, { create_sinon.respondWithJson(requests, {
html: mockXBlockEditorHtml, html: mockXBlockEditorHtml,
resources: [] 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"], "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, var requests, unitView, initialize, lastRequest, respondWithHtml, verifyComponents, i,
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'); mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
...@@ -150,7 +150,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m ...@@ -150,7 +150,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
}); });
describe("Disabled edit/publish links during ajax call", function() { describe("Disabled edit/publish links during ajax call", function() {
var link, i, var link,
draft_states = [ draft_states = [
{ {
state: "draft", state: "draft",
...@@ -204,7 +204,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m ...@@ -204,7 +204,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
expect(editButtons.length).toBe(2); expect(editButtons.length).toBe(2);
editButtons[1].click(); editButtons[1].click();
// Make sure that the correct xblock is requested to be edited // 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, { create_sinon.respondWithJson(requests, {
html: mockXBlockEditorHtml, html: mockXBlockEditorHtml,
resources: [] 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 ...@@ -21,8 +21,8 @@ define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sino
appendSetFixtures('<div id="page-notification"></div>'); appendSetFixtures('<div id="page-notification"></div>');
}; };
createNotificationSpy = function() { createNotificationSpy = function(type) {
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]); var notificationSpy = spyOnConstructor(NotificationView, type || "Mini", ["show", "hide"]);
notificationSpy.show.andReturn(notificationSpy); notificationSpy.show.andReturn(notificationSpy);
return notificationSpy; return notificationSpy;
}; };
......
...@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", ...@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
// Notify the runtime that the modal has been shown // Notify the runtime that the modal has been shown
if (runtime) { if (runtime) {
this.runtime = runtime; this.runtime = runtime;
runtime.notify("edit-modal-shown", this); runtime.notify('modal-shown', this);
} }
// Update the modal's header // Update the modal's header
...@@ -166,7 +166,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", ...@@ -166,7 +166,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
// Notify the runtime that the modal has been hidden // Notify the runtime that the modal has been hidden
if (this.runtime) { if (this.runtime) {
this.runtime.notify('edit-modal-hidden'); this.runtime.notify('modal-hidden');
} }
// Completely clear the contents of the modal // Completely clear the contents of the modal
...@@ -180,7 +180,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", ...@@ -180,7 +180,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
displayName; displayName;
if (xblockWrapperElement.length > 0) { if (xblockWrapperElement.length > 0) {
xblockElement = xblockWrapperElement.find('.xblock'); 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 not found, try looking for the old unit page style rendering
if (!displayName) { if (!displayName) {
displayName = this.xblockElement.find('.component-header').text().trim(); displayName = this.xblockElement.find('.component-header').text().trim();
......
...@@ -2,11 +2,9 @@ ...@@ -2,11 +2,9 @@
* XBlockContainerPage is used to display Studio's container page for an xblock which has children. * XBlockContainerPage is used to display Studio's container page for an xblock which has children.
* This page allows the user to understand and manipulate the xblock and its children. * This page allows the user to understand and manipulate the xblock and its children.
*/ */
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/container",
"js/views/baseview", "js/views/container", "js/views/xblock", "js/views/components/add_xblock", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
"js/views/modals/edit_xblock", "js/models/xblock_info"], function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo) {
function ($, _, gettext, NotificationView, BaseView, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo) {
var XBlockContainerPage = BaseView.extend({ var XBlockContainerPage = BaseView.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
...@@ -36,6 +34,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -36,6 +34,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
// Render the xblock // Render the xblock
xblockView.render({ xblockView.render({
success: function(xblock) { success: function(xblock) {
xblockView.xblock.runtime.notify("page-shown", self);
xblockView.$el.removeClass('is-hidden'); xblockView.$el.removeClass('is-hidden');
self.renderAddXBlockComponents(); self.renderAddXBlockComponents();
self.onXBlockRefresh(xblockView); self.onXBlockRefresh(xblockView);
...@@ -55,7 +54,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -55,7 +54,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
}, },
refreshTitle: function() { 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-title').text(title);
this.$('.page-header .subtitle a').last().text(title); this.$('.page-header .subtitle a').last().text(title);
}, },
...@@ -112,12 +111,16 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -112,12 +111,16 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
buttonPanel = target.closest('.add-xblock-component'), buttonPanel = target.closest('.add-xblock-component'),
listPanel = buttonPanel.prev(), listPanel = buttonPanel.prev(),
scrollOffset = this.getScrollOffset(buttonPanel), scrollOffset = this.getScrollOffset(buttonPanel),
placeholderElement = $('<div></div>').appendTo(listPanel), placeholderElement = $('<div class="studio-xblock-wrapper"></div>').appendTo(listPanel),
requestData = _.extend(template, { requestData = _.extend(template, {
parent_locator: parentLocator parent_locator: parentLocator
}); });
return $.postJSON(this.getURLRoot() + '/', requestData, 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) { duplicateComponent: function(xblockElement) {
...@@ -129,14 +132,18 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -129,14 +132,18 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
this.runOperationShowingMessage(gettext('Duplicating&hellip;'), this.runOperationShowingMessage(gettext('Duplicating&hellip;'),
function() { function() {
var scrollOffset = self.getScrollOffset(xblockElement), var scrollOffset = self.getScrollOffset(xblockElement),
placeholderElement = $('<div></div>').insertAfter(xblockElement), placeholderElement = $('<div class="studio-xblock-wrapper"></div>').insertAfter(xblockElement),
parentElement = self.findXBlockElement(parent), parentElement = self.findXBlockElement(parent),
requestData = { requestData = {
duplicate_source_locator: xblockElement.data('locator'), duplicate_source_locator: xblockElement.data('locator'),
parent_locator: parentElement.data('locator') parent_locator: parentElement.data('locator')
}; };
return $.postJSON(self.getURLRoot() + '/', requestData, 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", ...@@ -153,16 +160,21 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
url: self.getURLRoot() + "/" + url: self.getURLRoot() + "/" +
xblockElement.data('locator') + "?" + xblockElement.data('locator') + "?" +
$.param({recurse: true, all_versions: false}) $.param({recurse: true, all_versions: false})
}).success(function() { }).success(_.bind(self.onDelete, self, xblockElement));
// get the parent so we can remove this component from its parent.
var parent = self.findXBlockElement(xblockElement.parent());
xblockElement.remove();
self.xblockView.updateChildren(parent);
});
}); });
}); });
}, },
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) { onNewXBlock: function(xblockElement, scrollOffset, data) {
this.setScrollOffset(xblockElement, scrollOffset); this.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator); xblockElement.data('locator', data.locator);
...@@ -173,10 +185,11 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", ...@@ -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 * 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 * reorderable container then the element will be refreshed inline. If not, then the
* parent container will be refreshed instead. * 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) { refreshXBlock: function(element) {
var parentElement = xblockElement.parent(), var xblockElement = this.findXBlockElement(element),
parentElement = xblockElement.parent(),
rootLocator = this.xblockView.model.id; rootLocator = this.xblockView.model.id;
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
this.render({ }); this.render({ });
......
...@@ -233,7 +233,7 @@ ...@@ -233,7 +233,7 @@
@include transition(all $tmg-f3 linear 0s); @include transition(all $tmg-f3 linear 0s);
display: block; display: block;
border-radius: 3px; border-radius: 3px;
padding: ($baseline/4) ($baseline/2); padding: 3px ($baseline/2);
color: $gray-l1; color: $gray-l1;
&:hover { &:hover {
......
...@@ -28,6 +28,11 @@ ...@@ -28,6 +28,11 @@
display: inline-block; display: inline-block;
width: 50%; width: 50%;
vertical-align: middle; vertical-align: middle;
.xblock-display-name {
display: inline-block;
vertical-align: middle;
}
} }
.header-actions { .header-actions {
......
...@@ -253,6 +253,46 @@ body.view-container .content-primary { ...@@ -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 // add a new component menu override - most styles currently live in _unit.scss
.new-component-item { .new-component-item {
margin: $baseline ($baseline/2); margin: $baseline ($baseline/2);
......
<header class="xblock-header"> <header class="xblock-header">
<div class="xblock-header-primary"> <div class="xblock-header-primary">
<div class="header-details"> <div class="header-details">
<span>Test Container</span> <span class="xblock-display-name">Test Container</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i> <i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">Expand or Collapse</span> <span class="sr">Expand or Collapse</span>
</a> </a>
<span>Group A</span> <span class="xblock-display-name">Group A</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
...@@ -131,7 +131,7 @@ ...@@ -131,7 +131,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i> <i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">Expand or Collapse</span> <span class="sr">Expand or Collapse</span>
</a> </a>
<span>Group B</span> <span class="xblock-display-name">Group B</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i> <i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">Expand or Collapse</span> <span class="sr">Expand or Collapse</span>
</a> </a>
<span>Empty Vertical Test</span> <span class="xblock-display-name">Empty Vertical Test</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
<header class="xblock-header"> <header class="xblock-header">
<div class="xblock-header-primary"> <div class="xblock-header-primary">
<div class="header-details"> <div class="header-details">
<span>Updated Test Container</span> <span class="xblock-display-name">Updated Test Container</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
<li class="studio-xblock-wrapper is-draggable"> <li class="studio-xblock-wrapper is-draggable">
<header class="xblock-header"> <header class="xblock-header">
<div class="header-details"> <div class="header-details">
<span>Mock XBlock</span> <span class="xblock-display-name">Mock XBlock</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
<li class="studio-xblock-wrapper is-draggable"> <li class="studio-xblock-wrapper is-draggable">
<header class="xblock-header"> <header class="xblock-header">
<div class="header-details"> <div class="header-details">
<span>Mock XBlock</span> <span class="xblock-display-name">Mock XBlock</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i> <i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">${_('Expand or Collapse')}</span> <span class="sr">${_('Expand or Collapse')}</span>
</a> </a>
<span>${xblock.display_name_with_default | h}</span> <span class="xblock-display-name">${xblock.display_name_with_default | h}</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
......
...@@ -20,7 +20,7 @@ collapsible_class = "is-collapsible" if xblock.has_children else "" ...@@ -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}"> <section class="wrapper-xblock ${section_class} ${collapsible_class}" data-course-key="${xblock.location.course_key}">
% endif % endif
<header class="xblock-header"> <header class="xblock-header xblock-header-${xblock.category}">
<div class="xblock-header-primary"> <div class="xblock-header-primary">
<div class="header-details"> <div class="header-details">
% if show_inline: % if show_inline:
...@@ -29,7 +29,7 @@ collapsible_class = "is-collapsible" if xblock.has_children else "" ...@@ -29,7 +29,7 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
<span class="sr">${_('Expand or Collapse')}</span> <span class="sr">${_('Expand or Collapse')}</span>
</a> </a>
% endif % endif
<span>${xblock.display_name_with_default | h}</span> <span class="xblock-display-name">${xblock.display_name_with_default | h}</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
...@@ -47,13 +47,13 @@ collapsible_class = "is-collapsible" if xblock.has_children else "" ...@@ -47,13 +47,13 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
<span class="sr">${_("Duplicate")}</span> <span class="sr">${_("Duplicate")}</span>
</a> </a>
</li> </li>
% endif
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button"> <a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
<i class="icon-trash"></i> <i class="icon-trash"></i>
<span class="sr">${_("Delete")}</span> <span class="sr">${_("Delete")}</span>
</a> </a>
</li> </li>
% endif
% if is_reorderable: % if is_reorderable:
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
......
<%! 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 ...@@ -16,7 +16,6 @@ from xblock.fragment import Fragment
from xmodule.seq_module import SequenceModule from xmodule.seq_module import SequenceModule
from xmodule.vertical_module import VerticalModule from xmodule.vertical_module import VerticalModule
from xmodule.x_module import shim_xmodule_js, XModuleDescriptor, XModule 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 import MONGO_MODULESTORE_TYPE
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -60,7 +59,7 @@ def wrap_xblock(runtime_class, block, view, frag, context, usage_id_serializer, ...@@ -60,7 +59,7 @@ def wrap_xblock(runtime_class, block, view, frag, context, usage_id_serializer,
css_classes = ['xblock', 'xblock-' + view] css_classes = ['xblock', 'xblock-' + view]
if isinstance(block, (XModule, XModuleDescriptor)): if isinstance(block, (XModule, XModuleDescriptor)):
if view == 'student_view': if view in ['student_view', 'author_view']:
# The block is acting as an XModule # The block is acting as an XModule
css_classes.append('xmodule_display') css_classes.append('xmodule_display')
elif view == 'studio_view': 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 {};
};
...@@ -10,7 +10,7 @@ from pkg_resources import resource_string ...@@ -10,7 +10,7 @@ from pkg_resources import resource_string
from xmodule.progress import Progress from xmodule.progress import Progress
from xmodule.seq_module import SequenceDescriptor from xmodule.seq_module import SequenceDescriptor
from xmodule.studio_editable import StudioEditableModule from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
from xmodule.x_module import XModule, module_attr from xmodule.x_module import XModule, module_attr
from xmodule.modulestore.inheritance import UserPartitionList from xmodule.modulestore.inheritance import UserPartitionList
...@@ -51,6 +51,21 @@ class ValidationMessageType(object): ...@@ -51,6 +51,21 @@ class ValidationMessageType(object):
return None return None
# TODO: move this into the xblock repo once it has a formal validation contract
class ValidationMessage(object):
"""
Represents a single validation message for an xblock.
"""
def __init__(self, xblock, message_text, message_type):
assert isinstance(message_text, unicode)
self.xblock = xblock
self.message_text = message_text
self.message_type = message_type
def __unicode__(self):
return self.message_text
class SplitTestFields(object): class SplitTestFields(object):
"""Fields needed for split test module""" """Fields needed for split test module"""
has_children = True has_children = True
...@@ -87,7 +102,7 @@ class SplitTestFields(object): ...@@ -87,7 +102,7 @@ class SplitTestFields(object):
) )
user_partition_id = Integer( user_partition_id = Integer(
help=_("The configuration for how users are grouped for this content experiment. After you select the group configuration and save the content experiment, you cannot change this setting."), help=_("The configuration defines how users are grouped for this content experiment. Caution: Changing the group configuration of a student-visible experiment will impact the experiment data."),
scope=Scope.content, scope=Scope.content,
display_name=_("Group Configuration"), display_name=_("Group Configuration"),
default=no_partition_selected["value"], default=no_partition_selected["value"],
...@@ -238,35 +253,58 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule): ...@@ -238,35 +253,58 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
fragment.initialize_js('ABTestSelector') fragment.initialize_js('ABTestSelector')
return fragment return fragment
def studio_preview_view(self, context): def author_view(self, context):
""" """
Renders the Studio preview by rendering each child so that they can all be seen and edited. Renders the Studio preview by rendering each child so that they can all be seen and edited.
""" """
fragment = Fragment() fragment = Fragment()
root_xblock = context.get('root_xblock') root_xblock = context.get('root_xblock')
is_root = root_xblock and root_xblock.location == self.location is_root = root_xblock and root_xblock.location == self.location
active_groups_preview = None
inactive_groups_preview = None
# We don't show the "add missing groups" button on the unit page-- only when showing the container page.
is_missing_groups = False
if is_root:
user_partition = self.descriptor.get_selected_partition()
[active_children, inactive_children] = self.descriptor.active_and_inactive_children()
is_missing_groups = user_partition and len(active_children) < len(user_partition.groups)
active_groups_preview = self.studio_render_children(
fragment, active_children, context
)
inactive_groups_preview = self.studio_render_children(
fragment, inactive_children, context
)
# First render a header at the top of the split test module... fragment.add_content(self.system.render_template('split_test_author_view.html', {
fragment.add_content(self.system.render_template('split_test_studio_header.html', {
'split_test': self, 'split_test': self,
'is_root': is_root, 'is_root': is_root,
'active_groups_preview': active_groups_preview,
'inactive_groups_preview': inactive_groups_preview,
'is_missing_groups': is_missing_groups
})) }))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_author_view.js'))
# ... then render the children only when this block is being shown as the container fragment.initialize_js('SplitTestAuthorView')
if is_root:
self.render_children(context, fragment, can_reorder=False)
return fragment return fragment
def studio_render_children(self, fragment, children, context):
"""
Renders the specified children and returns it as an HTML string. In addition, any
dependencies are added to the specified fragment.
"""
html = ""
for active_child_descriptor in children:
active_child = self.system.get_module(active_child_descriptor)
rendered_child = active_child.render(StudioEditableModule.get_preview_view_name(active_child), context)
fragment.add_frag_resources(rendered_child)
html = html + rendered_child.content
return html
def student_view(self, context): def student_view(self, context):
""" """
Render the contents of the chosen condition for students, and all the Renders the contents of the chosen condition for students, and all the
conditions for staff. conditions for staff.
""" """
# When rendering a Studio preview, render all of the block's children
if context and context.get('runtime_type', None) == 'studio':
return self.studio_preview_view(context)
if self.child is None: if self.child is None:
# raise error instead? In fact, could complain on descriptor load... # raise error instead? In fact, could complain on descriptor load...
return Fragment(content=u"<div>Nothing here. Move along.</div>") return Fragment(content=u"<div>Nothing here. Move along.</div>")
...@@ -305,14 +343,13 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule): ...@@ -305,14 +343,13 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
@XBlock.needs('user_tags') # pylint: disable=abstract-method @XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.wants('partitions') @XBlock.wants('partitions')
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDescriptor):
# the editing interface can be the same as for sequences -- just a container # the editing interface can be the same as for sequences -- just a container
module_class = SplitTestModule module_class = SplitTestModule
filename_extension = "xml" filename_extension = "xml"
mako_template = "widgets/split-edit.html" mako_template = "widgets/metadata-only-edit.html"
css = {'scss': [resource_string(__name__, 'css/split_test/edit.scss')]}
child_descriptor = module_attr('child_descriptor') child_descriptor = module_attr('child_descriptor')
log_child_render = module_attr('log_child_render') log_child_render = module_attr('log_child_render')
...@@ -359,8 +396,7 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): ...@@ -359,8 +396,7 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
def get_context(self): def get_context(self):
_context = super(SplitTestDescriptor, self).get_context() _context = super(SplitTestDescriptor, self).get_context()
_context.update({ _context.update({
'disable_user_partition_editing': self._disable_user_partition_editing(), 'selected_partition': self.get_selected_partition()
'selected_partition': self._get_selected_partition()
}) })
return _context return _context
...@@ -380,26 +416,17 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): ...@@ -380,26 +416,17 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
# Any existing value of user_partition_id will be in "old_content" instead of "old_metadata" # Any existing value of user_partition_id will be in "old_content" instead of "old_metadata"
# because it is Scope.content. # because it is Scope.content.
if 'user_partition_id' not in old_content or old_content['user_partition_id'] != self.user_partition_id: if 'user_partition_id' not in old_content or old_content['user_partition_id'] != self.user_partition_id:
selected_partition = self._get_selected_partition() selected_partition = self.get_selected_partition()
if selected_partition is not None: if selected_partition is not None:
assert hasattr(self.system, 'modulestore') and hasattr(self.system.modulestore, 'create_and_save_xmodule'), \ self.group_id_mapping = {} # pylint: disable=attribute-defined-outside-init
"editor_saved should only be called when a mutable modulestore is available"
modulestore = self.system.modulestore
group_id_mapping = {}
for group in selected_partition.groups: for group in selected_partition.groups:
dest_usage_key = self.location.replace(category="vertical", name=uuid4().hex) self._create_vertical_for_group(group)
metadata = {'display_name': group.name}
modulestore.create_and_save_xmodule(
dest_usage_key,
definition_data=None,
metadata=metadata,
system=self.system,
)
self.children.append(dest_usage_key) # pylint: disable=no-member
group_id_mapping[unicode(group.id)] = dest_usage_key
self.group_id_to_child = group_id_mapping
# Don't need to call update_item in the modulestore because the caller of this method will do it. # Don't need to call update_item in the modulestore because the caller of this method will do it.
else:
# If children referenced in group_id_to_child have been deleted, remove them from the map.
for str_group_id, usage_key in self.group_id_to_child.items():
if usage_key not in self.children: # pylint: disable=no-member
del self.group_id_to_child[str_group_id]
@property @property
def editable_metadata_fields(self): def editable_metadata_fields(self):
...@@ -408,7 +435,6 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): ...@@ -408,7 +435,6 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
editable_fields = super(SplitTestDescriptor, self).editable_metadata_fields editable_fields = super(SplitTestDescriptor, self).editable_metadata_fields
if not self._disable_user_partition_editing():
# Explicitly add user_partition_id, which does not automatically get picked up because it is Scope.content. # Explicitly add user_partition_id, which does not automatically get picked up because it is Scope.content.
# Note that this means it will be saved by the Studio editor as "metadata", but the field will # Note that this means it will be saved by the Studio editor as "metadata", but the field will
# still update correctly. # still update correctly.
...@@ -427,13 +453,7 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): ...@@ -427,13 +453,7 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
]) ])
return non_editable_fields return non_editable_fields
def _disable_user_partition_editing(self): def get_selected_partition(self):
"""
If user_partition_id has been set to anything besides the default value, disable editing.
"""
return self.user_partition_id != SplitTestFields.user_partition_id.default
def _get_selected_partition(self):
""" """
Returns the partition that this split module is currently using, or None Returns the partition that this split module is currently using, or None
if the currently selected partition ID does not match any of the defined partitions. if the currently selected partition ID does not match any of the defined partitions.
...@@ -444,23 +464,116 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): ...@@ -444,23 +464,116 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
return None return None
def validation_message(self): def active_and_inactive_children(self):
"""
Returns two values:
1. The active children of this split test, in the order of the groups.
2. The remaining (inactive) children, in the order they were added to the split test.
"""
children = self.get_children()
user_partition = self.get_selected_partition()
if not user_partition:
return [], children
def get_child_descriptor(location):
"""
Returns the child descriptor which matches the specified location, or None if one is not found.
"""
for child in children:
if child.location == location:
return child
return None
# Compute the active children in the order specified by the user partition
active_children = []
for group in user_partition.groups:
group_id = unicode(group.id)
child_location = self.group_id_to_child.get(group_id, None)
child = get_child_descriptor(child_location)
if child:
active_children.append(child)
# Compute the inactive children in the order they were added to the split test
inactive_children = [child for child in children if child not in active_children]
return active_children, inactive_children
def validation_messages(self):
""" """
Returns a validation message describing the current state of the block, as well as a message type Returns a list of validation messages describing the current state of the block. Each message
indicating whether the message represents information, a warning or an error. includes a message type indicating whether the message represents information, a warning or an error.
""" """
_ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name _ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name
messages = []
if self.user_partition_id < 0: if self.user_partition_id < 0:
return _(u"You must select a group configuration for this content experiment."), ValidationMessageType.warning messages.append(ValidationMessage(
user_partition = self._get_selected_partition() self,
_(u"You must select a group configuration for this content experiment."),
ValidationMessageType.warning
))
else:
user_partition = self.get_selected_partition()
if not user_partition: if not user_partition:
return \ messages.append(ValidationMessage(
self,
_(u"This content experiment will not be shown to students because it refers to a group configuration that has been deleted. You can delete this experiment or reinstate the group configuration to repair it."), \ _(u"This content experiment will not be shown to students because it refers to a group configuration that has been deleted. You can delete this experiment or reinstate the group configuration to repair it."), \
ValidationMessageType.error ValidationMessageType.error
groups = user_partition.groups ))
if not len(groups) == len(self.get_children()): else:
return _(u"This content experiment is in an invalid state and cannot be repaired. Please delete and recreate."), ValidationMessageType.error [active_children, inactive_children] = self.active_and_inactive_children()
if len(active_children) < len(user_partition.groups):
messages.append(ValidationMessage(
self,
_(u"This content experiment is missing groups that are defined in the current configuration. You can press the 'Create Missing Groups' button to create them."),
ValidationMessageType.error
))
if len(inactive_children) > 0:
messages.append(ValidationMessage(
self,
_(u"This content experiment has children that are not associated with the selected group configuration. You can move content into an active group or delete it if it is unneeded."),
ValidationMessageType.warning
))
return messages
@XBlock.handler
def add_missing_groups(self, request, suffix=''): # pylint: disable=unused-argument
"""
Create verticals for any missing groups in the split test instance.
Called from Studio view.
"""
user_partition = self.get_selected_partition()
for group in user_partition.groups:
str_group_id = unicode(group.id)
changed = False
if str_group_id not in self.group_id_to_child:
self._create_vertical_for_group(group)
changed = True
if changed:
# request does not have a user attribute, so pass None for user.
self.system.modulestore.update_item(self, None)
return Response()
def _create_vertical_for_group(self, group):
"""
Creates a vertical to associate with the group.
return _(u"This content experiment uses group configuration '{experiment_name}'.").format( This appends the new vertical to the end of children, and updates group_id_to_child.
experiment_name=user_partition.name A mutable modulestore is needed to call this method (will need to update after mixed
), ValidationMessageType.information modulestore work, currently relies on mongo's create_and_save_xmodule method).
"""
assert hasattr(self.system, 'modulestore') and hasattr(self.system.modulestore, 'create_and_save_xmodule'), \
"editor_saved should only be called when a mutable modulestore is available"
modulestore = self.system.modulestore
dest_usage_key = self.location.replace(category="vertical", name=uuid4().hex)
metadata = {'display_name': group.name}
modulestore.create_and_save_xmodule(
dest_usage_key,
definition_data=None,
metadata=metadata,
system=self.system,
)
self.children.append(dest_usage_key) # pylint: disable=no-member
self.group_id_to_child[unicode(group.id)] = dest_usage_key
""" """
Mixin to support editing in Studio. Mixin to support editing in Studio.
""" """
from xmodule.x_module import module_attr
class StudioEditableModule(object): 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 This class is only intended to be used with an XModule, as it assumes the existence of
self.descriptor and self.system. 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, 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. then the children will be rendered to support drag and drop.
...@@ -22,7 +23,7 @@ class StudioEditableModule(object): ...@@ -22,7 +23,7 @@ class StudioEditableModule(object):
if can_reorder: if can_reorder:
context['reorderable_items'].add(child.location) context['reorderable_items'].add(child.location)
child_module = self.system.get_module(child) # pylint: disable=E1101 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) fragment.add_frag_resources(rendered_child)
contents.append({ contents.append({
...@@ -36,3 +37,21 @@ class StudioEditableModule(object): ...@@ -36,3 +37,21 @@ class StudioEditableModule(object):
'can_add': can_add, 'can_add': can_add,
'can_reorder': can_reorder, '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
...@@ -159,35 +159,47 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -159,35 +159,47 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
Unit tests for how split test interacts with Studio. Unit tests for how split test interacts with Studio.
""" """
def test_render_studio_view(self): def test_render_author_view(self):
""" """
Test the rendering of the Studio view. Test the rendering of the Studio author view.
""" """
# The split_test module should render both its groups when it is the root def create_studio_context(root_xblock):
reorderable_items = set() """
context = { Context for rendering the studio "author_view".
'runtime_type': 'studio', """
return {
'container_view': True, 'container_view': True,
'reorderable_items': reorderable_items, 'reorderable_items': set(),
'root_xblock': self.split_test_module, 'root_xblock': root_xblock,
} }
html = self.module_system.render(self.split_test_module, 'student_view', context).content
# The split_test module should render both its groups when it is the root
context = create_studio_context(self.split_test_module)
html = self.module_system.render(self.split_test_module, 'author_view', context).content
self.assertIn('HTML FOR GROUP 0', html) self.assertIn('HTML FOR GROUP 0', html)
self.assertIn('HTML FOR GROUP 1', html) self.assertIn('HTML FOR GROUP 1', html)
# Note that the mock xblock system doesn't render the template but the parameters instead
self.assertNotIn('\'is_missing_groups\': True', html)
# When rendering as a child, it shouldn't render either of its groups # When rendering as a child, it shouldn't render either of its groups
reorderable_items = set() context = create_studio_context(self.course_sequence)
context = { html = self.module_system.render(self.split_test_module, 'author_view', context).content
'runtime_type': 'studio',
'container_view': True,
'reorderable_items': reorderable_items,
'root_xblock': self.course_sequence,
}
html = self.module_system.render(self.split_test_module, 'student_view', context).content
self.assertNotIn('HTML FOR GROUP 0', html) self.assertNotIn('HTML FOR GROUP 0', html)
self.assertNotIn('HTML FOR GROUP 1', html) self.assertNotIn('HTML FOR GROUP 1', html)
# The "Create Missing Groups" button should be rendered when groups are missing
context = create_studio_context(self.split_test_module)
self.split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')])
]
html = self.module_system.render(self.split_test_module, 'author_view', context).content
self.assertIn('HTML FOR GROUP 0', html)
self.assertIn('HTML FOR GROUP 1', html)
# Note that the mock xblock system doesn't render the template but the parameters instead
self.assertIn('\'is_missing_groups\': True', html)
def test_editable_settings(self): def test_editable_settings(self):
""" """
Test the setting information passed back from editable_metadata_fields. Test the setting information passed back from editable_metadata_fields.
...@@ -197,14 +209,8 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -197,14 +209,8 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
self.assertNotIn(SplitTestDescriptor.due.name, editable_metadata_fields) self.assertNotIn(SplitTestDescriptor.due.name, editable_metadata_fields)
self.assertNotIn(SplitTestDescriptor.user_partitions.name, editable_metadata_fields) self.assertNotIn(SplitTestDescriptor.user_partitions.name, editable_metadata_fields)
# user_partition_id will only appear in the editable settings if the value is the # user_partition_id will always appear in editable_metadata_settings, regardless
# default "unselected" value. This split instance has user_partition_id = 0, so # of the selected value.
# user_partition_id will not be editable.
self.assertNotIn(SplitTestDescriptor.user_partition_id.name, editable_metadata_fields)
# Explicitly set user_partition_id to the default value. Now user_partition_id will be editable.
self.split_test_module.user_partition_id = SplitTestFields.no_partition_selected['value']
editable_metadata_fields = self.split_test_module.editable_metadata_fields
self.assertIn(SplitTestDescriptor.user_partition_id.name, editable_metadata_fields) self.assertIn(SplitTestDescriptor.user_partition_id.name, editable_metadata_fields)
def test_non_editable_settings(self): def test_non_editable_settings(self):
...@@ -239,6 +245,50 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -239,6 +245,50 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
self.assertEqual(0, partitions[1]['value']) self.assertEqual(0, partitions[1]['value'])
self.assertEqual("first_partition", partitions[1]['display_name']) self.assertEqual("first_partition", partitions[1]['display_name'])
def test_active_and_inactive_children(self):
"""
Tests the active and inactive children returned for different split test configurations.
"""
split_test_module = self.split_test_module
children = split_test_module.get_children()
# Verify that a split test has no active children if it has no specified user partition.
split_test_module.user_partition_id = -1
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
self.assertEqual(active_children, [])
self.assertEqual(inactive_children, children)
# Verify that all the children are returned as active for a correctly configured split_test
split_test_module.user_partition_id = 0
split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
]
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
self.assertEqual(active_children, children)
self.assertEqual(inactive_children, [])
# Verify that a split_test does not return inactive children in the active children
self.split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha')])
]
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
self.assertEqual(active_children, [children[0]])
self.assertEqual(inactive_children, [children[1]])
# Verify that a split_test ignores misconfigured children
self.split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("2", 'gamma')])
]
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
self.assertEqual(active_children, [children[0]])
self.assertEqual(inactive_children, [children[1]])
# Verify that a split_test referring to a non-existent user partition has no active children
self.split_test_module.user_partition_id = 2
[active_children, inactive_children] = split_test_module.active_and_inactive_children()
self.assertEqual(active_children, [])
self.assertEqual(inactive_children, children)
def test_validation_message_types(self): def test_validation_message_types(self):
""" """
Test the behavior of validation message types. Test the behavior of validation message types.
...@@ -249,45 +299,82 @@ class SplitTestModuleStudioTest(SplitTestModuleTest): ...@@ -249,45 +299,82 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
def test_validation_messages(self): def test_validation_messages(self):
""" """
Test the validation messages produced for different split_test configurations. Test the validation messages produced for different split test configurations.
""" """
split_test_module = self.split_test_module
def verify_validation_message(split_test_module, expected_message, expected_message_type): def verify_validation_message(message, expected_message, expected_message_type):
""" """
Verify that the module has the expected validation message and type. Verify that the validation message has the expected validation message and type.
""" """
(message, message_type) = split_test_module.validation_message() self.assertEqual(unicode(message), expected_message)
self.assertEqual(message, expected_message) self.assertEqual(message.message_type, expected_message_type)
self.assertEqual(message_type, expected_message_type)
# Verify the messages for an unconfigured user partition
# Verify the message for an unconfigured experiment split_test_module.user_partition_id = -1
self.split_test_module.user_partition_id = -1 messages = split_test_module.validation_messages()
verify_validation_message(self.split_test_module, self.assertEqual(len(messages), 1)
verify_validation_message(messages[0],
u"You must select a group configuration for this content experiment.", u"You must select a group configuration for this content experiment.",
ValidationMessageType.warning) ValidationMessageType.warning)
# Verify the message for a correctly configured experiment # Verify the messages for a correctly configured split_test
self.split_test_module.user_partition_id = 0 split_test_module.user_partition_id = 0
self.split_test_module.user_partitions = [ split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')]) UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
] ]
verify_validation_message(self.split_test_module, messages = split_test_module.validation_messages()
u"This content experiment uses group configuration 'first_partition'.", self.assertEqual(len(messages), 0)
ValidationMessageType.information)
# Verify the message for a block with the wrong number of groups # Verify the messages for a split test with too few groups
self.split_test_module.user_partitions = [ split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition', UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')]) [Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'gamma')])
] ]
verify_validation_message(self.split_test_module, messages = split_test_module.validation_messages()
u"This content experiment is in an invalid state and cannot be repaired. " self.assertEqual(len(messages), 1)
u"Please delete and recreate.", verify_validation_message(messages[0],
u"This content experiment is missing groups that are defined in "
u"the current configuration. "
u"You can press the 'Create Missing Groups' button to create them.",
ValidationMessageType.error) ValidationMessageType.error)
# Verify the message for a block referring to a non-existent experiment # Verify the messages for a split test with children that are not associated with any group
self.split_test_module.user_partition_id = 2 split_test_module.user_partitions = [
verify_validation_message(self.split_test_module, UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha')])
]
messages = split_test_module.validation_messages()
self.assertEqual(len(messages), 1)
verify_validation_message(messages[0],
u"This content experiment has children that are not associated with the "
u"selected group configuration. "
u"You can move content into an active group or delete it if it is unneeded.",
ValidationMessageType.warning)
# Verify the messages for a split test with both missing and inactive children
split_test_module.user_partitions = [
UserPartition(0, 'first_partition', 'First Partition',
[Group("0", 'alpha'), Group("2", 'gamma')])
]
messages = split_test_module.validation_messages()
self.assertEqual(len(messages), 2)
verify_validation_message(messages[0],
u"This content experiment is missing groups that are defined in "
u"the current configuration. "
u"You can press the 'Create Missing Groups' button to create them.",
ValidationMessageType.error)
verify_validation_message(messages[1],
u"This content experiment has children that are not associated with the "
u"selected group configuration. "
u"You can move content into an active group or delete it if it is unneeded.",
ValidationMessageType.warning)
# Verify the messages for a split test referring to a non-existent user partition
split_test_module.user_partition_id = 2
messages = split_test_module.validation_messages()
self.assertEqual(len(messages), 1)
verify_validation_message(messages[0],
u"This content experiment will not be shown to students because it refers " u"This content experiment will not be shown to students because it refers "
u"to a group configuration that has been deleted. " u"to a group configuration that has been deleted. "
u"You can delete this experiment or reinstate the group configuration to repair it.", u"You can delete this experiment or reinstate the group configuration to repair it.",
......
...@@ -12,7 +12,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest): ...@@ -12,7 +12,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest):
""" """
reorderable_items = set() reorderable_items = set()
context = { context = {
'runtime_type': 'studio',
'container_view': True, 'container_view': True,
'reorderable_items': reorderable_items, 'reorderable_items': reorderable_items,
'read_only': False, 'read_only': False,
...@@ -20,6 +19,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest): ...@@ -20,6 +19,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest):
} }
# Both children of the vertical should be rendered as reorderable # 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()[0].location, reorderable_items)
self.assertIn(self.vertical.get_children()[1].location, reorderable_items) self.assertIn(self.vertical.get_children()[1].location, reorderable_items)
...@@ -52,24 +52,22 @@ class VerticalModuleTestCase(BaseVerticalModuleTest): ...@@ -52,24 +52,22 @@ class VerticalModuleTestCase(BaseVerticalModuleTest):
def test_render_studio_view(self): 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 # Vertical shouldn't render children on the unit page
context = { context = {
'runtime_type': 'studio',
'container_view': False, '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_1, html)
self.assertNotIn(self.test_html_2, html) self.assertNotIn(self.test_html_2, html)
# Vertical should render reorderable children on the container page # Vertical should render reorderable children on the container page
reorderable_items = set() reorderable_items = set()
context = { context = {
'runtime_type': 'studio',
'container_view': True, 'container_view': True,
'reorderable_items': reorderable_items, '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_1, html)
self.assertIn(self.test_html_2, html) self.assertIn(self.test_html_2, html)
...@@ -2,7 +2,7 @@ from xblock.fragment import Fragment ...@@ -2,7 +2,7 @@ from xblock.fragment import Fragment
from xmodule.x_module import XModule from xmodule.x_module import XModule
from xmodule.seq_module import SequenceDescriptor from xmodule.seq_module import SequenceDescriptor
from xmodule.progress import Progress 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 pkg_resources import resource_string
from copy import copy from copy import copy
...@@ -19,27 +19,6 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule): ...@@ -19,27 +19,6 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
''' Layout module for laying out submodules vertically.''' ''' Layout module for laying out submodules vertically.'''
def student_view(self, context): 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() fragment = Fragment()
contents = [] contents = []
...@@ -55,12 +34,23 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule): ...@@ -55,12 +34,23 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
'content': rendered_child.content '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, 'items': contents,
'xblock_context': context, 'xblock_context': context,
})) }))
return fragment 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): def get_progress(self):
# TODO: Cache progress or children array? # TODO: Cache progress or children array?
children = self.get_children() children = self.get_children()
...@@ -77,7 +67,10 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule): ...@@ -77,7 +67,10 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
return new_class return new_class
class VerticalDescriptor(VerticalFields, SequenceDescriptor): class VerticalDescriptor(VerticalFields, SequenceDescriptor, StudioEditableDescriptor):
"""
Descriptor class for editing verticals.
"""
module_class = VerticalModule module_class = VerticalModule
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
......
...@@ -1138,7 +1138,7 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p ...@@ -1138,7 +1138,7 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
return result return result
def render(self, block, view_name, context=None): 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 assert block.xmodule_runtime is not None
if isinstance(block, (XModule, XModuleDescriptor)): if isinstance(block, (XModule, XModuleDescriptor)):
to_render = block._xmodule to_render = block._xmodule
......
...@@ -200,6 +200,7 @@ class CourseFixture(StudioApiFixture): ...@@ -200,6 +200,7 @@ class CourseFixture(StudioApiFixture):
self._handouts = [] self._handouts = []
self._children = [] self._children = []
self._assets = [] self._assets = []
self._advanced_settings = {}
def __str__(self): def __str__(self):
""" """
...@@ -236,6 +237,12 @@ class CourseFixture(StudioApiFixture): ...@@ -236,6 +237,12 @@ class CourseFixture(StudioApiFixture):
""" """
self._assets.extend(asset_name) 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): def install(self):
""" """
Create the course and XBlocks within the course. Create the course and XBlocks within the course.
...@@ -248,6 +255,7 @@ class CourseFixture(StudioApiFixture): ...@@ -248,6 +255,7 @@ class CourseFixture(StudioApiFixture):
self._install_course_handouts() self._install_course_handouts()
self._configure_course() self._configure_course()
self._upload_assets() self._upload_assets()
self._add_advanced_settings()
self._create_xblock_children(self._course_location, self._children) self._create_xblock_children(self._course_location, self._children)
return self return self
...@@ -415,6 +423,23 @@ class CourseFixture(StudioApiFixture): ...@@ -415,6 +423,23 @@ class CourseFixture(StudioApiFixture):
raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format( raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
asset_name=asset_name, url=url, code=upload_response.status_code)) 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): def _create_xblock_children(self, parent_loc, xblock_descriptions):
""" """
Recursively create XBlock children. Recursively create XBlock children.
...@@ -489,6 +514,6 @@ class CourseFixture(StudioApiFixture): ...@@ -489,6 +514,6 @@ class CourseFixture(StudioApiFixture):
Encode `post_dict` (a dictionary) as UTF-8 encoded JSON. Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
""" """
return json.dumps({ 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() for k, v in post_dict.items()
}) })
...@@ -2,6 +2,7 @@ from bok_choy.page_object import PageObject ...@@ -2,6 +2,7 @@ from bok_choy.page_object import PageObject
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from utils import click_css from utils import click_css
from selenium.webdriver.support.ui import Select
class ComponentEditorView(PageObject): class ComponentEditorView(PageObject):
...@@ -40,7 +41,7 @@ class ComponentEditorView(PageObject): ...@@ -40,7 +41,7 @@ class ComponentEditorView(PageObject):
""" """
return None 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. Returns the index of the setting entry with given label (display name) within the Settings modal.
""" """
...@@ -48,15 +49,14 @@ class ComponentEditorView(PageObject): ...@@ -48,15 +49,14 @@ class ComponentEditorView(PageObject):
setting_labels = self.q(css=self._bounded_selector('.metadata_edit .wrapper-comp-setting .setting-label')) setting_labels = self.q(css=self._bounded_selector('.metadata_edit .wrapper-comp-setting .setting-label'))
for index, setting in enumerate(setting_labels): for index, setting in enumerate(setting_labels):
if setting.text == label: if setting.text == label:
return index return self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting .setting-input'))[index]
return None return None
def set_field_value_and_save(self, label, value): 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.get_setting_element(label)
elem = self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting input.setting-input'))[index]
# Click in the field, delete the value there. # Click in the field, delete the value there.
action = ActionChains(self.browser).click(elem) action = ActionChains(self.browser).click(elem)
for _x in range(0, len(elem.get_attribute('value'))): for _x in range(0, len(elem.get_attribute('value'))):
...@@ -64,3 +64,12 @@ class ComponentEditorView(PageObject): ...@@ -64,3 +64,12 @@ class ComponentEditorView(PageObject):
# Send the new text, then Tab to move to the next field (so change event is triggered). # 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() action.send_keys(value).send_keys(Keys.TAB).perform()
click_css(self, 'a.action-save') 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): ...@@ -54,7 +54,24 @@ class ContainerPage(PageObject):
""" """
Return a list of xblocks loaded on the container page. 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 lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
def drag(self, source_index, target_index): def drag(self, source_index, target_index):
...@@ -77,15 +94,6 @@ class ContainerPage(PageObject): ...@@ -77,15 +94,6 @@ class ContainerPage(PageObject):
).release().perform() ).release().perform()
wait_for_notification(self) 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): def duplicate(self, source_index):
""" """
Duplicate the item with index source_index (based on vertical placement in page). Duplicate the item with index source_index (based on vertical placement in page).
...@@ -101,6 +109,11 @@ class ContainerPage(PageObject): ...@@ -101,6 +109,11 @@ class ContainerPage(PageObject):
click_css(self, 'a.button.action-primary', 0) click_css(self, 'a.button.action-primary', 0)
def edit(self): 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() self.q(css='.edit-button').first.click()
EmptyPromise( EmptyPromise(
lambda: self.q(css='.xblock-studio_view').present, lambda: self.q(css='.xblock-studio_view').present,
...@@ -109,6 +122,18 @@ class ContainerPage(PageObject): ...@@ -109,6 +122,18 @@ class ContainerPage(PageObject):
return self 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): class XBlockWrapper(PageObject):
""" """
...@@ -161,4 +186,4 @@ class XBlockWrapper(PageObject): ...@@ -161,4 +186,4 @@ class XBlockWrapper(PageObject):
@property @property
def preview_selector(self): 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): ...@@ -27,7 +27,7 @@ class UnitPage(PageObject):
def _is_finished_loading(): def _is_finished_loading():
# Wait until all components have been loaded # 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 is_done = len(self.q(css=Component.BODY_SELECTOR).results) == number_of_leaf_xblocks
return (is_done, is_done) return (is_done, is_done)
...@@ -99,9 +99,14 @@ class Component(PageObject): ...@@ -99,9 +99,14 @@ class Component(PageObject):
@property @property
def preview_selector(self): def preview_selector(self):
return self._bounded_selector('.xblock-student_view') return self._bounded_selector('.xblock-author_view,.xblock-student_view')
def edit(self): 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() self.q(css=self._bounded_selector('.edit-button')).first.click()
EmptyPromise( EmptyPromise(
lambda: self.q(css='.xblock-studio_view').present, lambda: self.q(css='.xblock-studio_view').present,
......
...@@ -9,10 +9,12 @@ def click_css(page, css, source_index=0, require_notification=True): ...@@ -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). 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. 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. 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] target = buttons[source_index]
ActionChains(page.browser).click(target).release().perform() ActionChains(page.browser).click(target).release().perform()
if require_notification: if require_notification:
...@@ -31,5 +33,36 @@ def wait_for_notification(page): ...@@ -31,5 +33,36 @@ def wait_for_notification(page):
num_notifications = len(page.q(css='.wrapper-notification-mini.is-hiding')) num_notifications = len(page.q(css='.wrapper-notification-mini.is-hiding'))
return (num_notifications == 1, num_notifications) return (num_notifications == 1, num_notifications)
Promise(_is_saving, 'Notification showing.').fulfill() Promise(_is_saving, 'Notification should have been shown.').fulfill()
Promise(_is_saving_done, 'Notification hidden.').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 ...@@ -8,6 +8,7 @@ from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest from .helpers import UniqueCourseTest
from ..pages.studio.component_editor import ComponentEditorView from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.utils import add_discussion
from unittest import skip from unittest import skip
...@@ -32,6 +33,87 @@ class ContainerBase(UniqueCourseTest): ...@@ -32,6 +33,87 @@ class ContainerBase(UniqueCourseTest):
self.course_info['run'] 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.container_title = ""
self.group_a = "Expand or Collapse\nGroup A" self.group_a = "Expand or Collapse\nGroup A"
self.group_b = "Expand or Collapse\nGroup B" self.group_b = "Expand or Collapse\nGroup B"
...@@ -55,18 +137,6 @@ class ContainerBase(UniqueCourseTest): ...@@ -55,18 +137,6 @@ class ContainerBase(UniqueCourseTest):
self.duplicate_label = "Duplicate of '{0}'" self.duplicate_label = "Duplicate of '{0}'"
self.discussion_label = "Discussion" 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( course_fix = CourseFixture(
self.course_info['org'], self.course_info['org'],
self.course_info['number'], self.course_info['number'],
...@@ -96,46 +166,8 @@ class ContainerBase(UniqueCourseTest): ...@@ -96,46 +166,8 @@ class ContainerBase(UniqueCourseTest):
self.user = course_fix.user 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): class DragAndDropTest(NestedVerticalTest):
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):
""" """
Tests of reordering within the container page. Tests of reordering within the container page.
""" """
...@@ -196,7 +228,7 @@ class DragAndDropTest(ContainerBase): ...@@ -196,7 +228,7 @@ class DragAndDropTest(ContainerBase):
def add_new_components_and_rearrange(container): def add_new_components_and_rearrange(container):
# Add a video component to Group 1 # 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 # Duplicate the first item in Group A
container.duplicate(self.group_a_item_1_action_index) container.duplicate(self.group_a_item_1_action_index)
...@@ -216,7 +248,7 @@ class DragAndDropTest(ContainerBase): ...@@ -216,7 +248,7 @@ class DragAndDropTest(ContainerBase):
self.do_action_and_verify(add_new_components_and_rearrange, expected_ordering) 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. Tests of adding a component to the container page.
""" """
...@@ -224,7 +256,7 @@ class AddComponentTest(ContainerBase): ...@@ -224,7 +256,7 @@ class AddComponentTest(ContainerBase):
def add_and_verify(self, menu_index, expected_ordering): def add_and_verify(self, menu_index, expected_ordering):
self.do_action_and_verify( self.do_action_and_verify(
lambda (container): container.add_discussion(menu_index), lambda (container): add_discussion(container, menu_index),
expected_ordering expected_ordering
) )
...@@ -256,7 +288,7 @@ class AddComponentTest(ContainerBase): ...@@ -256,7 +288,7 @@ class AddComponentTest(ContainerBase):
self.add_and_verify(container_menu, expected_ordering) self.add_and_verify(container_menu, expected_ordering)
class DuplicateComponentTest(ContainerBase): class DuplicateComponentTest(NestedVerticalTest):
""" """
Tests of duplicating a component on the container page. Tests of duplicating a component on the container page.
""" """
...@@ -302,7 +334,7 @@ class DuplicateComponentTest(ContainerBase): ...@@ -302,7 +334,7 @@ class DuplicateComponentTest(ContainerBase):
self.do_action_and_verify(duplicate_twice, expected_ordering) self.do_action_and_verify(duplicate_twice, expected_ordering)
class DeleteComponentTest(ContainerBase): class DeleteComponentTest(NestedVerticalTest):
""" """
Tests of deleting a component from the container page. Tests of deleting a component from the container page.
""" """
...@@ -319,10 +351,13 @@ class DeleteComponentTest(ContainerBase): ...@@ -319,10 +351,13 @@ class DeleteComponentTest(ContainerBase):
{self.group_a: [self.group_a_item_2]}, {self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]}, {self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}] {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. 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 ...@@ -77,7 +77,11 @@ Studio
Class Features 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 * 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. 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