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 = ` +
+

+ + ${nameStr} + +

+
+ +
+
+ `; + return $(el); + }, + + /** The user has clicked on the "Paste Unit button" */ + pasteUnit(event) { + // event.preventDefault(); + // Get the ID of the parent container (a subsection if we're pasting a unit/vertical) that we're pasting into + const $parentElement = $(event.target).closest('.outline-item'); + const parentLocator = $parentElement.data('locator'); + // Get the display name of what we're pasting: + const displayName = $(event.target).closest('.paste-component').find('.detail-block-name').text(); + // Create a placeholder XBlock while we're pasting: + const $placeholderEl = this.createPlaceholderElementForPaste('unit', displayName); + const $listPanel = $(event.target).closest('.outline-content').children('ol').first(); + $listPanel.append($placeholderEl); + + // Start showing a "Pasting" notification: + ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => { + return $.postJSON(this.model.urlRoot + '/', { + parent_locator: parentLocator, + staged_content: "clipboard", + }).then((data) => { + this.refresh(); // Update this and replace the placeholder with the actual pasted unit. + return data; + }).fail(() => { + $placeholderEl.remove(); + }); + }).done((data) => { + const { + conflicting_files: conflictingFiles, + error_files: errorFiles, + new_files: newFiles, + } = data.static_file_notices; + + const notices = []; + if (errorFiles.length) { + notices.push((next) => new PromptView.Error({ + title: gettext("Some errors occurred"), + message: ( + gettext("The following required files could not be added to the course:") + + " " + errorFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (conflictingFiles.length) { + notices.push((next) => new PromptView.Warning({ + title: gettext("You may need to update a file(s) manually"), + message: ( + gettext( + "The following files already exist in this course but don't match the " + + "version used by the component you pasted:" + ) + " " + conflictingFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (newFiles.length) { + notices.push(() => new NotificationView.Confirmation({ + title: gettext("New files were added to this course's Files & Uploads"), + message: ( + gettext("The following required files were imported to this course:") + + " " + newFiles.join(", ") + ), + closeIcon: true, + })); + } + if (notices.length) { + // Show the notices, one at a time: + const showNext = () => { + const view = notices.shift()(showNext); + view.show(); + } + // Delay to avoid conflict with the "Pasting..." notification. + setTimeout(showNext, 1250); + } + }); + }, + highlightsXBlock: function() { var modal = CourseOutlineModalsFactory.getModal('highlights', this.model, { onSave: this.refresh.bind(this), @@ -216,6 +411,23 @@ function( } }, + /** + * If the new "Actions" menu is enabled, most actions like Configure, + * Duplicate, Move, Delete, etc. are moved into this menu. For this + * event, we just toggle displaying the menu. + * @param {*} event + */ + showActionsMenu(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(); + }, + addButtonActions: function(element) { XBlockOutlineView.prototype.addButtonActions.apply(this, arguments); element.find('.configure-button').click(function(event) { @@ -232,6 +444,17 @@ function( this.highlightsXBlock(); } }.bind(this)); + element.find('.copy-button').click((event) => { + event.preventDefault(); + this.copyXBlock(); + }); + element.find('.paste-component-button').click((event) => { + event.preventDefault(); + this.pasteUnit(event); + }); + element.find('.action-actions-menu').click((event) => { + this.showActionsMenu(event); + }); }, makeContentDraggable: function(element) { diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index 1d10fb6375..dd1c13eda9 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -13,6 +13,43 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, var expandedLocators, CourseOutlinePage; + /** + * On the course outline page, many different UI elements (for now, every unit on the page) need to know the status + * of the user's clipboard. This singleton manages the state of the user's clipboard and can emit events whenever + * the clipboard is changed, whether from another tab or some action the user took on this page. + */ + class ClipboardManager extends EventTarget { + constructor(initialUserClipboard) { + super(); + this._userClipboard = initialUserClipboard; + // Refresh the status when something is copied on another tab: + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); + this.clipboardBroadcastChannel.onmessage = (event) => { + this.updateUserClipboard(event.data, false); + }; + } + + /** + * Get the data about the user's clipboard. This is exactly the same as + * what would be returned from the "get clipboard" REST API. + */ + get userClipboard() { + return this._userClipboard; + } + + updateUserClipboard(newUserClipboard, broadcast = true) { + this._userClipboard = newUserClipboard; + // Emit an "updated" event so listeners can subscribe. This is different than the broadcast channel + // because this only works within the DOM of a single tab, not across all open tabs that the user has. + // In other words, this event trickles down to each section, subsection, and unit view on the outline page. + this.dispatchEvent(new CustomEvent("update", {detail: newUserClipboard})); + // But also notify listeners on other tabs: + if (broadcast) { + this.clipboardBroadcastChannel.postMessage(newUserClipboard); // And notify any other open tabs + } + } + } + CourseOutlinePage = BasePage.extend({ // takes XBlockInfo as a model @@ -33,7 +70,8 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, pollingDelay: 100, options: { - collapsedClass: 'is-collapsed' + collapsedClass: 'is-collapsed', + initialUserClipboard: {content: null}, }, // Extracting this to a variable allows comprehensive themes to replace or extend `CourseOutlineView`. @@ -53,6 +91,7 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function() { $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden'); })); + this.clipboardManager = new ClipboardManager(this.options.initialUserClipboard); }, setCollapseExpandVisibility: function() { @@ -110,7 +149,8 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, model: this.model, isRoot: true, initialState: this.initialState, - expandedLocators: this.expandedLocators + expandedLocators: this.expandedLocators, + clipboardManager: this.clipboardManager, }); this.outlineView.render(); this.outlineView.setViewState(this.initialState || {}); diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index c3ab7a8b0c..9ded8d66e9 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -23,7 +23,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE // takes XBlockInfo as a model options: { - collapsedClass: 'is-collapsed' + collapsedClass: 'is-collapsed', + canEdit: true, // If not specified, assume user has permission to make changes }, templateName: 'xblock-outline', @@ -40,6 +41,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE this.parentView = this.options.parentView; this.renderedChildren = false; this.model.on('sync', this.onSync, this); + this.clipboardManager = this.options.clipboardManager; // May be undefined if not on the course outline page }, render: function() { @@ -110,7 +112,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE includesChildren: this.shouldRenderChildren(), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), staffOnlyMessage: this.model.get('staff_only_message'), - course: course + course: course, + enableCopyPasteUnits: this.model.get("enable_copy_paste_units"), // ENABLE_COPY_PASTE_UNITS waffle flag }; }, @@ -218,7 +221,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE parentView: this, initialState: this.initialState, expandedLocators: this.expandedLocators, - template: this.template + template: this.template, + clipboardManager: this.clipboardManager, }, options)); }, diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 1eb5be08cc..4f7532c915 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -320,7 +320,7 @@ } } } -// New "Paste component" menu, shown on the Unit page to users with a component in their clipboard +// New "Paste component" menu, shown on the Unit and outline page to users with a component in their clipboard .paste-component { margin: $baseline ($baseline/2); @@ -454,6 +454,18 @@ $outline-indent-width: $baseline; } } +// The "Paste new [Unit/Section/Subsection]" button on the course outline page, visible only when clipboard has the right thing in it +.outline .paste-component { + margin: ($baseline/2) 0; // different margins for the "paste new unit/section/subsection" button on the outline vs. the unit page + + .paste-component-button { + // Also there is a smaller font size for this button on the outline vs. the unit page: + &.button { + font-size: 1.2rem; + } + } +} + %outline-item-status { @extend %t-copy-sub2; @@ -902,3 +914,22 @@ $outline-indent-width: $baseline; } } } + +// The "actions menu" for subsections, sections, and units on the Studio course outline page +.outline .actions-list.nav-dd .wrapper-nav-sub { + + @include text-align(left); // Undo the 'text-align: right' inherited from the parent + + z-index: 10; // stay in front of other components on the page. + + .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/course_outline.html b/cms/templates/course_outline.html index 915ec64da4..dfbfd76c49 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -21,7 +21,8 @@ from django.urls import reverse require(["js/factories/outline"], function (OutlineFactory) { OutlineFactory( ${course_structure | n, dump_js_escaped_json}, - ${initial_state | n, dump_js_escaped_json} + ${initial_state | n, dump_js_escaped_json}, + ${initial_user_clipboard | n, dump_js_escaped_json} ); }); diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 87b59786a0..4c49c363f6 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -143,7 +143,7 @@ if (is_proctored_exam) { <% } else { %>
<% } %> -
- <% } %> + <% } %> <% } %> <% } %>