Proposal: Simplified frontend plugin API config

I attended the fantastic frontend plugin workshop at the Open edX conference today. I had an env.config.jsx that looked like this at the end of the session:

import React from 'react';
import { Button } from '@openedx/paragon'
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';

const config = {
 pluginSlots: {
   unit_contents_slot: {
     keepDefault: true,
     plugins: [
       {
         // Display the unit ID *above* the content
         op: PLUGIN_OPERATIONS.Insert,
         widget: {
           id: 'before_unit_content',
           priority: 10, // 10 will come before the unit content, which has priority 50
           type: DIRECT_PLUGIN,
           RenderWidget: (props) => (
             <div style={{backgroundColor: 'cornflowerblue', color: 'white', padding: '8px 2px'}}>
               <small>This unit is <code style={{color: 'white'}}>{props.unitId}</code></small>
             </div>
           ),
         },
       },
       { // Blur the content until the user confirms they are ready to learn
         op: PLUGIN_OPERATIONS.Wrap,
         widgetId: 'default_contents', // Wrap the contents
         wrapper: ({ component }) => {
           const [isBlurred, setBlur] = React.useState(true);
           if (isBlurred) {
             return (
               <div style={{position: 'relative'}}>
                 <div style={{filter: 'blur(5px)', pointerEvents: 'none'}}>
                   { component }
                 </div>
                 <div style={{
                   position: 'absolute', backgroundColor: 'white', left: '10%',
                   width: '80%', top: '200px', padding: '30px', border: '1px solid darkgrey',
                 }}>
                   <p>Are you sure you want to learn this now?</p>
                   <Button onClick={() => setBlur(false)}>Yes</Button>
                 </div>
               </div>
             );
           } else {
             return <>{ component }</>;
           }
         },
       },
     ],
   },
 },
}

export default config;

and I found myself wondering: why do we need a list of operations at all? Assuming that there is only env.config.jsx file, couldn’t the plugins list just be replaced with a single component function, wrapping the slot defaults? ie:

import React from 'react';
import { Button } from '@openedx/paragon'

const config = {
  pluginSlots: {
    unit_contents_slot: (DefaultComponent, props, children) => {
      const beforeContent = (
         <div style={{backgroundColor: 'cornflowerblue', color: 'white', padding: '8px 2px'}}>
           <small>This unit is <code style={{color: 'white'}}>{props.unitId}</code></small>
         </div>
      );
      const [isBlurred, setBlur] = React.useState(true);
      if (isBlurred) {
        return (
           <>
              {beforeContent}
              <div style={{position: 'relative'}}>
                <div style={{filter: 'blur(5px)', pointerEvents: 'none'}}>
                  <DefaultComponent {...props}> {children} </DefaultComponent>
                </div>
                <div style={{
                  position: 'absolute', backgroundColor: 'white', left: '10%',
                  width: '80%', top: '200px', padding: '30px', border: '1px solid darkgrey',
                }}>
                  <p>Are you sure you want to learn this now?</p>
                  <Button onClick={() => setBlur(false)}>Yes</Button>
                </div>
              </div>
           </>
        );
      } else {
        return (
			<>
			{beforeContent}
			<DefaultComponent {...props}>
		      {children}
			<DefaultComponent/>
			</>
		)
      }
    }
  }
};
3 Likes

@braden @arbrandes @Felipe

@brian.smith mentioned that this could be made a little more user-friendly for certain operations by providing pre-defined functions rather than operation constants:


import React from 'react';
import { Button } from '@openedx/paragon'
import { REMOVE } from '@openedx/frontend-plugin-framework'

const config = {
  pluginSlots: {
    unit_contents_slot: REMOVE
  }
}

where REMOVE is predefined in FPF as:

const REMOVE = (DefaultComponent, props, children) => ( <> </> );

You could imagine some other predefined helpers like PREPEND and APPEND.

1 Like

@regis ^^^^^^

1 Like

