Commit 581ed636 by Braden MacDonald

Merge pull request #5 from open-craft/studio-editable-xblock

Shared Mixins to enable effortless Studio-editable XBlocks (OC-513)
parents c1446572 ca86d73b
*.log
*.pyc
.coverage
cover
xblock_utils.egg-info
tests.integration.*.png
xblock-utils: Various utilities for XBlocks
-------------------------------------------
===========================================
These are a collection of useful utility functions,
test base classes and documentation shared by many XBlocks.
......@@ -10,4 +10,188 @@ test base classes and documentation shared by many XBlocks.
To test the utilities, run::
./run_tests.py
python run_tests.py
To get a coverage report, use:
python run_tests.py --with-coverage --cover-package=xblockutils --cover-html
StudioEditableXBlockMixin
-------------------------
.. code:: python
from xblockutils.studio_editable import StudioEditableXBlockMixin
This mixin will automatically generate a working ``studio_view`` form
that allows content authors to edit the fields of your XBlock. To use,
simply add the class to your base class list, and add a new class field
called ``editable_fields``, set to a tuple of the names of the fields
you want your user to be able to edit.
.. code:: python
@XBlock.needs("i18n")
class ExampleBlock(StudioEditableXBlockMixin, XBlock):
...
mode = String(
display_name="Mode",
help="Determines the behaviour of this component. Standard is recommended.",
default='standard',
scope=Scope.content,
values=('standard', 'crazy')
)
editable_fields = ('mode', 'display_name')
That's all you need to do. The mixin will read the optional
``display_name``, ``help``, ``default``, and ``values`` settings from
the fields you mention and build the editor form as well as an AJAX save
handler.
If you want to validate the data, you can override
``validate_field_data(self, validation, data)`` and/or
``clean_studio_edits(self, data)`` - see the source code for details.
Supported field types:
* Boolean:
``field_name = Boolean(display_name="Field Name")``
* Float:
``field_name = Float(display_name="Field Name")``
* Integer:
``field_name = Integer(display_name="Field Name")``
* String:
``field_name = String(display_name="Field Name")``
* String (multiline):
``field_name = String(multiline_editor=True, resettable_editor=False)``
* String (html):
``field_name = String(multiline_editor='html', resettable_editor=False)``
Any of the above will use a dropdown menu if they have a pre-defined
list of possible values.
* List of unordered unique values (i.e. sets) drawn from a small set of
possible values:
``field_name = List(list_style='set', list_values_provider=some_method)``
- The ``List`` declaration must include the property ``list_style='set'`` to
indicate that the ``List`` field is being used with set semantics.
- The ``List`` declaration must also define a ``list_values_provider`` method
which will be called with the block as its only parameter and which must
return a list of possible values.
* Rudimentary support for Dict, ordered List, and any other JSONField-derived field types
- ``list_field = List(display_name="Ordered List", default=[])``
- ``dict_field = Dict(display_name="Normal Dict", default={})``
Supported field options (all field types):
* ``values`` can define a list of possible options, changing the UI element
to a select box. Values can be set to any of the formats `defined in the
XBlock source code <https://github.com/edx/XBlock/blob/master/xblock/fields.py>`__:
- A finite set of elements: ``[1, 2, 3]``
- A finite set of elements where the display names differ from the values
::
[
{"display_name": "Always", "value": "always"},
{"display_name": "Past Due", "value": "past_due"},
]
- A range for floating point numbers with specific increments:
``{"min": 0 , "max": 10, "step": .1}``
- A callable that returns one of the above. (Note: the callable does
*not* get passed the XBlock instance or runtime, so it cannot be a
normal member function)
* ``values_provider`` can define a callable that accepts the XBlock
instance as an argument, and returns a list of possible values in one
of the formats listed above.
* ``resettable_editor`` - defaults to ``True``. Set ``False`` to hide the
"Reset" button used to return a field to its default value by removing
the field's value from the XBlock instance.
Basic screenshot: |Screenshot 1|
StudioContainerXBlockMixin
--------------------------
.. code:: python
from xblockutils.studio_editable import StudioContainerXBlockMixin
This mixin helps to create XBlocks that allow content authors to add,
remove, or reorder child blocks. By removing any existing
``author_view`` and adding this mixin, you'll get editable,
re-orderable, and deletable child support in Studio. To enable authors to
add arbitrary blocks as children, simply override ``author_edit_view``
and set ``can_add=True`` when calling ``render_children`` - see the
source code. To restrict authors so they can add only specific types of
child blocks or a limited number of children requires custom HTML.
An example is the mentoring XBlock: |Screenshot 2|
SeleniumXBlockTest
------------------
.. code:: python
from xblockutils.base_test import SeleniumXBlockTest
This is a base class that you can use for writing Selenium integration
tests that are hosted in the XBlock SDK (Workbench).
Here is an example:
.. code:: python
class TestStudentView(SeleniumXBlockTest):
"""
Test the Student View of MyCoolXBlock
"""
def setUp(self):
super(TestStudentView, self).setUp()
self.set_scenario_xml('<mycoolblock display_name="Test Demo Block" field2="hello" />')
self.element = self.go_to_view("student_view")
def test_shows_field_2(self):
"""
The xblock should display the text value of field2.
"""
self.assertIn("hello", self.element.text)
StudioEditableBaseTest
----------------------
.. code:: python
from xblockutils.studio_editable_test import StudioEditableBaseTest
This is a subclass of ``SeleniumXBlockTest`` that adds a few helper
methods useful for testing the ``studio_view`` of any XBlock using
``StudioEditableXBlockMixin``.
child\_isinstance
-----------------
.. code:: python
from xblockutils.helpers import child_isinstance
If your XBlock needs to find children/descendants of a particular
class/mixin, you should use
.. code:: python
child_isinstance(self, child_usage_id, SomeXBlockClassOrMixin)
rather than calling
.. code:: python
``isinstance(self.runtime.get_block(child_usage_id), SomeXBlockClassOrMixin)``.
On runtimes such as those in edx-platform, ``child_isinstance`` is
orders of magnitude faster.
.. |Screenshot 1| image:: https://cloud.githubusercontent.com/assets/945577/6341782/7d237966-bb83-11e4-9344-faa647056999.png
.. |Screenshot 2| image:: https://cloud.githubusercontent.com/assets/945577/6341803/d0195ec4-bb83-11e4-82f6-8052c9f70690.png
......@@ -15,10 +15,12 @@ disable=
protected-access,
star-args,
too-few-public-methods,
too-many-public-methods,
too-many-ancestors,
too-many-arguments,
too-many-branches,
too-many-locals,
too-many-public-methods,
too-many-statements,
unused-argument
[SIMILARITIES]
......
......@@ -22,6 +22,17 @@ import os
import os.path
from setuptools import setup
def package_data(pkg, root_list):
"""Generic function to find package_data for `pkg` under `root`."""
data = []
for root in root_list:
for dirname, _, files in os.walk(os.path.join(pkg, root)):
for fname in files:
data.append(os.path.relpath(os.path.join(dirname, fname), pkg))
return {pkg: data}
setup(
name='xblock-utils',
version='0.1a0',
......@@ -31,5 +42,6 @@ setup(
],
install_requires=[
'XBlock',
]
],
package_data=package_data("xblockutils", ["public", "templates"]),
)
"""
Tests for helpers.py
"""
import unittest
from workbench.runtime import WorkbenchRuntime
from xblock.core import XBlock
from xblockutils.helpers import child_isinstance
class DogXBlock(XBlock):
""" Test XBlock representing any dog. Raises error if instantiated. """
pass
class GoldenRetrieverXBlock(DogXBlock):
""" Test XBlock representing a golden retriever """
pass
class CatXBlock(XBlock):
""" Test XBlock representing any cat """
pass
class BasicXBlock(XBlock):
""" Basic XBlock """
has_children = True
class TestChildIsInstance(unittest.TestCase):
"""
Test child_isinstance helper method, in the workbench runtime.
"""
@XBlock.register_temp_plugin(GoldenRetrieverXBlock, "gr")
@XBlock.register_temp_plugin(CatXBlock, "cat")
@XBlock.register_temp_plugin(BasicXBlock, "block")
def setUp(self):
super(TestChildIsInstance, self).setUp()
self.runtime = WorkbenchRuntime()
self.root_id = self.runtime.parse_xml_string('<block> <block><cat/><gr/></block> <cat/> <gr/> </block>')
def test_child_isinstance(self):
"""
Check that child_isinstance() works on direct children
"""
root = self.runtime.get_block(self.root_id)
self.assertFalse(child_isinstance(root, root.children[0], DogXBlock))
self.assertFalse(child_isinstance(root, root.children[0], GoldenRetrieverXBlock))
self.assertTrue(child_isinstance(root, root.children[0], BasicXBlock))
self.assertFalse(child_isinstance(root, root.children[1], DogXBlock))
self.assertFalse(child_isinstance(root, root.children[1], GoldenRetrieverXBlock))
self.assertTrue(child_isinstance(root, root.children[1], CatXBlock))
self.assertFalse(child_isinstance(root, root.children[2], CatXBlock))
self.assertTrue(child_isinstance(root, root.children[2], DogXBlock))
self.assertTrue(child_isinstance(root, root.children[2], GoldenRetrieverXBlock))
def test_child_isinstance_descendants(self):
"""
Check that child_isinstance() works on deeper descendants
"""
root = self.runtime.get_block(self.root_id)
block = root.runtime.get_block(root.children[0])
self.assertIsInstance(block, BasicXBlock)
self.assertFalse(child_isinstance(root, block.children[0], DogXBlock))
self.assertTrue(child_isinstance(root, block.children[0], CatXBlock))
self.assertTrue(child_isinstance(root, block.children[1], DogXBlock))
self.assertTrue(child_isinstance(root, block.children[1], GoldenRetrieverXBlock))
self.assertFalse(child_isinstance(root, block.children[1], CatXBlock))
......@@ -24,54 +24,30 @@ Base classes for Selenium or bok-choy based integration tests of XBlocks.
import time
from selenium.webdriver.support.ui import WebDriverWait
from workbench import scenarios
from workbench.runtime import WorkbenchRuntime
from workbench.scenarios import SCENARIOS, add_xml_scenario, remove_scenario
from workbench.test.selenium_test import SeleniumTest
from .resources import ResourceLoader
class SeleniumBaseTest(SeleniumTest):
class SeleniumXBlockTest(SeleniumTest):
"""
Base class for Selenium integration tests of XBlocks, hosted in the workbench
Base class for using the workbench to test XBlocks with Selenium or bok-choy.
If you want to test an XBlock that's not already installed into the python environment,
you can use @XBlock.register_temp_plugin around your test method[s].
"""
module_name = None # You must set this to __name__ in any subclass so ResourceLoader can find scenario XML files
default_css_selector = None # Selector used by go_to_page to return the XBlock DOM element
relative_scenario_path = 'xml'
timeout = 10 # seconds
@property
def _module_name(self):
""" Internal method to access module_name with a friendly warning if it's unset """
if self.module_name is None:
raise NotImplementedError("Overwrite cls.module_name in your derived class.")
return self.module_name
@property
def _default_css_selector(self):
""" Internal method to access default_css_selector with a warning if it's unset """
if self.default_css_selector is None:
raise NotImplementedError("Overwrite cls.default_css_selector in your derived class.")
return self.default_css_selector
def setUp(self):
super(SeleniumBaseTest, self).setUp()
# Use test scenarios
self.browser.get(self.live_server_url) # Needed to load tests once
scenarios.SCENARIOS.clear()
loader = ResourceLoader(self._module_name)
scenarios_list = loader.load_scenarios_from_path(self.relative_scenario_path, include_identifier=True)
for identifier, title, xml in scenarios_list:
scenarios.add_xml_scenario(identifier, title, xml)
self.addCleanup(scenarios.remove_scenario, identifier)
# Suzy opens the browser to visit the workbench
self.browser.get(self.live_server_url)
# She knows it's the site by the header
header1 = self.browser.find_element_by_css_selector('h1')
self.assertEqual(header1.text, 'XBlock scenarios')
super(SeleniumXBlockTest, self).setUp()
# Delete all scenarios from the workbench:
import workbench.urls # Trigger initial scenario load. pylint: disable=unused-variable
SCENARIOS.clear()
# Disable CSRF checks on XBlock handlers:
import workbench.views
workbench.views.handler.csrf_exempt = True
def wait_until_hidden(self, elem):
""" Wait until the DOM element elem is hidden """
......@@ -107,6 +83,75 @@ class SeleniumBaseTest(SeleniumTest):
u"Selector '{}' should exist.".format(selector)
)
@staticmethod
def set_scenario_xml(xml):
""" Reset the workbench to have only one scenario with the specified XML """
SCENARIOS.clear()
add_xml_scenario("test", "Test Scenario", xml)
def go_to_view(self, view_name='student_view', student_id=None):
"""
Navigate to the page `page_name`, as listed on the workbench home
Returns the DOM element on the visited page located by the `css_selector`
"""
url = self.live_server_url + '/scenario/test/{}/'.format(view_name)
if student_id:
url += '?student={}'.format(student_id)
self.browser.get(url)
return self.browser.find_element_by_css_selector('.workbench .preview > div.xblock-v1:first-child')
def load_root_xblock(self):
"""
Load (in Python) the XBlock at the root of the current scenario.
"""
dom_node = self.browser.find_element_by_css_selector('.workbench .preview > div.xblock-v1:first-child')
usage_id = dom_node.get_attribute('data-usage')
student_id = "none"
runtime = WorkbenchRuntime(student_id)
return runtime.get_block(usage_id)
class SeleniumBaseTest(SeleniumXBlockTest):
"""
Selenium Base Test for loading a whole folder of XML scenarios and then running tests.
This is kept for compatibility, but it is recommended that SeleniumXBlockTest be used
instead, since it is faster and more flexible (specifically, scenarios are only loaded
as needed, and can be defined inline with the tests).
"""
module_name = None # You must set this to __name__ in any subclass so ResourceLoader can find scenario XML files
default_css_selector = None # Selector used by go_to_page to return the XBlock DOM element
relative_scenario_path = 'xml' # Path from the module (module_name) to the secnario XML files
@property
def _module_name(self):
""" Internal method to access module_name with a friendly warning if it's unset """
if self.module_name is None:
raise NotImplementedError("Overwrite cls.module_name in your derived class.")
return self.module_name
@property
def _default_css_selector(self):
""" Internal method to access default_css_selector with a warning if it's unset """
if self.default_css_selector is None:
raise NotImplementedError("Overwrite cls.default_css_selector in your derived class.")
return self.default_css_selector
def setUp(self):
super(SeleniumBaseTest, self).setUp()
# Use test scenarios:
loader = ResourceLoader(self._module_name)
scenarios_list = loader.load_scenarios_from_path(self.relative_scenario_path, include_identifier=True)
for identifier, title, xml in scenarios_list:
add_xml_scenario(identifier, title, xml)
self.addCleanup(remove_scenario, identifier)
# Suzy opens the browser to visit the workbench
self.browser.get(self.live_server_url)
# She knows it's the site by the header
header1 = self.browser.find_element_by_css_selector('h1')
self.assertEqual(header1.text, 'XBlock scenarios')
def go_to_page(self, page_name, css_selector=None, view_name=None):
"""
Navigate to the page `page_name`, as listed on the workbench home
......
"""
Useful helper methods
"""
def child_isinstance(block, child_id, block_class_or_mixin):
"""
Efficiently check if a child of an XBlock is an instance of the given class.
Arguments:
block -- the parent (or ancestor) of the child block in question
child_id -- the usage key of the child block we are wondering about
block_class_or_mixin -- We return true if block's child indentified by child_id is an
instance of this.
This method is equivalent to
isinstance(block.runtime.get_block(child_id), block_class_or_mixin)
but is far more efficient, as it avoids the need to instantiate the child.
"""
def_id = block.runtime.id_reader.get_definition_id(child_id)
type_name = block.runtime.id_reader.get_block_type(def_id)
child_class = block.runtime.load_block_type(type_name)
return issubclass(child_class, block_class_or_mixin)
/* Javascript for StudioEditableXBlockMixin. */
function StudioEditableXBlockMixin(runtime, element) {
"use strict";
var fields = [];
var tinyMceAvailable = (typeof $.fn.tinymce !== 'undefined'); // Studio includes a copy of tinyMCE and its jQuery plugin
$(element).find('.field-data-control').each(function() {
var $field = $(this);
var $wrapper = $field.closest('li');
var $resetButton = $wrapper.find('button.setting-clear');
var type = $wrapper.data('cast');
fields.push({
name: $wrapper.data('field-name'),
isSet: function() { return $wrapper.hasClass('is-set'); },
val: function() {
var val = $field.val();
// Cast values to the appropriate type so that we send nice clean JSON over the wire:
if (type == 'boolean')
return (val == 'true' || val == '1');
if (type == "integer")
return parseInt(val, 10);
if (type == "float")
return parseFloat(val);
if (type == "generic" || type == "list" || type == "set") {
val = val.trim();
if (val === "")
val = null;
else
val = JSON.parse(val); // TODO: handle parse errors
}
return val;
}
});
var fieldChanged = function() {
// Field value has been modified:
$wrapper.addClass('is-set');
$resetButton.removeClass('inactive').addClass('active');
};
$field.bind("change input paste", fieldChanged);
$resetButton.click(function() {
$field.val($wrapper.attr('data-default')); // Use attr instead of data to force treating the default value as a string
$wrapper.removeClass('is-set');
$resetButton.removeClass('active').addClass('inactive');
});
if (type == 'html' && tinyMceAvailable) {
if ($field.tinymce())
$field.tinymce().remove(); // Stale instance from a previous dialog. Delete it to avoid interference.
$field.tinymce({
theme: 'modern',
skin: 'studio-tmce4',
height: '200px',
formats: { code: { inline: 'code' } },
codemirror: { path: "" + baseUrl + "/js/vendor" },
plugins: "link codemirror",
menubar: false,
statusbar: false,
toolbar_items_size: 'small',
toolbar: "formatselect | styleselect | bold italic underline forecolor wrapAsCode | bullist numlist outdent indent blockquote | link unlink | code",
resize: "both",
setup : function(ed) {
ed.on('change', fieldChanged);
}
});
}
});
$(element).find('.wrapper-list-settings .list-set').each(function() {
var $optionList = $(this);
var $checkboxes = $(this).find('input');
var $wrapper = $optionList.closest('li');
var $resetButton = $wrapper.find('button.setting-clear');
fields.push({
name: $wrapper.data('field-name'),
isSet: function() { return $wrapper.hasClass('is-set'); },
val: function() {
var val = [];
$checkboxes.each(function() {
if ($(this).is(':checked')) {
val.push(JSON.parse($(this).val()));
}
});
return val;
}
});
var fieldChanged = function() {
// Field value has been modified:
$wrapper.addClass('is-set');
$resetButton.removeClass('inactive').addClass('active');
};
$checkboxes.bind("change input", fieldChanged);
$resetButton.click(function() {
var defaults = JSON.parse($wrapper.attr('data-default'));
$checkboxes.each(function() {
var val = JSON.parse($(this).val());
$(this).prop('checked', defaults.indexOf(val) > -1);
});
$wrapper.removeClass('is-set');
$resetButton.removeClass('active').addClass('inactive');
});
});
var studio_submit = function(data) {
var handlerUrl = runtime.handlerUrl(element, 'submit_studio_edits');
runtime.notify('save', {state: 'start', message: gettext("Saving")});
$.ajax({
type: "POST",
url: handlerUrl,
data: JSON.stringify(data),
dataType: "json",
global: false, // Disable Studio's error handling that conflicts with studio's notify('save') and notify('cancel') :-/
success: function(response) { runtime.notify('save', {state: 'end'}); }
}).fail(function(jqXHR) {
var message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.");
if (jqXHR.responseText) { // Is there a more specific error message we can show?
try {
message = JSON.parse(jqXHR.responseText).error;
if (typeof message === "object" && message.messages) {
// e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
message = $.map(message.messages, function(msg) { return msg.text; }).join(", ");
}
} catch (error) { message = jqXHR.responseText.substr(0, 300); }
}
runtime.notify('error', {title: gettext("Unable to update settings"), message: message});
});
};
$('.save-button', element).bind('click', function(e) {
e.preventDefault();
var values = {};
var notSet = []; // List of field names that should be set to default values
for (var i in fields) {
var field = fields[i];
if (field.isSet()) {
values[field.name] = field.val();
} else {
notSet.push(field.name);
}
}
studio_submit({values: values, defaults: notSet});
});
$(element).find('.cancel-button').bind('click', function(e) {
e.preventDefault();
runtime.notify('cancel', {});
});
}
"""
Tests for StudioEditableXBlockMixin
"""
from selenium.webdriver.support.ui import WebDriverWait
from xblockutils.base_test import SeleniumXBlockTest
class StudioEditableBaseTest(SeleniumXBlockTest):
"""
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 fix_js_environment(self):
""" Make the Workbench JS runtime more compatibile with Studio's """
# Mock gettext()
self.browser.execute_script('window.gettext = function(t) { return t; };')
# Mock runtime.notify() so we can watch for notify events like 'save'
self.browser.execute_script(
'window.notifications = [];'
'window.RuntimeProvider.getRuntime(1).notify = function() {'
' window.notifications.push(arguments);'
'};'
)
def dequeue_runtime_notification(self, wait_first=True):
"""
Return the oldest call from JavaScript to block.runtime.notify() that we haven't yet
seen here in Python-land. Waits for a notification unless wait_first is False.
"""
if wait_first:
self.wait_for_runtime_notification()
return self.browser.execute_script('return window.notifications.shift();')
def wait_for_runtime_notification(self):
""" Wait until runtime.notify() has been called """
wait = WebDriverWait(self.browser, self.timeout)
wait.until(lambda driver: driver.execute_script('return window.notifications.length > 0;'))
def get_element_for_field(self, field_name):
""" 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)
return self.browser.find_element_by_css_selector(selector)
def click_reset_for_field(self, field_name):
""" Click the reset button next to the specified setting field """
selector = "li.field[data-field-name={}] .setting-clear".format(field_name)
self.browser.find_element_by_css_selector(selector).click()
{% load i18n %}
<div class="editor-with-buttons">
<div class="wrapper-comp-settings is-active editor-with-buttons" id="settings-tab">
<ul class="list-input settings-list">
{% for field in fields %}
<li
class="field comp-setting-entry metadata_entry {% if field.is_set %}is-set{% endif %}"
data-field-name="{{field.name}}"
data-default="{% if field.type == 'boolean' %}{{ field.default|yesno:'1,0' }}{% else %}{{ field.default|default_if_none:"" }}{% endif %}"
data-cast="{{field.type}}"
>
<div class="wrapper-comp-setting{% if field.type == "set" %} metadata-list-enum {%endif%}">
<label class="label setting-label" for="xb-field-edit-{{field.name}}">{{field.display_name}}</label>
{% if field.type == "boolean" %}
<select
class="field-data-control"
id="xb-field-edit-{{field.name}}"
>
<option value="1" {% if field.value %}selected{% endif %}>
True {% if field.default %}&nbsp;&nbsp;&nbsp;&nbsp;(Default){% endif %}
</option>
<option value="0" {% if not field.value %}selected{% endif %}>
False {% if not field.default %}&nbsp;&nbsp;&nbsp;&nbsp;(Default){% endif %}
</option>
</select>
{% elif field.has_values %}
<select
class="field-data-control"
id="xb-field-edit-{{field.name}}"
>
{% for option in field.values %}
<option value="{{option.value}}" {% if field.value == option.value %}selected{% endif %}>
{{option.display_name}} {% if option.value == field.default %}&nbsp;&nbsp;&nbsp;&nbsp;(Default){% endif %}
</option>
{% endfor %}
</select>
{% elif field.type == "string" %}
<input
type="text"
class="field-data-control"
id="xb-field-edit-{{field.name}}"
value="{{field.value|default_if_none:""}}"
>
{% elif field.type == "integer" or field.type == "float" %}
<input
type="number"
class="field-data-control"
id="xb-field-edit-{{field.name}}"
{% if field.step %} step="{{field.step}}" {% elif field.type == "integer" %} step=1 {% endif %}
{% if field.max %} max="{{field.max}}" {% endif %}
{% if field.min %} min="{{field.min}}" {% endif %}
value="{{field.value|default_if_none:""}}"
>
{% elif field.type == "text" or field.type == "html" %}
<textarea class="field-data-control" data-field-name="{{field.name}}" id="xb-field-edit-{{field.name}}" rows=10 cols=70>{{field.value}}</textarea>
{% elif field.type == 'set' and field.has_list_values %}
{% comment %}
TODO: If len(list_values) is high, show an alternate editor
with a select box and a growing list of selected choices
{% endcomment %}
<div class="wrapper-list-settings">
<ul class="list-settings list-set">
{% for choice in field.list_values %}
<li class="list-settings-item">
<label>
<input
type="checkbox"
value="{{choice.value}}"
style="width:auto;height:auto;"
{% if choice.value in field.value %}checked="checked"{% endif %}
>
{{choice.display_name}}
</label>
</li>
{% empty %}
<li>{% trans "None Available" %}</li>
{% endfor %}
</ul>
</div>
{% elif field.type == 'generic' or field.type == 'list' or field.type == 'set' %}
{# Show a textarea so we can edit it as a JSON string #}
<textarea class="field-data-control" data-field-name="{{field.name}}" id="xb-field-edit-{{field.name}}" rows=5 cols=70>{{field.value}}</textarea>
{% else %}
Unsupported field type. This setting cannot be edited.
{% endif %}
{% if field.allow_reset %}
<button class="action setting-clear {% if field.is_set %}active{%else%}inactive{% endif %}" type="button" name="setting-clear" value="{% trans "Clear" %}" data-tooltip="{% trans "Clear" %}">
<i class="icon fa fa-undo"></i><span class="sr">{% trans "Clear Value" %}</span>
</button>
{% endif %}
</div>
{% if field.help %}
<span class="tip setting-help"> {{ field.help }} </span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div class="xblock-actions">
<ul>
<li class="action-item">
<a href="#" class="button action-primary save-button">{% trans "Save" %}</a>
</li>
<li class="action-item">
<a href="#" class="button cancel-button">{% trans "Cancel" %}</a>
</li>
</ul>
</div>
</div>
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