Front end plugin framework

Plugins extend the front end without modifying the base code. They are written as React components, bundled with Webpack, copied to the central server, and synced from there to every site that connects to it. A plugin can:

  • Render its own page (route + menu item)
  • Inject a component into a pre-defined "slot" inside a built-in page (a dashboard widget, an app-bar button on a detail view, a custom column on a list view, etc.)
  • Use any shared UI component, hook, theme, translation, or GraphQL client from the host
  • Store and read its own data via the plugin_data GraphQL endpoints

See the example plugins repo for working samples, and the in-repo core-plugins package for the reference plugin bundled with this codebase.

How plugins are loaded

When the app starts, every available plugin is read and added to a Zustand store (the usePluginProvider). Each plugin exports a default object that conforms to the Plugins type — that object is deep-merged into the store, with array-valued slots concatenated. So multiple plugins can contribute to the same slot.

The loading mechanism differs between dev and prod:

ModeSourceHot reload
DevelopmentLocal files under client/packages/plugins/*/frontend/latest, discovered at webpack start timeYes for code changes inside a plugin; restart frontend when adding a new plugin
ProductionServer endpoint listing compatible plugin bundles, fetched per-bundle via Webpack module federation at runtimeNo

In production the client fetches the plugin list on startup, downloads each bundle as a separate <script> tag, initialises it against the shared scope via __webpack_init_sharing__, then adds the resulting Plugins object to the provider.

React context across the federation boundary

This is the single most important thing to understand. In dev mode a plugin lives inside the host's webpack build, so React context (theme, query client, i18n, auth) flows in naturally. In prod mode the plugin runs as a separately compiled bundle and only the modules listed as shared in its webpack config are singletons across the boundary; everything else has its own copy. That means most React contexts are not shared.

Two helpers from the host work around this by re-publishing their state through react-singleton-context:

  • ThemeProviderProxy
  • QueryClientProviderProxy

For slot plugins (dashboard widgets, app-bar buttons, list-view columns, etc.) the plugin must wrap the root of its contributed component in both, otherwise the standard hooks (useTranslation, useAuthContext, useTheme, useQuery, etc.) silently break in production. Forgetting this is the most common reason a plugin works in dev and breaks in prod.

For plugin pages the host applies both wrappers automatically — the page component can use those hooks directly without any boilerplate.

Plugin identity

Each plugin's name field in its package.json is its plugin code — the Webpack module-federation name. It must be unique across all plugins, must be valid as a URL segment (^[a-z0-9_-]+$), and is also what the server uses to identify a bundle on upload.

Plugin versioning is tied to the minimum host version it supports — see Compatibility / versioning below.

Available injection points

The full, authoritative list lives in Plugins type — what follows is a reference summary of each slot, what it receives, and where the host currently renders it. Each top-level key on the Plugins object is optional, so a plugin contributes only the slots it cares about.

Adding a new injection point requires changes to both types.ts and the host page that consumes it. See Adding a new injection point below.

Whole-page plugins — pages

Register a complete page with its own route and menu item. The plugin chooses whether the menu entry sits under an existing top-level category (Inventory, Manage, etc.) or contributes a brand-new top-level category that several plugins can share. See Authoring a plugin page below for the full schema and URL scheme.

Dashboard — dashboard

Sub-slotPurpose
widgetTop-level dashboard widget. Accepts hiddenWidgets to suppress built-in widgets by id.
panelPanel inside a widget. Receives widgetContext prop identifying the parent widget.
statisticStatistic inside a panel. Receives panelContext prop identifying the parent panel.

Inbound shipment — inboundShipmentAppBar

Array of components rendered in the app bar of the inbound-shipment detail view. Each receives the current shipment: InboundFragment as a prop.

Inbound shipment status change — inboundShipment.validateStatusChange

Array of pure validator functions run before an inbound-shipment status change (from the detail-view footer). Each receives (shipment: InboundFragment, targetStatus: InvoiceNodeStatus) and returns a user-facing message to block the transition, or null to allow it. The host runs them after its own checks (permission, empty invoice, on-hold, pending lines) and shows the first returned message as an error. Use this to enforce a rule across all lines at receive/verify time — e.g. requiring a discrepancy reason — that a per-line editViewField alone can't guarantee (bulk actions like "set quantities to 0" never open the line modal). Functions must be pure (no React hooks); they run against the shipment data already loaded by the host.

Prescription — prescriptionPaymentForm

Array of components rendered inside the prescription payment dialog. Receives props described by PrescriptionPaymentComponentProps.

Item detail — item.detailViewField

Extra fields appended to the item detail view. Each receives item: ItemFragment.

Stock lines — stockLine

Sub-slotPurpose
tableStateLoaderRenders nothing visually; receives the currently displayed stockLines: StockLineRowFragment[]. Used to pre-fetch any plugin-specific data for the visible page of rows and stash it in zustand state so column cells can look it up synchronously.
tableColumnA ColumnDef<StockLineRowFragment> added to the stock-line list view.
editViewFieldA form field rendered inside the stock-line edit view, with events: UsePluginEvents<{ isDirty: boolean }> to signal dirty state and respond to save events.

Request requisition lines — requestRequisitionLine

Sub-slotPurpose
tableStateLoaderSame pattern as stockLine.tableStateLoader but for request requisition lines.
tableColumnA ColumnDef<RequestLineFragment> for the list view.
editViewFieldA field rendered inside the line-edit modal, with optional draft and unitName.
editViewInfoAn info panel rendered alongside the edit modal.
hideInfoList of built-in info panel ids to suppress.

Request requisition — requestRequisition.sidePanelSection

Section appended to the request-requisition detail view's side panel; receives the RequestFragment.

Master lists — masterLists

Sub-slotPurpose
tableStateLoaderSame pattern; receives masterLists: MasterListRowFragment[].
tableColumnA ColumnDef<MasterListRowFragment> for the list view.

Authoring a plugin page

A plugin page contributes one entry to the pages?: PluginPage[] slot. The shape:

import { AppRoute } from '@openmsupply-client/config';
import { Plugins, ReportsIcon, UserPermission } from '@openmsupply-client/common';

const myPlugin: Plugins = {
  pages: [
    {
      route: 'stock-aging',                            // single URL segment, [a-z0-9_-]+
      Component: StockAgingPage,                       // React component
      menu: {
        label: 'Stock aging',
        // (optional) gate behind permission(s); ALL must be held to see the page
        permissions: [UserPermission.StockLineQuery],
        category: {
          type: 'existing',                            // attach under built-in Inventory
          appRoute: AppRoute.Inventory,
        },
      },
    },
    {
      route: 'daily',
      Component: ReportingDailyPage,
      menu: {
        label: 'Daily',
        category: {
          type: 'new',                                 // contribute a new top-level section
          key: 'reporting',                            // URL segment + grouping key
          label: 'Reporting',
          icon: ReportsIcon,                           // any icon from @openmsupply-client/common
          order: 500,                                  // lower = earlier in drawer; default 1000
        },
      },
    },
  ],
};

URL scheme

Every page mounts at /<categoryKey>/<route>:

  • For type: 'existing', categoryKey is the AppRoute string value (e.g. inventory)
  • For type: 'new', categoryKey is the plugin-supplied key

So the examples above resolve to /inventory/stock-aging and /reporting/daily. React Router picks these specific paths over the wildcard category routers, so /inventory/stock-aging is preferred over /inventory/* automatically. It is the plugin author's responsibility to choose a route that doesn't shadow a built-in sub-path of the same category. Plugin-supplied new category keys cannot equal any existing AppRoute value (the registration logic rejects these with a console warning).

If two plugins register the same (categoryKey, route) tuple, the later registration is dropped with a console warning.

For an existing category, the link appears inside that category's collapsible section in the drawer, beneath the built-in items.

For a new category, all plugins that share the same key are grouped under one collapsible section. Category-level metadata (label, icon, order) is taken from the first page that declared the category — subsequent pages with the same key contribute only their own menu entry.

Child menu items have no icon (matching the built-in nav). The icon field exists only on the new-category declaration and is rendered themed (color="primary" fontSize="small"); omit it to use the default plug icon.

Layouts and chrome

A PluginPage is a single component. The host wraps every plugin page in ThemeProviderProxy and QueryClientProviderProxy automatically, so you can use standard hooks (useTranslation, useAuthContext, useQuery, etc.) directly — there's no proxy-provider boilerplate to write at the page root.

The breadcrumb shown in the app bar is also set automatically from the menu.label (and menu.category.label for new categories), so the page title in the chrome matches the menu entry without any extra wiring.

To match the standard page layout, import the portal components from @openmsupply-client/common and render into them — exactly as built-in pages do:

import {
  AppBarButtonsPortal,
  AppBarContentPortal,
  AppFooterPortal,
} from '@openmsupply-client/common';

export const MyPage = () => (
  <>
    <AppBarButtonsPortal>{/* action buttons */}</AppBarButtonsPortal>
    <AppBarContentPortal>{/* heading / extra content */}</AppBarContentPortal>
    {/* page body */}
    <AppFooterPortal Content={/* footer */} />
  </>
);

