feat: course unit - edit iframe modal window (#35777)

Adds logic to support the functionality of editing xblocks via the legacy modal editing window.
This commit is contained in:
Peter Kulko
2025-02-20 12:02:43 -08:00
committed by GitHub
parent 7dd4a0969c
commit 0319d62a78
11 changed files with 414 additions and 43 deletions

View File

@@ -9,13 +9,14 @@ from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import Http404, HttpResponse
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.http import require_http_methods
from opaque_keys.edx.keys import CourseKey
from web_fragments.fragment import Fragment
from cms.djangoapps.contentstore.utils import load_services_for_studio
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string
from common.djangoapps.student.auth import (
has_studio_read_access,
has_studio_write_access,
@@ -44,6 +45,8 @@ from ..helpers import (
is_unit,
)
from .preview import get_preview_fragment
from .component import _get_item_in_course
from ..utils import get_container_handler_context
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
handle_xblock,
@@ -302,6 +305,39 @@ def xblock_view_handler(request, usage_key_string, view_name):
return HttpResponse(status=406)
@xframe_options_exempt
@require_http_methods(["GET"])
@login_required
def xblock_edit_view(request, usage_key_string):
"""
Return rendered xblock edit view.
Allows editing of an XBlock specified by the usage key.
"""
usage_key = usage_key_with_run(usage_key_string)
if not has_studio_read_access(request.user, usage_key.course_key):
raise PermissionDenied()
store = modulestore()
with store.bulk_operations(usage_key.course_key):
course, xblock, _, __ = _get_item_in_course(request, usage_key)
container_handler_context = get_container_handler_context(request, usage_key, course, xblock)
fragment = get_preview_fragment(request, xblock, {})
hashed_resources = {
hash_resource(resource): resource._asdict() for resource in fragment.resources
}
container_handler_context.update({
"action_name": "edit",
"resources": list(hashed_resources.items()),
})
return render_to_response('container_editor.html', container_handler_context)
@require_http_methods("GET")
@login_required
@expect_json

View File

@@ -23,6 +23,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from pyquery import PyQuery
from pytz import UTC
from bs4 import BeautifulSoup
from web_fragments.fragment import Fragment
from webob import Response
from xblock.core import XBlockAside
@@ -4538,3 +4539,61 @@ class TestUpdateFromSource(ModuleStoreTestCase):
user_id=user.id,
)
self.check_updated(source_block, destination_block.location)
class TestXblockEditView(CourseTestCase):
"""
Test xblock_edit_view.
"""
def setUp(self):
super().setUp()
self.chapter = self._create_block(self.course, "chapter", "Week 1")
self.sequential = self._create_block(self.chapter, "sequential", "Lesson 1")
self.vertical = self._create_block(self.sequential, "vertical", "Unit")
self.html = self._create_block(self.vertical, "html", "HTML")
self.child_container = self._create_block(
self.vertical, "split_test", "Split Test"
)
self.child_vertical = self._create_block(
self.child_container, "vertical", "Child Vertical"
)
self.video = self._create_block(self.child_vertical, "video", "My Video")
self.store = modulestore()
self.store.publish(self.vertical.location, self.user.id)
def _create_block(self, parent, category, display_name, **kwargs):
"""
creates a block in the module store, without publishing it.
"""
return BlockFactory.create(
parent=parent,
category=category,
display_name=display_name,
publish_item=False,
user_id=self.user.id,
**kwargs,
)
def test_xblock_edit_view(self):
url = reverse_usage_url("xblock_edit_handler", self.video.location)
resp = self.client.get_html(url)
self.assertEqual(resp.status_code, 200)
html_content = resp.content.decode(resp.charset)
self.assertIn("var decodedActionName = 'edit';", html_content)
def test_xblock_edit_view_contains_resources(self):
url = reverse_usage_url("xblock_edit_handler", self.video.location)
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
html_content = resp.content.decode(resp.charset)
soup = BeautifulSoup(html_content, "html.parser")
resource_links = [link["href"] for link in soup.find_all("link", {"rel": "stylesheet"})]
script_sources = [script["src"] for script in soup.find_all("script") if script.get("src")]
self.assertGreater(len(resource_links), 0, f"No CSS resources found in HTML. Found: {resource_links}")
self.assertGreater(len(script_sources), 0, f"No JS resources found in HTML. Found: {script_sources}")

View File

@@ -47,6 +47,17 @@ define([
title: gettext("Studio's having trouble saving your work"),
message: message
});
if (window.self !== window.top) {
try {
window.parent.postMessage({
type: 'studioAjaxError',
message: 'Sends a message when an AJAX error occurs',
payload: {}
}, document.referrer);
} catch (e) {
console.error(e);
}
}
console.log('Studio AJAX Error', { // eslint-disable-line no-console
url: event.currentTarget.URL,
response: jqXHR.responseText,

View File

@@ -70,17 +70,6 @@ function($, _, XBlockView, ModuleUtils, gettext, StringUtils, NotificationView)
newParent = undefined;
},
update: function(event, ui) {
try {
window.parent.postMessage(
{
type: 'refreshPositions',
message: 'Refresh positions of all xblocks',
payload: {}
}, document.referrer
);
} catch (e) {
console.error(e);
}
// When dragging from one ol to another, this method
// will be called twice (once for each list). ui.sender will
// be null if the change is related to the list the element
@@ -137,6 +126,17 @@ function($, _, XBlockView, ModuleUtils, gettext, StringUtils, NotificationView)
if (successCallback) {
successCallback();
}
try {
window.parent.postMessage(
{
type: 'refreshPositions',
message: 'Refresh positions of all xblocks',
payload: {}
}, document.referrer
);
} catch (e) {
console.error(e);
}
// Update publish and last modified information from the server.
xblockInfo.fetch();
}

View File

@@ -109,6 +109,18 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
},
hide: function() {
try {
window.parent.postMessage(
{
type: 'hideXBlockEditorModal',
message: 'Sends a message when the modal window is hided',
payload: {}
}, document.referrer
);
} catch (e) {
console.error(e);
}
// Completely remove the modal from the DOM
this.undelegateEvents();
this.$el.html('');
@@ -119,6 +131,15 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
event.preventDefault();
event.stopPropagation(); // Make sure parent modals don't see the click
}
try {
window.parent.postMessage({
type: 'closeXBlockEditorModal',
message: 'Sends a message when the modal window is closed',
payload: {}
}, document.referrer);
} catch (e) {
console.error(e);
}
this.hide();
},

