feat: minimal UI for the Problem Bank block
This commit is contained in:
@@ -436,7 +436,7 @@ def get_library_content_picker_url(course_locator) -> str:
|
||||
content_picker_url = None
|
||||
if libraries_v2_enabled():
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
content_picker_url = f'{mfe_base_url}/component-picker'
|
||||
content_picker_url = f'{mfe_base_url}/component-picker?variant=published'
|
||||
|
||||
return content_picker_url
|
||||
|
||||
|
||||
@@ -300,8 +300,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long
|
||||
course = modulestore().get_course(xblock.location.course_key)
|
||||
can_edit = context.get('can_edit', True)
|
||||
can_add = context.get('can_add', True)
|
||||
# Is this a course or a library?
|
||||
is_course = xblock.scope_ids.usage_id.context_key.is_course
|
||||
is_course = xblock.context_key.is_course
|
||||
tags_count_map = context.get('tags_count_map')
|
||||
tags_count = 0
|
||||
if tags_count_map:
|
||||
@@ -320,7 +321,10 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
'is_selected': context.get('is_selected', False),
|
||||
'selectable': context.get('selectable', False),
|
||||
'selected_groups_label': selected_groups_label,
|
||||
'can_add': context.get('can_add', True),
|
||||
'can_add': can_add,
|
||||
# Generally speaking, "if you can add, you can delete". One exception is itembank (Problem Bank)
|
||||
# which has its own separate "add" workflow but uses the normal delete workflow for its child blocks.
|
||||
'can_delete': can_add or (root_xblock and root_xblock.scope_ids.block_type == "itembank" and can_edit),
|
||||
'can_move': context.get('can_move', is_course),
|
||||
'language': getattr(course, 'language', None),
|
||||
'is_course': is_course,
|
||||
|
||||
@@ -1648,6 +1648,9 @@ INSTALLED_APPS = [
|
||||
'corsheaders',
|
||||
'openedx.core.djangoapps.cors_csrf',
|
||||
|
||||
# Provides the 'django_markup' template library so we can use 'interpolate_html' in django templates
|
||||
'xss_utils',
|
||||
|
||||
# History tables
|
||||
'simple_history',
|
||||
|
||||
|
||||
68
cms/static/js/views/modals/select_v2_library_content.js
Normal file
68
cms/static/js/views/modals/select_v2_library_content.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Provides utilities to open and close the library content picker.
|
||||
*
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'],
|
||||
function($, _, gettext, BaseModal) {
|
||||
'use strict';
|
||||
|
||||
var SelectV2LibraryContent = BaseModal.extend({
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'add-component-from-library',
|
||||
modalSize: 'lg',
|
||||
view: 'studio_view',
|
||||
viewSpecificClasses: 'modal-add-component-picker confirm',
|
||||
// Translators: "title" is the name of the current component being edited.
|
||||
titleFormat: gettext('Add library content'),
|
||||
addPrimaryActionButton: false,
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
BaseModal.prototype.initialize.call(this);
|
||||
// Add event listen to close picker when the iframe tells us to
|
||||
const handleMessage = (event) => {
|
||||
if (event.data?.type === 'pickerComponentSelected') {
|
||||
var requestData = {
|
||||
library_content_key: event.data.usageKey,
|
||||
category: event.data.category,
|
||||
}
|
||||
this.callback(requestData);
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
this.messageListener = window.addEventListener("message", handleMessage);
|
||||
this.cleanupListener = () => { window.removeEventListener("message", handleMessage) };
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
BaseModal.prototype.hide.call(this);
|
||||
this.cleanupListener();
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds the action buttons to the modal.
|
||||
*/
|
||||
addActionButtons: function() {
|
||||
this.addActionButton('cancel', gettext('Cancel'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a component picker modal from library.
|
||||
* @param contentPickerUrl Url for component picker
|
||||
* @param callback A function to call with the selected block(s)
|
||||
*/
|
||||
showComponentPicker: function(contentPickerUrl, callback) {
|
||||
this.contentPickerUrl = contentPickerUrl;
|
||||
this.callback = callback;
|
||||
|
||||
this.render();
|
||||
this.show();
|
||||
},
|
||||
|
||||
getContentHtml: function() {
|
||||
return `<iframe src="${this.contentPickerUrl}" onload="this.contentWindow.focus()" frameborder="0" style="width: 100%; height: 100%;"/>`;
|
||||
},
|
||||
});
|
||||
|
||||
return SelectV2LibraryContent;
|
||||
});
|
||||
@@ -8,7 +8,8 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
|
||||
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/xblock_access_editor',
|
||||
'js/views/pages/container_subviews', 'js/views/unit_outline', 'js/views/utils/xblock_utils',
|
||||
'common/js/components/views/feedback_notification', 'common/js/components/views/feedback_prompt',
|
||||
'js/views/utils/tagging_drawer_utils', 'js/utils/module', 'js/views/modals/preview_v2_library_changes'
|
||||
'js/views/utils/tagging_drawer_utils', 'js/utils/module', 'js/views/modals/preview_v2_library_changes',
|
||||
'js/views/modals/select_v2_library_content'
|
||||
],
|
||||
function($, _, Backbone, gettext, BasePage,
|
||||
ViewUtils, ContainerView, XBlockView,
|
||||
@@ -16,7 +17,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor,
|
||||
ContainerSubviews, UnitOutlineView, XBlockUtils,
|
||||
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils,
|
||||
PreviewLibraryChangesModal) {
|
||||
PreviewLibraryChangesModal, SelectV2LibraryContent) {
|
||||
'use strict';
|
||||
|
||||
var XBlockContainerPage = BasePage.extend({
|
||||
@@ -30,6 +31,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
'click .move-button': 'showMoveXBlockModal',
|
||||
'click .delete-button': 'deleteXBlock',
|
||||
'click .library-sync-button': 'showXBlockLibraryChangesPreview',
|
||||
'click .problem-bank-v2-add-button': 'showSelectV2LibraryContent',
|
||||
'click .show-actions-menu-button': 'showXBlockActionsMenu',
|
||||
'click .new-component-button': 'scrollToNewComponentButtons',
|
||||
'click .save-button': 'saveSelectedLibraryComponents',
|
||||
@@ -255,6 +257,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
} else {
|
||||
// The thing in the clipboard can be pasted into this unit:
|
||||
const detailsPopupEl = this.$(".clipboard-details-popup")[0];
|
||||
if (!detailsPopupEl) return; // This happens on the Problem Bank container page - no paste button is there anyways
|
||||
detailsPopupEl.querySelector(".detail-block-name").innerText = data.content.display_name;
|
||||
detailsPopupEl.querySelector(".detail-block-type").innerText = data.content.block_type_display;
|
||||
detailsPopupEl.querySelector(".detail-course-name").innerText = data.source_context_title;
|
||||
@@ -435,6 +438,43 @@ function($, _, Backbone, gettext, BasePage,
|
||||
});
|
||||
},
|
||||
|
||||
showSelectV2LibraryContent: function(event, options) {
|
||||
event.preventDefault();
|
||||
|
||||
const xblockElement = this.findXBlockElement(event.target);
|
||||
const modal = new SelectV2LibraryContent(options);
|
||||
const courseAuthoringMfeUrl = this.model.attributes.course_authoring_url;
|
||||
const itemBankBlockId = xblockElement.data("locator");
|
||||
const pickerUrl = courseAuthoringMfeUrl + '/component-picker?variant=published';
|
||||
|
||||
modal.showComponentPicker(pickerUrl, (selectedBlockData) => {
|
||||
const createData = {
|
||||
parent_locator: itemBankBlockId,
|
||||
// The user wants to add this block from the library to the Problem Bank:
|
||||
library_content_key: selectedBlockData.library_content_key,
|
||||
category: selectedBlockData.category,
|
||||
};
|
||||
let doneAddingBlock = () => { this.refreshXBlock(xblockElement, false); };
|
||||
if (this.model.id === itemBankBlockId) {
|
||||
// We're on the detailed view, showing all the components inside the problem bank.
|
||||
// Create a placeholder that will become the new block(s)
|
||||
const $placeholderEl = $(this.createPlaceholderElement());
|
||||
const $insertSpot = xblockElement.find('.insert-new-lib-blocks-here');
|
||||
const placeholderElement = $placeholderEl.insertBefore($insertSpot);
|
||||
const scrollOffset = ViewUtils.getScrollOffset($placeholderEl);
|
||||
doneAddingBlock = (addResult) => {
|
||||
ViewUtils.setScrollOffset(placeholderElement, scrollOffset);
|
||||
placeholderElement.data('locator', addResult.locator);
|
||||
return this.refreshXBlock(placeholderElement, true);
|
||||
};
|
||||
}
|
||||
// Now we actually add the block:
|
||||
ViewUtils.runOperationShowingMessage(gettext('Adding'), () => {
|
||||
return $.postJSON(this.getURLRoot() + '/', createData, doneAddingBlock);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* If the new "Actions" menu is enabled, most XBlock actions like
|
||||
* Duplicate, Move, Delete, Manage Access, etc. are moved into this
|
||||
|
||||
@@ -197,8 +197,7 @@ upstream_info = UpstreamLink.try_get_for_block(xblock)
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
% if can_add:
|
||||
<!-- If we can add, we can delete. -->
|
||||
% if can_delete:
|
||||
<li class="nav-item">
|
||||
<a class="delete-button" href="#" role="button">${_("Delete")}</a>
|
||||
</li>
|
||||
|
||||
@@ -17,7 +17,9 @@ from webob import Response
|
||||
from xblock.completable import XBlockCompletionMode
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Boolean, Integer, List, Scope, String
|
||||
from xblock.utils.resources import ResourceLoader
|
||||
|
||||
from xmodule.block_metadata_utils import display_name_with_default
|
||||
from xmodule.mako_block import MakoTemplateBlockBase
|
||||
from xmodule.studio_editable import StudioEditableBlock
|
||||
from xmodule.util.builtin_assets import add_webpack_js_to_fragment
|
||||
@@ -33,6 +35,7 @@ from xmodule.x_module import (
|
||||
_ = lambda text: text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
loader = ResourceLoader(__name__)
|
||||
|
||||
|
||||
@XBlock.needs('mako')
|
||||
@@ -461,9 +464,9 @@ class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
validation = super().validate()
|
||||
if not isinstance(validation, StudioValidation):
|
||||
validation = StudioValidation.copy(validation)
|
||||
if not validation.empty:
|
||||
pass # If there's already a validation error, leave it there.
|
||||
elif self.max_count < -1 or self.max_count == 0:
|
||||
if not validation.empty: # If there's already a validation error, leave it there.
|
||||
return validation
|
||||
if self.max_count < -1 or self.max_count == 0:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.ERROR,
|
||||
@@ -475,7 +478,7 @@ class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
action_label=_("Edit the problem bank configuration."),
|
||||
)
|
||||
)
|
||||
elif len(self.children) < self.max_count:
|
||||
elif 0 < len(self.children) < self.max_count:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.WARNING,
|
||||
@@ -484,7 +487,7 @@ class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
"but only {actual} have been selected."
|
||||
).format(count=self.max_count, actual=len(self.children)),
|
||||
action_class='edit-button',
|
||||
action_label=_("Edit the problem bank configuration."),
|
||||
action_label=_("Edit the problem bank configuration.")
|
||||
)
|
||||
)
|
||||
return validation
|
||||
@@ -498,20 +501,29 @@ class ItemBankBlock(ItemBankMixin, XBlock):
|
||||
fragment = Fragment()
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and root_xblock.usage_key == self.usage_key
|
||||
# User has clicked the "View" link. Show a preview of all possible children:
|
||||
if is_root and self.children: # pylint: disable=no-member
|
||||
fragment.add_content(self.runtime.service(self, 'mako').render_cms_template(
|
||||
"library-block-author-preview-header.html", {
|
||||
'max_count': self.max_count if self.max_count >= 0 else len(self.children),
|
||||
'display_name': self.display_name or self.url_name,
|
||||
}))
|
||||
if is_root and self.children:
|
||||
# User has clicked the "View" link. Show a preview of all possible children:
|
||||
context['can_edit_visibility'] = False
|
||||
context['can_move'] = False
|
||||
context['can_collapse'] = True
|
||||
self.render_children(context, fragment, can_reorder=False, can_add=False)
|
||||
context['is_loading'] = False
|
||||
|
||||
fragment.initialize_js('LibraryContentAuthorView')
|
||||
else:
|
||||
# We're just on the regular unit page, or we're on the "view" page but no children exist yet.
|
||||
# Show a summary message and instructions.
|
||||
summary_html = loader.render_django_template('templates/item_bank/author_view.html', {
|
||||
# Due to template interpolation limitations, we have to pass some HTML for the link here:
|
||||
"view_link": f'<a href="/container/{self.usage_key}">',
|
||||
"blocks": [
|
||||
{"display_name": display_name_with_default(child)}
|
||||
for child in self.get_children()
|
||||
],
|
||||
"block_count": len(self.children),
|
||||
"max_count": self.max_count,
|
||||
})
|
||||
fragment.add_content(summary_html)
|
||||
# Whether on the main author view or the detailed children view, show a button to add more from the library:
|
||||
add_html = loader.render_django_template('templates/item_bank/author_view_add.html', {})
|
||||
fragment.add_content(add_html)
|
||||
return fragment
|
||||
|
||||
def format_block_keys_for_analytics(self, block_keys: list[tuple[str, str]]) -> list[dict]:
|
||||
|
||||
46
xmodule/templates/item_bank/author_view.html
Normal file
46
xmodule/templates/item_bank/author_view.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% load i18n %}
|
||||
{% load django_markup %}
|
||||
<div style="padding: 1em">
|
||||
{% if block_count > 0 %}
|
||||
{% if max_count == -1 %}
|
||||
<p>
|
||||
{% filter force_escape %}
|
||||
{% blocktrans count num_selected=block_count %}
|
||||
Learners will see the selected component:
|
||||
{% plural %}
|
||||
Learners will see all of the {{ num_selected }} selected components, in random order:
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
{% filter force_escape %}
|
||||
{% blocktrans with max_count=max_count count num_selected=block_count %}
|
||||
Learners will see the selected component:
|
||||
{% plural %}
|
||||
Learners will see {{ max_count }} of the {{ num_selected }} selected components:
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<ol style="list-style: decimal; margin-left: 2em;">
|
||||
{% for block in blocks %}
|
||||
<li>{{ block.display_name }}</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
<p style="color: var(--gray);">
|
||||
{% blocktrans trimmed asvar view_msg %}
|
||||
Press {link_start}View{link_end} to preview, sync/update, and/or remove the selected components.
|
||||
{% endblocktrans %}
|
||||
{% interpolate_html view_msg link_start=view_link|safe link_end='</a>'|safe %}
|
||||
</p>
|
||||
<p style="color: var(--gray);">
|
||||
{% blocktrans trimmed asvar edit_msg %}
|
||||
Press {link_start}Edit{link_end} to configure how many will be shown and other settings.
|
||||
{% endblocktrans %}
|
||||
{% interpolate_html edit_msg link_start='<a role="button" href="#" class="edit-button action-button">'|safe link_end='</a>'|safe %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p>{% trans "You have not selected any components yet." as tmsg %}{{tmsg|force_escape}}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
14
xmodule/templates/item_bank/author_view_add.html
Normal file
14
xmodule/templates/item_bank/author_view_add.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% load i18n %}
|
||||
{% load django_markup %}
|
||||
<div class="insert-new-lib-blocks-here"></div>
|
||||
<div class="xblock-header-secondary">
|
||||
{% comment %}
|
||||
How this button works: An event handler in cms/static/js/views/pages/container.js
|
||||
will watch for clicks and then display the SelectV2LibraryContent modal and process
|
||||
the list of selected blocks returned from the modal.
|
||||
{% endcomment %}
|
||||
{% blocktrans trimmed asvar tmsg %}
|
||||
{button_start}Add components{button_end} from a content library to this problem bank.
|
||||
{% endblocktrans %}
|
||||
{% interpolate_html tmsg button_start='<button class="btn btn-primary problem-bank-v2-add-button"><span class="icon fa fa-plus" aria-hidden="true"></span> '|safe button_end='</button>'|safe %}
|
||||
</div>
|
||||
@@ -192,10 +192,11 @@ class TestItemBankForCms(ItemBankTestBase):
|
||||
""" Test author view rendering """
|
||||
self._bind_course_block(self.item_bank)
|
||||
rendered = self.item_bank.render(AUTHOR_VIEW, {})
|
||||
assert '' == rendered.content
|
||||
# content should be empty
|
||||
assert 'LibraryContentAuthorView' == rendered.js_init_fn
|
||||
# but some js initialization should happen
|
||||
assert 'Learners will see 1 of the 4 selected components' in rendered.content
|
||||
assert '<li>My Item 0</li>' in rendered.content
|
||||
assert '<li>My Item 1</li>' in rendered.content
|
||||
assert '<li>My Item 2</li>' in rendered.content
|
||||
assert '<li>My Item 3</li>' in rendered.content
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
|
||||
Reference in New Issue
Block a user