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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions docs/api-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,33 @@ sorting and limiting, then optional server-side clustering.
Formatting
----------

Translation of category keys, option values and field names on the way out
(:ref:`config-translations`).
Turning a point into what its popup shows: translation of category keys, option values and
field names on the way out (:ref:`config-translations`), and the rendering of a field's value
into HTML — by a plugin's shortcode, or by one of the field types goodmap builds in
(:ref:`plugins-builtin-field-types`).

.. automodule:: goodmap.formatter
:members:

.. automodule:: goodmap.field_types
:members:
:show-inheritance:

Map presentation
----------------

What the page is told about the map itself. Both are resolved once at startup from the data
source and handed to the template, rather than fetched per request, so a value that could not
be honoured fails the boot instead of every page load. Configured in
:ref:`data-source-marker-styles` and :ref:`data-source-initial_view`.

.. automodule:: goodmap.marker_styles
:members:

.. automodule:: goodmap.initial_view
:members:
:show-inheritance:

Errors
------

Expand Down
5 changes: 5 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ frontend to decide what to render. Both are set the same way.
- frontend
- Shows an alternative table view of the map data, for users who cannot use the map
itself.
* - ``ENABLE_ADMIN_PANEL``
- backend
- Serves the admin interface at ``/goodmap-admin`` for managing points, suggestions and
reports. Without it that route redirects to ``/``. It requires a logged-in user
either way.
* - ``FAKE_LOGIN``
- backend
- platzky flag: lets anyone log in by picking a role, with no authentication. For
Expand Down
36 changes: 36 additions & 0 deletions docs/data-source.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ and their schema, alongside platzky's ``site_content`` section:
"categories": { ... },
"visible_data": [ ... ],
"meta_data": [ ... ],
"initial_view": { ... },
"marker_styles": { ... },
"reported_issue_types": [ ... ],
"suggestions": [ ... ],
"reports": [ ... ]
Expand Down Expand Up @@ -257,6 +259,40 @@ Each category's active mode is exposed as ``filter_mode`` in the
``/api/categories-full`` response, so a custom frontend can render the right control —
checkbox or radio — without hardcoding category names.

.. _data-source-initial_view:

Initial view
------------

``initial_view`` is where the map opens — the position a visitor sees before panning
anywhere. It is optional: a map that declares none opens on the whole of Poland, which is
what the frontend hardcoded before this was configurable.

.. code-block:: json

{
"initial_view": {
Comment thread
raven-wing marked this conversation as resolved.
"center": [51.1079, 17.0385],
"zoom": 12
}
}

``center``
``[latitude, longitude]`` the map is centred on.

``zoom``
Leaflet zoom level — roughly 6 a country, 10 a province, 13 a town, 16 a street. It may
not exceed 19, the furthest the OpenStreetMap tile layer serves, and how far in a visitor
can zoom is that same fixed ceiling rather than anything you configure.

Either key may be omitted and keeps its default, so moving only the centre is usually
enough.

A view GoodMap cannot honour — a latitude past the pole, a zoom no tile layer serves, a
misspelled key — stops the app from starting, the same way an unknown icon provider does.
Leaflet would otherwise clamp or ignore the value and open somewhere other than you asked
for, which is far harder to notice than a failed deploy.

.. _data-source-marker-styles:

Marker styles
Expand Down
17 changes: 17 additions & 0 deletions docs/http-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,23 @@ list of ``[label, value]`` pairs — the fields listed in ``visible_data``, in t
with both label and value translated. ``metadata`` holds the ``meta_data`` fields. Fields
in neither list are not returned at all.

A field GoodMap renders — one naming a built-in ``type`` such as ``hyperlink`` or ``CTA``,
or one a plugin's shortcode is bound to — comes back as an object rather than a bare value:

.. code-block:: json

["website", {
"type": "hyperlink",
"value": "https://example.com",
"displayValue": "Example",
"html": "<a href=\"https://example.com\" target=\"_blank\" rel=\"noopener noreferrer\">Example</a>"
}]

``html`` is the rendering to display and ``type`` says what produced it; the entry's own
keys travel alongside for a client that would rather render from the data. See
:ref:`plugins-builtin-field-types` for which types GoodMap ships and
:ref:`plugins-shortcode-rendered-fields` for how a plugin adds one.

The path segment must be a valid UUID; anything else fails routing with ``404``. A
well-formed UUID that does not exist also gives ``404 {"message": "Location not found"}``.

