diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 7a02efc078..9ae4b0271c 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -214,6 +214,22 @@ ENABLE_COPY_PASTE_FEATURE = WaffleFlag( ) +# .. toggle_name: contentstore.enable_copy_paste_units +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Moves most unit-level actions into a submenu and adds new "Copy Unit" and "Paste +# Unit" actions which can be used to copy units within or among courses. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-08-01 +# .. toggle_target_removal_date: 2023-10-01 +# .. toggle_tickets: https://github.com/openedx/modular-learning/issues/11 https://github.com/openedx/modular-learning/issues/50 +ENABLE_COPY_PASTE_UNITS = WaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_copy_paste_units', + __name__, + CONTENTSTORE_LOG_PREFIX, +) + + # .. toggle_name: contentstore.enable_studio_content_api # .. toggle_implementation: WaffleFlag # .. toggle_default: False diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 05bd39a355..148b259898 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -28,11 +28,7 @@ from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag from cms.djangoapps.contentstore.toggles import use_new_problem_editor from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration -try: - # Technically this is a django app plugin, so we should not error if it's not installed: - import openedx.core.djangoapps.content_staging.api as content_staging_api -except ImportError: - content_staging_api = None +from openedx.core.djangoapps.content_staging import api as content_staging_api from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from ..toggles import use_new_unit_page @@ -198,10 +194,7 @@ def container_handler(request, usage_key_string): index += 1 # Get the status of the user's clipboard so they can paste components if they have something to paste - if content_staging_api: - user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) - else: - user_clipboard = {"content": None} + user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) return render_to_response('container.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course, # Needed only for display of menus at top of page. diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 96743daf51..28de4cfde9 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1,8 +1,6 @@ """ Views related to operations on course objects """ - - import copy import json import logging @@ -60,6 +58,7 @@ from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadReq from common.djangoapps.util.string_utils import _has_non_ascii_characters from common.djangoapps.xblock_django.api import deprecated_xblocks from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content_staging import api as content_staging_api from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -747,6 +746,8 @@ def course_index(request, course_key): advanced_dict = CourseMetadata.fetch(course_block) proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user) + user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) + return render_to_response('course_outline.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course_block, @@ -754,6 +755,7 @@ def course_index(request, course_key): 'sections': sections, 'course_structure': course_structure, 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, # lint-amnesty, pylint: disable=line-too-long + 'initial_user_clipboard': user_clipboard, 'rerun_notification_id': current_action.id if current_action else None, 'course_release_date': course_release_date, 'settings_url': settings_url, diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index e26242f81f..3e652c899b 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -38,6 +38,7 @@ from xblock.core import XBlock from xblock.fields import Scope from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG +from cms.djangoapps.contentstore.toggles import ENABLE_COPY_PASTE_UNITS from cms.djangoapps.models.settings.course_grading import CourseGradingModel from common.djangoapps.edxmako.services import MakoService from common.djangoapps.static_replace import replace_static_urls @@ -1346,6 +1347,9 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements else: xblock_info["staff_only_message"] = False + # If the ENABLE_COPY_PASTE_UNITS feature flag is enabled, we show the newer menu that allows copying/pasting + xblock_info["enable_copy_paste_units"] = ENABLE_COPY_PASTE_UNITS.is_enabled() + xblock_info[ "has_partition_group_components" ] = has_children_visible_to_specific_partition_groups(xblock) diff --git a/cms/static/js/factories/outline.js b/cms/static/js/factories/outline.js index 57e2fa17ab..caf7cbab0c 100644 --- a/cms/static/js/factories/outline.js +++ b/cms/static/js/factories/outline.js @@ -3,12 +3,13 @@ define([ ], function(CourseOutlinePage, XBlockOutlineInfo) { 'use strict'; - return function(XBlockOutlineInfoJson, initialStateJson) { + return function(XBlockOutlineInfoJson, initialStateJson, initialUserClipboardJson) { var courseXBlock = new XBlockOutlineInfo(XBlockOutlineInfoJson, {parse: true}), view = new CourseOutlinePage({ el: $('#content'), model: courseXBlock, - initialState: initialStateJson + initialState: initialStateJson, + initialUserClipboard: initialUserClipboardJson, }); view.render(); }; diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 72647d3edb..d8ae241a37 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -8,10 +8,11 @@ * - changes cause a refresh of the entire section rather than just the view for the changed xblock * - adding units will automatically redirect to the unit page rather than showing them inline */ -define(['jquery', 'underscore', 'js/views/xblock_outline', 'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils', +define(['jquery', 'underscore', 'js/views/xblock_outline', 'edx-ui-toolkit/js/utils/string-utils', + 'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils', 'js/models/xblock_outline_info', 'js/views/modals/course_outline_modals', 'js/utils/drag_and_drop'], function( - $, _, XBlockOutlineView, ViewUtils, XBlockViewUtils, + $, _, XBlockOutlineView, StringUtils, ViewUtils, XBlockViewUtils, XBlockOutlineInfo, CourseOutlineModalsFactory, ContentDragger ) { var CourseOutlineView = XBlockOutlineView.extend({ @@ -22,6 +23,8 @@ function( render: function() { var renderResult = XBlockOutlineView.prototype.render.call(this); this.makeContentDraggable(this.el); + // Show/hide the paste button + this.initializePasteButton(this.el); return renderResult; }, @@ -202,6 +205,198 @@ function( } }, + /** Copy a Unit to the clipboard */ + copyXBlock() { + const clipboardEndpoint = "/api/content-staging/v1/clipboard/"; + // Start showing a "Copying" notification: + ViewUtils.runOperationShowingMessage(gettext('Copying'), () => { + return $.postJSON( + clipboardEndpoint, + { usage_key: this.model.get('id') } + ).then((data) => { + // const status = data.content?.status; + const status = data.content && data.content.status; + // ^ platform's old require.js/esprima breaks on newer syntax in some JS files but not all. + if (status === "ready") { + // The Unit has been copied and is ready to use. + this.clipboardManager.updateUserClipboard(data); // This will update the UI and notify other tabs + return data; + } else if (status === "loading") { + // The clipboard is being loaded asynchronously. + // Poll the endpoint until the copying process is complete: + const deferred = $.Deferred(); + const checkStatus = () => { + $.getJSON(clipboardEndpoint, (pollData) => { + // const newStatus = pollData.content?.status; + const newStatus = pollData.content && pollData.content.status; + if (newStatus === "ready") { + this.clipboardManager.updateUserClipboard(pollData); + deferred.resolve(pollData); + } else if (newStatus === "loading") { + setTimeout(checkStatus, 1000); + } else { + deferred.reject(); + throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`); + } + }) + } + setTimeout(checkStatus, 1000); + return deferred; + } else { + throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); + } + }); + }); + }, + + initializePasteButton(element) { + if ($(element).hasClass('outline-subsection')) { + if (this.options.canEdit && this.clipboardManager) { + // We should have the user's clipboard status from CourseOutlinePage, whose clipboardManager manages + // the clipboard data on behalf of all the XBlocks in the outline. + this.refreshPasteButton(this.clipboardManager.userClipboard); + this.clipboardManager.addEventListener("update", (event) => { + this.refreshPasteButton(event.detail); + }); + } else { + this.$(".paste-component").hide(); + } + } + }, + + /** + * Given the latest information about the user's clipboard, hide or show the Paste button as appropriate. + */ + refreshPasteButton(data) { + // 'data' is the same data returned by the "get clipboard status" API endpoint + // i.e. /api/content-staging/v1/clipboard/ + if (this.options.canEdit && data.content) { + if (data.content.status === "expired") { + // This has expired and can no longer be pasted. + this.$(".paste-component").hide(); + } else if (data.content.block_type === 'vertical') { + // This is suitable for pasting as a unit. + const detailsPopupEl = this.$(".clipboard-details-popup")[0]; + // Only Units should have the paste button initialized + if (detailsPopupEl !== undefined) { + const detailsPopupEl = this.$(".clipboard-details-popup")[0]; + 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; + if (data.source_edit_url) { + detailsPopupEl.setAttribute("href", data.source_edit_url); + detailsPopupEl.classList.remove("no-edit-link"); + } else { + detailsPopupEl.setAttribute("href", "#"); + detailsPopupEl.classList.add("no-edit-link"); + } + this.$('.paste-component').show() + } + + } else { + this.$('.paste-component').hide() + } + + } else { + this.$('.paste-component').hide(); + } + }, + + createPlaceholderElementForPaste(category, componentDisplayName) { + const nameStr = StringUtils.interpolate(gettext("Copy of '{componentDisplayName}'"), { componentDisplayName }, true); + const el = document.createElement("li"); + el.classList.add("outline-item", "outline-" + category, "has-warnings", "is-draggable"); + el.innerHTML = ` +