From e81669c2a6e950adc88a5a0a0e15d3d1ea9428c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 3 Jul 2026 23:16:11 +0200 Subject: [PATCH 01/19] feat: allow goodmaps plugins --- docs/plugins.rst | 121 +++++++++++++----- frontend/src/components/Map/MapComponent.jsx | 4 +- frontend/src/plugins/GlobalPlugins.jsx | 18 --- frontend/src/plugins/MapOverlays.jsx | 27 ++++ frontend/src/plugins/pluginLoader.js | 4 +- frontend/src/plugins/pluginRegistry.js | 18 ++- frontend/tests/plugins/MapOverlays.test.jsx | 38 ++++++ frontend/tests/plugins/PluginSlot.test.jsx | 2 +- goodmap/__init__.py | 4 +- goodmap/goodmap.py | 37 +++++- goodmap/plugin.py | 16 +++ pyproject.toml | 2 +- tests/unit_tests/test_goodmap.py | 128 +++++++++++++------ 13 files changed, 313 insertions(+), 106 deletions(-) delete mode 100644 frontend/src/plugins/GlobalPlugins.jsx create mode 100644 frontend/src/plugins/MapOverlays.jsx create mode 100644 frontend/tests/plugins/MapOverlays.test.jsx diff --git a/docs/plugins.rst b/docs/plugins.rst index ce0e06e4..31481acd 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -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 +` 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": "", ...}``; 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": "", ...}``. 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
{config.message}
; + } + +Goodmap serves the bundle at ``/plugins//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``. + +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": "", ...}`` 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 naszych partnerów", + "en": "Nothing nearby. See our partners" + } } } - ] + } } -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 diff --git a/frontend/src/components/Map/MapComponent.jsx b/frontend/src/components/Map/MapComponent.jsx index 458ec7e7..d9f58aaf 100644 --- a/frontend/src/components/Map/MapComponent.jsx +++ b/frontend/src/components/Map/MapComponent.jsx @@ -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. @@ -47,7 +47,7 @@ const MapComponentInner = () => { - + { - const [plugins, setPlugins] = useState(() => getAllPlugins()); - - useEffect(() => subscribe(() => setPlugins(getAllPlugins())), []); - - return ( - <> - {plugins.map(([scope, Component]) => ( - - ))} - - ); -}; - -export default GlobalPlugins; diff --git a/frontend/src/plugins/MapOverlays.jsx b/frontend/src/plugins/MapOverlays.jsx new file mode 100644 index 00000000..93120c9c --- /dev/null +++ b/frontend/src/plugins/MapOverlays.jsx @@ -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]) => ( + + ))} + + ); +}; + +MapOverlays.propTypes = { + isMapLoading: PropTypes.bool.isRequired, +}; + +export default MapOverlays; diff --git a/frontend/src/plugins/pluginLoader.js b/frontend/src/plugins/pluginLoader.js index 0fa21eb7..9a745198 100644 --- a/frontend/src/plugins/pluginLoader.js +++ b/frontend/src/plugins/pluginLoader.js @@ -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, kind } 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, kind); } catch (e) { console.warn(`Failed to load plugin "${scope}":`, e); } diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 7c7a4ad3..eace3f0e 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -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, kind) { + registry.set(scope, { Component, config, kind }); listeners.forEach(fn => fn()); } 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.kind === 'overlay') + .map(([scope, { Component, config }]) => [scope, Component, config]); } export function subscribe(fn) { diff --git a/frontend/tests/plugins/MapOverlays.test.jsx b/frontend/tests/plugins/MapOverlays.test.jsx new file mode 100644 index 00000000..dfedd0c9 --- /dev/null +++ b/frontend/tests/plugins/MapOverlays.test.jsx @@ -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 }) => {config.message}; + Overlay.propTypes = { config: PropTypes.shape({ message: PropTypes.string }).isRequired }; + act(() => + registerPlugin('overlay-scope', Overlay, { message: 'nothing nearby' }, 'overlay'), + ); + + render(); + + expect(screen.getByText('nothing nearby')).toBeInTheDocument(); + }); + + it('does not render field-renderer plugins', () => { + const Field = () => field plugin; + act(() => registerPlugin('field-scope', Field, {}, 'field')); + + render(); + + 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({}); + }); +}); diff --git a/frontend/tests/plugins/PluginSlot.test.jsx b/frontend/tests/plugins/PluginSlot.test.jsx index b3a0832f..4bebedbe 100644 --- a/frontend/tests/plugins/PluginSlot.test.jsx +++ b/frontend/tests/plugins/PluginSlot.test.jsx @@ -14,7 +14,7 @@ describe('PluginSlot', () => { it('renders the registered component with given props', () => { const TestComponent = ({ message }) => {message}; TestComponent.propTypes = { message: PropTypes.string.isRequired }; - act(() => registerPlugin('test-scope', TestComponent)); + act(() => registerPlugin('test-scope', TestComponent, {}, 'field')); render(); expect(screen.getByText('hello plugin')).toBeInTheDocument(); diff --git a/goodmap/__init__.py b/goodmap/__init__.py index 3c2ccef4..615ac508 100644 --- a/goodmap/__init__.py +++ b/goodmap/__init__.py @@ -1,3 +1,3 @@ -from goodmap.plugin import GoodmapPluginBase +from goodmap.plugin import GoodmapPluginBase, MapOverlayPluginBase -__all__ = ["GoodmapPluginBase"] +__all__ = ["GoodmapPluginBase", "MapOverlayPluginBase"] diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 04377f98..93c79da3 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -25,6 +25,7 @@ get_location_obligatory_fields, ) from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading +from goodmap.plugin import MapOverlayPluginBase logger = logging.getLogger(__name__) @@ -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 @@ -65,10 +67,16 @@ def _add_cors(response): response.headers["Access-Control-Allow-Origin"] = "*" return response + # "kind" tells the frontend how to render the component: map overlays mount once + # over the map (MapOverlays); everything else renders in a marker field (PluginSlot). + is_overlay = isinstance(plugin_class, type) and issubclass( + plugin_class, MapOverlayPluginBase + ) manifest_entry = { "scope": ep.name, "url": f"/plugins/{ep.name}/static/remoteEntry.js", - "module": "./Button", + "module": "./Plugin", + "kind": "overlay" if is_overlay else "field", } return bp, manifest_entry except Exception: @@ -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( @@ -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 = {} + 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 diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 91cd3aca..33362179 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -10,3 +10,19 @@ class GoodmapPluginBase(PluginBase): def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) + + +class MapOverlayPluginBase(GoodmapPluginBase): + """Capability: a plugin that renders an overlay on top of the map view. + + Map-overlay plugins contribute a frontend component (served via Module + Federation) that is mounted globally over the map — e.g. a banner shown when + no points are visible. They do not transform point/location data; for that, + use platzky's ``ContentTransformerPluginBase`` instead. + + goodmap registers this capability with platzky (via ``extra_plugin_bases``) + so overlay plugins are config-gated through the standard plugin loader. + """ + + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) diff --git a/pyproject.toml b/pyproject.toml index 9d97eba6..d98615a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ Flask-WTF = "^1.2.1" gql = "^3.4.0" aiohttp = "^3.8.4" pydantic = "^2.12.0" -platzky = "2.0.0a8" +platzky = "2.0.0a9" deprecation = "^2.1.0" numpy = "^2.2.0" # Using fork because official PyPI version (0.7.7) has outdated numpy setup hack diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index ef2ce421..146e6171 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -1,5 +1,6 @@ import importlib.metadata import os +import sys import tempfile import types from typing import Any @@ -12,6 +13,7 @@ from goodmap import goodmap from goodmap.config import GoodmapConfig from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading +from goodmap.plugin import MapOverlayPluginBase from tests.unit_tests.conftest import make_flag_set config = GoodmapConfig( @@ -31,7 +33,11 @@ def test_create_app_from_config(): mock_platzky_app_creation.return_value.is_enabled.return_value = False with patch("goodmap.goodmap.extend_db_with_goodmap_queries", MagicMock()) as mock_extend_db: goodmap.create_app_from_config(config) - mock_platzky_app_creation.assert_called_once_with(config) + mock_platzky_app_creation.assert_called_once_with( + config, + extra_plugin_bases=[goodmap.MapOverlayPluginBase], + extra_plugins_entrypoints=["goodmap.plugins"], + ) mock_extend_db.assert_called_once() @@ -169,36 +175,63 @@ def test_index_route_location_schema_with_lazy_loading(): assert "test_category" in response_text -def make_mock_entry_point(name: str, module_path: str): - """Create a mock EntryPoint that loads a module from the given path. +def _overlay_ep(name: str, plugin_dir: str | None): + """Create a mock EntryPoint whose load() returns a real MapOverlayPluginBase subclass. - Creates a real module file so inspect.getfile resolves correctly. + The class's module file resolves to ``plugin_dir/__init__.py`` so the static-resource + lookup points at ``plugin_dir/static``. Pass ``plugin_dir=None`` to make the module + file unresolvable, exercising the static-registration failure path. """ - os.makedirs(module_path, exist_ok=True) - init_file = os.path.join(module_path, "__init__.py") - if not os.path.exists(init_file): - open(init_file, "w").close() + module = types.ModuleType(name) + if plugin_dir is not None: + os.makedirs(plugin_dir, exist_ok=True) + init_file = os.path.join(plugin_dir, "__init__.py") + if not os.path.exists(init_file): + open(init_file, "w").close() + module.__file__ = init_file + else: + module.__file__ = None + sys.modules[name] = module + + cls = type("Plugin", (MapOverlayPluginBase,), {}) + cls.__module__ = name + module.Plugin = cls + + ep = mock.MagicMock(spec=importlib.metadata.EntryPoint) + ep.name = name + ep.load.return_value = cls + return ep - real_module = types.ModuleType(name) - real_module.__file__ = init_file - spec = mock.MagicMock(spec=importlib.metadata.EntryPoint) - spec.name = name - spec.load.return_value = real_module - return spec +def _patch_entry_points(groups: dict[str, list[Any]]): + """Patch importlib.metadata.entry_points to return per-group entry points. + + Production resolves a distinct list per group; the global patch must mirror that so + cross-group discovery (platzky + goodmap) doesn't see spurious duplicates. + """ + + def _fake_entry_points(*_args: Any, **kwargs: Any) -> list[Any]: + return list(groups.get(kwargs.get("group"), [])) + + return patch("importlib.metadata.entry_points", side_effect=_fake_entry_points) + + +def _overlay_plugin_config(name: str, plugin_config: dict[str, Any] | None = None) -> GoodmapConfig: + return _make_test_app_config( + extra_data={"plugins": {name: {"is_active": True, "config": plugin_config or {}}}} + ) def test_plugin_with_static_dir(): - """Should register blueprint and manifest entry when plugin has a static directory.""" - config = _make_test_app_config() + """Active overlay plugin with a static dir gets a blueprint + manifest entry incl. config.""" + config = _overlay_plugin_config("my_plugin", {"foo": "bar"}) with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = os.path.join(tmpdir, "my_plugin") - static_dir = os.path.join(plugin_dir, "static") - os.makedirs(static_dir) + os.makedirs(os.path.join(plugin_dir, "static")) - ep = make_mock_entry_point("my_plugin", plugin_dir) + ep = _overlay_ep("my_plugin", plugin_dir) - with patch("importlib.metadata.entry_points", return_value=[ep]): + with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) assert "plugin_my_plugin" in app.blueprints @@ -206,40 +239,55 @@ def test_plugin_with_static_dir(): { "scope": "my_plugin", "url": "/plugins/my_plugin/static/remoteEntry.js", - "module": "./Button", + "module": "./Plugin", + "kind": "overlay", + "config": {"foo": "bar"}, } ] +def test_plugin_inactive_is_not_served(): + """A plugin that is installed but not active in config gets no frontend manifest entry.""" + config = _make_test_app_config() # no plugins configured -> inactive + with tempfile.TemporaryDirectory() as tmpdir: + plugin_dir = os.path.join(tmpdir, "off_plugin") + os.makedirs(os.path.join(plugin_dir, "static")) + + ep = _overlay_ep("off_plugin", plugin_dir) + + with _patch_entry_points({"goodmap.plugins": [ep]}): + app = goodmap.create_app_from_config(config) + + assert "plugin_off_plugin" not in app.blueprints + assert app.config["PLUGIN_MANIFEST"] == [] + + def test_plugin_without_static_dir(): - """Should not register blueprint when plugin has no static directory.""" - config = _make_test_app_config() + """Active overlay plugin without a static dir gets no blueprint/manifest entry.""" + config = _overlay_plugin_config("no_static_plugin") with tempfile.TemporaryDirectory() as tmpdir: - ep = make_mock_entry_point("no_static_plugin", tmpdir) + plugin_dir = os.path.join(tmpdir, "no_static_plugin") + ep = _overlay_ep("no_static_plugin", plugin_dir) - with patch("importlib.metadata.entry_points", return_value=[ep]): + with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) assert "plugin_no_static_plugin" not in app.blueprints assert app.config["PLUGIN_MANIFEST"] == [] -def test_plugin_load_failure(): - """Should log warning and skip plugin when loading fails.""" - config = _make_test_app_config() - ep = mock.MagicMock(spec=importlib.metadata.EntryPoint) - ep.name = "broken_plugin" - ep.load.side_effect = ImportError("Module not found") +def test_plugin_static_registration_failure_is_skipped(): + """An active plugin whose static resources can't be resolved is skipped with a warning.""" + config = _overlay_plugin_config("weird_plugin") + ep = _overlay_ep("weird_plugin", plugin_dir=None) # module file unresolvable - with patch("importlib.metadata.entry_points", return_value=[ep]): + with _patch_entry_points({"goodmap.plugins": [ep]}): with patch.object(goodmap.logger, "warning") as mock_warning: app = goodmap.create_app_from_config(config) - assert "plugin_broken_plugin" not in app.blueprints + assert "plugin_weird_plugin" not in app.blueprints assert app.config["PLUGIN_MANIFEST"] == [] - mock_warning.assert_called_once_with( - "Failed to serve static files for plugin '%s'", "broken_plugin" - ) + mock_warning.assert_any_call("Failed to serve static files for plugin '%s'", "weird_plugin") def _make_test_app_config(feature_flags: Any = None, extra_data: Any = None) -> GoodmapConfig: @@ -365,7 +413,7 @@ def _spy_core_pages(*args: Any, **kwargs: Any) -> Any: post_ep.load.return_value = _PluginB with mock.patch("goodmap.goodmap.core_pages", side_effect=_spy_core_pages): - with mock.patch("importlib.metadata.entry_points", return_value=[field_ep, post_ep]): + with _patch_entry_points({"platzky.plugins": [field_ep, post_ep]}): goodmap.create_app_from_config(config) assert "testfieldsc" in captured["shortcodes"] @@ -374,7 +422,7 @@ def _spy_core_pages(*args: Any, **kwargs: Any) -> Any: def test_plugin_blueprint_sets_cors_header(): """Should set Access-Control-Allow-Origin on plugin blueprint responses.""" - config = _make_test_app_config() + config = _overlay_plugin_config("cors_plugin") with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = os.path.join(tmpdir, "cors_plugin") static_dir = os.path.join(plugin_dir, "static") @@ -383,9 +431,9 @@ def test_plugin_blueprint_sets_cors_header(): # Create a test file in the static dir open(os.path.join(static_dir, "test.js"), "w").close() - ep = make_mock_entry_point("cors_plugin", plugin_dir) + ep = _overlay_ep("cors_plugin", plugin_dir) - with patch("importlib.metadata.entry_points", return_value=[ep]): + with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) app.config["WTF_CSRF_ENABLED"] = False # NOSONAR From cd4c62cb71dd69a268224316594a03da6403a99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 3 Jul 2026 23:19:23 +0200 Subject: [PATCH 02/19] fix not working deps --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index f7e81956..b3ca59bf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2140,14 +2140,14 @@ type = ["mypy (>=1.14.1)"] [[package]] name = "platzky" -version = "2.0.0a8" +version = "2.0.0a9" description = "Not only blog engine" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "platzky-2.0.0a8-py3-none-any.whl", hash = "sha256:0b391494d4d2abb8da89aa24b316c363822e093c6b9ed50236e0f0d39c1959a2"}, - {file = "platzky-2.0.0a8.tar.gz", hash = "sha256:4064b5d00e7f094dee6af9e1f20f971d18d8910b9684b77bf1ca9ece19b49191"}, + {file = "platzky-2.0.0a9-py3-none-any.whl", hash = "sha256:59eddbd5debb233365c726c97cf0f1c12a0c00bad9ef23283abdda406df02acf"}, + {file = "platzky-2.0.0a9.tar.gz", hash = "sha256:e956450560c61108cfb7eff5bac68431132d129818b29c0d73688f85f5ba9070"}, ] [package.dependencies] @@ -3988,4 +3988,4 @@ docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "997e3912da323776ca6a357b87605e2bbde65847c546a61825c351caf097e9a6" +content-hash = "558027fdf41d02c045771f9b1dd97fc21f8a78c94172510a061116f094549ae6" From 77fc38c2a6162f27c6f87577cefdfd3e2bf05a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 3 Jul 2026 23:52:57 +0200 Subject: [PATCH 03/19] linting --- goodmap/__init__.py | 4 ++-- tests/unit_tests/test_goodmap.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/goodmap/__init__.py b/goodmap/__init__.py index 615ac508..9cc1b55c 100644 --- a/goodmap/__init__.py +++ b/goodmap/__init__.py @@ -1,3 +1,3 @@ -from goodmap.plugin import GoodmapPluginBase, MapOverlayPluginBase +from goodmap.plugin import MapOverlayPluginBase -__all__ = ["GoodmapPluginBase", "MapOverlayPluginBase"] +__all__ = ["MapOverlayPluginBase"] diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 146e6171..2ef27c9f 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -195,7 +195,6 @@ def _overlay_ep(name: str, plugin_dir: str | None): cls = type("Plugin", (MapOverlayPluginBase,), {}) cls.__module__ = name - module.Plugin = cls ep = mock.MagicMock(spec=importlib.metadata.EntryPoint) ep.name = name @@ -211,7 +210,10 @@ def _patch_entry_points(groups: dict[str, list[Any]]): """ def _fake_entry_points(*_args: Any, **kwargs: Any) -> list[Any]: - return list(groups.get(kwargs.get("group"), [])) + group = kwargs.get("group") + if group is None: + return [] + return list(groups.get(group, [])) return patch("importlib.metadata.entry_points", side_effect=_fake_entry_points) From 79d7eb1ed9140cdfddec945b227b965d7175b286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 00:36:08 +0200 Subject: [PATCH 04/19] kind is overlay now --- frontend/src/plugins/pluginLoader.js | 4 ++-- frontend/src/plugins/pluginRegistry.js | 6 +++--- goodmap/goodmap.py | 12 ++++++------ goodmap/plugin.py | 17 +++++++++++++++-- tests/unit_tests/test_goodmap.py | 2 +- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/frontend/src/plugins/pluginLoader.js b/frontend/src/plugins/pluginLoader.js index 9a745198..665ac6ea 100644 --- a/frontend/src/plugins/pluginLoader.js +++ b/frontend/src/plugins/pluginLoader.js @@ -18,14 +18,14 @@ export async function loadPlugins() { await __webpack_init_sharing__('default'); - for (const { scope, url, module: moduleName, config, kind } 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, config, kind); + registerPlugin(scope, Module.default, config, capability); } catch (e) { console.warn(`Failed to load plugin "${scope}":`, e); } diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index eace3f0e..3ff08105 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -1,8 +1,8 @@ const registry = new Map(); const listeners = new Set(); -export function registerPlugin(scope, Component, config, kind) { - registry.set(scope, { Component, config, kind }); +export function registerPlugin(scope, Component, config, capability) { + registry.set(scope, { Component, config, capability }); listeners.forEach(fn => fn()); } @@ -18,7 +18,7 @@ export function getPluginConfig(scope) { // plugins are mounted per marker via PluginSlot and are excluded here. export function getOverlayPlugins() { return Array.from(registry.entries()) - .filter(([, entry]) => entry.kind === 'overlay') + .filter(([, entry]) => entry.capability === 'overlay') .map(([scope, { Component, config }]) => [scope, Component, config]); } diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 93c79da3..bc9b6da6 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -67,16 +67,16 @@ def _add_cors(response): response.headers["Access-Control-Allow-Origin"] = "*" return response - # "kind" tells the frontend how to render the component: map overlays mount once - # over the map (MapOverlays); everything else renders in a marker field (PluginSlot). - is_overlay = isinstance(plugin_class, type) and issubclass( - plugin_class, MapOverlayPluginBase - ) + # "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": "./Plugin", - "kind": "overlay" if is_overlay else "field", + "capability": plugin_class.capability, } return bp, manifest_entry except Exception: diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 33362179..102e1e8f 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -1,12 +1,23 @@ """Base class for goodmap map plugins.""" -from typing import Any +from typing import Any, ClassVar from platzky.plugin.plugin import PluginBase class GoodmapPluginBase(PluginBase): - """Base class for goodmap map plugins.""" + """Base class (family root) for goodmap plugin capabilities. + + Each concrete subclass declares ``capability`` — a stable identifier for the + integration point the plugin provides (recorded in ``PLUGIN_MANIFEST`` and + used by the frontend to route the plugin to its handler). Some capabilities + mount a component at a location (e.g. ``overlay`` over the map, ``field`` in + a marker); others alter behaviour with no fixed placement (e.g. swapping the + tile engine). ``capability`` names *what the plugin is*, independent of where + — or whether — it renders. + """ + + capability: ClassVar[str] def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -24,5 +35,7 @@ class MapOverlayPluginBase(GoodmapPluginBase): so overlay plugins are config-gated through the standard plugin loader. """ + capability: ClassVar[str] = "overlay" + def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 2ef27c9f..a2a614aa 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -242,7 +242,7 @@ def test_plugin_with_static_dir(): "scope": "my_plugin", "url": "/plugins/my_plugin/static/remoteEntry.js", "module": "./Plugin", - "kind": "overlay", + "capability": "overlay", "config": {"foo": "bar"}, } ] From 294b6a9f0b5a9f3773f43d4457a21212c89f7e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 09:23:20 +0200 Subject: [PATCH 05/19] better docs --- docs/plugins.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index 31481acd..0a8bfc03 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -95,8 +95,8 @@ to the React component as the ``config`` prop: "is_active": true, "config": { "messages": { - "pl": "Nie ma nic w pobliżu. Zobacz naszych partnerów", - "en": "Nothing nearby. See our partners" + "pl": "Nie ma nic w pobliżu", + "en": "Nothing nearby" } } } From 9c16a0786e423f7e90d524710d9e9158b3d05cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 13:18:45 +0200 Subject: [PATCH 06/19] naming fixed --- .../MarkerPopup/mapCustomTypeToReactComponent.jsx | 6 +++--- frontend/src/plugins/MapOverlays.jsx | 4 ++-- frontend/src/plugins/PluginSlot.jsx | 12 ++++++------ frontend/src/plugins/pluginLoader.js | 8 ++++---- frontend/src/plugins/pluginRegistry.js | 14 +++++++------- .../tests/MarkerPopup/LocationDetailsBox.test.jsx | 6 ++++-- frontend/tests/plugins/MapOverlays.test.jsx | 4 ++-- frontend/tests/plugins/PluginSlot.test.jsx | 6 +++--- goodmap/goodmap.py | 2 +- tests/unit_tests/test_formatter.py | 6 +++--- tests/unit_tests/test_goodmap.py | 2 +- 11 files changed, 36 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx b/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx index 043d085b..6c82823a 100644 --- a/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx +++ b/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx @@ -42,9 +42,9 @@ const sanitizeUrl = raw => { * @returns {React.ReactElement|string} React component for the custom type or string content */ export const mapCustomTypeToReactComponent = customValue => { - if (customValue.scope) { - const { scope, ...props } = customValue; - return ; + if (customValue.pluginName) { + const { pluginName, ...props } = customValue; + return ; } if (!customValue.type || !customValue.value) { diff --git a/frontend/src/plugins/MapOverlays.jsx b/frontend/src/plugins/MapOverlays.jsx index 93120c9c..fb6abebf 100644 --- a/frontend/src/plugins/MapOverlays.jsx +++ b/frontend/src/plugins/MapOverlays.jsx @@ -13,8 +13,8 @@ const MapOverlays = ({ isMapLoading }) => { return ( <> - {plugins.map(([scope, Component, config]) => ( - + {plugins.map(([pluginName, Plugin, config]) => ( + ))} ); diff --git a/frontend/src/plugins/PluginSlot.jsx b/frontend/src/plugins/PluginSlot.jsx index c1abeafe..8312efe5 100644 --- a/frontend/src/plugins/PluginSlot.jsx +++ b/frontend/src/plugins/PluginSlot.jsx @@ -2,20 +2,20 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { getPlugin, subscribe } from './pluginRegistry'; -const PluginSlot = ({ scope, props: componentProps }) => { - const [Component, setComponent] = useState(() => getPlugin(scope)); +const PluginSlot = ({ pluginName, props: componentProps }) => { + const [Plugin, setPlugin] = useState(() => getPlugin(pluginName)); - useEffect(() => subscribe(() => setComponent(() => getPlugin(scope))), [scope]); + useEffect(() => subscribe(() => setPlugin(() => getPlugin(pluginName))), [pluginName]); - if (!Component) { + if (!Plugin) { return null; } // eslint-disable-next-line react/jsx-props-no-spreading - return ; + return ; }; PluginSlot.propTypes = { - scope: PropTypes.string.isRequired, + pluginName: PropTypes.string.isRequired, props: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; diff --git a/frontend/src/plugins/pluginLoader.js b/frontend/src/plugins/pluginLoader.js index 665ac6ea..3edb129d 100644 --- a/frontend/src/plugins/pluginLoader.js +++ b/frontend/src/plugins/pluginLoader.js @@ -18,16 +18,16 @@ export async function loadPlugins() { await __webpack_init_sharing__('default'); - for (const { scope, url, module: moduleName, config, capability } of manifest) { + for (const { pluginName, url, module: moduleName, config, capability } of manifest) { try { await loadRemoteScript(url); - const container = window[scope]; + const container = window[pluginName]; await container.init(__webpack_share_scopes__.default); const factory = await container.get(moduleName); const Module = factory(); - registerPlugin(scope, Module.default, config, capability); + registerPlugin(pluginName, Module.default, config, capability); } catch (e) { - console.warn(`Failed to load plugin "${scope}":`, e); + console.warn(`Failed to load plugin "${pluginName}":`, e); } } } diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 3ff08105..5039a9d2 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -1,17 +1,17 @@ const registry = new Map(); const listeners = new Set(); -export function registerPlugin(scope, Component, config, capability) { - registry.set(scope, { Component, config, capability }); +export function registerPlugin(pluginName, Plugin, config, capability) { + registry.set(pluginName, { Plugin, config, capability }); listeners.forEach(fn => fn()); } -export function getPlugin(scope) { - return registry.get(scope)?.Component; +export function getPlugin(pluginName) { + return registry.get(pluginName)?.Plugin; } -export function getPluginConfig(scope) { - return registry.get(scope)?.config ?? {}; +export function getPluginConfig(pluginName) { + return registry.get(pluginName)?.config ?? {}; } // Map-overlay plugins mount once over the map (see MapOverlays); field-renderer @@ -19,7 +19,7 @@ export function getPluginConfig(scope) { export function getOverlayPlugins() { return Array.from(registry.entries()) .filter(([, entry]) => entry.capability === 'overlay') - .map(([scope, { Component, config }]) => [scope, Component, config]); + .map(([pluginName, { Plugin, config }]) => [pluginName, Plugin, config]); } export function subscribe(fn) { diff --git a/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx b/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx index 862fb967..58857cc1 100644 --- a/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx +++ b/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx @@ -92,10 +92,12 @@ describe('should render marker popup correctly', () => { }); describe('should handle plugin fields', () => { - it('renders the field label for a scoped plugin field even when plugin is not loaded', () => { + it('renders the field label for a plugin field even when plugin is not loaded', () => { const placeWithPlugin = { ...correctMarkerData, - data: [['promocode', { scope: 'promocode', code: 'U1VN', text: 'Get it' }]], + data: [ + ['promocode', { pluginName: 'promocode', code: 'U1VN', text: 'Get it' }], + ], metadata: { uuid: 'test-uuid' }, }; render(); diff --git a/frontend/tests/plugins/MapOverlays.test.jsx b/frontend/tests/plugins/MapOverlays.test.jsx index dfedd0c9..504cc091 100644 --- a/frontend/tests/plugins/MapOverlays.test.jsx +++ b/frontend/tests/plugins/MapOverlays.test.jsx @@ -10,7 +10,7 @@ describe('MapOverlays', () => { const Overlay = ({ config }) => {config.message}; Overlay.propTypes = { config: PropTypes.shape({ message: PropTypes.string }).isRequired }; act(() => - registerPlugin('overlay-scope', Overlay, { message: 'nothing nearby' }, 'overlay'), + registerPlugin('overlay-plugin', Overlay, { message: 'nothing nearby' }, 'overlay'), ); render(); @@ -20,7 +20,7 @@ describe('MapOverlays', () => { it('does not render field-renderer plugins', () => { const Field = () => field plugin; - act(() => registerPlugin('field-scope', Field, {}, 'field')); + act(() => registerPlugin('field-plugin', Field, {}, 'field')); render(); diff --git a/frontend/tests/plugins/PluginSlot.test.jsx b/frontend/tests/plugins/PluginSlot.test.jsx index 4bebedbe..02483034 100644 --- a/frontend/tests/plugins/PluginSlot.test.jsx +++ b/frontend/tests/plugins/PluginSlot.test.jsx @@ -7,16 +7,16 @@ import { registerPlugin } from '../../src/plugins/pluginRegistry'; describe('PluginSlot', () => { it('renders nothing when plugin is not registered', () => { - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it('renders the registered component with given props', () => { const TestComponent = ({ message }) => {message}; TestComponent.propTypes = { message: PropTypes.string.isRequired }; - act(() => registerPlugin('test-scope', TestComponent, {}, 'field')); + act(() => registerPlugin('test-plugin', TestComponent, {}, 'field')); - render(); + render(); expect(screen.getByText('hello plugin')).toBeInTheDocument(); }); }); diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index bc9b6da6..fba311cd 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -73,7 +73,7 @@ def _add_cors(response): # 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, + "pluginName": ep.name, "url": f"/plugins/{ep.name}/static/remoteEntry.js", "module": "./Plugin", "capability": plugin_class.capability, diff --git a/tests/unit_tests/test_formatter.py b/tests/unit_tests/test_formatter.py index 48d127d2..206f1fbb 100644 --- a/tests/unit_tests/test_formatter.py +++ b/tests/unit_tests/test_formatter.py @@ -24,7 +24,7 @@ def __init__(self, defaults=None): self._defaults = defaults or {} def transform_field_value(self, value: object) -> dict[str, object]: - return {**self._defaults, "value": value, "scope": self.name} + return {**self._defaults, "value": value, "pluginName": self.name} def render(self, attrs: ShortcodeAttrs, content: str) -> str: return content @@ -33,7 +33,7 @@ def render(self, attrs: ShortcodeAttrs, content: str) -> str: def test_field_plugin_transforms_value(): place = {**test_place, "promo_code": "SAVE20"} result = prepare_pin(place, ["promo_code"], [], shortcodes={"promo_code": _FakeShortcode()}) - assert result["data"] == [["promo_code", {"scope": "promo_code", "value": "SAVE20"}]] + assert result["data"] == [["promo_code", {"pluginName": "promo_code", "value": "SAVE20"}]] def test_field_plugin_merges_defaults(): @@ -43,7 +43,7 @@ def test_field_plugin_merges_defaults(): assert result["data"] == [ [ "promo_code", - {"scope": "promo_code", "value": "SAVE20", "color": "#4caf50", "text": "Reveal"}, + {"pluginName": "promo_code", "value": "SAVE20", "color": "#4caf50", "text": "Reveal"}, ] ] diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index a2a614aa..3d860e04 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -239,7 +239,7 @@ def test_plugin_with_static_dir(): assert "plugin_my_plugin" in app.blueprints assert app.config["PLUGIN_MANIFEST"] == [ { - "scope": "my_plugin", + "pluginName": "my_plugin", "url": "/plugins/my_plugin/static/remoteEntry.js", "module": "./Plugin", "capability": "overlay", From 2f6733eb33b8b467a38e411d0e53fc85393e9426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 14:02:13 +0200 Subject: [PATCH 07/19] fixes after review --- .../components/MarkerPopup/FieldRenderer.jsx | 58 ++++++++++++ .../MarkerPopup/builtinFieldRenderers.jsx | 76 ++++++++++++++++ .../components/MarkerPopup/fieldContent.js | 10 +++ .../mapCustomTypeToReactComponent.jsx | 89 +++---------------- frontend/src/plugins/MapOverlays.jsx | 2 +- frontend/src/plugins/PluginSlot.jsx | 22 ----- frontend/src/plugins/pluginRegistry.js | 2 +- .../tests/MarkerPopup/FieldRenderer.test.jsx | 47 ++++++++++ .../MarkerPopup/LocationDetailsBox.test.jsx | 4 +- frontend/tests/plugins/PluginSlot.test.jsx | 22 ----- tests/unit_tests/test_formatter.py | 6 +- 11 files changed, 210 insertions(+), 128 deletions(-) create mode 100644 frontend/src/components/MarkerPopup/FieldRenderer.jsx create mode 100644 frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx create mode 100644 frontend/src/components/MarkerPopup/fieldContent.js delete mode 100644 frontend/src/plugins/PluginSlot.jsx create mode 100644 frontend/tests/MarkerPopup/FieldRenderer.test.jsx delete mode 100644 frontend/tests/plugins/PluginSlot.test.jsx diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx new file mode 100644 index 00000000..e32ef42d --- /dev/null +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { getPlugin, subscribe } from '../../plugins/pluginRegistry'; +import getContentAsString from './fieldContent'; +import { builtinFieldRenderers } from './builtinFieldRenderers'; + +// Built-in names a plugin also tried to claim; warned once each so the collision +// is visible to the author instead of silently shadowed. +const shadowedPlugins = new Set(); + +/** + * Resolves a field `type` to its renderer component: first-party built-ins take + * precedence, then field-capability plugins (looked up by name in the registry). + * A plugin registered under a built-in's name is shadowed by the built-in and + * warned once, since that first-party precedence is deliberate (e.g. keeping the + * URL-sanitizing link/button from being overridden). + * + * @param {string} type - The field's render type. + * @returns {React.ComponentType|undefined} The renderer, or undefined if none is registered. + */ +export const resolveFieldRenderer = type => { + const builtin = builtinFieldRenderers[type]; + if (builtin) { + if (getPlugin(type) && !shadowedPlugins.has(type)) { + shadowedPlugins.add(type); + console.warn( + `Field plugin "${type}" is shadowed by a built-in renderer of the same name; using the built-in.`, + ); + } + return builtin; + } + return getPlugin(type); +}; + +/** + * Renders a marker field value through its resolved renderer. Built-ins resolve + * synchronously; field plugins may load asynchronously, so this subscribes to the + * registry and re-renders when a matching plugin arrives. Falls back to a string + * representation while no renderer is available. + */ +const FieldRenderer = ({ type, props }) => { + const [Renderer, setRenderer] = useState(() => resolveFieldRenderer(type)); + + useEffect(() => subscribe(() => setRenderer(() => resolveFieldRenderer(type))), [type]); + + if (!Renderer) { + return getContentAsString(props.displayValue ?? props.value ?? ''); + } + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +}; + +FieldRenderer.propTypes = { + type: PropTypes.string.isRequired, + props: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +export default FieldRenderer; diff --git a/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx new file mode 100644 index 00000000..2c2ebd2e --- /dev/null +++ b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { MarkerCTAButtonStyle } from '../../styles/buttonStyle'; + +/** + * Sanitizes URLs to prevent javascript: or data: injection attacks. + * Only allows http:, https:, mailto:, and tel: protocols. + * + * @param {*} raw - Raw URL to sanitize + * @returns {string|null} Sanitized URL or null if invalid/unsafe + */ +const sanitizeUrl = raw => { + try { + // Use globalThis.location.origin as base, with fallback for non-browser environments + const base = globalThis.location?.origin || 'http://localhost'; + const url = new URL(String(raw), base); + const allowed = new Set(['http:', 'https:', 'mailto:', 'tel:']); + return allowed.has(url.protocol) ? url.href : null; + } catch { + return null; + } +}; + +/** + * Built-in field renderer: a safe external hyperlink. + * Falls back to plain text when the URL is unsafe. + */ +export const HyperlinkField = ({ value, displayValue = null }) => { + const text = displayValue || value; + const safe = sanitizeUrl(value); + if (!safe) return text; + return ( + + {text} + + ); +}; + +HyperlinkField.propTypes = { + value: PropTypes.string.isRequired, + displayValue: PropTypes.string, +}; + +/** + * Built-in field renderer: a call-to-action button that opens the (sanitized) + * URL in a new tab. + */ +export const CTAButtonField = ({ value, displayValue = null }) => { + const handleRedirect = () => { + const safe = sanitizeUrl(value); + if (!safe) return; + globalThis.open(safe, '_blank'); + }; + return ( + + ); +}; + +CTAButtonField.propTypes = { + value: PropTypes.string.isRequired, + displayValue: PropTypes.string, +}; + +// Built-in field renderers, keyed by field `type`. Resolved before plugins so a +// plugin cannot shadow a first-party renderer (e.g. the URL-sanitizing link/button). +export const builtinFieldRenderers = { + hyperlink: HyperlinkField, + CTA: CTAButtonField, +}; diff --git a/frontend/src/components/MarkerPopup/fieldContent.js b/frontend/src/components/MarkerPopup/fieldContent.js new file mode 100644 index 00000000..97c08391 --- /dev/null +++ b/frontend/src/components/MarkerPopup/fieldContent.js @@ -0,0 +1,10 @@ +/** + * Converts a field value to a string representation. + * Arrays are joined with a comma-space separator; other values are stringified. + * + * @param {*} data - Data to convert to string + * @returns {string} Joined string if array, otherwise the data converted to string + */ +const getContentAsString = data => (Array.isArray(data) ? data.join(', ') : String(data ?? '')); + +export default getContentAsString; diff --git a/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx b/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx index 6c82823a..d02e94aa 100644 --- a/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx +++ b/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx @@ -1,86 +1,23 @@ import React from 'react'; -import { MarkerCTAButtonStyle } from '../../styles/buttonStyle'; -import PluginSlot from '../../plugins/PluginSlot'; +import getContentAsString from './fieldContent'; +import FieldRenderer from './FieldRenderer'; -/** - * Converts data to a string representation. - * Arrays are joined with comma-space separator, other values are converted to string. - * - * @param {*} data - Data to convert to string - * @returns {string} Joined string if array, otherwise the data converted to string - */ -export const getContentAsString = data => - Array.isArray(data) ? data.join(', ') : String(data ?? ''); +// Re-exported for existing consumers (e.g. LocationDetails) that import it from here. +export { getContentAsString }; /** - * Sanitizes URLs to prevent javascript: or data: injection attacks. - * Only allows http:, https:, mailto:, and tel: protocols. + * Renders a custom typed marker field value. * - * @param {*} raw - Raw URL to sanitize - * @returns {string|null} Sanitized URL or null if invalid/unsafe - */ -const sanitizeUrl = raw => { - try { - // Use globalThis.location.origin as base, with fallback for non-browser environments - const base = globalThis.location?.origin || 'http://localhost'; - const url = new URL(String(raw), base); - const allowed = new Set(['http:', 'https:', 'mailto:', 'tel:']); - return allowed.has(url.protocol) ? url.href : null; - } catch { - return null; - } -}; - -/** - * Maps custom typed values to appropriate React components. - * Supports hyperlinks and CTA (Call-To-Action) buttons. + * Fields carrying a `type` are dispatched to their renderer — a built-in + * (hyperlink, CTA) or a field plugin — via FieldRenderer. Typeless values fall + * back to a string representation. * - * @param {Object} customValue - Custom value object with type and value properties - * @param {string} customValue.type - Type of custom value ('hyperlink' or 'CTA') - * @param {string} customValue.value - URL or value to use - * @param {string} [customValue.displayValue] - Optional display text (falls back to value) - * @returns {React.ReactElement|string} React component for the custom type or string content + * @param {Object} customValue - Field value object; `type` selects the renderer. + * @returns {React.ReactElement|string} Rendered field, or string content. */ export const mapCustomTypeToReactComponent = customValue => { - if (customValue.pluginName) { - const { pluginName, ...props } = customValue; - return ; - } - - if (!customValue.type || !customValue.value) { - return getContentAsString(customValue); - } - - const valueToDisplay = customValue?.displayValue || customValue.value; - - switch (customValue.type) { - case 'hyperlink': { - const safe = sanitizeUrl(customValue.value); - if (!safe) return valueToDisplay; - return ( - - {valueToDisplay} - - ); - } - case 'CTA': { - const handleRedirect = () => { - const safe = sanitizeUrl(customValue.value); - if (!safe) return; - globalThis.open(safe, '_blank'); - }; - return ( - - ); - } - default: - return getContentAsString(valueToDisplay); + if (customValue?.type) { + return ; } + return getContentAsString(customValue); }; diff --git a/frontend/src/plugins/MapOverlays.jsx b/frontend/src/plugins/MapOverlays.jsx index fb6abebf..52b82fa0 100644 --- a/frontend/src/plugins/MapOverlays.jsx +++ b/frontend/src/plugins/MapOverlays.jsx @@ -3,7 +3,7 @@ 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. +// map, not tied to any marker. Field-renderer plugins are mounted per marker by FieldRenderer. // 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 }) => { diff --git a/frontend/src/plugins/PluginSlot.jsx b/frontend/src/plugins/PluginSlot.jsx deleted file mode 100644 index 8312efe5..00000000 --- a/frontend/src/plugins/PluginSlot.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { getPlugin, subscribe } from './pluginRegistry'; - -const PluginSlot = ({ pluginName, props: componentProps }) => { - const [Plugin, setPlugin] = useState(() => getPlugin(pluginName)); - - useEffect(() => subscribe(() => setPlugin(() => getPlugin(pluginName))), [pluginName]); - - if (!Plugin) { - return null; - } - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -}; - -PluginSlot.propTypes = { - pluginName: PropTypes.string.isRequired, - props: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types -}; - -export default PluginSlot; diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 5039a9d2..a859f8a2 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -15,7 +15,7 @@ export function getPluginConfig(pluginName) { } // Map-overlay plugins mount once over the map (see MapOverlays); field-renderer -// plugins are mounted per marker via PluginSlot and are excluded here. +// plugins are mounted per marker via FieldRenderer and are excluded here. export function getOverlayPlugins() { return Array.from(registry.entries()) .filter(([, entry]) => entry.capability === 'overlay') diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx new file mode 100644 index 00000000..5c28abfa --- /dev/null +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import '@testing-library/jest-dom'; +import { render, screen, act } from '@testing-library/react'; +import FieldRenderer from '../../src/components/MarkerPopup/FieldRenderer'; +import { registerPlugin } from '../../src/plugins/pluginRegistry'; + +describe('FieldRenderer', () => { + it('renders a built-in field renderer resolved by type (hyperlink)', () => { + render( + , + ); + expect(screen.getByRole('link', { name: 'Example' })).toHaveAttribute( + 'href', + 'https://example.com/', + ); + }); + + it('renders a field plugin resolved by type and passes props', () => { + const Promo = ({ code }) => {code}; + Promo.propTypes = { code: PropTypes.string.isRequired }; + act(() => registerPlugin('promo', Promo, {}, 'field')); + + render(); + expect(screen.getByText('SAVE20')).toBeInTheDocument(); + }); + + it('falls back to string content when the type has no renderer', () => { + render(); + expect(screen.getByText('plain text')).toBeInTheDocument(); + }); + + it('lets a built-in take precedence over a plugin of the same type and warns once', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const Rogue = () => rogue; + act(() => registerPlugin('hyperlink', Rogue, {}, 'field')); + + render(); + expect(screen.queryByText('rogue')).not.toBeInTheDocument(); + expect(screen.getByRole('link')).toBeInTheDocument(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('hyperlink')); + warn.mockRestore(); + }); +}); diff --git a/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx b/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx index 58857cc1..b62f0b1f 100644 --- a/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx +++ b/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx @@ -95,9 +95,7 @@ describe('should render marker popup correctly', () => { it('renders the field label for a plugin field even when plugin is not loaded', () => { const placeWithPlugin = { ...correctMarkerData, - data: [ - ['promocode', { pluginName: 'promocode', code: 'U1VN', text: 'Get it' }], - ], + data: [['promocode', { type: 'promocode', code: 'U1VN', text: 'Get it' }]], metadata: { uuid: 'test-uuid' }, }; render(); diff --git a/frontend/tests/plugins/PluginSlot.test.jsx b/frontend/tests/plugins/PluginSlot.test.jsx deleted file mode 100644 index 02483034..00000000 --- a/frontend/tests/plugins/PluginSlot.test.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import '@testing-library/jest-dom'; -import { render, screen, act } from '@testing-library/react'; -import PluginSlot from '../../src/plugins/PluginSlot'; -import { registerPlugin } from '../../src/plugins/pluginRegistry'; - -describe('PluginSlot', () => { - it('renders nothing when plugin is not registered', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); - - it('renders the registered component with given props', () => { - const TestComponent = ({ message }) => {message}; - TestComponent.propTypes = { message: PropTypes.string.isRequired }; - act(() => registerPlugin('test-plugin', TestComponent, {}, 'field')); - - render(); - expect(screen.getByText('hello plugin')).toBeInTheDocument(); - }); -}); diff --git a/tests/unit_tests/test_formatter.py b/tests/unit_tests/test_formatter.py index 206f1fbb..ab0c1fca 100644 --- a/tests/unit_tests/test_formatter.py +++ b/tests/unit_tests/test_formatter.py @@ -24,7 +24,7 @@ def __init__(self, defaults=None): self._defaults = defaults or {} def transform_field_value(self, value: object) -> dict[str, object]: - return {**self._defaults, "value": value, "pluginName": self.name} + return {**self._defaults, "value": value, "type": self.name} def render(self, attrs: ShortcodeAttrs, content: str) -> str: return content @@ -33,7 +33,7 @@ def render(self, attrs: ShortcodeAttrs, content: str) -> str: def test_field_plugin_transforms_value(): place = {**test_place, "promo_code": "SAVE20"} result = prepare_pin(place, ["promo_code"], [], shortcodes={"promo_code": _FakeShortcode()}) - assert result["data"] == [["promo_code", {"pluginName": "promo_code", "value": "SAVE20"}]] + assert result["data"] == [["promo_code", {"type": "promo_code", "value": "SAVE20"}]] def test_field_plugin_merges_defaults(): @@ -43,7 +43,7 @@ def test_field_plugin_merges_defaults(): assert result["data"] == [ [ "promo_code", - {"pluginName": "promo_code", "value": "SAVE20", "color": "#4caf50", "text": "Reveal"}, + {"type": "promo_code", "value": "SAVE20", "color": "#4caf50", "text": "Reveal"}, ] ] From 7cc377f3e782d8433cc6a88e7c7657ab9939b2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 14:45:23 +0200 Subject: [PATCH 08/19] fixes for plugin handling --- docs/api.rst | 11 ++++ docs/plugins.rst | 41 +++++-------- .../Map/components/AccessibilityTable.jsx | 4 +- .../components/MarkerPopup/FieldRenderer.jsx | 33 ++++++---- .../MarkerPopup/LocationDetails.jsx | 18 +++--- .../mapCustomTypeToReactComponent.jsx | 23 ------- .../tests/MarkerPopup/FieldRenderer.test.jsx | 16 +++-- goodmap/__init__.py | 4 +- goodmap/goodmap.py | 4 +- goodmap/plugin.py | 18 ++++++ tests/unit_tests/test_goodmap.py | 61 +++++++++++++------ 11 files changed, 136 insertions(+), 97 deletions(-) delete mode 100644 frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx diff --git a/docs/api.rst b/docs/api.rst index b8b75aeb..054d942c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -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: diff --git a/docs/plugins.rst b/docs/plugins.rst index 0a8bfc03..750d873d 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -13,13 +13,14 @@ through platzky's normal plugin loader — see :doc:`platzky's plugin docs Two kinds of Goodmap frontend plugins ------------------------------------- -The capability a plugin subclasses determines *how* its frontend renders: +The capability a plugin provides determines *how* its frontend renders: -**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": "", ...}``; the frontend - detects the ``scope`` key and mounts the plugin component there (``PluginSlot``). +**Field renderers** (:class:`goodmap.plugin.MarkerFieldPluginBase` + a platzky shortcode) + Render a single location field inside a marker popup. The plugin's shortcode + transforms the field value into ``{"type": "", ...}`` on the backend; its + frontend component (capability ``"field"``) is resolved by ``type`` and mounted by + ``FieldRenderer``. The built-in field types ``hyperlink`` and ``CTA`` resolve through + the same mechanism and take precedence over a plugin of the same name. **Map overlays** (:class:`goodmap.plugin.MapOverlayPluginBase`) Render a component once *over the whole map*, not tied to any marker — e.g. a @@ -66,18 +67,20 @@ changes: } Goodmap serves the bundle at ``/plugins//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``. +manifest entry ``{pluginName, url, module: "./Plugin", capability, config}``. Each +Goodmap capability base class declares its own ``capability`` value — +:class:`~goodmap.plugin.MapOverlayPluginBase` declares ``"overlay"`` and +:class:`~goodmap.plugin.MarkerFieldPluginBase` declares ``"field"`` — and the frontend +uses it to mount the component at the right place (overlays over the map by +``MapOverlays``; field renderers in a marker by ``FieldRenderer``). 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": "", ...}`` and the -frontend renders the matching plugin component in the marker popup. +plugin, its shortcode transforms the value into ``{"type": "", ...}`` and the +frontend renders the matching component (resolved by ``type``) in the marker popup. Configuration ------------- @@ -107,17 +110,3 @@ Each plugin defines its own ``config`` schema — refer to the plugin's document for available fields. After adding or removing a plugin, restart the Flask server. - -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 - - DEBUG:goodmap.formatter:Dropping field 'promocode': unconfigured plugin data ... - -To see these messages, enable debug logging: - -.. code-block:: bash - - export FLASK_DEBUG=1 diff --git a/frontend/src/components/Map/components/AccessibilityTable.jsx b/frontend/src/components/Map/components/AccessibilityTable.jsx index dec2c7a5..a375169d 100644 --- a/frontend/src/components/Map/components/AccessibilityTable.jsx +++ b/frontend/src/components/Map/components/AccessibilityTable.jsx @@ -11,7 +11,7 @@ import Arrow from '@mui/icons-material/ArrowLeftRounded'; import { IconButton } from '@mui/material'; import PropTypes from 'prop-types'; import { httpService } from '../../../services/http/httpService'; -import { mapCustomTypeToReactComponent } from '../../MarkerPopup/mapCustomTypeToReactComponent'; +import FieldRenderer from '../../MarkerPopup/FieldRenderer'; import { useCategories } from '../../Categories/CategoriesContext'; /** @@ -132,7 +132,7 @@ const AccessibilityTable = ({ userPosition, setIsAccessibilityTableOpen }) => { > {row.map((cell, index) => ( - {cell.type ? mapCustomTypeToReactComponent(cell) : cell} + {cell.type ? : cell} ))} diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index e32ef42d..7976f32b 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -33,26 +33,37 @@ export const resolveFieldRenderer = type => { }; /** - * Renders a marker field value through its resolved renderer. Built-ins resolve - * synchronously; field plugins may load asynchronously, so this subscribes to the - * registry and re-renders when a matching plugin arrives. Falls back to a string - * representation while no renderer is available. + * Renders a marker field value. + * + * A value carrying a `type` is dispatched to its renderer — a built-in (hyperlink, + * CTA) or a field plugin. Built-ins resolve synchronously; field plugins may load + * asynchronously, so this subscribes to the registry and re-renders when a matching + * plugin arrives. Anything with no resolvable renderer — a typeless object, a + * primitive, or a not-yet-loaded plugin — falls back to a string representation. */ -const FieldRenderer = ({ type, props }) => { - const [Renderer, setRenderer] = useState(() => resolveFieldRenderer(type)); +const FieldRenderer = ({ value }) => { + const type = value?.type; + const [Renderer, setRenderer] = useState(() => (type ? resolveFieldRenderer(type) : undefined)); - useEffect(() => subscribe(() => setRenderer(() => resolveFieldRenderer(type))), [type]); + useEffect( + () => subscribe(() => setRenderer(() => (type ? resolveFieldRenderer(type) : undefined))), + [type], + ); if (!Renderer) { - return getContentAsString(props.displayValue ?? props.value ?? ''); + return getContentAsString(type ? value.displayValue ?? value.value ?? '' : value); } // eslint-disable-next-line react/jsx-props-no-spreading - return ; + return ; }; FieldRenderer.propTypes = { - type: PropTypes.string.isRequired, - props: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.array, + PropTypes.object, + ]).isRequired, }; export default FieldRenderer; diff --git a/frontend/src/components/MarkerPopup/LocationDetails.jsx b/frontend/src/components/MarkerPopup/LocationDetails.jsx index 2a2c98b9..e6c87d46 100644 --- a/frontend/src/components/MarkerPopup/LocationDetails.jsx +++ b/frontend/src/components/MarkerPopup/LocationDetails.jsx @@ -6,7 +6,8 @@ import ShareIcon from '@mui/icons-material/Share'; import { useTranslation } from 'react-i18next'; import { isMobile } from 'react-device-detect'; import { buttonStyleSmall } from '../../styles/buttonStyle'; -import { getContentAsString, mapCustomTypeToReactComponent } from './mapCustomTypeToReactComponent'; +import getContentAsString from './fieldContent'; +import FieldRenderer from './FieldRenderer'; import { ReportProblemForm } from './ReportProblemForm'; import { toast } from '../../utils/toast'; @@ -95,12 +96,15 @@ const isCustomValue = value => value !== null && typeof value === 'object' && !A * @param {string|number|Array|Object} props.valueToDisplay - Value to display, can be primitive or custom type object * @returns {React.ReactElement} Paragraph element containing the formatted value */ -const LocationDetailsValue = ({ valueToDisplay }) => { - const value = isCustomValue(valueToDisplay) - ? mapCustomTypeToReactComponent(valueToDisplay) - : valueToDisplay; - return {isCustomValue(valueToDisplay) ? value : getContentAsString(value)}; -}; +const LocationDetailsValue = ({ valueToDisplay }) => ( + + {isCustomValue(valueToDisplay) ? ( + + ) : ( + getContentAsString(valueToDisplay) + )} + +); LocationDetailsValue.propTypes = { valueToDisplay: PropTypes.oneOfType([ diff --git a/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx b/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx deleted file mode 100644 index d02e94aa..00000000 --- a/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import getContentAsString from './fieldContent'; -import FieldRenderer from './FieldRenderer'; - -// Re-exported for existing consumers (e.g. LocationDetails) that import it from here. -export { getContentAsString }; - -/** - * Renders a custom typed marker field value. - * - * Fields carrying a `type` are dispatched to their renderer — a built-in - * (hyperlink, CTA) or a field plugin — via FieldRenderer. Typeless values fall - * back to a string representation. - * - * @param {Object} customValue - Field value object; `type` selects the renderer. - * @returns {React.ReactElement|string} Rendered field, or string content. - */ -export const mapCustomTypeToReactComponent = customValue => { - if (customValue?.type) { - return ; - } - return getContentAsString(customValue); -}; diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx index 5c28abfa..9a8546a3 100644 --- a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -9,8 +9,7 @@ describe('FieldRenderer', () => { it('renders a built-in field renderer resolved by type (hyperlink)', () => { render( , ); expect(screen.getByRole('link', { name: 'Example' })).toHaveAttribute( @@ -24,21 +23,26 @@ describe('FieldRenderer', () => { Promo.propTypes = { code: PropTypes.string.isRequired }; act(() => registerPlugin('promo', Promo, {}, 'field')); - render(); + render(); expect(screen.getByText('SAVE20')).toBeInTheDocument(); }); - it('falls back to string content when the type has no renderer', () => { - render(); + it('falls back to the field value when the type has no renderer', () => { + render(); expect(screen.getByText('plain text')).toBeInTheDocument(); }); + it('renders a primitive value as a string', () => { + render(); + expect(screen.getByText('just text')).toBeInTheDocument(); + }); + it('lets a built-in take precedence over a plugin of the same type and warns once', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const Rogue = () => rogue; act(() => registerPlugin('hyperlink', Rogue, {}, 'field')); - render(); + render(); expect(screen.queryByText('rogue')).not.toBeInTheDocument(); expect(screen.getByRole('link')).toBeInTheDocument(); expect(warn).toHaveBeenCalledWith(expect.stringContaining('hyperlink')); diff --git a/goodmap/__init__.py b/goodmap/__init__.py index 9cc1b55c..fac85b99 100644 --- a/goodmap/__init__.py +++ b/goodmap/__init__.py @@ -1,3 +1,3 @@ -from goodmap.plugin import MapOverlayPluginBase +from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase -__all__ = ["MapOverlayPluginBase"] +__all__ = ["MapOverlayPluginBase", "MarkerFieldPluginBase"] diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index fba311cd..a56141bf 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -25,7 +25,7 @@ get_location_obligatory_fields, ) from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading -from goodmap.plugin import MapOverlayPluginBase +from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase logger = logging.getLogger(__name__) @@ -147,7 +147,7 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: # through platzky's normal plugin loader, alongside platzky's own plugins. app = platzky.create_app_from_config( config, - extra_plugin_bases=[MapOverlayPluginBase], + extra_plugin_bases=[MapOverlayPluginBase, MarkerFieldPluginBase], extra_plugins_entrypoints=[_PLUGIN_ENTRY_POINT_GROUP], ) diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 102e1e8f..418c32ff 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -39,3 +39,21 @@ class MapOverlayPluginBase(GoodmapPluginBase): def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) + + +class MarkerFieldPluginBase(GoodmapPluginBase): + """Capability: a plugin that renders a single location field inside a marker popup. + + Field plugins contribute a frontend component (served via Module Federation) that + renders a marker field whose ``type`` matches the plugin. The field's value is + produced by the plugin's platzky shortcode as ``{"type": "", ...}`` and mounted + by ``FieldRenderer`` on the frontend, which resolves ``type`` to the component. + + goodmap registers this capability with platzky (via ``extra_plugin_bases``) so field + plugins are config-gated through the standard plugin loader. + """ + + capability: ClassVar[str] = "field" + + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 3d860e04..a28eb741 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -13,7 +13,7 @@ from goodmap import goodmap from goodmap.config import GoodmapConfig from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading -from goodmap.plugin import MapOverlayPluginBase +from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase from tests.unit_tests.conftest import make_flag_set config = GoodmapConfig( @@ -35,7 +35,7 @@ def test_create_app_from_config(): goodmap.create_app_from_config(config) mock_platzky_app_creation.assert_called_once_with( config, - extra_plugin_bases=[goodmap.MapOverlayPluginBase], + extra_plugin_bases=[goodmap.MapOverlayPluginBase, goodmap.MarkerFieldPluginBase], extra_plugins_entrypoints=["goodmap.plugins"], ) mock_extend_db.assert_called_once() @@ -175,12 +175,14 @@ def test_index_route_location_schema_with_lazy_loading(): assert "test_category" in response_text -def _overlay_ep(name: str, plugin_dir: str | None): - """Create a mock EntryPoint whose load() returns a real MapOverlayPluginBase subclass. +def _plugin_ep(name: str, plugin_dir: str | None, base: type = MapOverlayPluginBase): + """Create a mock EntryPoint whose load() returns a real ``base`` subclass. - The class's module file resolves to ``plugin_dir/__init__.py`` so the static-resource - lookup points at ``plugin_dir/static``. Pass ``plugin_dir=None`` to make the module - file unresolvable, exercising the static-registration failure path. + ``base`` is the goodmap capability base the plugin subclasses (its ``capability`` is + read straight off the class into the manifest). The class's module file resolves to + ``plugin_dir/__init__.py`` so the static-resource lookup points at ``plugin_dir/static``. + Pass ``plugin_dir=None`` to make the module file unresolvable, exercising the + static-registration failure path. """ module = types.ModuleType(name) if plugin_dir is not None: @@ -193,7 +195,7 @@ def _overlay_ep(name: str, plugin_dir: str | None): module.__file__ = None sys.modules[name] = module - cls = type("Plugin", (MapOverlayPluginBase,), {}) + cls = type("Plugin", (base,), {}) cls.__module__ = name ep = mock.MagicMock(spec=importlib.metadata.EntryPoint) @@ -218,7 +220,7 @@ def _fake_entry_points(*_args: Any, **kwargs: Any) -> list[Any]: return patch("importlib.metadata.entry_points", side_effect=_fake_entry_points) -def _overlay_plugin_config(name: str, plugin_config: dict[str, Any] | None = None) -> GoodmapConfig: +def _plugin_config(name: str, plugin_config: dict[str, Any] | None = None) -> GoodmapConfig: return _make_test_app_config( extra_data={"plugins": {name: {"is_active": True, "config": plugin_config or {}}}} ) @@ -226,12 +228,12 @@ def _overlay_plugin_config(name: str, plugin_config: dict[str, Any] | None = Non def test_plugin_with_static_dir(): """Active overlay plugin with a static dir gets a blueprint + manifest entry incl. config.""" - config = _overlay_plugin_config("my_plugin", {"foo": "bar"}) + config = _plugin_config("my_plugin", {"foo": "bar"}) with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = os.path.join(tmpdir, "my_plugin") os.makedirs(os.path.join(plugin_dir, "static")) - ep = _overlay_ep("my_plugin", plugin_dir) + ep = _plugin_ep("my_plugin", plugin_dir) with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) @@ -248,6 +250,29 @@ def test_plugin_with_static_dir(): ] +def test_field_plugin_is_manifested_with_field_capability(): + """A MarkerFieldPluginBase plugin is manifested with ``capability: "field"``.""" + config = _plugin_config("promo", {"color": "#0f0"}) + with tempfile.TemporaryDirectory() as tmpdir: + plugin_dir = os.path.join(tmpdir, "promo") + os.makedirs(os.path.join(plugin_dir, "static")) + + ep = _plugin_ep("promo", plugin_dir, base=MarkerFieldPluginBase) + + with _patch_entry_points({"goodmap.plugins": [ep]}): + app = goodmap.create_app_from_config(config) + + assert app.config["PLUGIN_MANIFEST"] == [ + { + "pluginName": "promo", + "url": "/plugins/promo/static/remoteEntry.js", + "module": "./Plugin", + "capability": "field", + "config": {"color": "#0f0"}, + } + ] + + def test_plugin_inactive_is_not_served(): """A plugin that is installed but not active in config gets no frontend manifest entry.""" config = _make_test_app_config() # no plugins configured -> inactive @@ -255,7 +280,7 @@ def test_plugin_inactive_is_not_served(): plugin_dir = os.path.join(tmpdir, "off_plugin") os.makedirs(os.path.join(plugin_dir, "static")) - ep = _overlay_ep("off_plugin", plugin_dir) + ep = _plugin_ep("off_plugin", plugin_dir) with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) @@ -266,10 +291,10 @@ def test_plugin_inactive_is_not_served(): def test_plugin_without_static_dir(): """Active overlay plugin without a static dir gets no blueprint/manifest entry.""" - config = _overlay_plugin_config("no_static_plugin") + config = _plugin_config("no_static_plugin") with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = os.path.join(tmpdir, "no_static_plugin") - ep = _overlay_ep("no_static_plugin", plugin_dir) + ep = _plugin_ep("no_static_plugin", plugin_dir) with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) @@ -280,8 +305,8 @@ def test_plugin_without_static_dir(): def test_plugin_static_registration_failure_is_skipped(): """An active plugin whose static resources can't be resolved is skipped with a warning.""" - config = _overlay_plugin_config("weird_plugin") - ep = _overlay_ep("weird_plugin", plugin_dir=None) # module file unresolvable + config = _plugin_config("weird_plugin") + ep = _plugin_ep("weird_plugin", plugin_dir=None) # module file unresolvable with _patch_entry_points({"goodmap.plugins": [ep]}): with patch.object(goodmap.logger, "warning") as mock_warning: @@ -424,7 +449,7 @@ def _spy_core_pages(*args: Any, **kwargs: Any) -> Any: def test_plugin_blueprint_sets_cors_header(): """Should set Access-Control-Allow-Origin on plugin blueprint responses.""" - config = _overlay_plugin_config("cors_plugin") + config = _plugin_config("cors_plugin") with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = os.path.join(tmpdir, "cors_plugin") static_dir = os.path.join(plugin_dir, "static") @@ -433,7 +458,7 @@ def test_plugin_blueprint_sets_cors_header(): # Create a test file in the static dir open(os.path.join(static_dir, "test.js"), "w").close() - ep = _overlay_ep("cors_plugin", plugin_dir) + ep = _plugin_ep("cors_plugin", plugin_dir) with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) From d923db6b4010f9e4ff81777c8b44f1945c044d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 15:55:17 +0200 Subject: [PATCH 09/19] fixes --- .../components/MarkerPopup/FieldRenderer.jsx | 9 +++---- .../MarkerPopup/builtinFieldRenderers.jsx | 11 ++++----- .../tests/MarkerPopup/FieldRenderer.test.jsx | 24 +++++++++++++++++++ goodmap/goodmap.py | 5 +++- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index 7976f32b..4c6779da 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -45,10 +45,11 @@ const FieldRenderer = ({ value }) => { const type = value?.type; const [Renderer, setRenderer] = useState(() => (type ? resolveFieldRenderer(type) : undefined)); - useEffect( - () => subscribe(() => setRenderer(() => (type ? resolveFieldRenderer(type) : undefined))), - [type], - ); + useEffect(() => { + const resolve = () => setRenderer(() => (type ? resolveFieldRenderer(type) : undefined)); + resolve(); // re-resolve when `type` changes, not only on later registry events + return subscribe(resolve); + }, [type]); if (!Renderer) { return getContentAsString(type ? value.displayValue ?? value.value ?? '' : value); diff --git a/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx index 2c2ebd2e..3ec3ed64 100644 --- a/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx +++ b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx @@ -46,11 +46,10 @@ HyperlinkField.propTypes = { * URL in a new tab. */ export const CTAButtonField = ({ value, displayValue = null }) => { - const handleRedirect = () => { - const safe = sanitizeUrl(value); - if (!safe) return; - globalThis.open(safe, '_blank'); - }; + const text = displayValue || value; + const safe = sanitizeUrl(value); + if (!safe) return text; + const handleRedirect = () => globalThis.open(safe, '_blank'); return ( ); }; diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx index 9a8546a3..1a995d5b 100644 --- a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -37,6 +37,30 @@ describe('FieldRenderer', () => { expect(screen.getByText('just text')).toBeInTheDocument(); }); + it('re-resolves the renderer when value.type changes on the same instance', () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('link')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go' })).toBeInTheDocument(); + }); + + it('renders a CTA as plain text when the URL is unsafe', () => { + // data: is not in sanitizeUrl's allowlist (http/https/mailto/tel), so it is rejected + render( + , + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByText('X')).toBeInTheDocument(); + }); + it('lets a built-in take precedence over a plugin of the same type and warns once', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const Rogue = () => rogue; diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index a56141bf..467d1550 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -179,7 +179,10 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: try: plugins_data = app.db.get_plugins_data() except Exception: - logger.warning("Could not read plugin config data; frontend plugins get empty config") + logger.warning( + "Could not read plugin config data; frontend plugins get empty config", + exc_info=True, + ) plugins_data = {} plugin_manifest = [] From a89dc9dd6c3f3d51f01b03e996122beaaad83ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 19:23:40 +0200 Subject: [PATCH 10/19] todo added --- frontend/src/components/MarkerPopup/FieldRenderer.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index 4c6779da..88a873a7 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -8,6 +8,8 @@ import { builtinFieldRenderers } from './builtinFieldRenderers'; // is visible to the author instead of silently shadowed. const shadowedPlugins = new Set(); +// TODO: add decorator capability to types. + /** * Resolves a field `type` to its renderer component: first-party built-ins take * precedence, then field-capability plugins (looked up by name in the registry). From 3027ee1aba2c635ae8a202e552ec28324fe8dbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sat, 4 Jul 2026 19:48:31 +0200 Subject: [PATCH 11/19] added field decorator --- docs/plugins.rst | 70 +++++++++++++++++-- .../components/MarkerPopup/FieldRenderer.jsx | 43 ++++++++---- frontend/src/plugins/pluginRegistry.js | 9 +++ .../tests/MarkerPopup/FieldRenderer.test.jsx | 23 +++++- goodmap/__init__.py | 12 +++- goodmap/goodmap.py | 12 +++- goodmap/plugin.py | 19 +++++ tests/unit_tests/test_goodmap.py | 35 +++++++++- 8 files changed, 198 insertions(+), 25 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index 750d873d..75674d3c 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -10,7 +10,7 @@ through platzky's normal plugin loader — see :doc:`platzky's plugin docs ` for the underlying mechanism (``extra_plugin_bases`` / ``extra_plugins_entrypoints``). -Two kinds of Goodmap frontend plugins +Kinds of Goodmap frontend plugins ------------------------------------- The capability a plugin provides determines *how* its frontend renders: @@ -22,6 +22,14 @@ The capability a plugin provides determines *how* its frontend renders: ``FieldRenderer``. The built-in field types ``hyperlink`` and ``CTA`` resolve through the same mechanism and take precedence over a plugin of the same name. +**Field decorators** (:class:`goodmap.plugin.MarkerFieldDecoratorPluginBase`) + Wrap the rendered output of a field renderer instead of replacing it. The decorator + component receives the base's rendered element as ``children`` and composes around it + (icon, badge, tracking wrapper, styling), so the base renderer — and its behaviour, + e.g. the built-in link/button URL sanitization — still runs. The ``type`` it wraps is + taken from its ``config`` (``{"decorates": ""}``); decorators (capability + ``"field-decorator"``) are applied by ``FieldRenderer``. + **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 @@ -69,10 +77,11 @@ changes: Goodmap serves the bundle at ``/plugins//static/remoteEntry.js`` and adds a manifest entry ``{pluginName, url, module: "./Plugin", capability, config}``. Each Goodmap capability base class declares its own ``capability`` value — -:class:`~goodmap.plugin.MapOverlayPluginBase` declares ``"overlay"`` and -:class:`~goodmap.plugin.MarkerFieldPluginBase` declares ``"field"`` — and the frontend -uses it to mount the component at the right place (overlays over the map by -``MapOverlays``; field renderers in a marker by ``FieldRenderer``). +:class:`~goodmap.plugin.MapOverlayPluginBase` declares ``"overlay"``, +:class:`~goodmap.plugin.MarkerFieldPluginBase` declares ``"field"``, and +:class:`~goodmap.plugin.MarkerFieldDecoratorPluginBase` declares ``"field-decorator"`` — +and the frontend uses it to mount the component at the right place (overlays over the map +by ``MapOverlays``; field renderers and decorators in a marker by ``FieldRenderer``). Field renderers and ``visible_data`` ------------------------------------ @@ -82,6 +91,43 @@ Field renderers and ``visible_data`` plugin, its shortcode transforms the value into ``{"type": "", ...}`` and the frontend renders the matching component (resolved by ``type``) in the marker popup. +Field decorator plugins +----------------------- + +A decorator wraps an existing field renderer's output instead of replacing it — the way +to customize a built-in (``hyperlink``, ``CTA``) safely, since the base renderer still +runs. It subclasses :class:`~goodmap.plugin.MarkerFieldDecoratorPluginBase` and needs no +shortcode (it augments rendering, it does not produce field values): + +.. code-block:: python + + # hyperlink_badge/plugin.py + from typing import Any + from goodmap.plugin import MarkerFieldDecoratorPluginBase + + class HyperlinkBadgePlugin(MarkerFieldDecoratorPluginBase): + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) + +The React component receives the base renderer's rendered output as ``children`` (plus +its own ``config``) and composes around it. Because it only sees the already-rendered, +already-sanitized output, it cannot bypass the base renderer's behaviour: + +.. code-block:: jsx + + // frontend/src/Plugin.jsx (exposed as "./Plugin") + export default function HyperlinkBadge({ config, children }) { + return ( + + {children} + {config.label && {config.label}} + + ); + } + +The field ``type`` a decorator wraps is set in its ``config`` via ``decorates`` (below). +Multiple decorators on the same type compose in registration order. + Configuration ------------- @@ -106,6 +152,20 @@ to the React component as the ``config`` prop: } } +A field-decorator plugin additionally sets ``decorates`` in its ``config`` to target the +field ``type`` it wraps: + +.. code-block:: json + + { + "plugins": { + "hyperlink_badge": { + "is_active": true, + "config": { "decorates": "hyperlink", "label": "↗" } + } + } + } + Each plugin defines its own ``config`` schema — refer to the plugin's documentation for available fields. diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index 88a873a7..171bcd76 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { getPlugin, subscribe } from '../../plugins/pluginRegistry'; +import { getPlugin, getFieldDecorators, subscribe } from '../../plugins/pluginRegistry'; import getContentAsString from './fieldContent'; import { builtinFieldRenderers } from './builtinFieldRenderers'; @@ -8,14 +8,13 @@ import { builtinFieldRenderers } from './builtinFieldRenderers'; // is visible to the author instead of silently shadowed. const shadowedPlugins = new Set(); -// TODO: add decorator capability to types. - /** * Resolves a field `type` to its renderer component: first-party built-ins take * precedence, then field-capability plugins (looked up by name in the registry). * A plugin registered under a built-in's name is shadowed by the built-in and - * warned once, since that first-party precedence is deliberate (e.g. keeping the - * URL-sanitizing link/button from being overridden). + * warned once — that first-party precedence is deliberate (e.g. keeping the + * URL-sanitizing link/button from being replaced). To customize a built-in's + * rendering, register a field-decorator for the type instead of overriding it. * * @param {string} type - The field's render type. * @returns {React.ComponentType|undefined} The renderer, or undefined if none is registered. @@ -26,7 +25,8 @@ export const resolveFieldRenderer = type => { if (getPlugin(type) && !shadowedPlugins.has(type)) { shadowedPlugins.add(type); console.warn( - `Field plugin "${type}" is shadowed by a built-in renderer of the same name; using the built-in.`, + `Field plugin "${type}" is shadowed by a built-in renderer of the same name; ` + + `use a field-decorator to wrap the built-in instead.`, ); } return builtin; @@ -34,6 +34,13 @@ export const resolveFieldRenderer = type => { return getPlugin(type); }; +// Resolves the renderer plus any decorators registered for a `type` in one shot, so +// FieldRenderer re-resolves both together on a `type` change or a registry event. +const resolveField = type => ({ + Renderer: type ? resolveFieldRenderer(type) : undefined, + decorators: type ? getFieldDecorators(type) : [], +}); + /** * Renders a marker field value. * @@ -42,22 +49,32 @@ export const resolveFieldRenderer = type => { * asynchronously, so this subscribes to the registry and re-renders when a matching * plugin arrives. Anything with no resolvable renderer — a typeless object, a * primitive, or a not-yet-loaded plugin — falls back to a string representation. + * + * Field decorators registered for the `type` then wrap the rendered output as their + * `children` (composing in registration order). The base renderer still runs, so + * first-party behaviour like the built-in link/button URL sanitization is preserved. */ const FieldRenderer = ({ value }) => { const type = value?.type; - const [Renderer, setRenderer] = useState(() => (type ? resolveFieldRenderer(type) : undefined)); + const [{ Renderer, decorators }, setResolved] = useState(() => resolveField(type)); useEffect(() => { - const resolve = () => setRenderer(() => (type ? resolveFieldRenderer(type) : undefined)); + const resolve = () => setResolved(resolveField(type)); resolve(); // re-resolve when `type` changes, not only on later registry events return subscribe(resolve); }, [type]); - if (!Renderer) { - return getContentAsString(type ? value.displayValue ?? value.value ?? '' : value); - } - // eslint-disable-next-line react/jsx-props-no-spreading - return ; + const base = Renderer ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ) : ( + getContentAsString(type ? value.displayValue ?? value.value ?? '' : value) + ); + + return decorators.reduce( + (child, { Decorator, config }) => {child}, + base, + ); }; FieldRenderer.propTypes = { diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index a859f8a2..e5a6052b 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -22,6 +22,15 @@ export function getOverlayPlugins() { .map(([pluginName, { Plugin, config }]) => [pluginName, Plugin, config]); } +// Field decorators (capability "field-decorator") wrap the rendered output of the +// renderer for their target `type`, declared via `config.decorates`. Returned in +// registration order so multiple decorators compose predictably. +export function getFieldDecorators(type) { + return Array.from(registry.values()) + .filter(entry => entry.capability === 'field-decorator' && entry.config?.decorates === type) + .map(entry => ({ Decorator: entry.Plugin, config: entry.config })); +} + export function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx index 1a995d5b..940b757b 100644 --- a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import '@testing-library/jest-dom'; -import { render, screen, act } from '@testing-library/react'; +import { render, screen, act, within } from '@testing-library/react'; import FieldRenderer from '../../src/components/MarkerPopup/FieldRenderer'; import { registerPlugin } from '../../src/plugins/pluginRegistry'; @@ -72,4 +72,25 @@ describe('FieldRenderer', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('hyperlink')); warn.mockRestore(); }); + + it('wraps the base renderer output with a decorator matching the type', () => { + const Badge = ({ children }) =>
{children}
; + Badge.propTypes = { children: PropTypes.node.isRequired }; + act(() => registerPlugin('badge', Badge, { decorates: 'hyperlink' }, 'field-decorator')); + + render(); + + // The base (sanitizing) renderer still runs, inside the decorator wrapper. + const badge = screen.getByTestId('badge'); + expect(within(badge).getByRole('link')).toHaveAttribute('href', 'https://example.com/'); + }); + + it('does not apply a decorator registered for a different type', () => { + const Badge = ({ children }) =>
{children}
; + Badge.propTypes = { children: PropTypes.node.isRequired }; + act(() => registerPlugin('cta-badge', Badge, { decorates: 'CTA' }, 'field-decorator')); + + render(); + expect(screen.queryByTestId('cta-badge')).not.toBeInTheDocument(); + }); }); diff --git a/goodmap/__init__.py b/goodmap/__init__.py index fac85b99..c934c8ea 100644 --- a/goodmap/__init__.py +++ b/goodmap/__init__.py @@ -1,3 +1,11 @@ -from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase +from goodmap.plugin import ( + MapOverlayPluginBase, + MarkerFieldDecoratorPluginBase, + MarkerFieldPluginBase, +) -__all__ = ["MapOverlayPluginBase", "MarkerFieldPluginBase"] +__all__ = [ + "MapOverlayPluginBase", + "MarkerFieldDecoratorPluginBase", + "MarkerFieldPluginBase", +] diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 467d1550..0a884bfc 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -25,7 +25,11 @@ get_location_obligatory_fields, ) from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading -from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase +from goodmap.plugin import ( + MapOverlayPluginBase, + MarkerFieldDecoratorPluginBase, + MarkerFieldPluginBase, +) logger = logging.getLogger(__name__) @@ -147,7 +151,11 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: # through platzky's normal plugin loader, alongside platzky's own plugins. app = platzky.create_app_from_config( config, - extra_plugin_bases=[MapOverlayPluginBase, MarkerFieldPluginBase], + extra_plugin_bases=[ + MapOverlayPluginBase, + MarkerFieldPluginBase, + MarkerFieldDecoratorPluginBase, + ], extra_plugins_entrypoints=[_PLUGIN_ENTRY_POINT_GROUP], ) diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 418c32ff..d35a616c 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -57,3 +57,22 @@ class MarkerFieldPluginBase(GoodmapPluginBase): def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) + + +class MarkerFieldDecoratorPluginBase(GoodmapPluginBase): + """Capability: a plugin that decorates (wraps) another field renderer's output. + + A decorator's frontend component receives the base renderer's already-rendered + output as ``children`` and composes around it (icon, badge, tracking wrapper, + styling). The base renderer still runs, so first-party behaviour — e.g. the URL + sanitization in the built-in link/button — cannot be bypassed. The field ``type`` + a decorator wraps is taken from its ``config`` (``{"decorates": ""}``). + + goodmap registers this capability with platzky (via ``extra_plugin_bases``) so + decorator plugins are config-gated through the standard plugin loader. + """ + + capability: ClassVar[str] = "field-decorator" + + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index a28eb741..09be7579 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -13,7 +13,11 @@ from goodmap import goodmap from goodmap.config import GoodmapConfig from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading -from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase +from goodmap.plugin import ( + MapOverlayPluginBase, + MarkerFieldDecoratorPluginBase, + MarkerFieldPluginBase, +) from tests.unit_tests.conftest import make_flag_set config = GoodmapConfig( @@ -35,7 +39,11 @@ def test_create_app_from_config(): goodmap.create_app_from_config(config) mock_platzky_app_creation.assert_called_once_with( config, - extra_plugin_bases=[goodmap.MapOverlayPluginBase, goodmap.MarkerFieldPluginBase], + extra_plugin_bases=[ + goodmap.MapOverlayPluginBase, + goodmap.MarkerFieldPluginBase, + goodmap.MarkerFieldDecoratorPluginBase, + ], extra_plugins_entrypoints=["goodmap.plugins"], ) mock_extend_db.assert_called_once() @@ -273,6 +281,29 @@ def test_field_plugin_is_manifested_with_field_capability(): ] +def test_field_decorator_plugin_is_manifested_with_decorator_capability(): + """A decorator plugin is manifested with ``capability: "field-decorator"``.""" + config = _plugin_config("tracker", {"decorates": "hyperlink"}) + with tempfile.TemporaryDirectory() as tmpdir: + plugin_dir = os.path.join(tmpdir, "tracker") + os.makedirs(os.path.join(plugin_dir, "static")) + + ep = _plugin_ep("tracker", plugin_dir, base=MarkerFieldDecoratorPluginBase) + + with _patch_entry_points({"goodmap.plugins": [ep]}): + app = goodmap.create_app_from_config(config) + + assert app.config["PLUGIN_MANIFEST"] == [ + { + "pluginName": "tracker", + "url": "/plugins/tracker/static/remoteEntry.js", + "module": "./Plugin", + "capability": "field-decorator", + "config": {"decorates": "hyperlink"}, + } + ] + + def test_plugin_inactive_is_not_served(): """A plugin that is installed but not active in config gets no frontend manifest entry.""" config = _make_test_app_config() # no plugins configured -> inactive From 5390465957cde91fd5151e460e021a642e85d0d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sun, 5 Jul 2026 12:54:46 +0200 Subject: [PATCH 12/19] added plugin example --- docs/plugins.rst | 35 +++--- examples/plugins/silly-gif/README.md | 102 ++++++++++++++++++ .../plugins/silly-gif/frontend/package.json | 22 ++++ .../silly-gif/frontend/src/MapOverlay.jsx | 22 ++++ .../silly-gif/frontend/src/MarkerField.jsx | 9 ++ .../plugins/silly-gif/frontend/src/index.js | 4 + .../silly-gif/frontend/webpack.config.js | 40 +++++++ examples/plugins/silly-gif/pyproject.toml | 22 ++++ .../plugins/silly-gif/silly_gif/__init__.py | 26 +++++ .../components/MarkerPopup/FieldRenderer.jsx | 6 +- frontend/src/plugins/pluginLoader.js | 20 ++-- frontend/src/plugins/pluginRegistry.js | 23 ++-- .../tests/plugins/pluginRegistry.test.jsx | 22 ++++ goodmap/goodmap.py | 80 ++++++++------ goodmap/plugin.py | 28 +++-- poetry.lock | 8 +- pyproject.toml | 2 +- tests/unit_tests/test_formatter.py | 21 ++++ tests/unit_tests/test_goodmap.py | 42 ++++++-- 19 files changed, 450 insertions(+), 84 deletions(-) create mode 100644 examples/plugins/silly-gif/README.md create mode 100644 examples/plugins/silly-gif/frontend/package.json create mode 100644 examples/plugins/silly-gif/frontend/src/MapOverlay.jsx create mode 100644 examples/plugins/silly-gif/frontend/src/MarkerField.jsx create mode 100644 examples/plugins/silly-gif/frontend/src/index.js create mode 100644 examples/plugins/silly-gif/frontend/webpack.config.js create mode 100644 examples/plugins/silly-gif/pyproject.toml create mode 100644 examples/plugins/silly-gif/silly_gif/__init__.py create mode 100644 frontend/tests/plugins/pluginRegistry.test.jsx diff --git a/docs/plugins.rst b/docs/plugins.rst index 75674d3c..d1ce96f4 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -35,9 +35,16 @@ The capability a plugin provides determines *how* its frontend renders: 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``). +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, ``./MarkerField`` for field renderers, ``./MarkerFieldDecorator`` +for decorators (the ``module`` declared on the capability base) — 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/silly-gif`` for a plugin that is both a map overlay *and* a marker +field renderer. Map overlay plugins ------------------- @@ -69,19 +76,21 @@ changes: .. code-block:: jsx - // frontend/src/Plugin.jsx (exposed as "./Plugin") + // frontend/src/MapOverlay.jsx (exposed as "./MapOverlay") export default function MyOverlayPlugin({ config }) { return
{config.message}
; } -Goodmap serves the bundle at ``/plugins//static/remoteEntry.js`` and adds a -manifest entry ``{pluginName, url, module: "./Plugin", capability, config}``. Each -Goodmap capability base class declares its own ``capability`` value — -:class:`~goodmap.plugin.MapOverlayPluginBase` declares ``"overlay"``, -:class:`~goodmap.plugin.MarkerFieldPluginBase` declares ``"field"``, and -:class:`~goodmap.plugin.MarkerFieldDecoratorPluginBase` declares ``"field-decorator"`` — -and the frontend uses it to mount the component at the right place (overlays over the map -by ``MapOverlays``; field renderers and decorators in a marker by ``FieldRenderer``). +Goodmap serves the bundle at ``/plugins//static/remoteEntry.js`` and adds a manifest +entry ``{pluginName, url, module, capability, config}`` for each capability the plugin +provides. Each Goodmap capability base class declares its own ``capability`` value and +``module`` — +:class:`~goodmap.plugin.MapOverlayPluginBase` (``"overlay"`` / ``./MapOverlay``), +:class:`~goodmap.plugin.MarkerFieldPluginBase` (``"field"`` / ``./MarkerField``), and +:class:`~goodmap.plugin.MarkerFieldDecoratorPluginBase` +(``"field-decorator"`` / ``./MarkerFieldDecorator``) — and the frontend uses ``capability`` +to mount the component at the right place (overlays over the map by ``MapOverlays``; field +renderers and decorators in a marker by ``FieldRenderer``). Field renderers and ``visible_data`` ------------------------------------ @@ -115,7 +124,7 @@ already-sanitized output, it cannot bypass the base renderer's behaviour: .. code-block:: jsx - // frontend/src/Plugin.jsx (exposed as "./Plugin") + // frontend/src/MarkerFieldDecorator.jsx (exposed as "./MarkerFieldDecorator") export default function HyperlinkBadge({ config, children }) { return ( diff --git a/examples/plugins/silly-gif/README.md b/examples/plugins/silly-gif/README.md new file mode 100644 index 00000000..8e741b53 --- /dev/null +++ b/examples/plugins/silly-gif/README.md @@ -0,0 +1,102 @@ +# `silly-gif` — example goodmap plugin + +A minimal, complete goodmap plugin that shows a silly gif in **two** places at once: + +- as a **map-loading overlay** (the `overlay` capability), and +- inside **marker fields** (the `field` capability). + +It exists to show how a plugin is put together — especially that **one plugin can provide +several frontend capabilities** — so you can copy it as a starting point. + +## Layout + +``` +silly-gif/ +├── pyproject.toml # entry point registration +├── silly_gif/ +│ ├── __init__.py # the plugin class (declares the capabilities) +│ └── static/ # built frontend bundle (created by the build; goodmap serves it) +└── frontend/ + ├── package.json + ├── webpack.config.js # Module Federation: exposes ./MapOverlay and ./MarkerField + └── src/ + ├── index.js # empty MF bootstrap + ├── MapOverlay.jsx # the "overlay" component + └── MarkerField.jsx # the "field" component +``` + +## How the pieces connect + +**The backend is just the capabilities you subclass.** `SillyGifPlugin` subclasses +`MapOverlayPluginBase` and `MarkerFieldPluginBase`, so goodmap emits **one manifest entry +per capability** — both pointing at the same `remoteEntry.js`, each at its own module: + +| capability | base class | module | mounted by | +|---|---|---|---| +| `overlay` | `MapOverlayPluginBase` | `./MapOverlay` | `MapOverlays` (once, over the map) | +| `field` | `MarkerFieldPluginBase` | `./MarkerField` | `FieldRenderer` (per marker field) | + +**The frontend build exposes one component per capability**, under the module names the +capability bases declare (`MapOverlayPluginBase.module === "./MapOverlay"`, etc.). The +Module Federation `name` (`silly_gif`) must equal the entry-point name. + +**Mind the differing prop contracts** — this trips people up: + +- An **overlay** component receives the plugin **`config`** plus `isMapLoading`. + (One gif for the whole map, taken from config.) +- A **field** component receives the **field value** spread as props — not `config` — + because `FieldRenderer` renders ``. So each marker can carry its + own gif in its data. + +## Build + +```bash +cd frontend +npm install +npm run build # writes ../silly_gif/static/remoteEntry.js +``` + +## Install & activate + +```bash +pip install -e . # or: poetry add ./examples/silly-gif in your goodmap app +``` + +Then enable it in your data source's `plugins` config (the `overlay` gif comes from here): + +```json +{ + "plugins": { + "silly_gif": { + "is_active": true, + "config": { "gif": "https://example.com/loading.gif" } + } + } +} +``` + +For the **field** capability, a marker's field value must be the typed dict the +`./MarkerField` component renders. Add such a field to a location and list it in +`visible_data`: + +```json +{ + "name": "Cat Café", + "position": [51.1, 17.0], + "silliness": { "type": "silly_gif", "gif": "https://example.com/cat.gif" } +} +``` + +> The `{ "type": "silly_gif", ... }` value can be authored directly in the data (as above), +> or produced from a plain field value by a platzky **shortcode** (a +> `ContentTransformerPluginBase` whose `transform_field_value` returns `{"type": "silly_gif", …}`). +> The shortcode route is out of scope for this minimal example. + +Restart the Flask server after adding or removing a plugin. + +## What to copy for your own plugin + +1. Pick the capabilities you need and subclass those bases (one or many). +2. Expose one component per capability under its `module` name in `webpack.config.js`. +3. Register the entry point in `pyproject.toml`. +4. Build into `your_package/static/`, install, and activate in config. diff --git a/examples/plugins/silly-gif/frontend/package.json b/examples/plugins/silly-gif/frontend/package.json new file mode 100644 index 00000000..0169f46f --- /dev/null +++ b/examples/plugins/silly-gif/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "silly-gif-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "webpack --mode production" + }, + "babel": { + "presets": ["@babel/preset-react"] + }, + "dependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@babel/core": "^7.0.0", + "@babel/preset-react": "^7.0.0", + "babel-loader": "^9.0.0", + "webpack": "^5.0.0", + "webpack-cli": "^5.0.0" + } +} diff --git a/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx b/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx new file mode 100644 index 00000000..22516c6d --- /dev/null +++ b/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +// The "overlay" capability's component. goodmap's MapOverlays mounts it once over the map +// and passes the plugin `config` plus `isMapLoading`. Here we show the gif (from config) +// only while the map's data is loading. +export default function SillyGifOverlay({ config, isMapLoading }) { + if (!isMapLoading) return null; + return ( + loading + ); +} diff --git a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx new file mode 100644 index 00000000..855a0b85 --- /dev/null +++ b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +// The "field" capability's component. goodmap's FieldRenderer mounts it for a marker field +// whose value is `{ type: 'silly_gif', gif: '' }`, spreading that value object as props. +// Note the contract differs from the overlay: a field component receives the *field value* +// (each marker can carry its own gif), not the plugin `config`. +export default function SillyGifField({ gif }) { + return silly gif; +} diff --git a/examples/plugins/silly-gif/frontend/src/index.js b/examples/plugins/silly-gif/frontend/src/index.js new file mode 100644 index 00000000..c666c19f --- /dev/null +++ b/examples/plugins/silly-gif/frontend/src/index.js @@ -0,0 +1,4 @@ +// Empty bootstrap entry for the Module Federation remote. The useful output is +// `remoteEntry.js` (the container exposing ./MapOverlay and ./MarkerField), which webpack +// emits from the ModuleFederationPlugin regardless of this entry. Nothing runs here. +export {}; diff --git a/examples/plugins/silly-gif/frontend/webpack.config.js b/examples/plugins/silly-gif/frontend/webpack.config.js new file mode 100644 index 00000000..f0c302c7 --- /dev/null +++ b/examples/plugins/silly-gif/frontend/webpack.config.js @@ -0,0 +1,40 @@ +const path = require('node:path'); +const { ModuleFederationPlugin } = require('webpack').container; + +// A goodmap frontend plugin is a Module Federation *remote*. This config mirrors the host +// (goodmap's own webpack.config.js): one container, React shared as a singleton, and one +// exposed module per capability the plugin provides. +module.exports = { + entry: './src/index.js', + mode: 'production', + output: { + // Build into the Python package's `static/` dir. goodmap serves it at + // /plugins/silly_gif/static/remoteEntry.js and points the manifest there. + path: path.resolve(__dirname, '..', 'silly_gif', 'static'), + publicPath: 'auto', + clean: true, + }, + resolve: { extensions: ['.js', '.jsx'] }, + module: { + rules: [{ test: /\.jsx?$/, exclude: /node_modules/, use: 'babel-loader' }], + }, + plugins: [ + new ModuleFederationPlugin({ + // MUST equal the plugin's entry-point name — it is the global container the + // goodmap host looks up as window['silly_gif']. + name: 'silly_gif', + filename: 'remoteEntry.js', + // One expose per capability, keyed by the capability's `module` (see the goodmap + // capability base classes: MapOverlayPluginBase.module === "./MapOverlay", etc.). + exposes: { + './MapOverlay': './src/MapOverlay', + './MarkerField': './src/MarkerField', + }, + // Borrow the host's single React instance so hooks work across the boundary. + shared: { + react: { singleton: true, requiredVersion: false }, + 'react-dom': { singleton: true, requiredVersion: false }, + }, + }), + ], +}; diff --git a/examples/plugins/silly-gif/pyproject.toml b/examples/plugins/silly-gif/pyproject.toml new file mode 100644 index 00000000..e19fe05f --- /dev/null +++ b/examples/plugins/silly-gif/pyproject.toml @@ -0,0 +1,22 @@ +[tool.poetry] +name = "goodmap-silly-gif" +version = "0.1.0" +description = "Example goodmap plugin: a silly gif shown as a map-loading overlay and inside marker fields." +authors = ["you "] +packages = [{ include = "silly_gif" }] +# Ship the built Module Federation bundle (frontend/webpack.config.js writes it here). +include = ["silly_gif/static/**/*"] + +[tool.poetry.dependencies] +python = "^3.10" +goodmap = "*" + +# Register the plugin in goodmap's entry-point group. The key ("silly_gif") is the plugin's +# identity everywhere: the Module Federation container name (webpack `name`), the manifest +# `pluginName`, and — for the field capability — the field `type` this plugin renders. +[tool.poetry.plugins."goodmap.plugins"] +silly_gif = "silly_gif:SillyGifPlugin" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/examples/plugins/silly-gif/silly_gif/__init__.py b/examples/plugins/silly-gif/silly_gif/__init__.py new file mode 100644 index 00000000..e1173e7a --- /dev/null +++ b/examples/plugins/silly-gif/silly_gif/__init__.py @@ -0,0 +1,26 @@ +"""Example goodmap plugin declaring two frontend capabilities. + +``SillyGifPlugin`` subclasses two goodmap capability bases, so goodmap manifests it once +per capability (see ``PLUGIN_MANIFEST``), both served from the plugin's single +``remoteEntry.js``: + +- :class:`~goodmap.plugin.MapOverlayPluginBase` -> capability ``"overlay"``, component + ``./MapOverlay``: a gif shown over the map while it loads. +- :class:`~goodmap.plugin.MarkerFieldPluginBase` -> capability ``"field"``, component + ``./MarkerField``: a gif rendered for marker fields valued + ``{"type": "silly_gif", "gif": ""}``. + +That's the whole backend: a plugin is *what capabilities it subclasses*. The two React +components live under ``frontend/src`` and are wired up by ``frontend/webpack.config.js``. +""" + +from typing import Any + +from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase + + +class SillyGifPlugin(MapOverlayPluginBase, MarkerFieldPluginBase): + """A silly gif, both as a map-loading overlay and inside marker fields.""" + + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index 171bcd76..7807070c 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { getPlugin, getFieldDecorators, subscribe } from '../../plugins/pluginRegistry'; +import { getFieldPlugin, getFieldDecorators, subscribe } from '../../plugins/pluginRegistry'; import getContentAsString from './fieldContent'; import { builtinFieldRenderers } from './builtinFieldRenderers'; @@ -22,7 +22,7 @@ const shadowedPlugins = new Set(); export const resolveFieldRenderer = type => { const builtin = builtinFieldRenderers[type]; if (builtin) { - if (getPlugin(type) && !shadowedPlugins.has(type)) { + if (getFieldPlugin(type) && !shadowedPlugins.has(type)) { shadowedPlugins.add(type); console.warn( `Field plugin "${type}" is shadowed by a built-in renderer of the same name; ` + @@ -31,7 +31,7 @@ export const resolveFieldRenderer = type => { } return builtin; } - return getPlugin(type); + return getFieldPlugin(type); }; // Resolves the renderer plus any decorators registered for a `type` in one shot, so diff --git a/frontend/src/plugins/pluginLoader.js b/frontend/src/plugins/pluginLoader.js index 3edb129d..92d26b21 100644 --- a/frontend/src/plugins/pluginLoader.js +++ b/frontend/src/plugins/pluginLoader.js @@ -18,16 +18,22 @@ export async function loadPlugins() { await __webpack_init_sharing__('default'); + // A plugin may appear in several manifest entries (one per capability), all sharing one + // remoteEntry.js. Load and initialize each plugin's container once, then pull the + // per-capability module from it. + const initialized = new Set(); + for (const { pluginName, url, module: moduleName, config, capability } of manifest) { try { - await loadRemoteScript(url); - const container = window[pluginName]; - await container.init(__webpack_share_scopes__.default); - const factory = await container.get(moduleName); - const Module = factory(); - registerPlugin(pluginName, Module.default, config, capability); + if (!initialized.has(pluginName)) { + await loadRemoteScript(url); + await window[pluginName].init(__webpack_share_scopes__.default); + initialized.add(pluginName); + } + const factory = await window[pluginName].get(moduleName); + registerPlugin(pluginName, factory().default, config, capability); } catch (e) { - console.warn(`Failed to load plugin "${pluginName}":`, e); + console.warn(`Failed to load plugin "${pluginName}" (${capability}):`, e); } } } diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index e5a6052b..84e02e65 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -1,25 +1,34 @@ +// A plugin can provide several capabilities (e.g. an overlay and a marker field), so +// entries are keyed by pluginName × capability rather than by pluginName alone. const registry = new Map(); const listeners = new Set(); +const entryKey = (pluginName, capability) => `${capability}::${pluginName}`; + export function registerPlugin(pluginName, Plugin, config, capability) { - registry.set(pluginName, { Plugin, config, capability }); + registry.set(entryKey(pluginName, capability), { pluginName, Plugin, config, capability }); listeners.forEach(fn => fn()); } -export function getPlugin(pluginName) { - return registry.get(pluginName)?.Plugin; +// The field-renderer component a plugin registered for its own name (a "field" capability). +export function getFieldPlugin(pluginName) { + return registry.get(entryKey(pluginName, 'field'))?.Plugin; } +// A plugin's config is shared across its capability entries; return it from any of them. export function getPluginConfig(pluginName) { - return registry.get(pluginName)?.config ?? {}; + for (const entry of registry.values()) { + if (entry.pluginName === pluginName) return entry.config ?? {}; + } + return {}; } // Map-overlay plugins mount once over the map (see MapOverlays); field-renderer // plugins are mounted per marker via FieldRenderer and are excluded here. export function getOverlayPlugins() { - return Array.from(registry.entries()) - .filter(([, entry]) => entry.capability === 'overlay') - .map(([pluginName, { Plugin, config }]) => [pluginName, Plugin, config]); + return Array.from(registry.values()) + .filter(entry => entry.capability === 'overlay') + .map(entry => [entry.pluginName, entry.Plugin, entry.config]); } // Field decorators (capability "field-decorator") wrap the rendered output of the diff --git a/frontend/tests/plugins/pluginRegistry.test.jsx b/frontend/tests/plugins/pluginRegistry.test.jsx new file mode 100644 index 00000000..d4484647 --- /dev/null +++ b/frontend/tests/plugins/pluginRegistry.test.jsx @@ -0,0 +1,22 @@ +import { + registerPlugin, + getFieldPlugin, + getOverlayPlugins, +} from '../../src/plugins/pluginRegistry'; + +describe('pluginRegistry multi-capability', () => { + it('keeps a plugin’s overlay and field entries under separate capabilities', () => { + const Overlay = () => null; + const Field = () => null; + registerPlugin('silly', Overlay, { gif: 'x' }, 'overlay'); + registerPlugin('silly', Field, { gif: 'x' }, 'field'); + + // The field lookup resolves the field component, not the overlay one. + expect(getFieldPlugin('silly')).toBe(Field); + + // The overlay listing surfaces the overlay component for the same plugin. + const overlays = getOverlayPlugins().filter(([name]) => name === 'silly'); + expect(overlays).toHaveLength(1); + expect(overlays[0][1]).toBe(Overlay); + }); +}); diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 0a884bfc..0ad2198d 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -25,38 +25,55 @@ get_location_obligatory_fields, ) from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading -from goodmap.plugin import ( - MapOverlayPluginBase, - MarkerFieldDecoratorPluginBase, - MarkerFieldPluginBase, -) +from goodmap.plugin import CAPABILITY_BASES, GoodmapPluginBase logger = logging.getLogger(__name__) _PLUGIN_ENTRY_POINT_GROUP = "goodmap.plugins" +def _frontend_capability_bases(plugin_class: Any) -> list[type[GoodmapPluginBase]]: + """The goodmap frontend capability bases a plugin subclasses (may be several). + + Derived by class-based recognition, the same way platzky matches plugins against its + capability bases. A plugin can provide multiple frontend capabilities (e.g. an overlay + and a marker field) by subclassing more than one base. + """ + if not isinstance(plugin_class, type): + return [] + return [base for base in CAPABILITY_BASES if issubclass(plugin_class, base)] + + def _register_plugin_static_resources( ep: importlib.metadata.EntryPoint, -) -> tuple[Blueprint | None, dict[str, Any] | None]: - """Load a plugin's static resources and return its blueprint and manifest entry. +) -> tuple[Blueprint | None, list[dict[str, Any]]]: + """Load a plugin's static resources and return its blueprint and manifest entries. - Loads the plugin module, checks for a 'static' directory, and if found - creates a Flask blueprint and a manifest entry for the frontend. + Loads the plugin module, checks for a 'static' directory, and if found creates a Flask + blueprint plus one manifest entry per frontend capability the plugin provides. Each + capability points at its own Module Federation ``module``, all served from the plugin's + single ``remoteEntry.js``. Args: ep: The entry point for the plugin. Returns: - A tuple of (blueprint, manifest_entry). Both are None if the plugin - has no static directory or loading fails. + A tuple of (blueprint, manifest_entries). The blueprint is None and the list empty + if the plugin has no static directory, no frontend capability, or loading fails. """ try: 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 + return None, [] + + # One manifest entry per capability the plugin provides. "capability" tells the + # frontend which handler mounts the component (overlay via MapOverlays, field via + # FieldRenderer, …) and "module" is that capability's Module Federation key. + bases = _frontend_capability_bases(plugin_class) + if not bases: + return None, [] bp = Blueprint( f"plugin_{ep.name}", @@ -71,21 +88,19 @@ 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 = { - "pluginName": ep.name, - "url": f"/plugins/{ep.name}/static/remoteEntry.js", - "module": "./Plugin", - "capability": plugin_class.capability, - } - return bp, manifest_entry + entries = [ + { + "pluginName": ep.name, + "url": f"/plugins/{ep.name}/static/remoteEntry.js", + "module": base.module, + "capability": base.capability, + } + for base in bases + ] + return bp, entries except Exception: logger.warning("Failed to serve static files for plugin '%s'", ep.name) - return None, None + return None, [] def _setup_location_model( @@ -151,11 +166,7 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: # through platzky's normal plugin loader, alongside platzky's own plugins. app = platzky.create_app_from_config( config, - extra_plugin_bases=[ - MapOverlayPluginBase, - MarkerFieldPluginBase, - MarkerFieldDecoratorPluginBase, - ], + extra_plugin_bases=list(CAPABILITY_BASES), extra_plugins_entrypoints=[_PLUGIN_ENTRY_POINT_GROUP], ) @@ -201,11 +212,12 @@ def create_app_from_config(config: GoodmapConfig) -> platzky.Engine: # 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 and entry is not None: + bp, entries = _register_plugin_static_resources(ep) + if bp is not None and entries: app.register_blueprint(bp) - entry["config"] = plugin_cfg.config - plugin_manifest.append(entry) + for entry in entries: + entry["config"] = plugin_cfg.config + plugin_manifest.append(entry) app.config["PLUGIN_MANIFEST"] = plugin_manifest diff --git a/goodmap/plugin.py b/goodmap/plugin.py index d35a616c..7124d82c 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -8,16 +8,17 @@ class GoodmapPluginBase(PluginBase): """Base class (family root) for goodmap plugin capabilities. - Each concrete subclass declares ``capability`` — a stable identifier for the - integration point the plugin provides (recorded in ``PLUGIN_MANIFEST`` and - used by the frontend to route the plugin to its handler). Some capabilities - mount a component at a location (e.g. ``overlay`` over the map, ``field`` in - a marker); others alter behaviour with no fixed placement (e.g. swapping the - tile engine). ``capability`` names *what the plugin is*, independent of where - — or whether — it renders. + Each concrete subclass declares a ``capability`` — a stable identifier for the + integration point the plugin provides (recorded in ``PLUGIN_MANIFEST`` and used + by the frontend to route the plugin to its handler) — and the ``module``, the + Module Federation key under which the frontend component for that capability is + exposed. A plugin may subclass **several** capability bases; goodmap emits one + manifest entry per capability, each pointing at that capability's ``module``, all + served from the plugin's single ``remoteEntry.js``. """ capability: ClassVar[str] + module: ClassVar[str] def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -36,6 +37,7 @@ class MapOverlayPluginBase(GoodmapPluginBase): """ capability: ClassVar[str] = "overlay" + module: ClassVar[str] = "./MapOverlay" def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -54,6 +56,7 @@ class MarkerFieldPluginBase(GoodmapPluginBase): """ capability: ClassVar[str] = "field" + module: ClassVar[str] = "./MarkerField" def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -73,6 +76,17 @@ class MarkerFieldDecoratorPluginBase(GoodmapPluginBase): """ capability: ClassVar[str] = "field-decorator" + module: ClassVar[str] = "./MarkerFieldDecorator" def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) + + +# The frontend plugin capabilities goodmap defines: registered with platzky (so plugins +# subclassing them are recognised and config-gated) and used to derive each plugin's +# manifest entries. A plugin may subclass one or more of these. +CAPABILITY_BASES: tuple[type[GoodmapPluginBase], ...] = ( + MapOverlayPluginBase, + MarkerFieldPluginBase, + MarkerFieldDecoratorPluginBase, +) diff --git a/poetry.lock b/poetry.lock index b3ca59bf..903d767f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2140,14 +2140,14 @@ type = ["mypy (>=1.14.1)"] [[package]] name = "platzky" -version = "2.0.0a9" +version = "2.0.0a10" description = "Not only blog engine" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "platzky-2.0.0a9-py3-none-any.whl", hash = "sha256:59eddbd5debb233365c726c97cf0f1c12a0c00bad9ef23283abdda406df02acf"}, - {file = "platzky-2.0.0a9.tar.gz", hash = "sha256:e956450560c61108cfb7eff5bac68431132d129818b29c0d73688f85f5ba9070"}, + {file = "platzky-2.0.0a10-py3-none-any.whl", hash = "sha256:8a5ee8f4a69c256d3c293a3b7d4ed5e08057fc1ea6ca96e07474a1db192f894a"}, + {file = "platzky-2.0.0a10.tar.gz", hash = "sha256:d9d3fe1c5920498928fce9d10b6557b3e4b100a13ffd0890c1d4db85c2a6b8d5"}, ] [package.dependencies] @@ -3988,4 +3988,4 @@ docs = ["myst-parser", "sphinx", "sphinx-rtd-theme"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "558027fdf41d02c045771f9b1dd97fc21f8a78c94172510a061116f094549ae6" +content-hash = "8533a76052ff0671aef9bff8aa52bdc28949432d5302ce052156857ee4eab83e" diff --git a/pyproject.toml b/pyproject.toml index d98615a8..93883c79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ Flask-WTF = "^1.2.1" gql = "^3.4.0" aiohttp = "^3.8.4" pydantic = "^2.12.0" -platzky = "2.0.0a9" +platzky = "2.0.0a10" deprecation = "^2.1.0" numpy = "^2.2.0" # Using fork because official PyPI version (0.7.7) has outdated numpy setup hack diff --git a/tests/unit_tests/test_formatter.py b/tests/unit_tests/test_formatter.py index ab0c1fca..2bed80a6 100644 --- a/tests/unit_tests/test_formatter.py +++ b/tests/unit_tests/test_formatter.py @@ -48,6 +48,27 @@ def test_field_plugin_merges_defaults(): ] +class _DefaultShortcode(Shortcode): + """Shortcode relying on platzky's default transform_field_value (no override).""" + + name = "promo_code" + description = "test" + + def render(self, attrs: ShortcodeAttrs, content: str) -> str: + return content + + +def test_field_plugin_uses_platzky_default_type_key(): + """platzky's default transform_field_value emits the ``type`` key goodmap routes on. + + Guards the cross-repo contract: goodmap's frontend resolves field renderers by ``type``, + so a plugin using platzky's default shortcode (no override) must land under ``type``. + """ + place = {**test_place, "promo_code": {"code": "SAVE20"}} + result = prepare_pin(place, ["promo_code"], [], shortcodes={"promo_code": _DefaultShortcode()}) + assert result["data"] == [["promo_code", {"type": "promo_code", "code": "SAVE20"}]] + + def test_formatting_when_missing_visible_field(): visible_fields = ["types", "gender", "visible_without_data", "dict_data", "plain_text"] expected_data = { diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 09be7579..70d32ad2 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -14,6 +14,7 @@ from goodmap.config import GoodmapConfig from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading from goodmap.plugin import ( + CAPABILITY_BASES, MapOverlayPluginBase, MarkerFieldDecoratorPluginBase, MarkerFieldPluginBase, @@ -39,11 +40,7 @@ def test_create_app_from_config(): goodmap.create_app_from_config(config) mock_platzky_app_creation.assert_called_once_with( config, - extra_plugin_bases=[ - goodmap.MapOverlayPluginBase, - goodmap.MarkerFieldPluginBase, - goodmap.MarkerFieldDecoratorPluginBase, - ], + extra_plugin_bases=list(CAPABILITY_BASES), extra_plugins_entrypoints=["goodmap.plugins"], ) mock_extend_db.assert_called_once() @@ -251,7 +248,7 @@ def test_plugin_with_static_dir(): { "pluginName": "my_plugin", "url": "/plugins/my_plugin/static/remoteEntry.js", - "module": "./Plugin", + "module": "./MapOverlay", "capability": "overlay", "config": {"foo": "bar"}, } @@ -274,7 +271,7 @@ def test_field_plugin_is_manifested_with_field_capability(): { "pluginName": "promo", "url": "/plugins/promo/static/remoteEntry.js", - "module": "./Plugin", + "module": "./MarkerField", "capability": "field", "config": {"color": "#0f0"}, } @@ -297,13 +294,42 @@ def test_field_decorator_plugin_is_manifested_with_decorator_capability(): { "pluginName": "tracker", "url": "/plugins/tracker/static/remoteEntry.js", - "module": "./Plugin", + "module": "./MarkerFieldDecorator", "capability": "field-decorator", "config": {"decorates": "hyperlink"}, } ] +def test_plugin_with_multiple_frontend_capabilities_gets_one_entry_per_capability(): + """A plugin subclassing two capability bases is manifested once per capability.""" + + class _OverlayAndField(MapOverlayPluginBase, MarkerFieldPluginBase): + pass + + config = _plugin_config("silly", {"gif": "cat.gif"}) + with tempfile.TemporaryDirectory() as tmpdir: + plugin_dir = os.path.join(tmpdir, "silly") + os.makedirs(os.path.join(plugin_dir, "static")) + + ep = _plugin_ep("silly", plugin_dir, base=_OverlayAndField) + + with _patch_entry_points({"goodmap.plugins": [ep]}): + app = goodmap.create_app_from_config(config) + + by_capability = {e["capability"]: e for e in app.config["PLUGIN_MANIFEST"]} + assert set(by_capability) == {"overlay", "field"} + assert by_capability["overlay"]["module"] == "./MapOverlay" + assert by_capability["field"]["module"] == "./MarkerField" + # Same plugin, same bundle URL, same config across both entries. + assert all( + e["pluginName"] == "silly" + and e["url"] == "/plugins/silly/static/remoteEntry.js" + and e["config"] == {"gif": "cat.gif"} + for e in app.config["PLUGIN_MANIFEST"] + ) + + def test_plugin_inactive_is_not_served(): """A plugin that is installed but not active in config gets no frontend manifest entry.""" config = _make_test_app_config() # no plugins configured -> inactive From 80e7a6b7ded15bef16805532f5534b7e62ea1a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sun, 5 Jul 2026 20:40:53 +0200 Subject: [PATCH 13/19] remove unnecessary keys --- docs/plugins.rst | 16 ++++----- examples/plugins/silly-gif/README.md | 16 ++++----- .../silly-gif/frontend/src/MapOverlay.jsx | 2 +- .../silly-gif/frontend/src/MarkerField.jsx | 2 +- .../silly-gif/frontend/webpack.config.js | 4 +-- .../plugins/silly-gif/silly_gif/__init__.py | 4 +-- frontend/src/plugins/pluginRegistry.js | 12 ++++--- .../tests/MarkerPopup/FieldRenderer.test.jsx | 10 +++--- frontend/tests/plugins/MapOverlays.test.jsx | 8 ++--- .../tests/plugins/pluginRegistry.test.jsx | 4 +-- goodmap/goodmap.py | 23 ++++++++----- goodmap/plugin.py | 33 ++++++++----------- tests/unit_tests/test_goodmap.py | 16 ++++----- 13 files changed, 76 insertions(+), 74 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index d1ce96f4..1af6fb24 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -18,7 +18,7 @@ The capability a plugin provides determines *how* its frontend renders: **Field renderers** (:class:`goodmap.plugin.MarkerFieldPluginBase` + a platzky shortcode) Render a single location field inside a marker popup. The plugin's shortcode transforms the field value into ``{"type": "", ...}`` on the backend; its - frontend component (capability ``"field"``) is resolved by ``type`` and mounted by + frontend component (capability ``"MarkerField"``) is resolved by ``type`` and mounted by ``FieldRenderer``. The built-in field types ``hyperlink`` and ``CTA`` resolve through the same mechanism and take precedence over a plugin of the same name. @@ -28,7 +28,7 @@ The capability a plugin provides determines *how* its frontend renders: (icon, badge, tracking wrapper, styling), so the base renderer — and its behaviour, e.g. the built-in link/button URL sanitization — still runs. The ``type`` it wraps is taken from its ``config`` (``{"decorates": ""}``); decorators (capability - ``"field-decorator"``) are applied by ``FieldRenderer``. + ``"MarkerFieldDecorator"``) are applied by ``FieldRenderer``. **Map overlays** (:class:`goodmap.plugin.MapOverlayPluginBase`) Render a component once *over the whole map*, not tied to any marker — e.g. a @@ -43,7 +43,7 @@ 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/silly-gif`` for a plugin that is both a map overlay *and* a marker +module. See ``examples/plugins/silly-gif`` for a plugin that is both a map overlay *and* a marker field renderer. Map overlay plugins @@ -83,12 +83,12 @@ changes: Goodmap serves the bundle at ``/plugins//static/remoteEntry.js`` and adds a manifest entry ``{pluginName, url, module, capability, config}`` for each capability the plugin -provides. Each Goodmap capability base class declares its own ``capability`` value and -``module`` — -:class:`~goodmap.plugin.MapOverlayPluginBase` (``"overlay"`` / ``./MapOverlay``), -:class:`~goodmap.plugin.MarkerFieldPluginBase` (``"field"`` / ``./MarkerField``), and +provides. The ``capability`` token and its ``module`` are derived from the capability base +class name (``PluginBase`` stripped) — +:class:`~goodmap.plugin.MapOverlayPluginBase` (``"MapOverlay"`` / ``./MapOverlay``), +:class:`~goodmap.plugin.MarkerFieldPluginBase` (``"MarkerField"`` / ``./MarkerField``), and :class:`~goodmap.plugin.MarkerFieldDecoratorPluginBase` -(``"field-decorator"`` / ``./MarkerFieldDecorator``) — and the frontend uses ``capability`` +(``"MarkerFieldDecorator"`` / ``./MarkerFieldDecorator``) — and the frontend uses ``capability`` to mount the component at the right place (overlays over the map by ``MapOverlays``; field renderers and decorators in a marker by ``FieldRenderer``). diff --git a/examples/plugins/silly-gif/README.md b/examples/plugins/silly-gif/README.md index 8e741b53..800cf075 100644 --- a/examples/plugins/silly-gif/README.md +++ b/examples/plugins/silly-gif/README.md @@ -2,8 +2,8 @@ A minimal, complete goodmap plugin that shows a silly gif in **two** places at once: -- as a **map-loading overlay** (the `overlay` capability), and -- inside **marker fields** (the `field` capability). +- as a **map-loading overlay** (the `MapOverlay` capability), and +- inside **marker fields** (the `MarkerField` capability). It exists to show how a plugin is put together — especially that **one plugin can provide several frontend capabilities** — so you can copy it as a starting point. @@ -21,8 +21,8 @@ silly-gif/ ├── webpack.config.js # Module Federation: exposes ./MapOverlay and ./MarkerField └── src/ ├── index.js # empty MF bootstrap - ├── MapOverlay.jsx # the "overlay" component - └── MarkerField.jsx # the "field" component + ├── MapOverlay.jsx # the MapOverlay component + └── MarkerField.jsx # the MarkerField component ``` ## How the pieces connect @@ -33,11 +33,11 @@ per capability** — both pointing at the same `remoteEntry.js`, each at its own | capability | base class | module | mounted by | |---|---|---|---| -| `overlay` | `MapOverlayPluginBase` | `./MapOverlay` | `MapOverlays` (once, over the map) | -| `field` | `MarkerFieldPluginBase` | `./MarkerField` | `FieldRenderer` (per marker field) | +| `MapOverlay` | `MapOverlayPluginBase` | `./MapOverlay` | `MapOverlays` (once, over the map) | +| `MarkerField` | `MarkerFieldPluginBase` | `./MarkerField` | `FieldRenderer` (per marker field) | -**The frontend build exposes one component per capability**, under the module names the -capability bases declare (`MapOverlayPluginBase.module === "./MapOverlay"`, etc.). The +**The frontend build exposes one component per capability**, under the module names goodmap +derives from each capability base name (`MapOverlayPluginBase` → `./MapOverlay`). The Module Federation `name` (`silly_gif`) must equal the entry-point name. **Mind the differing prop contracts** — this trips people up: diff --git a/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx b/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx index 22516c6d..7f28e1e7 100644 --- a/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx +++ b/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx @@ -1,6 +1,6 @@ import React from 'react'; -// The "overlay" capability's component. goodmap's MapOverlays mounts it once over the map +// The "MapOverlay" capability's component. goodmap's MapOverlays mounts it once over the map // and passes the plugin `config` plus `isMapLoading`. Here we show the gif (from config) // only while the map's data is loading. export default function SillyGifOverlay({ config, isMapLoading }) { diff --git a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx index 855a0b85..79d17414 100644 --- a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx +++ b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx @@ -1,6 +1,6 @@ import React from 'react'; -// The "field" capability's component. goodmap's FieldRenderer mounts it for a marker field +// The "MarkerField" capability's component. goodmap's FieldRenderer mounts it for a marker field // whose value is `{ type: 'silly_gif', gif: '' }`, spreading that value object as props. // Note the contract differs from the overlay: a field component receives the *field value* // (each marker can carry its own gif), not the plugin `config`. diff --git a/examples/plugins/silly-gif/frontend/webpack.config.js b/examples/plugins/silly-gif/frontend/webpack.config.js index f0c302c7..5aef101b 100644 --- a/examples/plugins/silly-gif/frontend/webpack.config.js +++ b/examples/plugins/silly-gif/frontend/webpack.config.js @@ -24,8 +24,8 @@ module.exports = { // goodmap host looks up as window['silly_gif']. name: 'silly_gif', filename: 'remoteEntry.js', - // One expose per capability, keyed by the capability's `module` (see the goodmap - // capability base classes: MapOverlayPluginBase.module === "./MapOverlay", etc.). + // One expose per capability, keyed by the module goodmap derives from the + // capability base name (MapOverlayPluginBase -> "./MapOverlay", etc.). exposes: { './MapOverlay': './src/MapOverlay', './MarkerField': './src/MarkerField', diff --git a/examples/plugins/silly-gif/silly_gif/__init__.py b/examples/plugins/silly-gif/silly_gif/__init__.py index e1173e7a..b044f184 100644 --- a/examples/plugins/silly-gif/silly_gif/__init__.py +++ b/examples/plugins/silly-gif/silly_gif/__init__.py @@ -4,9 +4,9 @@ per capability (see ``PLUGIN_MANIFEST``), both served from the plugin's single ``remoteEntry.js``: -- :class:`~goodmap.plugin.MapOverlayPluginBase` -> capability ``"overlay"``, component +- :class:`~goodmap.plugin.MapOverlayPluginBase` -> capability ``"MapOverlay"``, component ``./MapOverlay``: a gif shown over the map while it loads. -- :class:`~goodmap.plugin.MarkerFieldPluginBase` -> capability ``"field"``, component +- :class:`~goodmap.plugin.MarkerFieldPluginBase` -> capability ``"MarkerField"``, component ``./MarkerField``: a gif rendered for marker fields valued ``{"type": "silly_gif", "gif": ""}``. diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 84e02e65..3e309055 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -10,9 +10,9 @@ export function registerPlugin(pluginName, Plugin, config, capability) { listeners.forEach(fn => fn()); } -// The field-renderer component a plugin registered for its own name (a "field" capability). +// The field-renderer component a plugin registered for its own name (the MarkerField capability). export function getFieldPlugin(pluginName) { - return registry.get(entryKey(pluginName, 'field'))?.Plugin; + return registry.get(entryKey(pluginName, 'MarkerField'))?.Plugin; } // A plugin's config is shared across its capability entries; return it from any of them. @@ -27,16 +27,18 @@ export function getPluginConfig(pluginName) { // plugins are mounted per marker via FieldRenderer and are excluded here. export function getOverlayPlugins() { return Array.from(registry.values()) - .filter(entry => entry.capability === 'overlay') + .filter(entry => entry.capability === 'MapOverlay') .map(entry => [entry.pluginName, entry.Plugin, entry.config]); } -// Field decorators (capability "field-decorator") wrap the rendered output of the +// Field decorators (the MarkerFieldDecorator capability) wrap the rendered output of the // renderer for their target `type`, declared via `config.decorates`. Returned in // registration order so multiple decorators compose predictably. export function getFieldDecorators(type) { return Array.from(registry.values()) - .filter(entry => entry.capability === 'field-decorator' && entry.config?.decorates === type) + .filter( + entry => entry.capability === 'MarkerFieldDecorator' && entry.config?.decorates === type, + ) .map(entry => ({ Decorator: entry.Plugin, config: entry.config })); } diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx index 940b757b..c5bbce35 100644 --- a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -21,7 +21,7 @@ describe('FieldRenderer', () => { it('renders a field plugin resolved by type and passes props', () => { const Promo = ({ code }) => {code}; Promo.propTypes = { code: PropTypes.string.isRequired }; - act(() => registerPlugin('promo', Promo, {}, 'field')); + act(() => registerPlugin('promo', Promo, {}, 'MarkerField')); render(); expect(screen.getByText('SAVE20')).toBeInTheDocument(); @@ -64,7 +64,7 @@ describe('FieldRenderer', () => { it('lets a built-in take precedence over a plugin of the same type and warns once', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const Rogue = () => rogue; - act(() => registerPlugin('hyperlink', Rogue, {}, 'field')); + act(() => registerPlugin('hyperlink', Rogue, {}, 'MarkerField')); render(); expect(screen.queryByText('rogue')).not.toBeInTheDocument(); @@ -76,7 +76,9 @@ describe('FieldRenderer', () => { it('wraps the base renderer output with a decorator matching the type', () => { const Badge = ({ children }) =>
{children}
; Badge.propTypes = { children: PropTypes.node.isRequired }; - act(() => registerPlugin('badge', Badge, { decorates: 'hyperlink' }, 'field-decorator')); + act(() => + registerPlugin('badge', Badge, { decorates: 'hyperlink' }, 'MarkerFieldDecorator'), + ); render(); @@ -88,7 +90,7 @@ describe('FieldRenderer', () => { it('does not apply a decorator registered for a different type', () => { const Badge = ({ children }) =>
{children}
; Badge.propTypes = { children: PropTypes.node.isRequired }; - act(() => registerPlugin('cta-badge', Badge, { decorates: 'CTA' }, 'field-decorator')); + act(() => registerPlugin('cta-badge', Badge, { decorates: 'CTA' }, 'MarkerFieldDecorator')); render(); expect(screen.queryByTestId('cta-badge')).not.toBeInTheDocument(); diff --git a/frontend/tests/plugins/MapOverlays.test.jsx b/frontend/tests/plugins/MapOverlays.test.jsx index 504cc091..18722c2b 100644 --- a/frontend/tests/plugins/MapOverlays.test.jsx +++ b/frontend/tests/plugins/MapOverlays.test.jsx @@ -10,7 +10,7 @@ describe('MapOverlays', () => { const Overlay = ({ config }) => {config.message}; Overlay.propTypes = { config: PropTypes.shape({ message: PropTypes.string }).isRequired }; act(() => - registerPlugin('overlay-plugin', Overlay, { message: 'nothing nearby' }, 'overlay'), + registerPlugin('overlay-plugin', Overlay, { message: 'nothing nearby' }, 'MapOverlay'), ); render(); @@ -20,7 +20,7 @@ describe('MapOverlays', () => { it('does not render field-renderer plugins', () => { const Field = () => field plugin; - act(() => registerPlugin('field-plugin', Field, {}, 'field')); + act(() => registerPlugin('field-plugin', Field, {}, 'MarkerField')); render(); @@ -29,8 +29,8 @@ describe('MapOverlays', () => { 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')); + act(() => registerPlugin('with-config', Noop, { a: 1 }, 'MapOverlay')); + act(() => registerPlugin('without-config', Noop, undefined, 'MapOverlay')); expect(getPluginConfig('with-config')).toEqual({ a: 1 }); expect(getPluginConfig('without-config')).toEqual({}); diff --git a/frontend/tests/plugins/pluginRegistry.test.jsx b/frontend/tests/plugins/pluginRegistry.test.jsx index d4484647..a71927dc 100644 --- a/frontend/tests/plugins/pluginRegistry.test.jsx +++ b/frontend/tests/plugins/pluginRegistry.test.jsx @@ -8,8 +8,8 @@ describe('pluginRegistry multi-capability', () => { it('keeps a plugin’s overlay and field entries under separate capabilities', () => { const Overlay = () => null; const Field = () => null; - registerPlugin('silly', Overlay, { gif: 'x' }, 'overlay'); - registerPlugin('silly', Field, { gif: 'x' }, 'field'); + registerPlugin('silly', Overlay, { gif: 'x' }, 'MapOverlay'); + registerPlugin('silly', Field, { gif: 'x' }, 'MarkerField'); // The field lookup resolves the field component, not the overlay one. expect(getFieldPlugin('silly')).toBe(Field); diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 0ad2198d..9f4af308 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -88,15 +88,20 @@ def _add_cors(response): response.headers["Access-Control-Allow-Origin"] = "*" return response - entries = [ - { - "pluginName": ep.name, - "url": f"/plugins/{ep.name}/static/remoteEntry.js", - "module": base.module, - "capability": base.capability, - } - for base in bases - ] + entries = [] + for base in bases: + # The capability token and its Module Federation key are derived from the base + # class name (MapOverlayPluginBase -> "MapOverlay" / "./MapOverlay"), so the + # class is the single source of truth — no separate identifier to keep in sync. + capability = base.__name__.removesuffix("PluginBase") + entries.append( + { + "pluginName": ep.name, + "url": f"/plugins/{ep.name}/static/remoteEntry.js", + "module": f"./{capability}", + "capability": capability, + } + ) return bp, entries except Exception: logger.warning("Failed to serve static files for plugin '%s'", ep.name) diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 7124d82c..9f3430e4 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -1,6 +1,6 @@ """Base class for goodmap map plugins.""" -from typing import Any, ClassVar +from typing import Any from platzky.plugin.plugin import PluginBase @@ -8,18 +8,13 @@ class GoodmapPluginBase(PluginBase): """Base class (family root) for goodmap plugin capabilities. - Each concrete subclass declares a ``capability`` — a stable identifier for the - integration point the plugin provides (recorded in ``PLUGIN_MANIFEST`` and used - by the frontend to route the plugin to its handler) — and the ``module``, the - Module Federation key under which the frontend component for that capability is - exposed. A plugin may subclass **several** capability bases; goodmap emits one - manifest entry per capability, each pointing at that capability's ``module``, all - served from the plugin's single ``remoteEntry.js``. + A goodmap plugin declares its frontend capabilities by subclassing the concrete + capability bases below (one or more). goodmap derives each capability's manifest + token and Module Federation module from the base class name — ``MapOverlayPluginBase`` + -> capability ``"MapOverlay"`` exposed as ``"./MapOverlay"`` — so the class is the + single source of truth and there is no separate identifier to keep in sync. """ - capability: ClassVar[str] - module: ClassVar[str] - def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -34,10 +29,9 @@ class MapOverlayPluginBase(GoodmapPluginBase): goodmap registers this capability with platzky (via ``extra_plugin_bases``) so overlay plugins are config-gated through the standard plugin loader. - """ - capability: ClassVar[str] = "overlay" - module: ClassVar[str] = "./MapOverlay" + Manifest capability ``"MapOverlay"``; component exposed as ``"./MapOverlay"``. + """ def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -53,10 +47,9 @@ class MarkerFieldPluginBase(GoodmapPluginBase): goodmap registers this capability with platzky (via ``extra_plugin_bases``) so field plugins are config-gated through the standard plugin loader. - """ - capability: ClassVar[str] = "field" - module: ClassVar[str] = "./MarkerField" + Manifest capability ``"MarkerField"``; component exposed as ``"./MarkerField"``. + """ def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -73,10 +66,10 @@ class MarkerFieldDecoratorPluginBase(GoodmapPluginBase): goodmap registers this capability with platzky (via ``extra_plugin_bases``) so decorator plugins are config-gated through the standard plugin loader. - """ - capability: ClassVar[str] = "field-decorator" - module: ClassVar[str] = "./MarkerFieldDecorator" + Manifest capability ``"MarkerFieldDecorator"``; component exposed as + ``"./MarkerFieldDecorator"``. + """ def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 70d32ad2..5f6ad51e 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -183,8 +183,8 @@ def test_index_route_location_schema_with_lazy_loading(): def _plugin_ep(name: str, plugin_dir: str | None, base: type = MapOverlayPluginBase): """Create a mock EntryPoint whose load() returns a real ``base`` subclass. - ``base`` is the goodmap capability base the plugin subclasses (its ``capability`` is - read straight off the class into the manifest). The class's module file resolves to + ``base`` is the goodmap capability base the plugin subclasses (its manifest capability + is derived from the base class name). The class's module file resolves to ``plugin_dir/__init__.py`` so the static-resource lookup points at ``plugin_dir/static``. Pass ``plugin_dir=None`` to make the module file unresolvable, exercising the static-registration failure path. @@ -249,7 +249,7 @@ def test_plugin_with_static_dir(): "pluginName": "my_plugin", "url": "/plugins/my_plugin/static/remoteEntry.js", "module": "./MapOverlay", - "capability": "overlay", + "capability": "MapOverlay", "config": {"foo": "bar"}, } ] @@ -272,7 +272,7 @@ def test_field_plugin_is_manifested_with_field_capability(): "pluginName": "promo", "url": "/plugins/promo/static/remoteEntry.js", "module": "./MarkerField", - "capability": "field", + "capability": "MarkerField", "config": {"color": "#0f0"}, } ] @@ -295,7 +295,7 @@ def test_field_decorator_plugin_is_manifested_with_decorator_capability(): "pluginName": "tracker", "url": "/plugins/tracker/static/remoteEntry.js", "module": "./MarkerFieldDecorator", - "capability": "field-decorator", + "capability": "MarkerFieldDecorator", "config": {"decorates": "hyperlink"}, } ] @@ -318,9 +318,9 @@ class _OverlayAndField(MapOverlayPluginBase, MarkerFieldPluginBase): app = goodmap.create_app_from_config(config) by_capability = {e["capability"]: e for e in app.config["PLUGIN_MANIFEST"]} - assert set(by_capability) == {"overlay", "field"} - assert by_capability["overlay"]["module"] == "./MapOverlay" - assert by_capability["field"]["module"] == "./MarkerField" + assert set(by_capability) == {"MapOverlay", "MarkerField"} + assert by_capability["MapOverlay"]["module"] == "./MapOverlay" + assert by_capability["MarkerField"]["module"] == "./MarkerField" # Same plugin, same bundle URL, same config across both entries. assert all( e["pluginName"] == "silly" From 6379b9f371439f5df7878af19e807bad44bb9dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Sun, 5 Jul 2026 23:11:39 +0200 Subject: [PATCH 14/19] fixes for example plugin --- examples/plugins/silly-gif/.gitignore | 6 ++++++ examples/plugins/silly-gif/README.md | 1 - examples/plugins/silly-gif/frontend/src/index.js | 4 ---- examples/plugins/silly-gif/frontend/webpack.config.js | 4 +++- 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 examples/plugins/silly-gif/.gitignore delete mode 100644 examples/plugins/silly-gif/frontend/src/index.js diff --git a/examples/plugins/silly-gif/.gitignore b/examples/plugins/silly-gif/.gitignore new file mode 100644 index 00000000..7a281ffa --- /dev/null +++ b/examples/plugins/silly-gif/.gitignore @@ -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/ diff --git a/examples/plugins/silly-gif/README.md b/examples/plugins/silly-gif/README.md index 800cf075..00bbda88 100644 --- a/examples/plugins/silly-gif/README.md +++ b/examples/plugins/silly-gif/README.md @@ -20,7 +20,6 @@ silly-gif/ ├── package.json ├── webpack.config.js # Module Federation: exposes ./MapOverlay and ./MarkerField └── src/ - ├── index.js # empty MF bootstrap ├── MapOverlay.jsx # the MapOverlay component └── MarkerField.jsx # the MarkerField component ``` diff --git a/examples/plugins/silly-gif/frontend/src/index.js b/examples/plugins/silly-gif/frontend/src/index.js deleted file mode 100644 index c666c19f..00000000 --- a/examples/plugins/silly-gif/frontend/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -// Empty bootstrap entry for the Module Federation remote. The useful output is -// `remoteEntry.js` (the container exposing ./MapOverlay and ./MarkerField), which webpack -// emits from the ModuleFederationPlugin regardless of this entry. Nothing runs here. -export {}; diff --git a/examples/plugins/silly-gif/frontend/webpack.config.js b/examples/plugins/silly-gif/frontend/webpack.config.js index 5aef101b..df0d946e 100644 --- a/examples/plugins/silly-gif/frontend/webpack.config.js +++ b/examples/plugins/silly-gif/frontend/webpack.config.js @@ -5,7 +5,9 @@ const { ModuleFederationPlugin } = require('webpack').container; // (goodmap's own webpack.config.js): one container, React shared as a singleton, and one // exposed module per capability the plugin provides. module.exports = { - entry: './src/index.js', + // A federation-only remote has no app to run; ModuleFederationPlugin emits the + // useful output (remoteEntry.js), so no entry chunk is needed. + entry: {}, mode: 'production', output: { // Build into the Python package's `static/` dir. goodmap serves it at From c091ea1c025e99555aab5621fc1468ae1bef0868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Mon, 6 Jul 2026 11:31:49 +0200 Subject: [PATCH 15/19] merge two plugin bases --- docs/plugins.rst | 56 +++++++++---------- frontend/src/plugins/pluginRegistry.js | 16 +++--- .../tests/MarkerPopup/FieldRenderer.test.jsx | 6 +- goodmap/__init__.py | 12 +--- goodmap/plugin.py | 43 ++++++-------- tests/unit_tests/test_goodmap.py | 15 +++-- 6 files changed, 66 insertions(+), 82 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index 1af6fb24..96f4893f 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -15,20 +15,20 @@ Kinds of Goodmap frontend plugins The capability a plugin provides determines *how* its frontend renders: -**Field renderers** (:class:`goodmap.plugin.MarkerFieldPluginBase` + a platzky shortcode) - Render a single location field inside a marker popup. The plugin's shortcode - transforms the field value into ``{"type": "", ...}`` on the backend; its - frontend component (capability ``"MarkerField"``) is resolved by ``type`` and mounted by - ``FieldRenderer``. The built-in field types ``hyperlink`` and ``CTA`` resolve through - the same mechanism and take precedence over a plugin of the same name. - -**Field decorators** (:class:`goodmap.plugin.MarkerFieldDecoratorPluginBase`) - Wrap the rendered output of a field renderer instead of replacing it. The decorator - component receives the base's rendered element as ``children`` and composes around it - (icon, badge, tracking wrapper, styling), so the base renderer — and its behaviour, - e.g. the built-in link/button URL sanitization — still runs. The ``type`` it wraps is - taken from its ``config`` (``{"decorates": ""}``); decorators (capability - ``"MarkerFieldDecorator"``) are applied by ``FieldRenderer``. +**Marker fields** (:class:`goodmap.plugin.MarkerFieldPluginBase`) + Render a single location field inside a marker popup (capability ``"MarkerField"``, + mounted by ``FieldRenderer``). A field plugin plays one of two roles, chosen by its + ``config``: + + - **Renderer** (no ``config.decorates``): it *is* the component for the field ``type`` + matching its name — its shortcode transforms the value into ``{"type": "", ...}`` + and it receives that value spread as props. The built-in field types ``hyperlink`` and + ``CTA`` resolve the same way and take precedence over a plugin of the same name. + - **Decorator** (``config.decorates`` set to a field ``type``): it *wraps* that type's + rendering, receiving the base's rendered output as ``children`` (not the value) and + composing around it (icon, badge, tracking wrapper, styling). Because it only sees the + already-rendered output, it cannot bypass the base's behaviour, e.g. the built-in + link/button URL sanitization. A renderer is simply the innermost/base decorator. **Map overlays** (:class:`goodmap.plugin.MapOverlayPluginBase`) Render a component once *over the whole map*, not tied to any marker — e.g. a @@ -37,9 +37,8 @@ The capability a plugin provides determines *how* its frontend renders: 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, ``./MarkerField`` for field renderers, ``./MarkerFieldDecorator`` -for decorators (the ``module`` declared on the capability base) — all served from the -plugin's single ``remoteEntry.js``. +``./MapOverlay`` for overlays and ``./MarkerField`` for field plugins (renderers and +decorators alike) — 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 @@ -85,12 +84,11 @@ Goodmap serves the bundle at ``/plugins//static/remoteEntry.js`` and adds 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``), -:class:`~goodmap.plugin.MarkerFieldPluginBase` (``"MarkerField"`` / ``./MarkerField``), and -:class:`~goodmap.plugin.MarkerFieldDecoratorPluginBase` -(``"MarkerFieldDecorator"`` / ``./MarkerFieldDecorator``) — and the frontend uses ``capability`` -to mount the component at the right place (overlays over the map by ``MapOverlays``; field -renderers and decorators in a marker by ``FieldRenderer``). +: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 renderers and decorators in a marker by ``FieldRenderer``, which +tells them apart by ``config.decorates``). Field renderers and ``visible_data`` ------------------------------------ @@ -105,16 +103,18 @@ Field decorator plugins A decorator wraps an existing field renderer's output instead of replacing it — the way to customize a built-in (``hyperlink``, ``CTA``) safely, since the base renderer still -runs. It subclasses :class:`~goodmap.plugin.MarkerFieldDecoratorPluginBase` and needs no -shortcode (it augments rendering, it does not produce field values): +runs. It is an ordinary :class:`~goodmap.plugin.MarkerFieldPluginBase` (there is no separate +decorator capability); setting ``config.decorates`` is what makes it act as a decorator +rather than a renderer. It needs no shortcode (it augments rendering, it does not produce +field values): .. code-block:: python # hyperlink_badge/plugin.py from typing import Any - from goodmap.plugin import MarkerFieldDecoratorPluginBase + from goodmap.plugin import MarkerFieldPluginBase - class HyperlinkBadgePlugin(MarkerFieldDecoratorPluginBase): + class HyperlinkBadgePlugin(MarkerFieldPluginBase): def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) @@ -124,7 +124,7 @@ already-sanitized output, it cannot bypass the base renderer's behaviour: .. code-block:: jsx - // frontend/src/MarkerFieldDecorator.jsx (exposed as "./MarkerFieldDecorator") + // frontend/src/MarkerField.jsx (exposed as "./MarkerField") export default function HyperlinkBadge({ config, children }) { return ( diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 3e309055..813128aa 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -10,9 +10,11 @@ export function registerPlugin(pluginName, Plugin, config, capability) { listeners.forEach(fn => fn()); } -// The field-renderer component a plugin registered for its own name (the MarkerField capability). +// The base field renderer for a type: the MarkerField plugin registered under that name +// that is a renderer (no `config.decorates`) rather than a decorator of another type. export function getFieldPlugin(pluginName) { - return registry.get(entryKey(pluginName, 'MarkerField'))?.Plugin; + const entry = registry.get(entryKey(pluginName, 'MarkerField')); + return entry && !entry.config?.decorates ? entry.Plugin : undefined; } // A plugin's config is shared across its capability entries; return it from any of them. @@ -31,14 +33,12 @@ export function getOverlayPlugins() { .map(entry => [entry.pluginName, entry.Plugin, entry.config]); } -// Field decorators (the MarkerFieldDecorator capability) wrap the rendered output of the -// renderer for their target `type`, declared via `config.decorates`. Returned in -// registration order so multiple decorators compose predictably. +// Field decorators: MarkerField plugins acting as decorators (they declare `config.decorates`) +// wrap the rendered output of the renderer for their target `type`. Returned in registration +// order so multiple decorators compose predictably. export function getFieldDecorators(type) { return Array.from(registry.values()) - .filter( - entry => entry.capability === 'MarkerFieldDecorator' && entry.config?.decorates === type, - ) + .filter(entry => entry.capability === 'MarkerField' && entry.config?.decorates === type) .map(entry => ({ Decorator: entry.Plugin, config: entry.config })); } diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx index c5bbce35..aee5d580 100644 --- a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -76,9 +76,7 @@ describe('FieldRenderer', () => { it('wraps the base renderer output with a decorator matching the type', () => { const Badge = ({ children }) =>
{children}
; Badge.propTypes = { children: PropTypes.node.isRequired }; - act(() => - registerPlugin('badge', Badge, { decorates: 'hyperlink' }, 'MarkerFieldDecorator'), - ); + act(() => registerPlugin('badge', Badge, { decorates: 'hyperlink' }, 'MarkerField')); render(); @@ -90,7 +88,7 @@ describe('FieldRenderer', () => { it('does not apply a decorator registered for a different type', () => { const Badge = ({ children }) =>
{children}
; Badge.propTypes = { children: PropTypes.node.isRequired }; - act(() => registerPlugin('cta-badge', Badge, { decorates: 'CTA' }, 'MarkerFieldDecorator')); + act(() => registerPlugin('cta-badge', Badge, { decorates: 'CTA' }, 'MarkerField')); render(); expect(screen.queryByTestId('cta-badge')).not.toBeInTheDocument(); diff --git a/goodmap/__init__.py b/goodmap/__init__.py index c934c8ea..fac85b99 100644 --- a/goodmap/__init__.py +++ b/goodmap/__init__.py @@ -1,11 +1,3 @@ -from goodmap.plugin import ( - MapOverlayPluginBase, - MarkerFieldDecoratorPluginBase, - MarkerFieldPluginBase, -) +from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase -__all__ = [ - "MapOverlayPluginBase", - "MarkerFieldDecoratorPluginBase", - "MarkerFieldPluginBase", -] +__all__ = ["MapOverlayPluginBase", "MarkerFieldPluginBase"] diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 9f3430e4..6e3e5b3d 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -38,12 +38,24 @@ def __init__(self, config: dict[str, Any]) -> None: class MarkerFieldPluginBase(GoodmapPluginBase): - """Capability: a plugin that renders a single location field inside a marker popup. + """Capability: a plugin that renders a marker-popup field — as the base renderer or as + a decorator that wraps one. - Field plugins contribute a frontend component (served via Module Federation) that - renders a marker field whose ``type`` matches the plugin. The field's value is - produced by the plugin's platzky shortcode as ``{"type": "", ...}`` and mounted - by ``FieldRenderer`` on the frontend, which resolves ``type`` to the component. + A field plugin's component is mounted by ``FieldRenderer`` and plays one of two roles, + chosen by its ``config``: + + - **Renderer** (no ``config.decorates``): it *is* the component for the field ``type`` + matching its name. It receives the field value spread as props and renders it — the + base of the field's rendering. The value comes from the plugin's platzky shortcode as + ``{"type": "", ...}``. + - **Decorator** (``config.decorates`` set to a field ``type``): it *wraps* that type's + rendering. It receives the base's rendered output as ``children`` — not the value — and + composes around it (icon, badge, tracking wrapper, styling). Because it only sees the + already-rendered output, it cannot bypass the base's behaviour (e.g. the built-in + link/button URL sanitization). Multiple decorators compose in registration order. + + A renderer is simply the innermost/base decorator: it decorates the raw value into an + element, and the wrappers decorate that. Only the base sees the value. goodmap registers this capability with platzky (via ``extra_plugin_bases``) so field plugins are config-gated through the standard plugin loader. @@ -55,31 +67,10 @@ def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) -class MarkerFieldDecoratorPluginBase(GoodmapPluginBase): - """Capability: a plugin that decorates (wraps) another field renderer's output. - - A decorator's frontend component receives the base renderer's already-rendered - output as ``children`` and composes around it (icon, badge, tracking wrapper, - styling). The base renderer still runs, so first-party behaviour — e.g. the URL - sanitization in the built-in link/button — cannot be bypassed. The field ``type`` - a decorator wraps is taken from its ``config`` (``{"decorates": ""}``). - - goodmap registers this capability with platzky (via ``extra_plugin_bases``) so - decorator plugins are config-gated through the standard plugin loader. - - Manifest capability ``"MarkerFieldDecorator"``; component exposed as - ``"./MarkerFieldDecorator"``. - """ - - def __init__(self, config: dict[str, Any]) -> None: - super().__init__(config) - - # The frontend plugin capabilities goodmap defines: registered with platzky (so plugins # subclassing them are recognised and config-gated) and used to derive each plugin's # manifest entries. A plugin may subclass one or more of these. CAPABILITY_BASES: tuple[type[GoodmapPluginBase], ...] = ( MapOverlayPluginBase, MarkerFieldPluginBase, - MarkerFieldDecoratorPluginBase, ) diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index 5f6ad51e..b6c93c8f 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -16,7 +16,6 @@ from goodmap.plugin import ( CAPABILITY_BASES, MapOverlayPluginBase, - MarkerFieldDecoratorPluginBase, MarkerFieldPluginBase, ) from tests.unit_tests.conftest import make_flag_set @@ -278,14 +277,18 @@ def test_field_plugin_is_manifested_with_field_capability(): ] -def test_field_decorator_plugin_is_manifested_with_decorator_capability(): - """A decorator plugin is manifested with ``capability: "field-decorator"``.""" +def test_field_decorator_is_a_marker_field_plugin_with_decorates_config(): + """A decorator is a MarkerField plugin; its decorator role is carried by ``config.decorates``. + + There is no separate decorator capability — the manifest capability is ``"MarkerField"``, + and the frontend treats a MarkerField plugin with ``config.decorates`` as a decorator. + """ config = _plugin_config("tracker", {"decorates": "hyperlink"}) with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = os.path.join(tmpdir, "tracker") os.makedirs(os.path.join(plugin_dir, "static")) - ep = _plugin_ep("tracker", plugin_dir, base=MarkerFieldDecoratorPluginBase) + ep = _plugin_ep("tracker", plugin_dir, base=MarkerFieldPluginBase) with _patch_entry_points({"goodmap.plugins": [ep]}): app = goodmap.create_app_from_config(config) @@ -294,8 +297,8 @@ def test_field_decorator_plugin_is_manifested_with_decorator_capability(): { "pluginName": "tracker", "url": "/plugins/tracker/static/remoteEntry.js", - "module": "./MarkerFieldDecorator", - "capability": "MarkerFieldDecorator", + "module": "./MarkerField", + "capability": "MarkerField", "config": {"decorates": "hyperlink"}, } ] From 7f09f877ac4d34086a6ff622d733a3d429b33c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Mon, 6 Jul 2026 13:02:59 +0200 Subject: [PATCH 16/19] simplification --- docs/plugins.rst | 59 +++++++------- examples/plugins/silly-gif/README.md | 12 +-- .../silly-gif/frontend/src/MarkerField.jsx | 13 +-- .../components/MarkerPopup/FieldRenderer.jsx | 80 ++++++------------- frontend/src/plugins/pluginRegistry.js | 21 ++--- .../tests/MarkerPopup/FieldRenderer.test.jsx | 47 +++++------ .../tests/plugins/pluginRegistry.test.jsx | 10 ++- goodmap/plugin.py | 34 ++++---- tests/unit_tests/test_goodmap.py | 13 ++- 9 files changed, 126 insertions(+), 163 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index 96f4893f..eeb61cea 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -17,18 +17,20 @@ 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``). A field plugin plays one of two roles, chosen by its - ``config``: - - - **Renderer** (no ``config.decorates``): it *is* the component for the field ``type`` - matching its name — its shortcode transforms the value into ``{"type": "", ...}`` - and it receives that value spread as props. The built-in field types ``hyperlink`` and - ``CTA`` resolve the same way and take precedence over a plugin of the same name. - - **Decorator** (``config.decorates`` set to a field ``type``): it *wraps* that type's - rendering, receiving the base's rendered output as ``children`` (not the value) and - composing around it (icon, badge, tracking wrapper, styling). Because it only sees the - already-rendered output, it cannot bypass the base's behaviour, e.g. the built-in - link/button URL sanitization. A renderer is simply the innermost/base decorator. + mounted by ``FieldRenderer``). ``FieldRenderer`` renders a field as a **fold**: a *seed* + (the built-in for the field ``type`` rendered from the value, e.g. ``hyperlink``/``CTA``, + or a string when there is none), wrapped by every field plugin that attaches to that + ``type``. 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": "", ...}``. + - ``order`` (optional): position in the stack — lower is more innermost; ties keep + registration order. + + Every field plugin is the same kind of wrapper, receiving ``{ value, children, config }``. + A **renderer** ignores ``children`` and renders from ``value``; a **decorator** composes + around ``children`` (icon, badge, tracking wrapper). There is no separate role — the + innermost plugin is simply the one whose ``children`` is the seed. **Map overlays** (:class:`goodmap.plugin.MapOverlayPluginBase`) Render a component once *over the whole map*, not tied to any marker — e.g. a @@ -87,8 +89,8 @@ 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 renderers and decorators in a marker by ``FieldRenderer``, which -tells them apart by ``config.decorates``). +map by ``MapOverlays``; field plugins in a marker by ``FieldRenderer``, which folds them by +``config.field`` and ``config.order``). Field renderers and ``visible_data`` ------------------------------------ @@ -98,15 +100,13 @@ Field renderers and ``visible_data`` plugin, its shortcode transforms the value into ``{"type": "", ...}`` and the frontend renders the matching component (resolved by ``type``) in the marker popup. -Field decorator plugins ------------------------ +Field plugins as decorators +--------------------------- -A decorator wraps an existing field renderer's output instead of replacing it — the way -to customize a built-in (``hyperlink``, ``CTA``) safely, since the base renderer still -runs. It is an ordinary :class:`~goodmap.plugin.MarkerFieldPluginBase` (there is no separate -decorator capability); setting ``config.decorates`` is what makes it act as a decorator -rather than a renderer. It needs no shortcode (it augments rendering, it does not produce -field values): +A field plugin that wraps an existing field's rendering (rather than rendering the value +itself) is just an ordinary :class:`~goodmap.plugin.MarkerFieldPluginBase` that attaches to +that ``type`` — the way to customize a built-in (``hyperlink``, ``CTA``). It needs no +shortcode (it augments rendering, it does not produce field values): .. code-block:: python @@ -118,9 +118,8 @@ field values): def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) -The React component receives the base renderer's rendered output as ``children`` (plus -its own ``config``) and composes around it. Because it only sees the already-rendered, -already-sanitized output, it cannot bypass the base renderer's behaviour: +The React component receives ``{ value, children, config }``. Acting as a decorator, it +composes around ``children`` (the current rendering) and ignores ``value``: .. code-block:: jsx @@ -134,8 +133,8 @@ already-sanitized output, it cannot bypass the base renderer's behaviour: ); } -The field ``type`` a decorator wraps is set in its ``config`` via ``decorates`` (below). -Multiple decorators on the same type compose in registration order. +The plugin sets ``config.field`` to the type it attaches to (below) and, optionally, +``config.order`` for its position in the fold; higher order wraps further out. Configuration ------------- @@ -161,8 +160,8 @@ to the React component as the ``config`` prop: } } -A field-decorator plugin additionally sets ``decorates`` in its ``config`` to target the -field ``type`` it wraps: +A field plugin sets ``field`` in its ``config`` to the field ``type`` it attaches to (and, +optionally, ``order`` for its place in the fold): .. code-block:: json @@ -170,7 +169,7 @@ field ``type`` it wraps: "plugins": { "hyperlink_badge": { "is_active": true, - "config": { "decorates": "hyperlink", "label": "↗" } + "config": { "field": "hyperlink", "order": 1, "label": "↗" } } } } diff --git a/examples/plugins/silly-gif/README.md b/examples/plugins/silly-gif/README.md index 00bbda88..c6ba9067 100644 --- a/examples/plugins/silly-gif/README.md +++ b/examples/plugins/silly-gif/README.md @@ -43,9 +43,10 @@ Module Federation `name` (`silly_gif`) must equal the entry-point name. - An **overlay** component receives the plugin **`config`** plus `isMapLoading`. (One gif for the whole map, taken from config.) -- A **field** component receives the **field value** spread as props — not `config` — - because `FieldRenderer` renders ``. So each marker can carry its - own gif in its data. +- A **field** component receives **`{ value, children, config }`** — `FieldRenderer` folds + field plugins around a marker field. This one is a *renderer*: it uses `value` (the field + data — so each marker carries its own gif) and ignores `children`. It attaches to the + `silly_gif` field type via `config.field`. ## Build @@ -61,14 +62,15 @@ npm run build # writes ../silly_gif/static/remoteEntry.js pip install -e . # or: poetry add ./examples/silly-gif in your goodmap app ``` -Then enable it in your data source's `plugins` config (the `overlay` gif comes from here): +Then enable it in your data source's `plugins` config. `gif` feeds the overlay; `field` +tells the field component which field `type` it renders: ```json { "plugins": { "silly_gif": { "is_active": true, - "config": { "gif": "https://example.com/loading.gif" } + "config": { "gif": "https://example.com/loading.gif", "field": "silly_gif" } } } } diff --git a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx index 79d17414..e747994d 100644 --- a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx +++ b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx @@ -1,9 +1,10 @@ import React from 'react'; -// The "MarkerField" capability's component. goodmap's FieldRenderer mounts it for a marker field -// whose value is `{ type: 'silly_gif', gif: '' }`, spreading that value object as props. -// Note the contract differs from the overlay: a field component receives the *field value* -// (each marker can carry its own gif), not the plugin `config`. -export default function SillyGifField({ gif }) { - return silly gif; +// The "MarkerField" capability's component. goodmap's FieldRenderer folds field plugins +// around a marker field; every field plugin receives `{ value, children, config }`. This one +// acts as a *renderer*: it renders from `value` (the field data, e.g. +// `{ type: 'silly_gif', gif: '' }`) and ignores `children`. It attaches to the +// 'silly_gif' field type via `config.field` (see the plugin config). +export default function SillyGifField({ value }) { + return silly gif; } diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index 7807070c..439fc297 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -1,79 +1,47 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { getFieldPlugin, getFieldDecorators, subscribe } from '../../plugins/pluginRegistry'; +import { getFieldPlugins, subscribe } from '../../plugins/pluginRegistry'; import getContentAsString from './fieldContent'; import { builtinFieldRenderers } from './builtinFieldRenderers'; -// Built-in names a plugin also tried to claim; warned once each so the collision -// is visible to the author instead of silently shadowed. -const shadowedPlugins = new Set(); - -/** - * Resolves a field `type` to its renderer component: first-party built-ins take - * precedence, then field-capability plugins (looked up by name in the registry). - * A plugin registered under a built-in's name is shadowed by the built-in and - * warned once — that first-party precedence is deliberate (e.g. keeping the - * URL-sanitizing link/button from being replaced). To customize a built-in's - * rendering, register a field-decorator for the type instead of overriding it. - * - * @param {string} type - The field's render type. - * @returns {React.ComponentType|undefined} The renderer, or undefined if none is registered. - */ -export const resolveFieldRenderer = type => { - const builtin = builtinFieldRenderers[type]; - if (builtin) { - if (getFieldPlugin(type) && !shadowedPlugins.has(type)) { - shadowedPlugins.add(type); - console.warn( - `Field plugin "${type}" is shadowed by a built-in renderer of the same name; ` + - `use a field-decorator to wrap the built-in instead.`, - ); - } - return builtin; - } - return getFieldPlugin(type); -}; - -// Resolves the renderer plus any decorators registered for a `type` in one shot, so -// FieldRenderer re-resolves both together on a `type` change or a registry event. -const resolveField = type => ({ - Renderer: type ? resolveFieldRenderer(type) : undefined, - decorators: type ? getFieldDecorators(type) : [], -}); - /** - * Renders a marker field value. + * Renders a marker field value as a fold. * - * A value carrying a `type` is dispatched to its renderer — a built-in (hyperlink, - * CTA) or a field plugin. Built-ins resolve synchronously; field plugins may load - * asynchronously, so this subscribes to the registry and re-renders when a matching - * plugin arrives. Anything with no resolvable renderer — a typeless object, a - * primitive, or a not-yet-loaded plugin — falls back to a string representation. + * A *seed* rendering is produced first — the first-party built-in for the field `type` + * (rendered from the value), or a string rendering of the value when there is none. Then + * every field plugin that attaches to that `type` (via `config.field`) wraps the result, + * innermost-first by `config.order`. * - * Field decorators registered for the `type` then wrap the rendered output as their - * `children` (composing in registration order). The base renderer still runs, so - * first-party behaviour like the built-in link/button URL sanitization is preserved. + * Every field plugin is the same kind of thing: a wrapper receiving `{ value, children, + * config }`. A "renderer" ignores `children` and renders from `value`; a "decorator" + * composes around `children`. Plugins load asynchronously, so this subscribes to the + * registry and re-renders as they arrive (and re-resolves when `type` changes). */ const FieldRenderer = ({ value }) => { const type = value?.type; - const [{ Renderer, decorators }, setResolved] = useState(() => resolveField(type)); + const [plugins, setPlugins] = useState(() => (type ? getFieldPlugins(type) : [])); useEffect(() => { - const resolve = () => setResolved(resolveField(type)); - resolve(); // re-resolve when `type` changes, not only on later registry events - return subscribe(resolve); + const update = () => setPlugins(type ? getFieldPlugins(type) : []); + update(); // re-resolve when `type` changes, not only on later registry events + return subscribe(update); }, [type]); - const base = Renderer ? ( + const Builtin = type ? builtinFieldRenderers[type] : undefined; + const seed = Builtin ? ( // eslint-disable-next-line react/jsx-props-no-spreading - + ) : ( getContentAsString(type ? value.displayValue ?? value.value ?? '' : value) ); - return decorators.reduce( - (child, { Decorator, config }) => {child}, - base, + return plugins.reduce( + (children, { Plugin, config }) => ( + + {children} + + ), + seed, ); }; diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 813128aa..5133e2d4 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -10,13 +10,6 @@ export function registerPlugin(pluginName, Plugin, config, capability) { listeners.forEach(fn => fn()); } -// The base field renderer for a type: the MarkerField plugin registered under that name -// that is a renderer (no `config.decorates`) rather than a decorator of another type. -export function getFieldPlugin(pluginName) { - const entry = registry.get(entryKey(pluginName, 'MarkerField')); - return entry && !entry.config?.decorates ? entry.Plugin : undefined; -} - // A plugin's config is shared across its capability entries; return it from any of them. export function getPluginConfig(pluginName) { for (const entry of registry.values()) { @@ -33,13 +26,15 @@ export function getOverlayPlugins() { .map(entry => [entry.pluginName, entry.Plugin, entry.config]); } -// Field decorators: MarkerField plugins acting as decorators (they declare `config.decorates`) -// wrap the rendered output of the renderer for their target `type`. Returned in registration -// order so multiple decorators compose predictably. -export function getFieldDecorators(type) { +// The field plugins that attach to a field `type` (via `config.field`), as an ordered stack. +// FieldRenderer folds them around the seed rendering, innermost (lowest `config.order`) first; +// ties keep registration order (a stable sort). Each is a wrapper — a "renderer" ignores its +// children and renders from the value, a "decorator" composes around them. +export function getFieldPlugins(type) { return Array.from(registry.values()) - .filter(entry => entry.capability === 'MarkerField' && entry.config?.decorates === type) - .map(entry => ({ Decorator: entry.Plugin, config: entry.config })); + .filter(entry => entry.capability === 'MarkerField' && entry.config?.field === type) + .sort((a, b) => (a.config?.order ?? 0) - (b.config?.order ?? 0)) + .map(entry => ({ Plugin: entry.Plugin, config: entry.config })); } export function subscribe(fn) { diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx index aee5d580..9f2ce326 100644 --- a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -18,16 +18,16 @@ describe('FieldRenderer', () => { ); }); - it('renders a field plugin resolved by type and passes props', () => { - const Promo = ({ code }) => {code}; - Promo.propTypes = { code: PropTypes.string.isRequired }; - act(() => registerPlugin('promo', Promo, {}, 'MarkerField')); + it('renders a field plugin that renders from the value', () => { + const Promo = ({ value }) => {value.code}; + Promo.propTypes = { value: PropTypes.shape({ code: PropTypes.string }).isRequired }; + act(() => registerPlugin('promo', Promo, { field: 'promo' }, 'MarkerField')); render(); expect(screen.getByText('SAVE20')).toBeInTheDocument(); }); - it('falls back to the field value when the type has no renderer', () => { + it('falls back to the field value when nothing renders the type', () => { render(); expect(screen.getByText('plain text')).toBeInTheDocument(); }); @@ -37,7 +37,7 @@ describe('FieldRenderer', () => { expect(screen.getByText('just text')).toBeInTheDocument(); }); - it('re-resolves the renderer when value.type changes on the same instance', () => { + it('re-resolves when value.type changes on the same instance', () => { const { rerender } = render( , ); @@ -61,36 +61,37 @@ describe('FieldRenderer', () => { expect(screen.getByText('X')).toBeInTheDocument(); }); - it('lets a built-in take precedence over a plugin of the same type and warns once', () => { - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); - const Rogue = () => rogue; - act(() => registerPlugin('hyperlink', Rogue, {}, 'MarkerField')); - - render(); - expect(screen.queryByText('rogue')).not.toBeInTheDocument(); - expect(screen.getByRole('link')).toBeInTheDocument(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('hyperlink')); - warn.mockRestore(); - }); - - it('wraps the base renderer output with a decorator matching the type', () => { + it('wraps a built-in with a field plugin, preserving the base rendering', () => { const Badge = ({ children }) =>
{children}
; Badge.propTypes = { children: PropTypes.node.isRequired }; - act(() => registerPlugin('badge', Badge, { decorates: 'hyperlink' }, 'MarkerField')); + act(() => registerPlugin('badge', Badge, { field: 'hyperlink', order: 1 }, 'MarkerField')); render(); - // The base (sanitizing) renderer still runs, inside the decorator wrapper. + // The built-in (sanitizing) renderer still runs, inside the wrapper. const badge = screen.getByTestId('badge'); expect(within(badge).getByRole('link')).toHaveAttribute('href', 'https://example.com/'); }); - it('does not apply a decorator registered for a different type', () => { + it('does not apply a field plugin registered for a different type', () => { const Badge = ({ children }) =>
{children}
; Badge.propTypes = { children: PropTypes.node.isRequired }; - act(() => registerPlugin('cta-badge', Badge, { decorates: 'CTA' }, 'MarkerField')); + act(() => registerPlugin('cta-badge', Badge, { field: 'CTA', order: 1 }, 'MarkerField')); render(); expect(screen.queryByTestId('cta-badge')).not.toBeInTheDocument(); }); + + it('folds multiple field plugins innermost-first by order', () => { + const Inner = ({ children }) =>
{children}
; + Inner.propTypes = { children: PropTypes.node.isRequired }; + const Outer = ({ children }) =>
{children}
; + Outer.propTypes = { children: PropTypes.node.isRequired }; + act(() => registerPlugin('outer', Outer, { field: 'ordered', order: 2 }, 'MarkerField')); + act(() => registerPlugin('inner', Inner, { field: 'ordered', order: 1 }, 'MarkerField')); + + render(); + // Lower order is innermost, so the higher-order 'outer' wraps 'inner'. + expect(within(screen.getByTestId('outer')).getByTestId('inner')).toBeInTheDocument(); + }); }); diff --git a/frontend/tests/plugins/pluginRegistry.test.jsx b/frontend/tests/plugins/pluginRegistry.test.jsx index a71927dc..67f5c023 100644 --- a/frontend/tests/plugins/pluginRegistry.test.jsx +++ b/frontend/tests/plugins/pluginRegistry.test.jsx @@ -1,6 +1,6 @@ import { registerPlugin, - getFieldPlugin, + getFieldPlugins, getOverlayPlugins, } from '../../src/plugins/pluginRegistry'; @@ -9,10 +9,12 @@ describe('pluginRegistry multi-capability', () => { const Overlay = () => null; const Field = () => null; registerPlugin('silly', Overlay, { gif: 'x' }, 'MapOverlay'); - registerPlugin('silly', Field, { gif: 'x' }, 'MarkerField'); + registerPlugin('silly', Field, { gif: 'x', field: 'silly' }, 'MarkerField'); - // The field lookup resolves the field component, not the overlay one. - expect(getFieldPlugin('silly')).toBe(Field); + // The field lookup finds the field component attached to that type. + const fieldPlugins = getFieldPlugins('silly'); + expect(fieldPlugins).toHaveLength(1); + expect(fieldPlugins[0].Plugin).toBe(Field); // The overlay listing surfaces the overlay component for the same plugin. const overlays = getOverlayPlugins().filter(([name]) => name === 'silly'); diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 6e3e5b3d..6179dabc 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -38,24 +38,22 @@ def __init__(self, config: dict[str, Any]) -> None: class MarkerFieldPluginBase(GoodmapPluginBase): - """Capability: a plugin that renders a marker-popup field — as the base renderer or as - a decorator that wraps one. - - A field plugin's component is mounted by ``FieldRenderer`` and plays one of two roles, - chosen by its ``config``: - - - **Renderer** (no ``config.decorates``): it *is* the component for the field ``type`` - matching its name. It receives the field value spread as props and renders it — the - base of the field's rendering. The value comes from the plugin's platzky shortcode as - ``{"type": "", ...}``. - - **Decorator** (``config.decorates`` set to a field ``type``): it *wraps* that type's - rendering. It receives the base's rendered output as ``children`` — not the value — and - composes around it (icon, badge, tracking wrapper, styling). Because it only sees the - already-rendered output, it cannot bypass the base's behaviour (e.g. the built-in - link/button URL sanitization). Multiple decorators compose in registration order. - - A renderer is simply the innermost/base decorator: it decorates the raw value into an - element, and the wrappers decorate that. Only the base sees the value. + """Capability: a plugin that renders (or wraps) a marker-popup field. + + Every field plugin is the same kind of thing — a *wrapper* in the field's rendering + fold, mounted by ``FieldRenderer``. Its ``config`` declares: + + - ``field``: the field ``type`` it attaches to (e.g. ``"hyperlink"``, or a custom type + whose value the plugin's platzky shortcode produces as ``{"type": "", ...}``). + - ``order`` (optional): its position in the stack — lower is more innermost; ties keep + registration order. + + ``FieldRenderer`` seeds the rendering with the built-in for the type (or a string) and + folds every plugin for that ``field`` around it, innermost-first. Each plugin's component + receives ``{ value, children, config }``: a "renderer" ignores ``children`` and renders + from ``value``; a "decorator" composes around ``children``. (There is no separate + renderer vs decorator role — the innermost is simply the one whose ``children`` is the + seed.) goodmap registers this capability with platzky (via ``extra_plugin_bases``) so field plugins are config-gated through the standard plugin loader. diff --git a/tests/unit_tests/test_goodmap.py b/tests/unit_tests/test_goodmap.py index b6c93c8f..f8fccf75 100644 --- a/tests/unit_tests/test_goodmap.py +++ b/tests/unit_tests/test_goodmap.py @@ -277,13 +277,10 @@ def test_field_plugin_is_manifested_with_field_capability(): ] -def test_field_decorator_is_a_marker_field_plugin_with_decorates_config(): - """A decorator is a MarkerField plugin; its decorator role is carried by ``config.decorates``. - - There is no separate decorator capability — the manifest capability is ``"MarkerField"``, - and the frontend treats a MarkerField plugin with ``config.decorates`` as a decorator. - """ - config = _plugin_config("tracker", {"decorates": "hyperlink"}) +def test_field_plugin_config_is_passed_through_to_the_manifest(): + """A field plugin is manifested as ``"MarkerField"``; its ``config`` (``field``/``order``, + which the frontend uses to place it in the fold) is passed through untouched.""" + config = _plugin_config("tracker", {"field": "hyperlink", "order": 1}) with tempfile.TemporaryDirectory() as tmpdir: plugin_dir = os.path.join(tmpdir, "tracker") os.makedirs(os.path.join(plugin_dir, "static")) @@ -299,7 +296,7 @@ def test_field_decorator_is_a_marker_field_plugin_with_decorates_config(): "url": "/plugins/tracker/static/remoteEntry.js", "module": "./MarkerField", "capability": "MarkerField", - "config": {"decorates": "hyperlink"}, + "config": {"field": "hyperlink", "order": 1}, } ] From 151930b8316268e18018cd828e97b4db93655ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Mon, 6 Jul 2026 13:07:59 +0200 Subject: [PATCH 17/19] fix docs --- docs/plugins.rst | 57 +++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index eeb61cea..d2ca3cab 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -27,10 +27,11 @@ The capability a plugin provides determines *how* its frontend renders: - ``order`` (optional): position in the stack — lower is more innermost; ties keep registration order. - Every field plugin is the same kind of wrapper, receiving ``{ value, children, config }``. - A **renderer** ignores ``children`` and renders from ``value``; a **decorator** composes - around ``children`` (icon, badge, tracking wrapper). There is no separate role — the - innermost plugin is simply the one whose ``children`` is the seed. + Every field plugin is the same kind of thing — a wrapper receiving ``{ value, children, + config }``. What a component does with those props is up to it: render from ``value`` + (ignoring ``children``), or compose around ``children`` (a badge, a tracking wrapper, …). + The innermost plugin is simply the one whose ``children`` is the seed; there is no + renderer-vs-decorator distinction. **Map overlays** (:class:`goodmap.plugin.MapOverlayPluginBase`) Render a component once *over the whole map*, not tied to any marker — e.g. a @@ -39,13 +40,13 @@ The capability a plugin provides determines *how* its frontend renders: 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 (renderers and -decorators alike) — all served from the plugin's single ``remoteEntry.js``. +``./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 marker -field renderer. +module. See ``examples/plugins/silly-gif`` for a plugin that is both a map overlay *and* a +field plugin. Map overlay plugins ------------------- @@ -92,34 +93,40 @@ the frontend uses ``capability`` to mount the component at the right place (over map by ``MapOverlays``; field plugins in a marker by ``FieldRenderer``, which folds them by ``config.field`` and ``config.order``). -Field renderers and ``visible_data`` ------------------------------------- +Field plugins +------------- ``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, its shortcode transforms the value into ``{"type": "", ...}`` and the -frontend renders the matching component (resolved by ``type``) in the marker popup. +:ref:`data-model-visible_data`). ``FieldRenderer`` renders each such field as a fold: a +seed (the built-in for the field ``type``, or a string) wrapped by every field plugin that +attaches to that ``type`` via ``config.field``, innermost-first by ``config.order``. -Field plugins as decorators ---------------------------- +A field plugin is a :class:`~goodmap.plugin.MarkerFieldPluginBase` whose component receives +``{ value, children, config }``. There's one kind of field plugin; what a component *does* +with its props is what makes it read as a "renderer" or a "decorator": -A field plugin that wraps an existing field's rendering (rather than rendering the value -itself) is just an ordinary :class:`~goodmap.plugin.MarkerFieldPluginBase` that attaches to -that ``type`` — the way to customize a built-in (``hyperlink``, ``CTA``). It needs no -shortcode (it augments rendering, it does not produce field values): +**Render from the value** — produces a field's rendering; ignores ``children`` and reads +``value``. Its platzky shortcode turns the raw value into ``{"type": "", ...}``: .. code-block:: python - # hyperlink_badge/plugin.py + # promo/plugin.py from typing import Any from goodmap.plugin import MarkerFieldPluginBase - class HyperlinkBadgePlugin(MarkerFieldPluginBase): + class PromoPlugin(MarkerFieldPluginBase): def __init__(self, config: dict[str, Any]) -> None: super().__init__(config) -The React component receives ``{ value, children, config }``. Acting as a decorator, it -composes around ``children`` (the current rendering) and ignores ``value``: +.. code-block:: jsx + + // frontend/src/MarkerField.jsx (exposed as "./MarkerField") + export default function Promo({ value }) { + return {value.code}; + } + +**Wrap the current rendering** — customizes an existing field (e.g. a built-in ``hyperlink`` +/``CTA``); composes around ``children`` and ignores ``value``. Needs no shortcode: .. code-block:: jsx @@ -133,8 +140,8 @@ composes around ``children`` (the current rendering) and ignores ``value``: ); } -The plugin sets ``config.field`` to the type it attaches to (below) and, optionally, -``config.order`` for its position in the fold; higher order wraps further out. +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. Configuration ------------- From 24575398ee5e7ad050e21f3d56e2dae9b66fd8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Mon, 6 Jul 2026 13:33:02 +0200 Subject: [PATCH 18/19] some docs aligns --- Makefile | 12 ++++++++++++ examples/plugins/silly-gif/README.md | 6 +++--- .../plugins/silly-gif/frontend/src/MarkerField.jsx | 6 +++--- .../src/components/MarkerPopup/FieldRenderer.jsx | 6 +++--- frontend/src/plugins/pluginRegistry.js | 6 +++--- frontend/tests/plugins/MapOverlays.test.jsx | 2 +- goodmap/plugin.py | 7 +++---- 7 files changed, 28 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 402b9c9d..15ded23b 100644 --- a/Makefile +++ b/Makefile @@ -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. diff --git a/examples/plugins/silly-gif/README.md b/examples/plugins/silly-gif/README.md index c6ba9067..2d566899 100644 --- a/examples/plugins/silly-gif/README.md +++ b/examples/plugins/silly-gif/README.md @@ -44,9 +44,9 @@ Module Federation `name` (`silly_gif`) must equal the entry-point name. - An **overlay** component receives the plugin **`config`** plus `isMapLoading`. (One gif for the whole map, taken from config.) - A **field** component receives **`{ value, children, config }`** — `FieldRenderer` folds - field plugins around a marker field. This one is a *renderer*: it uses `value` (the field - data — so each marker carries its own gif) and ignores `children`. It attaches to the - `silly_gif` field type via `config.field`. + field plugins around a marker field. This one renders from `value` (the field data — so + each marker carries its own gif) and ignores `children`. It attaches to the `silly_gif` + field type via `config.field`. ## Build diff --git a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx index e747994d..ba636a57 100644 --- a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx +++ b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx @@ -2,9 +2,9 @@ import React from 'react'; // The "MarkerField" capability's component. goodmap's FieldRenderer folds field plugins // around a marker field; every field plugin receives `{ value, children, config }`. This one -// acts as a *renderer*: it renders from `value` (the field data, e.g. -// `{ type: 'silly_gif', gif: '' }`) and ignores `children`. It attaches to the -// 'silly_gif' field type via `config.field` (see the plugin config). +// renders from `value` (the field data, e.g. `{ type: 'silly_gif', gif: '' }`) and +// ignores `children`. It attaches to the 'silly_gif' field type via `config.field` (see the +// plugin config). export default function SillyGifField({ value }) { return silly gif; } diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index 439fc297..822e5c2f 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -13,9 +13,9 @@ import { builtinFieldRenderers } from './builtinFieldRenderers'; * innermost-first by `config.order`. * * Every field plugin is the same kind of thing: a wrapper receiving `{ value, children, - * config }`. A "renderer" ignores `children` and renders from `value`; a "decorator" - * composes around `children`. Plugins load asynchronously, so this subscribes to the - * registry and re-renders as they arrive (and re-resolves when `type` changes). + * config }` — it may render from `value` (ignoring `children`) or compose around + * `children`. Plugins load asynchronously, so this subscribes to the registry and + * re-renders as they arrive (and re-resolves when `type` changes). */ const FieldRenderer = ({ value }) => { const type = value?.type; diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 5133e2d4..338c9ba5 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -18,7 +18,7 @@ export function getPluginConfig(pluginName) { return {}; } -// Map-overlay plugins mount once over the map (see MapOverlays); field-renderer +// Map-overlay plugins mount once over the map (see MapOverlays); field // plugins are mounted per marker via FieldRenderer and are excluded here. export function getOverlayPlugins() { return Array.from(registry.values()) @@ -28,8 +28,8 @@ export function getOverlayPlugins() { // The field plugins that attach to a field `type` (via `config.field`), as an ordered stack. // FieldRenderer folds them around the seed rendering, innermost (lowest `config.order`) first; -// ties keep registration order (a stable sort). Each is a wrapper — a "renderer" ignores its -// children and renders from the value, a "decorator" composes around them. +// ties keep registration order (a stable sort). Each is a wrapper receiving +// `{ value, children, config }` — it may render from the value or compose around its children. export function getFieldPlugins(type) { return Array.from(registry.values()) .filter(entry => entry.capability === 'MarkerField' && entry.config?.field === type) diff --git a/frontend/tests/plugins/MapOverlays.test.jsx b/frontend/tests/plugins/MapOverlays.test.jsx index 18722c2b..0587d0c0 100644 --- a/frontend/tests/plugins/MapOverlays.test.jsx +++ b/frontend/tests/plugins/MapOverlays.test.jsx @@ -18,7 +18,7 @@ describe('MapOverlays', () => { expect(screen.getByText('nothing nearby')).toBeInTheDocument(); }); - it('does not render field-renderer plugins', () => { + it('does not render field plugins', () => { const Field = () => field plugin; act(() => registerPlugin('field-plugin', Field, {}, 'MarkerField')); diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 6179dabc..5e4f647b 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -50,10 +50,9 @@ class MarkerFieldPluginBase(GoodmapPluginBase): ``FieldRenderer`` seeds the rendering with the built-in for the type (or a string) and folds every plugin for that ``field`` around it, innermost-first. Each plugin's component - receives ``{ value, children, config }``: a "renderer" ignores ``children`` and renders - from ``value``; a "decorator" composes around ``children``. (There is no separate - renderer vs decorator role — the innermost is simply the one whose ``children`` is the - seed.) + receives ``{ value, children, config }`` — it may render from ``value`` (ignoring + ``children``) or compose around ``children``. The innermost plugin is simply the one + whose ``children`` is the seed. goodmap registers this capability with platzky (via ``extra_plugin_bases``) so field plugins are config-gated through the standard plugin loader. From fbba15660043999f7479fa0763f2bfa18017a5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Mon, 6 Jul 2026 15:19:07 +0200 Subject: [PATCH 19/19] simplify renderer --- docs/plugins.rst | 52 ++++++++-------- e2e-tests/tests/basic/test_share.py | 10 +++- examples/plugins/silly-gif/README.md | 8 +-- .../silly-gif/frontend/src/MarkerField.jsx | 14 ++--- .../components/MarkerPopup/FieldRenderer.jsx | 60 +++++++++---------- .../MarkerPopup/builtinFieldRenderers.jsx | 23 +++---- frontend/src/plugins/pluginRegistry.js | 6 +- .../tests/MarkerPopup/FieldRenderer.test.jsx | 28 ++++----- goodmap/plugin.py | 12 ++-- 9 files changed, 114 insertions(+), 99 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index d2ca3cab..8880cb73 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -17,21 +17,23 @@ 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 **fold**: a *seed* - (the built-in for the field ``type`` rendered from the value, e.g. ``hyperlink``/``CTA``, - or a string when there is none), wrapped by every field plugin that attaches to that - ``type``. A plugin's ``config`` declares which field it attaches to and where it sits: + 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": "", ...}``. - - ``order`` (optional): position in the stack — lower is more innermost; ties keep + - ``order`` (optional): position in the pipe — lower is more innermost; ties keep registration order. - Every field plugin is the same kind of thing — a wrapper receiving ``{ value, children, - config }``. What a component does with those props is up to it: render from ``value`` - (ignoring ``children``), or compose around ``children`` (a badge, a tracking wrapper, …). - The innermost plugin is simply the one whose ``children`` is the seed; there is no - renderer-vs-decorator distinction. + 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 @@ -97,16 +99,17 @@ 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 fold: a -seed (the built-in for the field ``type``, or a string) wrapped by every field plugin that -attaches to that ``type`` via ``config.field``, innermost-first by ``config.order``. +: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 receives -``{ value, children, config }``. There's one kind of field plugin; what a component *does* -with its props is what makes it read as a "renderer" or a "decorator": +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 value** — produces a field's rendering; ignores ``children`` and reads -``value``. Its platzky shortcode turns the raw value into ``{"type": "", ...}``: +**Render from the input** — the innermost stage receives the raw value and produces the +rendering. Its platzky shortcode turns the raw value into ``{"type": "", ...}``: .. code-block:: python @@ -121,20 +124,20 @@ with its props is what makes it read as a "renderer" or a "decorator": .. code-block:: jsx // frontend/src/MarkerField.jsx (exposed as "./MarkerField") - export default function Promo({ value }) { - return {value.code}; + export default function Promo({ input }) { + return {input.code}; } -**Wrap the current rendering** — customizes an existing field (e.g. a built-in ``hyperlink`` -/``CTA``); composes around ``children`` and ignores ``value``. Needs no shortcode: +**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: .. code-block:: jsx // frontend/src/MarkerField.jsx (exposed as "./MarkerField") - export default function HyperlinkBadge({ config, children }) { + export default function HyperlinkBadge({ input, config }) { return ( - {children} + {input} {config.label && {config.label}} ); @@ -142,6 +145,7 @@ with its props is what makes it read as a "renderer" or a "decorator": 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 ------------- diff --git a/e2e-tests/tests/basic/test_share.py b/e2e-tests/tests/basic/test_share.py index 9eb81602..d5123e0b 100644 --- a/e2e-tests/tests/basic/test_share.py +++ b/e2e-tests/tests/basic/test_share.py @@ -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") @@ -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") diff --git a/examples/plugins/silly-gif/README.md b/examples/plugins/silly-gif/README.md index 2d566899..9fb76277 100644 --- a/examples/plugins/silly-gif/README.md +++ b/examples/plugins/silly-gif/README.md @@ -43,10 +43,10 @@ Module Federation `name` (`silly_gif`) must equal the entry-point name. - An **overlay** component receives the plugin **`config`** plus `isMapLoading`. (One gif for the whole map, taken from config.) -- A **field** component receives **`{ value, children, config }`** — `FieldRenderer` folds - field plugins around a marker field. This one renders from `value` (the field data — so - each marker carries its own gif) and ignores `children`. It attaches to the `silly_gif` - field type via `config.field`. +- A **field** component is a stage **`({ input, config }) => element`** — `FieldRenderer` + pipes field plugins from the field's raw value. This one is the innermost/renderer stage: + it gets the raw field data as `input` (so each marker carries its own gif) and renders from + it. It attaches to the `silly_gif` field type via `config.field`. ## Build diff --git a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx index ba636a57..80ad4b9d 100644 --- a/examples/plugins/silly-gif/frontend/src/MarkerField.jsx +++ b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx @@ -1,10 +1,10 @@ import React from 'react'; -// The "MarkerField" capability's component. goodmap's FieldRenderer folds field plugins -// around a marker field; every field plugin receives `{ value, children, config }`. This one -// renders from `value` (the field data, e.g. `{ type: 'silly_gif', gif: '' }`) and -// ignores `children`. It attaches to the 'silly_gif' field type via `config.field` (see the -// plugin config). -export default function SillyGifField({ value }) { - return silly gif; +// The "MarkerField" capability's component. goodmap's FieldRenderer pipes field plugins from +// the field's raw value; every field plugin is a stage `({ input, config }) => element`. This +// one is the innermost/renderer stage: it gets the raw field data as `input` (e.g. +// `{ type: 'silly_gif', gif: '' }`) and renders from it. It attaches to the 'silly_gif' +// field type via `config.field` (see the plugin config). +export default function SillyGifField({ input }) { + return silly gif; } diff --git a/frontend/src/components/MarkerPopup/FieldRenderer.jsx b/frontend/src/components/MarkerPopup/FieldRenderer.jsx index 822e5c2f..e233b0f8 100644 --- a/frontend/src/components/MarkerPopup/FieldRenderer.jsx +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -1,47 +1,47 @@ -import React, { useState, useEffect } from 'react'; +import React, { useReducer, useEffect } from 'react'; import PropTypes from 'prop-types'; import { getFieldPlugins, subscribe } from '../../plugins/pluginRegistry'; import getContentAsString from './fieldContent'; import { builtinFieldRenderers } from './builtinFieldRenderers'; /** - * Renders a marker field value as a fold. + * Renders a marker field value as a pipe. * - * A *seed* rendering is produced first — the first-party built-in for the field `type` - * (rendered from the value), or a string rendering of the value when there is none. Then - * every field plugin that attaches to that `type` (via `config.field`) wraps the result, - * innermost-first by `config.order`. + * The raw `value` flows through a chain of stages: the built-in for the field `type` (if + * any) renders it into an element, then each field plugin attached to that `type` (by + * `config.field`) transforms the result, innermost-first by `config.order`. Every stage is + * `({ input, config }) => element`, receiving the previous stage's output as `input` — so + * the innermost gets the raw value and renders from it, and each later stage gets the + * current element and wraps it. * - * Every field plugin is the same kind of thing: a wrapper receiving `{ value, children, - * config }` — it may render from `value` (ignoring `children`) or compose around - * `children`. Plugins load asynchronously, so this subscribes to the registry and - * re-renders as they arrive (and re-resolves when `type` changes). + * A wrapper therefore presupposes that something renders the type (a built-in, or a renderer + * plugin it ships with / depends on); a type with only wrappers and no renderer is a + * misconfiguration. With no stage at all, the value falls back to a string. + * + * Everything is computed during render, so a changed `value`/`type` is always reflected; + * plugins load asynchronously, so this subscribes to the registry and forces a re-render as + * they arrive. */ const FieldRenderer = ({ value }) => { + const [, forceRender] = useReducer(count => count + 1, 0); + useEffect(() => subscribe(forceRender), []); + const type = value?.type; - const [plugins, setPlugins] = useState(() => (type ? getFieldPlugins(type) : [])); + const Builtin = type ? builtinFieldRenderers[type] : undefined; + const plugins = type ? getFieldPlugins(type) : []; - useEffect(() => { - const update = () => setPlugins(type ? getFieldPlugins(type) : []); - update(); // re-resolve when `type` changes, not only on later registry events - return subscribe(update); - }, [type]); + const stages = [ + ...(Builtin ? [{ Stage: Builtin, config: undefined }] : []), + ...plugins.map(({ Plugin, config }) => ({ Stage: Plugin, config })), + ]; - const Builtin = type ? builtinFieldRenderers[type] : undefined; - const seed = Builtin ? ( - // eslint-disable-next-line react/jsx-props-no-spreading - - ) : ( - getContentAsString(type ? value.displayValue ?? value.value ?? '' : value) - ); + if (stages.length === 0) { + return getContentAsString(type ? value.displayValue ?? value.value ?? '' : value); + } - return plugins.reduce( - (children, { Plugin, config }) => ( - - {children} - - ), - seed, + return stages.reduce( + (input, { Stage, config }) => , + value, ); }; diff --git a/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx index 3ec3ed64..4a85dcad 100644 --- a/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx +++ b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx @@ -21,11 +21,19 @@ const sanitizeUrl = raw => { } }; +// Built-ins are the innermost stage of the field fold: they receive the field's raw value +// as `input` (`{ value, displayValue, ... }`) and render it into an element. +const fieldInputShape = PropTypes.shape({ + value: PropTypes.string.isRequired, + displayValue: PropTypes.string, +}); + /** * Built-in field renderer: a safe external hyperlink. * Falls back to plain text when the URL is unsafe. */ -export const HyperlinkField = ({ value, displayValue = null }) => { +export const HyperlinkField = ({ input }) => { + const { value, displayValue } = input; const text = displayValue || value; const safe = sanitizeUrl(value); if (!safe) return text; @@ -36,16 +44,14 @@ export const HyperlinkField = ({ value, displayValue = null }) => { ); }; -HyperlinkField.propTypes = { - value: PropTypes.string.isRequired, - displayValue: PropTypes.string, -}; +HyperlinkField.propTypes = { input: fieldInputShape.isRequired }; /** * Built-in field renderer: a call-to-action button that opens the (sanitized) * URL in a new tab. */ -export const CTAButtonField = ({ value, displayValue = null }) => { +export const CTAButtonField = ({ input }) => { + const { value, displayValue } = input; const text = displayValue || value; const safe = sanitizeUrl(value); if (!safe) return text; @@ -62,10 +68,7 @@ export const CTAButtonField = ({ value, displayValue = null }) => { ); }; -CTAButtonField.propTypes = { - value: PropTypes.string.isRequired, - displayValue: PropTypes.string, -}; +CTAButtonField.propTypes = { input: fieldInputShape.isRequired }; // Built-in field renderers, keyed by field `type`. Resolved before plugins so a // plugin cannot shadow a first-party renderer (e.g. the URL-sanitizing link/button). diff --git a/frontend/src/plugins/pluginRegistry.js b/frontend/src/plugins/pluginRegistry.js index 338c9ba5..791650f3 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -27,9 +27,9 @@ export function getOverlayPlugins() { } // The field plugins that attach to a field `type` (via `config.field`), as an ordered stack. -// FieldRenderer folds them around the seed rendering, innermost (lowest `config.order`) first; -// ties keep registration order (a stable sort). Each is a wrapper receiving -// `{ value, children, config }` — it may render from the value or compose around its children. +// FieldRenderer pipes them from the seed, innermost (lowest `config.order`) first; ties keep +// registration order (a stable sort). Each stage is `({ input, config }) => element` — the +// innermost gets the raw value and renders it, each later stage gets the element and wraps it. export function getFieldPlugins(type) { return Array.from(registry.values()) .filter(entry => entry.capability === 'MarkerField' && entry.config?.field === type) diff --git a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx index 9f2ce326..986d2aaa 100644 --- a/frontend/tests/MarkerPopup/FieldRenderer.test.jsx +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -18,9 +18,9 @@ describe('FieldRenderer', () => { ); }); - it('renders a field plugin that renders from the value', () => { - const Promo = ({ value }) => {value.code}; - Promo.propTypes = { value: PropTypes.shape({ code: PropTypes.string }).isRequired }; + it('renders a field plugin that renders from the input value', () => { + const Promo = ({ input }) => {input.code}; + Promo.propTypes = { input: PropTypes.shape({ code: PropTypes.string }).isRequired }; act(() => registerPlugin('promo', Promo, { field: 'promo' }, 'MarkerField')); render(); @@ -62,8 +62,8 @@ describe('FieldRenderer', () => { }); it('wraps a built-in with a field plugin, preserving the base rendering', () => { - const Badge = ({ children }) =>
{children}
; - Badge.propTypes = { children: PropTypes.node.isRequired }; + const Badge = ({ input }) =>
{input}
; + Badge.propTypes = { input: PropTypes.node.isRequired }; act(() => registerPlugin('badge', Badge, { field: 'hyperlink', order: 1 }, 'MarkerField')); render(); @@ -74,24 +74,24 @@ describe('FieldRenderer', () => { }); it('does not apply a field plugin registered for a different type', () => { - const Badge = ({ children }) =>
{children}
; - Badge.propTypes = { children: PropTypes.node.isRequired }; + const Badge = ({ input }) =>
{input}
; + Badge.propTypes = { input: PropTypes.node.isRequired }; act(() => registerPlugin('cta-badge', Badge, { field: 'CTA', order: 1 }, 'MarkerField')); render(); expect(screen.queryByTestId('cta-badge')).not.toBeInTheDocument(); }); - it('folds multiple field plugins innermost-first by order', () => { - const Inner = ({ children }) =>
{children}
; - Inner.propTypes = { children: PropTypes.node.isRequired }; - const Outer = ({ children }) =>
{children}
; - Outer.propTypes = { children: PropTypes.node.isRequired }; + it('pipes field plugins innermost-first by order', () => { + // Innermost (lowest order) renders from the raw value; the outer one wraps it. + const Inner = ({ input }) =>
{input.value}
; + Inner.propTypes = { input: PropTypes.shape({ value: PropTypes.string }).isRequired }; + const Outer = ({ input }) =>
{input}
; + Outer.propTypes = { input: PropTypes.node.isRequired }; act(() => registerPlugin('outer', Outer, { field: 'ordered', order: 2 }, 'MarkerField')); act(() => registerPlugin('inner', Inner, { field: 'ordered', order: 1 }, 'MarkerField')); render(); - // Lower order is innermost, so the higher-order 'outer' wraps 'inner'. - expect(within(screen.getByTestId('outer')).getByTestId('inner')).toBeInTheDocument(); + expect(within(screen.getByTestId('outer')).getByTestId('inner')).toHaveTextContent('x'); }); }); diff --git a/goodmap/plugin.py b/goodmap/plugin.py index 5e4f647b..99aa291f 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -48,11 +48,13 @@ class MarkerFieldPluginBase(GoodmapPluginBase): - ``order`` (optional): its position in the stack — lower is more innermost; ties keep registration order. - ``FieldRenderer`` seeds the rendering with the built-in for the type (or a string) and - folds every plugin for that ``field`` around it, innermost-first. Each plugin's component - receives ``{ value, children, config }`` — it may render from ``value`` (ignoring - ``children``) or compose around ``children``. The innermost plugin is simply the one - whose ``children`` is the seed. + ``FieldRenderer`` pipes the field's raw value through a chain of stages: the built-in for + the type (if any) renders it, then each plugin for that ``field`` transforms the result, + innermost-first. Each stage is ``({ input, config }) => element`` and receives the previous + stage's output as ``input`` — so the innermost stage gets the raw value and renders from + it, and every later stage gets the current element and wraps it. (A wrapping plugin thus + requires something to render 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.) goodmap registers this capability with platzky (via ``extra_plugin_bases``) so field plugins are config-gated through the standard plugin loader.