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 #240

Merged
Merged
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
177 changes: 177 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,131 @@ 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 hooks 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 tutormfe.hooks import PLUGIN_SLOTS

PLUGIN_SLOTS.add_items([
# Hide the default footer
(
"all",
"footer_slot",
"""
{
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'default_contents',
}"""
),
# Insert a custom footer
(
"all",
"footer_slot",
"""
{
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. To begin with, we're using tutormfe's own ``PLUGIN_SLOTS`` filter. It's a regular Tutor filter, but you won't find it in the main ``tutor`` package:

.. code-block:: python

from tutormfe.hooks import PLUGIN_SLOTS

Next up, we're adding actual slot configuration, starting by hiding the default footer. The first parameter in a filter item specifies which MFE to apply the slot configuration to; for example: ``"learner-dashboard"``, or ``"learning"``. We're using ``"all"`` here, which is a special case: it means the slot configuration should be applied to all MFEs that actually have that slot. (If a particular MFE doesn't have the slot, it will just ignore its configuration.)

The second parameter, ``"footer_slot"``, is the name of the slot as defined in the code of the MFE itself.

.. code-block:: python

PLUGIN_SLOTS.add_items([
# Hide the default footer
(
"all",
"footer_slot",
"""
{
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'default_contents',
}"""
),

The last parameter to ``add_item()`` is a big string with the actual slot configuration, which will be interpreted as JSX. What we're doing there is hiding the default contents of the footer with a ``PLUGIN_OPERATIONS.Hide``. (You can 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.) And the ``default_contents`` widget ID we're targetting always refers to what's in an unconfigured slot by default.
arbrandes marked this conversation as resolved.
Show resolved Hide resolved

In the second filter item, we once again target the ``"footer_slot"`` on ``"all"`` MFEs. This time, we use ``PLUGIN_OPERATIONS.Insert`` to add our custom JSX component, comprised of a simple ``<h1>`` message we're defining in an anonymous function. We give it a widgetID of ``custom_footer``:

.. code-block:: python

# Insert a custom footer
(
"all",
"footer_slot",
"""
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_footer',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1>This is the footer.</h1>
),
},
}"""
)

That's it! If you rebuild the ``mfe`` image after enabling the plugin (via ``tutor images build mfe`` or ``tutor local launch``), "This is the footer." should appear at the bottom of every MFE.

It's also possible to target a specific MFE's footer. For instance:

.. code-block:: python

PLUGIN_SLOTS.add_items([
# Hide the custom footer
(
"profile",
"footer_slot",
"""
{
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'custom_footer',
}"""
),
# Insert a footer just for the Profile MFE
(
"profile",
"footer_slot",
"""
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_profile_footer',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1>This is the Profile MFE's footer.</h1>
),
},
}"""
)
])

Note that here we're assuming you didn't remove the global footer configuration defined by the filter items targeting ``"all"``, so you have to hide ``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.

For more complex frontend plugins, you should make use of ``mfe-env-config-*`` patches to define your JSX components separately. For instance, you could create an NPM plugin package, install it via ``mfe-dockerfile-post-npm-install``, import the desired components via ``mfe-env-config-buildtime-imports``, then refer to them with the ``PLUGIN_SLOTS`` filter as described above. Refer to the `patch catalog <#template-patch-catalog>`_ below for more details.


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

Expand Down Expand Up @@ -411,6 +536,58 @@ 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-buildtime-imports
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Use this patch for any 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 for all MFEs.

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

Note that if you want to only import a module for a particular MFE, doing it here won't work: you'll probably want to use the ``mfe-env-config-runtime-definitions-{}`` patch described below.

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

mfe-env-config-buildtime-definitions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Use this patch for arbitrary ``env.config.jsx`` javascript code that gets evaluated at build time. It is particularly useful for defining slightly more complex components for use in plugin slots.

There's no version of this patch that runs per MFE. If you want to define MFE-specific code, you should use the MFE-specific ``mfe-env-config-runtime-definitions-{}`` to achieve the same effect.

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

mfe-env-config-runtime-definitions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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. Just make sure to use `import() function <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import>`_ syntax:

.. code-block:: javascript

const mymodule1 = await import('mymodule1');
const { default: myComponent } = await import('mymodule2');

Note the second line in the example above: default module exports work a little differently with ``import()``. To use the default export you can destructure the imported module, but you have to explicitly rename the ``default`` key, as `documented in MDN <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import#importing_defaults>`_.

Warning: if the dynamic import of a module fails for whatever reason, ``env.config.jsx`` execution will fail silently.

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

mfe-env-config-runtime-definitions-{}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

With this patch you can conditionally import modules or define code 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.

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

mfe-env-config-runtime-final
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

At this point, ``env.config.jsx`` is ready to return the ``config`` object to the initialization code at runtime. 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
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)
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()

PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []] = Filter()
24 changes: 17 additions & 7 deletions 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, PLUGIN_SLOTS

# Handle version suffix in main mode, just like tutor core
if __version_suffix__:
Expand Down Expand Up @@ -82,20 +82,20 @@ def _add_core_mfe_apps(apps: dict[str, MFE_ATTRS_TYPE]) -> dict[str, MFE_ATTRS_T
return apps


@functools.lru_cache(maxsize=None)
@tutor_hooks.lru_cache
def get_mfes() -> dict[str, MFE_ATTRS_TYPE]:
"""
This function is cached for performance.
"""
return MFE_APPS.apply({})


@tutor_hooks.Actions.PLUGIN_LOADED.add()
def _clear_get_mfes_cache(_name: str) -> None:
@tutor_hooks.lru_cache
def get_plugin_slots(mfe_name: str) -> list[tuple[str, str]]:
"""
Don't forget to clear cache, or we'll have some strange surprises...
This function is cached for performance.
"""
get_mfes.cache_clear()
return [i[-2:] for i in PLUGIN_SLOTS.iterate() if i[0] == mfe_name]
Copy link
Contributor

Choose a reason for hiding this comment

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

iterate can only be called on filters that return a list of things. I think that the annotation of PLUGIN_SLOTS is incorrect. It should be:

PLUGIN_SLOTS: Filter[list[tuple[str, str, str]], []]

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nice catch! This solved the linting cascade.



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


def iter_plugin_slots(mfe_name: str) -> t.Iterable[tuple[str, str]]:
"""
Yield:

