feat: add reset option to Randomized Content Block

This makes the reset button to refresh the contents of a Randomized
Content Block (RCB) without reloading the full page by fetching a new
set of problems in the "reset" response and replacing the DOM contents.

The reset button returns the student view as a string and the client
uses the HtmlUtils package to replace the contents and reinitializes the
XBlock.

This allows students to use the RCB as a flash card system.

Co-authored-by: tinumide <tinuade@opencraft.com>
This commit is contained in:
Arunmozhi
2021-09-01 13:47:39 +00:00
committed by Agrendalath
parent dae0de89dd
commit 2b39da69b9
5 changed files with 116 additions and 2 deletions

View File

@@ -0,0 +1,18 @@
/* JavaScript for reset option that can be done on a randomized LibraryContentBlock */
function LibraryContentReset(runtime, element) {
$('.problem-reset-btn', element).click((e) => {
e.preventDefault();
$.post({
url: runtime.handlerUrl(element, 'reset_selected_children'),
success(data) {
edx.HtmlUtils.setHtml(element, edx.HtmlUtils.HTML(data));
// Rebind the reset button for the block
XBlock.initializeBlock(element);
// Render the new set of problems (XBlocks)
$(".xblock", element).each(function(i, child) {
XBlock.initializeBlock(child);
});
},
});
});
}

View File

@@ -8,6 +8,7 @@ import logging
import random
from copy import copy
from gettext import ngettext
from rest_framework import status
import bleach
from django.conf import settings
@@ -21,7 +22,7 @@ from web_fragments.fragment import Fragment
from webob import Response
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock
from xblock.fields import Integer, List, Scope, String
from xblock.fields import Integer, List, Scope, String, Boolean
from capa.responsetypes import registry
from xmodule.mako_module import MakoTemplateBlockBase
@@ -176,6 +177,14 @@ class LibraryContentBlock(
default=[],
scope=Scope.user_state,
)
# This cannot be called `show_reset_button`, because children blocks inherit this as a default value.
allow_resetting_children = Boolean(
display_name=_("Show Reset Button"),
help=_("Determines whether a 'Reset Problems' button is shown, so users may reset their answers and reshuffle "
"selected items."),
scope=Scope.settings,
default=False
)
@property
def source_library_key(self):
@@ -346,6 +355,27 @@ class LibraryContentBlock(
return self.selected
@XBlock.handler
def reset_selected_children(self, _, __):
"""
Resets the XBlock's state for a user.
This resets the state of all `selected` children and then clears the `selected` field
so that the new blocks are randomly chosen for this user.
"""
if not self.allow_resetting_children:
return Response('"Resetting selected children" is not allowed for this XBlock',
status=status.HTTP_400_BAD_REQUEST)
for block_type, block_id in self.selected_children():
block = self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
if hasattr(block, 'reset_problem'):
block.reset_problem(None)
block.save()
self.selected = []
return Response(json.dumps(self.student_view({}).content))
def _get_selected_child_blocks(self):
"""
Generator returning XBlock instances of the children selected for the
@@ -383,7 +413,11 @@ class LibraryContentBlock(
'show_bookmark_button': False,
'watched_completable_blocks': set(),
'completion_delay_ms': None,
'reset_button': self.allow_resetting_children,
}))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_reset.js'))
fragment.initialize_js('LibraryContentReset')
return fragment
def author_view(self, context):

View File

@@ -3,7 +3,8 @@ Basic unit tests for LibraryContentBlock
Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
"""
from unittest.mock import Mock, patch
import ddt
from unittest.mock import MagicMock, Mock, patch
from bson.objectid import ObjectId
from fs.memoryfs import MemoryFS
@@ -11,6 +12,7 @@ from lxml import etree
from search.search_engine_base import SearchEngine
from web_fragments.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
from rest_framework import status
from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE, LibraryContentBlock
from xmodule.library_tools import LibraryToolsService
@@ -20,6 +22,7 @@ from xmodule.modulestore.tests.utils import MixedSplitTestCase
from xmodule.tests import get_test_system
from xmodule.validation import StudioValidationMessage
from xmodule.x_module import AUTHOR_VIEW
from xmodule.capa_module import ProblemBlock
from .test_course_module import DummySystem as TestImportSystem
@@ -30,6 +33,7 @@ class LibraryContentTest(MixedSplitTestCase):
"""
Base class for tests of LibraryContentBlock (library_content_block.py)
"""
def setUp(self):
super().setUp()
@@ -164,6 +168,7 @@ class TestLibraryContentExportImport(LibraryContentTest):
self._verify_xblock_properties(imported_lc_block)
@ddt.ddt
class LibraryContentBlockTestMixin:
"""
Basic unit tests for LibraryContentBlock
@@ -378,6 +383,45 @@ class LibraryContentBlockTestMixin:
assert len(selected) == count
return selected
@ddt.data(
# User resets selected children with reset button on content block
(True, 8),
# User resets selected children without reset button on content block
(False, 8),
)
@ddt.unpack
def test_reset_selected_children_capa_blocks(self, allow_resetting_children, max_count):
"""
Tests that the `reset_selected_children` method of a content block resets only
XBlocks that have a `reset_problem` attribute when `allow_resetting_children` is True
This test block has 4 HTML XBlocks and 4 Problem XBlocks. Therefore, if we ensure
that the `reset_problem` has been called len(self.problem_types) times, then
it means that this is working correctly
"""
self.lc_block.allow_resetting_children = allow_resetting_children
self.lc_block.max_count = max_count
# Add some capa blocks
self._create_capa_problems()
self.lc_block.refresh_children()
self.lc_block = self.store.get_item(self.lc_block.location)
# Mock the student view to return an empty dict to be returned as response
self.lc_block.student_view = MagicMock()
self.lc_block.student_view.return_value.content = {}
with patch.object(ProblemBlock, 'reset_problem', return_value={'success': True}) as reset_problem:
response = self.lc_block.reset_selected_children(None, None)
if allow_resetting_children:
self.lc_block.student_view.assert_called_once_with({})
assert reset_problem.call_count == len(self.problem_types)
assert response.status_code == status.HTTP_200_OK
assert response.content_type == "text/html"
assert response.body == b"{}"
else:
reset_problem.assert_not_called()
assert response.status_code == status.HTTP_400_BAD_REQUEST
@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=None, autospec=True))
class TestLibraryContentBlockNoSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest):
@@ -396,6 +440,7 @@ class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, Libra
"""
Tests for library container with mocked search engine response.
"""
def _get_search_response(self, field_dictionary=None):
""" Mocks search response as returned by search engine """
target_type = field_dictionary.get('problem_types')

View File

@@ -635,6 +635,17 @@ html.video-fullscreen {
border-bottom: 1px solid #ddd;
margin-bottom: ($baseline*0.75);
padding: 0 0 15px;
.problem-reset-btn-wrapper {
position: relative;
.problem-reset-btn {
&:hover,
&:focus,
&:active {
color: $primary;
}
}
}
}
.vert > .xblock-student_view.is-hidden,

View File

@@ -69,6 +69,12 @@ from openedx.core.djangolib.markup import HTML
% endfor
</div>
% if reset_button:
<div class="problem-reset-btn-wrapper">
<button type="button" class="problem-reset-btn btn-link" data-value="${_('Reset Problems')}"><span aria-hidden="true">${_('Reset Problems')}</span><span class="sr">${_("Reset Problems")}</span></button>
</div>
% endif
<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
</%static:require_module_async>