Skip to content

Commit 83827a3

Browse files
feat: Confirmation modal to preview and accept v2 library updates (#35669)
1 parent ca80d10 commit 83827a3

File tree

6 files changed

+177
-11
lines changed

6 files changed

+177
-11
lines changed

cms/lib/xblock/test/test_upstream_sync.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def setUp(self):
4848
upstream.data = "<html><body>Upstream content V2</body></html>"
4949
upstream.save()
5050

51+
libs.publish_changes(self.library.key, self.user.id)
52+
5153
def test_sync_bad_downstream(self):
5254
"""
5355
Syncing into an unsupported downstream (such as a another Content Library block) raises BadDownstream, but
@@ -133,6 +135,16 @@ def test_sync_updates_happy_path(self):
133135
upstream.data = "<html><body>Upstream content V3</body></html>"
134136
upstream.save()
135137

138+
# Assert that un-published updates are not yet pulled into downstream
139+
sync_from_upstream(downstream, self.user)
140+
assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
141+
assert downstream.upstream_display_name == "Upstream Title V2"
142+
assert downstream.display_name == "Upstream Title V2"
143+
assert downstream.data == "<html><body>Upstream content V2</body></html>"
144+
145+
# Publish changes
146+
libs.publish_changes(self.library.key, self.user.id)
147+
136148
# Follow-up sync. Assert that updates are pulled into downstream.
137149
sync_from_upstream(downstream, self.user)
138150
assert downstream.upstream_version == 3
@@ -157,6 +169,7 @@ def test_sync_updates_to_modified_content(self):
157169
upstream.display_name = "Upstream Title V3"
158170
upstream.data = "<html><body>Upstream content V3</body></html>"
159171
upstream.save()
172+
libs.publish_changes(self.library.key, self.user.id)
160173

161174
# Downstream modifications
162175
downstream.display_name = "Downstream Title Override" # "safe" customization
@@ -277,13 +290,21 @@ def test_prompt_and_decline_sync(self):
277290
assert link.version_available == 2
278291
assert link.ready_to_sync is False
279292

280-
# Upstream updated to V3
293+
# Upstream updated to V3, but not yet published
281294
upstream = xblock.load_block(self.upstream_key, self.user)
282295
upstream.data = "<html><body>Upstream content V3</body></html>"
283296
upstream.save()
284297
link = UpstreamLink.get_for_block(downstream)
285298
assert link.version_synced == 2
286299
assert link.version_declined is None
300+
assert link.version_available == 2
301+
assert link.ready_to_sync is False
302+
303+
# Publish changes
304+
libs.publish_changes(self.library.key, self.user.id)
305+
link = UpstreamLink.get_for_block(downstream)
306+
assert link.version_synced == 2
307+
assert link.version_declined is None
287308
assert link.version_available == 3
288309
assert link.ready_to_sync is True
289310

@@ -299,6 +320,7 @@ def test_prompt_and_decline_sync(self):
299320
upstream = xblock.load_block(self.upstream_key, self.user)
300321
upstream.data = "<html><body>Upstream content V4</body></html>"
301322
upstream.save()
323+
libs.publish_changes(self.library.key, self.user.id)
302324
link = UpstreamLink.get_for_block(downstream)
303325
assert link.version_synced == 2
304326
assert link.version_declined == 3

cms/lib/xblock/upstream_sync.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,7 @@ def get_for_block(cls, downstream: XBlock) -> t.Self:
165165
return cls(
166166
upstream_ref=downstream.upstream,
167167
version_synced=downstream.upstream_version,
168-
version_available=(lib_meta.draft_version_num if lib_meta else None),
169-
# TODO: Previous line is wrong. It should use the published version instead, but the
170-
# LearningCoreXBlockRuntime APIs do not yet support published content yet.
171-
# Will be fixed in a follow-up task: https://github.com/openedx/edx-platform/issues/35582
172-
# version_available=(lib_meta.published_version_num if lib_meta else None),
168+
version_available=(lib_meta.published_version_num if lib_meta else None),
173169
version_declined=downstream.upstream_version_declined,
174170
error_message=None,
175171
)
@@ -213,9 +209,14 @@ def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[Upstr
213209
"""
214210
link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException
215211
# We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
216-
from openedx.core.djangoapps.xblock.api import load_block # pylint: disable=wrong-import-order
212+
from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
217213
try:
218-
lib_block: XBlock = load_block(LibraryUsageLocatorV2.from_string(downstream.upstream), user)
214+
lib_block: XBlock = load_block(
215+
LibraryUsageLocatorV2.from_string(downstream.upstream),
216+
user,
217+
check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
218+
version=LatestVersion.PUBLISHED,
219+
)
219220
except (NotFound, PermissionDenied) as exc:
220221
raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
221222
return link, lib_block
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* The PreviewLibraryChangesModal is a Backbone view that shows an iframe in a
3+
* modal window. The iframe embeds a view from the Authoring MFE that allows
4+
* authors to preview the new version of a library-sourced XBlock, and decide
5+
* whether to accept ("sync") or reject ("ignore") the changes.
6+
*/
7+
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal',
8+
'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils'],
9+
function($, _, gettext, BaseModal, ViewUtils, XBlockViewUtils) {
10+
'use strict';
11+
12+
var PreviewLibraryChangesModal = BaseModal.extend({
13+
events: _.extend({}, BaseModal.prototype.events, {
14+
'click .action-accept': 'acceptChanges',
15+
'click .action-ignore': 'ignoreChanges',
16+
}),
17+
18+
options: $.extend({}, BaseModal.prototype.options, {
19+
modalName: 'preview-lib-changes',
20+
modalSize: 'med',
21+
view: 'studio_view',
22+
viewSpecificClasses: 'modal-lib-preview confirm',
23+
// Translators: "title" is the name of the current component being edited.
24+
titleFormat: gettext('Preview changes to: {title}'),
25+
addPrimaryActionButton: false,
26+
}),
27+
28+
initialize: function() {
29+
BaseModal.prototype.initialize.call(this);
30+
},
31+
32+
/**
33+
* Adds the action buttons to the modal.
34+
*/
35+
addActionButtons: function() {
36+
this.addActionButton('accept', gettext('Accept changes'), true);
37+
this.addActionButton('ignore', gettext('Ignore changes'));
38+
this.addActionButton('cancel', gettext('Cancel'));
39+
},
40+
41+
/**
42+
* Show an edit modal for the specified xblock
43+
* @param xblockElement The element that contains the xblock to be edited.
44+
* @param rootXBlockInfo An XBlockInfo model that describes the root xblock on the page.
45+
* @param refreshFunction A function to refresh the block after it has been updated
46+
*/
47+
showPreviewFor: function(xblockElement, rootXBlockInfo, refreshFunction) {
48+
this.xblockElement = xblockElement;
49+
this.xblockInfo = XBlockViewUtils.findXBlockInfo(xblockElement, rootXBlockInfo);
50+
this.courseAuthoringMfeUrl = rootXBlockInfo.attributes.course_authoring_url;
51+
const headerElement = xblockElement.find('.xblock-header-primary');
52+
this.downstreamBlockId = this.xblockInfo.get('id');
53+
this.upstreamBlockId = headerElement.data('upstream-ref');
54+
this.upstreamBlockVersionSynced = headerElement.data('version-synced');
55+
this.refreshFunction = refreshFunction;
56+
57+
this.render();
58+
this.show();
59+
},
60+
61+
getContentHtml: function() {
62+
return `
63+
<iframe src="${this.courseAuthoringMfeUrl}/legacy/preview-changes/${this.upstreamBlockId}?old=${this.upstreamBlockVersionSynced}">
64+
`;
65+
},
66+
67+
getTitle: function() {
68+
var displayName = this.xblockInfo.get('display_name');
69+
if (!displayName) {
70+
if (this.xblockInfo.isVertical()) {
71+
displayName = gettext('Unit');
72+
} else {
73+
displayName = gettext('Component');
74+
}
75+
}
76+
return edx.StringUtils.interpolate(
77+
this.options.titleFormat, {
78+
title: displayName
79+
}
80+
);
81+
},
82+
83+
acceptChanges: function(event) {
84+
event.preventDefault();
85+
$.post(`/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`).done(() => {
86+
this.hide();
87+
this.refreshFunction();
88+
}); // Note: if this POST request fails, Studio will display an error toast automatically.
89+
},
90+
91+
ignoreChanges: function(event) {
92+
event.preventDefault();
93+
ViewUtils.confirmThenRunOperation(
94+
gettext('Ignore these changes?'),
95+
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).'),
96+
gettext('Ignore'),
97+
() => {
98+
$.ajax({
99+
type: 'DELETE',
100+
url: `/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`,
101+
data: {},
102+
}).done(() => {
103+
this.hide();
104+
this.refreshFunction();
105+
}); // Note: if this DELETE request fails, Studio will display an error toast automatically.
106+
}
107+
);
108+
},
109+
});
110+
111+
return PreviewLibraryChangesModal;
112+
});

