Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f260928
fix: photos are optional
raven-wing Jul 29, 2026
f72d4e8
added missing file
raven-wing Jul 29, 2026
eb31ece
fix: error is actually visible now
raven-wing Aug 2, 2026
2b0b6f9
docs added
raven-wing Aug 2, 2026
7d12b3a
fix lint
raven-wing Aug 2, 2026
6cd52a6
added missing files
raven-wing Aug 2, 2026
c538556
fix docs
raven-wing Aug 2, 2026
4553d7e
fixes
raven-wing Aug 2, 2026
042458a
fix scroll
raven-wing Aug 2, 2026
bae24fa
load spinner
raven-wing Aug 2, 2026
9a37fcc
fix docs
raven-wing Aug 2, 2026
a275e24
fix docs
raven-wing Aug 2, 2026
02ddc3e
fix
raven-wing Aug 3, 2026
f9af39a
little cleanup
raven-wing Aug 3, 2026
c8fc966
fixes after update
raven-wing Aug 4, 2026
7a0a319
fixed comments
raven-wing Aug 4, 2026
8a880e4
little refactor
raven-wing Aug 4, 2026
8d785aa
removed compressing from our site
raven-wing Aug 4, 2026
f39c8da
fixes
raven-wing Aug 4, 2026
3df1861
added dependency
raven-wing Aug 4, 2026
5e9009c
splitted suggest new point behaviour
raven-wing Aug 4, 2026
4385180
little refactor
raven-wing Aug 4, 2026
4c6d28b
dead code
raven-wing Aug 4, 2026
afa8f07
code cleanup
raven-wing Aug 4, 2026
434fb69
some cleanup
raven-wing Aug 4, 2026
5fc4ecd
lint fixes
raven-wing Aug 4, 2026
630b97d
fix after review
raven-wing Aug 4, 2026
c9347de
removed dead code
raven-wing Aug 4, 2026
ba408bb
refactor
raven-wing Aug 8, 2026
ec63578
fix licenses
raven-wing Aug 8, 2026
f3ff1cc
refactor
raven-wing Aug 8, 2026
2f0c4bc
remove some extra check
raven-wing Aug 8, 2026
bc99d6c
simplify
raven-wing Aug 10, 2026
9993bd2
fix after review
raven-wing Aug 13, 2026
b288dc9
fixes after review
raven-wing Aug 14, 2026
e94bb19
fix after review
raven-wing Aug 14, 2026
31c7915
little refactor
raven-wing Aug 14, 2026
fb361bc
lint fix
raven-wing Aug 15, 2026
f58f4c4
refactor test
raven-wing Aug 16, 2026
2cf6fa0
some renames
raven-wing Aug 16, 2026
22b9dad
added docs with feature flags
raven-wing Aug 16, 2026
32d1b49
docs fix
raven-wing Aug 16, 2026
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: 25 additions & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,31 @@ The active mode for each category is also exposed as ``filter_mode`` in the
``/api/categories-full`` response, so a custom frontend can render the
right control (checkbox vs. radio) without hardcoding category names.

Photo Uploads
~~~~~~~~~~~~~

Users can attach a photo when suggesting a new location. By default, Goodmap
accepts JPEG photos up to 5 MiB. To allow other formats or change the size
limit, set the ``ATTACHMENT:`` key in your configuration file (see
`platzky's AttachmentConfig
<https://platzky.readthedocs.io/en/latest/api.html#platzky.config.AttachmentConfig>`_):

.. code-block:: yaml

ATTACHMENT:
allowed_mime_types: ["image/jpeg", "image/png"]
allowed_extensions: ["jpg", "jpeg", "png"]
max_size: 8388608 # 8 MiB

Omit ``ATTACHMENT:`` to keep the default (JPEG only, 5 MiB).

A photo in an unsupported format is rejected with an error message asking the
user to pick a different file. A photo in an allowed format that exceeds the
size limit is automatically compressed in the browser before upload; the user
is warned that this may reduce image quality. If the photo still exceeds the
limit after compression, it is rejected. The server enforces the same limits
on upload, independently of the browser-side checks.

.. _data-model-visible_data:

Database Types
Expand Down
158 changes: 158 additions & 0 deletions e2e-tests/tests/basic/test_suggest_new_point.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""
Suggest New Point Tests

Tests the "suggest a new point" dialog's validation and error feedback,
in particular that error messages render as an inline banner inside the
dialog (rather than a toast that can end up stacked behind it - see
SuggestNewPointButton.jsx for context on why toasts alone weren't reliable
here).
"""

from playwright.sync_api import Page, expect

from tests.conftest import BASE_URL


def _suggest_new_point_dialog(page: Page):
# The page also has a permanently-present #left-panel with role="dialog"
# (a Bootstrap offcanvas), so a bare get_by_role("dialog") is ambiguous.
# This dialog's accessible name comes from aria-labelledby pointing at its
# MUI DialogTitle, which disambiguates it.
return page.get_by_role("dialog", name="Suggest a New Point")


def _open_suggest_new_point_dialog(page: Page):
suggest_button = page.locator('[data-testid="suggest-new-point"]')
expect(suggest_button).to_have_css("opacity", "1", timeout=5000)
suggest_button.click()

dialog = _suggest_new_point_dialog(page)
expect(dialog).to_be_visible()
return dialog


def _upload_tall_photo(page: Page) -> None:
"""
Attaches a synthetic, extremely tall JPEG (generated in-browser via canvas, so no
fixture file is needed on disk) to the photo input.

