Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ lint-check-all: lint-check
$(MAKE) -C frontend lint
$(MAKE) -C e2e-tests lint-check

# Backend + frontend unit tests (e2e is excluded - it needs running servers).
# Convenience target for local use only; each sub-project's CI tests itself.
test-all: unit-tests
$(MAKE) -C frontend unit-tests

# Full local verification of backend + frontend (lint + tests) without modifying files.
# e2e is excluded (separate sub-project, needs running servers). Convenience target only.
check:
$(MAKE) lint-check
$(MAKE) -C frontend lint
$(MAKE) test-all

# Approximates the dependency-review-action gate used in CI (license-check.yml)
# across all three sub-projects, so vulnerable transitive deps can be caught
# before pushing instead of discovered in a PR check.
Expand Down
11 changes: 11 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,14 @@ API Endpoints
:members:
:undoc-members:
:show-inheritance:

Plugin Capabilities
-------------------

Base classes a Goodmap plugin subclasses to declare its capability (see
:doc:`plugins`).

.. automodule:: goodmap.plugin
:members:
:undoc-members:
:show-inheritance:
207 changes: 167 additions & 40 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
@@ -1,64 +1,191 @@
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``).

Kinds of Goodmap frontend plugins
-------------------------------------

The capability a plugin provides determines *how* its frontend renders:

**Marker fields** (:class:`goodmap.plugin.MarkerFieldPluginBase`)
Render a single location field inside a marker popup (capability ``"MarkerField"``,
mounted by ``FieldRenderer``). ``FieldRenderer`` renders a field as a **pipe**: the raw
value flows through a chain of stages — the built-in for the field ``type`` (e.g.
``hyperlink``/``CTA``) renders it, then each field plugin attached to that ``type``
transforms the result. A plugin's ``config`` declares which field it attaches to and
where it sits:

- ``field``: the field ``type`` it applies to. For a custom type, the plugin's platzky
shortcode transforms the value into ``{"type": "<field>", ...}``.
- ``order`` (optional): position in the pipe — lower is more innermost; ties keep
registration order.

Every field plugin is the same kind of thing — a stage ``({ input, config }) => element``.
Each receives the previous stage's output as ``input``: the innermost stage gets the raw
value (and renders from it), every later stage gets the current element (and wraps it). So
a plugin either renders a field or wraps one, with no separate role. A wrapping plugin
presupposes something renders the type — a built-in, or a renderer it ships with or
depends on; a type with only wrappers and no renderer is a misconfiguration.

**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.

All kinds are discovered from the ``goodmap.plugins`` entry-point group. Each capability
exposes its React component under that capability's Module Federation module key —
``./MapOverlay`` for overlays and ``./MarkerField`` for field plugins — all served from the
plugin's single ``remoteEntry.js``.

A single plugin may provide **several** capabilities by subclassing more than one base;
goodmap then emits one manifest entry per capability, each pointing at that capability's
module. See ``examples/plugins/silly-gif`` for a plugin that is both a map overlay *and* a
field plugin.

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/MapOverlay.jsx (exposed as "./MapOverlay")
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 ``{pluginName, url, module, capability, config}`` for each capability the plugin
provides. The ``capability`` token and its ``module`` are derived from the capability base
class name (``PluginBase`` stripped) —
:class:`~goodmap.plugin.MapOverlayPluginBase` (``"MapOverlay"`` / ``./MapOverlay``) and
:class:`~goodmap.plugin.MarkerFieldPluginBase` (``"MarkerField"`` / ``./MarkerField``) — and
the frontend uses ``capability`` to mount the component at the right place (overlays over the
map by ``MapOverlays``; field plugins in a marker by ``FieldRenderer``, which folds them by
``config.field`` and ``config.order``).

Field plugins
-------------

``visible_data`` is a list of field names displayed in location markers (see
:ref:`data-model-visible_data`). ``FieldRenderer`` renders each such field as a pipe: the raw
value flows through the built-in for the field ``type`` (if any) and then each field plugin
attached to that ``type`` via ``config.field``, innermost-first by ``config.order``.