A plugin page can also render zero chrome — just a plain body — and the surrounding app bar and footer will stay empty. State sharing across the chrome is automatic because everything lives in a single component tree.

Permissions

menu.permissions is an optional list of UserPermission values; all must be held by the current user for the menu item to be shown. The route is also guarded — direct navigation by a user who lacks the permission renders NotFound.

Events between core and plugin

Some slots need to share state both ways. For example, an editViewField may need to (a) tell the host page when it has unsaved changes, and (b) run validation/save logic when the host's save button is pressed. The usePluginEvents hook handles both: it stores arbitrary state plus a set of event listeners that the host can dispatch synchronously.

Declare the interface on the slot's prop type:

// In types.ts (host side)
events: UsePluginEvents<
  { isReady: boolean },
  { validateThisString: string },
  'ok' | { error: string }
>;

Bind it in the host component:

const CoreComponent = () => {
  const pluginEvents = usePluginEvents<
    { isReady: boolean },
    { validateThisString: string },
    'ok' | { error: string }
  >({ isReady: false });

  // Trigger validation from the host:
  const validate = async (value: string) => {
    const result = await pluginEvents.dispatchEvent({ validateThisString: value });
    if (result === 'ok') closeModal();
    else setError(result.error);
  };

  return (
    <>
      {pluginEvents.state.isReady && <div>Ready</div>}
      <PluginSlot events={pluginEvents} />
    </>
  );
};

