Commit eacc239c by Eugeny Kolpakov

Merge pull request #23 from edx/studio_container_with_nested

Helper class providing studio views for XBlocks that can have nested XBlocks
parents 25f15734 817791ed
...@@ -4,3 +4,4 @@ Jonathan Piacenti <jonathan@opencraft.com> ...@@ -4,3 +4,4 @@ Jonathan Piacenti <jonathan@opencraft.com>
Matjaz Gregoric <matjaz@opencraft.com> Matjaz Gregoric <matjaz@opencraft.com>
Ned Batchelder <ned@edx.org> Ned Batchelder <ned@edx.org>
Xavier Antoviaque <xavier@opencraft.com> Xavier Antoviaque <xavier@opencraft.com>
Eugeny Kolpakov <eugey@opencraft.com>
{% if can_reorder %}
<ol class="reorderable-container">
{% endif %}
{% for item in items %}
<!-- studio-xblock-wrapper is injected in actual studio somewhere else, but we need it for testing -->
<div class="studio-xblock-wrapper" data-locator="{{item.id}}">
{{ item.content|safe }}
</div>
{% endfor %}
{% if can_reorder %}
</ol>
{% endif %}
{% if can_add %}
<div class="add-xblock-component new-component-item adding"></div>
{% endif %}
import datetime import datetime
import textwrap
import mock
import pytz import pytz
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Boolean, Dict, Float, Integer, List, String, DateTime from xblock.fields import Boolean, Dict, Float, Integer, List, String, DateTime
from xblock.fragment import Fragment
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from xblockutils.studio_editable import StudioEditableXBlockMixin from tests.integration.utils import render_template
from xblockutils.studio_editable_test import StudioEditableBaseTest from xblockutils.studio_editable import StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, \
NestedXBlockSpec
from xblockutils.studio_editable_test import StudioEditableBaseTest, StudioContainerWithNestedXBlocksBaseTest
class EditableXBlock(StudioEditableXBlockMixin, XBlock): class EditableXBlock(StudioEditableXBlockMixin, XBlock):
""" """
A basic Studio-editable XBlock (for use in tests) A basic Studio-editable XBlock (for use in tests)
""" """
CATEGORY = "editable"
STUDIO_LABEL = "Editable Block"
color = String(default="red") color = String(default="red")
count = Integer(default=42) count = Integer(default=42)
comment = String(default="") comment = String(default="")
date = DateTime(default=datetime.datetime(2014, 5, 14, tzinfo=pytz.UTC)) date = DateTime(default=datetime.datetime(2014, 5, 14, tzinfo=pytz.UTC))
editable_fields = ('color', 'count', 'comment', 'date') editable_fields = ('color', 'count', 'comment', 'date')
def student_view(self, context):
return Fragment()
def validate_field_data(self, validation, data): def validate_field_data(self, validation, data):
""" """
A validation method to check that 'count' is positive and prevent A validation method to check that 'count' is positive and prevent
...@@ -261,6 +272,9 @@ class FancyXBlock(StudioEditableXBlockMixin, XBlock): ...@@ -261,6 +272,9 @@ class FancyXBlock(StudioEditableXBlockMixin, XBlock):
'string_multiline', 'string_multiline_reset', 'string_html', 'string_multiline', 'string_multiline_reset', 'string_html',
) )
def student_view(self, context):
return Fragment()
class TestFancyXBlock_StudioView(StudioEditableBaseTest): class TestFancyXBlock_StudioView(StudioEditableBaseTest):
""" """
...@@ -329,3 +343,109 @@ class TestFancyXBlock_StudioView(StudioEditableBaseTest): ...@@ -329,3 +343,109 @@ class TestFancyXBlock_StudioView(StudioEditableBaseTest):
"{} should be unchanged".format(field_name) "{} should be unchanged".format(field_name)
) )
self.assertTrue(block.fields[field_name].is_set_on(block)) self.assertTrue(block.fields[field_name].is_set_on(block))
class FancyBlockShim(object):
CATEGORY = "fancy"
STUDIO_LABEL = "Fancy Block"
class XBlockWithNested(StudioContainerWithNestedXBlocksMixin, XBlock):
@property
def allowed_nested_blocks(self):
return [
EditableXBlock,
NestedXBlockSpec(FancyBlockShim, single_instance=True)
]
class XBlockWithDisabledNested(StudioContainerWithNestedXBlocksMixin, XBlock):
@property
def allowed_nested_blocks(self):
return [
NestedXBlockSpec(EditableXBlock, disabled=True, disabled_reason="Some reason"),
NestedXBlockSpec(FancyBlockShim, disabled=False, disabled_reason="Irrelevant")
]
class StudioContainerWithNestedXBlocksTest(StudioContainerWithNestedXBlocksBaseTest):
def setUp(self):
super(StudioContainerWithNestedXBlocksTest, self).setUp()
patcher = mock.patch(
'workbench.runtime.WorkbenchRuntime.render_template', mock.Mock(side_effect=render_template)
)
patcher.start()
self.addCleanup(patcher.stop)
def _check_button(self, button, category, label, single, disabled, disabled_reason=''):
self.assertEqual(button.get_attribute('data-category'), category)
self.assertEqual(button.text, label)
self.assertEqual(button.get_attribute('data-single-instance'), str(single).lower())
self._assert_disabled(button, disabled)
self.assertEqual(button.get_attribute('title'), disabled_reason)
def _assert_disabled(self, button, disabled):
if disabled:
self.assertEqual(button.get_attribute('disabled'), 'true')
else:
self.assertEqual(button.get_attribute('disabled'), None)
def set_up_root_block(self, scenario, view):
self.set_scenario_xml(scenario)
self.go_to_view(view)
self.fix_js_environment()
return self.load_root_xblock()
@XBlock.register_temp_plugin(XBlockWithNested, "nested")
def test_author_edit_view_nested(self):
self.set_up_root_block("<nested />", "author_edit_view")
add_buttons = self.get_add_buttons()
self.assertEqual(len(add_buttons), 2)
button_editable, button_fancy = add_buttons
self._check_button(button_editable, EditableXBlock.CATEGORY, EditableXBlock.STUDIO_LABEL, False, False)
self._check_button(button_fancy, FancyBlockShim.CATEGORY, FancyBlockShim.STUDIO_LABEL, True, False)
@XBlock.register_temp_plugin(XBlockWithDisabledNested, "nested")
def test_author_edit_view_nested_with_disabled(self):
self.set_up_root_block("<nested />", "author_edit_view")
add_buttons = self.get_add_buttons()
self.assertEqual(len(add_buttons), 2)
button_editable, button_fancy = add_buttons
self._check_button(
button_editable, EditableXBlock.CATEGORY, EditableXBlock.STUDIO_LABEL, False, True, "Some reason"
)
self._check_button(button_fancy, FancyBlockShim.CATEGORY, FancyBlockShim.STUDIO_LABEL, False, False)
@XBlock.register_temp_plugin(XBlockWithNested, "nested")
def test_can_add_blocks(self):
self.set_up_root_block("<nested />", "author_edit_view")
button_editable, button_fancy = self.get_add_buttons()
self._assert_disabled(button_editable, False)
button_editable.click()
self._assert_disabled(button_editable, False)
button_editable.click()
self._assert_disabled(button_editable, False)
self._assert_disabled(button_fancy, False)
button_fancy.click()
self._assert_disabled(button_fancy, True)
@XBlock.register_temp_plugin(XBlockWithNested, "nested")
@XBlock.register_temp_plugin(FancyXBlock, "fancy")
@XBlock.register_temp_plugin(EditableXBlock, "editable")
def test_initial_state_with_blocks(self):
scenario = textwrap.dedent("""
<nested>
<editable />
<fancy />
</nested>
""")
self.set_up_root_block(scenario, "author_edit_view")
button_editable, button_fancy = self.get_add_buttons()
self._assert_disabled(button_editable, False)
self._assert_disabled(button_fancy, True)
from django.template import Context, Template
from xblockutils.resources import ResourceLoader
loader = ResourceLoader(__name__)
def render_template(template_path, context, **kwargs):
file_path = "tests/integration/template_stubs/"+template_path
with open(file_path, 'r') as tpl_file:
template_str = tpl_file.read().replace('\n', '')
template = Template(template_str)
return template.render(Context(context))
function StudioContainerXBlockWithNestedXBlocksMixin(runtime, element) {
var $buttons = $(".add-xblock-component-button", element),
$element = $(element);
function isSingleInstance($button) {
return $button.data('single-instance');
}
$buttons.click(function(ev) {
var $this = $(this);
if ($this.is('.disabled')) {
ev.preventDefault();
ev.stopPropagation();
} else {
if (isSingleInstance($this)) {
$this.addClass('disabled');
$this.attr('disabled', 'disabled');
}
}
});
function updateButtons() {
var nestedBlockLocations = $.map($element.find(".studio-xblock-wrapper"), function(block_wrapper) {
return $(block_wrapper).data('locator');
});
$buttons.each(function() {
var $this = $(this);
if (!isSingleInstance($this)) {
return;
}
var category = $this.data('category');
var childExists = false;
// FIXME: This is potentially buggy - if some XBlock's category is a substring of some other XBlock category
// it will exhibit wrong behavior. However, it's not possible to do anything about that unless studio runtime
// announces which block was deleted, not it's parent.
for (var i = 0; i < nestedBlockLocations.length; i++) {
if (nestedBlockLocations[i].indexOf(category) > -1) {
childExists = true;
break;
}
}
if (childExists) {
$this.attr('disabled', 'disabled');
$this.addClass('disabled')
}
else {
$this.removeAttr('disabled');
$this.removeClass('disabled');
}
});
}
updateButtons();
runtime.listenTo('deleted-child', updateButtons);
}
...@@ -16,7 +16,7 @@ import logging ...@@ -16,7 +16,7 @@ import logging
from django.utils.translation import ugettext from django.utils.translation import ugettext
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope, JSONField, List, Integer, Float, Boolean, String, DateTime from xblock.fields import Scope, JSONField, List, Integer, Float, Boolean, String, DateTime
from xblock.exceptions import JsonHandlerError from xblock.exceptions import JsonHandlerError, NoSuchViewError
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblock.validation import Validation from xblock.validation import Validation
...@@ -276,15 +276,20 @@ class StudioContainerXBlockMixin(object): ...@@ -276,15 +276,20 @@ class StudioContainerXBlockMixin(object):
""" """
contents = [] contents = []
child_context = {'reorderable_items': set()}
if context:
child_context.update(context)
for child_id in self.children: for child_id in self.children:
child = self.runtime.get_block(child_id) child = self.runtime.get_block(child_id)
if can_reorder: if can_reorder:
context['reorderable_items'].add(child.scope_ids.usage_id) child_context['reorderable_items'].add(child.scope_ids.usage_id)
rendered_child = child.render('author_view' if hasattr(child, 'author_view') else 'student_view', context) view_to_render = 'author_view' if hasattr(child, 'author_view') else 'student_view'
rendered_child = child.render(view_to_render, child_context)
fragment.add_frag_resources(rendered_child) fragment.add_frag_resources(rendered_child)
contents.append({ contents.append({
'id': child.location.to_deprecated_string(), 'id': unicode(child.scope_ids.usage_id),
'content': rendered_child.content 'content': rendered_child.content
}) })
...@@ -323,3 +328,151 @@ class StudioContainerXBlockMixin(object): ...@@ -323,3 +328,151 @@ class StudioContainerXBlockMixin(object):
not editing this block's children. not editing this block's children.
""" """
return self.student_view(context) return self.student_view(context)
class NestedXBlockSpec(object):
"""
Class that allows detailed specification of allowed nested XBlocks. For use with
StudioContainerWithNestedXBlocksMixin.allowed_nested_blocks
"""
def __init__(self, block, single_instance=False, disabled=False, disabled_reason=None):
self._block = block
self._single_instance = single_instance
self._disabled = disabled
self._disabled_reason = disabled_reason
@property
def category(self):
""" Block category - used as a computer-readable name of an XBlock """
return self._block.CATEGORY
@property
def label(self):
""" Block label - used as human-readable name of an XBlock """
return self._block.STUDIO_LABEL
@property
def single_instance(self):
""" If True, only allow single nested instance of Xblock """
return self._single_instance
@property
def disabled(self):
"""
If True, renders add buttons disabled - only use when XBlock can't be added at all (i.e. not available).
To allow single instance of XBlock use single_instance property
"""
return self._disabled
@property
def disabled_reason(self):
"""
If block is disabled this property is used as add button title, giving some hint about why it is disabled
"""
return self._disabled_reason
class XBlockWithPreviewMixin(object):
"""
An XBlock mixin providing simple preview view. It is to be used with StudioContainerWithNestedXBlocksMixin to
avoid adding studio wrappers (title, edit button, etc.) to a block when it is rendered as child in parent's
author_preview_view
"""
def preview_view(self, context):
"""
Preview view - used by StudioContainerWithNestedXBlocksMixin to render nested xblocks in preview context.
Default implementation uses author_view if available, otherwise falls back to student_view
Child classes can override this method to control their presentation in preview context
"""
view_to_render = 'author_view' if hasattr(self, 'author_view') else 'student_view'
renderer = getattr(self, view_to_render)
return renderer(context)
class StudioContainerWithNestedXBlocksMixin(StudioContainerXBlockMixin):
"""
An XBlock mixin providing interface for specifying allowed nested blocks and adding/previewing them in Studio.
"""
has_children = True
CHILD_PREVIEW_TEMPLATE = "templates/default_preview_view.html"
@property
def loader(self): # pylint: disable=no-self-use
"""
Loader for loading and rendering assets stored in child XBlock package
"""
return loader
@property
def allowed_nested_blocks(self): # pylint: disable=no-self-use
"""
Returns a list of allowed nested XBlocks. Each item can be either
* An XBlock class
* A NestedXBlockSpec
If XBlock class is used it is assumed that this XBlock is enabled and allows multiple instances.
NestedXBlockSpec allows explicitly setting disabled/enabled state, disabled reason (if any) and single/multiple
instances
"""
return []
def get_nested_blocks_spec(self):
"""
Converts allowed_nested_blocks items to NestedXBlockSpec to provide common interface
"""
return [
block_spec if isinstance(block_spec, NestedXBlockSpec) else NestedXBlockSpec(block_spec)
for block_spec in self.allowed_nested_blocks
]
def author_edit_view(self, context):
"""
View for adding/editing nested blocks
"""
fragment = Fragment()
self.render_children(context, fragment, can_reorder=True, can_add=False)
fragment.add_content(
loader.render_template('templates/add_buttons.html', {'child_blocks': self.get_nested_blocks_spec()})
)
fragment.add_javascript(loader.load_unicode('public/studio_container.js'))
fragment.initialize_js('StudioContainerXBlockWithNestedXBlocksMixin')
return fragment
def author_preview_view(self, context):
"""
View for previewing contents in studio.
"""
children_contents = []
fragment = Fragment()
for child_id in self.children:
child = self.runtime.get_block(child_id)
view_to_render = 'preview_view' if hasattr(child, 'preview_view') else 'student_view'
child_fragment = self._render_child_fragment(child, context, view_to_render)
fragment.add_frag_resources(child_fragment)
children_contents.append(child_fragment.content)
render_context = {
'block': self,
'children_contents': children_contents
}
render_context.update(context)
fragment.add_content(self.loader.render_template(self.CHILD_PREVIEW_TEMPLATE, render_context))
return fragment
def _render_child_fragment(self, child, context, view='student_view'):
"""
Helper method to overcome html block rendering quirks
"""
try:
child_fragment = child.render(view, context)
except NoSuchViewError:
if child.scope_ids.block_type == 'html' and getattr(self.runtime, 'is_author_mode', False):
# html block doesn't support preview_view, and if we use student_view Studio will wrap
# it in HTML that we don't want in the preview. So just render its HTML directly:
child_fragment = Fragment(child.data)
else:
child_fragment = child.render('student_view', context)
return child_fragment
...@@ -5,24 +5,10 @@ from selenium.webdriver.support.ui import WebDriverWait ...@@ -5,24 +5,10 @@ from selenium.webdriver.support.ui import WebDriverWait
from xblockutils.base_test import SeleniumXBlockTest from xblockutils.base_test import SeleniumXBlockTest
class StudioEditableBaseTest(SeleniumXBlockTest): class CommonBaseTest(SeleniumXBlockTest):
""" """
Base class that can be used for integration tests of any XBlocks that use Base class of StudioEditableBaseTest and StudioContainerWithNestedXBlocksBaseTest
StudioEditableXBlockMixin
""" """
def click_save(self, expect_success=True):
""" Click on the save button """
# Click 'Save':
self.browser.find_element_by_link_text('Save').click()
# Before saving the block changes, the runtime should get a 'save' notice:
notification = self.dequeue_runtime_notification()
self.assertEqual(notification[0], "save")
self.assertEqual(notification[1]["state"], "start")
if expect_success:
notification = self.dequeue_runtime_notification()
self.assertEqual(notification[0], "save")
self.assertEqual(notification[1]["state"], "end")
def fix_js_environment(self): def fix_js_environment(self):
""" Make the Workbench JS runtime more compatibile with Studio's """ """ Make the Workbench JS runtime more compatibile with Studio's """
# Mock gettext() # Mock gettext()
...@@ -49,6 +35,25 @@ class StudioEditableBaseTest(SeleniumXBlockTest): ...@@ -49,6 +35,25 @@ class StudioEditableBaseTest(SeleniumXBlockTest):
wait = WebDriverWait(self.browser, self.timeout) wait = WebDriverWait(self.browser, self.timeout)
wait.until(lambda driver: driver.execute_script('return window.notifications.length > 0;')) wait.until(lambda driver: driver.execute_script('return window.notifications.length > 0;'))
class StudioEditableBaseTest(CommonBaseTest):
"""
Base class that can be used for integration tests of any XBlocks that use
StudioEditableXBlockMixin
"""
def click_save(self, expect_success=True):
""" Click on the save button """
# Click 'Save':
self.browser.find_element_by_link_text('Save').click()
# Before saving the block changes, the runtime should get a 'save' notice:
notification = self.dequeue_runtime_notification()
self.assertEqual(notification[0], "save")
self.assertEqual(notification[1]["state"], "start")
if expect_success:
notification = self.dequeue_runtime_notification()
self.assertEqual(notification[0], "save")
self.assertEqual(notification[1]["state"], "end")
def get_element_for_field(self, field_name): def get_element_for_field(self, field_name):
""" Given the name of a field, return the DOM element for its form control """ """ Given the name of a field, return the DOM element for its form control """
selector = "li.field[data-field-name={}] .field-data-control".format(field_name) selector = "li.field[data-field-name={}] .field-data-control".format(field_name)
...@@ -58,3 +63,16 @@ class StudioEditableBaseTest(SeleniumXBlockTest): ...@@ -58,3 +63,16 @@ class StudioEditableBaseTest(SeleniumXBlockTest):
""" Click the reset button next to the specified setting field """ """ Click the reset button next to the specified setting field """
selector = "li.field[data-field-name={}] .setting-clear".format(field_name) selector = "li.field[data-field-name={}] .setting-clear".format(field_name)
self.browser.find_element_by_css_selector(selector).click() self.browser.find_element_by_css_selector(selector).click()
class StudioContainerWithNestedXBlocksBaseTest(CommonBaseTest):
"""
Base class that can be used for integration tests of any XBlocks that use
StudioContainerWithNestedXBlocksMixin
"""
def get_add_buttons(self):
"""
Gets add buttons in author view
"""
selector = ".add-xblock-component .new-component a.add-xblock-component-button"
return self.browser.find_elements_by_css_selector(selector)
{% load i18n %}
<div class="add-xblock-component new-component-item adding">
<div class="new-component">
<h5>{% trans "Add New Component" %}</h5>
<ul class="new-component-type">
{% for block_spec in child_blocks %}
<li>
<a href="#" class="single-template add-xblock-component-button"
data-category="{{ block_spec.category }}" data-single-instance="{{ block_spec.single_instance|lower }}"
{% if block_spec.disabled %}
disabled="disabled" title="{{ block_spec.disabled_reason }}"
{% endif %}
>
{{ block_spec.label }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="author-preview-view">
{% for child_content in children_contents %} {{ child_content|safe }} {% endfor %}
</div>
\ No newline at end of file
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