diff --git a/problem_builder/plot.py b/problem_builder/plot.py
index d332c3a1..dac55170 100644
--- a/problem_builder/plot.py
+++ b/problem_builder/plot.py
@@ -26,6 +26,8 @@
from xblock.core import XBlock
from xblock.fields import String, Scope
from xblock.fragment import Fragment
+from xblock.validation import ValidationMessage
+from xblockutils.helpers import child_isinstance
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import (
StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin
@@ -244,12 +246,89 @@ def get_data(self, data, suffix):
'average_claims': self.average_claims,
}
+ @property
+ def allowed_nested_blocks(self):
+ """
+ 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 [PlotOverlayBlock]
+
+ @lazy
+ def overlay_ids(self):
+ """
+ Get the usage_ids of all of this XBlock's children that are overlays.
+ """
+ return [
+ _normalize_id(child_id) for child_id in self.children if
+ child_isinstance(self, child_id, PlotOverlayBlock)
+ ]
+
+ @lazy
+ def overlays(self):
+ """
+ Get the overlay children of this block.
+ """
+ return [self.runtime.get_block(overlay_id) for overlay_id in self.overlay_ids]
+
+ @lazy
+ def overlay_data(self):
+ if not self.claims:
+ return []
+
+ overlay_data = []
+ claims = self.claims.split('\n')
+ for index, overlay in enumerate(self.overlays):
+ claims_json = []
+ if overlay.claim_data:
+ claim_data = overlay.claim_data.split('\n')
+ for claim, data in zip(claims, claim_data):
+ claim = claim.split(', ')[0]
+ r1, r2 = data.split(', ')
+ claims_json.append([claim, int(r1), int(r2)])
+ claims_json = json.dumps(claims_json)
+ overlay_data.append({
+ 'plot_label': overlay.plot_label,
+ 'point_color': overlay.point_color,
+ 'description': overlay.description,
+ 'citation': overlay.citation,
+ 'claims_json': claims_json,
+ 'position': index,
+ })
+ return overlay_data
+
+ @lazy
+ def claims_display(self):
+ if not self.claims:
+ return []
+
+ claims = []
+ for claim in self.claims.split('\n'):
+ claim, q1, q2 = claim.split(', ')
+ claims.append([claim, q1, q2])
+ return claims
+
def author_preview_view(self, context):
- return Fragment(
- u"
{}
".format(
- _(u"This block displays a plot that summarizes answers to scale questions.")
- )
- )
+ context['self'] = self
+ fragment = Fragment()
+ fragment.add_content(loader.render_template('templates/html/plot_preview.html', context))
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/plot-preview.css'))
+ if self.overlay_ids:
+ fragment.add_content(
+ u"
{}
".format(
+ _(u"In addition to the default and average overlays the plot includes the following overlays:")
+ ))
+ for overlay in self.overlays:
+ overlay_fragment = self._render_child_fragment(overlay, context, view='mentoring_view')
+ fragment.add_frag_resources(overlay_fragment)
+ fragment.add_content(overlay_fragment.content)
+ return fragment
def mentoring_view(self, context):
return self.student_view(context)
@@ -266,3 +345,115 @@ def student_view(self, context=None):
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/plot.js'))
fragment.initialize_js('PlotBlock')
return fragment
+
+ def author_edit_view(self, context):
+ """
+ Add some HTML to the author view that allows authors to add child blocks.
+ """
+ context['wrap_children'] = {
+ 'head': u'
',
+ 'tail': u'
'
+ }
+ fragment = super(PlotBlock, self).author_edit_view(context)
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder-edit.css'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/util.js'))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/plot_edit.js'))
+ fragment.initialize_js('PlotEdit')
+ return fragment
+
+
+@XBlock.needs('i18n')
+class PlotOverlayBlock(StudioEditableXBlockMixin, XBlockWithPreviewMixin, XBlock):
+ """
+ XBlock that represents a user-defined overlay for a plot block.
+ """
+
+ CATEGORY = 'sb-plot-overlay'
+ STUDIO_LABEL = _(u"Plot Overlay")
+
+ # Settings
+ display_name = String(
+ display_name=_("Overlay title"),
+ default="Overlay",
+ scope=Scope.content
+ )
+
+ plot_label = String(
+ display_name=_("Plot label"),
+ help=_("Label for button that allows to toggle visibility of this overlay"),
+ default="",
+ scope=Scope.content
+ )
+
+ point_color = String(
+ display_name=_("Point color"),
+ help=_("Point color to use for this overlay"),
+ default="",
+ scope=Scope.content
+ )
+
+ description = String(
+ display_name=_("Description"),
+ help=_("Description of this overlay (optional)"),
+ default="",
+ scope=Scope.content
+ )
+
+ citation = String(
+ display_name=_("Citation"),
+ help=_("Source of data belonging to this overlay (optional)"),
+ default="",
+ scope=Scope.content
+ )
+
+ claim_data = String(
+ display_name=_("Claim data"),
+ help=_(
+ 'Claim data to include in this overlay. '
+ 'Each line defines a tuple of the form "q1, q2", '
+ 'where "q1" is the value associated with the first scale or rating question, '
+ 'and "q2" is the value associated with the second scale or rating question. '
+ 'Note that data will be associated with claims in the order that they are defined in the parent plot.'
+ ),
+ default="",
+ multiline_editor=True,
+ resettable_editor=False
+ )
+
+ editable_fields = (
+ "plot_label", "point_color", "description", "citation", "claim_data"
+ )
+
+ def validate_field_data(self, validation, data):
+ """
+ Validate this block's field data.
+ """
+ super(PlotOverlayBlock, self).validate_field_data(validation, data)
+
+ def add_error(msg):
+ validation.add(ValidationMessage(ValidationMessage.ERROR, msg))
+
+ if not data.plot_label.strip():
+ add_error(_(u"No plot label set. Button for toggling visibility of this overlay will not have a label."))
+ if not data.point_color.strip():
+ add_error(_(u"No point color set. This overlay will not work correctly."))
+
+ # If parent plot is associated with one or more claims, prompt user to add claim data
+ parent = self.get_parent()
+ if parent.claims.strip() and not data.claim_data.strip():
+ add_error(_(u"No claim data provided. This overlay will not work correctly."))
+
+ def author_preview_view(self, context):
+ return self.student_view(context)
+
+ def mentoring_view(self, context):
+ context = context.copy() if context else {}
+ context['hide_header'] = True
+ return self.author_preview_view(context)
+
+ def student_view(self, context):
+ context['self'] = self
+ fragment = Fragment()
+ fragment.add_content(loader.render_template('templates/html/overlay.html', context))
+ fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/overlay.css'))
+ return fragment
diff --git a/problem_builder/public/css/overlay.css b/problem_builder/public/css/overlay.css
new file mode 100644
index 00000000..5e584848
--- /dev/null
+++ b/problem_builder/public/css/overlay.css
@@ -0,0 +1,7 @@
+.sb-plot-overlay {
+ margin-bottom: 10px;
+}
+
+.italic {
+ font-style: italic;
+}
diff --git a/problem_builder/public/css/plot-preview.css b/problem_builder/public/css/plot-preview.css
new file mode 100644
index 00000000..e636f276
--- /dev/null
+++ b/problem_builder/public/css/plot-preview.css
@@ -0,0 +1,21 @@
+.sb-plot table {
+ width: 100%;
+ margin-top: 1em;
+ margin-bottom: 1em;
+ border: 2px solid #999;
+}
+
+.sb-plot thead {
+ border-bottom: 2px solid #999;
+ background-color: #ddd;
+ font-weight: bold;
+}
+
+.sb-plot tr:nth-child(even) {
+ background-color: #eee;
+}
+
+.sb-plot td {
+ border-left: 1px solid #999;
+ padding: 5px;
+}
diff --git a/problem_builder/public/css/plot.css b/problem_builder/public/css/plot.css
index 569d0c78..674d1d5b 100644
--- a/problem_builder/public/css/plot.css
+++ b/problem_builder/public/css/plot.css
@@ -1,11 +1,29 @@
+.sb-plot {
+ overflow: auto;
+}
+
.quadrants label {
font-weight: bold;
}
.overlays {
float: right;
+ width: 40%;
+}
+
+.overlays input {
+ margin-top: 10px;
+ margin-right: 5px;
}
.quadrants input, .overlays input {
background-color: rgb(204, 204, 204);
}
+
+.plot-info {
+ margin-top: 15px;
+}
+
+.plot-info-header {
+ font-weight: bold;
+}
diff --git a/problem_builder/public/css/problem-builder-edit.css b/problem_builder/public/css/problem-builder-edit.css
index c1a0b3a3..e78f6103 100644
--- a/problem_builder/public/css/problem-builder-edit.css
+++ b/problem_builder/public/css/problem-builder-edit.css
@@ -15,6 +15,7 @@
}
/* Custom appearance for our "Add" buttons */
+.xblock[data-block-type=sb-plot] .add-xblock-component .new-component .new-component-type .add-xblock-component-button,
.xblock[data-block-type=sb-review-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button,
.xblock[data-block-type=sb-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button,
.xblock[data-block-type=step-builder] .add-xblock-component .new-component .new-component-type .add-xblock-component-button,
@@ -25,6 +26,8 @@
line-height: 30px;
}
+.xblock[data-block-type=sb-plot] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled,
+.xblock[data-block-type=sb-plot] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover,
.xblock[data-block-type=sb-review-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled,
.xblock[data-block-type=sb-review-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled:hover,
.xblock[data-block-type=sb-step] .add-xblock-component .new-component .new-component-type .add-xblock-component-button.disabled,
diff --git a/problem_builder/public/js/plot.js b/problem_builder/public/js/plot.js
index 0ee66e18..4e042815 100644
--- a/problem_builder/public/js/plot.js
+++ b/problem_builder/public/js/plot.js
@@ -1,5 +1,23 @@
function PlotBlock(runtime, element) {
+ // jQuery helpers
+
+ jQuery.fn.isEmpty = function() {
+ return !$.trim($(this).html());
+ };
+
+ jQuery.fn.isHidden = function() {
+ // Don't use jQuery :hidden selector here;
+ // this is necessary to ensure that result is independent of parent visibility
+ return $(this).css('display') === 'none';
+ };
+
+ jQuery.fn.isVisible = function() {
+ // Don't use jQuery :visible selector here;
+ // this is necessary to ensure that result is independent of parent visibility
+ return $(this).css('display') !== 'none';
+ };
+
// Plot
// Define margins
@@ -58,7 +76,8 @@ function PlotBlock(runtime, element) {
var defaultButton = $('.plot-default', element),
averageButton = $('.plot-average', element),
- quadrantsButton = $('.plot-quadrants', element);
+ quadrantsButton = $('.plot-quadrants', element),
+ overlayButtons = $('input.plot-overlay', element);
// Claims
@@ -80,7 +99,7 @@ function PlotBlock(runtime, element) {
// Event handlers
function toggleOverlay(claims, color, klass, refresh) {
- var selector = "." + klass,
+ var selector = buildSelector(klass),
selection = svgContainer.selectAll(selector);
if (selection.empty()) {
showOverlay(selection, claims, color, klass);
@@ -92,6 +111,14 @@ function PlotBlock(runtime, element) {
}
}
+ function buildSelector(klass) {
+ var classes = klass.split(' ');
+ if (classes.length === 1) {
+ return "." + klass;
+ }
+ return '.' + classes.join('.');
+ }
+
function showOverlay(selection, claims, color, klass) {
selection
.data(claims)
@@ -127,6 +154,29 @@ function PlotBlock(runtime, element) {
}
}
+ function toggleOverlayInfo(klass, hide) {
+ var plotInfo = $('.plot-info', element),
+ selector = buildSelector(klass),
+ overlayInfo = plotInfo.children(selector);
+ if (hide || overlayInfo.isVisible()) {
+ overlayInfo.hide();
+ var overlayInfos = plotInfo.children('.plot-overlay'),
+ hidePlotInfo = true;
+ overlayInfos.each(function() {
+ var overlayInfo = $(this);
+ hidePlotInfo = hidePlotInfo && (overlayInfo.isHidden() || overlayInfo.isEmpty());
+ });
+ if (hidePlotInfo) {
+ plotInfo.hide();
+ }
+ } else {
+ overlayInfo.show();
+ if (!overlayInfo.isEmpty() && !plotInfo.isVisible()) {
+ plotInfo.show();
+ }
+ }
+ }
+
function toggleQuadrantLabels() {
var selection = svgContainer.selectAll(".quadrant-label"),
quadrantLabelsOn = quadrantsButton.val() === 'On';
@@ -168,7 +218,7 @@ function PlotBlock(runtime, element) {
toggleBorderColor(this, defaultColor, refresh);
});
- averageButton.on('click', function(event) {
+ averageButton.on('click', function() {
toggleOverlay(averageClaims, averageColor, 'claim-average');
toggleBorderColor(this, averageColor);
});
@@ -177,9 +227,30 @@ function PlotBlock(runtime, element) {
toggleQuadrantLabels();
});
+ overlayButtons.each(function(index) {
+
+ var overlayButton = $(this),
+ claims = overlayButton.data('claims'),
+ color = overlayButton.data('point-color'),
+ klass = overlayButton.attr('class');
+
+ overlayButton.on('click', function() {
+ toggleOverlay(claims, color, klass);
+ toggleBorderColor(this, color);
+ toggleOverlayInfo(klass);
+ });
+
+ // Hide overlay info initially
+ toggleOverlayInfo(klass, 'hide');
+
+ });
+
// Quadrant labels are off initially; color of button for toggling them should reflect this
quadrantsButton.css("border-color", "red");
+ // Hide plot info initially
+ $('.plot-info', element).hide();
+
// API
var dataXHR;
diff --git a/problem_builder/public/js/plot_edit.js b/problem_builder/public/js/plot_edit.js
new file mode 100644
index 00000000..74e56904
--- /dev/null
+++ b/problem_builder/public/js/plot_edit.js
@@ -0,0 +1,5 @@
+function PlotEdit(runtime, element) {
+ 'use strict';
+ StudioContainerXBlockWithNestedXBlocksMixin(runtime, element);
+ ProblemBuilderUtil.transformClarifications(element);
+}
diff --git a/problem_builder/templates/html/overlay.html b/problem_builder/templates/html/overlay.html
new file mode 100644
index 00000000..36af9623
--- /dev/null
+++ b/problem_builder/templates/html/overlay.html
@@ -0,0 +1,30 @@
+{% load i18n %}
+
+ {% if self.plot_label and self.point_color %}
+
{{ self.plot_label }} {% trans "Overlay" %}
+ {% endif %}
+
+ {% trans "Description:" %}
+ {% if self.description %}
+ {{ self.description }}
+ {% else %}
+ {% trans "No description provided" %}
+ {% endif %}
+
+
+ {% trans "Source:" %}
+ {% if self.citation %}
+ {{ self.citation }}
+ {% else %}
+ {% trans "No citation provided" %}
+ {% endif %}
+
+
+ {% trans "Data:" %}
+ {% if self.claim_data %}
+ {{ self.claim_data }}
+ {% else %}
+ {% trans "No data provided" %}
+ {% endif %}
+