Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cms/djangoapps/contentstore/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,22 @@ def individualize_anonymous_user_id(course_id):
)


# .. 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
Expand Down
11 changes: 2 additions & 9 deletions cms/djangoapps/contentstore/views/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@
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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
"""
Views related to operations on course objects
"""


import copy
import json
import logging
Expand Down Expand Up @@ -60,6 +58,7 @@
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
Expand Down Expand Up @@ -747,13 +746,16 @@ 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,
'lms_link': lms_link,
'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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
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
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions cms/static/js/factories/outline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand Down
227 changes: 225 additions & 2 deletions cms/static/js/views/course_outline.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
},

Expand Down Expand Up @@ -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 = `
<div class="${category}-header">
<h3 class="${category}-header-details" style="width: 50%">
<span class="${category}-title item-title">
${nameStr}
</span>
</h3>
<div class="${category}-header-actions" style="width: 50%; text-align: right;">
<span class="icon fa fa-spinner fa-pulse fa-spin" aria-hidden="true"></span>
</div>
</div>
`;
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),
Expand All @@ -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) {
Comment thread
bradenmacdonald marked this conversation as resolved.
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) {
Expand All @@ -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) {
Expand Down
Loading