Skip to content

Commit

Permalink
Merge pull request #35679 from open-craft/braden/pool-ui
Browse files Browse the repository at this point in the history
"Bare Bones" UI for Problem Bank [FC-0062]

Part of: openedx/frontend-app-authoring#1385
  • Loading branch information
kdmccormick authored Oct 23, 2024
2 parents 08c8e9d + 9e28ba9 commit e0c56aa
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 26 deletions.
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,9 @@
'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',

Expand Down
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;
});
44 changes: 42 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,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
Expand Down
3 changes: 1 addition & 2 deletions cms/templates/studio_xblock_wrapper.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,7 @@
</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>
Expand Down
42 changes: 27 additions & 15 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 @@ -33,6 +35,7 @@
_ = lambda text: text

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


@XBlock.needs('mako')
Expand Down Expand Up @@ -461,9 +464,9 @@ 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 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,
Expand All @@ -475,7 +478,7 @@ def validate(self):
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,
Expand All @@ -484,7 +487,7 @@ def validate(self):
"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
Expand All @@ -498,20 +501,29 @@ def author_view(self, context):
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]:
Expand Down
46 changes: 46 additions & 0 deletions xmodule/templates/item_bank/author_view.html
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions xmodule/templates/item_bank/author_view_add.html
Original file line number Diff line number Diff line change
@@ -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>
9 changes: 5 additions & 4 deletions xmodule/tests/test_item_bank.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,11 @@ def test_author_view(self):
""" 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
Expand Down

0 comments on commit e0c56aa

Please sign in to comment.