Commit f236debd by Braden MacDonald

Merge pull request #78 from open-craft/slider-block

Step Builder: New question type: "Scale" (OC-1009)
parents 015fe14d 2f7e35a1
......@@ -5,12 +5,13 @@ before_install:
- "export DISPLAY=:99"
- "sh -e /etc/init.d/xvfb start"
install:
- "pip install -e git://github.com/edx/xblock-sdk.git#egg=xblock-sdk"
- "pip install -e git://github.com/edx/xblock-sdk.git@22c1b2f173919bef22f2d9d9295ec5396d02dffd#egg=xblock-sdk"
- "pip install -r requirements.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/test-requirements.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/base.txt"
- "pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.txt"
- "pip uninstall -y xblock-problem-builder && python setup.py sdist && pip install dist/xblock-problem-builder-2.0.tar.gz"
- "pip install -r test_requirements.txt"
- "mkdir var"
script:
- pep8 problem_builder --max-line-length=120
- pylint problem_builder --disable=all --enable=function-redefined,undefined-variable,unused-variable
......
......@@ -152,13 +152,6 @@ class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, QuestionMixin, StudioEdita
default="",
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')
......
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.resources import ResourceLoader
......@@ -125,12 +125,25 @@ class QuestionMixin(EnumerableChildMixin):
has_author_view = True
# Fields:
name = String(
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=_("Question title"),
help=_('Leave blank to use the default ("Question 1", "Question 2", etc.)'),
default="", # Blank will use 'Question x' - see display_name_with_default
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
def siblings(self):
......
......@@ -49,3 +49,7 @@
margin-top: 1em;
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 @@
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 {
margin-left: 10px;
display: inline-block;
......
......@@ -7,6 +7,7 @@ function MentoringStandardView(runtime, element, mentoring) {
function handleSubmitResults(response, disable_submit) {
messagesDOM.empty().hide();
var all_have_results = response.results.length > 0;
$.each(response.results || [], function(index, result_spec) {
var input = result_spec[0];
var result = result_spec[1];
......@@ -16,6 +17,7 @@ function MentoringStandardView(runtime, element, mentoring) {
num_attempts: response.num_attempts
};
callIfExists(child, 'handleSubmit', result, options);
all_have_results = all_have_results && !$.isEmptyObject(result);
});
$('.attempts', element).data('max_attempts', response.max_attempts);
......@@ -29,10 +31,10 @@ function MentoringStandardView(runtime, element, mentoring) {
messagesDOM.show();
}
// this method is called on successful submission and on page load
// results will be empty only for initial load if no submissions was made
// in such case we must allow submission to support submitting empty read-only long answer recaps
if (disable_submit || response.results.length > 0) {
// Disable the submit button if we have just submitted new answers,
// or if we have just [re]loaded the page and are showing a complete set
// of old answers.
if (disable_submit || all_have_results) {
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() / 100.0;
},
handleReview: function(result){
$slider.val(result.submission * 100.0);
$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
from lazy import lazy
import uuid
from xblock.core import XBlock
from xblock.fields import Scope, String, Float, UNIQUE_ID
from xblock.fields import Scope, String
from xblock.fragment import Fragment
from xblock.validation import ValidationMessage
from xblockutils.helpers import child_isinstance
......@@ -61,13 +61,6 @@ class QuestionnaireAbstractBlock(
values entered by the student, and supports multiple types of multiple-choice
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(
display_name=_("Question"),
help=_("Question to ask the student"),
......@@ -81,13 +74,6 @@ class QuestionnaireAbstractBlock(
scope=Scope.content,
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')
has_children = 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 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 by students to indicate a numeric value on a sliding scale.
The student's answer is always considered "correct".
"""
CATEGORY = 'pb-slider'
STUDIO_LABEL = _(u"Ranged Value Slider")
answerable = True
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['title'] = self.display_name_with_default
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
student_view = mentoring_view
preview_view = mentoring_view
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.
"""
context['hide_header'] = True # Header is already shown in the Studio wrapper
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 self.student_value is None:
return {}
return {
'submission': self.student_value,
'status': 'correct',
'tips': [],
'weight': self.weight,
'score': 1,
}
def get_results(self, _previous_result_unused=None):
""" Alias for get_last_result() """
return self.get_last_result()
def submit(self, value):
log.debug(u'Received Slider submission: "%s"', value)
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 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 (
from problem_builder.mixins import EnumerableChildMixin, MessageParentMixin, StepParentMixin
from problem_builder.mrq import MRQBlock
from problem_builder.plot import PlotBlock
from problem_builder.slider import SliderBlock
from problem_builder.table import MentoringTableBlock
......@@ -147,7 +148,7 @@ class MentoringStepBlock(
return [
NestedXBlockSpec(AnswerBlock, boilerplate='studio_default'),
MCQBlock, RatingBlock, MRQBlock, HtmlBlockShim,
AnswerRecapBlock, MentoringTableBlock, PlotBlock
AnswerRecapBlock, MentoringTableBlock, PlotBlock, SliderBlock
] + additional_blocks
@property
......
......@@ -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-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-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="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>
......
<div class="xblock-pb-slider">
{% if not hide_header %}<h3 class="question-title">{{ title }}</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>
# -*- 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 ###########################################################
from .base_test import ProblemBuilderBaseTest, MentoringAssessmentBaseTest, GetChoices
# Classes ###########################################################
class SliderBlockTestMixins(object):
""" Mixins for testing slider blocks. Assumes only one slider block is on the page. """
def get_slider_value(self):
return int(self.browser.execute_script("return $('.pb-slider-range').val()"))
def set_slider_value(self, val):
self.browser.execute_script("$('.pb-slider-range').val(arguments[0]).change()", val)
class SliderBlockTest(SliderBlockTestMixins, ProblemBuilderBaseTest):
"""
Tests for the SliderBlock inside a normal Problem Builder block.
"""
def test_simple_flow(self):
""" Test a regular Problem Builder block containing one slider """
pb_wrapper = self.load_scenario("slider_problem.xml", {"include_mcq": False})
self.wait_for_init()
# The initial value should be 50 and submit should be enabled since 50 is a valid value:
self.assertTrue(self.submit_button.is_enabled())
self.assertEqual(self.get_slider_value(), 50)
self.expect_checkmark_visible(False)
# Set the value to 75:
self.set_slider_value(75)
self.assertEqual(self.get_slider_value(), 75)
self.click_submit(pb_wrapper)
# Now, we expect submit to be disabled and the checkmark to be visible:
self.expect_checkmark_visible(True)
self.assertFalse(self.submit_button.is_enabled())
# Now change the value, and the button/checkmark should reset:
self.set_slider_value(45)
self.assertTrue(self.submit_button.is_enabled())
self.expect_checkmark_visible(False)
# Now reload the page:
self.browser.execute_script("$(document).html(' ');")
pb_wrapper = self.go_to_view("student_view")
self.wait_for_init()
# Now the initial value should be 75 and submit should be disabled (to discourage submitting the same answer):
self.assertEqual(self.get_slider_value(), 75)
self.assertFalse(self.submit_button.is_enabled())
self.expect_checkmark_visible(True)
def test_simple_flow_with_peer(self):
""" Test a regular Problem Builder block containing one slider and an MCQ """
pb_wrapper = self.load_scenario("slider_problem.xml", {"include_mcq": True})
self.wait_for_init()
# The initial value should be 50 and submit should be disabled until an MCQ choice is selected
self.assertEqual(self.get_slider_value(), 50)
self.assertFalse(self.submit_button.is_enabled())
self.expect_checkmark_visible(False)
# Set the value to 15:
self.set_slider_value(15)
self.assertEqual(self.get_slider_value(), 15)
self.assertFalse(self.submit_button.is_enabled())
# Choose a choice:
GetChoices(pb_wrapper).select('Yes')
self.assertTrue(self.submit_button.is_enabled())
self.click_submit(pb_wrapper)
# Now, we expect submit to be disabled and the checkmark to be visible:
self.expect_checkmark_visible(True)
self.assertFalse(self.submit_button.is_enabled())
# Now change the value, and the button/checkmark should reset:
self.set_slider_value(20)
self.assertTrue(self.submit_button.is_enabled())
self.expect_checkmark_visible(False)
def wait_for_init(self):
""" Wait for the scenario to initialize """
self.wait_until_hidden(self.browser.find_element_by_css_selector('.messages'))
@property
def submit_button(self):
return self.browser.find_element_by_css_selector('.submit input.input-main')
def expect_checkmark_visible(self, visible):
checkmark = self.browser.find_element_by_css_selector('.xblock-pb-slider .submit-result')
self.assertEqual(checkmark.is_displayed(), visible)
class SliderStepBlockTest(SliderBlockTestMixins, MentoringAssessmentBaseTest):
"""
Tests for the SliderBlock inside a Step Builder block.
"""
def test_step_with_slider(self):
""" Test a regular Step Builder block containing one slider and an MCQ """
step_builder, controls = self.load_assessment_scenario("slider_step.xml")
self.wait_for_init()
self.assertEqual(self.get_slider_value(), 50)
# Check step 1 (the slider step):
question = self.expect_question_visible(1, step_builder, question_text="Information Reliability")
self.assertIn("How reliable is this information?", question.text)
self.assertIn("Select a value from 0% to 100%", question.text) # Screen reader explanation
self.assertTrue(controls.submit.is_enabled())
self.assert_hidden(controls.try_again)
self.set_slider_value(0)
controls.submit.click()
self.do_submit_wait(controls, last=False)
self.wait_until_clickable(controls.next_question)
controls.next_question.click()
# Submit step 2:
question = self.expect_question_visible(2, step_builder)
GetChoices(question).select("Yes")
controls.submit.click()
self.do_submit_wait(controls, last=True)
self.wait_until_clickable(controls.review)
controls.review.click()
self.wait_until_visible(controls.try_again)
# You can't get a slider question wrong, but it does count as one correct point by default:
self.assertIn("You answered 2 questions correctly", step_builder.text)
def wait_for_init(self):
""" Wait for the scenario to initialize """
self.wait_until_hidden(self.browser.find_element_by_css_selector('.assessment-review-tips'))
<problem-builder url_name="pb_with_slider">
<pb-slider name="slider_1" question="How much do you like pizza?" min_label="A little" max_label="A lot" />
{% if include_mcq %}
<pb-mcq name="mcq_2" question="Do you like this MCQ?" correct_choices='["yes"]'>
<pb-choice value="yes">Yes</pb-choice>
<pb-choice value="maybenot">Maybe not</pb-choice>
<pb-choice value="understand">I don't understand</pb-choice>
</pb-mcq>
{% endif %}
</problem-builder>
<step-builder display_name="Step Builder">
<sb-step display_name="Slider step">
<pb-slider name="slider_1" display_name="Information Reliability" question="How reliable is this information?" min_label="0%" max_label="100%" />
</sb-step>
<sb-step display_name="MCQ step">
<pb-mcq name="mcq" display_name="Question 2" question="Do you like this MCQ?" correct_choices='["yes"]'>
<pb-choice value="yes">Yes</pb-choice>
<pb-choice value="no">No</pb-choice>
</pb-mcq>
</sb-step>
<sb-review-step/>
</step-builder>
......@@ -108,7 +108,17 @@ class TestMentoringStep(unittest.TestCase):
block = MentoringStepBlock(Mock(), DictFieldData({}), Mock())
self.assertEqual(
self.get_allowed_blocks(block),
['pb-answer', 'pb-mcq', 'pb-rating', 'pb-mrq', 'html', 'pb-answer-recap', 'pb-table', 'sb-plot']
[
'pb-answer',
'pb-mcq',
'pb-rating',
'pb-mrq',
'html',
'pb-answer-recap',
'pb-table',
'sb-plot',
'pb-slider',
]
)
from sys import modules
xmodule_mock = Mock()
......@@ -121,7 +131,16 @@ class TestMentoringStep(unittest.TestCase):
with patch.dict(modules, fake_modules):
self.assertEqual(
self.get_allowed_blocks(block), [
'pb-answer', 'pb-mcq', 'pb-rating', 'pb-mrq', 'html', 'pb-answer-recap',
'pb-table', 'sb-plot', 'video', 'imagemodal'
'pb-answer',
'pb-mcq',
'pb-rating',
'pb-mrq',
'html',
'pb-answer-recap',
'pb-table',
'sb-plot',
'pb-slider',
'video',
'imagemodal',
]
)
......@@ -54,6 +54,7 @@ BLOCKS = [
'pb-mcq = problem_builder.mcq:MCQBlock',
'pb-rating = problem_builder.mcq:RatingBlock',
'pb-mrq = problem_builder.mrq:MRQBlock',
'pb-slider = problem_builder.slider:SliderBlock',
'pb-message = problem_builder.message:MentoringMessageBlock',
'pb-tip = problem_builder.tip:TipBlock',
'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