diff --git a/AUTHORS b/AUTHORS index ad7de53c..096711dc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,3 +9,4 @@ Alan Boudreault Eugeny Kolpakov Braden MacDonald Jonathan Piacenti +Tim Krones diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py index adf90442..d817dc75 100644 --- a/problem_builder/mentoring.py +++ b/problem_builder/mentoring.py @@ -834,6 +834,14 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes scope=Scope.settings ) + # User state + active_step = Integer( + # Keep track of the student progress. + default=0, + scope=Scope.user_state, + enforce_type=True + ) + editable_fields = ('display_name',) @lazy @@ -854,27 +862,31 @@ def steps(self): def student_view(self, context): fragment = Fragment() - child_content = u"" + children_contents = [] for child_id in self.children: child = self.runtime.get_block(child_id) if child is None: # child should not be None but it can happen due to bugs or permission issues - child_content += u"

[{}]

".format(self._(u"Error: Unable to load child component.")) + child_content = u"

[{}]

".format(self._(u"Error: Unable to load child component.")) elif not isinstance(child, MentoringMessageBlock): child_fragment = self._render_child_fragment(child, context, view='mentoring_view') fragment.add_frag_resources(child_fragment) - child_content += child_fragment.content + child_content = child_fragment.content + children_contents.append(child_content) - fragment.add_content(loader.render_template('templates/html/mentoring.html', { + fragment.add_content(loader.render_template('templates/html/mentoring_with_steps.html', { 'self': self, 'title': self.display_name, 'show_title': self.show_title, - 'child_content': child_content, + 'children_contents': children_contents, })) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/problem-builder.css')) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring_with_steps.js')) self.include_theme_files(fragment) + fragment.initialize_js('MentoringWithStepsBlock') + return fragment @property @@ -897,6 +909,27 @@ def allowed_nested_blocks(self): NestedXBlockSpec(OnAssessmentReviewMentoringMessageShim, boilerplate='on-assessment-review'), ] + @XBlock.json_handler + def update_active_step(self, new_value, suffix=''): + if new_value < len(self.steps): + self.active_step = new_value + return { + 'active_step': self.active_step + } + + @XBlock.json_handler + def try_again(self, data, suffix=''): + self.active_step = 0 + + step_blocks = [self.runtime.get_block(child_id) for child_id in self.steps] + + for step in step_blocks: + step.reset() + + return { + 'active_step': self.active_step + } + def author_edit_view(self, context): """ Add some HTML to the author view that allows authors to add child blocks. diff --git a/problem_builder/public/js/mentoring_with_steps.js b/problem_builder/public/js/mentoring_with_steps.js new file mode 100644 index 00000000..8a3fab57 --- /dev/null +++ b/problem_builder/public/js/mentoring_with_steps.js @@ -0,0 +1,150 @@ +function MentoringWithStepsBlock(runtime, element) { + + var steps = runtime.children(element).filter( + function(c) { return c.element.className.indexOf('pb-mentoring-step') > -1; } + ); + var activeStep = $('.mentoring', element).data('active-step'); + var checkmark, submitDOM, nextDOM, tryAgainDOM, submitXHR; + + function isLastStep() { + return (activeStep === steps.length-1); + } + + function updateActiveStep(newValue) { + var handlerUrl = runtime.handlerUrl(element, 'update_active_step'); + $.post(handlerUrl, JSON.stringify(newValue)) + .success(function(response) { + activeStep = response.active_step; + }); + } + + function handleResults(response) { + // Update active step so next step is shown on page reload (even if user does not click "Next Step") + updateActiveStep(activeStep+1); + + // Update UI + if (response.completed === 'correct') { + checkmark.addClass('checkmark-correct icon-ok fa-check'); + } else if (response.completed === 'partial') { + checkmark.addClass('checkmark-partially-correct icon-ok fa-check'); + } else { + checkmark.addClass('checkmark-incorrect icon-exclamation fa-exclamation'); + } + + submitDOM.attr('disabled', 'disabled'); + + nextDOM.removeAttr("disabled"); + if (nextDOM.is(':visible')) { nextDOM.focus(); } + + if (isLastStep()) { + tryAgainDOM.removeAttr('disabled'); + tryAgainDOM.show(); + } + } + + function submit() { + // We do not handle submissions at this level, so just forward to "submit" method of active step + var step = steps[activeStep]; + step.submit(handleResults); + } + + function hideAllSteps() { + for (var i=0; i < steps.length; i++) { + $(steps[i].element).hide(); + } + } + + function cleanAll() { + checkmark.removeClass('checkmark-correct icon-ok fa-check'); + checkmark.removeClass('checkmark-partially-correct icon-ok fa-check'); + checkmark.removeClass('checkmark-incorrect icon-exclamation fa-exclamation'); + hideAllSteps(); + } + + function updateDisplay() { + cleanAll(); + showActiveStep(); + nextDOM.attr('disabled', 'disabled'); + validateXBlock(); + } + + function showActiveStep() { + var step = steps[activeStep]; + $(step.element).show(); + } + + function onChange() { + // We do not allow users to modify answers belonging to a step after submitting them: + // Once an answer has been submitted ("Next Step" button is enabled), + // start ignoring changes to the answer. + if (nextDOM.attr('disabled')) { + validateXBlock(); + } + } + + function validateXBlock() { + var isValid = true; + var step = steps[activeStep]; + if (step) { + isValid = step.validate(); + } + if (!isValid) { + submitDOM.attr('disabled', 'disabled'); + } else { + submitDOM.removeAttr('disabled'); + } + if (isLastStep()) { + nextDOM.hide(); + } + } + + function initSteps(options) { + for (var i=0; i < steps.length; i++) { + var step = steps[i]; + step.initChildren(options); + } + } + + function handleTryAgain(result) { + activeStep = result.active_step; + updateDisplay(); + tryAgainDOM.hide(); + submitDOM.show(); + if (! isLastStep()) { + nextDOM.show(); + } + } + + function tryAgain() { + var handlerUrl = runtime.handlerUrl(element, 'try_again'); + if (submitXHR) { + submitXHR.abort(); + } + submitXHR = $.post(handlerUrl, JSON.stringify({})).success(handleTryAgain); + } + + function initXBlockView() { + checkmark = $('.assessment-checkmark', element); + + submitDOM = $(element).find('.submit .input-main'); + submitDOM.on('click', submit); + submitDOM.show(); + + nextDOM = $(element).find('.submit .input-next'); + nextDOM.on('click', updateDisplay); + nextDOM.show(); + + tryAgainDOM = $(element).find('.submit .input-try-again'); + tryAgainDOM.on('click', tryAgain); + + var options = { + onChange: onChange + }; + initSteps(options); + + updateDisplay(); + } + + initXBlockView(); + +} diff --git a/problem_builder/public/js/step.js b/problem_builder/public/js/step.js new file mode 100644 index 00000000..618fdda6 --- /dev/null +++ b/problem_builder/public/js/step.js @@ -0,0 +1,58 @@ +function MentoringStepBlock(runtime, element) { + + var children = runtime.children(element); + var submitXHR; + + function callIfExists(obj, fn) { + if (typeof obj !== 'undefined' && typeof obj[fn] == 'function') { + return obj[fn].apply(obj, Array.prototype.slice.call(arguments, 2)); + } else { + return null; + } + } + + return { + + initChildren: function(options) { + for (var i=0; i < children.length; i++) { + var child = children[i]; + callIfExists(child, 'init', options); + } + }, + + validate: function() { + var is_valid = true; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child && child.name !== undefined) { + var child_validation = callIfExists(child, 'validate'); + if (_.isBoolean(child_validation)) { + is_valid = is_valid && child_validation; + } + } + } + return is_valid; + }, + + submit: function(result_handler) { + var handler_name = 'submit'; + var data = {}; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child && child.name !== undefined && typeof(child[handler_name]) !== "undefined") { + data[child.name.toString()] = child[handler_name](); + } + } + var handlerUrl = runtime.handlerUrl(element, handler_name); + if (submitXHR) { + submitXHR.abort(); + } + submitXHR = $.post(handlerUrl, JSON.stringify(data)) + .success(function(response) { + result_handler(response); + }); + } + + }; + +} diff --git a/problem_builder/step.py b/problem_builder/step.py index 2f968349..ac31dec3 100644 --- a/problem_builder/step.py +++ b/problem_builder/step.py @@ -18,12 +18,13 @@ # "AGPLv3". If not, see . # +import logging + from lazy.lazy import lazy from xblock.core import XBlock -from xblock.fields import String, Boolean, Scope +from xblock.fields import String, List, Scope from xblock.fragment import Fragment -from xblockutils.helpers import child_isinstance from xblockutils.resources import ResourceLoader from xblockutils.studio_editable import ( NestedXBlockSpec, StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin @@ -31,11 +32,12 @@ from problem_builder.answer import AnswerBlock, AnswerRecapBlock from problem_builder.mcq import MCQBlock, RatingBlock -from problem_builder.mixins import EnumerableChildMixin +from problem_builder.mixins import EnumerableChildMixin, StepParentMixin from problem_builder.mrq import MRQBlock from problem_builder.table import MentoringTableBlock +log = logging.getLogger(__name__) loader = ResourceLoader(__name__) @@ -56,6 +58,12 @@ def _normalize_id(key): return key +class Correctness(object): + CORRECT = 'correct' + PARTIAL = 'partial' + INCORRECT = 'incorrect' + + class HtmlBlockShim(object): CATEGORY = 'html' STUDIO_LABEL = _(u"HTML") @@ -64,7 +72,7 @@ class HtmlBlockShim(object): @XBlock.needs('i18n') class MentoringStepBlock( StudioEditableXBlockMixin, StudioContainerWithNestedXBlocksMixin, XBlockWithPreviewMixin, - EnumerableChildMixin, XBlock + EnumerableChildMixin, StepParentMixin, XBlock ): """ An XBlock for a step. @@ -73,7 +81,7 @@ class MentoringStepBlock( STUDIO_LABEL = _(u"Mentoring Step") CATEGORY = 'pb-mentoring-step' - # Fields: + # Settings display_name = String( display_name=_("Step Title"), help=_('Leave blank to use sequential numbering'), @@ -81,6 +89,13 @@ class MentoringStepBlock( scope=Scope.content ) + # User state + student_results = List( + # Store results of student choices. + default=[], + scope=Scope.user_state + ) + editable_fields = ('display_name', 'show_title',) @lazy @@ -104,13 +119,41 @@ def allowed_nested_blocks(self): AnswerRecapBlock, MentoringTableBlock, ] - @property - def steps(self): - """ Get the usage_ids of all of this XBlock's children that are "Questions" """ - from mixins import QuestionMixin - return [ - _normalize_id(child_id) for child_id in self.children if child_isinstance(self, child_id, QuestionMixin) - ] + @XBlock.json_handler + def submit(self, submissions, suffix=''): + log.info(u'Received submissions: {}'.format(submissions)) + + # Submit child blocks (questions) and gather results + submit_results = [] + for child in self.get_steps(): + if child.name and child.name in submissions: + submission = submissions[child.name] + child_result = child.submit(submission) + submit_results.append([child.name, child_result]) + child.save() + + # Update results stored for this step + self.reset() + for result in submit_results: + self.student_results.append(result) + + # Compute "answer status" for this step + if all(result[1]['status'] == 'correct' for result in submit_results): + completed = Correctness.CORRECT + elif all(result[1]['status'] == 'incorrect' for result in submit_results): + completed = Correctness.INCORRECT + else: + completed = Correctness.PARTIAL + + return { + 'message': 'Success!', + 'completed': completed, + 'results': submit_results, + } + + def reset(self): + while self.student_results: + self.student_results.pop() def author_edit_view(self, context): """ @@ -160,4 +203,7 @@ def _render_view(self, context, view): 'child_contents': child_contents, })) + fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/step.js')) + fragment.initialize_js('MentoringStepBlock') + return fragment diff --git a/problem_builder/templates/html/mentoring_with_steps.html b/problem_builder/templates/html/mentoring_with_steps.html new file mode 100644 index 00000000..a521c0d0 --- /dev/null +++ b/problem_builder/templates/html/mentoring_with_steps.html @@ -0,0 +1,24 @@ +{% load i18n %} +
+ + {% if show_title and title %} +
+

{{ title }}

+
+ {% endif %} + +
+ + {% for child_content in children_contents %} + {{ child_content|safe }} + {% endfor %} + +
+ + + + +
+
+ +