A field plugin is a :class:`~goodmap.plugin.MarkerFieldPluginBase` whose component is a stage
``({ input, config }) => element`` — it receives the previous stage's output as ``input``.
There's one kind of field plugin; what it does with ``input`` is what makes it read as a
"renderer" or a "decorator":

**Render from the input** — the innermost stage receives the raw value and produces the
rendering. Its platzky shortcode turns the raw value into ``{"type": "<field>", ...}``:

.. code-block:: python

# promo/plugin.py
from typing import Any
from goodmap.plugin import MarkerFieldPluginBase

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

.. code-block:: jsx

// frontend/src/MarkerField.jsx (exposed as "./MarkerField")
export default function Promo({ input }) {
return <code>{input.code}</code>;
}

Overview
--------
**Wrap the input** — a later stage receives the current element and composes around it (e.g.
to customize a built-in ``hyperlink``/``CTA``). Needs no shortcode:

Plugins are discovered automatically through Python entry points
(``platzky.plugins`` group). Each plugin can:
.. code-block:: jsx

* Register shortcodes for blog/content rendering
* Expose a React component via Module Federation
* Provide static assets served by the Flask backend
// frontend/src/MarkerField.jsx (exposed as "./MarkerField")
export default function HyperlinkBadge({ input, config }) {
return (
<span className="hyperlink-badge">
{input}
{config.label && <sup> {config.label}</sup>}
</span>
);
}

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.
Both are the same plugin kind. Each sets ``config.field`` to the type it attaches to and,
optionally, ``config.order``; lower order is more innermost, higher order wraps further out.
A wrapper must have a renderer beneath it (a built-in, or one it depends on).

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",
"en": "Nothing nearby"
}
}
}
]
}
}

Each plugin has its own configuration schema — refer to the plugin's
documentation for available fields.
A field plugin sets ``field`` in its ``config`` to the field ``type`` it attaches to (and,
optionally, ``order`` for its place in the fold):

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:

.. code-block:: text

DEBUG:goodmap.formatter:Dropping field 'promocode': unconfigured plugin data ...
.. code-block:: json

To see these messages, enable debug logging:
{
"plugins": {
"hyperlink_badge": {
"is_active": true,
"config": { "field": "hyperlink", "order": 1, "label": "↗" }
}
}
}

.. code-block:: bash
Each plugin defines its own ``config`` schema — refer to the plugin's documentation
for available fields.

export FLASK_DEBUG=1
After adding or removing a plugin, restart the Flask server.
10 changes: 8 additions & 2 deletions e2e-tests/tests/basic/test_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ def test_shared_link_opens_popup_with_correct_content(self, page: Page):
Verify navigating to a URL with ?locationId= auto-opens the popup
with the correct location content.
"""
page.goto(f"{BASE_URL}/?locationId=c8ecf476-5968-40da-ba5c-e810ad9ff203", wait_until="domcontentloaded")
page.goto(
f"{BASE_URL}/?locationId=c8ecf476-5968-40da-ba5c-e810ad9ff203",
wait_until="domcontentloaded",
)

# Verify popup is visible
popup = page.locator(".leaflet-popup-content")
Expand Down Expand Up @@ -162,7 +165,10 @@ def test_shared_link_opens_popup_on_mobile(self, mobile_page: Page, device_name:

Tests on all mobile devices: iphone-x, iphone-6, ipad-2, samsung-s10
"""
mobile_page.goto(f"{BASE_URL}/?locationId=c8ecf476-5968-40da-ba5c-e810ad9ff203", wait_until="domcontentloaded")
mobile_page.goto(
f"{BASE_URL}/?locationId=c8ecf476-5968-40da-ba5c-e810ad9ff203",
wait_until="domcontentloaded",
)

# On mobile, popup appears as Material-UI Dialog
dialog_content = mobile_page.locator(".MuiDialogContent-root")
Expand Down
6 changes: 6 additions & 0 deletions examples/plugins/silly-gif/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Frontend build artifacts — regenerated by `cd frontend && npm run build`
frontend/node_modules/
frontend/package-lock.json

# Module Federation bundle built into the Python package's static dir
silly_gif/static/
Loading
Loading