diff --git a/README.rst b/README.rst
index 391140eb..cdec93cd 100644
--- a/README.rst
+++ b/README.rst
@@ -18,6 +18,10 @@ In addition, this plugin comes with a few MFEs which are enabled by default:
- `Profile `__
- `Catalog `__
+In addition, this plugin bundles a number of "core plugins": frontend plugin packages injected into the MFEs above via the plugin slot framework. The following core plugins are enabled by default:
+
+- `Notifications `__ (a notifications tray in the MFE headers)
+
Instructions for using each of these MFEs are given below.
Installation
@@ -182,6 +186,30 @@ To disable an existing MFE, remove the corresponding entry from the ``MFE_APPS``
mfes.pop("profile")
return mfes
+Core plugins
+~~~~~~~~~~~~
+
+Core plugins are bundled frontend plugin packages that ship with tutor-mfe and are injected into the MFEs via the plugin slot framework. They are enabled by default, but operators can disable any of them by popping the corresponding entry from the ``CORE_PLUGINS`` filter, symmetrically to how ``MFE_APPS`` works.
+
+The following core plugins are currently bundled:
+
+- ``notifications``: the `notifications tray `__, added to the learning and Studio headers. Its behaviour can be tuned with three configuration settings:
+
+ - ``NOTIFICATIONS_DEFAULT_FROM_EMAIL`` (default: inherits from ``CONTACT_EMAIL``): the sender address used by notification emails.
+ - ``NOTIFICATIONS_ENABLE_SHOW_EMAIL_CHANNEL`` (default: ``True``): whether to show the email channel in the notification preferences UI.
+ - ``NOTIFICATIONS_ENABLE_SHOW_PUSH_CHANNEL`` (default: ``False``): whether to show the push channel in the notification preferences UI.
+
+To disable the notifications tray (or any other core plugin), add a Tutor plugin with:
+
+.. code-block:: python
+
+ from tutormfe.hooks import CORE_PLUGINS
+
+ @CORE_PLUGINS.add()
+ def _disable_notifications(plugins):
+ plugins.pop("notifications", None)
+ return plugins
+
Using custom translations to your MFEs
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/changelog.d/20260420_core_plugins_notifications.md b/changelog.d/20260420_core_plugins_notifications.md
new file mode 100644
index 00000000..f514aaa7
--- /dev/null
+++ b/changelog.d/20260420_core_plugins_notifications.md
@@ -0,0 +1,2 @@
+- [Feature] Introduce the ``CORE_PLUGINS`` filter, a new extension point for bundled frontend plugin packages that ship with tutor-mfe. Operators can disable any core plugin by popping its name from the filter, symmetrically to how ``MFE_APPS`` works. (by @arbrandes)
+- [Feature] Bundle the notifications tray (``@edx/frontend-plugin-notifications``) as the first core plugin, enabled by default. This replaces the need for a standalone ``tutor-contrib-platform-notifications`` plugin. (by @arbrandes)
diff --git a/tutormfe/hooks.py b/tutormfe/hooks.py
index d88c7b44..bd6f3566 100644
--- a/tutormfe/hooks.py
+++ b/tutormfe/hooks.py
@@ -12,6 +12,10 @@
MFE_ATTRS_TYPE = t.Dict[t.Literal["repository", "port", "version"], t.Union["str", int]]
+CORE_PLUGIN_ATTRS_TYPE = t.Dict[t.Literal["npm_package", "npm_version"], str]
+
MFE_APPS: Filter[dict[str, MFE_ATTRS_TYPE], []] = Filter()
+CORE_PLUGINS: Filter[dict[str, CORE_PLUGIN_ATTRS_TYPE], []] = Filter()
+
PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter()
diff --git a/tutormfe/patches/lms-env b/tutormfe/patches/lms-env
new file mode 100644
index 00000000..1713aa91
--- /dev/null
+++ b/tutormfe/patches/lms-env
@@ -0,0 +1,3 @@
+{% if is_core_plugin_enabled("notifications") %}
+NOTIFICATIONS_DEFAULT_FROM_EMAIL: '{{ NOTIFICATIONS_DEFAULT_FROM_EMAIL }}'
+{% endif %}
diff --git a/tutormfe/patches/openedx-common-settings b/tutormfe/patches/openedx-common-settings
new file mode 100644
index 00000000..606a4684
--- /dev/null
+++ b/tutormfe/patches/openedx-common-settings
@@ -0,0 +1,3 @@
+{% if is_core_plugin_enabled("notifications") %}
+NOTIFICATIONS_DEFAULT_FROM_EMAIL = ENV_TOKENS.get("NOTIFICATIONS_DEFAULT_FROM_EMAIL", ENV_TOKENS["CONTACT_EMAIL"])
+{% endif %}
diff --git a/tutormfe/patches/openedx-lms-production-settings b/tutormfe/patches/openedx-lms-production-settings
index b83a06d0..1ce1004b 100644
--- a/tutormfe/patches/openedx-lms-production-settings
+++ b/tutormfe/patches/openedx-lms-production-settings
@@ -94,3 +94,8 @@ CSRF_TRUSTED_ORIGINS.append("{% if ENABLE_HTTPS %}https://{% else %}http://{% en
{{ patch("mfe-lms-common-settings") }}
{{ patch("mfe-lms-production-settings") }}
+
+{% if is_core_plugin_enabled("notifications") %}
+MFE_CONFIG["SHOW_EMAIL_CHANNEL"] = {{ NOTIFICATIONS_ENABLE_SHOW_EMAIL_CHANNEL }}
+MFE_CONFIG["SHOW_PUSH_CHANNEL"] = {{ NOTIFICATIONS_ENABLE_SHOW_PUSH_CHANNEL }}
+{% endif %}
diff --git a/tutormfe/plugin.py b/tutormfe/plugin.py
index 4314a6ae..0ce4b373 100644
--- a/tutormfe/plugin.py
+++ b/tutormfe/plugin.py
@@ -13,7 +13,13 @@
from tutor.types import Config, get_typed
from .__about__ import __version__
-from .hooks import MFE_APPS, MFE_ATTRS_TYPE, PLUGIN_SLOTS
+from .hooks import (
+ CORE_PLUGIN_ATTRS_TYPE,
+ CORE_PLUGINS,
+ MFE_APPS,
+ MFE_ATTRS_TYPE,
+ PLUGIN_SLOTS,
+)
# Handle version suffix in main mode, just like tutor core
if __version_suffix__:
@@ -91,6 +97,25 @@ def _add_core_mfe_apps(apps: dict[str, MFE_ATTRS_TYPE]) -> dict[str, MFE_ATTRS_T
return apps
+# Core plugins are bundled frontend plugin packages (and associated wiring) that ship
+# enabled by default. Operators can disable any of them by popping the name from the
+# CORE_PLUGINS filter, symmetric to how MFE_APPS works.
+DEFAULT_CORE_PLUGINS: dict[str, CORE_PLUGIN_ATTRS_TYPE] = {
+ "notifications": {
+ "npm_package": "@edx/frontend-plugin-notifications",
+ "npm_version": "^2.0.3",
+ },
+}
+
+
+@CORE_PLUGINS.add(priority=tutor_hooks.priorities.HIGH)
+def _add_default_core_plugins(
+ plugins: dict[str, CORE_PLUGIN_ATTRS_TYPE],
+) -> dict[str, CORE_PLUGIN_ATTRS_TYPE]:
+ plugins.update(DEFAULT_CORE_PLUGINS)
+ return plugins
+
+
@tutor_hooks.lru_cache
def get_mfes() -> dict[str, MFE_ATTRS_TYPE]:
"""
@@ -99,6 +124,27 @@ def get_mfes() -> dict[str, MFE_ATTRS_TYPE]:
return MFE_APPS.apply({})
+@tutor_hooks.lru_cache
+def get_core_plugins() -> dict[str, CORE_PLUGIN_ATTRS_TYPE]:
+ """
+ This function is cached for performance.
+ """
+ return CORE_PLUGINS.apply({})
+
+
+def iter_core_plugins() -> t.Iterable[tuple[str, CORE_PLUGIN_ATTRS_TYPE]]:
+ """
+ Yield:
+
+ (name, dict)
+ """
+ yield from get_core_plugins().items()
+
+
+def is_core_plugin_enabled(name: str) -> bool:
+ return name in get_core_plugins()
+
+
class MFEMountData:
"""Stores categorized mounted and unmounted MFEs."""
@@ -158,6 +204,8 @@ def get_mfe(mfe_name: str) -> t.Union[MFE_ATTRS_TYPE, t.Any]:
("iter_mfes", iter_mfes),
("iter_plugin_slots", iter_plugin_slots),
("is_mfe_enabled", is_mfe_enabled),
+ ("iter_core_plugins", iter_core_plugins),
+ ("is_core_plugin_enabled", is_core_plugin_enabled),
("MFEMountData", MFEMountData),
]
)
@@ -327,6 +375,16 @@ def _build_3rd_party_dev_mfes_on_launch(
list(config.get("overrides", {}).items())
)
+# Notifications core plugin settings. Registered unconditionally: harmless if the
+# plugin is disabled, since nothing will consume them.
+tutor_hooks.Filters.CONFIG_DEFAULTS.add_items(
+ [
+ ("NOTIFICATIONS_ENABLE_SHOW_EMAIL_CHANNEL", True),
+ ("NOTIFICATIONS_ENABLE_SHOW_PUSH_CHANNEL", False),
+ ("NOTIFICATIONS_DEFAULT_FROM_EMAIL", "{{ CONTACT_EMAIL }}"),
+ ]
+)
+
# Actions
@tutor_hooks.Actions.CONFIG_LOADED.add()
diff --git a/tutormfe/templates/mfe/build/mfe/Dockerfile b/tutormfe/templates/mfe/build/mfe/Dockerfile
index 7a470822..7eac37ce 100644
--- a/tutormfe/templates/mfe/build/mfe/Dockerfile
+++ b/tutormfe/templates/mfe/build/mfe/Dockerfile
@@ -46,6 +46,9 @@ ENV CPPFLAGS=-DPNG_ARM_NEON_OPT=0
{#- We define this environment variable to bypass an issue with the installation of pact https://github.com/pact-foundation/pact-js-core/issues/264 #}
ENV PACT_SKIP_BINARY_INSTALL=true
RUN --mount=type=cache,target=/root/.npm,sharing=shared npm clean-install --no-audit --no-fund --registry=$NPM_REGISTRY
+{%- for _plugin_name, plugin in iter_core_plugins() %}
+RUN --mount=type=cache,target=/root/.npm,sharing=shared npm install --no-audit --no-fund --registry=$NPM_REGISTRY {{ plugin["npm_package"] }}@{{ plugin["npm_version"] }}
+{%- endfor %}
{{ patch("mfe-dockerfile-post-npm-install") }}
{{ patch("mfe-dockerfile-post-npm-install-{}".format(app_name)) }}
COPY --from={{ app_name }}-src / /openedx/app
diff --git a/tutormfe/templates/mfe/build/mfe/env.config.jsx b/tutormfe/templates/mfe/build/mfe/env.config.jsx
index f2ed3c11..72f8eb5d 100644
--- a/tutormfe/templates/mfe/build/mfe/env.config.jsx
+++ b/tutormfe/templates/mfe/build/mfe/env.config.jsx
@@ -25,9 +25,32 @@ async function setConfig () {
* needs to be inside the `try{}` block.
*/
const { DIRECT_PLUGIN, PLUGIN_OPERATIONS } = await import('@openedx/frontend-plugin-framework');
+ {%- if is_core_plugin_enabled("notifications") %}
+ const { NotificationsTray } = await import('@edx/frontend-plugin-notifications');
+ {%- endif %}
{{- patch("mfe-env-config-runtime-definitions") }}
+ {%- if is_core_plugin_enabled("notifications") %}
+ {%- for slot_name in [
+ "org.openedx.frontend.layout.header_desktop_secondary_menu.v1",
+ "org.openedx.frontend.layout.header_learning_help.v1",
+ "org.openedx.frontend.layout.studio_header_search_button_slot.v1",
+ ] %}
+ addPlugins(config, '{{ slot_name }}', [
+ {
+ op: PLUGIN_OPERATIONS.Insert,
+ widget: {
+ id: 'notification-drawer-widget',
+ priority: 10,
+ type: DIRECT_PLUGIN,
+ RenderWidget: NotificationsTray,
+ },
+ },
+ ]);
+ {%- endfor %}
+ {%- endif %}
+
{%- for slot_name, plugin_config in iter_plugin_slots("all") %}
addPlugins(config, '{{ slot_name }}', [{{ plugin_config }}]);
{%- endfor %}