Commit 772c1f26 by Braden MacDonald

Initial/basic implementation of slider block

parent 015fe14d
...@@ -152,13 +152,6 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita ...@@ -152,13 +152,6 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita
default="", default="",
multiline_editor=True, multiline_editor=True,
) )
weight = Float(
display_name=_("Weight"),
help=_("Defines the maximum total grade of the answer block."),
default=1,
scope=Scope.settings,
enforce_type=True
)
editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from', 'display_name', 'show_title') editable_fields = ('question', 'name', 'min_characters', 'weight', 'default_from', 'display_name', 'show_title')
......
from lazy import lazy from lazy import lazy
from xblock.fields import String, Boolean, Scope from xblock.fields import String, Boolean, Float, Scope, UNIQUE_ID
from xblockutils.helpers import child_isinstance from xblockutils.helpers import child_isinstance
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
...@@ -125,12 +125,26 @@ class QuestionMixin(EnumerableChildMixin): ...@@ -125,12 +125,26 @@ class QuestionMixin(EnumerableChildMixin):
has_author_view = True has_author_view = True
# Fields: # Fields:
name = String(
# This doesn't need to be a field but is kept for backwards compatibility with v1 student data
display_name=_("Question ID (name)"),
help=_("The ID of this question (required). Should be unique within this mentoring component."),
default=UNIQUE_ID,
scope=Scope.settings, # Must be scope.settings, or the unique ID will change every time this block is edited
)
display_name = String( display_name = String(
display_name=_("Question title"), display_name=_("Question title"),
help=_('Leave blank to use the default ("Question 1", "Question 2", etc.)'), help=_('Leave blank to use the default ("Question 1", "Question 2", etc.)'),
default="", # Blank will use 'Question x' - see display_name_with_default default="", # Blank will use 'Question x' - see display_name_with_default
scope=Scope.content scope=Scope.content
) )
weight = Float(
display_name=_("Weight"),
help=_("Defines the maximum total grade of this question."),
default=1,
scope=Scope.content,
enforce_type=True
)
@lazy @lazy
def siblings(self): def siblings(self):
......
...@@ -49,3 +49,7 @@ ...@@ -49,3 +49,7 @@
margin-top: 1em; margin-top: 1em;
padding-top: 0.3em; padding-top: 0.3em;
} }
.xblock-author_view-pb-slider .url-name-footer {
margin: 0 -20px -20px -20px; /* Counteract spacing from xblock-render wrapper. */
}
...@@ -64,6 +64,32 @@ ...@@ -64,6 +64,32 @@
margin-bottom: 0; margin-bottom: 0;
} }
.mentoring .xblock-pb-slider p label {
font-size: inherit;
}
.mentoring .pb-slider-box {
max-width: 400px;
}
.mentoring .pb-slider-range {
width: 100%;
}
.mentoring .pb-slider-min-label {
float: left;
}
.mentoring .pb-slider-max-label {
float: right;
}
.mentoring .clearfix::after {
clear: both;
display: block;
content: " ";
}
.mentoring .attempts { .mentoring .attempts {
margin-left: 10px; margin-left: 10px;
display: inline-block; display: inline-block;
......
...@@ -7,6 +7,7 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -7,6 +7,7 @@ function MentoringStandardView(runtime, element, mentoring) {
function handleSubmitResults(response, disable_submit) { function handleSubmitResults(response, disable_submit) {
messagesDOM.empty().hide(); messagesDOM.empty().hide();
var all_have_results = response.results.length > 0;
$.each(response.results || [], function(index, result_spec) { $.each(response.results || [], function(index, result_spec) {
var input = result_spec[0]; var input = result_spec[0];
var result = result_spec[1]; var result = result_spec[1];
...@@ -16,6 +17,7 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -16,6 +17,7 @@ function MentoringStandardView(runtime, element, mentoring) {
num_attempts: response.num_attempts num_attempts: response.num_attempts
}; };
callIfExists(child, 'handleSubmit', result, options); callIfExists(child, 'handleSubmit', result, options);
all_have_results = all_have_results && !$.isEmptyObject(result);
}); });
$('.attempts', element).data('max_attempts', response.max_attempts); $('.attempts', element).data('max_attempts', response.max_attempts);
...@@ -29,10 +31,10 @@ function MentoringStandardView(runtime, element, mentoring) { ...@@ -29,10 +31,10 @@ function MentoringStandardView(runtime, element, mentoring) {
messagesDOM.show(); messagesDOM.show();
} }
// this method is called on successful submission and on page load // Disable the submit button if we have just submitted new answers,
// results will be empty only for initial load if no submissions was made // or if we have just [re]loaded the page and are showing a complete set
// in such case we must allow submission to support submitting empty read-only long answer recaps // of old answers.
if (disable_submit || response.results.length > 0) { if (disable_submit || all_have_results) {
submitDOM.attr('disabled', 'disabled'); submitDOM.attr('disabled', 'disabled');
} }
} }
......
function SliderBlock(runtime, element) {
var $slider = $('.pb-slider-range', element);
return {
mode: null,
mentoring: null,
value: function() {
return parseInt($slider.val());
},
init: function(options) {
this.mentoring = options.mentoring;
this.mode = options.mode;
$slider.on('change', options.onChange);
},
submit: function() {
return this.value();
},
handleReview: function(result){
$slider.val(result.submission);
$slider.prop('disabled', true);
},
handleSubmit: function(result) {
// Show a green check if the user has submitted a valid value:
if (typeof result.submission !== "undefined") {
$('.submit-result', element).css('visibility', 'visible');
}
},
clearResult: function() {
$('.submit-result', element).css('visibility', 'hidden');
},
validate: function(){
return Boolean(this.value() >= 0 && this.value() <= 100);
}
};
}
...@@ -24,7 +24,7 @@ from django.utils.safestring import mark_safe ...@@ -24,7 +24,7 @@ from django.utils.safestring import mark_safe
from lazy import lazy from lazy import lazy
import uuid import uuid
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Scope, String, Float, UNIQUE_ID from xblock.fields import Scope, String, Float
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from xblockutils.helpers import child_isinstance from xblockutils.helpers import child_isinstance
...@@ -61,13 +61,6 @@ class QuestionnaireAbstractBlock( ...@@ -61,13 +61,6 @@ class QuestionnaireAbstractBlock(
values entered by the student, and supports multiple types of multiple-choice values entered by the student, and supports multiple types of multiple-choice
set, with preset choices and author-defined values. set, with preset choices and author-defined values.
""" """
name = String(
# This doesn't need to be a field but is kept for backwards compatibility with v1 student data
display_name=_("Question ID (name)"),
help=_("The ID of this question (required). Should be unique within this mentoring component."),
default=UNIQUE_ID,
scope=Scope.settings, # Must be scope.settings, or the unique ID will change every time this block is edited
)
question = String( question = String(
display_name=_("Question"), display_name=_("Question"),
help=_("Question to ask the student"), help=_("Question to ask the student"),
...@@ -81,13 +74,6 @@ class QuestionnaireAbstractBlock( ...@@ -81,13 +74,6 @@ class QuestionnaireAbstractBlock(
scope=Scope.content, scope=Scope.content,
default="" default=""
) )
weight = Float(
display_name=_("Weight"),
help=_("Defines the maximum total grade of this question."),
default=1,
scope=Scope.content,
enforce_type=True
)
editable_fields = ('question', 'message', 'weight', 'display_name', 'show_title') editable_fields = ('question', 'message', 'weight', 'display_name', 'show_title')
has_children = True has_children = True
answerable = True answerable = True
......
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
# Imports ###########################################################
import logging
import uuid
from xblock.core import XBlock
from xblock.fields import Scope, String, Float
from xblock.fragment import Fragment
from xblock.validation import ValidationMessage
from xblockutils.studio_editable import StudioEditableXBlockMixin
from xblockutils.resources import ResourceLoader
from .mixins import QuestionMixin, XBlockWithTranslationServiceMixin
from .sub_api import sub_api, SubmittingXBlockMixin
# Globals ###########################################################
log = logging.getLogger(__name__)
loader = ResourceLoader(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
return text
# Classes ###########################################################
@XBlock.needs("i18n")
class SliderBlock(
SubmittingXBlockMixin, QuestionMixin, StudioEditableXBlockMixin, XBlockWithTranslationServiceMixin, XBlock,
):
"""
An XBlock used to ask multiple-choice questions
"""
CATEGORY = 'pb-slider'
STUDIO_LABEL = _(u"Ranged Value Slider")
min_label = String(
display_name=_("Low"),
help=_("Label for low end of the range"),
scope=Scope.content,
default=_("0%"),
)
max_label = String(
display_name=_("High"),
help=_("Label for high end of the range"),
scope=Scope.content,
default=_("100%"),
)
question = String(
display_name=_("Question"),
help=_("Question to ask the student (optional)"),
scope=Scope.content,
default="",
multiline_editor=True,
)
student_value = Float(
# The value selected by the student
default=None,
scope=Scope.user_state,
)
editable_fields = ('min_label', 'max_label', 'display_name', 'question', 'show_title')
def mentoring_view(self, context):
""" Main view of this block """
context = context.copy() if context else {}
context['question'] = self.question
context['slider_id'] = 'pb-slider-{}'.format(uuid.uuid4().hex[:20])
context['initial_value'] = int(self.student_value*100) if self.student_value is not None else 50
context['min_label'] = self.min_label
context['max_label'] = self.max_label
context['hide_header'] = context.get('hide_header', False) or not self.show_title
context['instructions_string'] = self._("Select a value from {min_label} to {max_label}").format(
min_label=self.min_label, max_label=self.max_label
)
html = loader.render_template('templates/html/slider.html', context)
fragment = Fragment(html)
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/slider.js'))
fragment.initialize_js('SliderBlock')
return fragment
def student_view(self, context=None):
""" Normal view of this XBlock, identical to mentoring_view """
return self.mentoring_view(context)
def author_view(self, context):
"""
Add some HTML to the author view that allows authors to see the ID of the block, so they
can refer to it in other blocks such as Plot blocks.
"""
fragment = self.student_view(context)
fragment.add_content(loader.render_template('templates/html/slider_edit_footer.html', {
"url_name": self.url_name
}))
return fragment
def get_last_result(self):
""" Return the current/last result in the required format """
if not self.student_value:
return {}
return {
'submission': self.student_value,
'status': 'correct',
'tips': [],
'weight': self.weight,
'score': 1,
}
def submit(self, value):
log.debug(u'Received Slider submission: "%s"', value)
value = value / 100.0
if value < 0 or value > 1:
return {} # Invalid
self.student_value = value
if sub_api:
# Also send to the submissions API:
sub_api.create_submission(self.student_item_key, {'value': value, })
result = self.get_last_result()
log.debug(u'Slider submission result: %s', result)
return result
def get_author_edit_view_fragment(self, context):
"""
The options for the 1-5 values of the Likert scale aren't child blocks but we want to
show them in the author edit view, for clarity.
"""
fragment = Fragment(u"<p>{}</p>".format(self.question))
self.render_children(context, fragment, can_reorder=True, can_add=False)
return fragment
def validate_field_data(self, validation, data):
"""
Validate this block's field data.
"""
super(SliderBlock, self).validate_field_data(validation, data)
...@@ -38,6 +38,7 @@ from .message import ( ...@@ -38,6 +38,7 @@ from .message import (
from problem_builder.mixins import EnumerableChildMixin, MessageParentMixin, StepParentMixin from problem_builder.mixins import EnumerableChildMixin, MessageParentMixin, StepParentMixin
from problem_builder.mrq import MRQBlock from problem_builder.mrq import MRQBlock
from problem_builder.plot import PlotBlock from problem_builder.plot import PlotBlock
from problem_builder.slider import SliderBlock
from problem_builder.table import MentoringTableBlock from problem_builder.table import MentoringTableBlock
...@@ -147,7 +148,7 @@ class MentoringStepBlock( ...@@ -147,7 +148,7 @@ class MentoringStepBlock(
return [ return [
NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'), NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'),
MCQBlock, RatingBlock, MRQBlock, HtmlBlockShim, MCQBlock, RatingBlock, MRQBlock, HtmlBlockShim,
AnswerRecapBlock, MentoringTableBlock, PlotBlock AnswerRecapBlock, MentoringTableBlock, PlotBlock, SliderBlock
] + additional_blocks ] + additional_blocks
@property @property
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-mcq">{% trans "Multiple Choice Question" %}</a></li> <li><a href="#" class="single-template add-xblock-component-button" data-category="pb-mcq">{% trans "Multiple Choice Question" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-rating">{% trans "Rating Question" %}</a></li> <li><a href="#" class="single-template add-xblock-component-button" data-category="pb-rating">{% trans "Rating Question" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-mrq">{% trans "Multiple Response Question" %}</a></li> <li><a href="#" class="single-template add-xblock-component-button" data-category="pb-mrq">{% trans "Multiple Response Question" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-slider">{% trans "Ranged Value Slider" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="html">{% trans "HTML" %}</a></li> <li><a href="#" class="single-template add-xblock-component-button" data-category="html">{% trans "HTML" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-answer-recap">{% trans "Long Answer Recap" %}</a></li> <li><a href="#" class="single-template add-xblock-component-button" data-category="pb-answer-recap">{% trans "Long Answer Recap" %}</a></li>
<li><a href="#" class="single-template add-xblock-component-button" data-category="pb-table">{% trans "Answer Recap Table" %}</a></li> <li><a href="#" class="single-template add-xblock-component-button" data-category="pb-table">{% trans "Answer Recap Table" %}</a></li>
......
<div class="xblock-pb-slider">
{% if not hide_header %}<h3 class="question-title">{{ self.display_name_with_default }}</h3>{% endif %}
{% if question %}
<p><label for="{{slider_id}}">{{ question|safe }} <span class="sr">({{instructions_string}})</span></label></p>
{% endif %}
<div class="pb-slider-box clearfix">
<input type="range"
id="{{slider_id}}" class="pb-slider-range" min="0" max="100" step="1" value="{{initial_value}}"
{% if not question %}aria-label="{{instructions_string}}"{% endif %}
>
<div class="pb-slider-min-label" aria-hidden="true">{{ min_label }}</div>
<div class="pb-slider-max-label" aria-hidden="true">{{ max_label }}</div>
</div>
<div class="clearfix">
<span class="submit-result fa icon-2x checkmark-correct icon-ok fa-check" style="visibility: hidden;"></span>
</div>
</div>
{% load i18n %}
<div class="xblock-header-secondary url-name-footer">
<span class="url-name-label">{% trans "ID for referencing this slider:" %}</span>
<span class="url-name">{{ url_name }}</span>
</div>
...@@ -54,6 +54,7 @@ BLOCKS = [ ...@@ -54,6 +54,7 @@ BLOCKS = [
'pb-mcq = problem_builder.mcq:MCQBlock', 'pb-mcq = problem_builder.mcq:MCQBlock',
'pb-rating = problem_builder.mcq:RatingBlock', 'pb-rating = problem_builder.mcq:RatingBlock',
'pb-mrq = problem_builder.mrq:MRQBlock', 'pb-mrq = problem_builder.mrq:MRQBlock',
'pb-slider = problem_builder.slider:SliderBlock',
'pb-message = problem_builder.message:MentoringMessageBlock', 'pb-message = problem_builder.message:MentoringMessageBlock',
'pb-tip = problem_builder.tip:TipBlock', 'pb-tip = problem_builder.tip:TipBlock',
'pb-choice = problem_builder.choice:ChoiceBlock', 'pb-choice = problem_builder.choice:ChoiceBlock',
......
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