diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index d40a5ec7947..964f0c57e75 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index acc5fc95dfe..aa7421bb87b 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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, diff --git a/cms/envs/common.py b/cms/envs/common.py index 3942c9d68be..d23b040e669 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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', diff --git a/cms/static/js/views/components/add_library_content.js b/cms/static/js/views/components/add_library_content.js index ee1894b8aa9..278717ba921 100644 --- a/cms/static/js/views/components/add_library_content.js +++ b/cms/static/js/views/components/add_library_content.js @@ -1,6 +1,13 @@ /** * Provides utilities to open and close the library content picker. + * This is for adding a single, selected, non-randomized component (XBlock) + * from the library into the course. It achieves the same effect as copy-pasting + * the block from a library into the course. The block will remain synced with + * the "upstream" library version. * + * Compare cms/static/js/views/modals/select_v2_library_content.js which uses + * a multi-select modal to add component(s) to a Problem Bank (for + * randomization). */ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'], function($, _, gettext, BaseModal) { diff --git a/cms/static/js/views/modals/select_v2_library_content.js b/cms/static/js/views/modals/select_v2_library_content.js new file mode 100644 index 00000000000..76fb301540b --- /dev/null +++ b/cms/static/js/views/modals/select_v2_library_content.js @@ -0,0 +1,88 @@ +/** + * Provides utilities to open and close the library content picker. + * This is for adding multiple components to a Problem Bank (for randomization). + * + * Compare cms/static/js/views/components/add_library_content.js which uses + * a single-select modal to add one component to a course (non-randomized). + */ +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-components-from-library', + modalSize: 'lg', + view: 'studio_view', + viewSpecificClasses: 'modal-add-component-picker confirm', + titleFormat: gettext('Add library content'), + addPrimaryActionButton: false, + }), + + events: { + 'click .action-add': 'addSelectedComponents', + 'click .action-cancel': 'cancel', + }, + + initialize: function() { + BaseModal.prototype.initialize.call(this); + this.selections = []; + // Add event listen to close picker when the iframe tells us to + const handleMessage = (event) => { + if (event.data?.type === 'pickerSelectionChanged') { + this.selections = event.data.selections; + if (this.selections.length > 0) { + this.enableActionButton('add'); + } else { + this.disableActionButton('add'); + } + } + }; + 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('add', gettext('Add selected components'), true); + this.addActionButton('cancel', gettext('Cancel')); + this.disableActionButton('add'); + }, + + /** Handler when the user clicks the "Add Selected Components" primary button */ + addSelectedComponents: function(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); // Make sure parent modals don't see the click + } + this.hide(); + this.callback(this.selections); + }, + + /** + * 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 ``; + }, + }); + + return SelectV2LibraryContent; +}); diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index c49c2439473..69b28e920bd 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -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; @@ -423,6 +426,7 @@ function($, _, Backbone, gettext, BasePage, }); }, + /** Show the modal for previewing changes before syncing a library-sourced XBlock. */ showXBlockLibraryChangesPreview: function(event, options) { event.preventDefault(); @@ -435,6 +439,52 @@ function($, _, Backbone, gettext, BasePage, }); }, + /** Show the multi-select library content picker, for adding to a Problem Bank (itembank) Component */ + 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/multiple?variant=published'; + + modal.showComponentPicker(pickerUrl, (selectedBlocks) => { + // selectedBlocks has type: {usageKey: string, blockType: string}[] + let doneAddingAllBlocks = () => { this.refreshXBlock(xblockElement, false); }; + let doneAddingBlock = () => {}; + 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 $insertSpot = xblockElement.find('.insert-new-lib-blocks-here'); + doneAddingBlock = (addResult) => { + const $placeholderEl = $(this.createPlaceholderElement()); + const placeholderElement = $placeholderEl.insertBefore($insertSpot); + placeholderElement.data('locator', addResult.locator); + return this.refreshXBlock(placeholderElement, true); + }; + doneAddingAllBlocks = () => {}; + } + // Note: adding all the XBlocks in parallel will cause a race condition 😢 so we have to add + // them one at a time: + let lastAdded = $.when(); + for (const { usageKey, blockType } of selectedBlocks) { + const addData = { + library_content_key: usageKey, + category: blockType, + parent_locator: itemBankBlockId, + }; + lastAdded = lastAdded.then(() => ( + $.postJSON(this.getURLRoot() + '/', addData, doneAddingBlock) + )); + } + // Now we actually add the block: + ViewUtils.runOperationShowingMessage(gettext('Adding'), () => { + return lastAdded.done(() => { doneAddingAllBlocks() }); + }); + }); + }, + /** * If the new "Actions" menu is enabled, most XBlock actions like * Duplicate, Move, Delete, Manage Access, etc. are moved into this diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index d69ddea6687..14685963c90 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -197,8 +197,7 @@ % endif % endif - % if can_add: - + % if can_delete:
+ {% 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 %} +
+ {% else %} ++ {% 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 %} +
+ {% endif %} ++ {% 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=''|safe %} +
++ {% 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=''|safe link_end=''|safe %} +
+ {% else %} +{% trans "You have not selected any components yet." as tmsg %}{{tmsg|force_escape}}
+ {% endif %} +