View File

@@ -83,6 +83,11 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
},
getXBlockUpstreamLink: function() {
if (!this.xblockElement || !this.xblockElement.length) {
console.error('xblockElement is empty or not defined');
return;
}
const usageKey = this.xblockElement.data('locator');
$.ajax({
url: '/api/contentstore/v2/downstreams/' + usageKey,
@@ -219,6 +224,16 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
},
onSave: function() {
try {
window.parent.postMessage({
type: 'saveEditedXBlockData',
message: 'Sends a message when the xblock data is saved',
payload: {}
}, document.referrer);
} catch (e) {
console.error(e);
}
var refresh = this.editOptions.refresh;
this.hide();
if (refresh) {
@@ -230,6 +245,16 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
// Notify child views to stop listening events
Backbone.trigger('xblock:editorModalHidden');
try {
window.parent.postMessage({
type: 'closeXBlockEditorModal',
message: 'Sends a message when the modal window is closed',
payload: {}
}, document.referrer);
} catch (e) {
console.error(e);
}
BaseModal.prototype.hide.call(this);
// Notify the runtime that the modal has been hidden

View File

@@ -149,6 +149,9 @@ function($, _, Backbone, gettext, BasePage,
case 'refreshXBlock':
this.render();
break;
case 'completeXBlockEditing':
this.refreshXBlock(xblockElement, false);
break;
case 'completeManageXBlockAccess':
this.refreshXBlock(xblockElement, false);
break;
@@ -507,6 +510,18 @@ function($, _, Backbone, gettext, BasePage,
window.location.href = destinationUrl;
return;
}
if (this.options.isIframeEmbed) {
return window.parent.postMessage(
{
type: 'editXBlock',
message: 'Sends a message when the legacy modal window is shown',
payload: {
id: this.findXBlockElement(event.target).data('locator')
}
}, document.referrer
);
}
}
var xblockElement = this.findXBlockElement(event.target),
@@ -1050,23 +1065,20 @@ function($, _, Backbone, gettext, BasePage,
},
viewXBlockContent: function(event) {
try {
if (this.options.isIframeEmbed) {
event.preventDefault();
var usageId = event.currentTarget.href.split('/').pop() || '';
window.parent.postMessage(
{
type: 'handleViewXBlockContent',
payload: {
usageId: usageId,
},
}, document.referrer
);
return true;
try {
if (this.options.isIframeEmbed) {
event.preventDefault();
var usageId = event.currentTarget.href.split('/').pop() || '';
window.parent.postMessage({
type: 'handleViewXBlockContent',
message: 'View the content of the XBlock',
payload: { usageId },
}, document.referrer);
return true;
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
},
toggleSaveButton: function() {

View File

@@ -527,7 +527,9 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H
tagValueElement.className = 'tagging-label-value';
tagContentElement.appendChild(tagValueElement);
parentElement.appendChild(tagContentElement);
if (parentElement) {
parentElement.appendChild(tagContentElement);
}
if (tag.children.length > 0) {
var tagIconElement = document.createElement('span'),

View File

@@ -1,11 +1,14 @@
@import 'cms/theme/variables-v1';
@import 'elements/course-unit-mfe-iframe';
body {
min-width: 800px;
html {
body {
min-width: 800px;
background: transparent;
}
}
.wrapper {
[class*="view-"] .wrapper {
.inner-wrapper {
max-width: 100%;
}
@@ -114,7 +117,6 @@ body {
background-color: $primary;
color: $white;
border-color: $transparent;
color: $white;
}
&:focus {
@@ -171,6 +173,11 @@ body {
}
}
.edit-xblock-modal select {
background-color: $white;
width: 100%;
}
&.wrapper-modal-window .modal-window .modal-actions a {
color: $text-color;
background-color: $transparent;
@@ -441,6 +448,10 @@ body {
}
}
}
.studio-xblock-wrapper::marker {
content: '';
}
}
.view-container .content-primary {
@@ -457,7 +468,7 @@ body {
@extend %button-styles;
position: relative;
top: 7px;
top: -7px;
.fa-pencil {
display: none;
@@ -561,19 +572,40 @@ body {
}
}
[class*="view-"] .modal-lg.modal-editor .modal-header .editor-modes .action-item {
.editor-button,
.settings-button {
@extend %light-button;
body [class*="view-"] .openassessment_editor_buttons.xblock-actions {
padding: 15px 2% 3px 2%;
}
[class*="view-"] {
.modal-lg {
max-width: 1200px;
}
.modal-lg.modal-editor .modal-header .editor-modes .action-item {
.editor-button,
.settings-button {
@extend %light-button;
}
}
.wrapper.wrapper-modal-window .modal-window .modal-actions .action-primary {
@extend %primary-button;
}
#openassessment-editor {
#oa_basic_settings_editor #openassessment_title_editor_wrapper input, input[type=number] {
width: 48%;
}
}
}
[class*="view-"] .wrapper.wrapper-modal-window .modal-window .modal-actions .action-primary {
@extend %primary-button;
}
.wrapper-comp-settings {
[class*="view-"] div.wrapper-comp-settings {
.list-input.settings-list {
input:not([type="file"]):not([type="number"]),
select {
width: 48%;
}
.metadata-list-enum .create-setting {
@extend %modal-actions-button;
@@ -597,6 +629,7 @@ body {
.list-input.settings-list {
.field.comp-setting-entry.is-set .setting-input {
color: $text-color;
margin-bottom: 5px;
}
select {
@@ -784,3 +817,39 @@ select {
.wrapper-xblock .xblock-header-primary .header-actions .wrapper-nav-sub {
z-index: $zindex-dropdown;
}
.xblock-studio_view-drag-and-drop-v2 .xblock--drag-and-drop--editor {
.zone-align-select,
.item-styles-form input,
.drag-builder textarea,
.target-image-form textarea {
width: 100%;
}
.target-image-form input[type="text"] {
width: 100%;
&.background-url {
margin-bottom: 10px;
}
&.autozone-layout {
&.autozone-layout-cols,
&.autozone-layout-rows {
width: auto;
}
}
&.autozone-size {
&.autozone-size-width,
&.autozone-size-height {
width: auto;
}
}
}
.feedback-tab input:not([type=checkbox]),
.xblock--drag-and-drop--editor .feedback-tab select {
width: 100%;
}
}

View File

@@ -0,0 +1,133 @@
## coding=utf-8
## mako
## Pages currently use v1 styling by default. Once the Pattern Library
## rollout has been completed, this default can be switched to v2.
<%! main_css = "style-main-v1" %>
<%! course_unit_mfe_iframe_css = "course-unit-mfe-iframe-bundle" %>
## Standard imports
<%namespace name='static' file='static_content.html'/>
<%!
from django.utils.translation import gettext as _
from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from lms.djangoapps.branding import api as branding_api
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from cms.djangoapps.contentstore.helpers import xblock_type_display_name
from openedx.core.release import RELEASE_LINE
%>
<%def name="online_help_token()">
<%
return "container"
%>
</%def>
<%page expression_filter="h"/>
<!doctype html>
<html lang="${LANGUAGE_CODE}">
<head dir="${static.dir_rtl()}">
<%
jsi18n_path = "js/i18n/{language}/djangojs.js".format(language=LANGUAGE_CODE)
%>
% if getattr(settings, 'CAPTURE_CONSOLE_LOG', False):
<script type="text/javascript">
var oldOnError = window.onerror;
window.localStorage.setItem('console_log_capture', JSON.stringify([]));
window.onerror = function (message, url, lineno, colno, error) {
if (oldOnError) {
oldOnError.apply(this, arguments);
}
var messages = JSON.parse(window.localStorage.getItem('console_log_capture'));
messages.push([message, url, lineno, colno, (error || {}).stack]);
window.localStorage.setItem('console_log_capture', JSON.stringify(messages));
}
</script>
% endif
<script type="text/javascript" src="${static.url(jsi18n_path)}"></script>
% if settings.DEBUG:
<script type="text/javascript" src="${static.url('js/src/gettext_fallback.js')}"></script>
% endif
<%static:css group='style-vendor'/>
<%static:css group='style-vendor-tinymce-content'/>
<%static:css group='style-vendor-tinymce-skin'/>
<%static:css group='${self.attr.course_unit_mfe_iframe_css}'/>
% if uses_bootstrap:
<link rel="stylesheet" href="${static.url(self.attr.main_css)}" type="text/css" media="all" />
% else:
<%static:css group='${self.attr.main_css}'/>
% endif
<%include file="widgets/segment-io.html" />
<%block name="header_extras">
% for template_name in templates:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
<script type="text/template" id="image-modal-tpl">
<%static:include path="common/templates/image-modal.underscore" />
</script>
% if not settings.STUDIO_FRONTEND_CONTAINER_URL:
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/common.min.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/editImageModal.min.css')}" />
% endif
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% for _, resource in resources:
% if resource['kind'] == 'url' and resource['mimetype'] == 'text/css':
<link rel="stylesheet" href="${resource['data']}" type="text/css" />
% endif
% endfor
</%block>
<!-- Hotjar Tracking Code -->
<script>
(function(h, o, t, j, a, r){
h.hj = h.hj || function() { (h.hj.q = h.hj.q || []).push(arguments) };
h._hjSettings={ hjid: Number('${settings.HOTJAR_ID |n, js_escaped_string}'), hjsv: 6 };
a = o.getElementsByTagName('head')[0];
r = o.createElement('script');
r.async = 1;
r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv;
a.appendChild(r);
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
</script>
</head>
<body class="${static.dir_rtl()} <%block name='bodyclass'></%block> lang_${LANGUAGE_CODE} view-container">
<%static:js group='base_vendor' />
<%static:webpack entry='commons' />
<script type="text/javascript">
window.baseUrl = '${settings.STATIC_URL | n, js_escaped_string}';
require.config({ baseUrl: window.baseUrl });
</script>
<script type="text/javascript" src="${static.url("cms/js/require-config.js")}"></script>
<%block name='page_bundle'>
<%static:webpack entry="js/factories/container">
require(['js/models/xblock_info', 'js/views/modals/edit_xblock'],
function (XBlockInfo, EditXBlockModal) {
var decodedActionName = '${action_name|n, decode.utf8}';
var encodedXBlockDetails = ${xblock_info | n, dump_js_escaped_json};
if (decodedActionName === 'edit') {
var editXBlockModal = new EditXBlockModal();
var xblockInfoInstance = new XBlockInfo(encodedXBlockDetails);
editXBlockModal.edit([], xblockInfoInstance, {});
}
});
</%static:webpack>
</%block>
</body>
</html>

View File

@@ -18,6 +18,7 @@ import openedx.core.djangoapps.debug.views
import openedx.core.djangoapps.lang_pref.views
from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore import views as contentstore_views
from cms.djangoapps.contentstore.views.block import xblock_edit_view
from cms.djangoapps.contentstore.views.organization import OrganizationListView
from openedx.core.apidocs import api_info
from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance
@@ -150,6 +151,8 @@ urlpatterns = oauth2_urlpatterns + [
name='xblock_outline_handler'),
re_path(fr'^xblock/container/{settings.USAGE_KEY_PATTERN}$', contentstore_views.xblock_container_handler,
name='xblock_container_handler'),
re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}/action/edit$', xblock_edit_view,
name='xblock_edit_handler'),
re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}/(?P<view_name>[^/]+)$', contentstore_views.xblock_view_handler,
name='xblock_view_handler'),
re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}?$', contentstore_views.xblock_handler,