diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 5aabca4c90..dc5dd26e94 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -159,3 +159,19 @@ def individualize_anonymous_user_id(course_id): Returns a boolean if individualized anonymous_user_id is enabled on the course """ return INDIVIDUALIZE_ANONYMOUS_USER_ID.is_enabled(course_id) + + +# .. toggle_name: contentstore.enable_copy_paste_feature +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Moves most component-level actions into a submenu and adds new "Copy Component" and "Paste +# Component" actions which can be used to copy components (XBlocks) within or among courses. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-02-28 +# .. toggle_target_removal_date: 2023-05-01 +# .. toggle_tickets: https://github.com/openedx/modular-learning/issues/11 https://github.com/openedx/modular-learning/issues/50 +ENABLE_COPY_PASTE_FEATURE = WaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_copy_paste_feature', + __name__, + CONTENTSTORE_LOG_PREFIX, +) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index b3297d86c7..c65e00a0f3 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -26,7 +26,7 @@ from xmodule.util.sandboxing import SandboxService from xmodule.util.xmodule_django import add_webpack_to_fragment from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, ModuleSystem from cms.djangoapps.xblock_config.models import StudioConfig -from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id +from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id, ENABLE_COPY_PASTE_FEATURE from cms.lib.xblock.field_data import CmsFieldData from common.djangoapps.static_replace.services import ReplaceURLService from common.djangoapps.static_replace.wrapper import replace_urls_wrapper @@ -298,6 +298,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): if selected_groups_label: 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) + # Copy-paste is a new feature; while we are beta-testing it, only beta users with the Waffle flag enabled see it + enable_copy_paste = can_edit and ENABLE_COPY_PASTE_FEATURE.is_enabled() template_context = { 'xblock_context': context, 'xblock': xblock, @@ -305,7 +308,8 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'content': frag.content, 'is_root': is_root, 'is_reorderable': is_reorderable, - 'can_edit': context.get('can_edit', True), + 'can_edit': can_edit, + 'enable_copy_paste': enable_copy_paste, 'can_edit_visibility': context.get('can_edit_visibility', xblock.scope_ids.usage_id.context_key.is_course), 'selected_groups_label': selected_groups_label, 'can_add': context.get('can_add', True), diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index e545a74f0c..4ce217ff0d 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -18,8 +18,10 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView 'click .edit-button': 'editXBlock', 'click .access-button': 'editVisibilitySettings', 'click .duplicate-button': 'duplicateXBlock', + 'click .copy-button': 'copyXBlock', 'click .move-button': 'showMoveXBlockModal', 'click .delete-button': 'deleteXBlock', + 'click .show-actions-menu-button': 'showXBlockActionsMenu', 'click .new-component-button': 'scrollToNewComponentButtons' }, @@ -213,6 +215,23 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView }); }, + /** + * If the new "Actions" menu is enabled, most XBlock actions like + * Duplicate, Move, Delete, Manage Access, etc. are moved into this + * menu. For this event, we just toggle displaying the menu. + * @param {*} event + */ + showXBlockActionsMenu: function(event) { + const showActionsButton = event.currentTarget; + const subMenu = showActionsButton.parentElement.querySelector(".wrapper-nav-sub"); + // Code in 'base.js' normally handles toggling these dropdowns but since this one is + // not present yet during the domReady event, we have to handle displaying it ourselves. + subMenu.classList.toggle("is-shown"); + // if propagation is not stopped, the event will bubble up to the + // body element, which will close the dropdown. + event.stopPropagation(); + }, + editVisibilitySettings: function(event) { this.editXBlock(event, { view: 'visibility_view', @@ -274,6 +293,12 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView }); }, + copyXBlock: function(event) { + event.preventDefault(); + // This is a new feature, hidden behind a feature flag. + alert("Copying of XBlocks is coming soon."); + }, + 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 @@ -320,8 +345,8 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView }, /* - After move operation is complete, updates the xblock information from server . - */ + * After move operation is complete, updates the xblock information from server . + */ onXBlockMoved: function() { this.model.fetch(); }, @@ -350,12 +375,12 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView }, /** - * Refreshes the specified xblock's display. If the xblock is an inline child of a - * reorderable container then the element will be refreshed inline. If not, then the - * parent container will be refreshed instead. - * @param element An element representing the xblock to be refreshed. - * @param block_added Flag to indicate that new block has been just added. - */ + * Refreshes the specified xblock's display. If the xblock is an inline child of a + * reorderable container then the element will be refreshed inline. If not, then the + * parent container will be refreshed instead. + * @param element An element representing the xblock to be refreshed. + * @param block_added Flag to indicate that new block has been just added. + */ refreshXBlock: function(element, block_added, is_duplicate) { var xblockElement = this.findXBlockElement(element), parentElement = xblockElement.parent(), @@ -370,13 +395,13 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView }, /** - * Refresh an xblock element inline on the page, using the specified xblockInfo. - * Note that the element is removed and replaced with the newly rendered xblock. - * @param xblockElement The xblock element to be refreshed. - * @param block_added Specifies if a block has been added, rather than just needs - * refreshing. - * @returns {jQuery promise} A promise representing the complete operation. - */ + * Refresh an xblock element inline on the page, using the specified xblockInfo. + * Note that the element is removed and replaced with the newly rendered xblock. + * @param xblockElement The xblock element to be refreshed. + * @param block_added Specifies if a block has been added, rather than just needs + * refreshing. + * @returns {jQuery promise} A promise representing the complete operation. + */ refreshChildXBlock: function(xblockElement, block_added, is_duplicate) { var self = this, xblockInfo, diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index d6e8aff5d6..b8633d1a8a 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -68,6 +68,24 @@ width: 49%; @include text-align(right); + + // On components, if the copy/paste feature flag is enabled, we put the actions into a dropdown menu. + .wrapper-nav-sub { + @include text-align(left); // Undo the 'text-align: right' inherited from the parent + + z-index: 10; // Stay in front of things like the video xblock or the "add component" buttons + + .nav-item { + a { + // Match styling of ".wrapper-header nav .nav-item a" (dropdowns in Studio header) + color: $gray-d1; + + &:hover { + color: $uxpl-blue-hover-active; + } + } + } + } } } diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html index 5c2c20f769..81a08a4d01 100644 --- a/cms/templates/studio_xblock_wrapper.html +++ b/cms/templates/studio_xblock_wrapper.html @@ -82,7 +82,7 @@ block_is_unit = is_unit(xblock)