Skip to content

Commit

Permalink
feat: [AXIMST-28] expose editor for advanced xblocks (#2528)
Browse files Browse the repository at this point in the history
* feat: expose editor for advanced xblocks

* feat: added logic for displaying and hiding the xblock modal editing window

* refactor: container_editor template refactoring


---------

Co-authored-by: PKulkoRaccoonGang <[email protected]>
Co-authored-by: Іван Нєдєльніцев <[email protected]>
  • Loading branch information
3 people authored and monteri committed Apr 29, 2024
1 parent 45cd459 commit 4f84238
Show file tree
Hide file tree
Showing 3 changed files with 340 additions and 2 deletions.
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

0 comments on commit 4f84238

Please sign in to comment.