Files
edx-platform/cms/static/js/views/xblock.js
Kyle McCormick f4540c30e8 fix: flow ?site_theme down through Studio container preview
In ~Palm and earlier, all built-in XBlock Sass was included into CMS
(and LMS) styles before being compiled. So, if a site theme was meant to
affect built-in XBlock styling, those changes would be manifested
directly in the base CMS CSS that is included into every single Studio
page. When the user provided the `?site_theme` querystring parameter,
which is intended to allow devs & admins to view Studio through a given
theme, CMS would look up the given theme and serve the corresponding
base CMS CSS, which would affect the built-in XBlocks views (as
expected).

After ~Palm, built-in XBlocks styles are handled more similarly to to
pure XBlock styles, in that they are only requested when CMS tries to
render the block. In Studio, blocks are not rendered by the original
request, but by a subsequent AJAX request to the `/container_preview`
enpoint. Thus, passing the `?site_theme` query parameter to the original
request will apply the given theme to Studio's chrome, but the theme
will _not_ apply to built-in XBlock views, whose CSS is now loaded via
async request.

To fix this, we simply pass Studio's querystring parameters (including
`?site_theme`) along to the `/container_view` AJAX request. This will
cause CMS to correctly serve the built-in XBlock CSS from the theme
specified by `?site_theme`, rather than whatever the current theme is.

Part of: https://github.com/openedx/edx-platform/issues/32292
2023-07-06 11:58:06 -04:00

256 lines
10 KiB
JavaScript

