Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: support frontend plugins via env.config.jsx #234

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions 20241111_172451_arbrandes_frontend_plugin_support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- [Improvement] Adds support for frontend plugin slot configuration via env.config.jsx. (by @arbrandes)
175 changes: 175 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,117 @@ In case you need to run additional instructions just before the build step you c

You can find more patches in the `patch catalog <#template-patch-catalog>`_ below.

Using Frontend Plugin Slots
~~~~~~~~~~~~~~~~~~~~~~~~~~~

It's possible to take advantage of this plugin's patches to configure frontend plugin slots. Let's say you want to replace the entire footer with a simple message. Where before you might have had to fork ``frontend-component-footer``, the following is all that's currently needed:

.. code-block:: python

from tutor import hooks

hooks.Filters.ENV_PATCHES.add_item(
(
"mfe-env-config-plugin-footer_slot",
"""
{
/* Hide the default footer. */
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'default_contents',
},
{
/* Insert a custom footer. */
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_footer',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1>This is the footer.</h1>
),
},
},
"""
)
)

Let's take a closer look at what's happening here, starting with the patch name: the prefix is ``mfe-env-config-plugin-`` followed by the name of the slot you're trying to configure. In this case, ``footer_slot``. (There's a full list of `possible slot names <#mfe-env-config-plugin-slot-catalog>`_ below.) Next, we're hiding the default contents of the footer with a ``PLUGIN_OPERATIONS.Hide``. (Refer to the `frontend-plugin-framework README <https://github.com/openedx/frontend-plugin-framework/#>`_ for a full description of the possible plugin types and operations.) The ``default_contents`` widget ID always refers to what's in an unconfigured slot. Finally, we use ``PLUGIN_OPERATIONS.Insert`` to add our custom JSX component, composed of a simple ``<h1>`` message. We give it a widgetID of ``custom_footer``.

That's it! If you rebuild the ``mfe`` image after enabling the plugin, "This is the footer." should appear at the bottom of every MFE.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be linked to instructions to rebuild the mfe image?


It's also possible to target a specific MFE's footer. For that, you'd use a patch such as ``mfe-env-config-plugin-profile-footer_slot``, ``profile`` being the name of the MFE and ``footer_slot`` the name of the slot:

.. code-block:: python

hooks.Filters.ENV_PATCHES.add_item(
(
"mfe-env-config-plugin-profile-footer_slot",
"""
{
/* Hide the global footer we defined earlier. */
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'custom_footer',
},
{
/* Insert a custom footer specific to the Profile MFE. */
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_profile_footer',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1>This is the Profile MFE's footer.</h1>
),
},
},
"""
)
)

Note that we haven't removed the first ``mfe-env-config-plugin-footer_slot`` patch, so here we hide our ``custom_footer`` instead of ``default_contents``.

If you were to rebuild the MFE image now, the Profile MFE's footer would say "This is the Profile MFE's footer", whereas all the others would still contain the global "This is the footer." message.

.. _mfe-env-config-plugin-slot-catalog:

Here's a list of the currently available frontend plugin slots, separated by category. The documentation of each slot can be found by following the corresponding link.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the strategy to keep this document up to date? Is there a template for new plugin documentation within the MFE that might include a note to update the tutor readme?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figure the strategy can be similar to the one for new feature and waffle flags in edx-platform. Which is to say, one of two things happens:

  • As soon as a new flag/slot is merged in the upstream repo, somebody also opens a PR to Tutor to support it. This could indeed benefit from some sort of automated reminder, but given that new MFE features are more and more commonly required to work with PR sandboxes as part of the review process, and that PR sandboxes are built with Tutor, adding these to Tutor nightly will simply be a built-in step in getting the slot merged.

  • When Tutor and its plugins are updated when a new new release is coming out, part of the work is combing through upstream repos for added and changed features. This should become less and less necessary as Tutor nightly gets "automatically" updated as described above, but if by any chance a slot or two were missed, this is where they would be added.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, ty

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, and one neat thing about this PR is that it will make it possible for PR sandboxes to configure slots for testing (once the Grove plugin is updated to match).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach has one major drawback: Implementing a new slot will require making an upstream PR to tutor-mfe: this will cause a lot of back-and-forth, especially as we intend to have many more plugin slots in Open edX.

Another minor drawback is the requirement to create a Tutor plugin for users who implement their own plugin slots.

As far as I understand, there is little benefit to having an explicit list of slots here. All it gives us is some syntax sugar. We can achieve a similar result with the following MFE-specific filter:

