Commit b5ece688 by Braden MacDonald

Basic mixin to make XBlocks editable

parent c1446572
/* Javascript for StudioEditableXBlockMixin. */
function StudioEditableXBlockMixin(runtime, element) {
"use strict";
var fields = [];
$(element).find('.field-data-control').each(function() {
var $field = $(this);
var $wrapper = $field.closest('li');
var $resetButton = $wrapper.find('button.setting-clear');
var cast = $wrapper.data('cast');
fields.push({
$field: $field,
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 (cast == 'boolean')
return (val == 'true' || val == '1');
if (cast == "integer")
return parseInt(val, 10);
if (cast == "float")
return parseFloat(val);
return val;
}
});
$field.bind("change input paste", function() {
// Field value has been modified:
$wrapper.addClass('is-set');
$resetButton.removeClass('inactive').addClass('active');
});
$resetButton.click(function() {
$field.val($wrapper.data('default'));
$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;
} 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', {});
});
}
# -*- coding: utf-8 -*-
#
# Copyright (C) 2015 OpenCraft
# License: AGPLv3
"""
This module contains a mixin that allows third party XBlocks to be easily edited within edX
Studio just like the built-in modules. No configuration required, just add
StudioEditableXBlockMixin to your XBlock.
"""
# Imports ###########################################################
import logging
from xblock.core import XBlock
from xblock.fields import Scope, JSONField, Integer, Float, Boolean, String
from xblock.exceptions import JsonHandlerError
from xblock.fragment import Fragment
from xblock.validation import Validation
from xblockutils.resources import ResourceLoader
# Globals ###########################################################
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
class FutureFields(object):
"""
A helper class whose attribute values come from the specified dictionary or fallback object.
"""
def __init__(self, new_fields_dict, newly_removed_fields, fallback_obj):
self._new_fields_dict = new_fields_dict
self._blacklist = newly_removed_fields
self._fallback_obj = fallback_obj
def __getattr__(self, name):
try:
return self._new_fields_dict[name]
except KeyError:
if name in self._blacklist:
# Pretend like this field is not actually set, since we're going to be resetting it to default
return self._fallback_obj.fields[name].default
return getattr(self._fallback_obj, name)
class StudioEditableXBlockMixin(object):
"""
An XBlock mixin to provide a configuration UI for an XBlock in Studio.
"""
editable_fields = () # Set this to a list of the names of fields to appear in the editor
def studio_view(self, context):
"""
Render a form for editing this XBlock
"""
fragment = Fragment()
context = {'fields': []}
# Build a list of all the fields that can be edited:
for field_name in self.editable_fields:
field = self.fields[field_name]
assert field.scope in (Scope.content, Scope.settings), (
"Only Scope.content or Scope.settings fields can be used with "
"StudioEditableXBlockMixin. Other scopes are for user-specific data and are "
"not generally created/configured by content authors in Studio."
)
field_info = self._make_field_info(field_name, field)
if field_info is not None:
context["fields"].append(field_info)
fragment.content = loader.render_template('templates/studio_edit.html', context)
fragment.add_javascript(loader.load_unicode('public/studio_edit.js'))
fragment.initialize_js('StudioEditableXBlockMixin')
return fragment
def _make_field_info(self, field_name, field):
"""
Create the information that the template needs to render a form field for this field.
"""
supported_field_types = (
(Integer, 'integer'),
(Float, 'float'),
(Boolean, 'boolean'),
(String, 'string'),
(JSONField, 'generic'), # This is last so as a last resort we display a text field w/ the JSON string
)
info = {
'name': field_name,
'display_name': field.display_name,
'is_set': field.is_set_on(self),
'default': field.default,
'value': field.read_from(self),
'has_values': False,
'help': field.help,
}
for type_class, type_name in supported_field_types:
if isinstance(field, type_class):
info['type'] = type_name
# If String fields are declared like String(..., multiline_editor=True), then call them "text" type:
if type_class is String and field.runtime_options.get('multiline_editor'):
info['type'] = 'text'
break
if "type" not in info:
raise NotImplementedError("StudioEditableXBlockMixin currently only supports fields derived from JSONField")
if field.values and not isinstance(field, Boolean):
info['has_values'] = True
# This field has only a limited number of pre-defined options.
# Protip: when defining the field, values= can be a callable.
if isinstance(field.values, dict) and isinstance(field, (Float, Integer)):
# e.g. {"min": 0 , "max": 10, "step": .1}
info["min"] = field.values["min"]
info["max"] = field.values["max"]
info["step"] = field.values["step"]
else:
# e.g. [1, 2, 3] or [ {"display_name": "Always", "value": "always"}, {...}, ... ]
values = field.values
if "display_name" not in values[0]:
values = [{"display_name": val, "value": val} for val in values]
info['values'] = values
return info
@XBlock.json_handler
def submit_studio_edits(self, data, suffix=''):
"""
AJAX handler for studio_view() Save button
"""
values = {} # dict of new field values we are updating
to_reset = [] # list of field names to delete from this XBlock
for field_name in self.editable_fields:
field = self.fields[field_name]
if field_name in data['values']:
if isinstance(field, JSONField):
values[field_name] = field.from_json(data['values'][field_name])
else:
raise JsonHandlerError(400, "Unsupported field type: {}".format(field_name))
elif field_name in data['defaults'] and field.is_set_on(self):
to_reset.append(field_name)
self.clean_studio_edits(values)
validation = Validation(self.scope_ids.usage_id)
# We cannot set the fields on self yet, because even if validation fails, studio is going to save any changes we
# make. So we create a "fake" object that has all the field values we are about to set.
preview_data = FutureFields(
new_fields_dict=values,
newly_removed_fields=to_reset,
fallback_obj=self
)
self.validate_field_data(validation, preview_data)
if validation:
for field_name, value in values.iteritems():
setattr(self, field_name, value)
for field_name in to_reset:
self.fields[field_name].delete_from(self)
return {'result': 'success'}
else:
raise JsonHandlerError(400, validation.to_json())
def clean_studio_edits(self, data):
"""
Given POST data dictionary 'data', clean the data before validating it.
e.g. fix capitalization, remove trailing spaces, etc.
"""
# Example:
# if "name" in data:
# data["name"] = data["name"].strip()
pass
def validate_field_data(self, validation, data):
"""
Validate this block's field data. Instead of checking fields like self.name, check the
fields set on data, e.g. data.name. This allows the same validation method to be re-used
for the studio editor. Any errors found should be added to "validation".
This method should not return any value or raise any exceptions.
All of this XBlock's fields should be found in "data", even if they aren't being changed
or aren't even set (i.e. are defaults).
"""
# Example:
# if data.count <=0:
# validation.add(ValidationMessage(ValidationMessage.ERROR, u"Invalid count"))
pass
def validate(self):
"""
Validates the state of this XBlock.
Subclasses should override validate_field_data() to validate fields and override this
only for validation not related to this block's field values.
"""
validation = super(StudioEditableXBlockMixin, self).validate()
self.validate_field_data(validation, self)
return validation
{% 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">
<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" %}
<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 == 'generic' %}
{# 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 %}
<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>
</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