diff --git a/source/developers/concepts/about_xblock_asides.rst b/source/developers/concepts/about_xblock_asides.rst index a475edf4b..19db356d9 100644 --- a/source/developers/concepts/about_xblock_asides.rst +++ b/source/developers/concepts/about_xblock_asides.rst @@ -12,6 +12,11 @@ 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 @@ -135,21 +140,44 @@ 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 ******************* -Two implementations in the wild illustrate the range of what asides can -do. +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. 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. Course authors enable it per problem in Studio. The repository -name calls it an "xblock" but the implementation is purely an Aside. +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 ======================== @@ -160,7 +188,30 @@ 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 and per-course settings. +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 *********** @@ -171,15 +222,35 @@ 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 Authoring Story in the Course Authoring MFE -============================================== - -The Studio author view for an Aside is rendered by the legacy course -authoring frontend. The current Authoring micro-frontend has no -defined location to display Aside author UI. If your project depends on the -new MFE for authoring, plan to render the Aside's author UI through a -different mechanism, or accept that authors will use the legacy Studio for -this part of the workflow. +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. What it does have is +a unit editor that embeds the legacy Studio unit page in an iframe, so +an Aside's ``author_view`` UI (a checkbox, a tag picker, whatever the +Aside renders) shows up inside that embedded page when authors use the +new MFE, exactly as it would in legacy Studio, gated by the same +``StudioConfig`` setting. 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 ====================================== @@ -188,17 +259,31 @@ 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. +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. Two asides that both decorate ``student_view`` on the same -block may render correctly in isolation and break when combined. If you -need this, build a single Aside that composes both behaviors rather than -relying on two independent asides to coexist. +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 share the *same stored +value* on the platform's Split modulestore, confirmed by directly +installing `ol-openedx-chat`_ and `rapid-response-xblock`_ together +with both fields renamed to ``enabled``: toggling one Aside's checkbox +in Studio's author view visibly checks the other Aside's checkbox too, +even though the two render entirely different markup and JavaScript. +See :ref:`XBlock Asides Reference` for the mechanism. 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 ===================================== @@ -216,11 +301,12 @@ 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 -`StructuredTagsAside`_ may also be a helpful reference. +`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 -.. _xblock-sdk: https://github.com/openedx/xblock-sdk .. _StructuredTagsAside: https://github.com/openedx/openedx-platform/blob/release/verawood/cms/lib/xblock/tagging/tagging.py#L17 .. seealso:: diff --git a/source/developers/how-tos/add-an-xblock-aside.rst b/source/developers/how-tos/add-an-xblock-aside.rst index 79ac0ec8e..2ea3e2db7 100644 --- a/source/developers/how-tos/add-an-xblock-aside.rst +++ b/source/developers/how-tos/add-an-xblock-aside.rst @@ -48,14 +48,25 @@ Create a new directory for the Aside package, with the layout below: ├── pyproject.toml ├── feedback_badge_aside/ │ ├── __init__.py - │ └── Aside.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 6 `. +for the entry point you will add in :ref:`Step 8 `. .. code-block:: toml @@ -107,13 +118,30 @@ OLX export and import. class FeedbackBadgeAside(XBlockAside): """Adds a feedback link to learner-facing views of supported blocks.""" - enabled = Boolean( + 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. A +Boolean field named ``enabled`` on this Aside and an identically-named +``Scope.settings`` field on a completely unrelated, independently +installed Aside attached to the same block type read and write the +*same* stored value. This has been directly reproduced: installing +`ol-openedx-chat`_ and `rapid-response-xblock`_ together with both +renamed to ``enabled`` makes checking one Aside's checkbox in Studio +also check the other's, even though they render different markup and +JavaScript. See :ref:`XBlock Asides Reference` for the mechanism. 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 ************************************************** @@ -122,6 +150,9 @@ Add one method per XBlock view you want to decorate, using 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 @@ -132,7 +163,7 @@ a :class:`~web_fragments.fragment.Fragment`. class FeedbackBadgeAside(XBlockAside): """Adds a feedback link to learner-facing views of supported blocks.""" - enabled = Boolean( + is_feedback_badge_enabled = Boolean( display_name="Show feedback link", default=True, scope=Scope.settings, @@ -142,7 +173,7 @@ a :class:`~web_fragments.fragment.Fragment`. @XBlockAside.aside_for("student_view") def student_view_aside(self, block, context=None): """Render the feedback link for the learner view.""" - if not self.enabled: + if not self.is_feedback_badge_enabled: return Fragment("") block_id = block.scope_ids.usage_id.block_id @@ -152,21 +183,159 @@ a :class:`~web_fragments.fragment.Fragment`. ) return Fragment(html) - @XBlockAside.aside_for("studio_view") - def studio_view_aside(self, block, context=None): - """Render the author-side toggle UI in Studio.""" - checked = "checked" if self.enabled else "" - html = ( - f'' +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 "", ) - return Fragment(html) + 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: -For production code, render templates from files with the runtime's -template service rather than building HTML strings inline. The strings -above keep the example readable. +.. code-block:: text -Step 5: Filter to specific block types + + +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 @@ -199,7 +368,7 @@ the import and export paths where these may not be available; see .. _register entry point: -Step 6: Register the Aside as an entry point +Step 8: Register the Aside as an entry point ******************************************** In ``pyproject.toml``, add an entry point in the ``xblock_asides.v1`` @@ -216,7 +385,7 @@ 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 7: Install the package and restart services +Step 9: Install the package and restart services ************************************************ Install the package into the LMS and Studio Python environments. With @@ -224,19 +393,27 @@ Tutor: .. code-block:: bash - tutor mounts add /path/to/feedback_badge_aside - tutor dev launch + 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 8: Enable asides in the LMS -******************************** +Step 10: Enable asides in the LMS and Studio +********************************************* -The edx-platform LMS gates Aside rendering on a Django configuration -model, ``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. +The LMS and Studio each gate Aside rendering on their own, separate +Django configuration model. Enabling one does not enable the other. -Open the LMS Django admin and create a new configuration revision: +**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 @@ -256,21 +433,41 @@ There is no per-course allowlist and no per-Aside-type allowlist. Once 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 5. +in Step 7. -The Studio runtime does not consult this configuration. Asides render -in Studio author views independently of ``XBlockAsidesConfig``, as soon -as they are installed and registered. +**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: -Step 9: Verify the Aside is rendering -************************************* +.. 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. + +If your project uses the new Authoring MFE, there's no separate switch +to look for there: the MFE's unit editor embeds the same legacy 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 in the new +MFE, exactly as it does in legacy Studio. + +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 in Studio and -confirm the toggle appears in the studio view. +block. To verify the author-side UI, open the same block's unit page in +Studio (or the Authoring MFE) 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: +If the Aside does not appear at all: #. Check that the entry point is registered. Run: @@ -279,28 +476,102 @@ If the Aside does not appear: from xblock.core import XBlockAside print(list(XBlockAside.load_classes())) - in a Django shell. Your Aside's type name should be in the list. - + 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. -#. Check the LMS and Studio logs for any exceptions raised inside your - Aside view. + +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 simple cases, the Learning MFE already recognizes a few built-in +message types with no extra setup 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 + ); + +(Avoid ``plugin.resize`` for this purpose — the host page already posts +it on its own from a document-size observer, so an Aside posting the +same type competes with that loop instead of adding a new capability.) + +For anything the built-in types don't cover — a custom drawer, a +bespoke widget — post your own message type, and pair it with a +listener you register yourself: + +.. code-block:: javascript + + window.parent.postMessage( + {type: "your-namespace::your-event", payload: {...}}, + learningMfeBaseUrl + ); + +Nothing in ``frontend-app-learning`` reacts to a made-up type by +default, so this only works once something is listening for it. That +listener is a deployment-time addition — the Learning MFE loads an +``env.config.jsx`` file at startup that can run arbitrary side-effect +JavaScript, including dynamically importing and initializing a small +script that does its own ``window.addEventListener("message", ...)``, +checks ``event.origin`` and ``event.data.type``, and mounts whatever UI +it wants in response. Neither half of this requires forking or +patching ``frontend-app-learning``. + +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``. + +MIT Open Learning's "AskTIM" chat button is a complete, running example +of this whole pattern: `ol-openedx-chat`_'s ``ai_chat.js`` posts a +custom message type to the Learning MFE, and a small companion script +MIT deploys alongside the MFE (built on `smoot-design`_'s +``AiDrawerManager``, registered through their own ``env.config.jsx``) +listens for it and opens a chat drawer. Treat it as a reference to read, +not a dependency to add — the pattern works with any custom message +type and any listener you write yourself. See :ref:`XBlock Asides +Reference` for the full mechanics. Next Steps ********** Once the basic Aside is working, common follow-ups include: -* **Add an AJAX handler.** Decorate a method with ``@XBlock.handler`` - and call it from the rendered fragment with - ``self.runtime.handler_url(self, "handler_name")``. -* **Render from templates.** Use the runtime's template service to - render HTML from ``.html`` files in your package's static assets. -* **Persist user-specific state.** Add fields with - ``Scope.user_state`` to store per-learner data alongside the Aside. +* **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 @@ -317,6 +588,11 @@ mechanism, see :ref:`About XBlock Asides`. :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 +.. _smoot-design: https://github.com/mitodl/smoot-design + **Maintenance chart** +--------------+-------------------------------+----------------+--------------------------------+ diff --git a/source/developers/quickstarts/quickstart_xblock_aside.rst b/source/developers/quickstarts/quickstart_xblock_aside.rst index 3ae66b2ca..c5917240f 100644 --- a/source/developers/quickstarts/quickstart_xblock_aside.rst +++ b/source/developers/quickstarts/quickstart_xblock_aside.rst @@ -127,13 +127,14 @@ Tutor and relaunch the development environment: .. code-block:: bash - tutor mounts add ./hello_aside - tutor dev launch + 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 install the local package into -both the LMS and Studio containers each time they start. The -``dev launch`` command rebuilds and restarts the containers so the new -Aside is picked up. +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``, @@ -162,9 +163,12 @@ 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. -The Studio runtime does not consult this model. Asides will render in -Studio author views as soon as they are installed, independently of -whether ``XBlockAsidesConfig`` is enabled. +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 ************************************* @@ -190,10 +194,17 @@ If the banner does not appear, work through these checks in order: ``category == "problem"``. A Video block, an HTML block, or a Discussion block will not trigger the Aside. -#. **No exceptions are being swallowed.** Check the LMS logs for any - exception raised inside ``student_view_aside`` or - ``should_apply_to_block``. The runtime catches some Aside exceptions - silently, which can make a broken Aside look like a missing one. +#. **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 ******************* diff --git a/source/developers/references/developer_guide/extending_platform/xblock_asides.rst b/source/developers/references/developer_guide/extending_platform/xblock_asides.rst index 7e3ce0b74..3c260ca27 100644 --- a/source/developers/references/developer_guide/extending_platform/xblock_asides.rst +++ b/source/developers/references/developer_guide/extending_platform/xblock_asides.rst @@ -194,12 +194,70 @@ field's value is stored and which entities share it. 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 stored under the Aside's own usage ID, separate from the host block'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 ******** @@ -254,6 +312,14 @@ 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 *********** @@ -279,9 +345,42 @@ Discovery 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"``). The Studio runtime does not - apply this gate. See :ref:`Add an XBlock Aside` for the - administrative steps to enable the configuration. + ``"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 legacy 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 use the + new MFE. 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 @@ -337,6 +436,77 @@ Rendering 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 ***************** @@ -348,15 +518,22 @@ 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. Tag names -that do not resolve to a registered Aside are ignored. Field values are -then read by the Aside's ``parse_xml`` implementation. +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. + 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. @@ -372,13 +549,27 @@ The full sequence of calls when a runtime renders an XBlock view is: #. 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. + ``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. + 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:: @@ -392,6 +583,10 @@ The full sequence of calls when a runtime renders an XBlock view is: :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** +--------------+-------------------------------+----------------+--------------------------------+