Expand Down
124 changes: 117 additions & 7 deletions docs/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ The capability a plugin provides determines *how* its frontend renders:
**Marker fields** (``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
value flows through a chain of stages — the server-rendered ``html`` for the field seeds
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
Expand Down Expand Up @@ -105,8 +104,118 @@ 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``.
value flows through the server-rendered ``html`` for the field (if there is any) and then
each field plugin attached to that ``type`` via ``config.field``, innermost-first by
``config.order``.

.. _plugins-shortcode-rendered-fields:

**A marker field can be rendered with no frontend code at all**, by a platzky plugin that
contributes a shortcode named after it. ``prepare_pin`` matches each field name against the
shortcodes loaded plugins contribute and renders a match with that shortcode's
``render_value``, carrying the result as ``html`` for ``FieldRenderer`` to seed the fold
with — so there is no Module Federation build, no bundle to serve, and no ``config.field``
to keep in sync. The field plugins described below remain the way to add behaviour goodmap's
own React tree must take part in.

A shortcode reaches a marker field only if it is permitted to render one. Goodmap registers
``"marker_field"`` as a platzky content type, and ``prepare_pin`` is handed just the
shortcodes that pass both gates: the plugin must *accept* the type and the operator must
*grant* it. Both default to deny, so a shortcode that declares neither renders posts and
never touches a popup.

.. code-block:: python

class DiscountCodePlugin(ContentTransformerPluginBase):
accepted_content_types = ("marker_field",) # the plugin is willing

.. code-block:: json

{"plugins": {"discount_code": {"allowed_content_types": ["marker_field"]}}}

The operator's half lives in the data source beside the rest of the plugin's config (see
:ref:`data-source-plugins`). Marker fields are gated separately from post content because
``render_value`` does not pass through ``transform_content``: without the second gate a
plugin allowed to render posts would silently gain the popup as well.

The match is then by name. A plugin contributing this shortcode

.. code-block:: python

class DiscountCodeShortcode(Shortcode):
name = "discount_code"
#: A bare field value becomes the inner content, stored under this key.
content_key = "code"

def render(self, attrs, content):
# `content` is Markup — already escaped, so embed it as it is.
return f'<span class="discount-code">{content}</span>'

claims the field of the same name in a location entry

.. code-block:: json

{"name": "Bike repair point", "discount_code": "SUMMER24"}

and, with ``discount_code`` in ``visible_data``, the popup receives

.. code-block:: json

["discount_code", {
"code": "SUMMER24",
"type": "discount_code",
"html": "<span class=\"discount-code\">SUMMER24</span>"
}]

``html`` is what the popup displays. ``type`` is the shortcode's name, so a field plugin can
still attach to it by ``config.field`` and wrap what the shortcode rendered, and ``code`` is
the bare value under the shortcode's ``content_key``, for a plugin that would rather render
from the data itself.

.. warning::

A shortcode's rendering is presentation, not concealment. The bare value travels in the
payload beside the HTML, so a shortcode that masks or omits part of what it displays still
ships the original to the browser, where anyone reading the response can see it. Render a
field only from data its viewers may have; leave anything else out of ``visible_data``.

Of the three, only ``html`` and ``type`` are read by goodmap itself - the bare value is
carried purely for that render-from-data plugin, and may be dropped in a future version if
none turns out to want it.

That HTML is rendered, not sanitized. It comes from an installed plugin package, which
already executes in the server process, so filtering it would block nothing such a package
could not do more directly. The plugin's side of that bargain is to escape the *data* it
interpolates.

.. _plugins-builtin-field-types:

Goodmap's own field types are shortcodes too. ``hyperlink`` and ``CTA``, in
``goodmap/field_types.py``, are ordinary platzky ``Shortcode`` subclasses rendered through
the same ``render_value`` — so there is one renderer interface rather than two, no built-in
React field renderers at all, and one URL policy (platzky's, which admits ``http``,
``https``, ``mailto`` and ``tel``) rather than one in Python and another in JavaScript.

What differs is only how the shortcode is found. A plugin's is bound to a field by **name**,
which is what makes that field its own. Goodmap's own are looked up by the **type** the entry
declares, so any field can ask to be a ``hyperlink`` whatever it is called:

.. code-block:: json

{"website": {"type": "hyperlink", "value": "https://example.com"}}

That lookup is safe only because the catalogue is closed: an entry may name a type in there
and nothing else, so it can never point its own field at a plugin's renderer. ``prepare_pin``
consults it only where no plugin shortcode claimed the field by name.

A plugin cannot take over ``hyperlink`` or ``CTA`` either. The server always emits ``html``
for a type it renders, so that HTML is always the innermost stage — a field plugin attached
to one of these types wraps it and cannot replace it.

The two share a rendering, because they only ever differed in presentation: both are a URL
and the text to show for it. Which one a field is decides where the popup puts it — a line
among the details, or a button below them — which ``LocationDetails`` decides from the field
name.

A field plugin is a ``MarkerFieldPluginBase`` whose component is a stage
``({ input, config }) => element`` — it receives the previous stage's output as ``input``.
Expand Down Expand Up @@ -134,7 +243,7 @@ rendering. Its platzky shortcode turns the raw value into ``{"type": "<field>",
}

**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:
to customize a ``hyperlink`` or a ``CTA``). Needs no shortcode:

