diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index e6b41dc261..1de45f716d 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index cfbbcac5cd..93e382fb7f 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -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}") diff --git a/cms/static/cms/js/main.js b/cms/static/cms/js/main.js index c7a2507cf2..1f55dda706 100644 --- a/cms/static/cms/js/main.js +++ b/cms/static/cms/js/main.js @@ -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, diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js index 7bf3372c61..8615eb486f 100644 --- a/cms/static/js/views/container.js +++ b/cms/static/js/views/container.js @@ -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(); } diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js index 65b7f06ae0..b2e63403f8 100644 --- a/cms/static/js/views/modals/base_modal.js +++ b/cms/static/js/views/modals/base_modal.js @@ -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(); }, diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index b5b69c721b..586d27d8b2 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -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 diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index a18045b8bd..dc44b15238 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -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() { diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index cfb47c7cd8..cbe08fe1fd 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -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'), diff --git a/cms/static/sass/course-unit-mfe-iframe-bundle.scss b/cms/static/sass/course-unit-mfe-iframe-bundle.scss index c9b111b912..10754b7a51 100644 --- a/cms/static/sass/course-unit-mfe-iframe-bundle.scss +++ b/cms/static/sass/course-unit-mfe-iframe-bundle.scss @@ -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%; + } +} diff --git a/cms/templates/container_editor.html b/cms/templates/container_editor.html new file mode 100644 index 0000000000..e7585d7b96 --- /dev/null +++ b/cms/templates/container_editor.html @@ -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" +%> + + +<%page expression_filter="h"/> + + + + <% + jsi18n_path = "js/i18n/{language}/djangojs.js".format(language=LANGUAGE_CODE) + %> + + % if getattr(settings, 'CAPTURE_CONSOLE_LOG', False): + + % endif + + + + % if settings.DEBUG: + + % 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: + + % else: + <%static:css group='${self.attr.main_css}'/> + % endif + + <%include file="widgets/segment-io.html" /> + + <%block name="header_extras"> + % for template_name in templates: + + % endfor + + + % if not settings.STUDIO_FRONTEND_CONTAINER_URL: + + + % endif + + % for _, resource in resources: + % if resource['kind'] == 'url' and resource['mimetype'] == 'text/css': + + % endif + % endfor + + + + + + + + <%static:js group='base_vendor' /> + <%static:webpack entry='commons' /> + + + + + <%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, {}); + } + }); + + + + diff --git a/cms/urls.py b/cms/urls.py index 50781b4bb3..d01e89d9d2 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -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[^/]+)$', contentstore_views.xblock_view_handler, name='xblock_view_handler'), re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}?$', contentstore_views.xblock_handler,