(slot_name, plugin_config)
"""
yield from get_plugin_slots(mfe_name)


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


def get_mfe(mfe_name: str) -> MFE_ATTRS_TYPE:
def get_mfe(mfe_name: str) -> t.Union[MFE_ATTRS_TYPE, t.Any]:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@hinakhadim, your previous comment got me on the right track, thanks - but I didn't want to change running code just for the sake of a type hint, so this is what I did. It seems there's significant precedent for Any in the codebase, so I expect it to be fine.

Copy link
Contributor

Choose a reason for hiding this comment

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

The issue is caused by the lru_cache decorator, which is not correctly typed. You can bypass this issue without Any by writing:

def get_mfe(mfe_name: str) -> MFE_ATTRS_TYPE:
    mfe: MFE_ATTRS_TYPE = get_mfes().get(mfe_name, {})
    return mfe

return get_mfes().get(mfe_name, {})


Expand All @@ -120,6 +129,7 @@ def get_mfe(mfe_name: str) -> MFE_ATTRS_TYPE:
[
("get_mfe", get_mfe),
("iter_mfes", iter_mfes),
("iter_plugin_slots", iter_plugin_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
51 changes: 51 additions & 0 deletions tutormfe/templates/mfe/build/mfe/env.config.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{{- patch("mfe-env-config-buildtime-imports") }}

function addPlugins(config, slot_name, plugins) {
if (slot_name in config.pluginSlots === false) {
Copy link
Contributor

@regisb regisb Nov 22, 2024

Choose a reason for hiding this comment

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

nitpick: Is this idiomatic? Shouldn't it be if(!(slot_name in config.pluginSlots))? Or if(config.pluginSlots[slot_name] === null)?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think only if (slot_name in config.pluginSlots) is fine.

Copy link
Collaborator Author

@arbrandes arbrandes Nov 22, 2024

Choose a reason for hiding this comment

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

On syntax: when testing for whether properties exist or not, Javascript is a minefield. You have to make sure you're not testing the value of the property if it exists. This is one of the reasons why the in operator came about.

And truthiness is also a minefield in Javascript. === false is the only way to be absolutely sure. It's why MDN uses it in their example.

Either way, we do have to use the negative case, here. It avoids duplicating code.

config.pluginSlots[slot_name] = {
keepDefault: true,
plugins: []
};
}

config.pluginSlots[slot_name].plugins.push(...plugins);
}

{{- patch("mfe-env-config-buildtime-definitions") }}

async function setConfig () {
let config = {
pluginSlots: {}
};

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-runtime-definitions") }}

{%- for slot_name, plugin_config in iter_plugin_slots("all") %}
addPlugins(config, '{{ slot_name }}', [{{ plugin_config }}]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any particular reason for adding plugin_config object one by one?

Could we pass array for one plugin_slot (keeping in mind multiple plugins can have same slots)?
image

Like this:

PLUGIN_SLOTS.add_items([
        (
            mfe,
            "footer_slot",
            """
            [
    {
        op: PLUGIN_OPERATIONS.Hide,
        widgetId: 'default_contents',
    }
    {
      op: PLUGIN_OPERATIONS.Insert,
      widget: {
        id: 'default_contents',
        type: DIRECT_PLUGIN,
        priority: 1,
        RenderWidget: <Footer />,
      },
    },
    {
      op: PLUGIN_OPERATIONS.Insert,
      widget: {
        id: 'read_theme_cookie',
        type: DIRECT_PLUGIN,
        priority: 2,
        RenderWidget: AddDarkTheme,
      },
    },
  ]"""
        ),
    ])

Copy link
Contributor

Choose a reason for hiding this comment

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

Apologies, I just realized that objects are already passing in array. I need to pass without array brackets:

PLUGIN_SLOTS.add_items([
        (
            mfe,
            "footer_slot",
            """
            
    {
        op: PLUGIN_OPERATIONS.Hide,
        widgetId: 'default_contents',
    },
    {
      op: PLUGIN_OPERATIONS.Insert,
      widget: {
        id: 'default_contents',
        type: DIRECT_PLUGIN,
        priority: 1,
        RenderWidget: <Footer />,
      },
    },
    {
      op: PLUGIN_OPERATIONS.Insert,
      widget: {
        id: 'read_theme_cookie',
        type: DIRECT_PLUGIN,
        priority: 2,
        RenderWidget: AddDarkTheme,
      },
    },
  """
        ),
    ])

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There are different ways you can load the configuration in. @regisb suggested we document it one-by-one to avoid issues with trailing commas. Though in this latest iteration of env.config.jsx it doesn't really matter if there are trailing commas or not, I guess it keeps it simple.

Otherwise, yes you can totally use a single add_item() with a string that has multiple ops for one slot.

{%- endfor %}

{%- for app_name, _ in iter_mfes() %}
if (process.env.APP_ID == '{{ app_name }}') {
{{- patch("mfe-env-config-runtime-definitions-{}".format(app_name)) }}

{%- for slot_name, plugin_config in iter_plugin_slots(app_name) %}
addPlugins(config, '{{ slot_name }}', [{{ plugin_config }}]);
{%- endfor %}
}
{%- endfor %}

{{- patch("mfe-env-config-runtime-final") }}
} catch { }

return config;
}

export default setConfig;
Loading