RFC: Expandable Component Architecture

Hello everyone,

As we all know, plugins need the ability to alter pages on foreman core.
To achieve that, developers mostly use deface, which allows you to change DOM dynamically.

While deface works well with rails views, we should avoid using it on react components.
It changes the real DOM on server rendering, while a react component uses the virtual DOM,
and changes in the real DOM won’t affect the virtual DOM.

Recently, the UI team gather up with some ideas.
We took inspiration from react-slot-fill, which has some similarity to Pagelets

Slot - Fill

<Slot> - Extenable point in a component - used by foreman core.
<Fill>- Fils a slot with a JSX content - used by plugins.

A slot will use an internal registry, that globally manages the extendable points.

A fill component will have two filling options:

  1. Replacing - replace the entire slot’s content.
  2. Appending - append content to a slot, therefore plugins can add content to the same slot.

Example

A plugin appends a button in the Notification Drawer's footer:

<NotificationDrawer> 
#content...
<Slot id='notification-drawer-footer' appendable />
</NotificationDrawer>

Plugin:

<Fill target='notification-drawer-footer`>
  <Button> Appended button ! </Button>
</Fill>   

This architecture allows foreman core to be more opinionated with extending pages.
Which personally I guess it is a great advantage from core perspective.
However it does reduce plugins ability to do whatever they want on existing pages / components,
therefore it would be a challenge.

6 Likes

Thanks for filling the big gap in functionality. It sounds like a good solution and I don’t see it as a big downside that plugins are limited. That’s similar to pagelets and I haven’t heard people negative about that. On the contrary, authors appreciated knowing exactly where changes would end up (granted, I’ve spoken to a small number of authors). Perhaps we’ll get closer to a formal plugin API which would make writing plugins that don’t break up upgrades easier.

That is a great change, and I am all for it. One feature that is present in pagelets and probably should be here as well is the order / weight of the <Fill> element. Otherwise the UI can behave unexpectedly.

Thanks for bringing it up @amirfefer. I like the idea of Slot/Fill.

Here are some proposals to make plugin developers life easier:

  1. Abstract the target of the Fill component so plugins can consume an easier version of it from the extendable component as <NotificationDrawer.AppendFooter>...</NotificationDrawer.AppendFooter>.

In foreman core:

const fillTargetFactory = (target) => ({ children }) => (
  <Fill target={target}>
    {children}
  </Fill>
);

const NotificationDrawer = props => (
  <div>
    #content...
    <Slot id='notification-drawer-footer' appendable />
  </div>
);

NotificationDrawer.AppendFooter = fillTargetFactory('notification-drawer-footer');

export default NotificationDrawer;

In Plugin:

import { NotificationDrawer } from 'foremanReact'; 

const MyComponent = () => (
  <div>
    #content...
    <NotificationDrawer.AppendFooter>
      <Button> Appended button ! </Button>
    </NotificationDrawer.AppendFooter>
  </div>
);

This way plugins won’t care much about the target and they can rely on the NotificationDrwer Itself.

  1. If we want it to be easy to use it should also be easy to research how to do stuff.
    I think we should have some sort of documentation where you can find what you can extend and how (In the storybook?).
    We can achieve it by creating automation that can read the code, search all the uses of fillTargetFactory and generate those docs from it.
1 Like

Just a brief reply. I think it’s a good idea to have well defined extension points. Please note, that not all plugins use deface. Some common extension points are also available via prepending a view helper method.

The plugins still need to change from erb to jsx if a change is made in core. If we change one page at a time from ERB to JSX, plugins have to do the change immediately after the change in core. That might be a challenge for some plugin author. I’m wondering if it would be easier to just release foreman 2.0 that has no erb at all so plugins then just need to adjust once. What do you think?

I think it’s a good point @TimoGoebel, it makes sense to me because it’s a breaking changes.

This sounds as something bigger to discuss probably out of this thread. It means drop the whole UI and build it from scratch, do I read the proposal correctly?

I like the concept. Also I want to +1 what Shim mentioned, we need to have control over the order, think of bulk actions on hosts list page.

:+1: to having more structure around what plugins can extend. I think that this provides the best experience for our users because it ensures that the pages will be displayed as we intended.

As far as how to go about doing this, I think first we need some data about how many plugins there are that are using deface and what they are using it for and where. I think this could inform our decision as to how to approach this.

A quick grep tells me at least:

As @TimoGoebel said, many plugins are also extending Foreman’s rails helpers to add functionality in the UI, if these helpers are replaced with react components those use cases will also needed to be taken into account

I agree, but it wasn’t a proposal. I was simply asking how we should be doing the transition.

Just in case it was not clear for random readers - Foreman does have a concept of Pagelets in its MVC codebase and plugins use this extensively. We used to have a situation when most plugins relied deface, those times are gone luckily.

We’ve learned a lesson from Pagelets: we need a good mechanism for other plugins to offer new “extension points” easily. Include this in your design if that’s not obvious from the day one.

2 Likes

react-slot-fill looks promising. We need to think about how to do ordering of extensions as @Shimon_Shtein mentioned.

One place where we can’t use this approach is tables. At the moment table columns are defined as a data structure that is passed to a table component via props. We use reactabular for that, see the docs for more details. We need to find a way to modify existing column components (slot-fill probably covers that) but also for adding new columns (slot-fill won’t help here).

