Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [FC-0044] expose editor for advanced xblocks (#2528) #34656

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from web_fragments.fragment import Fragment

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,
Expand All @@ -38,10 +38,11 @@
STUDIO_VIEW,
) # lint-amnesty, pylint: disable=wrong-import-order


from ..helpers import (
is_unit,
)
from ..utils import get_container_handler_context
from .component import _get_item_in_course
from .preview import get_preview_fragment

from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
Expand Down Expand Up @@ -300,6 +301,23 @@ def xblock_view_handler(request, usage_key_string, view_name):
return HttpResponse(status=406)


@require_http_methods("GET")
@login_required
def edit_view_xblock(request, usage_key_string):
"""
The handler for rendered edit xblock view.
"""
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, lms_link, preview_lms_link = _get_item_in_course(request, usage_key)
container_handler_context = get_container_handler_context(request, usage_key, course, xblock)
return render_to_response('container_editor.html', container_handler_context)


@require_http_methods("GET")
@login_required
@expect_json
Expand Down
317 changes: 317 additions & 0 deletions cms/templates/container_editor.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
## 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" %>

## 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.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
)
from openedx.core.djangolib.markup import HTML
from openedx.core.release import RELEASE_LINE
%>
<%def name="online_help_token()">
<%
return "container"
%>
</%def>
<%!
from django.urls import reverse
from django.utils.translation import gettext as _
from cms.djangoapps.contentstore.helpers import xblock_studio_url, xblock_type_display_name
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
)
from openedx.core.djangolib.markup import HTML, Text
%>

<%page expression_filter="h"/>
<!doctype html>
<!--[if lte IE 9]><html class="ie9 lte9" lang="${LANGUAGE_CODE}"><![endif]-->
<!--[if !IE]><<!--><html lang="${LANGUAGE_CODE}"><!--<![endif]-->
<head dir="${static.dir_rtl()}">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="openedx-release-line" content="${RELEASE_LINE}" />
<title>
<%block name="title">
${xblock.display_name_with_default} ${xblock_type_display_name(xblock)}
</%block> |
% if context_course:
<% ctx_loc = context_course.location %>
${context_course.display_name_with_default} |
% elif context_library:
${context_library.display_name_with_default} |
% endif
${settings.STUDIO_NAME}
</title>

<%
jsi18n_path = "js/i18n/{language}/djangojs.js".format(language=LANGUAGE_CODE)
%>

% if getattr(settings, 'CAPTURE_CONSOLE_LOG', False):
<script type="text/javascript">
const 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);
}

const 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:
## Provides a fallback for gettext functions in development environment
<script type="text/javascript" src="${static.url('js/src/gettext_fallback.js')}"></script>
% endif
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="path_prefix" content="${EDX_ROOT_URL}">
<%block name="header_meta"></%block>
<% favicon_url = branding_api.get_favicon_url() %>
<link rel="icon" type="image/x-icon" href="${favicon_url}"/>
<%static:css group='style-vendor'/>
<%static:css group='style-vendor-tinymce-content'/>
<%static:css group='style-vendor-tinymce-skin'/>
<style>
html body {
background: transparent;
}
</style>

% 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>
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% 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

</%block>
<!-- Hotjar Tracking Code for studio -->
<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">
<%block name="view_notes"></%block>
<a class="nav-skip" href="#main">${_("Skip to main content")}</a>
<%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>
<!-- view -->
<div class="wrapper wrapper-view" dir="${static.dir_rtl()}">
<%
banner_messages = list(PageLevelMessages.user_messages(request))
%>
<main id="main" aria-label="Content" tabindex="-1">
<div id="content">
<%block name="content">
<script type="text/javascript">
window.STUDIO_FRONTEND_IN_CONTEXT_IMAGE_SELECTION = true;
</script>

<div style="display:none" class="wrapper-mast wrapper">
<header class="mast has-actions has-navigation has-subtitle">
<nav class="nav-actions" aria-label="${_('Page Actions')}">
<ul>
<li class="action-item action-edit nav-item">
<a href="#" class="button button-edit action-button edit-button">
<span class="icon fa fa-pencil" aria-hidden="true"></span>
<span class="action-button-text">${_("Edit")}</span>
</a>
</li>
</ul>
</nav>
</header>
</div>

<script type="text/javascript">
$(document).ready(() => {
// Serves to initialize the rendering of a xblock edit modal window.
setTimeout(() => $('.button-edit').trigger('click'), 300);

/**
* Callback function for the MutationObserver to handle mutations
* and send information when the modal window close logic is triggered.
*
* @callback mutationCallback
* @param {MutationRecord[]} mutations - The list of mutations detected by the observer.
*/
const xblockEditModalObserver = new MutationObserver((mutations) => {
const modalClassName = 'wrapper-modal-window-edit-xblock';

// When a modal window is opened while the template is rendering,
// an element with class modalClassName is rendered,
// the MutationObserver defines this process in removedNodes.
const modalElementMutationRecords = mutations
.filter(({ removedNodes }) => {
const filteredModalClassName = Array.from(removedNodes).filter((node) =>
node.className && node.className.includes(modalClassName));

return filteredModalClassName.length > 0;
});

// If the element was present and deleted, close the modal window.
if (modalElementMutationRecords.length > 0 && !$('.' + modalClassName).length) {
window.parent.postMessage({
method: 'close_edit_modal',
msg: 'Sends a message when the modal window is closed'
}, '*');
}
});

xblockEditModalObserver.observe(document, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
});
</script>
</%block>
</div>
</main>
</div>

<%block name="modal_placeholder"></%block>
<%block name="jsextra"></%block>

% if context_course:
<%static:webpack entry="js/factories/context_course"/>
<script type="text/javascript">
window.course = new ContextCourse({
id: '${context_course.id | n, js_escaped_string}',
name: '${context_course.display_name_with_default | n, js_escaped_string}',
url_name: '${context_course.location.block_id | n, js_escaped_string}',
org: '${context_course.location.org | n, js_escaped_string}',
num: '${context_course.location.course | n, js_escaped_string}',
display_course_number: '${context_course.display_coursenumber | n, js_escaped_string}',
revision: '${context_course.location.branch | n, js_escaped_string}',
self_paced: ${ context_course.self_paced | n, dump_js_escaped_json },
is_custom_relative_dates_active: ${CUSTOM_RELATIVE_DATES.is_enabled(context_course.id) | n, dump_js_escaped_json},
start: ${context_course.start | n, dump_js_escaped_json},
discussions_settings: ${context_course.discussions_settings | n, dump_js_escaped_json}
});
</script>
% endif

% if user.is_authenticated:
<%static:webpack entry='js/sock'/>
% endif

<%block name='page_bundle'>
<script type="text/javascript">
require(['js/factories/base'], function () {
<%block name='requirejs'></%block>
});
</script>
<%static:webpack entry="js/factories/container">
ContainerFactory(
${component_templates | n, dump_js_escaped_json},
${xblock_info | n, dump_js_escaped_json},
'${action | n, js_escaped_string}',
{
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
canEdit: true,
outlineURL: '${outline_url | n, js_escaped_string}',
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', '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}',
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
});

xblockView.xblockReady = function() {
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');
});
});

$('.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>
</%block>

<div class="modal-cover"></div>
</body>
</html>
3 changes: 3 additions & 0 deletions cms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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 edit_view_xblock
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
Expand Down Expand Up @@ -145,6 +146,8 @@
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}/editor$', edit_view_xblock,
name='xblock_editor_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,
Expand Down
Loading