Rendered at the dialog's fixed width, a 200x3000px image becomes a huge block that
guarantees the dialog's Paper overflows and needs to scroll - deterministically, and
independent of viewport size or how many form fields happen to be configured. Kept
to a moderate (not extreme) aspect ratio so the resulting smooth-scroll animation
(see SuggestNewPointButton.jsx) finishes quickly rather than taking several seconds.
"""
page.evaluate("""
async () => {
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 3000;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#3366ff';
ctx.fillRect(0, 0, canvas.width, canvas.height);

const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.8));
const file = new File([blob], 'tall-photo.jpg', { type: 'image/jpeg' });

const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);

const input = document.querySelector('[data-testid="photo-of-point"]');
input.files = dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
""")


class TestSuggestNewPointValidation:
"""Test suite for the suggest-new-point dialog's inline validation feedback"""

def test_submitting_empty_required_fields_shows_inline_error(self, page: Page, geolocation):
"""
Submitting with required fields empty must show a visible, in-dialog error
and must not submit the form. This is a regression test: the error used to
render as a toast that got trapped behind the dialog by a CSS stacking
context, making the failure look like the button silently did nothing.
"""
geolocation(51.10655, 17.0555) # Wroclaw
page.goto(BASE_URL, wait_until="domcontentloaded")

dialog = _open_suggest_new_point_dialog(page)
dialog.get_by_role("button", name="Submit").click()

# The alert must render inside the dialog's own stacking context, not just
# be present anywhere in the DOM, or it can still be visually hidden.
alert = dialog.get_by_role("alert")
expect(alert).to_be_visible(timeout=5000)
expect(alert).to_contain_text("Please fill in required fields")

# Dialog stays open so the user can fix the fields and retry.
expect(dialog).to_be_visible()

def test_error_banner_clears_when_dialog_is_reopened(self, page: Page, geolocation):
"""
A validation error from a previous attempt must not persist into a fresh
dialog session after cancel + reopen.
"""
geolocation(51.10655, 17.0555)
page.goto(BASE_URL, wait_until="domcontentloaded")

dialog = _open_suggest_new_point_dialog(page)
dialog.get_by_role("button", name="Submit").click()
expect(dialog.get_by_role("alert")).to_be_visible(timeout=5000)

dialog.get_by_role("button", name="Cancel").click()
expect(dialog).not_to_be_visible()

dialog = _open_suggest_new_point_dialog(page)
expect(dialog.get_by_role("alert")).not_to_be_visible()

def test_error_is_scrolled_into_view_when_dialog_content_is_tall(self, page: Page, geolocation):
"""
Regression test: when a tall photo pushes the dialog's Paper past the fold and
the user has scrolled down (e.g. to inspect the photo/fields), a validation error
appearing at the top of that same scroll area must be automatically scrolled into
view - not just present in the DOM. Without this, the error is technically
"visible" by CSS but sits entirely off-screen above the user's current scroll
position, indistinguishable from the button silently doing nothing (the original
bug this whole error-visibility effort started from).

Note: it's the Dialog's Paper that scrolls here, not DialogContent - despite
DialogContent having flex:1 1 auto + overflow-y:auto, it has no definite
cross-size to shrink against (it's itself sized by its own content), so it just
grows to fit everything; Paper is the ancestor with the actual maxHeight
constraint, so its scrollTop is what ends up non-zero.
"""
geolocation(51.10655, 17.0555)
page.goto(BASE_URL, wait_until="domcontentloaded")

dialog = _open_suggest_new_point_dialog(page)
_upload_tall_photo(page)

# Wait for the *decoded* image, not just the <img> tag's presence: with
# width:100%/height:auto and no explicit dimensions, the element can report a
# non-zero (but not-yet-final) box before the blob has actually finished
# decoding, which would make the scrollHeight snapshot below race the layout.
page.wait_for_function("""
() => {
const img = document.querySelector('img[alt="Selected"]');
return img && img.complete && img.naturalHeight > 1000;
}
""")

# `dialog` (role="dialog") resolves to the Paper element itself, not an ancestor
# containing it - MUI renders the ARIA role directly on .MuiDialog-paper.
dialog_paper = dialog
dialog_paper.evaluate("el => { el.scrollTop = el.scrollHeight; }")
# Sanity check the scroll actually moved away from the top - otherwise this test
# would pass trivially without ever exercising the scroll-to-top behavior.
scroll_top = dialog_paper.evaluate("el => el.scrollTop")
assert scroll_top > 0, "Dialog paper did not actually scroll - test setup is broken"

dialog.get_by_role("button", name="Submit").click()

alert = dialog.get_by_role("alert")
expect(alert).to_contain_text("Please fill in required fields")
# Generous timeout: the scroll-to-top is animated (behavior: 'smooth'), and
# to_be_in_viewport polls until the animation settles rather than checking once.
expect(alert).to_be_in_viewport(timeout=8000)
Loading
Loading