A Guide to Frontend Customisation With `frontend-base`

Introduction

As I posted recently there are big changes in progress in MFEs and it’ll be beneficial to get on top of them so we are well prepared for what’s coming. In this guide I’ll cover the key aspects of customising MFEs for a site and explain how everything works together.

The Basics of Frontend Sites

Open edX is moving away from having each MFE siloed away into its own package deployed independently and moving to a single “Site” which brings together all MFEs into a single package that’s deployed together and seamlessly moves between MFEs without reloading the page, loading duplicate dependencies and themes.

It all starts with a a copy of the frontend-template-site repo. You would make a fork of this customise it for a client or just use tutor-mfe to do it for you. We’ll focus on the direct approach hers so we understand what’s going on.

If you take a look at that repo you’ll see mostly boilerplate files. The most important files that we need to understand are the site.config.*.tsx files which configure the site, and the package.json file.

If you look at the package.json file you’ll see that all of the MFEs are added as dependencies of frontend-template-site. With each release a new version will be tagged and you can install it from npm rather than GitHub. You’ll also notice 0.0.0-dev as an allowed version, this allows for builds using an unreleased forked version of the MFE.

Now as for the site config files, those are the most important aspect of this repo so we’ll delve into them in more detail.

The Basics of Site Config

Think of the site configuration (site.config.tsx) as the equivalent of env.config.jsx but for an entire site rather than a single MFE. While env.config.jsx was mostly used for defining plugins, it was actually an enhancement of the previous config mechanism (the MFE Config API) and environment variables. It allowed for specifying rich JSON configuration, plugins and dynamic code that couldn’t be represented easily by those approaches.

The site configuration builds on the env.config.jsx. It is a TypeScript file that can have any kind of code, but usually it will import the MFEs you need to configure, the themes, and export the config for the entire site of multiple MFEs, plugins etc.

Let’s look at the dev file. The ‘site.config.build.tsx’ is for production deployments and only has minor differences.

Let’s start at the top, where we’re importing each MFE into this site, so that they can all be built together. Look at the following import:

import { learnerDashboardApp } from '@openedx/frontend-app-learner-dashboard';

What’s being imported here, is the configuration file for the Learner Dashboard MFE. We’ll dive into this later.

At the end of all the imports, the site config is importing the design-token-based theme from the brand package. This is now imported once for the entire site rather than for each MFE.

Finally, we have the bit that will actually be exported, which is the siteConfig. This has some site constants like the site id, the name, some key URLs, and then you have the commonAppConfig. This has the kind of config variables you’d usually supply via MFE Config API, the environment or via env.config.jsx.

The most important bit is the apps which is where all the MFEs that need to be part of the site are configured. You’ll notice that even the header and footer are installed as “apps”! An app can simply be a collection of slots.

You’ll notice how the site config isn’t passing the instructorDashboardApp as-is, it is instead overriding the base config with a different support URL.

After apps you see externalRoutes. These allow you to specify an external URL, not managed by the site as a provider for a particular “role”. This allows for the current situation where some MFEs are converted to frontend-base and part of the MFE site whereas others are still built as external components. What exactly a “role” is we’ll cover later

You could theoretically split the MFEs into multiple sites and link them together this way. You could have a different site for students, and for authors by grouping the learner dashboard, learning, profile, and account MFEs while keeping authoring admin console etc in a separate site and connecting them via external routes.

This file can also export a function that returns the siteConfig (similar to env.config.jsx).

This covers most of the key bits of the site config. Let’s now dig into the anatomy of an app.

The Basics of App Config

A site is a collection of apps, and an app’s config defines how it fits in with other apps and the site as a whole. Let’s got through an app’s config attributes one by one.

  • appId: This is just an id for the app, it should be unique for each app.
  • routes: These are react-router routes that are published by the app. This is similar to how Django’s urls.py file exports urlpatterns that are handled by that Django app.
  • providers: These context providers that the app needs, they will be wrapped around the site so you can pass data around easily. You could create an app that uses a slot to add a button in one place and another widget in another slot and have the button toggle the visibility of the widget via a context provider. This wasn’t possible before.
  • slots: These are the equivalent of pluginSlots in the env.config.jsx file. They provide the operations that need to be performed on existing slots and provides widgets for the same.
  • config and defaultConfig: The defaultConfig is something defined by the app whereas the config is something you provide via the site config.
  • provides: This is possibly the more nuanced and potentially confusing feature. This is essentially a global grab-bag of values that allow coordination between apps.

Of these routes, slots and provides (not provide*r*s) need special attention so we will discuss these in more detail starting with routes.

Basics of Routing

When each app is independent and handling its own routes things are simple and contained. However with multiple MFEs now running in the same context, the routing is now handled by the frontend-base. The apps provide the routes they handle and frontend-base provides tools to route between and within apps.

