feat: Confirmation modal to preview and accept v2 library updates (#35669)
This commit is contained in:
@@ -48,6 +48,8 @@ class UpstreamTestCase(ModuleStoreTestCase):
|
||||
upstream.data = "<html><body>Upstream content V2</body></html>"
|
||||
upstream.save()
|
||||
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
|
||||
def test_sync_bad_downstream(self):
|
||||
"""
|
||||
Syncing into an unsupported downstream (such as a another Content Library block) raises BadDownstream, but
|
||||
@@ -133,6 +135,16 @@ class UpstreamTestCase(ModuleStoreTestCase):
|
||||
upstream.data = "<html><body>Upstream content V3</body></html>"
|
||||
upstream.save()
|
||||
|
||||
# Assert that un-published updates are not yet pulled into downstream
|
||||
sync_from_upstream(downstream, self.user)
|
||||
assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
|
||||
assert downstream.upstream_display_name == "Upstream Title V2"
|
||||
assert downstream.display_name == "Upstream Title V2"
|
||||
assert downstream.data == "<html><body>Upstream content V2</body></html>"
|
||||
|
||||
# Publish changes
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
|
||||
# Follow-up sync. Assert that updates are pulled into downstream.
|
||||
sync_from_upstream(downstream, self.user)
|
||||
assert downstream.upstream_version == 3
|
||||
@@ -157,6 +169,7 @@ class UpstreamTestCase(ModuleStoreTestCase):
|
||||
upstream.display_name = "Upstream Title V3"
|
||||
upstream.data = "<html><body>Upstream content V3</body></html>"
|
||||
upstream.save()
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
|
||||
# Downstream modifications
|
||||
downstream.display_name = "Downstream Title Override" # "safe" customization
|
||||
@@ -277,13 +290,21 @@ class UpstreamTestCase(ModuleStoreTestCase):
|
||||
assert link.version_available == 2
|
||||
assert link.ready_to_sync is False
|
||||
|
||||
# Upstream updated to V3
|
||||
# Upstream updated to V3, but not yet published
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
upstream.data = "<html><body>Upstream content V3</body></html>"
|
||||
upstream.save()
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined is None
|
||||
assert link.version_available == 2
|
||||
assert link.ready_to_sync is False
|
||||
|
||||
# Publish changes
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined is None
|
||||
assert link.version_available == 3
|
||||
assert link.ready_to_sync is True
|
||||
|
||||
@@ -299,6 +320,7 @@ class UpstreamTestCase(ModuleStoreTestCase):
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
upstream.data = "<html><body>Upstream content V4</body></html>"
|
||||
upstream.save()
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined == 3
|
||||
|
||||
@@ -165,11 +165,7 @@ class UpstreamLink:
|
||||
return cls(
|
||||
upstream_ref=downstream.upstream,
|
||||
version_synced=downstream.upstream_version,
|
||||
version_available=(lib_meta.draft_version_num if lib_meta else None),
|
||||
# TODO: Previous line is wrong. It should use the published version instead, but the
|
||||
# LearningCoreXBlockRuntime APIs do not yet support published content yet.
|
||||
# Will be fixed in a follow-up task: https://github.com/openedx/edx-platform/issues/35582
|
||||
# version_available=(lib_meta.published_version_num if lib_meta else None),
|
||||
version_available=(lib_meta.published_version_num if lib_meta else None),
|
||||
version_declined=downstream.upstream_version_declined,
|
||||
error_message=None,
|
||||
)
|
||||
@@ -213,9 +209,14 @@ def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[Upstr
|
||||
"""
|
||||
link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException
|
||||
# We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
|
||||
from openedx.core.djangoapps.xblock.api import load_block # pylint: disable=wrong-import-order
|
||||
from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
|
||||
try:
|
||||
lib_block: XBlock = load_block(LibraryUsageLocatorV2.from_string(downstream.upstream), user)
|
||||
lib_block: XBlock = load_block(
|
||||
LibraryUsageLocatorV2.from_string(downstream.upstream),
|
||||
user,
|
||||
check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
|
||||
version=LatestVersion.PUBLISHED,
|
||||
)
|
||||
except (NotFound, PermissionDenied) as exc:
|
||||
raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
|
||||
return link, lib_block
|
||||
|
||||
112
cms/static/js/views/modals/preview_v2_library_changes.js
Normal file
112
cms/static/js/views/modals/preview_v2_library_changes.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The PreviewLibraryChangesModal is a Backbone view that shows an iframe in a
|
||||
* modal window. The iframe embeds a view from the Authoring MFE that allows
|
||||
* authors to preview the new version of a library-sourced XBlock, and decide
|
||||
* whether to accept ("sync") or reject ("ignore") the changes.
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal',
|
||||
'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils'],
|
||||
function($, _, gettext, BaseModal, ViewUtils, XBlockViewUtils) {
|
||||
'use strict';
|
||||
|
||||
var PreviewLibraryChangesModal = BaseModal.extend({
|
||||
events: _.extend({}, BaseModal.prototype.events, {
|
||||
'click .action-accept': 'acceptChanges',
|
||||
'click .action-ignore': 'ignoreChanges',
|
||||
}),
|
||||
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'preview-lib-changes',
|
||||
modalSize: 'med',
|
||||
view: 'studio_view',
|
||||
viewSpecificClasses: 'modal-lib-preview confirm',
|
||||
// Translators: "title" is the name of the current component being edited.
|
||||
titleFormat: gettext('Preview changes to: {title}'),
|
||||
addPrimaryActionButton: false,
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
BaseModal.prototype.initialize.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds the action buttons to the modal.
|
||||
*/
|
||||
addActionButtons: function() {
|
||||
this.addActionButton('accept', gettext('Accept changes'), true);
|
||||
this.addActionButton('ignore', gettext('Ignore changes'));
|
||||
this.addActionButton('cancel', gettext('Cancel'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an edit modal for the specified xblock
|
||||
* @param xblockElement The element that contains the xblock to be edited.
|
||||
* @param rootXBlockInfo An XBlockInfo model that describes the root xblock on the page.
|
||||
* @param refreshFunction A function to refresh the block after it has been updated
|
||||
*/
|
||||
showPreviewFor: function(xblockElement, rootXBlockInfo, refreshFunction) {
|
||||
this.xblockElement = xblockElement;
|
||||
this.xblockInfo = XBlockViewUtils.findXBlockInfo(xblockElement, rootXBlockInfo);
|
||||
this.courseAuthoringMfeUrl = rootXBlockInfo.attributes.course_authoring_url;
|
||||
const headerElement = xblockElement.find('.xblock-header-primary');
|
||||
this.downstreamBlockId = this.xblockInfo.get('id');
|
||||
this.upstreamBlockId = headerElement.data('upstream-ref');
|
||||
this.upstreamBlockVersionSynced = headerElement.data('version-synced');
|
||||
this.refreshFunction = refreshFunction;
|
||||
|
||||
this.render();
|
||||
this.show();
|
||||
},
|
||||
|
||||
getContentHtml: function() {
|
||||
return `
|
||||
<iframe src="${this.courseAuthoringMfeUrl}/legacy/preview-changes/${this.upstreamBlockId}?old=${this.upstreamBlockVersionSynced}">
|
||||
`;
|
||||
},
|
||||
|
||||
getTitle: function() {
|
||||
var displayName = this.xblockInfo.get('display_name');
|
||||
if (!displayName) {
|
||||
if (this.xblockInfo.isVertical()) {
|
||||
displayName = gettext('Unit');
|
||||
} else {
|
||||
displayName = gettext('Component');
|
||||
}
|
||||
}
|
||||
return edx.StringUtils.interpolate(
|
||||
this.options.titleFormat, {
|
||||
title: displayName
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
acceptChanges: function(event) {
|
||||
event.preventDefault();
|
||||
$.post(`/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`).done(() => {
|
||||
this.hide();
|
||||
this.refreshFunction();
|
||||
}); // Note: if this POST request fails, Studio will display an error toast automatically.
|
||||
},
|
||||
|
||||
ignoreChanges: function(event) {
|
||||
event.preventDefault();
|
||||
ViewUtils.confirmThenRunOperation(
|
||||
gettext('Ignore these changes?'),
|
||||
gettext('Would you like to permanently ignore this updated version? If so, you won\'t be able to update this until a newer version is published (in the library).'),
|
||||
gettext('Ignore'),
|
||||
() => {
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: `/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`,
|
||||
data: {},
|
||||
}).done(() => {
|
||||
this.hide();
|
||||
this.refreshFunction();
|
||||
}); // Note: if this DELETE request fails, Studio will display an error toast automatically.
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return PreviewLibraryChangesModal;
|
||||
});
|
||||
@@ -8,14 +8,15 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
|
||||
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/xblock_access_editor',
|
||||
'js/views/pages/container_subviews', 'js/views/unit_outline', 'js/views/utils/xblock_utils',
|
||||
'common/js/components/views/feedback_notification', 'common/js/components/views/feedback_prompt',
|
||||
'js/views/utils/tagging_drawer_utils', 'js/utils/module',
|
||||
'js/views/utils/tagging_drawer_utils', 'js/utils/module', 'js/views/modals/preview_v2_library_changes'
|
||||
],
|
||||
function($, _, Backbone, gettext, BasePage,
|
||||
ViewUtils, ContainerView, XBlockView,
|
||||
AddXBlockComponent, EditXBlockModal, MoveXBlockModal,
|
||||
XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor,
|
||||
ContainerSubviews, UnitOutlineView, XBlockUtils,
|
||||
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils) {
|
||||
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils,
|
||||
PreviewLibraryChangesModal) {
|
||||
'use strict';
|
||||
|
||||
var XBlockContainerPage = BasePage.extend({
|
||||
@@ -28,6 +29,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
'click .copy-button': 'copyXBlock',
|
||||
'click .move-button': 'showMoveXBlockModal',
|
||||
'click .delete-button': 'deleteXBlock',
|
||||
'click .library-sync-button': 'showXBlockLibraryChangesPreview',
|
||||
'click .show-actions-menu-button': 'showXBlockActionsMenu',
|
||||
'click .new-component-button': 'scrollToNewComponentButtons',
|
||||
'click .save-button': 'saveSelectedLibraryComponents',
|
||||
@@ -420,6 +422,18 @@ function($, _, Backbone, gettext, BasePage,
|
||||
});
|
||||
},
|
||||
|
||||
showXBlockLibraryChangesPreview: function(event, options) {
|
||||
event.preventDefault();
|
||||
|
||||
var xblockElement = this.findXBlockElement(event.target),
|
||||
self = this,
|
||||
modal = new PreviewLibraryChangesModal(options);
|
||||
|
||||
modal.showPreviewFor(xblockElement, this.model, function() {
|
||||
self.refreshXBlock(xblockElement, false);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* If the new "Actions" menu is enabled, most XBlock actions like
|
||||
* Duplicate, Move, Delete, Manage Access, etc. are moved into this
|
||||
|
||||
@@ -562,6 +562,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Modal for previewing changes to a library-sourced block
|
||||
// cms/static/js/views/modals/preview_v2_library_changes.js
|
||||
.modal-lib-preview {
|
||||
.modal-content {
|
||||
padding: 0 !important;
|
||||
|
||||
& > iframe {
|
||||
width: 100%;
|
||||
min-height: 350px;
|
||||
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ltiLaunchFrame{
|
||||
width:100%;
|
||||
height:100%
|
||||
|
||||
@@ -89,6 +89,10 @@ upstream_info = UpstreamLink.try_get_for_block(xblock)
|
||||
authoring_MFE_base_url = ${get_editor_page_base_url(xblock.location.course_key)}
|
||||
data-block-type = ${xblock.scope_ids.block_type}
|
||||
data-usage-id = ${xblock.scope_ids.usage_id}
|
||||
% if upstream_info.upstream_ref:
|
||||
data-upstream-ref = ${upstream_info.upstream_ref}
|
||||
data-version-synced = ${upstream_info.version_synced}
|
||||
%endif
|
||||
>
|
||||
<div class="header-details">
|
||||
% if show_inline:
|
||||
@@ -137,7 +141,6 @@ upstream_info = UpstreamLink.try_get_for_block(xblock)
|
||||
<button
|
||||
class="btn-default library-sync-button action-button"
|
||||
data-tooltip="${_("Update available - click to sync")}"
|
||||
onclick="$.post('/api/contentstore/v2/downstreams/${xblock.usage_key}/sync').done(() => { location.reload(); })"
|
||||
>
|
||||
<span class="icon fa fa-refresh" aria-hidden="true"></span>
|
||||
<span>${_("Update available")}</span>
|
||||
|
||||
Reference in New Issue
Block a user