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:
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user