define(['jquery',
'underscore',
'common/js/components/utils/view_utils',
'js/views/baseview',
'xblock/runtime.v1',
'edx-ui-toolkit/js/utils/html-utils'],
function($, _, ViewUtils, BaseView, XBlock, HtmlUtils) {
'use strict';
var XBlockView = BaseView.extend({
// takes XBlockInfo as a model
events: {
'click .notification-action-button': 'fireNotificationActionEvent'
},
initialize: function() {
BaseView.prototype.initialize.call(this);
this.view = this.options.view;
},
render: function(options) {
var self = this,
view = this.view,
xblockInfo = this.model,
xblockUrl = xblockInfo.url(),
querystring = window.location.search; // pass any querystring down to child views
return $.ajax({
url: decodeURIComponent(xblockUrl) + '/' + view + querystring,
type: 'GET',
cache: false,
headers: {Accept: 'application/json'},
success: function(fragment) {
self.handleXBlockFragment(fragment, options);
}
});
},
initRuntimeData: function(xblock, options) {
if (options && options.initRuntimeData && xblock && xblock.runtime && !xblock.runtime.page) {
xblock.runtime.page = options.initRuntimeData;
}
return xblock;
},
handleXBlockFragment: function(fragment, options) {
var self = this,
wrapper = this.$el,
xblockElement,
successCallback = options ? options.success || options.done : null,
errorCallback = options ? options.error || options.done : null,
xblock,
fragmentsRendered,
aside;
fragmentsRendered = this.renderXBlockFragment(fragment, wrapper);
fragmentsRendered.always(function() {
xblockElement = self.$('.xblock').first();
try {
xblock = XBlock.initializeBlock(xblockElement);
self.xblock = self.initRuntimeData(xblock, options);
self.xblockReady(self.xblock);
self.$('.xblock_asides-v1').each(function() {
if (!$(this).hasClass('xblock-initialized')) {
aside = XBlock.initializeBlock($(this));
self.initRuntimeData(aside, options);
}
});
if (successCallback) {
successCallback(xblock);
}
} catch (e) {
console.error(e, e.stack);
// Add 'xblock-initialization-failed' class to every xblock
self.$('.xblock').addClass('xblock-initialization-failed');
// If the xblock was rendered but failed then still call xblockReady to allow
// drag-and-drop to be initialized.
if (xblockElement) {
self.xblockReady(null);
}
if (errorCallback) {
errorCallback();
}
}
});
},
/**
* Sends a notification event to the runtime, if one is available. Note that the runtime
* is only available once the xblock has been rendered and successfully initialized.
* @param eventName The name of the event to be fired.
* @param data The data to be passed to any listener's of the event.
*/
notifyRuntime: function(eventName, data) {
var runtime = this.xblock && this.xblock.runtime,
xblockChildren;
if (runtime) {
runtime.notify(eventName, data);
} else if (this.xblock) {
xblockChildren = this.xblock.element && $(this.xblock.element).prop('xblock_children');
if (xblockChildren) {
$(xblockChildren).each(function() {
if (this.runtime) {
this.runtime.notify(eventName, data);
}
});
}
}
},
/**
* This method is called upon successful rendering of an xblock. Note that the xblock
* may have thrown JavaScript errors after rendering in which case the xblock parameter
* will be null.
*/
xblockReady: function(xblock) { // eslint-disable-line no-unused-vars
// Do nothing
},
/**
* Renders an xblock fragment into the specified element. The fragment has two attributes:
* html: the HTML to be rendered
* resources: any JavaScript or CSS resources that the HTML depends upon
* Note that the XBlock is rendered asynchronously, and so a promise is returned that
* represents this process.
* @param fragment The fragment returned from the xblock_handler
* @param element The element into which to render the fragment (defaults to this.$el)
* @returns {Promise} A promise representing the rendering process
*/
renderXBlockFragment: function(fragment, element) {
var html = fragment.html,
resources = fragment.resources,
blockView = this;
if (!element) {
element = this.$el;
}
// Render the HTML first as the scripts might depend upon it, and then
// asynchronously add the resources to the page. Any errors that are thrown
// by included scripts are logged to the console but are then ignored assuming
// that at least the rendered HTML will be in place.
try {
return this.addXBlockFragmentResources(resources).done(function() {
console.log('Updating HTML');
try {
blockView.updateHtml(element, html);
} catch (e) {
console.error(e, e.stack);
}
});
} catch (e) {
console.error(e, e.stack);
return $.Deferred().resolve();
}
},
/**
* Updates an element to have the specified HTML. The default method sets the HTML
* as child content, but this can be overridden.
* @param element The element to be updated
* @param html The desired HTML.
*/
updateHtml: function(element, html) {
HtmlUtils.setHtml(element, HtmlUtils.HTML(html));
},
/**
* Dynamically loads all of an XBlock's dependent resources. This is an asynchronous
* process so a promise is returned.
* @param resources The resources to be rendered
* @returns {Promise} A promise representing the rendering process
*/
addXBlockFragmentResources: function(resources) {
var self = this,
applyResource,
numResources,
deferred;
numResources = resources.length;
deferred = $.Deferred();
applyResource = function(index) {
var hash, resource, value, promise;
if (index >= numResources) {
deferred.resolve();
return;
}
value = resources[index];
hash = value[0];
if (!window.loadedXBlockResources) {
window.loadedXBlockResources = [];
}
if (_.indexOf(window.loadedXBlockResources, hash) < 0) {
resource = value[1];
promise = self.loadResource(resource);
window.loadedXBlockResources.push(hash);
promise.done(function() {
applyResource(index + 1);
}).fail(function() {
deferred.reject();
});
} else {
applyResource(index + 1);
}
};
applyResource(0);
return deferred.promise();
},
/**
* Loads the specified resource into the page.
* @param resource The resource to be loaded.
* @returns {Promise} A promise representing the loading of the resource.
*/
loadResource: function(resource) {
var $head = $('head'),
mimetype = resource.mimetype,
kind = resource.kind,
placement = resource.placement,
data = resource.data;
if (mimetype === 'text/css') {
if (kind === 'text') {
// xss-lint: disable=javascript-jquery-append,javascript-concat-html
$head.append('<style type="text/css">' + data + '</style>');
} else if (kind === 'url') {
// xss-lint: disable=javascript-jquery-append,javascript-concat-html
$head.append('<link rel="stylesheet" href="' + data + '" type="text/css">');
}
} else if (mimetype === 'application/javascript') {
if (kind === 'text') {
// xss-lint: disable=javascript-jquery-append,javascript-concat-html
$head.append('<script>' + data + '</script>');
} else if (kind === 'url') {
return ViewUtils.loadJavaScript(data);
}
} else if (mimetype === 'text/html') {
if (placement === 'head') {
HtmlUtils.append($head, HtmlUtils.HTML(data));
}
}
// Return an already resolved promise for synchronous updates
return $.Deferred().resolve().promise();
},
fireNotificationActionEvent: function(event) {
var eventName = $(event.currentTarget).data('notification-action');
if (eventName) {
event.preventDefault();
this.notifyRuntime(eventName, this.model.get('id'));
}
}
});
return XBlockView;
}); // end define();