I’m in favor of something along these lines. The current format is much clunkier than it needs to be because it has a lot of half-baked ideas, such as the assumption that it’s in a JSON file, whereas we can actually import functions and use them.

I like this too. It uses composition rather than a pre-defined and rigid set of rules. It says “put whatever you want in this slot. Here’s what’s normally there.”

If we remove the inline components and import them from somewhere else, the config document starts to get a lot simpler looking:

const config = {
  pluginSlots: {
    firstSlot: mySlotComponent,
    secondSlot: [
      pluginOne,
      pluginTwo
    ]
  }
}

I think it’s important to keep the ‘array’ syntax as an option - more on that below.

Our glossary of terms gets a bit muddy. If I were to try to adjust it off the cuff:

  • Slot: A place in the app where one or more plugins can be loaded. The app dictates how much screen space a slot is allowed to have and when. A slot lays out plugins as a flat, ordered group of children by default, and the styling on the slot determines how those are displayed (vertical, horizontal, carousel, tabs, etc.)
  • Slot layout: An override for the slot’s layout behavior - mySlotComponent above, Kyle’s innovation. Plugins are imported into this component, which lays them out however it wants (wrappers, append, prepend, etc.). The component itself is then imported into the config document.
  • Plugins: Widgets/Bits of functionality we want to add to a plugin slot - pluginOne and pluginTwo above. May be imported into the config file directly to use the default slot layout behavior, or imported into a custom slot layout to customize behavior.

As mentioned by Kyle above, we could create SlotLayout helpers for different types of layouts (i.e., the REMOVE thing). Think about trying to create a custom layout for a slot that has tabs without any help. Ouch.

Thinking out loud, we could also create a magic placeholder for the default content:

anotherSlot: [
  pluginOne,
  DEFAULT_PLUGIN,
  pluginTwo,
]

The plugin slot could use that to insert the default in that spot in the list if someone wants to use the ‘array’ syntax, which removes the need for prepend and append operations, along with the keepDefault option.

I think the ‘array’ syntax is an important option, as it allows folks to write the config in more of a ‘low code’ way, particularly if they’re an operator without tons of development experience and just want to use some third party plugins.

In general, I think we need to work hard to keep this simple and approachable. That means keep it declarative where possible, hide complexity, keep our terminology straight, useCamelCaseConsistently (pet peeve) for object keys, and provide examples and docs demonstrating best practices and how to do things.

@djoy I had a thought about the array syntax proposal.

It seems really clean in your example

but I think this actually means

const config = {
  pluginSlots: {
    secondSlot: [
      pluginOneWithNoProps,
      pluginTwoWithNoProps
    ]
  }
}

and without array syntax, this would be

const config = {
  pluginSlots: {
    secondSlot: () => (
      <>
        <PluginOneWithNoProps />
        <PluginTwoWithNoProps />
      </>
    )
  }
}

which does seem a bit less straightforward, but it would allow the syntax to be the same when we start dealing with props provided by the slot

const config = {
  pluginSlots: {
    secondSlot: ({slotPropOne, slotPropTwo}) => (
      <>
        <PluginOneWithProps pluginProp={slotPropOne} />
        <PluginTwoWithProps pluginProp={slotPropTwo} />
      </>
    )
  }
}

any thoughts on how to handle props being passed in with the array syntax?

1 Like

Interesting @djoy , but personally I’m not sure this would be a net good.

As @brian.smith points out, the array syntax is only relevant for a special case of a special case: prepending/appending children without passing in any props. As soon as an operator wants go beyond that, the array syntax becomes irrelevant, so IMO it’s in their best interest to be using the full function syntax right away.

I also have a distaste for special sentinel constants like DEFAULT_PLUGIN. This constant does not follow the rules of normal code; it’d have to be specially handled by the array syntax implementation. For example, if I was using function syntax, and I stuck DEFAULT_PLUGIN into the function body, would it manage to do the right thing, would it fail helpfully, or fail unhelpfully? I fear that without seriously complicating the implementation, it would fail unhelpfully.