Hello @amirfefer

If you’re converging towards React/JS powered webapp (instead of old-school “HTML with minimal JS” approach), it only makes sense to use the same client technology to realize any UI extensions. (You could use rails views to inject some kind of plugin-related metadata into your HTML page, though.)

If you’re planning on developing a UI extension mechanism, there are some general considerations:

  • discovery & linking: Will plugins be part of your project’s compiled output, or will plugins be separate packages to be installed, discovered and loaded at runtime?
  • security: Will you allow plugins to live in the same DOM context as your UI core, or will you use some form of isolation (e.g. preprocessor or iframe) to guard against malicious plugins?
  • plugin capability: Will you allow plugins to perform only UI additions, or will you allow existing core UI to be modified too?
  • ordering: Will you execute plugins in arbitrary order, or will you introduce a way to specify their priority?

Things are usually interconnected, e.g. statically linking plugin code into your compiled webapp output usually eliminates the security concern (since there’s no “loaded at runtime” aspect) and improves visibility (what features are available in the resulting webapp) but it also hinders flexibility - admins/users can’t extend the product at runtime.

If you’re going the route of having separate UI extension packages loaded at runtime, you’ll want to ensure that plugins vs. core are aligned both in terms of technology (React version etc.) and capability (supported plugin interface) - this can be done via npm package that provides the common base for both aspects. Dynamic linking always carries a security risk, so you’ll need to address that. Some people shift the responsibility to admins who install plugins, following the idea that plugins are treated in the same way as the core.

The Slot/Fill approach looks good, but it feels a bit limiting being tied to JSX only. The proposed examples only address visual extensibility, you might also want to consider behavioral extensibility, e.g. function as an extension point.

From my understanding, Slot is simply a way to expose a UI extension point. It could be visual:

<NotificationDrawer>
  {/* here, we expect the content to be JSX */}
  <Slot id='notification-drawer-footer' appendable />
</NotificationDrawer>

and it could also be non-visual:

<DataManager>
  {/* here, we expect the content to be function */}
  <Slot id='on-data-loaded' ref={this.setDataLoadedHook} />
</DataManager>

Slots could be used not only in React components, but also in “plain” JS code. This gives you better flexibility over what can be extended:

// programmatic way for use outside JSX
SlotRegistry.add('example-slot', content => {
  // called when this slot is filled with some content
})
// another example, here we specify additional Slot options
SlotRegistry.add('another-slot', { appendable: true }, callback)

Keep in mind that different plugins might override (replace) the same Slot - introducing a concept of plugin priority could add some determinism to that.

Finally, I’d really suggest to put great deal of effort on making sure that plugins can written in the simplest way possible, leveraging the declarative nature of JSX where appropriate. Also, the Fill concept shouldn’t be abstracted away, it’s a counterpart to Slot from plugin’s point of view (just like it’s shown in Amir’s initial examples):

// in foo-plugin.js
export default [
  <Fill id='notification-drawer-footer'> {/* React component */} </Fill>,
  <Fill id='on-data-loaded' content={someFunction} />,
  <Fill id='example-slot' content={'test'} />,
]

In the example above, a plugin simply exports an array of Fills, which goes through the Slot/Fill registry behind the scenes. Here, the Fill is meant to be used as a non-visual React component. Such code could be simply transformed into:

SlotRegistry.fill(id, content)

calls, so Fill is actually JSX declarative sugar to make plugin code less complex, avoiding programmatic calls to SlotRegistry.

1 Like

@amirfefer, thank you for looking into this. I gave your PR a brief spin and I am probably missing something, but for the Fill to register, it has to be mounted on the page, right? That worked flawlessly when I was on plugin’s page and extending navbar or notification drawer, but I struggled to add something into hosts form from a plugin, because the plugin does not ‘own’ anything on that page to render the Fill. Could you provide me with an example how I could do that please?

@Ondrej_Prazak, the point is not to allow plugins to extend whatever they want, instead, only allow to extend specific places that the core allow you to.

In other words, if you want to extend a place that doesn’t have a slot component (a place core don’t allow to extend (yet)), you will need to open a PR to core to add a slot in this place, which mean we can have a discussion about whether or not it is necessary.

@sharvit, what if I want to extend a place that already has a slot component? Lets say, there already is a slot that allows me to extend the host form. How would I use it from plugin? My concern is that Fill currently does registration in componentDidMount life cycle method, which means Fill has to be mounted on the same page as Slot.

@Ondrej_Prazak your concern makes sense, the fill component should have a rails helper that mount your fills extensions globally, so in that case, places like the host form, which your plugin does not own, will still work.

I’ll refer to this issue on the PR with an example.

I’ve created a small PR which implements a global js loader for plugins and should answer your concern.
For example, katello wants to add some react content into the host page

  1. katello registers the global js file for the fills
  2. katello registers the requested fill in that global file

katello/webpack/fills_index.js:

import React from 'react';
import FillExample from './components/HostPageExtend';
import { registerFill } from 'foremanReact/components/common/Fill/GlobalFill';

registerFill('katello-topbar-btn', FillExample);
);

katello/plugin.rb

    Foreman::Plugin.register :katello do
     # content...
      register_global_file 'fills'
    end
1 Like