Frontend Customisation Tutorial with frontend-base

Introduction

This builds on the previous Frontend Customisation Guide by taking you through creating a simple app that uses most of the features of frontend apps. We will create an app that will provide slot overrides for an MFE, add routes that add a custom page, and use provides.

Setup

Clone the frontend-template-site repo locally. Normally you’d treat this as a template and modify things as needed, but for this example we’ll use most of it as-is.

To keep things simple we will use tutor to handle this. Rename this folder to frontend-site and mount it in tutor using tutor mounts add /path/to/frontend-site. After this you need to run tutor dev launch -I to get everything set up.

To see if everything is working well, check the logs using:

tutor dev logs --follow --tail=100 mfe-dev

This will show the build logs of the MFE site. If you edit site.config.dev.tsx you should see activity here.

What we will do to begin with, is create a new app inside this frontend-site folder itself. In src/myapp create an app.ts file. For now all this needs to have is the following code:

import { App } from '@openedx/frontend-base';

const app: App = {
  appId: 'org.openedx.frontend.myapp',
};

export default app;

Let’s import this in site.config.dev.tsx as follows:

import myApp from './src/myapp/app';

And then add it to the list of apps.

Currently it does nothing, but with this in place we can now begin working on myapp and with hot reloading everything will just work!

Adding a new route

Let’s start with something that was previously quite difficult, adding a new route to our site.

We will add a /myapp path that will just show some dummy content. Let’s first write this component in src/myapp/MyApp.tsx, just the following to begin with:

import { Slot } from '@openedx/frontend-base';

const MyApp = () => (
  <Slot id="org.openedx.frontend.slot.myapp.v1">
    <div className="container-xl">
      <h1>Welcome to MyApp!</h1>
    </div>
  </Slot>
);

export default MyApp;

Here we’re adding a simple demo page with just some text, and we’re wrapping it in a slot so we can modify it later.

Next, let’s attach this to a route with the following content in src/myapp/routes.ts:

const routes = [
  {
    path: '/myapp',
    handle: {
      roles: ['org.openedx.frontend.role.myapp'],
    },
    lazy: async () => {
      const { default: Component } = await import('./MyApp');
      return { Component };
    },
  },
];
export default routes;

As you can see, we are adding a route with a path of /myapp. We are having this path handle the specified role, will look into this later. Next we’re providing an async function called lazy that loads our new component and returns it for this path.

Now let’s add this routes to our app by importing it and adding to the app config. Your app.ts file should now look like:

import { App } from '@openedx/frontend-base';
import routes from './routes';

const app: App = {
  appId: 'org.openedx.frontend.myapp',
  routes,
};

export default app;

Now if you visit http://apps.local.openedx.io:8080/myapp you will see this new component. Notice that we automatically get the header and footer.

A quick test of provides

Let’s now quickly see how we can use provides to customise this app. We will use a provides config from the shell app to tell it that our app doesn’t need a header and footer. We can do this by adding our role to org.openedx.frontend.provides.chromelessRoles.v1. Here is that that looks like:

import { App } from '@openedx/frontend-base';
import routes from './routes';

const app: App = {
  appId: 'org.openedx.frontend.myapp',
  routes,
  provides: {
    'org.openedx.frontend.provides.chromelessRoles.v1': ['org.openedx.frontend.role.myapp'],
  }
};

export default app;

As soon as you make this change and save, you’ll notice that the header and footer will disappear from our page! This is because the shell app checks for all the roles that have been added to this provides ID and if we’re on a path that has that role it will not render the header and footer.

Let’s delete this for now, since we need the header for what’s coming next.

Adding a slot to the header

Let’s now add a link to our app to the header. For this we need to first create a component for this link. Let’s create src/myapp/MyAppLink.tsx with the following:

import { getUrlByRouteRole } from '@openedx/frontend-base';

const MyAppLink = () => {
  const url = getUrlByRouteRole('org.openedx.frontend.role.myapp');
  return url && (<a href={url}>MyApp</a>);
};

export default MyAppLink;

This is a very simple component but there is some new stuff going on here. We could have just hardcoded the URL, but instead, since we tagged that route with a role, we can simply use getUrlByRouteRole to get the full URL and use that. This would work anywhere, even in another MFE.

Let’s add this link to the header. We’ll add a slots config to app.ts which should look like the following:

import { App, WidgetOperationTypes } from '@openedx/frontend-base';
import MyAppLink from './MyAppLink';
import routes from './routes';