Generally, I’m a fan of KISS and there-should-be-one-way-to-do-it. What I like about function syntax is that it is trivially simple to implement on the FPF side (just look up the slot name and call the layout function!) and easy to explain to API users (write a layout function that takes the defaults and returns the component you want to render).

Agreed on Slot and Layout, but I would go a different way for Plugins/Widgets, which would bring us more in line with how the Open edX backend and Tutor both use the term “plugin”:

Widgets: Bits of functionality we want to add to a plugin slot - pluginOne and pluginTwo above. May be imported into the config file directly to use the default slot layout behavior, or imported into a custom slot layout to customize behavior.
Plugins: NPM packages that export widgets, to be installed and used together.

Hmm, I may be missing something, so I’ll talk through it. :wink:

My expectation is that an MFE that provides a slot decides what data (via props) it wants to expose to the plugins loaded in that slot, and that is an important part of the API surface and will be documented.

So the short answer is that all plugins in a particular slot get the same props, and the config doesn’t have anything to do with it; it happens at runtime when the plugin component is loaded. Specifically, this already happens today. A PluginSlot component has a prop called pluginProps which does exactly this. We prop drill those props (via a spread operator with {...props} down to the actual plugin components (or iframe).

What you’ve demonstrated is adding a “wrapper” function around the plugins to control their layout, and yes, if you want to add one of those 1) you need this syntax, not the array thing, and 2) you’re responsible for passing those props through.

@kmccormick, I don’t think the array syntax is handling a special case of a special case; it’s handling (what I think is) the common case where you don’t need to do anything special and just want to insert a plugin and let the PluginSlot do it’s job.

Ah, I didn’t realize that props would be passed through automatically with the array synatx. That does make it seem more generally useful.

Still, my instinct is to lean towards helper functions, which can go a pretty long way towards making an accessible API for the common case (pardon my rusty JSX):

///////// @openedx/frontend-base/config.jsx
const insert = ({before, after}) => 
  (DefaultComponent, props, children) =>
    <>
     {before.map(widget => widget(...props)}
     <DefaultComponent {...props}> {children} </DefaultComponent>
     {after.map(widget => widget(...props)} 
    </>
const replace = (widgets) =>
  (DefaultComponent, props, children) =>
   <>
    {widgets.map(widget => widget(...props)
   </> 

//////// env.config.js
import { insert, replace } from "@openedx/frontend-base/config"
import { widget1, widget2, widget3 } from "....."
const config = {
  pluginSlots: {
    thisSlot: insert(before=[widget1], after=[widget2]),
    thatSlot: replace([widget3]),
  }
}

but if the consensus is otherwise then I’m happy to be overruled :slightly_smiling_face:

This was discussed extensively at the last Frontend Working Group meeting. Quick hits:

  • There is a strong desire to make it easy for non-dev operators to turn plugins on and off. That’s why a JSON-like syntax was originally implemented rather than a freeform functional syntax.
    • Marketing WG also wants a marketplace of one-click-installable extensions.
  • We’re not sure whether even the current JSON-like syntax is accessible to non-devs, though.
  • The thing we are calling “plugins” now are actually “widgets” plus a config language to stick those widgets in the right places. Proper “plugins” would be a simple on/off thing.
  • Seems like we need an idea of “plugins” which handle the widget configuration stuff themselves, which would be trivially easy to load into env.config.jsx or configure via a UI.
  • Rather than change the env.config.jsx syntax now, we should work on building a “v2” API which allows plugins themselves to configure widget(s). The current “v1” API could be reimplemented as a special case on top of the new “v2” API for backwards compat.
  • This change should be coupled with a unification of frontend slot naming scheme with the backend event and filters naming scheme. Together, all three extension points should be considered “Open edX Hooks”.

If you’re interested in the details, check out the notes and recording or keep an eye out for upcoming ADRs around making this happen.

1 Like