diff --git a/20241111_172451_arbrandes_frontend_plugin_support.md b/20241111_172451_arbrandes_frontend_plugin_support.md new file mode 100644 index 00000000..4248ed29 --- /dev/null +++ b/20241111_172451_arbrandes_frontend_plugin_support.md @@ -0,0 +1 @@ +- [Improvement] Adds support for frontend plugin slot configuration via env.config.jsx. (by @arbrandes) diff --git a/README.rst b/README.rst index 18bb1c04..66fa1cac 100644 --- a/README.rst +++ b/README.rst @@ -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: () => ( +

This is the footer.

+ ), + }, + }, + """ + ) + ) + +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 `_ 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 ``

`` 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: () => ( +

This is the Profile MFE's footer.

+ ), + }, + }, + """ + ) + ) + +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 `_ +- `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 `_ + +The footer slot, available in MFEs that use ``frontend-slot-footer``: + +- `footer_slot `_ + +A slot only available in the Account MFE: + +- `id_verification_page_plugin `_ + +Slots only available in the Learner Dashboard MFE: + +- `course_card_action_slot `_ +- `course_list_slot `_ +- `no_courses_view_slot `_ +- `widget_sidebar_slot `_ + +Slots only available in the Learning MFE: + +- `header_slot `_ +- `sequence_container_slot `_ +- `unit_title_slot `_ + Installing from a private npm registry ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -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 `_. + +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 `_. 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 `_. + +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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tutormfe/hooks.py b/tutormfe/hooks.py index e23d461d..431e80bc 100644 --- a/tutormfe/hooks.py +++ b/tutormfe/hooks.py @@ -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() diff --git a/tutormfe/plugin.py b/tutormfe/plugin.py index 6cc34fc9..a73942c5 100644 --- a/tutormfe/plugin.py +++ b/tutormfe/plugin.py @@ -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__: @@ -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. @@ -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]: """ @@ -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() def iter_mfes() -> t.Iterable[tuple[str, MFE_ATTRS_TYPE]]: @@ -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() @@ -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), ] ) diff --git a/tutormfe/templates/mfe/build/mfe/Dockerfile b/tutormfe/templates/mfe/build/mfe/Dockerfile index d68505c8..680e1804 100644 --- a/tutormfe/templates/mfe/build/mfe/Dockerfile +++ b/tutormfe/templates/mfe/build/mfe/Dockerfile @@ -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)) }} diff --git a/tutormfe/templates/mfe/build/mfe/env.config.jsx b/tutormfe/templates/mfe/build/mfe/env.config.jsx new file mode 100644 index 00000000..6c045b30 --- /dev/null +++ b/tutormfe/templates/mfe/build/mfe/env.config.jsx @@ -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;