.. code-block:: jsx

Expand All @@ -150,7 +259,8 @@ to customize a built-in ``hyperlink``/``CTA``). Needs no shortcode:

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).
A wrapper must have a renderer beneath it (a type rendered server-side — one of goodmap's
own, or a plugin's shortcode — or a renderer plugin it depends on).

.. _plugins-configuration:

Expand Down
9 changes: 8 additions & 1 deletion e2e-tests/e2e_test_data_initial.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,14 @@
],
"meta_data": [
"uuid"
]
],
"initial_view": {
"center": [
51.1079,
17.0385
],
"zoom": 12
}
},
"site_content": {
"home_page_path": "/map",
Expand Down
5 changes: 3 additions & 2 deletions e2e-tests/tests/basic/test_searchbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,9 @@ def test_should_fly_to_picked_suggestion_coordinates(self, page: Page):
# Leaflet only requests tiles at the final location once flyTo
# completes, so waiting for the destination tile implicitly
# synchronises on the animation end and confirms the map actually
# moved to the picked suggestion's coordinates (the map's default
# position is zoom 7, so this can't pass by coincidence).
# moved to the picked suggestion's coordinates. tile_pattern is pinned
# to zoom 13 and the seeded initial_view opens at 12, so this cannot
# pass without the map having moved - keep the two zooms different.
map_tile = page.locator(".leaflet-tile-container > img").first
expect(map_tile).to_have_attribute(
"src", re.compile(location["tile_pattern"]), timeout=FLY_TO_TIMEOUT
Expand Down
3 changes: 1 addition & 2 deletions e2e-tests/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,7 @@
"lat": 51.1102,
"lon": 17.0326,
"tile_pattern": (
r"https://[abc]\.tile\.openstreetmap\.org/13/"
r"(4482|4483|4484)/(2737|2738|2739)\.png"
r"https://[abc]\.tile\.openstreetmap\.org/13/" r"(4482|4483|4484)/(2737|2738|2739)\.png"
),
},
}
Expand Down
43 changes: 19 additions & 24 deletions e2e-tests/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,29 +63,22 @@ def get_rightmost_marker(page: Page) -> ElementHandle | None:


def verify_popup_content(page: Page, expected_content: dict[str, Any]) -> None:
"""
Verifies popup content including title, subtitle, categories, and CTA button.

Scopes assertions to .leaflet-popup-content or .MuiDialogContent-root
to avoid false positives from other elements on the page.
"""Verifies popup title, subtitle, categories, and CTA link.

Uses semantic element selectors (h3 for title, p for subtitle) for the new frontend.
Assertions are scoped to .leaflet-popup-content / .MuiDialogContent-root so other
elements on the page cannot satisfy them, and title/subtitle are found by their
semantic elements (h3, p).

Args:
page: Playwright page object
expected_content: Expected content dictionary with keys:
- title: Expected title text
- subtitle: Expected subtitle text
- categories: List of [category, value] tuples
- CTA (optional): Dict with displayValue and value (URL)

Example:
verify_popup_content(page, {
"title": "Bridge Name",
"subtitle": "small bridge",
"categories": [["type of place", "small bridge"]],
"CTA": {"displayValue": "View on Map", "value": "https://..."}
})
page: Playwright page object.
expected_content: What the popup should show, e.g.::

{
"title": "Bridge Name",
"subtitle": "small bridge",
"categories": [["type of place", "small bridge"]],
"CTA": {"displayValue": "View on Map", "value": "https://..."}, # optional
}
"""
# Scope to popup container
popup = page.locator(".leaflet-popup-content, .MuiDialogContent-root")
Expand All @@ -107,12 +100,14 @@ def verify_popup_content(page: Page, expected_content: dict[str, Any]) -> None:
# Check that the value appears at least once in the popup
expect(popup.get_by_text(value).first).to_be_visible()

# Verify and click CTA button if provided
# Verify the CTA if provided. It is a link the server rendered, styled as a button, so
# this checks where it points rather than clicking it — a target="_blank" click opens a
# second page and leaves the popup behind, which is not what the caller is asserting.
if "CTA" in expected_content:
cta = expected_content["CTA"]
cta_button = popup.locator("button", has_text=cta["displayValue"])
expect(cta_button).to_be_visible()
cta_button.click()
cta_link = popup.locator("a", has_text=cta["displayValue"])
expect(cta_link).to_be_visible()
expect(cta_link).to_have_attribute("href", cta["value"])


def verify_problem_form(page: Page) -> None:
Expand Down
Loading
Loading