Consume it in the plugin:

const PluginComponent = ({ events }) => {
  // Listen for events from the host:
  useEffect(() => {
    return events.mountEvent(({ validateThisString }) => {
      return validateThisString === 'good' ? 'ok' : { error: 'string is not good' };
    });
  }, []);

  // Push state up to the host:
  useEffect(() => {
    events.setState({ isReady: true });
  }, [somethingChanged]);

  return <div>Plugin display content...</div>;
};

When you mount an event handler, prefer useRef for up-to-date but non-reactive values so that listener re-mounting (which would briefly drop events) is avoided:

const [value, setValue] = useState('');
const valueRef = useRef(value);
valueRef.current = value;

useEffect(() => {
  return events.mountEvent(() => {
    // `valueRef.current` is always fresh; closing over `value` would be stale.
    console.log(valueRef.current);
  });
}, []); // mount once, never re-mount on `value` change

TODO: example sharing the UsePluginEvents generic types between host and plugin without re-declaring them.

Plugin data

Plugins can persist their own records in the shared plugin_data table via the GraphQL API. There are three operations:

  • pluginData — query
  • insertPluginData — insert
  • updatePluginData — update

Records are scoped by plugin code (so two plugins can't read each other's data) and optionally tied to a relatedRecordId so a plugin can attach data to a specific stock line, master list, etc.

The querying / mutation pattern matches the rest of the app:

const { data } = usePluginData.data(stockLine?.id ?? '');
const { mutate: insert } = usePluginData.insert();
const { mutate: update } = usePluginData.update();

TODO: full reference for the data shape and how to scope queries.

Creating a plugin

You can watch this video for example (TODO: make public).

The simplest way to begin is by forking or copy-pasting this template repo, then adding it as a submodule under client/packages/plugins/ using the yarn plugin helper.

Using the yarn plugin helper

First-time setup, from the repo root:

cp scripts/plugin-management/pluginRepoMap.example.json scripts/plugin-management/pluginRepoMap.json

pluginRepoMap.json is gitignored — edit it to add short names for the repos you work with, e.g.:

{
  "core": "https://github.com/msupply-foundation/open-msupply-plugins",
  "civ": "https://github.com/msupply-foundation/civ-plugins"
}

Then:

yarn plugin get <name>                  # add a plugin submodule, replacing it if already present.
                                        # Other installed plugins are left alone.
yarn plugin get <repo-url>              # pass a repo URL directly, no map entry needed
yarn plugin get <name> -b <branch>      # check out a specific branch
yarn plugin list                        # show what's currently installed locally
yarn plugin install                     # build and install every installed plugin (both frontend and backend)
yarn plugin install <selector>          # only the named plugin
yarn plugin install [...] frontend      # only the frontend half
yarn plugin install [...] backend       # only the backend half
yarn plugin uninstall <selector>        # remove a plugin from the server (both frontend and backend rows)
yarn plugin uninstall <selector> frontend # remove just the frontend row
yarn plugin uninstall <selector> backend  # remove just the backend row
yarn plugin uninstall --all             # wipe every plugin currently installed on the server (prompts first)
yarn plugin open                        # open the plugin in GitHub Desktop (must specify if >1 installed)
yarn plugin open <selector>             # open a specific plugin
yarn plugin reset                       # remove all plugin submodules from your working tree
yarn plugin reset <selector>            # remove just the named plugin submodule