const app: App = {
  appId: 'org.openedx.frontend.myapp',
  routes,
  slots: [
    {
      slotId: 'org.openedx.frontend.slot.header.primaryLinks.v1',
      id: 'org.openedx.frontend.widget.myapp.links',
      op: WidgetOperationTypes.APPEND,
      component: MyAppLink,
      condition: {
        inactive: ['org.openedx.frontend.role.myapp'],
      },
    }
  ]
};

export default app;

This will seem quite familiar to you. It’s adding our new app widget to the header’s primaryLinks slot. What’s entirely new, is the condition field. We don’t want this link to show up if we’re already on myapp so we’re going to add an inactive condition with this role name so that when our role is active, this slot won’t show up.

Adding a route to an existing app

Now let’s take a slightly more complex example of adding a route to an existing app. We’ll add a myapp route to the catalog app so you can access it at catalog/courses/courseId/myapp.

Here I’m just going to give you the code to add to site.config.dev.tsx and explain it after:

catalogApp.routes![0].children!.push({
  path: 'courses/:courseId/myapp',
  async lazy() {
    const { default: Component } = await import('./src/myapp/MyApp');
    return { Component };
  }
});

Here we’re adding a child path to the existing set of routes that the catalog app has. We use the ! after routes and children because technically they can be undefined, but in this case we know they aren’t.

This is just like the route we added earlier, except it’s now adding our component to a path that’s under the catalog MFE’s base path. If you now navigate to http://apps.local.openedx.io:8080/catalog/courses/course-v1:OpenedX+DemoX+DemoCourse/myapp you will now see the existing component but under the catalog app.

You can take this a step further and add a link to this page via a slot in the catalog MFE. We won’t discuss that here since that’s a straightforward case of looking up where to add the slot and adding it.

Modifying Widget Layouts

While it’s no longer possible to use the wrap operation, Slot layouts and layout operations are a more powerful concept. A very simple way to understand Slot layouts is that, all slots are automatically wrapped with another component, which its layout and the default layout is simply an empty component that returns its contents as-is.

Let’s see how we can use layout operations to change the layout of a slot and use that to do what we normally would with a wrap operation.

Let’s first create a new layout and understand how it works. Add the following content to src/myapp/MyAppLayout.tsx:

import { useWidgets } from '@openedx/frontend-base';

const MyAppLayout = () => {
  const widgets = useWidgets();
  return (
    <div style={{ border: '3px solid red' }}>
      {widgets}
    </div>
  );
};

export default MyAppLayout;

This is a very simple demo wrap operation. This layout simply adds a thick (3px) solid red border around the component. Frontend base is providing a React hook useWidgets that lets you interact with the widgets added to the slot using this layout.

To use this layout, let’s update app.ts file to add a new layout update slot:

import { App, LayoutOperationTypes, WidgetOperationTypes } from '@openedx/frontend-base';
import MyAppLayout from './MyAppLayout';
import MyAppLink from './MyAppLink';
import routes from './routes';

const app: App = {
  appId: 'org.openedx.frontend.myapp',
  routes,
  slots: [
    {
      slotId: 'org.openedx.frontend.slot.header.primaryLinks.v1',
      id: 'org.openedx.frontend.slot.myapp.links',
      op: WidgetOperationTypes.APPEND,
      component: MyAppLink,
      condition: {
        inactive: ['org.openedx.frontend.role.myapp'],
      },
    },
    {
      slotId: 'org.openedx.frontend.slots.myapp.v1',
      op: LayoutOperationTypes.REPLACE,
      component: MyAppLayout,
      condition: {
        authenticated: true,
      }
    },
  ]
};

export default app;

This is a pretty straightforward slot config, we specify a slot ID, and set the operation as a layout replacement, specify the component that will be used as the new layout an specify the conditions in which this layout should be used.

The end result of the above slot config is that we’ll see a thick red border around our component if the user is logged in, and no border if the user is logged out.

What makes this approach more powerful is that we have a lot of flexibility in how we place widgets using a layout. Normally each append will just add another widget in an empty react fragment, but what if you could swap from a flex column layout to a flex row layout to a grid layout? Even better you can query individual widgets to arrange them in more complex ways.

For example, if you wanted to lay elements with some widgets vertically in a stack and other horizontally in a row, you could use widget.byId or widget.byRole to query individual widgets by their ID or role, and then put them in any arrangement you want.

Want even more flexibility? Your layouts can have slots of their own, which can in turn have their own layouts!

Conclusion

There are a lot of new concepts to learn with the new frontend-base package, but learning these concepts will allow us to unlock a great deal of flexibility that earlier required forking. A lot of these can take some time to wrap your head around, since a lot has has been added, changed, enhanced and removed.

Hopefully the guide and this tutorial have helped you get familiar with the basics of frontend customisation with the latest changes to MFEs.