feat: new actions menu for copy/pasting units in Studio (behind waffle flag) (#32891)

This commit is contained in:
Braden MacDonald
2023-08-14 11:58:24 -07:00
committed by GitHub
parent 43b22557bb
commit 08fda1b3eb
11 changed files with 432 additions and 47 deletions

View File

@@ -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();
};

View File

@@ -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 = `
<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),
@@ -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) {

View File

@@ -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 || {});

View File

@@ -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));
},