<selector> (for install/uninstall/open/reset) is either an installed plugin's folder name (e.g. core-plugins) or a short name from pluginRepoMap.json (e.g. core). For uninstall, the script then reads the plugin's actual code(s) from the local submodule's package.json and sends those to the server — so yarn plugin uninstall civ is symmetric with yarn plugin install civ.

If no local submodule matches, uninstall falls back to treating the argument as a literal plugin code (the npm-package name as stored on the server — what shows up in the code column of Manage → Plugins, or in yarn plugin uninstall --all's preview list). So you can still uninstall a plugin on a server where the submodule isn't checked out locally — just pass the code directly.

yarn plugin install and yarn plugin uninstall default to http://localhost:8000 with credentials admin/pass. Override with --url, --username, --password, or pick a stored profile with --auth=<name> (see Auth profiles below). Get/reset abort if the affected plugin submodule has uncommitted changes — commit or stash inside it first.

Local cleanup vs server cleanup

The reset and uninstall commands deliberately do different things, and you usually want both for a true clean slate:

GoalCommandTouches
Remove a plugin submodule from your repoyarn plugin reset <selector>Working tree only
Remove all plugin submodulesyarn plugin resetWorking tree only
Uninstall a plugin from the serveryarn plugin uninstall <selector>Server DB (syncs out to remote sites)
Wipe every plugin from the serveryarn plugin uninstall --allServer DB

uninstall doesn't touch your local submodule, and reset doesn't touch the server. To start completely fresh against a dev server, run both. Single-plugin server deletes are also available from the UI (Manage → Plugins → trash icon, central server admins only).

Auth profiles

Auth values are stored as profiles in a gitignored scripts/plugin-management/pluginAuth.json. The _default profile is the implicit fallback, and you can define any number of named profiles for other servers. Example:

{
  "_default": {
    "url": "http://localhost:8000",
    "username": "admin",
    "password": "pass"
  },
  "staging": {
    "url": "https://staging.example.com",
    "username": "alice",
    "password": "secret"
  }
}

Pick a profile with --auth=<name>:

yarn plugin install --auth=staging

Per-field precedence (highest → lowest):

  1. inline flag (--url/--username/--password)
  2. the named profile from --auth
  3. the _default profile
  4. hard-coded constants

Each field falls through independently, so you can have e.g. a profile that only sets url and inherit username/password from _default.

First-time setup of a new server: pass --auth with a new name plus the values. The profile is auto-created (a notice is printed so typos don't slip through):

yarn plugin install --auth=staging --url=https://staging.example.com --username=alice --password=secret

Subsequent inline flags update the active profile (the one in --auth, or _default if none). Stored values are saved verbatim — to remove or rename a stored value, hand-edit pluginAuth.json.

You will need GitHub authentication set up to add a private repo — github cli is the easiest, or use one of the alternative methods.

What yarn plugin get does under the hood

It runs the equivalent of:

git submodule add [-b <branch>] <repo-url> client/packages/plugins/<repo-basename>

The submodule and the main repo are treated as separate git repositories — changes in each only affect that repo. .gitmodules and the files under client/packages/plugins/<your bundle>/ are gitignored — they are intentionally not committed to the main app.

Once added:

  1. Change name in package.json — this becomes the plugin code and unique identifier
  2. Update the omSupplyPlugin.types array to declare which slots the plugin implements (currently informational only, but will be validated on install)
  3. Hot reloading works in dev once the bundle is registered. Restart the frontend after adding a new plugin so webpack picks it up.

TODO: omSupplyPlugin.types could be derived automatically by inspecting plugin.tsx with ts-node — both for front-end and back-end plugins.

Developing on branches of plugins

To pin a submodule to a specific branch, add -b when first adding it:

yarn plugin get <name> -b <branch>

This adds a branch field to .gitmodules:

  [submodule "client/packages/plugins/myPluginBundle"]
    path = client/packages/plugins/myPluginBundle
    url = https://github.com/msupply-foundation/civ-plugins.git
    branch = fix-plugin-data-saving

Re-run yarn plugin get <name> -b <other-branch> to switch branches. Alternatively, edit .gitmodules by hand and pull:

git submodule update --remote

Note: the branch field accepts branch names only, not SHAs or tags.

Removing a submodule

Run yarn plugin reset from the repo root. It handles the full cleanup (deinit, working tree, .gitmodules, and .git/modules/... cache) — doing this by hand is error-prone, and leaving any of those behind silently breaks the next git submodule add. If you ever need to do it manually, the equivalent commands are:

rm -rf .gitmodules
rm -rf client/packages/plugins/myPluginBundle/
rm -rf .git/modules/client/packages/plugins/myPluginBundle/

When using a private repo, you'll need to be logged in as a user with read access.

To remove a plugin from a server (so it stops syncing out to remote sites), use yarn plugin uninstall <selector> instead — see the command list above. reset only touches your local working tree; it doesn't talk to any server.

Testing the production build

You can work on a plugin as if it were part of the app — types are shared, autocomplete and hot reload work. To test it as a production bundle, the easiest path is:

yarn plugin install

This walks the active plugin directory, runs yarn install + yarn build-plugin for every package.json it finds under frontend/ and backend/, then bundles and uploads to the local server. Use the --auth flag (or auth profiles in pluginAuth.json) to target a different server — see the auth section above.

Under the hood, the rust CLI handles the bundle + upload. If you want the intermediate pluginbundle.json file or to drive the steps individually:

# From the server directory
cargo run --bin remote_server_cli -- generate-plugin-bundle \
  -i ../client/packages/plugins/myPluginBundle/frontend \
  -o pluginbundle.json

This generates pluginbundle.json containing metadata (code, version, declared types) plus base64-encoded contents of every file in the dist directory that yarn build produced. Upload it:

cargo run --bin remote_server_cli -- install-plugin-bundle \
  -p pluginbundle.json \
  --url 'http://localhost:8000' \
  --username admin --password pass

Note: upload only works against a central server.

Or do both in one step:

cargo run --bin remote_server_cli -- generate-and-install-plugin-bundle \
  -i '../client/packages/plugins/myPluginBundle/frontend' \
  --url 'http://localhost:8000' \
  --username admin --password pass

To exercise the plugin via the production path: yarn build the client from the repo root, restart the backend, and visit the app via the backend at http://localhost:8000. The frontend will then fetch plugins from the server (as it does in real deployments — central syncs them to remote site servers) rather than from local disk.

Note: when running some backend plugins in dev mode you may hit a stack overflow. Increase the stack size: export RUST_MIN_STACK=8388608; cargo run.

Reference plugin examples

The in-repo core-plugins bundle ships several examples:

  • ShippingStatus — adds an app-bar button to the inbound-shipment detail view. Demonstrates: receiving data from the host, using standard UI components, using the theme.
  • Dashboard (Replenishment, SyncStatus) — adds two dashboard widgets. Demonstrates: exporting multiple components, fetching data via GraphQL, using utility functions.
  • Stock Donor — adds a custom field to a stock line, showing the stored value as a list-view column and providing an edit input in the detail view. Demonstrates: column plugin, plugin data via GraphQL (insert/update), usePluginEvents for triggering validation and save, table state loader, react-singleton-context proxies.
  • Aggregate AMC — adds a column + edit field + info panel to request requisition lines.
  • Stock aging — a full plugin page mounted at /inventory/stock-aging, demonstrating a page that uses the standard AppBarButtons + AppBarContent + AppFooter portal chrome plus a Toolbar.
  • Reporting / Daily — a bare-bones plugin page under a new "Reporting" top-level category at /reporting/daily, demonstrating a page that opts out of all chrome.

The Stock Donor example uses the tableStateLoader slot to bulk-fetch plugin data for all currently visible stock rows in one go, stash it in a zustand store, and let column cells look it up synchronously — this avoids per-row fetches in the column render path.

TODO: notes on plugin column ordering once it's been added to the framework.

Compatibility / versioning

TODO: explain the folder structure (frontend/latest, frontend/2_6, …), how versions are linked to the minimum host version a plugin supports, and how older hosts can be tested with a newer plugin via the include/exclude lists in getLocalPlugins.js.

Adding a new injection point

When a built-in page needs to be made extensible, you have to update both the type definition and the host that consumes it:

  1. Add an optional field to the Plugins type in client/packages/common/src/plugins/types.ts. For an array-valued slot, contributions from multiple plugins will be concatenated automatically by the provider's merge logic.
  2. In the consuming host component, call usePluginProvider() and read your new field — typically rendering each entry in a list.
  3. If the slot needs two-way state, attach a UsePluginEvents instance per the events docs above.

TODO: short video walkthrough — the existing video shows extending the front-end plugin interface; this commit in a plugin fork and this commit on the front end show one such change end-to-end (note that commit fails compilation and includes unrelated type relocations).

Backend plugins

TODO: full description. The intro video touches on backend plugins briefly.

More about bundling

TODO: how a bundle should be deployed in production.

Signing

TODO: signing process, once signing is re-instated.