The routes are standard React Router 6 routes with an enhancement. A route can include a list of “roles” it can handle. For example the authn MFE has a login path that can handle the loginRole. This means that if any app anywhere else wants to link to the login page, instead of hardcoding a link, it can simply use the getUrlByRouteRole function in frontend-base and get the login URL from it if it knows the role ID.

The simplest way to think about it is that roles are like tags for a particular URL path. A path may have multiple roles if needed. However multiple paths shouldn’t use the same role since that can (currently) cause issues unless you know what you’re doing.

Basics of Slots

What used to be Plugin Slots are now simply slots and work a little differently than before. The plugin-slots directory is now simply slots and you’ll notice a widgets directory that contains widgets to insert into existing slots. MFEs now not only have slots for extending their UI but also provide widgets that can be used to extend the UI of other MFEs.

For instance, each MFE can customise its header by providing widgets that need to be inserted into the header. Slots now support conditional rendering so the slot config can specify the conditions in which this widget should be inserted into the slot, so widgets can be added to the header only for specific MFEs or logged in users etc. You can have widgets that only apply to certain “roles” so the authn MFE can have the footer appear different if the loginRole is active.

Like before each slot has an id, and idAliases, however now you no longer need to use pluginProps you can just directly add the props you need. A big change is the new layout option which defines how a Slot is laid out separately from how it functions. More on this later.

Now for how widgets are inserted into slots. The slot configuration shape can be seen here.

As before you need to provide a unique id for your widget, a slotId of the slot to operate on, and an op for the operation to perform. Depending on the operation you can then need to provide an element, a component, options, and a relatedId.

Unlike before where you had insert, wrap, hide, and modify, you now have:

  • widgetAppend: Add widget to the end of existing widget
  • widgetPrepend: Add widget to the start of existing widget
  • widgetInsertAfter: Add widget after a specific other widget (via relatedId)
  • widgetInsertBefore: Add widget before a specific other widget (via relatedId)
  • widgetReplace: Replace a specific widget (via relatedId)
  • widgetRemove: Remove a specific widget (via relatedId)
  • widgetOptions: Modify/add options to a specific widget (via relatedId)
  • layoutReplace: Modify the layout of widget without changing content
  • layoutOptions: Modify the options passed to the layout

When I say “existing widget” above what I mean is that if you have a slot around an existing UI, append will add another element after it, prepend before it etc. but if you do append 3 times each one will add to the end. If you do an append and then prepend you’ll get the prepended widget then the original element and then the appended widget.

Layouts are a new thing. There is no more wrap operation, instead each slot has a layout, which by default is just the contents of the slot itself. If you change the layout you can provide one that effectively wraps it. More details on this can be read in this ADR.

Another very useful new addition is conditions. You can now provide widgets for slots conditionally. You can use condition.active and condition.inactive to have a slot render if a particular role is active or inactive. You can use condition.authenticated to have a slot visible only for authenticated users. Finally there is conditions.callback for a custom function to determine when to show a widget in a slot.

Overall this new Slot system makes slots a lot more flexible and makes some common operations (conditional slots) a lot easier while making other operations (wrapping) a bit more complex.

Basics of Provides

To understand provides, let’s imagine that we have an app that provides note-taking capabilities. It renders a UI that allows students to take notes. We want this sidebar to show up in places where it makes sense. We could hardcode a list of roles where this UI should show up, but then we’d need to keep this up to date in the notes app whereas we’d want any other MFE to opt into this feature when it needs it. This is where provides comes in.

Our notes app can document a ID that it will check to see which roles want the notes UI. Any app that wants to use the notes UI can publish its list of roles under that key in provides.

For example, let’s say we pick the ID org.openedx.frontend.provides.notes.v1. Our app can then have a provides set to:

provides: {
	'org.openedx.frontend.provides.notes.v1': ['org.openedx.frontend.role.one', 'org.openedx.frontend.role.two']
}

The notes app can then use utilities frontend-base such as getProvidesAsStrings to get a list of all roles that have opted-in to this feature, and show the widget if the current role (via getActiveRoles) is one of the roles configured for this feature.

One concrete way this is used in the platform is that the frontend-base shell app uses the provides with ID org.openedx.frontend.provides.chromelessRoles.v1 to check which roles should be rendered without header or footer. The Authn MFE generally doesn’t use a header or footer so it publishes its roles to this ID, and the shell app in frontend-base then hides the header and footer for roles that opt into it.

Conclusion

There is lot to learn and understand about the new frontend-base and frontend-template-site approach to MFEs. It should unlock a lot of flexibility but does come with a learning curve. Hopefully this has been a good primer to get into this new way of doing things.