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/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 ce0e06e4..8880cb73 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -1,64 +1,191 @@ Plugins ======= -Goodmap supports platzky plugins — standalone Python packages that extend -functionality via shortcodes and module federation frontend components. -Plugins are written against platzky's shortcode system and are not aware of -locations; Goodmap maps shortcode names to location field renderers automatically. +Goodmap builds on platzky's plugin system and adds its own plugin ecosystem for the +map. A Goodmap plugin is an ordinary Python package that declares a ``goodmap.plugins`` +entry point and ships a frontend component (served via Module Federation). Goodmap +registers this entry-point group and its own capability base classes with platzky at +startup, so Goodmap plugins are discovered, config-gated (``is_active``), and loaded +through platzky's normal plugin loader — see :doc:`platzky's plugin docs +` for the underlying mechanism (``extra_plugin_bases`` / +``extra_plugins_entrypoints``). + +Kinds of Goodmap frontend plugins +------------------------------------- + +The capability a plugin provides determines *how* its frontend renders: + +**Marker fields** (:class:`goodmap.plugin.MarkerFieldPluginBase`) + Render a single location field inside a marker popup (capability ``"MarkerField"``, + mounted by ``FieldRenderer``). ``FieldRenderer`` renders a field as a **pipe**: the raw + value flows through a chain of stages — the built-in for the field ``type`` (e.g. + ``hyperlink``/``CTA``) renders it, then each field plugin attached to that ``type`` + transforms the result. A plugin's ``config`` declares which field it attaches to and + where it sits: + + - ``field``: the field ``type`` it applies to. For a custom type, the plugin's platzky + shortcode transforms the value into ``{"type": "", ...}``. + - ``order`` (optional): position in the pipe — lower is more innermost; ties keep + registration order. + + Every field plugin is the same kind of thing — a stage ``({ input, config }) => element``. + Each receives the previous stage's output as ``input``: the innermost stage gets the raw + value (and renders from it), every later stage gets the current element (and wraps it). So + a plugin either renders a field or wraps one, with no separate role. A wrapping plugin + presupposes something renders the type — a built-in, or a renderer it ships with or + depends on; a type with only wrappers and no renderer is a misconfiguration. + +**Map overlays** (:class:`goodmap.plugin.MapOverlayPluginBase`) + Render a component once *over the whole map*, not tied to any marker — e.g. a + banner shown when no points are visible in the current view. Overlay components + are mounted by ``MapOverlays``. They do not transform point/location data. + +All kinds are discovered from the ``goodmap.plugins`` entry-point group. Each capability +exposes its React component under that capability's Module Federation module key — +``./MapOverlay`` for overlays and ``./MarkerField`` for field plugins — all served from the +plugin's single ``remoteEntry.js``. + +A single plugin may provide **several** capabilities by subclassing more than one base; +goodmap then emits one manifest entry per capability, each pointing at that capability's +module. See ``examples/plugins/silly-gif`` for a plugin that is both a map overlay *and* a +field plugin. + +Map overlay plugins +------------------- + +A map overlay subclasses :class:`~goodmap.plugin.MapOverlayPluginBase` and declares a +``goodmap.plugins`` entry point: + +.. code-block:: python + + # my_overlay/plugin.py + from typing import Any + from goodmap.plugin import MapOverlayPluginBase + + class MyOverlayPlugin(MapOverlayPluginBase): + """Show a banner over the map.""" + + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) + +.. code-block:: toml + + # pyproject.toml + [tool.poetry.plugins."goodmap.plugins"] + my_overlay = "my_overlay:MyOverlayPlugin" + +The plugin's per-plugin ``config`` (from the database, see below) is delivered to the +React component as a ``config`` prop, so overlays are configurable without code +changes: + +.. code-block:: jsx + + // frontend/src/MapOverlay.jsx (exposed as "./MapOverlay") + export default function MyOverlayPlugin({ config }) { + return
{config.message}
; + } + +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. The ``capability`` token and its ``module`` are derived from the capability base +class name (``PluginBase`` stripped) — +:class:`~goodmap.plugin.MapOverlayPluginBase` (``"MapOverlay"`` / ``./MapOverlay``) and +:class:`~goodmap.plugin.MarkerFieldPluginBase` (``"MarkerField"`` / ``./MarkerField``) — and +the frontend uses ``capability`` to mount the component at the right place (overlays over the +map by ``MapOverlays``; field plugins in a marker by ``FieldRenderer``, which folds them by +``config.field`` and ``config.order``). + +Field plugins +------------- + +``visible_data`` is a list of field names displayed in location markers (see +:ref:`data-model-visible_data`). ``FieldRenderer`` renders each such field as a pipe: the raw +value flows through the built-in for the field ``type`` (if any) and then each field plugin +attached to that ``type`` via ``config.field``, innermost-first by ``config.order``. + +A field plugin is a :class:`~goodmap.plugin.MarkerFieldPluginBase` whose component is a stage +``({ input, config }) => element`` — it receives the previous stage's output as ``input``. +There's one kind of field plugin; what it does with ``input`` is what makes it read as a +"renderer" or a "decorator": + +**Render from the input** — the innermost stage receives the raw value and produces the +rendering. Its platzky shortcode turns the raw value into ``{"type": "", ...}``: + +.. code-block:: python + + # promo/plugin.py + from typing import Any + from goodmap.plugin import MarkerFieldPluginBase + + class PromoPlugin(MarkerFieldPluginBase): + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) + +.. code-block:: jsx + + // frontend/src/MarkerField.jsx (exposed as "./MarkerField") + export default function Promo({ input }) { + return {input.code}; + } -Overview --------- +**Wrap the input** — a later stage receives the current element and composes around it (e.g. +to customize a built-in ``hyperlink``/``CTA``). Needs no shortcode: -Plugins are discovered automatically through Python entry points -(``platzky.plugins`` group). Each plugin can: +.. code-block:: jsx -* Register shortcodes for blog/content rendering -* Expose a React component via Module Federation -* Provide static assets served by the Flask backend + // frontend/src/MarkerField.jsx (exposed as "./MarkerField") + export default function HyperlinkBadge({ input, config }) { + return ( + + {input} + {config.label && {config.label}} + + ); + } -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. +Both are the same plugin kind. Each sets ``config.field`` to the type it attaches to and, +optionally, ``config.order``; lower order is more innermost, higher order wraps further out. +A wrapper must have a renderer beneath it (a built-in, or one it depends on). Configuration ------------- -Add the plugin entry to the ``plugins`` list in your data source (e.g. -``data.json``): +Activate a plugin by adding it to the ``plugins`` object in your data source, keyed by +the entry-point name. The plugin loads only when ``is_active`` is ``true``; its +``config`` is passed to the plugin's ``__init__`` and (for frontend plugins) delivered +to the React component as the ``config`` prop: .. code-block:: json { - "plugins": [ - { - "name": "promocode", + "plugins": { + "nothingshere": { + "is_active": true, "config": { - "text": "Reveal your discount", - "color": "#e63946" + "messages": { + "pl": "Nie ma nic w pobliżu", + "en": "Nothing nearby" + } } } - ] + } } -Each plugin has its own configuration schema — refer to the plugin's -documentation for available fields. +A field plugin sets ``field`` in its ``config`` to the field ``type`` it attaches to (and, +optionally, ``order`` for its place in the fold): -After adding or removing a plugin, restart the Flask server. - -If a plugin is removed from the configuration while a location still has -fields referencing it, those fields are silently dropped from the API -response. A debug message is logged: - -.. code-block:: text - - DEBUG:goodmap.formatter:Dropping field 'promocode': unconfigured plugin data ... +.. code-block:: json -To see these messages, enable debug logging: + { + "plugins": { + "hyperlink_badge": { + "is_active": true, + "config": { "field": "hyperlink", "order": 1, "label": "↗" } + } + } + } -.. code-block:: bash +Each plugin defines its own ``config`` schema — refer to the plugin's documentation +for available fields. - export FLASK_DEBUG=1 +After adding or removing a plugin, restart the Flask server. 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/.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 new file mode 100644 index 00000000..9fb76277 --- /dev/null +++ b/examples/plugins/silly-gif/README.md @@ -0,0 +1,103 @@ +# `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 `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. + +## 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/ + ├── MapOverlay.jsx # the MapOverlay component + └── MarkerField.jsx # the MarkerField 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 | +|---|---|---|---| +| `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 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: + +- An **overlay** component receives the plugin **`config`** plus `isMapLoading`. + (One gif for the whole map, taken from config.) +- 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 + +```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. `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", "field": "silly_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..7f28e1e7 --- /dev/null +++ b/examples/plugins/silly-gif/frontend/src/MapOverlay.jsx @@ -0,0 +1,22 @@ +import React from 'react'; + +// 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 }) { + 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..80ad4b9d --- /dev/null +++ b/examples/plugins/silly-gif/frontend/src/MarkerField.jsx @@ -0,0 +1,10 @@ +import React from 'react'; + +// 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/examples/plugins/silly-gif/frontend/webpack.config.js b/examples/plugins/silly-gif/frontend/webpack.config.js new file mode 100644 index 00000000..df0d946e --- /dev/null +++ b/examples/plugins/silly-gif/frontend/webpack.config.js @@ -0,0 +1,42 @@ +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 = { + // 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 + // /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 module goodmap derives from the + // capability base name (MapOverlayPluginBase -> "./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..b044f184 --- /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 ``"MapOverlay"``, component + ``./MapOverlay``: a gif shown over the map while it loads. +- :class:`~goodmap.plugin.MarkerFieldPluginBase` -> capability ``"MarkerField"``, 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/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 = () => { - + { > {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 new file mode 100644 index 00000000..e233b0f8 --- /dev/null +++ b/frontend/src/components/MarkerPopup/FieldRenderer.jsx @@ -0,0 +1,57 @@ +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 pipe. + * + * 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. + * + * 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 Builtin = type ? builtinFieldRenderers[type] : undefined; + const plugins = type ? getFieldPlugins(type) : []; + + const stages = [ + ...(Builtin ? [{ Stage: Builtin, config: undefined }] : []), + ...plugins.map(({ Plugin, config }) => ({ Stage: Plugin, config })), + ]; + + if (stages.length === 0) { + return getContentAsString(type ? value.displayValue ?? value.value ?? '' : value); + } + + return stages.reduce( + (input, { Stage, config }) => , + value, + ); +}; + +FieldRenderer.propTypes = { + 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/builtinFieldRenderers.jsx b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx new file mode 100644 index 00000000..4a85dcad --- /dev/null +++ b/frontend/src/components/MarkerPopup/builtinFieldRenderers.jsx @@ -0,0 +1,78 @@ +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-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 = ({ input }) => { + const { value, displayValue } = input; + const text = displayValue || value; + const safe = sanitizeUrl(value); + if (!safe) return text; + return ( + + {text} + + ); +}; + +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 = ({ input }) => { + const { value, displayValue } = input; + const text = displayValue || value; + const safe = sanitizeUrl(value); + if (!safe) return text; + const handleRedirect = () => globalThis.open(safe, '_blank'); + return ( + + ); +}; + +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). +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 deleted file mode 100644 index 043d085b..00000000 --- a/frontend/src/components/MarkerPopup/mapCustomTypeToReactComponent.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react'; -import { MarkerCTAButtonStyle } from '../../styles/buttonStyle'; -import PluginSlot from '../../plugins/PluginSlot'; - -/** - * 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 ?? ''); - -/** - * 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; - } -}; - -/** - * Maps custom typed values to appropriate React components. - * Supports hyperlinks and CTA (Call-To-Action) buttons. - * - * @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 - */ -export const mapCustomTypeToReactComponent = customValue => { - if (customValue.scope) { - const { scope, ...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); - } -}; diff --git a/frontend/src/plugins/GlobalPlugins.jsx b/frontend/src/plugins/GlobalPlugins.jsx deleted file mode 100644 index 4ca3b7a8..00000000 --- a/frontend/src/plugins/GlobalPlugins.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { getAllPlugins, subscribe } from './pluginRegistry'; - -const GlobalPlugins = () => { - 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..52b82fa0 --- /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 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 }) => { + const [plugins, setPlugins] = useState(() => getOverlayPlugins()); + + useEffect(() => subscribe(() => setPlugins(getOverlayPlugins())), []); + + return ( + <> + {plugins.map(([pluginName, Plugin, config]) => ( + + ))} + + ); +}; + +MapOverlays.propTypes = { + isMapLoading: PropTypes.bool.isRequired, +}; + +export default MapOverlays; diff --git a/frontend/src/plugins/PluginSlot.jsx b/frontend/src/plugins/PluginSlot.jsx deleted file mode 100644 index c1abeafe..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 = ({ scope, props: componentProps }) => { - const [Component, setComponent] = useState(() => getPlugin(scope)); - - useEffect(() => subscribe(() => setComponent(() => getPlugin(scope))), [scope]); - - if (!Component) { - return null; - } - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -}; - -PluginSlot.propTypes = { - scope: PropTypes.string.isRequired, - props: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types -}; - -export default PluginSlot; diff --git a/frontend/src/plugins/pluginLoader.js b/frontend/src/plugins/pluginLoader.js index 0fa21eb7..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'); - for (const { scope, url, module: moduleName } of manifest) { + // 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[scope]; - await container.init(__webpack_share_scopes__.default); - const factory = await container.get(moduleName); - const Module = factory(); - registerPlugin(scope, Module.default); + 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 "${scope}":`, 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 7c7a4ad3..791650f3 100644 --- a/frontend/src/plugins/pluginRegistry.js +++ b/frontend/src/plugins/pluginRegistry.js @@ -1,17 +1,40 @@ +// 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(); -export function registerPlugin(scope, Component) { - registry.set(scope, Component); +const entryKey = (pluginName, capability) => `${capability}::${pluginName}`; + +export function registerPlugin(pluginName, Plugin, config, capability) { + registry.set(entryKey(pluginName, capability), { pluginName, Plugin, config, capability }); listeners.forEach(fn => fn()); } -export function getPlugin(scope) { - return registry.get(scope); +// 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()) { + if (entry.pluginName === pluginName) return entry.config ?? {}; + } + return {}; +} + +// 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()) + .filter(entry => entry.capability === 'MapOverlay') + .map(entry => [entry.pluginName, entry.Plugin, entry.config]); } -export function getAllPlugins() { - return Array.from(registry.entries()); +// The field plugins that attach to a field `type` (via `config.field`), as an ordered stack. +// 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) + .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 new file mode 100644 index 00000000..986d2aaa --- /dev/null +++ b/frontend/tests/MarkerPopup/FieldRenderer.test.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import '@testing-library/jest-dom'; +import { render, screen, act, within } 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 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(); + expect(screen.getByText('SAVE20')).toBeInTheDocument(); + }); + + it('falls back to the field value when nothing renders the type', () => { + render(); + expect(screen.getByText('plain text')).toBeInTheDocument(); + }); + + it('renders a primitive value as a string', () => { + render(); + expect(screen.getByText('just text')).toBeInTheDocument(); + }); + + it('re-resolves 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('wraps a built-in with a field plugin, preserving the base rendering', () => { + const Badge = ({ input }) =>
{input}
; + Badge.propTypes = { input: PropTypes.node.isRequired }; + act(() => registerPlugin('badge', Badge, { field: 'hyperlink', order: 1 }, 'MarkerField')); + + render(); + + // 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 field plugin registered for a different type', () => { + 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('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(); + expect(within(screen.getByTestId('outer')).getByTestId('inner')).toHaveTextContent('x'); + }); +}); diff --git a/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx b/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx index 862fb967..b62f0b1f 100644 --- a/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx +++ b/frontend/tests/MarkerPopup/LocationDetailsBox.test.jsx @@ -92,10 +92,10 @@ 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', { type: '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 new file mode 100644 index 00000000..0587d0c0 --- /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-plugin', Overlay, { message: 'nothing nearby' }, 'MapOverlay'), + ); + + render(); + + expect(screen.getByText('nothing nearby')).toBeInTheDocument(); + }); + + it('does not render field plugins', () => { + const Field = () => field plugin; + act(() => registerPlugin('field-plugin', Field, {}, 'MarkerField')); + + 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 }, '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/PluginSlot.test.jsx b/frontend/tests/plugins/PluginSlot.test.jsx deleted file mode 100644 index b3a0832f..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-scope', TestComponent)); - - render(); - expect(screen.getByText('hello plugin')).toBeInTheDocument(); - }); -}); diff --git a/frontend/tests/plugins/pluginRegistry.test.jsx b/frontend/tests/plugins/pluginRegistry.test.jsx new file mode 100644 index 00000000..67f5c023 --- /dev/null +++ b/frontend/tests/plugins/pluginRegistry.test.jsx @@ -0,0 +1,24 @@ +import { + registerPlugin, + getFieldPlugins, + 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' }, 'MapOverlay'); + registerPlugin('silly', Field, { gif: 'x', field: 'silly' }, 'MarkerField'); + + // 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'); + expect(overlays).toHaveLength(1); + expect(overlays[0][1]).toBe(Overlay); + }); +}); diff --git a/goodmap/__init__.py b/goodmap/__init__.py index 3c2ccef4..fac85b99 100644 --- a/goodmap/__init__.py +++ b/goodmap/__init__.py @@ -1,3 +1,3 @@ -from goodmap.plugin import GoodmapPluginBase +from goodmap.plugin import MapOverlayPluginBase, MarkerFieldPluginBase -__all__ = ["GoodmapPluginBase"] +__all__ = ["MapOverlayPluginBase", "MarkerFieldPluginBase"] diff --git a/goodmap/goodmap.py b/goodmap/goodmap.py index 04377f98..9f4af308 100644 --- a/goodmap/goodmap.py +++ b/goodmap/goodmap.py @@ -25,32 +25,55 @@ get_location_obligatory_fields, ) from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading +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: - 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 + 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}", @@ -65,15 +88,24 @@ def _add_cors(response): response.headers["Access-Control-Allow-Origin"] = "*" return response - manifest_entry = { - "scope": ep.name, - "url": f"/plugins/{ep.name}/static/remoteEntry.js", - "module": "./Button", - } - return bp, manifest_entry + 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) - return None, None + return None, [] def _setup_location_model( @@ -133,7 +165,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=list(CAPABILITY_BASES), + extra_plugins_entrypoints=[_PLUGIN_ENTRY_POINT_GROUP], + ) frontend_static_dir = os.path.join(directory, "static", "frontend") app.register_blueprint( @@ -160,12 +200,29 @@ 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", + exc_info=True, + ) + plugins_data = {} + plugin_manifest = [] for ep in importlib.metadata.entry_points(group=_PLUGIN_ENTRY_POINT_GROUP): - bp, entry = _register_plugin_static_resources(ep) - if bp is not None: + 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, entries = _register_plugin_static_resources(ep) + if bp is not None and entries: app.register_blueprint(bp) - 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 91cd3aca..99aa291f 100644 --- a/goodmap/plugin.py +++ b/goodmap/plugin.py @@ -6,7 +6,70 @@ class GoodmapPluginBase(PluginBase): - """Base class for goodmap map plugins.""" + """Base class (family root) for goodmap plugin capabilities. + + 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. + """ 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. + + Manifest capability ``"MapOverlay"``; component exposed as ``"./MapOverlay"``. + """ + + def __init__(self, config: dict[str, Any]) -> None: + super().__init__(config) + + +class MarkerFieldPluginBase(GoodmapPluginBase): + """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`` 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. + + Manifest capability ``"MarkerField"``; component exposed as ``"./MarkerField"``. + """ + + 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, +) diff --git a/poetry.lock b/poetry.lock index f7e81956..903d767f 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.0a10" 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.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 = "997e3912da323776ca6a357b87605e2bbde65847c546a61825c351caf097e9a6" +content-hash = "8533a76052ff0671aef9bff8aa52bdc28949432d5302ce052156857ee4eab83e" diff --git a/pyproject.toml b/pyproject.toml index 9d97eba6..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.0a8" +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 48d127d2..2bed80a6 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, "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", {"scope": "promo_code", "value": "SAVE20"}]] + assert result["data"] == [["promo_code", {"type": "promo_code", "value": "SAVE20"}]] def test_field_plugin_merges_defaults(): @@ -43,11 +43,32 @@ def test_field_plugin_merges_defaults(): assert result["data"] == [ [ "promo_code", - {"scope": "promo_code", "value": "SAVE20", "color": "#4caf50", "text": "Reveal"}, + {"type": "promo_code", "value": "SAVE20", "color": "#4caf50", "text": "Reveal"}, ] ] +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 ef2ce421..f8fccf75 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,11 @@ from goodmap import goodmap from goodmap.config import GoodmapConfig from goodmap.feature_flags import EnableAdminPanel, UseLazyLoading +from goodmap.plugin import ( + CAPABILITY_BASES, + MapOverlayPluginBase, + MarkerFieldPluginBase, +) from tests.unit_tests.conftest import make_flag_set config = GoodmapConfig( @@ -31,7 +37,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=list(CAPABILITY_BASES), + extra_plugins_entrypoints=["goodmap.plugins"], + ) mock_extend_db.assert_called_once() @@ -169,77 +179,199 @@ 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 _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 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. + """ + 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", (base,), {}) + cls.__module__ = name + + ep = mock.MagicMock(spec=importlib.metadata.EntryPoint) + ep.name = name + ep.load.return_value = cls + return ep + + +def _patch_entry_points(groups: dict[str, list[Any]]): + """Patch importlib.metadata.entry_points to return per-group entry points. - Creates a real module file so inspect.getfile resolves correctly. + Production resolves a distinct list per group; the global patch must mirror that so + cross-group discovery (platzky + goodmap) doesn't see spurious duplicates. """ - 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() - real_module = types.ModuleType(name) - real_module.__file__ = init_file + def _fake_entry_points(*_args: Any, **kwargs: Any) -> list[Any]: + group = kwargs.get("group") + if group is None: + return [] + return list(groups.get(group, [])) - spec = mock.MagicMock(spec=importlib.metadata.EntryPoint) - spec.name = name - spec.load.return_value = real_module - return spec + return patch("importlib.metadata.entry_points", side_effect=_fake_entry_points) + + +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 {}}}} + ) 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 = _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 = _plugin_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 assert app.config["PLUGIN_MANIFEST"] == [ { - "scope": "my_plugin", + "pluginName": "my_plugin", "url": "/plugins/my_plugin/static/remoteEntry.js", - "module": "./Button", + "module": "./MapOverlay", + "capability": "MapOverlay", + "config": {"foo": "bar"}, + } + ] + + +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": "./MarkerField", + "capability": "MarkerField", + "config": {"color": "#0f0"}, + } + ] + + +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")) + + ep = _plugin_ep("tracker", plugin_dir, base=MarkerFieldPluginBase) + + 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": "./MarkerField", + "capability": "MarkerField", + "config": {"field": "hyperlink", "order": 1}, } ] +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) == {"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" + 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 + with tempfile.TemporaryDirectory() as tmpdir: + plugin_dir = os.path.join(tmpdir, "off_plugin") + os.makedirs(os.path.join(plugin_dir, "static")) + + ep = _plugin_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 = _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 = _plugin_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 = _plugin_config("weird_plugin") + ep = _plugin_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 +497,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 +506,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 = _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 +515,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 = _plugin_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