feat: copy/paste unit from within a unit in Studio - feature flagged (#33724)

(requires the contentstore.enable_copy_paste_units waffle flag)
This commit is contained in:
Pooja Kulkarni
2023-12-01 14:33:34 -05:00
committed by GitHub
parent 94441861e0
commit f5b246d0e9
13 changed files with 258 additions and 62 deletions

View File

@@ -75,6 +75,7 @@ function(
$body.click(function() {
$('.nav-dd .nav-item .wrapper-nav-sub').removeClass('is-shown');
$('.nav-dd .nav-item .title').removeClass('is-selected');
$('.custom-dropdown .dropdown-options').hide();
});
$('.nav-dd .nav-item, .filterable-column .nav-item').click(function(e) {

View File

@@ -581,33 +581,6 @@ describe('Container Subviews', function() {
});
});
describe('PublishHistory', function() {
var lastPublishCss = '.wrapper-last-publish';
it('renders never published when the block is unpublished', function() {
renderContainerPage(this, mockContainerXBlockHtml, {
published: false, published_on: null, published_by: null
});
expect(containerPage.$(lastPublishCss).text()).toContain('Never published');
});
it('renders the last published date and user when the block is published', function() {
renderContainerPage(this, mockContainerXBlockHtml);
fetch({
published: true, published_on: 'Jul 01, 2014 at 12:45 UTC', published_by: 'amako'
});
expect(containerPage.$(lastPublishCss).text())
.toContain('Last published Jul 01, 2014 at 12:45 UTC by amako');
});
it('renders correctly when the block is published without publish info', function() {
renderContainerPage(this, mockContainerXBlockHtml);
fetch({
published: true, published_on: null, published_by: null
});
expect(containerPage.$(lastPublishCss).text()).toContain('Previously published');
});
});
describe('Message Area', function() {
var messageSelector = '.container-message .warning',

View File

@@ -77,6 +77,7 @@ function($, _, Backbone, gettext, BasePage,
model: this.model
});
this.messageView.render();
this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel");
// Display access message on units and split test components
if (!this.isLibraryPage) {
this.containerAccessView = new ContainerSubviews.ContainerAccess({
@@ -89,7 +90,8 @@ function($, _, Backbone, gettext, BasePage,
el: this.$('#publish-unit'),
model: this.model,
// When "Discard Changes" is clicked, the whole page must be re-rendered.
renderPage: this.render
renderPage: this.render,
clipboardBroadcastChannel: this.clipboardBroadcastChannel,
});
this.xblockPublisher.render();
@@ -120,7 +122,6 @@ function($, _, Backbone, gettext, BasePage,
}
this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved);
this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel");
},
getViewParameters: function() {
@@ -175,6 +176,7 @@ function($, _, Backbone, gettext, BasePage,
if (!self.isLibraryPage && !self.isLibraryContentPage) {
self.initializePasteButton();
}
},
block_added: options && options.block_added
});

View File

@@ -107,7 +107,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H
events: {
'click .action-publish': 'publish',
'click .action-discard': 'discardChanges',
'click .action-staff-lock': 'toggleStaffLock'
'click .action-staff-lock': 'toggleStaffLock',
'click .action-copy': 'copyToClipboard'
},
// takes XBlockInfo as a model
@@ -117,6 +118,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H
this.template = this.loadTemplate('publish-xblock');
this.model.on('sync', this.onSync, this);
this.renderPage = this.options.renderPage;
this.clipboardBroadcastChannel = this.options.clipboardBroadcastChannel;
},
onSync: function(model) {
@@ -174,6 +176,50 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H
});
},
copyToClipboard: function(e) {
e.preventDefault();
e.stopPropagation();
const clipboardEndpoint = "/api/content-staging/v1/clipboard/";
const usageKeyToCopy = this.model.get('id');
// Start showing a "Copying" notification:
ViewUtils.runOperationShowingMessage(gettext('Copying'), () => {
return $.postJSON(
clipboardEndpoint,
{ usage_key: usageKeyToCopy },
).then((data) => {
const status = data.content?.status;
if (status === "ready") {
// something that enables the paste button in the actions dropdown
this.clipboardBroadcastChannel.postMessage(data);
return data;
} else if (status === "loading") {
// The clipboard is being loaded asynchonously.
// Poll the endpoint until the copying process is complete:
const deferred = $.Deferred();
const checkStatus = () => {
$.getJSON(clipboardEndpoint, (pollData) => {
const newStatus = pollData.content?.status;
if (newStatus === "ready") {
// something that enables the paste button in actions dropdown
this.clipboardBroadcastChannel.postMessage(pollData);
deferred.resolve(pollData);
} else if (newStatus === "loading") {
setTimeout(checkStatus, 1_000);
} else {
deferred.reject();
throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`);
}
})
}
setTimeout(checkStatus, 1_000);
return deferred;
} else {
throw new Error(`Unexpected clipboard status "${status}" in successful API response.`);
}
});
});
},
discardChanges: function(e) {
var xblockInfo = this.model,
renderPage = this.renderPage;

View File

@@ -8,7 +8,7 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) {
var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState,
getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType, findXBlockInfo,
moveXBlock;
moveXBlock, pasteXBlock;
/**
* Represents the possible visibility states for an xblock:
@@ -69,6 +69,85 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) {
});
};
pasteXBlock = function(target) {
var parentLocator = target.data('parent'),
displayName = target.data('default-name');
return ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => {
return $.postJSON(ModuleUtils.getUpdateUrl(), {
parent_locator: parentLocator,
staged_content: "clipboard",
}).then((data) => {
return data;
});
}).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.Info({
title: gettext("New file(s) added to Files & Uploads."),
message: (
gettext("The following required files were imported to this course:") +
" " + newFiles.join(", ")
),
actions: {
primary: {
text: gettext('View files'),
click: function(notification) {
const article = document.querySelector('[data-course-assets]');
const assetsUrl = $(article).attr('data-course-assets');
window.location.href = assetsUrl;
return;
}
},
secondary: {
text: gettext('Dismiss'),
click: function(notification) {
return notification.hide();
}
}
}
}));
}
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);
}
});
};
/**
* Duplicates the specified xblock element in its parent xblock.
* @param {jquery Element} xblockElement The xblock element to be duplicated.
@@ -308,6 +387,7 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) {
getXBlockListTypeClass: getXBlockListTypeClass,
updateXBlockFields: updateXBlockFields,
getXBlockType: getXBlockType,
findXBlockInfo: findXBlockInfo
findXBlockInfo: findXBlockInfo,
pasteXBlock: pasteXBlock
};
});

View File

@@ -14,6 +14,10 @@ function($, _, ViewUtils, BaseView, XBlock, HtmlUtils) {
'click .notification-action-button': 'fireNotificationActionEvent'
},
options: {
clipboardData: { content: null },
},
initialize: function() {
BaseView.prototype.initialize.call(this);
this.view = this.options.view;

View File

@@ -288,6 +288,11 @@ $seq-nav-height: 40px;
ol {
display: flex;
.custom-dropdown {
position: relative;
display: inline-flex;
}
li {
box-sizing: border-box;
min-width: 40px;
@@ -300,6 +305,47 @@ $seq-nav-height: 40px;
@include border-right-style(solid);
}
.dropdown-main-button {
border-right: 1px solid #e7e7e7 !important;
}
.dropdown-toggle-button {
width: 15% !important;
&:hover {
border-bottom: 1px solid #e7e7e7 !important;
}
}
.dropdown-options {
position: absolute;
top: 100%;
z-index: 1000;
background-color: #ffffff;
min-width: 265px;
right: 0;
li {
padding: 0.5em 1em;
cursor: pointer;
a {
display: block;
width: 100%;
color: black;
}
.checkmark {
float: right;
margin-left: 10px;
}
}
}
.dropdown-options li:hover {
background-color: #f1f1f1;
}
button {
@extend %ui-fake-link;
@extend %ui-clear-button;

View File

@@ -239,6 +239,19 @@
color: $gray-l1;
}
}
.action-copy {
width: 100%;
border-color: #0075b4;
padding-top: 10px;
padding-bottom: 10px;
line-height: 24px;
border-radius: 4px;
&:hover {
@extend %btn-primary-blue;
}
}
}
}

View File

@@ -53,26 +53,57 @@ from openedx.core.djangolib.markup import HTML, Text
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
}
);
require(["js/models/xblock_info", "js/views/xblock", "js/views/utils/xblock_utils", "common/js/components/utils/view_utils"], function (XBlockInfo, XBlockView, XBlockUtils, ViewUtils) {
require(["js/models/xblock_info", "js/views/xblock", "js/views/utils/xblock_utils", "common/js/components/utils/view_utils", "gettext"], function (XBlockInfo, XBlockView, XBlockUtils, ViewUtils, gettext) {
var model = new XBlockInfo({
id: '${subsection.location|n, decode.utf8}'
});
var xblockView = new XBlockView({
model: model,
el: $('#sequence-nav'),
view: 'author_view?position=${position|n, decode.utf8}&next_url=${next_url|n, decode.utf8}&prev_url=${prev_url|n, decode.utf8}'
view: 'author_view?position=${position|n, decode.utf8}&next_url=${next_url|n, decode.utf8}&prev_url=${prev_url|n, decode.utf8}',
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
});
xblockView.xblockReady = function() {
$('.seq_new_button').click(function(evt) {
evt.preventDefault();
XBlockUtils.addXBlock($(evt.target)).done(function(locator) {
var toggleCaretButton = function(clipboardData) {
if (clipboardData && clipboardData.content && clipboardData.source_usage_key.includes("vertical")) {
$('.dropdown-toggle-button').show();
} else {
$('.dropdown-toggle-button').hide();
$('.dropdown-options').hide();
}
};
this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel");
this.clipboardBroadcastChannel.onmessage = (event) => {
toggleCaretButton(event.data);
};
toggleCaretButton(this.options.clipboardData);
$('#new-unit-button').on('click', function(event) {
event.preventDefault();
XBlockUtils.addXBlock($(this)).done(function(locator) {
ViewUtils.redirect('/container/' + locator + '?action=new');
return false;
});
return false;
});
$('.custom-dropdown .dropdown-toggle-button').on('click', function(event) {
event.stopPropagation(); // Prevent the event from closing immediately when we open it
$(this).next('.dropdown-options').slideToggle('fast'); // This toggles the dropdown visibility
var isExpanded = $(this).attr('aria-expanded') === 'true';
$(this).attr('aria-expanded', !isExpanded);
});
$('.seq_paste_unit').on('click', function(event) {
event.preventDefault();
$('.dropdown-options').hide();
XBlockUtils.pasteXBlock($(this)).done(function(data) {
ViewUtils.redirect('/container/' + data.locator + '?action=new');
});
});
};
xblockView.render();
});
</%static:webpack>

View File

@@ -10,8 +10,3 @@ if (published_on && published_by) {
copy = gettext("Previously published");
}
%>
<div class="wrapper-last-publish">
<% // xss-lint: disable=underscore-not-escaped %>
<p class="copy"><%= copy %></p>
</div>

View File

@@ -128,4 +128,14 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
</li>
</ul>
</div>
<div class="wrapper-pub-actions bar-mod-actions">
<ul>
<li>
<button class="btn btn-outline-primary btn-default action-copy">
<span class="icon fa fa-copy" aria-hidden="true"></span>
<span class="button-label"><%- gettext("Copy Unit") %></span>
</button>
</li>
</ul>
</div>
</div>

View File

@@ -85,22 +85,17 @@
% endfor
% endif
% if exclude_units:
<li role="presentation">
<button class="seq_new_button inactive xnav-item tab"
role="tab"
tabindex="-1"
aria-selected="false"
aria-expanded="false"
aria-controls="seq_content"
data-parent="${item_id}"
data-category="vertical"
data-default-name="${_('Unit')}"
>
<span
class="fa fa-plus"
aria-hidden="true"
></span> New Unit
</button>
<li role="presentation" class="custom-dropdown">
<button id="new-unit-button" class="dropdown-main-button" data-parent="${item_id}" data-category="vertical" data-default-name="${_('Unit')}">
<span class="icon fa fa-plus" aria-hidden="true"></span>
${_("New Unit")}
</button>
<button class="dropdown-toggle-button" aria-haspopup="true" aria-expanded="false" style="display: none;">
<span class="icon fa fa-caret-down" aria-hidden="true"></span>
</button>
<ul class="dropdown-options" style="display: none;">
<li><a href="#" class="seq_paste_unit" data-parent="${item_id}" data-category="vertical" data-default-name="${_('Unit')}">${_("Paste as new unit")}</a></li>
</ul>
</li>
% endif
</ol>

View File

@@ -11,7 +11,7 @@
"django-trans-missing-escape": 0,
"javascript-concat-html": 2,
"javascript-escape": 1,
"javascript-jquery-append": 1,
"javascript-jquery-append": 2,
"javascript-jquery-html": 5,
"javascript-jquery-insert-into-target": 2,
"javascript-jquery-insertion": 0,
@@ -36,5 +36,5 @@
"python-wrap-html": 0,
"underscore-not-escaped": 2
},
"total": 63
"total": 64
}