diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index bb692c016033..33351b94db9f 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -106,7 +106,7 @@ def _refresh_children(self, lib_content_block, status_code_expected=200): lib_content_block.runtime._services['user'] = user_service # pylint: disable=protected-access handler_url = reverse_usage_url( - 'component_handler', + 'preview_handler', lib_content_block.location, kwargs={'handler': 'refresh_children'} ) @@ -359,8 +359,6 @@ def test_change_after_first_sync(self): self.assertEqual(resp.status_code, 200) lc_block = modulestore().get_item(lc_block.location) self.assertEqual(len(lc_block.children), 1) # Children should not be deleted due to a bad setting. - html_block = modulestore().get_item(lc_block.children[0]) - self.assertEqual(html_block.data, data_value) def test_refreshes_children_if_libraries_change(self): """ Tests that children are automatically refreshed if libraries list changes """ @@ -406,7 +404,7 @@ def test_refreshes_children_if_libraries_change(self): html_block = modulestore().get_item(lc_block.children[0]) self.assertEqual(html_block.data, data2) - @patch("xmodule.library_tools.SearchEngine.get_search_engine", Mock(return_value=None, autospec=True)) + @patch("xmodule.tasks.SearchEngine.get_search_engine", Mock(return_value=None, autospec=True)) def test_refreshes_children_if_capa_type_change(self): """ Tests that children are automatically refreshed if capa type field changes """ name1, name2 = "Option Problem", "Multiple Choice Problem" @@ -993,23 +991,21 @@ def test_duplicated_version(self): self.library = store.get_library(self.lib_key) # Refresh our reference to the block - self.lc_block = store.get_item(self.lc_block.location) + self.lc_block = self._refresh_children(self.lc_block) self.problem_in_course = store.get_item(self.problem_in_course.location) # The library has changed... self.assertEqual(len(self.library.children), 2) - # But the block hasn't. - self.assertEqual(len(self.lc_block.children), 1) - self.assertEqual(self.problem_in_course.location, self.lc_block.children[0]) - self.assertEqual(self.problem_in_course.display_name, self.original_display_name) + # and the block has changed too. + self.assertEqual(len(self.lc_block.children), 2) # Duplicate self.lc_block: duplicate = store.get_item( duplicate_block(self.course.location, self.lc_block.location, self.user) ) # The duplicate should have identical children to the original: - self.assertEqual(len(duplicate.children), 1) + self.assertEqual(len(duplicate.children), 2) self.assertTrue(self.lc_block.source_library_version) self.assertEqual(self.lc_block.source_library_version, duplicate.source_library_version) problem2_in_course = store.get_item(duplicate.children[0]) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 148b259898cc..e20642b72ee5 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -195,6 +195,9 @@ def container_handler(request, usage_key_string): # Get the status of the user's clipboard so they can paste components if they have something to paste user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) + library_block_types = [problem_type['component'] for problem_type in LIBRARY_BLOCK_TYPES] + is_library_xblock = xblock.location.block_type in library_block_types + return render_to_response('container.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course, # Needed only for display of menus at top of page. @@ -203,6 +206,7 @@ def container_handler(request, usage_key_string): 'xblock_locator': xblock.location, 'unit': unit, 'is_unit_page': is_unit_page, + 'is_collapsible': is_library_xblock, 'subsection': subsection, 'section': section, 'position': index, @@ -218,6 +222,8 @@ def container_handler(request, usage_key_string): 'templates': CONTAINER_TEMPLATES, # Status of the user's clipboard, exactly as would be returned from the "GET clipboard" REST API. 'user_clipboard': user_clipboard, + 'is_sourced_block': xblock.location.block_type == 'library_sourced', + 'is_fullwidth_content': is_library_xblock, }) else: return HttpResponseBadRequest("Only supports HTML requests") diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 5ee2740abe78..3729b8e627b9 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -45,7 +45,7 @@ wrap_xblock_aside ) -from ..utils import get_visibility_partition_info +from ..utils import get_visibility_partition_info, StudioPermissionsService from .access import get_user_role from .session_kv_store import SessionKeyValueStore @@ -198,6 +198,7 @@ def _prepare_runtime_for_preview(request, block): deprecated_anonymous_user_id = anonymous_id_for_user(request.user, None) services = { + "studio_user_permissions": StudioPermissionsService(request.user), "i18n": XBlockI18nService, 'mako': mako_service, "settings": SettingsService(), @@ -310,6 +311,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'is_reorderable': is_reorderable, 'can_edit': can_edit, 'can_edit_visibility': context.get('can_edit_visibility', is_course), + 'is_loading': context.get('is_loading', 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_move': context.get('can_move', is_course), diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index ce5771ef83e8..b415b86a088d 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -211,7 +211,7 @@ def test_get_empty_container_fragment(self): self.assertNotRegex(html, r"wrapper-xblock[^-]+") # Verify that the header and article tags are still added - self.assertIn('
', html) + self.assertIn('
', html) self.assertIn('
', html) def test_get_container_fragment(self): diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index f7d369630769..34cde1950778 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -178,6 +178,7 @@ def handle_xblock(request, usage_key_string=None): the public studio content API. """ if usage_key_string: + usage_key = usage_key_with_run(usage_key_string) access_check = ( @@ -218,7 +219,10 @@ def handle_xblock(request, usage_key_string=None): _delete_item(usage_key, request.user) return JsonResponse() else: # Since we have a usage_key, we are updating an existing xblock. - return modify_xblock(usage_key, request) + modified_xblock = modify_xblock(usage_key, request) + _post_editor_saved_callback(get_xblock(usage_key, request.user)) + return modified_xblock + elif request.method in ("PUT", "POST"): if "duplicate_source_locator" in request.json: @@ -226,7 +230,6 @@ def handle_xblock(request, usage_key_string=None): duplicate_source_usage_key = usage_key_with_run( request.json["duplicate_source_locator"] ) - source_course = duplicate_source_usage_key.course_key dest_course = parent_usage_key.course_key if not has_studio_write_access( @@ -253,6 +256,8 @@ def handle_xblock(request, usage_key_string=None): request.user, request.json.get("display_name"), ) + _post_editor_saved_callback(get_xblock(dest_usage_key, request.user)) + return JsonResponse( { "locator": str(dest_usage_key), @@ -296,7 +301,6 @@ def handle_xblock(request, usage_key_string=None): def modify_xblock(usage_key, request): request_data = request.json - print(f'In modify_xblock with data = {request_data.get("data")}, fields = {request_data.get("fields")}') return _save_xblock( request.user, get_xblock(usage_key, request.user), @@ -372,11 +376,19 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None): return modulestore().update_item(xblock, user.id) -def _save_xblock( # lint-amnesty, pylint: disable=too-many-statements +def _post_editor_saved_callback(xblock): + """ + Updates the xblock in the modulestore after saving xblock. + """ + if callable(getattr(xblock, "post_editor_saved", None)): + xblock.post_editor_saved() + + +def _save_xblock( user, xblock, data=None, - children_strings=None, + children_strings=None, # lint-amnesty, pylint: disable=too-many-statements metadata=None, nullout=None, grader_type=None, diff --git a/cms/envs/common.py b/cms/envs/common.py index cbc100bcabe6..0fe6c07f141b 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1994,8 +1994,10 @@ ] LIBRARY_BLOCK_TYPES = [ - # Per https://github.com/openedx/build-test-release-wg/issues/231 - # we removed the library source content block from defaults until complete. + { + 'component': 'library_sourced', + 'boilerplate_name': None + }, { 'component': 'library_content', 'boilerplate_name': None diff --git a/cms/lib/xblock/tagging/test.py b/cms/lib/xblock/tagging/test.py index f00d1a53c89b..173523452d54 100644 --- a/cms/lib/xblock/tagging/test.py +++ b/cms/lib/xblock/tagging/test.py @@ -148,9 +148,12 @@ def test_preview_html(self): tree = etree.parse(StringIO(problem_html), parser) main_div_nodes = tree.xpath('/html/body/div/section/div') - self.assertEqual(len(main_div_nodes), 1) + self.assertEqual(len(main_div_nodes), 2) - div_node = main_div_nodes[0] + loader_div_node = main_div_nodes[0] + self.assertIn('ui-loading', loader_div_node.get('class')) + + div_node = main_div_nodes[1] self.assertEqual(div_node.get('data-init'), 'StructuredTagsInit') self.assertEqual(div_node.get('data-runtime-class'), 'PreviewRuntime') self.assertEqual(div_node.get('data-block-type'), 'tagging_aside') diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 83a3c177d13b..4889f621a9b0 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -7,11 +7,14 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page 'js/views/components/add_xblock', 'js/views/modals/edit_xblock', 'js/views/modals/move_xblock_modal', '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', + 'common/js/components/views/feedback_notification', 'common/js/components/views/feedback_prompt', 'js/utils/module', ], -function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent, - EditXBlockModal, MoveXBlockModal, XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor, - ContainerSubviews, UnitOutlineView, XBlockUtils, NotificationView, PromptView) { +function($, _, Backbone, gettext, BasePage, + ViewUtils, ContainerView, XBlockView, + AddXBlockComponent, EditXBlockModal, MoveXBlockModal, + XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor, + ContainerSubviews, UnitOutlineView, XBlockUtils, + NotificationView, PromptView, ModuleUtils) { 'use strict'; var XBlockContainerPage = BasePage.extend({ @@ -26,7 +29,10 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView 'click .delete-button': 'deleteXBlock', 'click .show-actions-menu-button': 'showXBlockActionsMenu', 'click .new-component-button': 'scrollToNewComponentButtons', + 'click .save-button': 'saveSelectedLibraryComponents', 'click .paste-component-button': 'pasteComponent', + 'change .header-library-checkbox': 'toggleLibraryComponent', + 'click .collapse-button': 'collapseXBlock', }, options: { @@ -48,6 +54,7 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView BasePage.prototype.initialize.call(this, options); this.viewClass = options.viewClass || this.defaultViewClass; this.isLibraryPage = (this.model.attributes.category === 'library'); + this.isLibrarySourced = (this.model.attributes.category === 'library_sourced'); this.nameEditor = new XBlockStringFieldEditor({ el: this.$('.wrapper-xblock-field'), model: this.model @@ -101,6 +108,12 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView model: this.model }); this.unitOutlineView.render(); + + } + if (this.isLibrarySourced) { + this.selectedLibraryComponents = []; + this.storedSelectedLibraryComponents = []; + this.getSelectedLibraryComponents(); } this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved); @@ -500,6 +513,78 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView }); }, + duplicateXBlock: function(event) { + event.preventDefault(); + this.duplicateComponent(this.findXBlockElement(event.target)); + }, + + showMoveXBlockModal: function(event) { + var xblockElement = this.findXBlockElement(event.target), + parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'), + modal = new MoveXBlockModal({ + sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model), + sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model), + XBlockURLRoot: this.getURLRoot(), + outlineURL: this.options.outlineURL + }); + + event.preventDefault(); + modal.show(); + }, + + deleteXBlock: function(event) { + event.preventDefault(); + this.deleteComponent(this.findXBlockElement(event.target)); + }, + + createPlaceholderElement: function() { + return $('
', {class: 'studio-xblock-wrapper'}); + }, + + createComponent: function(template, target) { + // A placeholder element is created in the correct location for the new xblock + // and then onNewXBlock will replace it with a rendering of the xblock. Note that + // for xblocks that can't be replaced inline, the entire parent will be refreshed. + var parentElement = this.findXBlockElement(target), + parentLocator = parentElement.data('locator'), + buttonPanel = target.closest('.add-xblock-component'), + listPanel = buttonPanel.prev(), + scrollOffset = ViewUtils.getScrollOffset(buttonPanel), + $placeholderEl = $(this.createPlaceholderElement()), + requestData = _.extend(template, { + parent_locator: parentLocator + }), + placeholderElement; + placeholderElement = $placeholderEl.appendTo(listPanel); + return $.postJSON(this.getURLRoot() + '/', requestData, + _.bind(this.onNewXBlock, this, placeholderElement, scrollOffset, false)) + .fail(function() { + // Remove the placeholder if the update failed + placeholderElement.remove(); + }); + }, + + duplicateComponent: function(xblockElement) { + // A placeholder element is created in the correct location for the duplicate xblock + // and then onNewXBlock will replace it with a rendering of the xblock. Note that + // for xblocks that can't be replaced inline, the entire parent will be refreshed. + var self = this, + parentElement = self.findXBlockElement(xblockElement.parent()), + scrollOffset = ViewUtils.getScrollOffset(xblockElement), + $placeholderEl = $(self.createPlaceholderElement()), + placeholderElement; + + placeholderElement = $placeholderEl.insertAfter(xblockElement); + XBlockUtils.duplicateXBlock(xblockElement, parentElement) + .done(function(data) { + self.onNewXBlock(placeholderElement, scrollOffset, true, data); + }) + .fail(function() { + // Remove the placeholder if the update failed + placeholderElement.remove(); + }); + }, + deleteComponent: function(xblockElement) { var self = this, xblockInfo = new XBlockInfo({ @@ -510,6 +595,61 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView }); }, + getSelectedLibraryComponents: function() { + var self = this; + var locator = this.$el.find('.studio-xblock-wrapper').data('locator'); + console.log(ModuleUtils); + $.getJSON( + ModuleUtils.getUpdateUrl(locator) + '/handler/get_block_ids', + function(data) { + self.selectedLibraryComponents = Array.from(data.source_block_ids); + self.storedSelectedLibraryComponents = Array.from(data.source_block_ids); + } + ); + }, + + saveSelectedLibraryComponents: function(e) { + var self = this; + var locator = this.$el.find('.studio-xblock-wrapper').data('locator'); + e.preventDefault(); + $.postJSON( + ModuleUtils.getUpdateUrl(locator) + '/handler/submit_studio_edits', + {values: {source_block_ids: self.storedSelectedLibraryComponents}}, + function() { + self.selectedLibraryComponents = Array.from(self.storedSelectedLibraryComponents); + self.toggleSaveButton(); + } + ); + }, + + toggleLibraryComponent: function(event) { + var componentId = $(event.target).closest('.studio-xblock-wrapper').data('locator'); + var storeIndex = this.storedSelectedLibraryComponents.indexOf(componentId); + if (storeIndex > -1) { + this.storedSelectedLibraryComponents.splice(storeIndex, 1); + this.toggleSaveButton(); + } else { + this.storedSelectedLibraryComponents.push(componentId); + this.toggleSaveButton(); + } + }, + + toggleSaveButton: function() { + var $saveButton = $('.nav-actions .save-button'); + if (JSON.stringify(this.selectedLibraryComponents.sort()) === JSON.stringify(this.storedSelectedLibraryComponents.sort())) { + $saveButton.addClass('is-hidden'); + window.removeEventListener('beforeunload', this.onBeforePageUnloadCallback); + } else { + $saveButton.removeClass('is-hidden'); + window.addEventListener('beforeunload', this.onBeforePageUnloadCallback); + } + }, + + onBeforePageUnloadCallback: function (event) { + event.preventDefault(); + event.returnValue = ''; + }, + onDelete: function(xblockElement) { // get the parent so we can remove this component from its parent. var xblockView = this.xblockView, diff --git a/cms/static/sass/elements/_layout.scss b/cms/static/sass/elements/_layout.scss index d4240b450f47..ae1090fe4623 100644 --- a/cms/static/sass/elements/_layout.scss +++ b/cms/static/sass/elements/_layout.scss @@ -223,6 +223,10 @@ box-shadow: none; border: 0; background-color: $white; + + &-fullwidth { + width: flex-grid(12, 12); + } } .content-supplementary { diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index 87dcb8c7d7c6..119a14826c63 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -43,6 +43,19 @@ display: flex; align-items: center; + .header-library-checkbox { + margin-right: 10px; + width: 17px; + height: 17px; + cursor: pointer; + vertical-align: middle; + } + + .header-library-checkbox-label { + vertical-align: middle; + cursor: pointer; + } + .header-details { @extend %cont-truncated; @@ -433,7 +446,17 @@ border-color: $blue; } - .xblock-header { + &.is-collapsed { + .xblock-render { + display: none; + } + + .collapse-button .fa { + transform: scale(1, -1); + } + } + + .xblock-header:not(.is-hidden) { display: block; } diff --git a/cms/templates/container.html b/cms/templates/container.html index 89cb4bbfd62f..3b1108d84124 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -145,12 +145,27 @@

${_("Page Actions")}

% else: + % if is_sourced_block: + + % endif + % if is_collapsible: + + % endif % endif @@ -163,8 +178,7 @@

${_("Page Actions")}

- -
+
diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index fa48a905cca4..229be95ed6cc 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -17,10 +17,10 @@ xblock_url = xblock_studio_url(xblock) show_inline = xblock.has_children and not xblock_url section_class = "level-nesting" if show_inline else "level-element" -collapsible_class = "is-collapsible" if xblock.has_children else "" label = determine_label(xblock.display_name_with_default, xblock.scope_ids.block_type) messages = xblock.validate().to_json() block_is_unit = is_unit(xblock) +is_sourced_block = xblock.location.block_type == 'library_sourced' %> <%namespace name='static' file='static_content.html'/> @@ -48,14 +48,17 @@
% endif -
+
+

${_("Importing components")}

+
% endif -
+