cms/static/js/views/pages/container.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
88
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/xblock_access_editor',
99
'js/views/pages/container_subviews', 'js/views/unit_outline', 'js/views/utils/xblock_utils',
1010
'common/js/components/views/feedback_notification', 'common/js/components/views/feedback_prompt',
11-
'js/views/utils/tagging_drawer_utils', 'js/utils/module',
11+
'js/views/utils/tagging_drawer_utils', 'js/utils/module', 'js/views/modals/preview_v2_library_changes'
1212
],
1313
function($, _, Backbone, gettext, BasePage,
1414
ViewUtils, ContainerView, XBlockView,
1515
AddXBlockComponent, EditXBlockModal, MoveXBlockModal,
1616
XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor,
1717
ContainerSubviews, UnitOutlineView, XBlockUtils,
18-
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils) {
18+
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils,
19+
PreviewLibraryChangesModal) {
1920
'use strict';
2021

2122
var XBlockContainerPage = BasePage.extend({
@@ -28,6 +29,7 @@ function($, _, Backbone, gettext, BasePage,
2829
'click .copy-button': 'copyXBlock',
2930
'click .move-button': 'showMoveXBlockModal',
3031
'click .delete-button': 'deleteXBlock',
32+
'click .library-sync-button': 'showXBlockLibraryChangesPreview',
3133
'click .show-actions-menu-button': 'showXBlockActionsMenu',
3234
'click .new-component-button': 'scrollToNewComponentButtons',
3335
'click .save-button': 'saveSelectedLibraryComponents',
@@ -420,6 +422,18 @@ function($, _, Backbone, gettext, BasePage,
420422
});
421423
},
422424

425+
showXBlockLibraryChangesPreview: function(event, options) {
426+
event.preventDefault();
427+
428+
var xblockElement = this.findXBlockElement(event.target),
429+
self = this,
430+
modal = new PreviewLibraryChangesModal(options);
431+
432+
modal.showPreviewFor(xblockElement, this.model, function() {
433+
self.refreshXBlock(xblockElement, false);
434+
});
435+
},
436+
423437
/**
424438
* If the new "Actions" menu is enabled, most XBlock actions like
425439
* Duplicate, Move, Delete, Manage Access, etc. are moved into this

cms/static/sass/views/_container.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,20 @@
562562
}
563563
}
564564

565+
// Modal for previewing changes to a library-sourced block
566+
// cms/static/js/views/modals/preview_v2_library_changes.js
567+
.modal-lib-preview {
568+
.modal-content {
569+
padding: 0 !important;
570+
571+
& > iframe {
572+
width: 100%;
573+
min-height: 350px;
574+
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
575+
}
576+
}
577+
}
578+
565579
.ltiLaunchFrame{
566580
width:100%;
567581
height:100%

cms/templates/studio_xblock_wrapper.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@
8989
authoring_MFE_base_url = ${get_editor_page_base_url(xblock.location.course_key)}
9090
data-block-type = ${xblock.scope_ids.block_type}
9191
data-usage-id = ${xblock.scope_ids.usage_id}
92+
% if upstream_info.upstream_ref:
93+
data-upstream-ref = ${upstream_info.upstream_ref}
94+
data-version-synced = ${upstream_info.version_synced}
95+
%endif
9296
>
9397
<div class="header-details">
9498
% if show_inline:
@@ -137,7 +141,6 @@
137141
<button
138142
class="btn-default library-sync-button action-button"
139143
data-tooltip="${_("Update available - click to sync")}"
140-
onclick="$.post('/api/contentstore/v2/downstreams/${xblock.usage_key}/sync').done(() => { location.reload(); })"
141144
>
142145
<span class="icon fa fa-refresh" aria-hidden="true"></span>
143146
<span>${_("Update available")}</span>

0 commit comments

Comments
 (0)