Skip to content

openedx/frontend-plugin-framework

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

82 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Frontend Plugin Framework

License status-badge Continuous Integration Codecov

Purpose

The Frontend Plugin Framework is designed to be an extension point to customize an Open edX MFE. This framework supports two types of plugins: iFrame-based and "Direct" plugins.

A Direct plugin allows for a component in the Host MFE -- or a React dependency -- to be made into a plugin and inserted in a plugin slot within the Host MFE.

The iFrame-based Plugin allows for a component that lives in another MFE (the Child MFE) to be plugged into a slot in the Host MFE.

The primary way this is made possible is through JS-based configurations, where the changes to a plugin slot are defined (see 'Plugin Operations').

Getting Started

Using the Example Apps

  1. Run make requirements in the root directory.
  2. Run npm run start in the root directory.
  3. Open another terminal and run npm run start:example to start the example app. You can visit http://localhost:8080 to see the example app.
  4. Make change to the existing code, everything should be hot reloaded.

Add Library Dependency

Add openedx/frontend-plugin-framework to the package.json of both Host and Child MFEs.

Host Micro-frontend (MFE)

Host MFEs define PluginSlot components in areas of the UI where they intend to accept plugin extensions. The Host MFE, and thus the maintainers of the Host MFE, are responsible for deciding where it is acceptable to add a plugin slot.

The slot also determines the dimensions and responsiveness of each plugin, and supports passing any additional data to the plugin as part of its contract.

<HostApp>
  <Route path="/page1">
    <SomeHostContent />
    <PluginSlot
      id="sidebar" // this `id` is referenced in the JS-based config
      pluginProps={{ // these props are passed along to each plugin
        className: 'flex-grow-1',
        title: 'example plugins',
      }}
      style={{
        height: 700,
      }}
    >
      <SideBar
        propExampleA: 'edX Sidebar',
        propExampleB: SomeIcon,
      >
    </PluginSlot >
  </Route>
  <Route path="/page2">
    <OtherRouteContent />
  </Route>
</HostApp>

Host MFE JS-based Configuration

Micro-frontends that would like to use the Plugin Framework need to use a JavaScript-based config named env.config with either .js or .jsx as the extension. Technically, only the Host MFE requires an env.config.js file as that is where the plugin slot's configuration is defined.

However, note that any Child MFE can theoretically contain one or more PluginSlot components themselves, thereby making it both a Child MFE and a Host MFE. In this instance, the Child MFE would need its own env.config.js file as well to define its plugin slots.

// env.config.js

import { DIRECT_PLUGIN, IFRAME_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';

// import any additional dependencies or functions to be used for each plugin operation
import Sidebar from './widgets/social/Sidebar';
import SocialMediaLink from './widgets/social/SocialMediaLink';
import { wrapSidebar, modifySidebar } from './widgets/social/utils';
import { SomeIcon } from '@openedx/paragon/icons';

const config = {
  // additional environment variables
  pluginSlots: {
    sidebar: { // plugin slot id
      keepDefault: true,
      plugins: [
        {
          op: PLUGIN_OPERATIONS.Insert,
          widget: {
            id: 'social_media_link',
            type: DIRECT_PLUGIN,
            priority: 10,
            RenderWidget: SocialMediaLink,
          },
        },
        {
          op: PLUGIN_OPERATIONS.Wrap,
          widgetId: 'default_contents',
          wrapper: wrapWidget,
        },
        {
          op: PLUGIN_OPERATIONS.Modify,
          widgetId: 'social_media_link',
          fn: modifyWidget,
        },
      ]
    }
  }
}

export default config;

For more information on how JS based configuration works, see:

Priority

The priority property determines where the widgets should be placed based on a 1-100 scale. A widget with a priority of 10 will appear above a widget with a priority of 20.

Default Content

The component that is wrapped by a Plugin Slot is referred to as the "default content". In order to render this content, the keepDefault boolean in the slot should be set to true. For organizations who aren't using the Plugin Slot (and therefore aren't defining a slot via JS config), keepDefault will default to true, thus ensuring that the developer experience isn't affected; the only change to note is that the component is now wrapped in a Plugin Slot.

If you need to use a plugin operation (e.g. Wrap, Hide, Modify) on default content, the widgetId you would use to refer to the content is defaults_contents.

Note: The default content will have a priority of 50, allowing for any plugins to appear before or after the default content.

Plugin Operations

There are four plugin operations that each require specific properties.

Insert a Direct Plugin

The Insert operation will add a widget in the plugin slot. The contents required for a Direct Plugin is the same as is demonstrated in the Default Contents section above, with the content key being optional.

/*
  * {String} op - Name of plugin operation
  * {Object} widget - The component to be inserted into the slot
*/

{
  op: PLUGIN_OPERATIONS.Insert,
  widget: {
    id: 'social_media_link',
    type: DIRECT_PLUGIN,
    priority: 10,
    RenderWidget: SocialMediaLink,
  }
}
Insert an iFrame Plugin