tutormfe.hooks.Filters.PLUGIN_SLOTS.add_item((
            "profile", "footer_slot",
            """
              {
                /* Hide the global footer we defined earlier. */
                op: PLUGIN_OPERATIONS.Hide,
                widgetId: 'custom_footer',
              }"""
))
tutormfe.hooks.Filters.PLUGIN_SLOTS.add_item((
            "profile", "footer_slot",
              """{
                /* Insert a custom footer specific to the Profile MFE. */
                op: PLUGIN_OPERATIONS.Insert,
                widget: {
                  id: 'custom_profile_footer',
                  type: DIRECT_PLUGIN,
                  RenderWidget: () => (
                    <h1>This is the Profile MFE's footer.</h1>
                  )
                }
              }
))"""

Copy link
Collaborator Author

@arbrandes arbrandes Nov 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but listing all the slots gives us one major advantage: it will be very evident what slots are actually available for a particular version of Tutor. Sure, we can still document them here anyway, but if we make it so a slot only works if it's explicitly supported, then it's also obligatory to document a slot when it's added here.

In any case, I'll give the PLUGIN_SLOTS filter a shot. I'm not sure yet if it's going to work with the single env.config.jsx approach I tried here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, here's the third take: #240


Header slots, available in MFEs that use ``frontend-component-header``:

- `course_info_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/CourseInfoSlot#course-info-slot>`_
- `desktop_header_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/DesktopHeaderSlot#desktop-header-slot>`_
- `desktop_logged_out_items_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/DesktopLoggedOutItemsSlot#desktop-logged-out-items-slot>`_
- `desktop_main_menu_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/DesktopMainMenuSlot#desktop-main-menu-slot>`_
- `desktop_secondary_menu_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/DesktopSecondaryMenuSlot#desktop-secondary-menu-slot>`_
- `desktop_user_menu_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/DesktopUserMenuSlot#desktop-user-menu-slot>`_
- `learning_help_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/LearningHelpSlot#learning-help-slot>`_
- `learning_logged_out_items_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/LearningLoggedOutItemsSlot#learning-logged-out-items-slot>`_
- `learning_user_menu_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/LearningUserMenuSlot#learning-user-menu-slot>`_
- `logo_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/LogoSlot#logo-slot>`_
- `mobile_header_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/MobileHeaderSlot#mobile-header-slot>`_
- `mobile_logged_out_items_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/MobileLoggedOutItemsSlot#mobile-logged-out-items-slot>`_
- `mobile_main_menu_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/MobileMainMenuSlot#slot-id-mobile_main_menu_slot>`_
- `mobile_user_menu_slot <https://github.com/openedx/frontend-component-header/tree/v5.7.1/src/plugin-slots/MobileUserMenuSlot#mobile-user-menu-slot>`_

The footer slot, available in MFEs that use ``frontend-slot-footer``:

- `footer_slot <https://github.com/openedx/frontend-slot-footer/tree/v1.0.6?tab=readme-ov-file#frontend-slot-footer>`_

A slot only available in the Account MFE:

- `id_verification_page_plugin <https://github.com/openedx/frontend-app-account/tree/open-release/sumac.master/src/plugin-slots/IdVerificationPageSlot#slot-id-id_verification_page_plugin>`_

Slots only available in the Learner Dashboard MFE:

- `course_card_action_slot <https://github.com/openedx/frontend-app-learner-dashboard/tree/open-release/sumac.master/src/plugin-slots/CourseCardActionSlot#course-card-action-slot>`_
- `course_list_slot <https://github.com/openedx/frontend-app-learner-dashboard/tree/open-release/sumac.master/src/plugin-slots/CourseListSlot#course-list-slot>`_
- `no_courses_view_slot <https://github.com/openedx/frontend-app-learner-dashboard/tree/open-release/sumac.master/src/plugin-slots/NoCoursesViewSlot#no-courses-view-slot>`_
- `widget_sidebar_slot <https://github.com/openedx/frontend-app-learner-dashboard/tree/open-release/sumac.master/src/plugin-slots/WidgetSidebarSlot#widget-sidebar-slot>`_

Slots only available in the Learning MFE:

- `header_slot <https://github.com/openedx/frontend-app-learning/tree/open-release/sumac.master/src/plugin-slots/HeaderSlot#header-slot>`_
- `sequence_container_slot <https://github.com/openedx/frontend-app-learning/tree/open-release/sumac.master/src/plugin-slots/SequenceContainerSlot#sequence-container-slot>`_
- `unit_title_slot <https://github.com/openedx/frontend-app-learning/tree/open-release/sumac.master/src/plugin-slots/UnitTitleSlot#slot-id-unit_title_slot>`_

Installing from a private npm registry
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -411,6 +522,70 @@ This is the list of all patches used across tutor-mfe (outside of any plugin). A
cd tutor-mfe
git grep "{{ patch" -- tutormfe/templates

mfe-env-config-static-imports
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Use this patch for any additional static imports you need in ``env.config.jsx``. They will be available here if you used the `mfe-docker-post-npm-install patch <#mfe-docker-post-npm-install>`_ to install an NPM package globally.

It gets rendered at the very top of the file. You should use normal `ES6 import syntax <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import>`_.

File changed: ``tutormfe/templates/mfe/build/mfe/env.config.jsx``

mfe-env-config-head
~~~~~~~~~~~~~~~~~~~

Use this patch for arbitrary ``env.config.jsx`` javascript code. It gets rendered immediately after static imports, and is particularly useful for defining slightly more complex components for use in plugin slots.

File changed: ``tutormfe/templates/mfe/build/mfe/env.config.jsx``
Comment on lines +525 to +539

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since mfe-env-config-static-imports-{} and mfe-env-config-dynamic-imports-{} don't exist, it might be nice to note these are only available as a "all MFEs" thing and not possible to use on a per-MFE basis.

Edit: same applies to mfe-env-config-tail


mfe-env-config-dynamic-imports
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

This patch gets rendered inside an ``async`` function in ``env.config.jsx`` that runs in the browser, allowing you to define conditional imports for external modules that may only be available at runtime. The following syntax is recommended:

.. code-block:: javascript

const mymodule1 = await import('mymodule1');
const { mycomponent1, mycomponent2 } = await import('mymodule2');

Note that if any module is not available at runtime, ``env.config.jsx`` execution will fail silently.

File changed: ``tutormfe/templates/mfe/build/mfe/env.config.jsx``

mfe-env-config-dynamic-imports-{}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

With this patch you can conditionally import a module for specific MFEs in ``env.config.jsx``. This is a useful place to put an import if you're using the ``mfe-docker-post-npm-install-*`` patch to install a plugin that only works on a particular MFE.

As above, make sure to use the `import() function <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import>`_. Failures here don't abort ``env.config.jsx`` processing entirely, but do short-circuit any MFE-specific configuration.

File changed: ``tutormfe/templates/mfe/build/mfe/env.config.jsx``

mfe-env-config-plugin-{}
~~~~~~~~~~~~~~~~~~~~~~~~

Use these to configure plugins in particular slots via ``env.config.jsx``. In this form, the suffix is one of the `possible slot names <#mfe-env-config-plugin-slot-catalog>`_. For instance, ``mfe-env-config-plugin-header_slot``.

You should provide a list of plugin operations, as per the expectations of `frontend-plugin-framework <https://github.com/openedx/frontend-plugin-framework/#>`_.

File changed: ``tutormfe/templates/mfe/build/mfe/env.config.jsx``

mfe-env-config-plugin-{}-{}
~~~~~~~~~~~~~~~~~~~~~~~~~~~

In this second form of plugin slot configuration via ``env.config.jsx``, the first suffix is the MFE name, and the last one after the dash is the slot name. For example, ``mfe-env-config-plugin-learner-dashboard-course_list_slot``.

It expects the same list of plugin operations as the ``mfe-env-config-plugin-{}`` form, the difference being that it is only applicable to the MFE in question.

File changed: ``tutormfe/templates/mfe/build/mfe/env.config.jsx``

mfe-env-config-tail
~~~~~~~~~~~~~~~~~~~

At this point, ``env.config.jsx`` is ready to return the ``config`` object to the initialization code. You can use this patch to do anything to the object, including using modules that were imported dynamically earlier.

File changed: ``tutormfe/templates/mfe/build/mfe/env.config.jsx``

mfe-lms-development-settings
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions tutormfe/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@
MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]]

MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter()

MFE_SLOTS: Filter[list[str], []] = Filter()
58 changes: 57 additions & 1 deletion tutormfe/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from tutor.types import Config, get_typed

from .__about__ import __version__
from .hooks import MFE_APPS, MFE_ATTRS_TYPE
from .hooks import MFE_APPS, MFE_ATTRS_TYPE, MFE_SLOTS

# Handle version suffix in nightly mode, just like tutor core
if __version_suffix__:
Expand Down Expand Up @@ -73,6 +73,37 @@
},
}

CORE_MFE_SLOTS: list[str] = [
# frontend-component-header
"course_info_slot",
"desktop_header_slot",
"desktop_logged_out_items_slot",
"desktop_main_menu_slot",
"desktop_secondary_menu_slot",
"desktop_user_menu_slot",
"learning_help_slot",
"learning_logged_out_items_slot",
"learning_user_menu_slot",
"logo_slot",
"mobile_header_slot",
"mobile_logged_out_items_slot",
"mobile_main_menu_slot",
"mobile_user_menu_slot",
# frontend-component-footer
"footer_slot",
# account
"id_verification_page_plugin",
# learner-dashboard
"course_card_action_slot",
"course_list_slot",
"no_courses_view_slot",
"widget_sidebar_slot",
# learning
"header_slot",
"sequence_container_slot",
"unit_title_slot",
]


