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:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user