The Insert operation will add a widget in the plugin slot. The contents required for an iFrame Plugin is the same as is demonstrated in the Default Contents section above.

/*
  * {String} op - Name of plugin operation
  * {Object} widget - The component to be inserted into the slot
*/

{
  op: PLUGIN_OPERATIONS.Insert,
  widget: {
    id: 'enterprise_navbar',
    type: IFRAME_PLUGIN,
    priority: 30,
    url: 'http://{child_mfe_url}/plugin_iframe',
    title: 'Login with XYZ',
  }
}
Modify

The Modify operation allows us to modify the contents of a widget, including its id, type, content, RenderWidget function, or its priority. The operation requires the id of the widget that will be modified and a function to make those changes.

const modifyWidget = (widget) => {
  widget.content = {
    propExampleA: 'University XYZ Sidebar',
    propExampleB: SomeOtherIcon,
  };
  return widget;
};

/*
  * {String} op - Name of plugin operation
  * {String} widgetId - The widget id needed for referencing when using Modify/Wrap/Hide
  * {Function} fn - The function to call that can modify the widget's contents and properties
*/

{
  op: PLUGIN_OPERATIONS.Modify,
  widgetId: 'sidebar_plugin',
  fn: modifyWidget,
}
Wrap

Unlike Modify, the Wrap operation adds a React component around the widget, and a single widget can receive more than one wrap operation. Each wrapper function takes in a component and id prop.

const wrapWidget = ({ component, idx }) => (
  <div className="bg-warning" data-testid={`wrapper${idx + 1}`} key={idx}>
    <p>This is a wrapper component that is placed around the widget.</p>
    {component}
    <p>With this wrapper, you can add anything before or after the widget.</p>
  </div>
);

/*
  * {String} op - Name of plugin operation
  * {String} widgetId - The widget id needed for referencing when using Modify/Wrap/Hide
  * {Function} wrapper - The function to call that can wrap the widget with a React component
*/

{
  op: PLUGIN_OPERATIONS.Wrap,
  widgetId: 'default_content_in_slot',
  wrapper: wrapWidget,
}
Hide

The Hide operation will simply hide whatever content is desired. This is generally used for the default content.

/*
  * {String} op - Name of plugin operation
  * {String} widgetId - The widget id needed for referencing when using Modify/Wrap/Hide
*/

{
  op: PLUGIN_OPERATIONS.Hide,
  widgetId: 'some_undesired_plugin',
}

Using a Child Micro-frontend (MFE) for iFrame-based Plugins and Fallback Behavior

The Child MFE is no different than any other MFE except that it can define a component that can then be pass into the Host MFE as an iFrame-based plugin via a route. This component communicates (via postMessage) with the Host MFE and resizes its content to match the dimensions available in the Host's plugin slot.

It's notoriously difficult to know in the Host MFE when an iFrame has failed to load. Because of security sandboxing, the host isn't allowed to know the HTTP status of the request or to inspect what was loaded, so we have to rely on waiting for a postMessage event from within the iFrame to know it has successfully loaded. A fallback component can be provided to the Plugin that is wrapped around the component, as noted below. Otherwise, the default Error fallback from Frontend Platform would be used.

<MyMFE>
  <Route path="/mainContent">
      <MyMainContent />
  </Route>
  <Route path="/plugin1">
    <Plugin fallbackComponent={<OtherFallback />}>
      <MyCustomContent />
    </Plugin>
  </Route>
</MyMFE>

Known Issues

Development Roadmap

The main priority in developing this library is to extract components from a Host MFE to:

  1. allow for teams to develop experimental features without impeding on any other team's work or the core functionality of the Host MFE.
  2. allow for customizing/extending the functionality of a Host MFE without having org-specific functionality in an open-source project.

Getting Help

If you're having trouble, we have discussion forums at https://discuss.openedx.org where you can connect with others in the community.

Our real-time conversations are on Slack. You can request a Slack invitation, then join our community Slack workspace. Because this is a frontend repository, the best place to discuss it would be in the #wg-frontend channel.

For anything non-trivial, the best path is to open an issue in this repository with as many details about the issue you are facing as you can provide.

https://github.com/openedx/frontend-plugin-framework/issues

For more information about these options, see the Getting Help page.

License

The code in this repository is licensed under the AGPLv3 unless otherwise noted.

Please see LICENSE for details.

Contributing

Contributions are very welcome. Please read How To Contribute for details.

This project is currently accepting all types of contributions, bug fixes, security fixes, maintenance work, or new features. However, please make sure to have a discussion about your new feature idea with the maintainers prior to beginning development to maximize the chances of your change being accepted. You can start a conversation by creating a new issue on this repo summarizing your idea.

The Open edX Code of Conduct

All community members are expected to follow the Open edX Code of Conduct.

People

The assigned maintainers for this component and other project details may be found in Backstage. Backstage pulls this data from the catalog-info.yaml file in this repo.

Reporting Security Issues

Please do not report security issues in public. Email [email protected] instead.