# The core MFEs are added with a high priority, such that other users can override or
# remove them.
Expand All @@ -82,6 +113,12 @@ def _add_core_mfe_apps(apps: dict[str, MFE_ATTRS_TYPE]) -> dict[str, MFE_ATTRS_T
return apps


@MFE_SLOTS.add(priority=tutor_hooks.priorities.HIGH)
def _add_core_mfe_slots(slots: list[str]) -> list[str]:
slots.extend(CORE_MFE_SLOTS)
return slots


@functools.lru_cache(maxsize=None)
def get_mfes() -> dict[str, MFE_ATTRS_TYPE]:
"""
Expand All @@ -90,12 +127,21 @@ def get_mfes() -> dict[str, MFE_ATTRS_TYPE]:
return MFE_APPS.apply({})


@functools.lru_cache(maxsize=None)
def get_slots() -> list[str]:
"""
This function is cached for performance.
"""
return MFE_SLOTS.apply([])


@tutor_hooks.Actions.PLUGIN_LOADED.add()
def _clear_get_mfes_cache(_name: str) -> None:
"""
Don't forget to clear cache, or we'll have some strange surprises...
"""
get_mfes.cache_clear()
get_slots.cache_clear()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not do that. This will break in some edge cases. Instead, both get_slots and get_mfes should use tutor.hooks.lru_cache.



def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]:
Expand All @@ -107,6 +153,15 @@ def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]:
yield from get_mfes().items()


def iter_slots() -> t.Iterable[str]:
"""
Yield:

name
"""
yield from get_slots()


def is_mfe_enabled(mfe_name: str) -> bool:
return mfe_name in get_mfes()

Expand All @@ -120,6 +175,7 @@ def get_mfe(mfe_name: str) -> MFE_ATTRS_TYPE:
[
("get_mfe", get_mfe),
("iter_mfes", iter_mfes),
("iter_slots", iter_slots),
("is_mfe_enabled", is_mfe_enabled),
]
)
Expand Down
1 change: 1 addition & 0 deletions tutormfe/templates/mfe/build/mfe/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ ENV PUBLIC_PATH='/{{ app_name }}/'
# So we point to a relative url that will be a proxy for the LMS.
ENV MFE_CONFIG_API_URL=/api/mfe_config/v1
ARG ENABLE_NEW_RELIC=false
COPY env.config.jsx /openedx/app
{{ patch("mfe-dockerfile-pre-npm-build") }}
{{ patch("mfe-dockerfile-pre-npm-build-{}".format(app_name)) }}

Expand Down
53 changes: 53 additions & 0 deletions tutormfe/templates/mfe/build/mfe/env.config.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{{- patch("mfe-env-config-static-imports") }}
{{- patch("mfe-env-config-head") }}

async function getConfig () {
let config = {};

try {
/* We can't assume FPF exists, as it's not declared as a dependency in all
* MFEs, so we import it dynamically. In addition, for dynamic imports to
* work with Webpack all of the code that actually uses the imported module
* needs to be inside the `try{}` block.
*/
const { DIRECT_PLUGIN, PLUGIN_OPERATIONS } = await import('@openedx/frontend-plugin-framework');
{{- patch("mfe-env-config-dynamic-imports") }}

config = {
pluginSlots: {
{%- for slot_name in iter_slots() %}
{%- if patch("mfe-env-config-plugin-{}".format(slot_name)) %}
{{ slot_name }}: {
keepDefault: true,
plugins: [
{{- patch("mfe-env-config-plugin-{}".format(slot_name)) }}
]
},
{%- endif %}
{%- endfor %}
}
};

{%- for app_name, app in iter_mfes() %}
if (process.env.npm_package_name == '@edx/frontend-app-{{ app_name }}') {
{%- if patch("mfe-env-config-dynamic-imports-{}".format(app_name)) %}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized this if is redundant. I had another try-catch here before, but it's not necessary, and no longer is the if. I'll rip it out.

{{- patch("mfe-env-config-dynamic-imports-{}".format(app_name)) }}
{%- endif %}

{%- for slot_name in iter_slots() %}
{%- if patch("mfe-env-config-plugin-{}-{}".format(app_name, slot_name)) %}
config.pluginSlots.{{ slot_name }}.plugins.push(
{{- patch("mfe-env-config-plugin-{}-{}".format(app_name, slot_name)) }}
);
{%- endif %}
{%- endfor %}
}
{%- endfor %}

{{- patch("mfe-env-config-tail") }}
} catch { }

return config;
}

export default getConfig;