Skip to content

Commit

Permalink
feat: support frontend plugins via env.config.jsx
Browse files Browse the repository at this point in the history
This provides a mechanism to configure frontend plugins using patches to
a base `env.config.jsx`, in such a way that multiple plugins can take
advantage of the file (including for purposes beyond frontend plugins)
without clobbering each other.
  • Loading branch information
arbrandes committed Nov 11, 2024
1 parent b7444e2 commit 92e964b
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 1 deletion.
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.

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.

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``

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()
61 changes: 60 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,40 @@
},
}

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 +116,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 +130,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()


def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]:
Expand All @@ -107,6 +156,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 +178,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)) %}
{{- 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;

0 comments on commit 92e964b

Please sign in to comment.