diff --git a/source/developers/concepts/about_xblock_asides.rst b/source/developers/concepts/about_xblock_asides.rst new file mode 100644 index 000000000..5ab193e86 --- /dev/null +++ b/source/developers/concepts/about_xblock_asides.rst @@ -0,0 +1,328 @@ +.. _About XBlock Asides: + +################### +About XBlock Asides +################### + +.. tags:: developer, concept + +An XBlock Aside is a class that injects content into the rendered views of +existing XBlocks without modifying those XBlocks. Asides let you add behavior, +data, and UI elements to many XBlock instances at once, across XBlock types you +do not own, while preserving the host XBlock's code, fields, and Open Learning +XML (OLX) representation. + +.. warning:: + + Asides have many limitations, including issues around course import and + export. See :ref:`Aside Limitations` for more detail. + +.. contents:: Contents + :local: + :depth: 1 + +What an Aside Is +**************** + +An Aside is a Python class that subclasses :class:`~xblock.core.XBlockAside`, +declares one or more view-injection methods using the +:func:`~xblock.core.XBlockAside.aside_for` decorator, and is registered with +the platform through a Python entry point in the ``xblock_asides.v1`` group. +When the platform renders an XBlock view, the runtime collects every +applicable Aside, invokes its matching Aside view, and appends the resulting +fragments to the host XBlock's rendered fragment. + +An Aside is **not** a child XBlock. It does not appear in the course outline, +it does not have its own URL, and it cannot be added to a course like a +regular block. It exists only in relation to a host block, and its lifecycle +is bound to that host block's lifecycle. + +For the precise API surface, see :ref:`XBlock Asides Reference`. + +The Problem Asides Solve +************************ + +When you want to enhance the behavior of an XBlock that you did not write, +you have three options: + +#. Fork the XBlock and modify it directly. +#. Replace the XBlock with a new XBlock that wraps the original. +#. Attach an Aside to the existing XBlock. + +The first two options carry significant costs. Forking creates a parallel +codebase that must be maintained against upstream changes. Replacing the +XBlock requires every existing course that uses the original to migrate, and +it does not scale when you want to enhance many different XBlock types in the +same way. + +Asides solve this by externalizing the enhancement. The host XBlock is not +modified. The same Aside can apply to a Video block, a Problem block, or any +other block type, by overriding a single classmethod. Asides can serialize +their own scoped fields during course import and export. + +Reach for an Aside when all of the following are true: + +* You want to enhance one or more existing XBlock types without forking them. +* The enhancement is conceptually layered on top of the block, not a + replacement for any of its behavior. +* The enhancement should apply to many block instances, possibly across + block types, without per-instance configuration in the course outline. +* The enhancement may need its own settings or stored data, scoped to the + block instance. + +Reach for something else when: + +* You are creating a brand new piece of course content. Write an XBlock. +* You only need to react to platform events. Consider an Open edX event + receiver. + +How an Aside Relates to Its Host Block +************************************** + +The runtime maintains a many-to-many relationship between asides and host +blocks at runtime, but each Aside instance is bound to exactly one host block +during a single render. The relationship is established in three stages. + +Per-Block Filtering +=================== + +For each candidate Aside type, the runtime instantiates the Aside and asks +it whether it should apply to this specific block by calling its +:meth:`~xblock.core.XBlockAside.should_apply_to_block` classmethod. The +default implementation returns ``True``. Real-world asides almost always +override this method to restrict themselves to specific block types, course +contexts, or feature flags. + +Rendering and Layout +==================== + +For each Aside that survives filtering, the runtime invokes the Aside method +that was decorated with ``@XBlockAside.aside_for(view_name)`` for the view +being rendered. The Aside method returns a ``Fragment``, the runtime wraps +that fragment with identifying markup, and the runtime appends the wrapped +fragment to the host block's rendered output. A runtime can override +:meth:`~xblock.runtime.Runtime.layout_asides` to control where and how the +Aside fragments are placed. + +Why Asides Are Worth the Trouble +******************************** + +The framing above describes the trade-offs from the perspective of someone +choosing among extension mechanisms. The deeper reasons asides exist, and +remain useful, come from the production deployments that depend on them. + +Multiple Block Types, One Implementation +======================================== + +A single Aside class can decorate Video blocks, Problem blocks, and any +other block type the author chooses, by checking ``block.category`` or +``block.scope_ids.block_type`` inside ``should_apply_to_block``. The MIT +Open Learning chat Aside, for example, attaches an "AskTIM" chat button to +both Video and Problem blocks from a single class, with one entry point. +Without asides, the same outcome would require either two parallel forks +or replacement blocks for both types. + +Course Author Control +===================== + +An Aside can declare its own scoped fields, just like an XBlock. By exposing +those fields in an author view, an Aside gives course authors a UI to enable +or disable the enhancement on a per-block basis. The settings are stored +under the Aside's own scope, not the host block's, so they are preserved +across exports and imports without any change to the host block's data +model. + +OLX Export and Import +===================== + +When a course is exported to OLX, the platform serializes each Aside as an +XML child element under its host block, named after the Aside's entry point +name. On import, the runtime reconstitutes the asides automatically. This +means an Aside-enhanced course is portable, with limitations described below. + +Reaching Outside the Iframe +============================ + +An Aside's fragment renders inside the same iframe as its host block, so +its JavaScript is confined to that iframe unless it does something about +it. The browser's ``postMessage`` API is the way out. The Learning +micro-frontend already recognizes a handful of built-in message types +(for things like opening a modal or resizing the frame) that any Aside +can send for free, with no setup. Beyond those, an Aside can post a +message with any custom ``type`` it defines, but nothing reacts to a +made-up type by default — something has to be listening for it. That +listener is a deployment-time addition, registered through the Learning +MFE's own runtime configuration, not a fork of the MFE itself. MIT Open +Learning's "AskTIM" chat button (the `ol-openedx-chat`_ Aside, described +next under "Real-World Examples") works exactly this way: its +JavaScript posts a message, and a small companion script MIT deploys +alongside the MFE listens for it and opens a chat drawer. See +:ref:`XBlock Asides Reference` for the full mechanics and +:ref:`Add an XBlock Aside` for a worked example of both halves. + +Real-World Examples +******************* + +Three implementations in the wild illustrate the range of what asides can +do, from a bare waffle-flag toggle to a fully wired author-facing UI. + +Rapid Response XBlock +===================== + +The `rapid-response-xblock`_ from MIT Open Learning is a single Aside that +applies to Problem blocks restricted to a single multiple-choice response +type. It overlays an instructor-only control on the problem in the LMS +that lets a live instructor open and close response windows during a +lecture, and it renders a real-time chart of student responses. A Boolean +field enables or disables it per problem, exposed through a checkbox in +its ``studio_view``; a companion, Django-settings-gated ``author_view`` +shows the same checkbox in Studio's author preview. The repository name +calls it an "xblock" but the implementation is purely an Aside. + +Open Learning Chat Aside +======================== + +The `ol-openedx-chat`_ Aside, also from MIT Open Learning, attaches an +"AskTIM" chat button to Video and Problem blocks. The button opens a +context-aware chat drawer that streams messages to a backend large language +model, passing block-specific context such as a video transcript identifier +or a problem's siblings. A single Aside class, registered as one entry +point, handles both block types and uses ``should_apply_to_block`` to gate +on a course-level waffle flag, the block-type check, and a course-level +enabled setting together. A course-author checkbox in its ``author_view`` +toggles the button per block, backed by a scoped field and an AJAX handler. +See :ref:`Add an XBlock Aside` for a full walkthrough of this pattern. + +Structured Tags Aside +===================== + +The `StructuredTagsAside`_ ships with the Open edX platform code by default, in +``cms/lib/xblock/tagging/tagging.py``. It attaches a tag picker to +Problem blocks so course authors can apply structured tags for content +search and organization. Unlike the other examples, its entire +author-facing configuration UI *is* its ``AUTHOR_VIEW`` fragment — there +is no separate toggle checkbox, just the tag picker itself, and the +picker only appears at all when Studio's aside-rendering gate (see +:ref:`XBlock Asides Reference`) is enabled. It has no +``should_apply_to_block`` +override; it instead checks ``isinstance(block, ProblemBlock)`` inline +inside its view method. Its own author view method is, confusingly, +still named ``student_view_aside`` even though it is decorated for +``AUTHOR_VIEW`` — a reminder that an Aside's view method names are +conventions, not requirements enforced by the framework. + +.. _Aside Limitations: + +Limitations +*********** + +Asides are a real, working feature in production deployments, but the +ecosystem around them is incomplete. The list below is drawn from the +state of the codebase as of the Sumac release and from a 2025 Open edX +Conference talk by Peter Pinch of MIT Open Learning. Read it before +committing to an Aside-based design. + +No Native Authoring Story in the Course Authoring MFE +====================================================== + +The Authoring micro-frontend has no native code for rendering or +toggling Asides — it does not know Asides exist. Its unit editor +embeds the Studio unit page in an iframe, and whatever that page +renders — including an Aside's ``author_view`` UI (a checkbox, a tag +picker, whatever the Aside renders), gated by ``StudioConfig`` — shows +up inside it. There is no separate, MFE-specific toggle to configure. +The embedded page and the MFE aren't otherwise isolated from each +other, either — they already exchange at least one real +``postMessage`` today. See :ref:`XBlock Asides Reference` for the full +mechanics of both the Studio-side gate and this iframe relationship. + +A Host Block Vanishes Silently If Its Aside Is Uninstalled +============================================================= + +If a course was authored with an Aside attached to one of its blocks, +and that Aside is later uninstalled or its entry point renamed, +re-importing the course does not raise any error — the import reports +success. What actually happens is worse than losing the Aside's data: +the **host block that carried the Aside is dropped from the course +entirely**. It does not appear as an error block or a placeholder; it +simply isn't there. There is no warning that anything was lost. If you +depend on a course's blocks surviving its lifecycle, treat uninstalling +or renaming an Aside that's in use as a breaking, silent change to +every course that has it attached to a block, and audit affected +courses before doing so. + +Not All XBlocks Round-Trip Through OLX +====================================== + +OLX export and import for asides depends on the host XBlock cooperating +with the export process. Some XBlocks, including ORA2, do not preserve +Aside data through their export and import paths. If your Aside must +survive a course export and re-import on a course that uses one of these +blocks, test the round trip end to end before depending on it. (This is +a different failure mode from the previous limitation: here the host +XBlock is present but doesn't cooperate with serialization; there, the +Aside itself is simply gone.) + +Multiple Asides on a Single Block Are Not Reliable +================================================== + +The runtime supports multiple Aside types decorating the same block in +principle, but interactions between asides on the same block are not +well-tested, and this goes deeper than rendering. Two Asides attached +to the same block type that happen to declare an identically-named +``Scope.content`` or ``Scope.settings`` field silently share the *same +stored value* on the platform's Split modulestore — confirmed by +directly reproducing the collision. See :ref:`XBlock Asides Reference` +for the mechanism and the reproduction. Two Asides that both decorate +``student_view`` can also render correctly in isolation +and break when combined, independent of field naming. If you need +multiple Asides on the same block type, give every field a name that's +unlikely to collide with another installed Aside, and build a single +Aside that composes the behaviors instead of relying on two independent +Asides to coexist wherever you can. + +JavaScript Library Loading Is Limited +===================================== + +Asides use the same fragment-based JavaScript loading mechanism as XBlocks, +which assumes a single set of static assets. If your Aside needs a JS +library that is not already loaded by the host page, you must add it +through the fragment, and you must handle ordering and conflicts yourself. +There is no shared Aside-level mechanism for declaring library dependencies. + +Where to Go Next +**************** + +If you are ready to build an Aside, start with :ref:`XBlock Aside Quickstart`. +If you already have a target XBlock in mind and want a step-by-step recipe, read +:ref:`Add an XBlock Aside`. For the complete list of classes, decorators, +methods, and entry points, consult :ref:`XBlock Asides Reference`. The +`rapid-response-xblock`_, `ol-openedx-chat`_, and `StructuredTagsAside`_ +implementations described above are also worth reading directly as +reference material. + +.. _rapid-response-xblock: https://github.com/mitodl/open-edx-plugins/tree/main/src/rapid_response_xblock +.. _ol-openedx-chat: https://github.com/mitodl/open-edx-plugins/tree/main/src/ol_openedx_chat +.. _StructuredTagsAside: https://github.com/openedx/openedx-platform/blob/release/verawood/cms/lib/xblock/tagging/tagging.py#L17 + +.. seealso:: + + :ref:`XBlock Asides Reference` (reference) + The complete API surface for ``XBlockAside`` and its runtime hooks. + + :ref:`Add an XBlock Aside` (how-to) + A step-by-step recipe for adding an Aside to existing XBlocks. + + :ref:`XBlock Aside Quickstart` (quickstart) + A beginner-friendly walkthrough from zero to a running Aside. + + :ref:`Hooks Extension Framework` (concept) + An alternative extension mechanism for non-view-based behaviors. + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+ diff --git a/source/developers/how-tos/add-an-xblock-aside.rst b/source/developers/how-tos/add-an-xblock-aside.rst new file mode 100644 index 000000000..1d896b5c9 --- /dev/null +++ b/source/developers/how-tos/add-an-xblock-aside.rst @@ -0,0 +1,568 @@ +.. _Add an XBlock Aside: + +#################### +Add an XBlock Aside +#################### + +.. tags:: developer, how-to + +Add an XBlock Aside to attach behavior, UI, or stored data to one or more +existing XBlock types without modifying those XBlocks. Use this recipe +when you want a single, installable Python package that decorates the +views of XBlocks in your platform. + +For background on what asides are and when to use them, read +:ref:`About XBlock Asides`. For a complete API reference, see +:ref:`XBlock Asides Reference`. + +.. contents:: Contents + :local: + :depth: 1 + +Prerequisites +************* + +Before you start, make sure you have: + +* A working Open edX development environment in which you can install a + Python package and restart the LMS and Studio services. Tutor devstack + is the recommended environment. +* The installed Python version used by the target Open edX release. +* Familiarity with writing a basic XBlock view that returns a + :class:`~web_fragments.fragment.Fragment`. If you have never written + one, complete :ref:`XBlock Aside Quickstart` first. + +This recipe builds a feedback-badge Aside that adds a "Report an issue" +link to Problem and Video blocks, with a course-author setting to enable +or disable it per block. Substitute your own block types and behavior as +needed. + +Step 1: Scaffold a Python package +********************************* + +Create a new directory for the Aside package, with the layout below: + +.. code-block:: text + + feedback_badge_aside/ + ├── pyproject.toml + ├── feedback_badge_aside/ + │ ├── __init__.py + │ ├── Aside.py + │ └── static/ + │ ├── html/ + │ │ └── studio_view.html + │ ├── css/ + │ │ └── studio.css + │ └── js/ + │ └── studio.js + └── README.rst + +The package name (``feedback_badge_aside``) and the module name +(``Aside.py``) are conventions; pick names that describe your Aside. +The ``static/html`` and ``static/js`` directories hold the template and +script for the author-facing toggle built in later steps — this +mirrors how production asides such as `ol-openedx-chat`_ lay out their +static assets. + +Populate ``pyproject.toml`` with the package metadata and a placeholder +for the entry point you will add in :ref:`Step 8 `. + +.. code-block:: toml + + [project] + name = "feedback-badge-Aside" + version = "0.1.0" + description = "An XBlock Aside that adds a feedback link to Problem and Video blocks." + requires-python = ">=X.Y" # set to the minimum Python version for the target Open edX release + dependencies = [ + "XBlock", + "web-fragments", + ] + + [build-system] + requires = ["setuptools>=61.0"] + build-backend = "setuptools.build_meta" + +Step 2: Define the Aside class +****************************** + +In ``feedback_badge_aside/Aside.py``, define a subclass of +:class:`~xblock.core.XBlockAside`. + +.. code-block:: python + + from xblock.core import XBlockAside + + + class FeedbackBadgeAside(XBlockAside): + """Adds a feedback link to learner-facing views of supported blocks.""" + +The class name does not need to match the entry point name, but keeping +them consistent makes debugging easier. + +Step 3: Declare fields for course-author control +************************************************ + +Add a Boolean field that course authors can toggle to enable or disable +the Aside on a per-block basis. Scope the field to ``Scope.settings``, +which means the value is stored per block and travels with the course in +OLX export and import. + +.. code-block:: python + + from xblock.core import XBlockAside + from xblock.fields import Boolean, Scope + + + class FeedbackBadgeAside(XBlockAside): + """Adds a feedback link to learner-facing views of supported blocks.""" + + is_feedback_badge_enabled = Boolean( + display_name="Show feedback link", + default=True, + scope=Scope.settings, + help="Whether to show a 'Report an issue' link on this block.", + ) + +Name the field for the Aside, not generically +(``is_feedback_badge_enabled``, not ``enabled``). This is a real data +safety requirement, not just a readability nicety: on the platform's +Split modulestore, ``Scope.settings`` and ``Scope.content`` fields for +every Aside attached to a given block type are stored in one shared +bucket, keyed by field name — not by which Aside declared the field, +so an identically-named field on a completely unrelated, independently +installed Aside reads and writes the *same* stored value. See +:ref:`XBlock Asides Reference` for the mechanism and a confirmed +reproduction. A name specific to this Aside — ideally one no other +installed Aside is likely to reuse — is the only real protection +against this. + +Step 4: Decorate the views you want to inject into +************************************************** + +Add one method per XBlock view you want to decorate, using +:meth:`~xblock.core.XBlockAside.aside_for`. The method takes ``self``, +the host ``block``, and an optional ``context`` dictionary, and returns +a :class:`~web_fragments.fragment.Fragment`. + +The learner-facing view is straightforward — a link, gated on the +field: + +.. code-block:: python + + from web_fragments.fragment import Fragment + from xblock.core import XBlockAside + from xblock.fields import Boolean, Scope + + + class FeedbackBadgeAside(XBlockAside): + """Adds a feedback link to learner-facing views of supported blocks.""" + + is_feedback_badge_enabled = Boolean( + display_name="Show feedback link", + default=True, + scope=Scope.settings, + help="Whether to show a 'Report an issue' link on this block.", + ) + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + """Render the feedback link for the learner view.""" + if not self.is_feedback_badge_enabled: + return Fragment("") + + block_id = block.scope_ids.usage_id.block_id + html = ( + f'Report an issue' + ) + return Fragment(html) + +The author-facing toggle is where it's worth taking more care. Decorate +``author_view``, not ``studio_view``. This isn't a style preference: +Studio's own aside-rendering machinery only splices an Aside's fragment +into the views it treats as preview views — ``student_view``, +``public_view``, and ``author_view`` — and ``studio_view`` isn't one of +them, so an Aside decorating ``studio_view`` never actually appears in +Studio's unit editor. ``author_view`` is also what production asides +such as `ol-openedx-chat`_ use for exactly this purpose. + +Add a small helper for loading the package's static files, and use it +to render the checkbox from a template instead of building HTML inline: + +.. code-block:: python + + import pkg_resources + + + def resource_string(path): + """Load a static resource from this package as a decoded string.""" + return pkg_resources.resource_string(__name__, path).decode("utf8") + + + class FeedbackBadgeAside(XBlockAside): + # ... fields and student_view_aside from above ... + + @XBlockAside.aside_for("author_view") + def author_view_aside(self, block, context=None): + """Render the author-facing toggle in Studio's unit preview.""" + html = resource_string("static/html/studio_view.html").format( + checked="checked" if self.is_feedback_badge_enabled else "", + ) + fragment = Fragment(html) + fragment.add_css(resource_string("static/css/studio.css")) + fragment.add_javascript(resource_string("static/js/studio.js")) + fragment.initialize_js("FeedbackBadgeStudioInit") + return fragment + +``static/html/studio_view.html`` — note the ``{checked}`` placeholder, +filled in by the ``.format()`` call in ``author_view_aside`` above, so +this is shown as plain text rather than as HTML: + +.. code-block:: text + + + +Note what this method does *not* do: it doesn't pass the checkbox state +through ``initialize_js``'s ``json_args`` parameter. That parameter +exists and is useful — ``student_view_aside`` could use it to hand +learner-facing JavaScript some initial state — but ``author_view_aside`` +here gets its state a different way, by rendering it directly into the +HTML template's ``checked`` attribute. Keep the two mechanisms distinct +in your own Asides: use template context for what the initial markup +should look like, and ``json_args`` for values your JavaScript needs +after the page has already loaded. + +The checkbox doesn't do anything yet — clicking it doesn't persist the +change. That's Step 5. + +Step 5: Add a handler to persist the toggle +******************************************** + +Add an AJAX handler, using the standard ``@XBlock.handler`` decorator, +that reads the posted value and saves it to the field: + +.. code-block:: python + + import json + + from webob import Response + from xblock.core import XBlock + + + class FeedbackBadgeAside(XBlockAside): + # ... fields and view methods from above ... + + @XBlock.handler + def update_config(self, request, suffix=""): + """Persist the course author's toggle setting.""" + data = json.loads(request.body) + self.is_feedback_badge_enabled = bool(data.get("is_enabled", True)) + return Response(json_body={"is_enabled": self.is_feedback_badge_enabled}) + +This is a normal XBlock handler — an Aside's handlers work exactly like +an XBlock's, and the runtime generates a handler URL for the Aside the +same way it does for the host block. + +Step 6: Add the JavaScript that wires up the checkbox +******************************************************* + +The checkbox needs client-side code to listen for changes and call the +handler. ``static/js/studio.js``: + +.. code-block:: javascript + + function FeedbackBadgeStudioInit(runtime, element) { + var studioRuntime = new window.StudioRuntime.v1(); + var handlerUrl = studioRuntime.handlerUrl(element, "update_config"); + var checkbox = element.querySelector(".feedback-badge-toggle"); + + checkbox.addEventListener("change", function () { + $.ajax({ + type: "POST", + url: handlerUrl, + data: JSON.stringify({is_enabled: checkbox.checked}), + }).done(function () { + runtime.notify("save", {state: "end"}); + }); + }); + } + +Two things worth noting, both taken from how `ol-openedx-chat`_ does +this in production: + +* Use ``new window.StudioRuntime.v1()`` to get a runtime capable of + building the handler URL, rather than the ``runtime`` argument + ``initialize_js`` passes in directly — this is what Studio's + JavaScript environment expects for saving Aside/XBlock data. +* Don't add a CSRF header to the request yourself. Studio already + attaches one globally for every ``$.ajax``/``$.post`` call + (``cms/static/cms/js/main.js`` calls ``$.ajaxSetup`` with the CSRF + token once, at page load), and the platform's own Aside JavaScript + (``cms/static/js/xblock_asides/structured_tags.js``) relies on this + without adding its own header. Adding one yourself is redundant at + best. + +If your package also needs to tell the Authoring MFE that the embedded +unit page's content changed — for example, because the surrounding UI +needs to react to the save — post a ``saveEditedXBlockData`` message +to ``document.referrer`` after a successful save, the same message +`ol-openedx-chat`_'s own ``studio.js`` sends: + +.. code-block:: javascript + + }).done(function () { + runtime.notify("save", {state: "end"}); + window.parent.postMessage( + {type: "saveEditedXBlockData"}, + document.referrer + ); + }); + +``frontend-app-authoring`` listens for exactly this message type to +know the embedded unit iframe changed. This is a deliberate integration +point that already exists between Studio's Aside JavaScript and the +Authoring MFE, not incidental boilerplate — include it if your Aside's +save should be reflected in the surrounding MFE UI. This is also used to +enable the "Publish" button in the Authoring MFE when an Aside's checkbox +is toggled, otherwise, reloading the page will enable the "Publish" button. + +Step 7: Filter to specific block types +************************************** + +By default, an Aside applies to every block. Override +:meth:`~xblock.core.XBlockAside.should_apply_to_block` to restrict the +Aside to the block types you support. + +.. code-block:: python + + @classmethod + def should_apply_to_block(cls, block): + """Apply this Aside to Problem and Video blocks only.""" + block_type = getattr(block, "category", None) + return block_type in {"problem", "video"} + +Add this classmethod to ``FeedbackBadgeAside``. Without it, the Aside +would attempt to render on every block in every course, including blocks +where the markup makes no sense. + +For more sophisticated filtering, ``should_apply_to_block`` can also +inspect: + +* ``block.scope_ids.usage_id.context_key`` to gate on a course or + library. +* Platform feature flags such as Waffle flags. +* Course-level settings retrieved through a runtime service. + +If your filter consults course settings or feature flags, guard against +the import and export paths where these may not be available; see +:ref:`About XBlock Asides` for the relevant limitations. + +.. _register entry point: + +Step 8: Register the Aside as an entry point +******************************************** + +In ``pyproject.toml``, add an entry point in the ``xblock_asides.v1`` +group. The entry point name on the left side of the equals sign becomes +the Aside's type name and is used as the XML tag during OLX +serialization. + +.. code-block:: toml + + [project.entry-points."xblock_asides.v1"] + feedback_badge = "feedback_badge_aside.Aside:FeedbackBadgeAside" + +Choose a type name that is unlikely to collide with other asides on the +same deployment. Treat the name as a stable public identifier; renaming +it later breaks OLX round-trips of any course that has used the Aside. + +Step 9: Install the package and restart services +************************************************ + +Install the package into the LMS and Studio Python environments. With +Tutor: + +.. code-block:: bash + + tutor mounts add lms,cms:/path/to/feedback_badge_aside:/openedx/feedback_badge_aside + tutor dev exec lms bash + pip install -e /openedx/feedback_badge_aside + exit + tutor dev restart lms + tutor dev exec cms bash + pip install -e /openedx/feedback_badge_aside + exit + tutor dev restart cms + +Step 10: Enable asides in the LMS and Studio +********************************************* + +The LMS and Studio each gate Aside rendering on their own, separate +Django configuration model. Enabling one does not enable the other. + +**LMS.** ``XBlockAsidesConfig``, defined in +``lms/djangoapps/lms_xblock/models.py``. Until this model has an +enabled revision, no Aside renders in the LMS regardless of +installation or registration. Open the LMS Django admin and create a +new configuration revision: + +.. code-block:: text + + http:///admin/lms_xblock/xblockasidesconfig/ + +Click :guilabel:`Add`, check :guilabel:`Enabled`, and save. The model is +a ``ConfigurationModel`` (revision-based), so each save creates a new +revision and the most recent enabled revision is treated as current. + +The same form has a ``Disabled blocks`` field, a space-separated list of +block types on which asides will **never** render in the LMS. The +default value is ``about course_info static_tab``. If your Aside should +apply to one of these block types, remove that type from the list. + +There is no per-course allowlist and no per-Aside-type allowlist. Once +``XBlockAsidesConfig`` is enabled and your Aside's host block type is +not in ``disabled_blocks``, the runtime offers your Aside to every +matching block in every course. Per-Aside filtering happens through +your Aside's own ``should_apply_to_block`` classmethod, which you wrote +in Step 7. + +**Studio.** A separate model, ``StudioConfig``, defined in +``cms/djangoapps/xblock_config/models.py``, gates Aside rendering in +Studio — the LMS model above has no effect there. It has the same +shape (an ``enabled`` flag and a ``disabled_blocks`` field, same +default) and the same admin workflow, at a different URL: + +.. code-block:: text + + http:///admin/xblock_config/studioconfig/ + +Both models are ``ConfigurationModel`` rows that default to +``enabled=False``, so out of the box your Aside renders in neither the +LMS nor Studio until you explicitly enable the model for each. + +The Authoring MFE doesn't need a separate switch either: its unit +editor embeds the same Studio unit page this ``StudioConfig`` setting +controls, inside an iframe. Once ``StudioConfig`` is enabled, the +``author_view`` checkbox from Step 4 appears inside that embedded page +when authors edit a unit. + +Step 11: Verify the Aside is rendering +*************************************** + +Open a course that contains a Problem or Video block, view it as a +learner, and confirm the feedback link appears at the bottom of the +block. To verify the author-side UI, open the same block's unit page in +Studio and confirm the checkbox appears in the author view. Click it, +reload the page, and confirm the checkbox keeps +its new state — that confirms the handler from Step 5 is actually +persisting the value, not just rendering it once. + +If the Aside does not appear at all: + +#. Check that the entry point is registered. Run: + + .. code-block:: python + + from xblock.core import XBlockAside + print(list(XBlockAside.load_classes())) + + in a Django shell. Your Aside's type name should be in the list. If + it's missing, check the LMS/Studio logs for a ``log.warning`` about + failing to load your Aside's entry point — a broken import in your + package is dropped silently at this stage, with the Aside simply + absent from the list above and no other error anywhere. +#. Check that ``should_apply_to_block`` returns ``True`` for the block + you are testing. + +If the Aside's fragment shows an error instead of your content, that's +a different situation: an exception was raised inside a view method +(``student_view_aside`` or ``author_view_aside``) or inside +``should_apply_to_block`` itself, after the Aside was already found and +loaded. None of these fail silently, in either environment: an +exception in ``author_view_aside`` or in ``should_apply_to_block`` +renders as an error block on the unit page in Studio, and an exception +in ``student_view_aside`` or in ``should_apply_to_block`` renders as an +error block in the Learning MFE. Check the corresponding logs for the +underlying exception either way. + +Step 12: Talk to the Learning MFE via postMessage (optional) +************************************************************** + +If your Aside needs to reach beyond its own iframe — to trigger +something in the surrounding Learning MFE page — use ``postMessage``. +This step is optional; skip it if your Aside's UI is self-contained. + +For a built-in message type, no extra setup is needed on either side. +For example, to open a modal from your Aside's learner-facing +JavaScript: + +.. code-block:: javascript + + window.parent.postMessage( + {type: "plugin.modal", payload: {open: true}}, + learningMfeBaseUrl + ); + +Get ``learningMfeBaseUrl`` from the server side and pass it into your +fragment's JavaScript through ``initialize_js``'s ``json_args``, rather +than guessing at a URL client-side — a mismatched target origin drops +the message with no console error on either end. `ol-openedx-chat`_ +sources this value from ``settings.LEARNING_MICROFRONTEND_URL``. + +For a custom message type, you'll also need a listener registered +through the Learning MFE's own ``env.config.jsx``. See :ref:`XBlock +Asides Reference` for the full set of built-in types, why +``plugin.resize`` collides with the host page's own use of it, the +listener recipe, and a complete worked example from MIT Open +Learning's `ol-openedx-chat`_. + +Next Steps +********** + +Once the basic Aside is working, common follow-ups include: + +* **Persist user-specific state.** Add fields with ``Scope.user_state`` + to store per-learner data alongside the Aside — but only save them + from a handler invoked in the LMS. Saving a ``Scope.user_state`` + field from a handler while the Aside runs under Studio raises + ``xblock.exceptions.InvalidScopeError``; see :ref:`XBlock Asides + Reference` for why. +* **Customize layout.** If you need the Aside to render somewhere other + than after the host block, override the runtime's ``layout_asides`` + in your platform integration. +* **Study more real-world Asides.** `ol-openedx-chat`_ (MIT Open + Learning) and the platform's own `StructuredTagsAside`_ show two more + points on the spectrum of author-facing configuration — see + :ref:`About XBlock Asides` for a tour of all of them. + +For the complete API surface, see :ref:`XBlock Asides Reference`. For +the conceptual background, including known limitations of the Aside +mechanism, see :ref:`About XBlock Asides`. + +.. seealso:: + + :ref:`About XBlock Asides` (concept) + Why asides exist, what problem they solve, and current limitations. + + :ref:`XBlock Asides Reference` (reference) + The complete API surface for ``XBlockAside`` and its runtime hooks. + + :ref:`XBlock Aside Quickstart` (quickstart) + A beginner-friendly walkthrough from zero to a running Aside. + +.. _ol-openedx-chat: https://github.com/mitodl/open-edx-plugins/tree/main/src/ol_openedx_chat +.. _rapid-response-xblock: https://github.com/mitodl/open-edx-plugins/tree/main/src/rapid_response_xblock +.. _StructuredTagsAside: https://github.com/openedx/openedx-platform/blob/release/verawood/cms/lib/xblock/tagging/tagging.py#L17 + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+ diff --git a/source/developers/quickstarts/quickstart_xblock_aside.rst b/source/developers/quickstarts/quickstart_xblock_aside.rst new file mode 100644 index 000000000..c5917240f --- /dev/null +++ b/source/developers/quickstarts/quickstart_xblock_aside.rst @@ -0,0 +1,275 @@ +.. _XBlock Aside Quickstart: + +########################################## +Quickstart: Build Your First XBlock Aside +########################################## + +.. tags:: developer, quickstart + +Build, install, and run a minimal XBlock Aside in a Tutor development +environment. By the end of this quickstart, you will have an Aside that +adds a small banner to every Problem block in a course, and you will +understand the four moving parts every Aside has: the class, the +decorated views, the entry point, and the install step. + +This quickstart deliberately keeps the Aside trivial. For a more +realistic recipe, work through :ref:`Add an XBlock Aside` next. For the +conceptual background, see :ref:`About XBlock Asides`. + +.. contents:: Contents + :local: + :depth: 1 + +Prerequisites +************* + +You need: + +* A running Tutor development environment with the LMS and Studio + services accessible. Tutor's `Getting started`_ guide walks through + the install if you do not have one yet. +* The installed Python version used by the target Open edX release. +* A course in your devstack with at least one Problem block. The Tutor + demo course works. +* A text editor and a terminal. + +You do **not** need: + +* Prior experience writing XBlocks. The Aside in this quickstart uses + plain Python and a hardcoded HTML string. +* Familiarity with the Open Learning XML (OLX) format. + +Step 1: Create a Python package +******************************* + +In a working directory of your choice, create the following file +structure: + +.. code-block:: text + + hello_aside/ + ├── pyproject.toml + └── hello_aside/ + ├── __init__.py + └── Aside.py + +Make ``__init__.py`` an empty file. The remaining steps describe what to +put in the other two files. + +Step 2: Write the Aside class +***************************** + +In ``hello_aside/Aside.py``, paste the following code: + +.. code-block:: python + + from web_fragments.fragment import Fragment + from xblock.core import XBlockAside + + + class HelloAside(XBlockAside): + """A trivial Aside that prints a banner above every Problem block.""" + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + return Fragment( + '
' + 'Hello from an XBlock Aside!' + '
' + ) + + @classmethod + def should_apply_to_block(cls, block): + return getattr(block, "category", None) == "problem" + +This is the entire implementation. Three things to notice: + +* The class subclasses :class:`~xblock.core.XBlockAside`. +* The :meth:`~xblock.core.XBlockAside.aside_for` decorator marks + ``student_view_aside`` as the method to call when an XBlock's + ``student_view`` is being rendered. +* :meth:`~xblock.core.XBlockAside.should_apply_to_block` restricts the + Aside to Problem blocks. Without it, the banner would appear on every + block in every course. + +Step 3: Configure the package +***************************** + +In ``hello_aside/pyproject.toml``, paste: + +.. code-block:: toml + + [project] + name = "hello-Aside" + version = "0.1.0" + requires-python = ">=X.Y" # set to the minimum Python version for the target Open edX release + dependencies = ["XBlock", "web-fragments"] + + [project.entry-points."xblock_asides.v1"] + hello_aside = "hello_aside.Aside:HelloAside" + + [build-system] + requires = ["setuptools>=61.0"] + build-backend = "setuptools.build_meta" + +The critical line is the one under +``[project.entry-points."xblock_asides.v1"]``. It tells the Open edX +platform that a class called ``HelloAside``, in the ``hello_aside.Aside`` +module, should be loaded as an Aside under the type name +``hello_aside``. This entry point group is how every Aside is +discovered. + +Step 4: Install the package into Tutor +************************************** + +From the directory containing ``hello_aside/``, mount the package into +Tutor and relaunch the development environment: + +.. code-block:: bash + + tutor mounts add lms,cms:./hello_aside:/openedx/hello_aside + tutor dev exec lms bash + pip install -e /openedx/hello_aside + exit + tutor dev restart lms + +The ``mounts add`` command tells Tutor to mount the local package into +both the LMS and Studio containers each time they start. + +If you are not using Tutor, install the package directly into the LMS +and Studio Python environments with ``pip install -e ./hello_aside``, +then restart both services. + +Step 5: Enable the Aside system in the LMS +****************************************** + +The LMS gates Aside rendering on a global Django configuration model, +``XBlockAsidesConfig``. Until that model is enabled, no Aside renders on +any block, regardless of whether it is registered or installed. + +Open the LMS Django admin in your browser: + +.. code-block:: text + + http:///admin/lms_xblock/xblockasidesconfig/ + +Click :guilabel:`Add` to create a new revision. Check :guilabel:`Enabled` +and click :guilabel:`Save`. The new revision becomes the current +configuration immediately. + +The same form has a ``Disabled blocks`` field that holds a +space-separated list of block types on which asides will **never** +render. The default value is ``about course_info static_tab``. The +``hello_aside`` example targets ``problem`` blocks, which are not in the +default disabled list, so no further changes are needed. + +Studio has its own, separate gate — a different model, +``StudioConfig``, controls whether asides render in Studio, and it +defaults to disabled too. This quickstart's ``hello_aside`` only +decorates ``student_view``, so it has nothing to show in Studio either +way; see :ref:`Add an XBlock Aside` for the Studio half of enabling +asides, and for adding an author-facing view in the first place. + +Step 6: Verify the Aside is rendering +************************************* + +Open a course in the LMS, navigate to a unit that contains a Problem +block, and confirm a light blue banner reading "Hello from an XBlock +Aside!" appears below the problem. If you see the banner, your Aside is +working. + +If the banner does not appear, work through these checks in order: + +#. **The Aside is registered.** Open a Django shell and run: + + .. code-block:: python + + from xblock.core import XBlockAside + print(list(XBlockAside.load_classes())) + + The list should include ``hello_aside``. If it does not, the entry + point is not installed; recheck Step 3 and Step 4. + +#. **The block type matches.** Your test block must have + ``category == "problem"``. A Video block, an HTML block, or a + Discussion block will not trigger the Aside. + +#. **The Aside failed to load, not just to apply.** If your Aside's + type name doesn't even show up in step 1's ``load_classes()`` list, + check the logs for a warning about failing to load it as a plugin — + a broken import in your package is dropped silently at that stage, + which is what actually makes a broken Aside look like a missing + one. An exception raised *after* loading, inside + ``student_view_aside`` or ``should_apply_to_block``, is a different + situation and isn't swallowed the same way — it renders as a + visible error block in place of the banner, rather than no banner + at all; see :ref:`Add an XBlock Aside` for how to tell the two + apart. + +What You Just Built +******************* + +You have a working Aside with all four required pieces: + +A class + ``HelloAside`` subclasses ``XBlockAside``. + +A decorated view + ``student_view_aside`` is decorated with + ``@XBlockAside.aside_for("student_view")``, so it is invoked + whenever the runtime renders any block's ``student_view``. + +A filter + ``should_apply_to_block`` restricts the Aside to Problem blocks. + +An entry point + The ``xblock_asides.v1`` entry point in ``pyproject.toml`` makes the + Aside discoverable by the platform at startup. + +Every production Aside has the same four pieces, plus additional +features such as scoped fields, AJAX handlers, author-side UI, and +template-rendered HTML. + +Where to Go Next +**************** + +To turn this trivial example into something useful: + +* **Add a course-author toggle.** Declare a ``Boolean`` field with + ``Scope.settings`` and conditionally render the banner based on its + value. The :ref:`Add an XBlock Aside` how-to walks through this. +* **Render from a template.** Replace the inline HTML string with a + template loaded from your package's static assets, rendered through + the runtime's template service. +* **Add an AJAX handler.** Decorate a method with ``@XBlock.handler`` + and call it from JavaScript in your fragment to support interactive + behavior. +* **Decorate the author view.** Add a second method decorated with + ``@XBlockAside.aside_for("author_view")`` to render an author-facing + preview in Studio. + +For each of these extensions, the :ref:`XBlock Asides Reference` is the +authoritative source. For the trade-offs and current limitations of the +Aside mechanism, read :ref:`About XBlock Asides` before scaling up your +design. + +.. _Getting started: https://docs.tutor.edly.io/quickstart.html + +.. seealso:: + + :ref:`About XBlock Asides` (concept) + Why asides exist, what problem they solve, and current limitations. + + :ref:`Add an XBlock Aside` (how-to) + A step-by-step recipe for adding an Aside to existing XBlocks. + + :ref:`XBlock Asides Reference` (reference) + The complete API surface for ``XBlockAside`` and its runtime hooks. + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+ diff --git a/source/developers/references/developer_guide/extending_platform/index.rst b/source/developers/references/developer_guide/extending_platform/index.rst index 584f582cb..9e136b677 100644 --- a/source/developers/references/developer_guide/extending_platform/index.rst +++ b/source/developers/references/developer_guide/extending_platform/index.rst @@ -7,3 +7,4 @@ Extending the edX Platform extending xblocks + xblock_asides diff --git a/source/developers/references/developer_guide/extending_platform/xblock_asides.rst b/source/developers/references/developer_guide/extending_platform/xblock_asides.rst new file mode 100644 index 000000000..ad2a21a42 --- /dev/null +++ b/source/developers/references/developer_guide/extending_platform/xblock_asides.rst @@ -0,0 +1,596 @@ +.. _XBlock Asides Reference: + +####################### +XBlock Asides Reference +####################### + +.. tags:: developer, reference + +This reference describes the public API surface for XBlock Asides as +defined in the ``xblock`` package. It covers the +:class:`~xblock.core.XBlockAside` base class, the decorators and +classmethods that subclasses use, the runtime hooks that govern Aside +discovery and rendering, the entry point group that registers asides as +plugins, and the OLX serialization contract. + +For an introduction to what asides are and when to use them, see +:ref:`About XBlock Asides`. For a guided walkthrough, see +:ref:`Add an XBlock Aside` or :ref:`XBlock Aside Quickstart`. + +.. contents:: Contents + :local: + :depth: 1 + +The XBlockAside Class +********************* + +The base class for all asides is ``xblock.core.XBlockAside``. It inherits +from ``Plugin`` and ``Blocklike``, so an Aside can declare scoped fields, +mark methods as handlers, and be loaded as a plugin in the same way as an +XBlock. + +.. code-block:: python + + from xblock.core import XBlockAside + + class MyAside(XBlockAside): + """An Aside that decorates one or more XBlock views.""" + +Class Attributes +================ + +``entry_point`` + The Python entry point group used to discover Aside plugins. Set on the + base class to ``"xblock_asides.v1"``. Subclasses should not change this. + +``fields`` + The set of fields declared on the Aside, automatically collected from + class-level :class:`~xblock.fields.Field` declarations. Inherited from + ``Blocklike``. + +Decorators +********** + +``XBlockAside.aside_for(view_name)`` +==================================== + +A classmethod decorator that marks a method as the Aside view for the +named XBlock view. When the runtime renders an XBlock view called +``view_name``, every applicable Aside whose class declares a method +decorated with ``@XBlockAside.aside_for(view_name)`` is invoked, and the +returned fragments are appended to the host block's rendered fragment. + +**Signature of the decorated method** + +.. code-block:: python + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + ... + return Fragment(...) + +The decorated method takes: + +* ``self`` — the Aside instance. +* ``block`` — the host XBlock instance being rendered. +* ``context`` — an optional dictionary of context data passed by the + caller of ``render``. + +The method must return a :class:`~web_fragments.fragment.Fragment`. + +**Multiple views per Aside** + +A single method may decorate multiple views by stacking decorators, and a +single Aside class may define different methods for different views. + +.. code-block:: python + + class MyAside(XBlockAside): + + @XBlockAside.aside_for("student_view") + def student_view_aside(self, block, context=None): + return Fragment("
Student-side Aside
") + + @XBlockAside.aside_for("author_view") + @XBlockAside.aside_for("studio_view") + def studio_aside(self, block, context=None): + return Fragment("
Author/Studio Aside
") + +**Common view names** + +The view names commonly decorated by asides include: + ++----------------------+---------------------------------------------------+ +| View name | When the runtime renders it | ++======================+===================================================+ +| ``student_view`` | The learner-facing view of a block in the LMS. | ++----------------------+---------------------------------------------------+ +| ``author_view`` | The author-facing preview of a block in Studio. | ++----------------------+---------------------------------------------------+ +| ``studio_view`` | The author-facing edit form of a block in Studio. | ++----------------------+---------------------------------------------------+ + +XBlocks may define additional views; an Aside can decorate any of them by +name. + +Classmethods +************ + +``XBlockAside.should_apply_to_block(cls, block)`` +================================================= + +A classmethod that returns ``True`` if the Aside should apply to the given +``block``, and ``False`` otherwise. The default implementation returns +``True`` unconditionally. + +Override this method to restrict the Aside to specific block types, +courses, or feature flags. The runtime calls this method on every +Aside-block pair before rendering, and asides for which it returns +``False`` are skipped. + +.. code-block:: python + + @classmethod + def should_apply_to_block(cls, block): + block_type = getattr(block, "category", None) + return block_type in {"problem", "video"} + +The ``block`` argument is the host XBlock instance. Most filtering logic +inspects ``block.scope_ids.block_type``, ``block.category``, the course +context derived from ``block.scope_ids.usage_id``, or platform feature +flags. + +Instance Methods +**************** + +``aside_view_declaration(view_name)`` +===================================== + +Return the bound method on this Aside instance that is decorated with +``@XBlockAside.aside_for(view_name)``, or ``None`` if no such method +exists. The runtime uses this method to look up the Aside's view function +when rendering. Subclasses do not normally need to call it directly. + +``needs_serialization()`` +========================= + +Return ``True`` if the Aside has any field whose value differs from its +default. The default implementation iterates over the Aside's fields and +returns ``True`` if any field is set on this instance. The runtime calls +this method during OLX export to decide whether to serialize the Aside as +an XML element. Subclasses rarely need to override this. + +``parse_xml(node, runtime, keys)`` and ``add_xml_to_node(node)`` +================================================================ + +Inherited from ``Blocklike``. Override these to customize how the Aside is +read from and written to OLX. The default implementations serialize the +Aside's fields as XML attributes and child elements, identical to the +default XBlock behavior. + +Fields and Scopes +***************** + +An Aside declares fields the same way an XBlock does, with class-level +field declarations. Fields are scoped, and the scope determines where the +field's value is stored and which entities share it. + +.. code-block:: python + + from xblock.core import XBlockAside + from xblock.fields import Boolean, Scope, String + + class MyAside(XBlockAside): + enabled = Boolean( + display_name="Enabled", + default=False, + scope=Scope.settings, + help="Whether this Aside is active for this block.", + ) + + last_message = String( + default="", + scope=Scope.user_state, + help="The most recent message for this user-block pair.", + ) + +Before saving a field like ``last_message`` from a handler, see the +valid-scopes caveat below — saving a ``Scope.user_state`` field while +an Aside runs under Studio raises an error. + +The supported scopes are the standard XBlock scopes from +:mod:`xblock.fields`: ``Scope.content``, ``Scope.settings``, +``Scope.user_state``, ``Scope.user_state_summary``, +``Scope.preferences``, and ``Scope.user_info``. An Aside's field values +are conceptually stored under the Aside's own usage ID, separate from +the host block's field values — but do not assume this means two +different Aside classes can safely share a field name, even though the +abstract ``xblock`` package's own key-construction logic embeds the +Aside's entry-point type into that usage ID. + +**On the platform's Split modulestore — the default and current +modulestore — same-named ``Scope.content``/``Scope.settings`` fields on +different Asides collide, confirmed in practice.** The KVS the platform +actually uses to store course-structure data +(``SplitMongoKVS``, in ``xmodule/modulestore/split_mongo/split_mongo_kvs.py``) +buckets Aside field storage by the *host block's* type +(``key.block_scope_id.block_type``), not the Aside's own type — every +Aside attached to a given block type shares one dictionary of field +name to value for that block. The read path +(``xmodule/modulestore/split_mongo/runtime.py``) reinforces this: it +pre-merges every attached Aside's persisted fields into that single +per-block-type dictionary before any individual Aside instance is +even constructed. Two Aside classes attached to the same block type +that both declare a field named, say, ``enabled``, read and write the +*same* stored value — checking one Aside's checkbox in Studio's author +view can visibly and functionally check the other Aside's checkbox +too, even when the two Asides use entirely different JavaScript and +DOM selectors, because the underlying field value they're both bound +to really is the same one. Last-write-wins on export, too. This has +been directly reproduced with `ol-openedx-chat`_ and +`rapid-response-xblock`_ installed together, sharing a field name. + +Choose a field name that no other Aside on your deployment is likely +to use — the confirmed collision above makes this a real data-safety +requirement, not just a debugging convenience. This is separate from, +and adds to, the render- and JavaScript-layer interference described +in the :ref:`About XBlock Asides` concept doc's "Multiple Asides on a +Single Block Are Not Reliable" limitation. + +**Only ``Scope.content`` and ``Scope.settings`` can be saved while an +Aside is running under Studio, confirmed in practice.** +``xmodule/modulestore/split_mongo/split_mongo_kvs.py:24`` defines +``SplitMongoKVS.VALID_SCOPES = (Scope.parent, Scope.children, +Scope.settings, Scope.content)`` — the two internal structural scopes, +plus the two course-structure scopes discussed above. Every other +field scope (``Scope.user_state``, ``Scope.user_state_summary``, +``Scope.preferences``, ``Scope.user_info``) is absent from that list. +Calling a handler that persists a field in one of those scopes (any +code path that reaches ``block.save()`` → ``force_save_fields`` → +``_field_data.set_many`` → the KVS's ``set``/``set_many``) raises +``xblock.exceptions.InvalidScopeError`` from that same ``set()`` +method, because Studio's preview and author-view rendering is backed +by ``SplitMongoKVS``. This is distinct from the field-collision +problem above: it isn't that two Asides' values might collide, it's +that persisting a field in any of these four excluded scopes raises an +error in Studio at all, regardless of naming. If your Aside needs one +of these scopes for the learner-facing view, expect it to work only +through the LMS's runtime (not backed by ``SplitMongoKVS``), and never +invoke a save of such a field from a Studio-side handler or author +view. + +Handlers +******** + +An Aside can define AJAX handlers using the standard +``@XBlock.handler`` decorator from ``xblock.core``. The handler URL is +generated by the runtime in the same way as for an XBlock, so an Aside's +view fragment can call its own handler with +``self.runtime.handler_url(self, "handler_name")``. + +.. code-block:: python + + from xblock.core import XBlock, XBlockAside + + class MyAside(XBlockAside): + + @XBlock.handler + def submit_feedback(self, request, suffix=""): + ... + return Response(json_body={"ok": True}) + +The handler signature, request object, and response handling are +identical to XBlock handlers. + +Entry Point Registration +************************ + +An Aside is discovered by the runtime through a Python entry point in the +``xblock_asides.v1`` group. Declare the entry point in the package's +``pyproject.toml``: + +.. code-block:: toml + + [project.entry-points."xblock_asides.v1"] + my_aside = "my_package.Aside:MyAside" + +Or in ``setup.py``: + +.. code-block:: python + + setup( + entry_points={ + "xblock_asides.v1": [ + "my_aside = my_package.Aside:MyAside", + ], + }, + ) + +The entry point name on the left of the equals sign is the Aside's +**type name**. It is used as the XML tag when the Aside is serialized to +OLX, and as the key in :class:`~xblock.scopes.ScopeIds` when an Aside +instance is constructed. Choose a name that is unique across all +installed asides on a deployment. + +Entry points are loaded through ``XBlockAside.load_classes()``, which +defaults to ``fail_silently=True``: if an Aside's module raises an +exception on import, the loader logs a warning and simply omits that +Aside from the registered list, rather than raising. A broken Aside +package can therefore fail to load with no error visible anywhere +except the log — see :ref:`Add an XBlock Aside` for how this shows up +in practice when troubleshooting a missing Aside. + +Runtime API +*********** + +The methods listed below are defined on +:class:`~xblock.runtime.Runtime` and govern Aside discovery, instantiation, +and rendering. Aside subclasses do not normally call these directly. They +are documented here so runtime implementors and Aside authors can +understand the lifecycle. + +Discovery +========= + +``runtime.applicable_aside_types(block)`` + Return the list of Aside type names that may apply to ``block``. The + default implementation returns every Aside class registered through + the ``xblock_asides.v1`` entry point. A runtime may override this to + filter by user, course, or other context. + + The edx-platform LMS runtime overrides this through + ``lms_applicable_aside_types`` in + ``lms/djangoapps/lms_xblock/runtime.py``, which gates Aside rendering + on the ``XBlockAsidesConfig`` Django configuration model. When the + model's current revision has ``enabled=False``, no asides render in + the LMS. When enabled, asides do not render on block types listed in + the model's ``disabled_blocks`` field (default value: + ``"about course_info static_tab"``). + + **Studio has its own, separate gate.** The CMS overrides Aside + discovery independently, through ``preview_applicable_aside_types`` + in ``cms/djangoapps/contentstore/views/preview.py``, which consults + ``StudioConfig.asides_enabled(block_type)`` — a second + ``ConfigurationModel``, defined in + ``cms/djangoapps/xblock_config/models.py``, with the same shape as + ``XBlockAsidesConfig`` (an ``enabled`` flag plus a ``disabled_blocks`` + field, same default: ``"about course_info static_tab"``). This gate + applies to every preview view Studio + renders for a block — ``student_view``, ``public_view``, and + ``author_view`` alike — not just one of them. + + ``XBlockAsidesConfig`` and ``StudioConfig`` are two independent + ``ConfigurationModel`` rows. Like every ``ConfigurationModel``, each + one defaults to ``enabled=False`` until an operator explicitly saves + an enabled revision, so out of the box **no Aside renders in either + the LMS or Studio**, regardless of installation or registration. + Enabling one does not enable the other — an operator who wants an + Aside to render in both the LMS and Studio must enable both models. + See :ref:`Add an XBlock Aside` for the administrative steps. + + **Asides in the Authoring MFE.** The Authoring micro-frontend has no + native code for rendering or toggling Asides. Its unit editor page + embeds the Studio unit page in an iframe + (``container_embed_handler`` in + ``cms/djangoapps/contentstore/views/component.py``), so an Aside's + ``author_view`` fragment — gated by ``StudioConfig`` exactly as + described above — appears inside that iframe when authors edit a + unit. There is no separate MFE-specific toggle. The embedded page + and the MFE are not otherwise isolated: they already exchange a real + ``postMessage`` today (a ``saveEditedXBlockData`` message the + embedded page sends after a save, which the MFE listens for — see + :ref:`Add an XBlock Aside` for where this shows up in an Aside's own + JavaScript). + +``runtime.load_aside_type(aside_type)`` + Return the :class:`XBlockAside` subclass corresponding to the given + ``aside_type`` string. Raises if no Aside is registered under that + name. + +Instantiation +============= + +``runtime.create_aside(block_type, keys)`` + Construct an Aside instance of the named ``block_type`` (the Aside + type name, despite the parameter name) with the given ``ScopeIds``. + Returns an :class:`XBlockAside` instance. + +``runtime.get_aside_of_type(block, aside_type)`` + Construct an Aside of the named type that is bound to the given host + ``block``. Generates the Aside's definition and usage IDs from the + host block's IDs using the runtime's ``id_generator``. Returns an + :class:`XBlockAside` instance. + +``runtime.get_aside(aside_usage_id)`` + Construct an Aside instance from a previously known Aside usage ID. + Used during OLX import and other paths where the Aside's identity is + already established. + +``runtime.get_asides(block)`` + Return the list of Aside instances that should decorate the given + ``block``. This method composes ``applicable_aside_types`` and each + Aside's ``should_apply_to_block`` filter, returning only the asides + for which both pass. + +Rendering +========= + +``runtime.render_asides(block, view_name, frag, context)`` + Called by the runtime's ``render`` method after the block's own view + has produced its fragment. Iterates over ``get_asides(block)``, looks + up each Aside's view declaration for ``view_name``, and dispatches + layout to ``layout_asides``. Returns the augmented + :class:`Fragment`. + +``runtime.layout_asides(block, context, frag, view_name, aside_frag_fns)`` + Execute the Aside view functions and combine their fragments with the + block's fragment. The default implementation appends each Aside's + wrapped fragment after the block's fragment. Override this to control + the placement, ordering, or conditional inclusion of asides. Any + override must call ``wrap_aside`` around each Aside fragment to + preserve client-side identification. + +``runtime.wrap_aside(block, Aside, view, frag, context)`` + Wrap an Aside's fragment with a ``
`` carrying the Aside's usage + ID, the host block's usage ID, and any JavaScript initialization + metadata. Override this if you need a different wrapping element or + different ``data-`` attributes. + +Talking to the Learning MFE via postMessage +******************************************** + +An Aside's fragment renders inside the same iframe as its host XBlock +whenever the Learning micro-frontend displays that block, so an Aside's +own JavaScript can use the browser's ``postMessage`` API to reach the +parent MFE page. There are three layers to this, from "already works" +to "build your own": + +**Built-in message types.** ``frontend-app-learning`` listens for a +small set of message types on the iframe's parent window, split across +two hooks: ``useIFrameBehavior.ts`` (``plugin.resize``, +``plugin.videoFullScreen``, ``plugin.autoAdvance``, and a bare +``{ offset }`` scroll message) and ``useModalIFrameData.js`` +(``plugin.modal`` / ``plugin.modal-close``). Both listeners branch only +on ``event.data.type`` and never check the sender, so any Aside can +trigger them with no MFE-side changes: + +.. code-block:: javascript + + window.parent.postMessage( + {type: 'plugin.modal', payload: {open: true}}, + learningMfeBaseUrl + ); + +Avoid reusing ``plugin.resize`` for this purpose — the host page +already posts it from its own document-size observer on every DOM +mutation, so an Aside posting the same type competes with that loop +instead of adding a new capability. ``plugin.modal`` has no such +collision. + +**Custom message type plus your own listener — the general recipe.** +The four built-in types above are the only ones ``frontend-app-learning`` +recognizes out of the box. For anything else — a custom drawer, a +bespoke widget — an Aside can still post a message with any ``type`` it +likes, but something has to be listening for it. That "something" is a +deployment-time customization, not a core MFE feature: the Learning +MFE loads an ``env.config.jsx`` file at startup (the standard Open edX +MFE runtime-configuration mechanism), which can run arbitrary +side-effect JavaScript in addition to ordinary config values — for +example, dynamically importing and initializing a small script that +does its own ``window.addEventListener('message', ...)``, checks +``event.origin`` against an allow-listed origin and ``event.data.type`` +against the string(s) it cares about, and mounts whatever UI it wants +in response. The recipe has two halves: + +#. The Aside's JavaScript posts + ``{type: 'your-namespace::your-event', payload: {...}}`` to a known + target origin. +#. The Learning MFE's ``env.config.jsx`` (or equivalent build-time + customization) registers a listener for that exact type and origin. + +Neither half requires forking or patching ``frontend-app-learning`` +itself. + +**A working example of the pattern.** MIT Open Learning's +`ol-openedx-chat`_ Aside implements exactly this: its +``ai_chat.js`` posts ``{type: "smoot-design::tutor-drawer-open", ...}`` +to a target origin sourced from ``settings.LEARNING_MICROFRONTEND_URL`` +(passed into the fragment's JavaScript through +``initialize_js(json_args=...)``, which is the safer, explicit +convention — prefer it over relying on ``document.referrer``). On the +receiving side, MIT's deployment injects `smoot-design`_'s +``AiDrawerManager`` bundle through their own ``env.config.jsx``, which +listens for that exact message type (and its current name, +``smoot-design::ai-drawer-open``) and mounts a chat drawer. This is +cited as one concrete, running implementation of the pattern above, not +as something every deployment needs to depend on — an operator can +build an equivalent listener around any custom message type without +using MIT's packages at all. + +OLX Serialization +***************** + +When a course is exported to OLX, the runtime serializes every Aside +that returns ``True`` from ``needs_serialization()`` as an XML child +element of its host block. The XML tag of the Aside element is the +Aside's entry point name. Field values are written as attributes or +nested elements according to the Aside's ``add_xml_to_node`` +implementation. + +On import, the runtime detects Aside elements by looking up their tag +names in the registered ``xblock_asides.v1`` entry points. If a tag +does not resolve to a registered Aside, the import does not raise an +error and does not report a failure — but it also does not simply +drop the Aside element and keep the host block. **The host block that +carried the unresolvable Aside is dropped from the course entirely,** +with no error block, no placeholder, and nothing in the import result +indicating anything went wrong. Field values for any +successfully-matched Aside are read by its ``parse_xml`` implementation +as usual. + +Two practical consequences: + +* An Aside's data only round-trips through OLX if both the source and + destination platforms have the same Aside installed under the same + entry point name. If they don't, the cost isn't limited to that + Aside's data — the host block goes missing too. +* Some XBlocks do not preserve Aside child elements through their own + export and import paths. See :ref:`About XBlock Asides` for the + current list of known issues. + +Render Pipeline Summary +*********************** + +The full sequence of calls when a runtime renders an XBlock view is: + +#. ``runtime.render(block, view_name, context)`` is called. +#. The runtime invokes the block's view function and obtains a fragment. +#. The runtime calls ``runtime.wrap_xblock`` on that fragment. +#. The runtime calls ``runtime.render_asides(block, view_name, frag, context)``. +#. ``render_asides`` calls ``runtime.get_asides(block)``, which uses + ``applicable_aside_types(block)`` and each Aside's + ``should_apply_to_block(block)`` to compute the filtered list. An + exception here is not caught by ``xblock`` either — confirmed in + both Studio and the Learning MFE, it renders as an error block the + same as an exception raised inside a view method (see the note on + ``layout_asides`` below). +#. For each surviving Aside, ``render_asides`` calls + ``Aside.aside_view_declaration(view_name)`` to find the matching + method. +#. ``render_asides`` calls ``layout_asides``, which invokes each Aside + view function, calls ``wrap_aside`` on each result, and appends the + wrapped fragments to the block's fragment. Neither ``xblock`` nor + ``layout_asides`` catches an exception raised by an Aside's view + function here — it propagates out of the render call. A platform + catches it at a higher layer instead: an exception raised inside an + Aside's ``student_view`` renders as an error block in place of the + host block in the Learning MFE, and an exception raised inside + ``author_view`` renders as an error block on the unit page in + Studio. Either way, a broken Aside visibly breaks its host block's + display rather than failing invisibly. (This is distinct from an + Aside failing to *load* as a plugin, which is silent — see "Entry + Point Registration" above.) +#. The combined fragment is returned to the original caller. + +.. seealso:: + + :ref:`About XBlock Asides` (concept) + Why asides exist, what problem they solve, and current limitations. + + :ref:`Add an XBlock Aside` (how-to) + A step-by-step recipe for adding an Aside to existing XBlocks. + + :ref:`XBlock Aside Quickstart` (quickstart) + A beginner-friendly walkthrough from zero to a running Aside. + +.. _ol-openedx-chat: https://github.com/mitodl/open-edx-plugins/tree/main/src/ol_openedx_chat +.. _rapid-response-xblock: https://github.com/mitodl/open-edx-plugins/tree/main/src/rapid_response_xblock +.. _smoot-design: https://github.com/mitodl/smoot-design + +**Maintenance chart** + ++--------------+-------------------------------+----------------+--------------------------------+ +| Review Date | Working Group Reviewer | Release |Test situation | ++--------------+-------------------------------+----------------+--------------------------------+ +| | | | | ++--------------+-------------------------------+----------------+--------------------------------+