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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ TODO: `obligatory_fields` is a new subsection, start using it in the actual appl
```
"accessible_by": ["bikes", "cars", "pedestrians"]
```
- `categories_default_checked` - for a given category, the options that should be pre-checked in the filter panel when the app first loads. E.g.
```json
"accessible_by": ["cars", "pedestrians"]
```
- `visible_data` - when a datapoint will be rendered as a pin on a map, these fields will be shown in the box when clicking on a pin. E.g.
```
"name",
Expand Down
40 changes: 40 additions & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,46 @@ Example configuration in your data source:
]
}

Categories and Filtering
~~~~~~~~~~~~~~~~~~~~~~~~

``categories`` is a dict of field name to the list of allowed values for that
field. Each category is rendered in the frontend as a group of filter
checkboxes, one per allowed value.

``categories_help``
List of category keys that should show a help tooltip next to the
category's title. The tooltip text is looked up via the translation key
``categories_help_<category_key>``.

``categories_options_help``
Dict of category key to the list of option values within that category
that should show a help tooltip. The tooltip text is looked up via the
translation key ``categories_options_help_<option_value>``.

``categories_default_checked``
Dict of category key to the list of option values that should be
pre-checked in the filter panel when the app first loads, before the user
has made any selection.

Example configuration in your data source:

.. code-block:: json

{
"categories": {
"accessible_by": ["bikes", "cars", "pedestrians"],
"type_of_place": ["big bridge", "small bridge"]
},
"categories_help": ["accessible_by"],
"categories_options_help": {
"accessible_by": ["cars", "pedestrians"]
},
"categories_default_checked": {
"accessible_by": ["cars"]
}
}

.. _data-model-visible_data:

Database Types
Expand Down
5 changes: 5 additions & 0 deletions e2e-tests/e2e_test_data_initial.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"pedestrians"
]
},
"categories_default_checked": {
"accessible_by": [
"cars"
]
},
"visible_data": [
"remark",
"accessible_by",
Expand Down
7 changes: 7 additions & 0 deletions e2e-tests/tests/basic/test_accessibility_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ def setup(self, page: Page, geolocation):
# Navigate to the page
page.goto(BASE_URL, wait_until="domcontentloaded")

# "accessible_by: cars" is checked by default (see categories_default_checked
# in the test data), which excludes Zwierzyniecka (bikes/pedestrians only,
# no cars). Uncheck it so both seeded locations are visible, matching what
# these tests assert.
page.wait_for_selector("#filter-form", timeout=MARKER_LOAD_TIMEOUT)
page.locator("#filter-form input#cars").uncheck()

# Wait for map markers to load before clicking list view button
markers = page.locator(".leaflet-marker-icon")
expect(markers.first).to_be_visible(timeout=MARKER_LOAD_TIMEOUT)
Expand Down
4 changes: 3 additions & 1 deletion e2e-tests/tests/basic/test_language.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pytest
from playwright.sync_api import Page, expect

from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT
from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, clear_all_checkboxes


def get_language_button(page: Page):
Expand Down Expand Up @@ -71,6 +71,8 @@ def test_switch_to_polish_changes_popup_text(self, page: Page):
page.get_by_role("link", name="polski").click()
page.wait_for_load_state("domcontentloaded")

clear_all_checkboxes(page)

# Click marker cluster to expand
page.locator(".leaflet-marker-icon").first.click()

Expand Down
31 changes: 31 additions & 0 deletions e2e-tests/tests/basic/test_left_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,37 @@ def test_small_bridge_filter_has_helper_tooltip(self, page: Page):
expect(tooltip).to_contain_text("A smaller pedestrian or bike bridge")


class TestLeftPanelDefaultCheckedFilters:
"""Test suite for the categories_default_checked config option"""

def test_configured_option_is_checked_by_default(self, page: Page):
"""
The 'cars' option of the accessible_by category is configured as
default-checked (see categories_default_checked in the test data),
so it should be pre-checked when the filter form loads.
"""
page.set_viewport_size({"width": 1200, "height": 800})
page.goto(BASE_URL, wait_until="domcontentloaded")

page.wait_for_selector("#filter-form", timeout=10000)

cars_checkbox = page.locator("#filter-form input#cars")
expect(cars_checkbox).to_be_checked()

def test_other_options_are_not_checked_by_default(self, page: Page):
"""
Options not listed in categories_default_checked should remain
unchecked when the filter form loads.
"""
page.set_viewport_size({"width": 1200, "height": 800})
page.goto(BASE_URL, wait_until="domcontentloaded")

page.wait_for_selector("#filter-form", timeout=10000)

pedestrians_checkbox = page.locator("#filter-form input#pedestrians")
expect(pedestrians_checkbox).not_to_be_checked()


class TestLeftPanelScrollbar:
"""Test suite for panel scrollbar styling"""

Expand Down
12 changes: 8 additions & 4 deletions e2e-tests/tests/basic/test_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ def test_should_not_have_scrollbars(self, page: Page):

def test_filter_checkbox_filters_markers(self, page: Page):
"""Verify clicking filter checkbox actually filters the markers on the map"""
# On desktop, filter panel is already visible (no toggle needed).
# "accessible_by: cars" is checked by default (see categories_default_checked
# in the test data), so start by clearing it to see both seeded locations.
cars_checkbox = page.get_by_role("checkbox", name="cars", exact=False)
expect(cars_checkbox).to_be_checked()
cars_checkbox.click()

# Wait for markers to load
first_marker = page.locator(".leaflet-marker-icon").first
expect(first_marker).to_be_visible(timeout=5000)
Expand All @@ -69,11 +76,8 @@ def test_filter_checkbox_filters_markers(self, page: Page):
markers = page.locator(".leaflet-marker-icon")
expect(markers).to_have_count(2)

# On desktop, filter panel is already visible (no toggle needed)

# Check the "cars" filter checkbox - this should filter to only show
# Re-check the "cars" filter checkbox - this should filter to only show
# places accessible by cars (1 marker instead of 2)
cars_checkbox = page.get_by_role("checkbox", name="cars", exact=False)
cars_checkbox.click()

# After filtering, only 1 marker should be visible (the one accessible by cars)
Expand Down
4 changes: 3 additions & 1 deletion e2e-tests/tests/basic/test_mobile_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import pytest
from playwright.sync_api import Page, expect

from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL
from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL, clear_all_checkboxes
from tests.helpers import EXPECTED_PLACE_ZWIERZYNIECKA, verify_popup_content, verify_problem_form


Expand All @@ -32,6 +32,8 @@ def test_displays_title_and_subtitle_in_popup(
# Navigate to the page (device emulation already configured by mobile_page fixture)
mobile_page.goto(BASE_URL, wait_until="domcontentloaded")

clear_all_checkboxes(mobile_page)

# Click first marker to expand cluster
# Use JavaScript click to bypass webpack overlay that may intercept clicks on CI
first_marker = mobile_page.locator(".leaflet-marker-icon").first
Expand Down
6 changes: 5 additions & 1 deletion e2e-tests/tests/basic/test_popup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from playwright.sync_api import Page, expect

from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT
from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, clear_all_checkboxes
from tests.helpers import EXPECTED_PLACE_ZWIERZYNIECKA, verify_popup_content, verify_problem_form


Expand All @@ -22,6 +22,8 @@ def test_displays_popup_title_subtitle_categories_and_cta(self, page: Page, wind
"""
page.goto(BASE_URL, wait_until="domcontentloaded")

clear_all_checkboxes(page)

# Click first marker to trigger cluster expansion
first_marker = page.locator(".leaflet-marker-icon").first
first_marker.click()
Expand Down Expand Up @@ -78,6 +80,8 @@ def test_problem_form_on_desktop(self, page: Page, window_open_stub):
"""
page.goto(BASE_URL, wait_until="domcontentloaded")

clear_all_checkboxes(page)

# Click first marker to trigger cluster expansion
first_marker = page.locator(".leaflet-marker-icon").first
first_marker.click()
Expand Down
15 changes: 14 additions & 1 deletion e2e-tests/tests/basic/test_share.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
import pytest
from playwright.sync_api import Page, expect

from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL, MARKER_LOAD_TIMEOUT
from tests.conftest import (
ALL_MOBILE_DEVICES,
BASE_URL,
MARKER_LOAD_TIMEOUT,
clear_all_checkboxes,
)


class TestShareOnDesktop:
Expand All @@ -26,6 +31,8 @@ def test_share_button_copies_link_to_clipboard(self, page: Page):
# Grant clipboard permissions
page.context.grant_permissions(["clipboard-read", "clipboard-write"])

clear_all_checkboxes(page)

# Click first marker to trigger cluster expansion
first_marker = page.locator(".leaflet-marker-icon").first
first_marker.click()
Expand Down Expand Up @@ -82,6 +89,8 @@ def test_shared_link_opens_popup_with_correct_content(self, page: Page):
wait_until="domcontentloaded",
)

clear_all_checkboxes(page)

# Verify popup is visible
popup = page.locator(".leaflet-popup-content")
expect(popup).to_be_visible(timeout=MARKER_LOAD_TIMEOUT)
Expand Down Expand Up @@ -115,6 +124,8 @@ def test_share_button_triggers_native_share(self, mobile_page: Page, device_name

mobile_page.goto(BASE_URL, wait_until="domcontentloaded")

clear_all_checkboxes(mobile_page)

# Click first marker to expand cluster
first_marker = mobile_page.locator(".leaflet-marker-icon").first
first_marker.evaluate("el => el.click()")
Expand Down Expand Up @@ -170,6 +181,8 @@ def test_shared_link_opens_popup_on_mobile(self, mobile_page: Page, device_name:
wait_until="domcontentloaded",
)

clear_all_checkboxes(mobile_page)

# On mobile, popup appears as Material-UI Dialog
dialog_content = mobile_page.locator(".MuiDialogContent-root")
expect(dialog_content).to_be_visible(timeout=MARKER_LOAD_TIMEOUT)
Expand Down
39 changes: 37 additions & 2 deletions e2e-tests/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,36 @@
}


def clear_all_checkboxes(page: Page) -> None:
"""
Click the "Clear filters" button to reset all selected category filters.

Several tests rely on all seeded locations being visible, which requires
clearing any filters checked by default (see categories_default_checked
in the e2e test data) before asserting on marker/location counts.

Opens the mobile filter panel (if collapsed) to reach the button, and
closes it again afterwards so it doesn't obscure subsequent interactions.

Clearing filters can trigger a marker refetch that mounts a location
whose popup auto-opens (e.g. a pending ?locationId= shared link). That
popup dialog can stack on top of the filter panel before the close button
is clicked, so the close is dispatched via JS: it fires the same click
event Bootstrap listens for, but skips Playwright's hit-testing, which
would otherwise block on the overlapping dialog.
"""
toggle_button = page.locator('button[aria-label="Toggle left panel"]')
opened_dialog = toggle_button.is_visible()
if opened_dialog:
toggle_button.click()

page.wait_for_selector("#filter-form", timeout=MARKER_LOAD_TIMEOUT)
page.locator('#filter-form button[aria-label="Clear all filters"]').click()

if opened_dialog:
page.locator('button[aria-label="Close left panel"]').evaluate("el => el.click()")


def _block_hmr(page: Page) -> None:
"""Block HMR/hot reload requests to prevent page refreshes during tests."""
page.route("**/ws", lambda route: route.abort())
Expand Down Expand Up @@ -113,7 +143,7 @@ def window_open_stub(page: Page) -> Callable[[], list[str]]:


@pytest.fixture
def mobile_page(browser, request) -> Generator[Page, None, None]:
def mobile_page(new_context, request) -> Generator[Page, None, None]:
"""
Create a page with proper mobile device emulation.

Expand All @@ -123,6 +153,11 @@ def mobile_page(browser, request) -> Generator[Page, None, None]:
Supports two parametrization styles:
1. Indirect: @pytest.mark.parametrize("mobile_page", ALL_MOBILE_DEVICES, indirect=True)
2. Legacy: @pytest.mark.parametrize("device_name", ALL_MOBILE_DEVICES)

Uses pytest-playwright's new_context factory fixture (rather than calling
browser.new_context() directly) so CLI-driven options like
--video=retain-on-failure still apply, and so failures on mobile tests
produce the same video/trace artifacts as desktop tests.
"""
if hasattr(request, "param"):
device_name = request.param
Expand All @@ -136,7 +171,7 @@ def mobile_page(browser, request) -> Generator[Page, None, None]:

device_config = MOBILE_DEVICES[device_name]

context = browser.new_context(
context = new_context(
viewport=device_config["viewport"],
user_agent=device_config["user_agent"],
has_touch=True,
Expand Down
5 changes: 5 additions & 0 deletions examples/e2e_test_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@
"pedestrians"
]
},
"categories_default_checked": {
"accessible_by": [
"cars"
]
},
"visible_data": [
"remark",
"accessible_by",
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/components/Categories/CategoriesContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ CategoriesContext.displayName = 'CategoriesContext';
* @returns {React.ReactElement} Context provider with categories state
*/
export const CategoriesProvider = ({ children }) => {
const [categories, setCategories] = useState([]);
const value = useMemo(() => ({ categories, setCategories }), [categories]);
const [categories, setCategories] = useState({});
const [isInitialized, setIsInitialized] = useState(false);
const value = useMemo(
() => ({ categories, setCategories, isInitialized, setIsInitialized }),
[categories, isInitialized],
);

return <CategoriesContext.Provider value={value}>{children}</CategoriesContext.Provider>;
};
Expand All @@ -27,9 +31,12 @@ export const CategoriesProvider = ({ children }) => {
* Must be used within a CategoriesProvider component.
*
* @throws {Error} If used outside of CategoriesProvider
* @returns {Object} Object containing categories array and setCategories function
* @returns {Array} return.categories - Current categories data
* @returns {Object} Object containing categories map and setCategories function
* @returns {Object} return.categories - Currently selected filter values keyed by category
* @returns {Function} return.setCategories - Function to update categories
* @returns {boolean} return.isInitialized - True once initial filter state (including
* default-checked options) has been loaded, so consumers can wait before fetching
* @returns {Function} return.setIsInitialized - Marks the initial filter state as loaded
*/
export const useCategories = () => {
const context = useContext(CategoriesContext);
Expand Down
Loading
Loading