Skip to content

Commit

Permalink
feat: minimal UI for the Problem Bank block
Browse files Browse the repository at this point in the history
  • Loading branch information
bradenmacdonald committed Oct 21, 2024
1 parent 6912ddb commit 7456c0a
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 34 deletions.
68 changes: 68 additions & 0 deletions cms/static/js/views/modals/select_v2_library_content.js
Original file line number Diff line number Diff line change
@@ -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;
});
45 changes: 43 additions & 2 deletions cms/static/js/views/pages/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ 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,
AddXBlockComponent, EditXBlockModal, MoveXBlockModal,
XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor,
ContainerSubviews, UnitOutlineView, XBlockUtils,
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils,
PreviewLibraryChangesModal) {
PreviewLibraryChangesModal, SelectV2LibraryContent) {
'use strict';

var XBlockContainerPage = BasePage.extend({
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -435,6 +438,44 @@ 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");
// TODO:: the ?parentLocator param shouldn't be necessary but is currently required by the component picker
const pickerUrl = courseAuthoringMfeUrl + '/component-picker?parentLocator=' + encodeURIComponent(itemBankBlockId);

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
Expand Down
68 changes: 36 additions & 32 deletions xmodule/item_bank_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
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
Expand All @@ -34,6 +36,7 @@
_ = lambda text: text

logger = logging.getLogger(__name__)
loader = ResourceLoader(__name__)


@XBlock.needs('mako')
Expand Down Expand Up @@ -441,29 +444,19 @@ def validate(self):
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 not self.children:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.WARNING,
(_('No problems have been selected.')),
action_class='edit-button',
action_label=_("Select problems to randomize.")
if not validation.empty: # If there's already a validation error, leave it there.
if len(self.children) > 0 and len(self.children) < self.max_count:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.WARNING,
_(
"The problem bank has been configured to show {count} problems, "
"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.")
)
)
)
elif len(self.children) < self.max_count:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.WARNING,
_(
"The problem bank has been configured to show {count} problems, "
"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."),
)
)
return validation

def author_view(self, context):
Expand All @@ -475,20 +468,31 @@ def author_view(self, context):
fragment = Fragment()
root_xblock = context.get('root_xblock')
is_root = root_xblock and root_xblock.location == self.location
# 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:
max_count = self.max_count
if max_count < 0:
max_count = len(self.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', {
"item_bank_id": 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]:
Expand Down
14 changes: 14 additions & 0 deletions xmodule/templates/item_bank/author_view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div style="padding: 1em">
{% if block_count > 0 %}
<p>Learners will see {{ max_count }} of the {{ block_count }} selected components:</p>
<ol style="list-style: decimal; margin-left: 2em;">
{% for block in blocks %}
<li>{{ block.display_name }}</li>
{% endfor %}
</ol>
<p style="color: var(--gray);">Press <a href="/container/{{ item_bank_id }}">View</a> to preview, sync/update, and/or remove the selected components.</p>
<p style="color: var(--gray);">Press <a role="button" href="#" class="edit-button action-button">Edit</a> to configure how many will be shown and other settings.</p>
{% else %}
<p>You have not selected any components yet.</p>
{% endif %}
</div>
12 changes: 12 additions & 0 deletions xmodule/templates/item_bank/author_view_add.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<div class="insert-new-lib-blocks-here"></div>
<div class="xblock-header-secondary">
{% comment %}
How does this button work? 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 %}
<button class="btn btn-primary problem-bank-v2-add-button">
<span class="icon fa fa-plus" aria-hidden="true"></span>
Add components
</button> from a content library to this problem bank.
</div>

0 comments on commit 7456c0a

Please sign in to comment.