diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index 91dc14983b86..5bebe5b1f760 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,27 +991,23 @@ 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_item(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]) - self.assertEqual(problem2_in_course.display_name, self.original_display_name) class TestIncompatibleModuleStore(LibraryTestCase): diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index c8173c72283f..6cda65f46534 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -41,12 +41,14 @@ log = logging.getLogger(__name__) # NOTE: This list is disjoint from ADVANCED_COMPONENT_TYPES -COMPONENT_TYPES = ['discussion', 'html', 'openassessment', 'problem', 'video'] +COMPONENT_TYPES = ['discussion', 'library', 'html', 'openassessment', 'problem', 'video'] ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES)) ADVANCED_PROBLEM_TYPES = settings.ADVANCED_PROBLEM_TYPES +LIBRARY_BLOCK_TYPES = settings.LIBRARY_BLOCK_TYPES + CONTAINER_TEMPLATES = [ "basic-modal", "modal-button", "edit-xblock-modal", "editor-mode-button", "upload-dialog", @@ -202,7 +204,8 @@ def container_handler(request, usage_key_string): 'xblock_info': xblock_info, 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, - 'templates': CONTAINER_TEMPLATES + 'templates': CONTAINER_TEMPLATES, + 'is_sourced_block': xblock.location.block_type == 'library_sourced', }) else: return HttpResponseBadRequest("Only supports HTML requests") @@ -278,7 +281,8 @@ def create_support_legend_dict(): 'html': _("Text"), 'problem': _("Problem"), 'video': _("Video"), - 'openassessment': _("Open Response") + 'openassessment': _("Open Response"), + 'library': _("Library Content"), } component_templates = [] @@ -287,8 +291,8 @@ def create_support_legend_dict(): # by the components in the order listed in COMPONENT_TYPES. component_types = COMPONENT_TYPES[:] - # Libraries do not support discussions and openassessment - component_not_supported_by_library = ['discussion', 'openassessment'] + # Libraries do not support discussions and openassessment and other libraries + component_not_supported_by_library = ['discussion', 'library', 'openassessment'] if library: component_types = [component for component in component_types if component not in set(component_not_supported_by_library)] @@ -383,6 +387,37 @@ def create_support_legend_dict(): ) categories.add(component) + # Add library block types. + if category == 'library' and not library: + disabled_block_names = [block.name for block in disabled_xblocks()] + library_block_types = [problem_type for problem_type in LIBRARY_BLOCK_TYPES + if problem_type['component'] not in disabled_block_names] + for library_block_type in library_block_types: + component = library_block_type['component'] + boilerplate_name = library_block_type['boilerplate_name'] + authorable_variations = authorable_xblocks(allow_unsupported=allow_unsupported, name=component) + library_component_support_level = component_support_level( + authorable_variations, component, boilerplate_name + ) + if library_component_support_level: + try: + component_display_name = xblock_type_display_name(component, default_display_name=component) + except PluginMissingError: + log.warning( + "Unable to load xblock type %s to read display_name", + component + ) + else: + templates_for_category.append( + create_template_dict( + component_display_name, + component, + library_component_support_level, + boilerplate_name + ) + ) + categories.add(component) + component_templates.append({ "type": category, "templates": templates_for_category, diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 2ef9b80d38b2..bf6e40ff89c9 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -198,7 +198,7 @@ def xblock_handler(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 _save_xblock( + response = _save_xblock( request.user, _get_xblock(usage_key, request.user), data=request.json.get('data'), @@ -213,6 +213,8 @@ def xblock_handler(request, usage_key_string=None): publish=request.json.get('publish'), fields=request.json.get('fields'), ) + _post_editor_saved_callback(_get_xblock(usage_key, request.user)) + return response elif request.method in ('PUT', 'POST'): if 'duplicate_source_locator' in request.json: parent_usage_key = usage_key_with_run(request.json['parent_locator']) @@ -528,6 +530,14 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None): return modulestore().update_item(xblock, user.id) +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, metadata=None, nullout=None, # lint-amnesty, pylint: disable=too-many-statements grader_type=None, is_prereq=None, prereq_usage_key=None, prereq_min_score=None, prereq_min_completion=None, publish=None, fields=None): diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index fd0bdf0dc57a..2a66dd2fe8d6 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -206,6 +206,9 @@ def _preview_module_system(request, descriptor, field_data): else: preview_anonymous_user_id = anonymous_id_for_user(request.user, course_id) + # Avoid circular import issues + from .item import StudioPermissionsService + return PreviewModuleSystem( static_url=settings.STATIC_URL, # TODO (cpennington): Do we want to track how instructors are using the preview problems? @@ -220,6 +223,7 @@ def _preview_module_system(request, descriptor, field_data): # Get the raw DescriptorSystem, not the CombinedSystem descriptor_runtime=descriptor._runtime, # pylint: disable=protected-access services={ + "studio_user_permissions": StudioPermissionsService(request.user), "field-data": field_data, "i18n": ModuleI18nService, 'mako': mako_service, @@ -309,6 +313,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'content': frag.content, 'is_root': is_root, 'is_reorderable': is_reorderable, + 'is_loading': context.get('is_loading', False), + 'is_selected': context.get('is_selected', False), + 'selectable': context.get('selectable', False), 'can_edit': context.get('can_edit', True), 'can_edit_visibility': context.get('can_edit_visibility', xblock.scope_ids.usage_id.context_key.is_course), 'selected_groups_label': selected_groups_label, diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index f182d02d3e6e..02c6705d6ccc 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -215,7 +215,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): @@ -232,7 +232,7 @@ def test_get_container_fragment(self): # Verify that the Studio nesting wrapper has been added self.assertIn('level-nesting', html) - self.assertIn('
', html) + self.assertIn('
', html) self.assertIn('
', html) # Verify that the Studio element wrapper has been added @@ -2253,6 +2253,9 @@ def setUp(self): XBlockStudioConfiguration.objects.create(name='video', enabled=True, support_level="us") # ORA Block has it's own category. XBlockStudioConfiguration.objects.create(name='openassessment', enabled=True, support_level="us") + # Library Sourced Block and Library Content block has it's own category. + XBlockStudioConfiguration.objects.create(name='library_sourced', enabled=True, support_level="fs") + XBlockStudioConfiguration.objects.create(name='library_content', enabled=True, support_level="fs") # XBlock masquerading as a problem XBlockStudioConfiguration.objects.create(name='drag-and-drop-v2', enabled=True, support_level="fs") XBlockStudioConfiguration.objects.create(name='staffgradedxblock', enabled=True, support_level="us") @@ -2295,6 +2298,7 @@ def test_basic_components(self): self._verify_basic_component_display_name("discussion", "Discussion") self._verify_basic_component_display_name("video", "Video") self._verify_basic_component_display_name("openassessment", "Open Response") + self.assertGreater(len(self.get_templates_of_type('library')), 0) self.assertGreater(len(self.get_templates_of_type('html')), 0) self.assertGreater(len(self.get_templates_of_type('problem')), 0) self.assertIsNone(self.get_templates_of_type('advanced')) diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index b99dbd3b22df..746e61cb3126 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -337,6 +337,7 @@ def test_get_component_templates(self): self.assertNotIn('discussion', templates) self.assertNotIn('advanced', templates) self.assertNotIn('openassessment', templates) + self.assertNotIn('library', templates) def test_advanced_problem_types(self): """ diff --git a/cms/envs/common.py b/cms/envs/common.py index f7f8a2fc208a..cdb597b6700d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1975,6 +1975,17 @@ } ] +LIBRARY_BLOCK_TYPES = [ + { + 'component': 'library_sourced', + 'boilerplate_name': None + }, + { + 'component': 'library_content', + 'boilerplate_name': None + } +] + ############### Settings for Retirement ##################### # See annotations in lms/envs/common.py for details. RETIRED_USERNAME_PREFIX = 'retired__user_' diff --git a/cms/lib/xblock/tagging/test.py b/cms/lib/xblock/tagging/test.py index db49d0f2550a..57bbee5f2184 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/images/large-library-icon.png b/cms/static/images/large-library-icon.png new file mode 100644 index 000000000000..c9f56224926a Binary files /dev/null and b/cms/static/images/large-library-icon.png differ diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 347f7ea77b7f..97a61c4571f0 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -6,10 +6,10 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page 'common/js/components/utils/view_utils', 'js/views/container', 'js/views/xblock', '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'], + 'js/views/pages/container_subviews', 'js/views/unit_outline', 'js/views/utils/xblock_utils', 'js/utils/module'], function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, MoveXBlockModal, XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor, - ContainerSubviews, UnitOutlineView, XBlockUtils) { + ContainerSubviews, UnitOutlineView, XBlockUtils, ModuleUtils) { 'use strict'; var XBlockContainerPage = BasePage.extend({ // takes XBlockInfo as a model @@ -20,7 +20,9 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page 'click .duplicate-button': 'duplicateXBlock', 'click .move-button': 'showMoveXBlockModal', 'click .delete-button': 'deleteXBlock', - 'click .new-component-button': 'scrollToNewComponentButtons' + 'click .save-button': 'saveSelectedLibraryComponents', + 'click .new-component-button': 'scrollToNewComponentButtons', + 'change .header-library-checkbox': 'toggleLibraryComponent' }, options: { @@ -42,6 +44,7 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page 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 @@ -96,6 +99,11 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page }); this.unitOutlineView.render(); } + if (this.isLibrarySourced) { + this.selectedLibraryComponents = []; + this.storedSelectedLibraryComponents = []; + this.getSelectedLibraryComponents(); + } this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved); }, @@ -300,6 +308,60 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page }); }, + getSelectedLibraryComponents: function() { + var self = this; + var locator = this.$el.find('.studio-xblock-wrapper').data('locator'); + $.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/assets/_graphics.scss b/cms/static/sass/assets/_graphics.scss index 881445f0d82a..5839711197cd 100644 --- a/cms/static/sass/assets/_graphics.scss +++ b/cms/static/sass/assets/_graphics.scss @@ -52,3 +52,10 @@ height: ($baseline*3); background: url('#{$static-path}/images/large-openassessment-icon.png') center no-repeat; } + +.large-library-icon { + display: inline-block; + width: ($baseline*3); + height: ($baseline*3); + background: url('#{$static-path}/images/large-library-icon.png') center no-repeat; +} diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index d6e8aff5d66a..0c3a77378d07 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; @@ -415,7 +428,7 @@ border-color: $blue; } - .xblock-header { + .xblock-header:not(.is-hidden) { display: block; } diff --git a/cms/templates/container.html b/cms/templates/container.html index 17de3d20ea76..6310e39d3e2d 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -144,6 +144,13 @@

${_("Page Actions")}

% else: + % if is_sourced_block: + + % endif