Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 90 additions & 31 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
@@ -1,57 +1,116 @@
Plugins
=======

Goodmap supports platzky plugins — standalone Python packages that extend
functionality via shortcodes and module federation frontend components.
Plugins are written against platzky's shortcode system and are not aware of
locations; Goodmap maps shortcode names to location field renderers automatically.
Goodmap builds on platzky's plugin system and adds its own plugin ecosystem for the
map. A Goodmap plugin is an ordinary Python package that declares a ``goodmap.plugins``
entry point and ships a frontend component (served via Module Federation). Goodmap
registers this entry-point group and its own capability base classes with platzky at
startup, so Goodmap plugins are discovered, config-gated (``is_active``), and loaded
through platzky's normal plugin loader — see :doc:`platzky's plugin docs
<platzky:plugins>` for the underlying mechanism (``extra_plugin_bases`` /
``extra_plugins_entrypoints``).

Overview
--------
Two kinds of Goodmap frontend plugins
-------------------------------------

Plugins are discovered automatically through Python entry points
(``platzky.plugins`` group). Each plugin can:
The capability a plugin subclasses determines *how* its frontend renders:

* Register shortcodes for blog/content rendering
* Expose a React component via Module Federation
* Provide static assets served by the Flask backend
**Field renderers** (``platzky.plugin.ContentTransformerPluginBase`` + shortcodes)
Render a single location field inside a marker popup. When a plugin-contributed
field appears in a location's ``visible_data`` and the plugin is active, the API
wraps the field value as ``{"scope": "<shortcode_name>", ...}``; the frontend
detects the ``scope`` key and mounts the plugin component there (``PluginSlot``).

Goodmap then uses the registered shortcode names as field renderer identifiers
for locations. ``visible_data`` is a list of field names that should be
displayed in location markers on the map (see :ref:`data-model-visible_data`
for details). When a plugin-contributed field appears in a location's
``visible_data`` and the plugin is configured, the API
wraps the field value with ``{"scope": "<shortcode_name>", ...}``. The frontend
detects the ``scope`` key and renders the appropriate plugin component.
**Map overlays** (:class:`goodmap.plugin.MapOverlayPluginBase`)
Render a component once *over the whole map*, not tied to any marker — e.g. a
banner shown when no points are visible in the current view. Overlay components
are mounted by ``MapOverlays``. They do not transform point/location data.

Both kinds are discovered from the ``goodmap.plugins`` entry-point group and must
expose their React component under the Module Federation key ``./Plugin`` (the module
name Goodmap requests from each plugin's ``remoteEntry.js``).

Map overlay plugins
-------------------

A map overlay subclasses :class:`~goodmap.plugin.MapOverlayPluginBase` and declares a
``goodmap.plugins`` entry point:

.. code-block:: python

# my_overlay/plugin.py
from typing import Any
from goodmap.plugin import MapOverlayPluginBase

class MyOverlayPlugin(MapOverlayPluginBase):
"""Show a banner over the map."""

def __init__(self, config: dict[str, Any]) -> None:
super().__init__(config)

.. code-block:: toml

# pyproject.toml
[tool.poetry.plugins."goodmap.plugins"]
my_overlay = "my_overlay:MyOverlayPlugin"

The plugin's per-plugin ``config`` (from the database, see below) is delivered to the
React component as a ``config`` prop, so overlays are configurable without code
changes:

.. code-block:: jsx

// frontend/src/Plugin.jsx (exposed as "./Plugin")
export default function MyOverlayPlugin({ config }) {
return <div>{config.message}</div>;
}

Goodmap serves the bundle at ``/plugins/<name>/static/remoteEntry.js`` and adds a
manifest entry ``{scope, url, module: "./Plugin", kind, config}``. ``kind`` is
``"overlay"`` for :class:`~goodmap.plugin.MapOverlayPluginBase` plugins and ``"field"``
otherwise; the frontend uses it to route overlays to ``MapOverlays`` and field
renderers to ``PluginSlot``.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Field renderers and ``visible_data``
------------------------------------

``visible_data`` is a list of field names displayed in location markers (see
:ref:`data-model-visible_data`). When a field is contributed by an active field-renderer
plugin, the API wraps its value with ``{"scope": "<shortcode_name>", ...}`` and the
frontend renders the matching plugin component in the marker popup.

Configuration
-------------

Add the plugin entry to the ``plugins`` list in your data source (e.g.
``data.json``):
Activate a plugin by adding it to the ``plugins`` object in your data source, keyed by
the entry-point name. The plugin loads only when ``is_active`` is ``true``; its
``config`` is passed to the plugin's ``__init__`` and (for frontend plugins) delivered
to the React component as the ``config`` prop:

.. code-block:: json

{
"plugins": [
{
"name": "promocode",
"plugins": {
"nothingshere": {
"is_active": true,
"config": {
"text": "Reveal your discount",
"color": "#e63946"
"messages": {
"pl": "Nie ma nic w pobliżu. Zobacz <a href='https://partner.example.com'>naszych partnerów</a>",
"en": "Nothing nearby. See <a href='https://partner.example.com'>our partners</a>"
}
}
}
]
}
}

Each plugin has its own configuration schema — refer to the plugin's
documentation for available fields.
Each plugin defines its own ``config`` schema — refer to the plugin's documentation
for available fields.

After adding or removing a plugin, restart the Flask server.

If a plugin is removed from the configuration while a location still has
fields referencing it, those fields are silently dropped from the API
response. A debug message is logged:
If a field-renderer plugin is removed from the configuration while a location still
has fields referencing it, those fields are silently dropped from the API response. A
debug message is logged:

.. code-block:: text

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Map/MapComponent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Markers } from './components/Markers';
import { MapLoadingOverlay } from './components/MapLoadingOverlay';
import { LocationProvider, useLocation } from './context/LocationContext';
import { GoToLocation } from './components/GoToLocation';
import GlobalPlugins from '../../plugins/GlobalPlugins';
import MapOverlays from '../../plugins/MapOverlays';

/**
* Inner map component that uses the shared location context.
Expand Down Expand Up @@ -47,7 +47,7 @@ const MapComponentInner = () => {
<AppToaster />
<LocationPermissionBanner />
<MapLoadingOverlay isLoading={isMapLoading} />
<GlobalPlugins />
<MapOverlays isMapLoading={isMapLoading} />
<MapContainer
center={mapConfig.initialMapCoordinates}
zoom={mapConfig.initialMapZoom}
Expand Down
18 changes: 0 additions & 18 deletions frontend/src/plugins/GlobalPlugins.jsx

This file was deleted.

27 changes: 27 additions & 0 deletions frontend/src/plugins/MapOverlays.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { getOverlayPlugins, subscribe } from './pluginRegistry';

// Renders map-overlay plugins (MapOverlayPluginBase): components mounted once over the
// map, not tied to any marker. Field-renderer plugins are mounted per marker by PluginSlot.
// Each overlay receives `config` and `isMapLoading` so it can defer rendering until the
// map's data has loaded (e.g. avoid flashing a "no points" message during the first fetch).
const MapOverlays = ({ isMapLoading }) => {
const [plugins, setPlugins] = useState(() => getOverlayPlugins());

useEffect(() => subscribe(() => setPlugins(getOverlayPlugins())), []);

return (
<>
{plugins.map(([scope, Component, config]) => (
<Component key={scope} config={config} isMapLoading={isMapLoading} />
))}
</>
);
};

MapOverlays.propTypes = {
isMapLoading: PropTypes.bool.isRequired,
};

export default MapOverlays;
4 changes: 2 additions & 2 deletions frontend/src/plugins/pluginLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@ export async function loadPlugins() {

await __webpack_init_sharing__('default');

for (const { scope, url, module: moduleName } of manifest) {
for (const { scope, url, module: moduleName, config, capability } of manifest) {
try {
await loadRemoteScript(url);
const container = window[scope];
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(moduleName);
const Module = factory();
registerPlugin(scope, Module.default);
registerPlugin(scope, Module.default, config, capability);
} catch (e) {
console.warn(`Failed to load plugin "${scope}":`, e);
}
Expand Down
18 changes: 13 additions & 5 deletions frontend/src/plugins/pluginRegistry.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
const registry = new Map();
const listeners = new Set();

export function registerPlugin(scope, Component) {
registry.set(scope, Component);
export function registerPlugin(scope, Component, config, capability) {
registry.set(scope, { Component, config, capability });
listeners.forEach(fn => fn());
}
Comment thread
raven-wing marked this conversation as resolved.
Outdated

export function getPlugin(scope) {
return registry.get(scope);
return registry.get(scope)?.Component;
}

export function getAllPlugins() {
return Array.from(registry.entries());
export function getPluginConfig(scope) {
return registry.get(scope)?.config ?? {};
}

// Map-overlay plugins mount once over the map (see MapOverlays); field-renderer
// plugins are mounted per marker via PluginSlot and are excluded here.
export function getOverlayPlugins() {
return Array.from(registry.entries())
.filter(([, entry]) => entry.capability === 'overlay')
.map(([scope, { Component, config }]) => [scope, Component, config]);
}

export function subscribe(fn) {
Expand Down
38 changes: 38 additions & 0 deletions frontend/tests/plugins/MapOverlays.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import '@testing-library/jest-dom';
import { render, screen, act } from '@testing-library/react';
import MapOverlays from '../../src/plugins/MapOverlays';
import { registerPlugin, getPluginConfig } from '../../src/plugins/pluginRegistry';

describe('MapOverlays', () => {
it('renders overlay plugins and passes config as a prop', () => {
const Overlay = ({ config }) => <span>{config.message}</span>;
Overlay.propTypes = { config: PropTypes.shape({ message: PropTypes.string }).isRequired };
act(() =>
registerPlugin('overlay-scope', Overlay, { message: 'nothing nearby' }, 'overlay'),
);

render(<MapOverlays isMapLoading={false} />);

expect(screen.getByText('nothing nearby')).toBeInTheDocument();
});

it('does not render field-renderer plugins', () => {
const Field = () => <span>field plugin</span>;
act(() => registerPlugin('field-scope', Field, {}, 'field'));

render(<MapOverlays isMapLoading={false} />);

expect(screen.queryByText('field plugin')).not.toBeInTheDocument();
});

it('exposes the registered config via getPluginConfig and defaults to {}', () => {
const Noop = () => null;
act(() => registerPlugin('with-config', Noop, { a: 1 }, 'overlay'));
act(() => registerPlugin('without-config', Noop, undefined, 'overlay'));

expect(getPluginConfig('with-config')).toEqual({ a: 1 });
expect(getPluginConfig('without-config')).toEqual({});
});
});
2 changes: 1 addition & 1 deletion frontend/tests/plugins/PluginSlot.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('PluginSlot', () => {
it('renders the registered component with given props', () => {
const TestComponent = ({ message }) => <span>{message}</span>;
TestComponent.propTypes = { message: PropTypes.string.isRequired };
act(() => registerPlugin('test-scope', TestComponent));
act(() => registerPlugin('test-scope', TestComponent, {}, 'field'));

render(<PluginSlot scope="test-scope" props={{ message: 'hello plugin' }} />);
expect(screen.getByText('hello plugin')).toBeInTheDocument();
Expand Down
4 changes: 2 additions & 2 deletions goodmap/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from goodmap.plugin import GoodmapPluginBase
from goodmap.plugin import MapOverlayPluginBase

__all__ = ["GoodmapPluginBase"]
__all__ = ["MapOverlayPluginBase"]
37 changes: 33 additions & 4 deletions goodmap/goodmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
get_location_obligatory_fields,
)
from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading
from goodmap.plugin import MapOverlayPluginBase

logger = logging.getLogger(__name__)

Expand All @@ -47,7 +48,8 @@ def _register_plugin_static_resources(
has no static directory or loading fails.
"""
try:
mod_path = os.path.dirname(os.path.realpath(inspect.getfile(ep.load())))
plugin_class = ep.load()
mod_path = os.path.dirname(os.path.realpath(inspect.getfile(plugin_class)))
static_dir = os.path.join(mod_path, "static")
if not os.path.isdir(static_dir):
return None, None
Expand All @@ -65,10 +67,16 @@ def _add_cors(response):
response.headers["Access-Control-Allow-Origin"] = "*"
return response

# "capability" tells the frontend which integration point the plugin provides,
# so it can route to the right handler (e.g. mount an "overlay" over the map via
# MapOverlays, a "field" in a marker via PluginSlot). Each goodmap capability base
# declares its own value; reading it off the class keeps this open to new
# capabilities without a per-type branch here.
manifest_entry = {
"scope": ep.name,
"url": f"/plugins/{ep.name}/static/remoteEntry.js",
"module": "./Button",
"module": "./Plugin",
"capability": plugin_class.capability,
}
return bp, manifest_entry
except Exception:
Expand Down Expand Up @@ -133,7 +141,15 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine:

locale_dir = os.path.join(directory, "locale")
config.translation_directories.append(locale_dir)
app = platzky.create_app_from_config(config)
# Register goodmap's own plugin ecosystem with platzky: MapOverlayPluginBase is a
# host-defined capability, and goodmap plugins are discovered from the
# "goodmap.plugins" entry-point group. This makes them config-gated (is_active)
# through platzky's normal plugin loader, alongside platzky's own plugins.
app = platzky.create_app_from_config(
config,
extra_plugin_bases=[MapOverlayPluginBase],
extra_plugins_entrypoints=[_PLUGIN_ENTRY_POINT_GROUP],
)

frontend_static_dir = os.path.join(directory, "static", "frontend")
app.register_blueprint(
Expand All @@ -160,11 +176,24 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine:

app.extensions["goodmap"] = {"location_obligatory_fields": location_obligatory_fields}

try:
plugins_data = app.db.get_plugins_data()
except Exception:
logger.warning("Could not read plugin config data; frontend plugins get empty config")
plugins_data = {}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

plugin_manifest = []
for ep in importlib.metadata.entry_points(group=_PLUGIN_ENTRY_POINT_GROUP):
plugin_cfg = plugins_data.get(ep.name)
# Only serve the frontend for plugins that are explicitly enabled in config.
# platzky's loader has already instantiated the active ones (gated identically),
# so the manifest stays in lockstep with the loaded backend plugins.
if plugin_cfg is None or not plugin_cfg.is_active:
continue
bp, entry = _register_plugin_static_resources(ep)
if bp is not None:
if bp is not None and entry is not None:
app.register_blueprint(bp)
entry["config"] = plugin_cfg.config
plugin_manifest.append(entry)

app.config["PLUGIN_MANIFEST"] = plugin_manifest
Expand Down
Loading
Loading