From 349b5b9eddc4a2af781badbb040760937c91fe57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 9 Jul 2026 11:15:12 +0200 Subject: [PATCH 01/12] feat: add default showed categories --- .../components/FiltersForm/FiltersForm.jsx | 10 ++++-- frontend/src/services/http/httpService.js | 15 ++++++-- frontend/tests/FiltersForm.test.jsx | 28 ++++++++++++++- goodmap/core_api.py | 2 ++ goodmap/db.py | 27 +++++++++++++- tests/unit_tests/test_core_api.py | 36 +++++++++++++++++++ tests/unit_tests/test_db.py | 19 +++++++++- 7 files changed, 129 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/FiltersForm/FiltersForm.jsx b/frontend/src/components/FiltersForm/FiltersForm.jsx index c4124ee2..89afb7ca 100644 --- a/frontend/src/components/FiltersForm/FiltersForm.jsx +++ b/frontend/src/components/FiltersForm/FiltersForm.jsx @@ -158,7 +158,7 @@ const LoadingSkeleton = () => ( ); export const FiltersForm = () => { - const { setCategories } = useCategories(); + const { categories: selectedFilters, setCategories } = useCategories(); const [categoriesData, setCategoriesData] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -188,8 +188,11 @@ export const FiltersForm = () => { useEffect(() => { const fetchCategories = async () => { setIsLoading(true); - const categoriesData = await httpService.getCategoriesData(); - setCategoriesData(categoriesData); + const { categories, defaultChecked } = await httpService.getCategoriesData(); + setCategoriesData(categories); + if (Object.keys(defaultChecked).length > 0) { + setCategories(defaultChecked); + } setIsLoading(false); }; fetchCategories(); @@ -208,6 +211,7 @@ export const FiltersForm = () => { type="checkbox" id={name} value={name} + checked={Boolean(selectedFilters[category]?.includes(name))} /> {translation} {tooltipData && ( diff --git a/frontend/src/services/http/httpService.js b/frontend/src/services/http/httpService.js index 678a90c5..93a0c97c 100644 --- a/frontend/src/services/http/httpService.js +++ b/frontend/src/services/http/httpService.js @@ -47,13 +47,15 @@ export const httpService = { * Fetches complete categories data including subcategories in a single request. * Uses the /api/categories-full endpoint to avoid waterfall requests. * - * @returns {Promise} Promise resolving to array of category data tuples + * @returns {Promise<{categories: Array, defaultChecked: Object}>} Promise resolving to + * the array of category data tuples plus a map of category key to the option values + * that should be pre-checked by default. */ getCategoriesData: async () => { const response = await fetch(CATEGORIES_FULL).then(res => res.json()); // Transform to expected format: [[key, name], options, help?, optionsHelp?] - return response.categories.map(category => { + const categories = response.categories.map(category => { const categoryTuple = [category.key, category.name]; const options = category.options; @@ -67,6 +69,15 @@ export const httpService = { } return [categoryTuple, options]; }); + + const defaultChecked = {}; + for (const category of response.categories) { + if (category.default_checked?.length) { + defaultChecked[category.key] = category.default_checked; + } + } + + return { categories, defaultChecked }; }, /** diff --git a/frontend/tests/FiltersForm.test.jsx b/frontend/tests/FiltersForm.test.jsx index cc4c2261..d97d390c 100644 --- a/frontend/tests/FiltersForm.test.jsx +++ b/frontend/tests/FiltersForm.test.jsx @@ -19,7 +19,7 @@ const categories = [ ], ]; -httpService.getCategoriesData.mockResolvedValue(categories); +httpService.getCategoriesData.mockResolvedValue({ categories, defaultChecked: {} }); describe('Creates good filter_form box', () => { beforeAll(() => { @@ -90,3 +90,29 @@ describe('Creates good filter_form box', () => { expect(queryByLabelText(/Help: Inaczej rodzaje/i)).toBeInTheDocument(); }); }); + +describe('Pre-checks options configured as default-checked', () => { + beforeEach(() => { + httpService.getCategoriesData.mockResolvedValueOnce({ + categories, + defaultChecked: { types: ['shoes'] }, + }); + return act(() => + render( + + + , + ), + ); + }); + + it('renders the default-checked option as checked', () => { + const shoesCheckbox = document.querySelector('#shoes'); + expect(shoesCheckbox.checked).toBe(true); + }); + + it('leaves options not listed as default-checked unchecked', () => { + const clothesCheckbox = document.querySelector('#clothes'); + expect(clothesCheckbox.checked).toBe(false); + }); +}); diff --git a/goodmap/core_api.py b/goodmap/core_api.py index 2def128f..47b7fce0 100644 --- a/goodmap/core_api.py +++ b/goodmap/core_api.py @@ -440,12 +440,14 @@ def get_categories_full(): result = [] categories_options_help = categories_data.get("categories_options_help", {}) + categories_default_checked = categories_data.get("categories_default_checked", {}) for key, options in categories_data["categories"].items(): category_entry = { "key": key, "name": gettext(key), "options": make_tuple_translation(options), + "default_checked": categories_default_checked.get(key, []), } if CategoriesHelp in feature_flags: diff --git a/goodmap/db.py b/goodmap/db.py index 91aec3c5..3b745381 100644 --- a/goodmap/db.py +++ b/goodmap/db.py @@ -608,11 +608,17 @@ def json_db_get_category_data(self, category_type=None): "categories_options_help": { category_type: self.data.get("categories_options_help", {}).get(category_type, []) }, + "categories_default_checked": { + category_type: self.data.get("categories_default_checked", {}).get( + category_type, [] + ) + }, } return { "categories": self.data["categories"], "categories_help": self.data.get("categories_help", []), "categories_options_help": self.data.get("categories_options_help", {}), + "categories_default_checked": self.data.get("categories_default_checked", {}), } @@ -627,11 +633,15 @@ def json_file_db_get_category_data(self, category_type=None): "categories_options_help": { category_type: data.get("categories_options_help", {}).get(category_type, []) }, + "categories_default_checked": { + category_type: data.get("categories_default_checked", {}).get(category_type, []) + }, } return { "categories": data["categories"], "categories_help": data.get("categories_help", []), "categories_options_help": data.get("categories_options_help", {}), + "categories_default_checked": data.get("categories_default_checked", {}), } @@ -645,11 +655,15 @@ def google_json_db_get_category_data(self, category_type=None): "categories_options_help": { category_type: data.get("categories_options_help", {}).get(category_type, []) }, + "categories_default_checked": { + category_type: data.get("categories_default_checked", {}).get(category_type, []) + }, } return { "categories": data.get("categories", {}), "categories_help": data.get("categories_help", []), "categories_options_help": data.get("categories_options_help", {}), + "categories_default_checked": data.get("categories_default_checked", {}), } @@ -668,13 +682,24 @@ def mongodb_db_get_category_data(self, category_type=None): category_type, [] ) }, + "categories_default_checked": { + category_type: config_doc.get("categories_default_checked", {}).get( + category_type, [] + ) + }, } return { "categories": config_doc.get("categories", {}), "categories_help": config_doc.get("categories_help", []), "categories_options_help": config_doc.get("categories_options_help", {}), + "categories_default_checked": config_doc.get("categories_default_checked", {}), } - return {"categories": {}, "categories_help": [], "categories_options_help": {}} + return { + "categories": {}, + "categories_help": [], + "categories_options_help": {}, + "categories_default_checked": {}, + } def get_category_data(db): diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index a613ccd5..75da7aed 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -170,6 +170,42 @@ def test_categories_full_endpoint(test_app): assert category["options"][0] == ["test", "test-translated"] assert category["options"][1] == ["test2", "test2-translated"] + # No default-checked options configured for this category + assert category["default_checked"] == [] + + +@mock.patch("goodmap.core_api.gettext", fake_translation) +def test_categories_full_endpoint_with_default_checked(): + test_app = create_test_app( + db_overrides={ + "categories": {"test-category": ["opt1", "opt2"]}, + "categories_default_checked": {"test-category": ["opt1"]}, + } + ) + response = test_app.get("/api/categories-full") + assert response.status_code == 200 + data = response.json + assert data is not None + + category = data["categories"][0] + assert category["default_checked"] == ["opt1"] + + +@mock.patch("goodmap.core_api.gettext", fake_translation) +def test_categories_full_endpoint_without_default_checked(): + test_app = create_test_app( + db_overrides={ + "categories": {"test-category": ["opt1", "opt2"]}, + } + ) + response = test_app.get("/api/categories-full") + assert response.status_code == 200 + data = response.json + assert data is not None + + category = data["categories"][0] + assert category["default_checked"] == [] + @mock.patch("goodmap.core_api.gettext", fake_translation) def test_categories_full_endpoint_with_multiple_categories(): diff --git a/tests/unit_tests/test_db.py b/tests/unit_tests/test_db.py index 893e53d9..6243166d 100644 --- a/tests/unit_tests/test_db.py +++ b/tests/unit_tests/test_db.py @@ -112,6 +112,7 @@ "location_obligatory_fields": [["name", "str"]], "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } data_json = json.dumps({"map": data}) @@ -1422,6 +1423,7 @@ def test_json_db_get_category_data(): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1434,6 +1436,7 @@ def test_json_db_get_category_data_specific_category(): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1449,6 +1452,7 @@ def test_json_file_db_get_category_data(tmp_path): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1464,6 +1468,7 @@ def test_json_file_db_get_category_data_specific_category(tmp_path): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1480,6 +1485,7 @@ def test_google_json_db_get_category_data(mock_cli): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1496,6 +1502,7 @@ def test_google_json_db_get_category_data_specific_category(mock_cli): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1509,6 +1516,7 @@ def test_mongodb_db_get_category_data(mock_client): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } db = MongoDB("mongodb://localhost:27017", "test_db") @@ -1518,6 +1526,7 @@ def test_mongodb_db_get_category_data(mock_client): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1531,6 +1540,7 @@ def test_mongodb_db_get_category_data_specific_category(mock_client): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } db = MongoDB("mongodb://localhost:27017", "test_db") @@ -1540,6 +1550,7 @@ def test_mongodb_db_get_category_data_specific_category(mock_client): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected @@ -1553,7 +1564,12 @@ def test_mongodb_db_get_category_data_no_config(mock_client): db = MongoDB("mongodb://localhost:27017", "test_db") extend_db_with_goodmap_queries(db, LocationBase) category_data = mongodb_db_get_category_data(db) - expected = {"categories": {}, "categories_help": [], "categories_options_help": {}} + expected = { + "categories": {}, + "categories_help": [], + "categories_options_help": {}, + "categories_default_checked": {}, + } assert category_data == expected @@ -1565,6 +1581,7 @@ def test_get_category_data(): "categories": {"test-category": ["searchable", "unsearchable"]}, "categories_help": ["Help text for categories"], "categories_options_help": {"test-category": ["Help for test category"]}, + "categories_default_checked": {"test-category": ["searchable"]}, } assert category_data == expected From c521ece52c1f04e79605473320b70e34d6107e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 22 Jul 2026 11:09:28 +0200 Subject: [PATCH 02/12] fixes after reveiw --- README.md | 4 +++ docs/quickstart.rst | 40 ++++++++++++++++++++++++ e2e-tests/e2e_test_data_initial.json | 5 +++ e2e-tests/tests/basic/test_left_panel.py | 31 ++++++++++++++++++ examples/e2e_test_data.json | 5 +++ 5 files changed, 85 insertions(+) diff --git a/README.md b/README.md index 7babf065..cf49e563 100644 --- a/README.md +++ b/README.md @@ -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. +``` +"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", diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 3a77ae95..3fa7f54e 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -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_``. + +``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_``. + +``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 diff --git a/e2e-tests/e2e_test_data_initial.json b/e2e-tests/e2e_test_data_initial.json index 9aca44ce..37eead36 100644 --- a/e2e-tests/e2e_test_data_initial.json +++ b/e2e-tests/e2e_test_data_initial.json @@ -76,6 +76,11 @@ "pedestrians" ] }, + "categories_default_checked": { + "accessible_by": [ + "cars" + ] + }, "visible_data": [ "remark", "accessible_by", diff --git a/e2e-tests/tests/basic/test_left_panel.py b/e2e-tests/tests/basic/test_left_panel.py index 7042fd08..f9463e9c 100644 --- a/e2e-tests/tests/basic/test_left_panel.py +++ b/e2e-tests/tests/basic/test_left_panel.py @@ -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""" diff --git a/examples/e2e_test_data.json b/examples/e2e_test_data.json index 8be5b4f2..f435d0ff 100644 --- a/examples/e2e_test_data.json +++ b/examples/e2e_test_data.json @@ -72,6 +72,11 @@ "pedestrians" ] }, + "categories_default_checked": { + "accessible_by": [ + "cars" + ] + }, "visible_data": [ "remark", "accessible_by", From c9f082a2d2b5b184580a0a91f79bab2a5c1ab8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 22 Jul 2026 17:02:54 +0200 Subject: [PATCH 03/12] fix after review --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cf49e563..a2f7c349 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ 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. From 45fc8f298f2d6bde72036e8df6194a8416983298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 22 Jul 2026 17:26:27 +0200 Subject: [PATCH 04/12] fixes after review --- .../Categories/CategoriesContext.jsx | 15 ++++++--- .../components/FiltersForm/FiltersForm.jsx | 22 ++++++------- .../src/components/Map/components/Markers.jsx | 9 ++++-- frontend/src/services/http/httpService.js | 11 +++---- frontend/tests/Map/MapComponent.test.jsx | 31 +++++++++++++++++++ goodmap/core_api.py | 6 +++- tests/unit_tests/test_core_api.py | 17 ++++++++++ 7 files changed, 87 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/Categories/CategoriesContext.jsx b/frontend/src/components/Categories/CategoriesContext.jsx index 414e718e..c6918b0d 100644 --- a/frontend/src/components/Categories/CategoriesContext.jsx +++ b/frontend/src/components/Categories/CategoriesContext.jsx @@ -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 {children}; }; @@ -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); diff --git a/frontend/src/components/FiltersForm/FiltersForm.jsx b/frontend/src/components/FiltersForm/FiltersForm.jsx index 89afb7ca..4f9fe6be 100644 --- a/frontend/src/components/FiltersForm/FiltersForm.jsx +++ b/frontend/src/components/FiltersForm/FiltersForm.jsx @@ -158,7 +158,7 @@ const LoadingSkeleton = () => ( ); export const FiltersForm = () => { - const { categories: selectedFilters, setCategories } = useCategories(); + const { categories: selectedFilters, setCategories, setIsInitialized } = useCategories(); const [categoriesData, setCategoriesData] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -170,11 +170,7 @@ export const FiltersForm = () => { const newSelectedFilters = { ...prevSelectedFilters }; if (checked) { - if (newSelectedFilters[category]) { - newSelectedFilters[category].push(value); - } else { - newSelectedFilters[category] = [value]; - } + newSelectedFilters[category] = [...(newSelectedFilters[category] ?? []), value]; } else { newSelectedFilters[category] = newSelectedFilters[category].filter( filter => filter !== value, @@ -188,12 +184,16 @@ export const FiltersForm = () => { useEffect(() => { const fetchCategories = async () => { setIsLoading(true); - const { categories, defaultChecked } = await httpService.getCategoriesData(); - setCategoriesData(categories); - if (Object.keys(defaultChecked).length > 0) { - setCategories(defaultChecked); + try { + const { categories, defaultChecked } = await httpService.getCategoriesData(); + setCategoriesData(categories); + if (Object.keys(defaultChecked).length > 0) { + setCategories(defaultChecked); + } + } finally { + setIsInitialized(true); + setIsLoading(false); } - setIsLoading(false); }; fetchCategories(); }, []); diff --git a/frontend/src/components/Map/components/Markers.jsx b/frontend/src/components/Map/components/Markers.jsx index dd4c0eb7..2315faf5 100644 --- a/frontend/src/components/Map/components/Markers.jsx +++ b/frontend/src/components/Map/components/Markers.jsx @@ -44,11 +44,16 @@ const getMarkers = locations => { * @returns {React.ReactElement|Array} MarkerClusterGroup containing location markers, or empty array while loading */ export const Markers = ({ onLoadingChange = null }) => { - const { categories } = useCategories(); + const { categories, isInitialized } = useCategories(); const [markers, setMarkers] = useState([]); const [areMarkersLoaded, setAreMarkersLoaded] = useState(false); const map = useMap(); useEffect(() => { + // Wait until the initial filter state (including default-checked options) + // is known, so the first locations fetch is already filtered. + if (!isInitialized) { + return undefined; + } setAreMarkersLoaded(false); const fetchMarkers = async () => { @@ -87,7 +92,7 @@ export const Markers = ({ onLoadingChange = null }) => { return () => { setMarkers([]); }; - }, [categories]); + }, [categories, isInitialized]); useEffect(() => { const mapContainer = map.getContainer(); diff --git a/frontend/src/services/http/httpService.js b/frontend/src/services/http/httpService.js index 93a0c97c..974f928c 100644 --- a/frontend/src/services/http/httpService.js +++ b/frontend/src/services/http/httpService.js @@ -70,12 +70,11 @@ export const httpService = { return [categoryTuple, options]; }); - const defaultChecked = {}; - for (const category of response.categories) { - if (category.default_checked?.length) { - defaultChecked[category.key] = category.default_checked; - } - } + const defaultChecked = Object.fromEntries( + response.categories + .filter(category => category.default_checked?.length) + .map(category => [category.key, category.default_checked]), + ); return { categories, defaultChecked }; }, diff --git a/frontend/tests/Map/MapComponent.test.jsx b/frontend/tests/Map/MapComponent.test.jsx index e41abbf8..dc2c1e36 100644 --- a/frontend/tests/Map/MapComponent.test.jsx +++ b/frontend/tests/Map/MapComponent.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { render, act, screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MapComponent } from '../../src/components/Map/MapComponent'; +import { FiltersForm } from '../../src/components/FiltersForm/FiltersForm'; import { CategoriesProvider } from '../../src/components/Categories/CategoriesContext'; import { httpService } from '../../src/services/http/httpService'; @@ -51,4 +52,34 @@ describe('MapComponent', () => { it('renders without crashing', () => { expect(screen.getAllByRole('presentation').length).toBeGreaterThan(0); }); + + it('does not fetch locations before filter state is initialized', () => { + expect(httpService.getLocations).not.toHaveBeenCalled(); + }); +}); + +describe('MapComponent with FiltersForm', () => { + beforeAll(() => { + globalThis.FEATURE_FLAGS = {}; + }); + + it('fetches locations once, already filtered by default-checked options', async () => { + httpService.getLocations.mockClear(); + httpService.getCategoriesData.mockResolvedValueOnce({ + categories, + defaultChecked: { types: ['shoes'] }, + }); + + await act(async () => { + render( + + + + , + ); + }); + + expect(httpService.getLocations).toHaveBeenCalledTimes(1); + expect(httpService.getLocations).toHaveBeenCalledWith({ types: ['shoes'] }); + }); }); diff --git a/goodmap/core_api.py b/goodmap/core_api.py index 47b7fce0..13ddb360 100644 --- a/goodmap/core_api.py +++ b/goodmap/core_api.py @@ -447,7 +447,11 @@ def get_categories_full(): "key": key, "name": gettext(key), "options": make_tuple_translation(options), - "default_checked": categories_default_checked.get(key, []), + "default_checked": [ + option + for option in categories_default_checked.get(key, []) + if option in options + ], } if CategoriesHelp in feature_flags: diff --git a/tests/unit_tests/test_core_api.py b/tests/unit_tests/test_core_api.py index 75da7aed..cd090fa4 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -191,6 +191,23 @@ def test_categories_full_endpoint_with_default_checked(): assert category["default_checked"] == ["opt1"] +@mock.patch("goodmap.core_api.gettext", fake_translation) +def test_categories_full_endpoint_drops_default_checked_not_in_options(): + test_app = create_test_app( + db_overrides={ + "categories": {"test-category": ["opt1", "opt2"]}, + "categories_default_checked": {"test-category": ["opt1", "not-an-option"]}, + } + ) + response = test_app.get("/api/categories-full") + assert response.status_code == 200 + data = response.json + assert data is not None + + category = data["categories"][0] + assert category["default_checked"] == ["opt1"] + + @mock.patch("goodmap.core_api.gettext", fake_translation) def test_categories_full_endpoint_without_default_checked(): test_app = create_test_app( From 6f65d78e831159c21196b3081707edbe50fa5a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 22 Jul 2026 17:40:52 +0200 Subject: [PATCH 05/12] fixes linting --- frontend/tests/Map/MapComponent.test.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/tests/Map/MapComponent.test.jsx b/frontend/tests/Map/MapComponent.test.jsx index dc2c1e36..567f4f21 100644 --- a/frontend/tests/Map/MapComponent.test.jsx +++ b/frontend/tests/Map/MapComponent.test.jsx @@ -63,6 +63,7 @@ describe('MapComponent with FiltersForm', () => { globalThis.FEATURE_FLAGS = {}; }); + // eslint-disable-next-line es-x/no-async-functions -- needed to await the fetch chain it('fetches locations once, already filtered by default-checked options', async () => { httpService.getLocations.mockClear(); httpService.getCategoriesData.mockResolvedValueOnce({ @@ -70,6 +71,7 @@ describe('MapComponent with FiltersForm', () => { defaultChecked: { types: ['shoes'] }, }); + // eslint-disable-next-line es-x/no-async-functions -- async act flushes effects await act(async () => { render( From f032d04b388b5c5965b551f2c860579147f9b383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 22 Jul 2026 19:05:55 +0200 Subject: [PATCH 06/12] fix sonar --- frontend/tests/FiltersForm.test.jsx | 30 +++++++++++------------ frontend/tests/Map/MapComponent.test.jsx | 31 ++++++++++-------------- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/frontend/tests/FiltersForm.test.jsx b/frontend/tests/FiltersForm.test.jsx index d97d390c..d983365e 100644 --- a/frontend/tests/FiltersForm.test.jsx +++ b/frontend/tests/FiltersForm.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { render, act, within } from '@testing-library/react'; +import { render, waitFor, within } from '@testing-library/react'; import { FiltersForm } from '../src/components/FiltersForm/FiltersForm'; import { CategoriesProvider } from '../src/components/Categories/CategoriesContext'; import { httpService } from '../src/services/http/httpService'; @@ -25,16 +25,17 @@ describe('Creates good filter_form box', () => { beforeAll(() => { globalThis.FEATURE_FLAGS = { CATEGORIES_HELP: true }; }); - beforeEach(() => { + beforeEach(async () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ json: jest.fn().mockResolvedValue(categories), }); - return act(() => - render( - - - , - ), + render( + + + , + ); + await waitFor(() => + expect(document.querySelector('#filter-label-types-typy')).not.toBeNull(), ); }); @@ -92,18 +93,17 @@ describe('Creates good filter_form box', () => { }); describe('Pre-checks options configured as default-checked', () => { - beforeEach(() => { + beforeEach(async () => { httpService.getCategoriesData.mockResolvedValueOnce({ categories, defaultChecked: { types: ['shoes'] }, }); - return act(() => - render( - - - , - ), + render( + + + , ); + await waitFor(() => expect(document.querySelector('#shoes')).not.toBeNull()); }); it('renders the default-checked option as checked', () => { diff --git a/frontend/tests/Map/MapComponent.test.jsx b/frontend/tests/Map/MapComponent.test.jsx index 567f4f21..c8bb0320 100644 --- a/frontend/tests/Map/MapComponent.test.jsx +++ b/frontend/tests/Map/MapComponent.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, act, screen } from '@testing-library/react'; +import { render, waitFor, screen } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { MapComponent } from '../../src/components/Map/MapComponent'; import { FiltersForm } from '../../src/components/FiltersForm/FiltersForm'; @@ -40,12 +40,10 @@ describe('MapComponent', () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ json: jest.fn().mockResolvedValue(categories), }); - return act(() => - render( - - - , - ), + render( + + + , ); }); @@ -63,7 +61,7 @@ describe('MapComponent with FiltersForm', () => { globalThis.FEATURE_FLAGS = {}; }); - // eslint-disable-next-line es-x/no-async-functions -- needed to await the fetch chain + // eslint-disable-next-line es-x/no-async-functions -- needed to await waitFor it('fetches locations once, already filtered by default-checked options', async () => { httpService.getLocations.mockClear(); httpService.getCategoriesData.mockResolvedValueOnce({ @@ -71,17 +69,14 @@ describe('MapComponent with FiltersForm', () => { defaultChecked: { types: ['shoes'] }, }); - // eslint-disable-next-line es-x/no-async-functions -- async act flushes effects - await act(async () => { - render( - - - - , - ); - }); + render( + + + + , + ); - expect(httpService.getLocations).toHaveBeenCalledTimes(1); + await waitFor(() => expect(httpService.getLocations).toHaveBeenCalledTimes(1)); expect(httpService.getLocations).toHaveBeenCalledWith({ types: ['shoes'] }); }); }); From ac787f3ac3045f084d8dc9d999548cc919f4d893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Wed, 22 Jul 2026 19:47:19 +0200 Subject: [PATCH 07/12] fix after review --- .../components/FiltersForm/FiltersForm.jsx | 65 +++++++++++++++---- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/FiltersForm/FiltersForm.jsx b/frontend/src/components/FiltersForm/FiltersForm.jsx index 4f9fe6be..6528db09 100644 --- a/frontend/src/components/FiltersForm/FiltersForm.jsx +++ b/frontend/src/components/FiltersForm/FiltersForm.jsx @@ -134,6 +134,25 @@ const TooltipWrapper = styled.span` margin-left: auto; `; +const ErrorMessage = styled.p` + font-size: 14px; + margin-bottom: 12px; +`; + +const RetryButton = styled.button` + font-size: 14px; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.5); + background: transparent; + color: inherit; + cursor: pointer; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } +`; + /** * Filters form component that allows users to filter map locations by categories. * Fetches category data from the API and renders checkboxes for each filter option. @@ -161,6 +180,7 @@ export const FiltersForm = () => { const { categories: selectedFilters, setCategories, setIsInitialized } = useCategories(); const [categoriesData, setCategoriesData] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); const handleCheckboxChange = event => { const { value, checked } = event.target; @@ -181,20 +201,28 @@ export const FiltersForm = () => { }); }; - useEffect(() => { - const fetchCategories = async () => { - setIsLoading(true); - try { - const { categories, defaultChecked } = await httpService.getCategoriesData(); - setCategoriesData(categories); - if (Object.keys(defaultChecked).length > 0) { - setCategories(defaultChecked); - } - } finally { - setIsInitialized(true); - setIsLoading(false); + const fetchCategories = async () => { + setIsLoading(true); + setHasError(false); + try { + const { categories, defaultChecked } = await httpService.getCategoriesData(); + setCategoriesData(categories); + if (Object.keys(defaultChecked).length > 0) { + setCategories(defaultChecked); } - }; + setIsInitialized(true); + } catch (error) { + console.error('Failed to load categories:', error); + // Establish an explicit fallback (no filters) instead of silently + // signaling initialization with unknown/missing category data. + setCategoriesData([]); + setHasError(true); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { fetchCategories(); }, []); @@ -250,5 +278,16 @@ export const FiltersForm = () => { ); } + if (hasError) { + return ( +
+ Failed to load filters. + + Retry + +
+ ); + } + return
{sections}
; }; From f0fe35ec33e7d2e475b9b2ca507cfb7b6e91afd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Thu, 23 Jul 2026 09:40:04 +0200 Subject: [PATCH 08/12] fixes --- .../tests/basic/test_accessibility_table.py | 7 ++++++ e2e-tests/tests/basic/test_language.py | 6 ++++- e2e-tests/tests/basic/test_map.py | 12 ++++++--- e2e-tests/tests/basic/test_mobile_box.py | 6 ++++- e2e-tests/tests/basic/test_popup.py | 10 +++++++- e2e-tests/tests/basic/test_share.py | 25 ++++++++++++++++++- e2e-tests/tests/conftest.py | 24 ++++++++++++++++++ 7 files changed, 82 insertions(+), 8 deletions(-) diff --git a/e2e-tests/tests/basic/test_accessibility_table.py b/e2e-tests/tests/basic/test_accessibility_table.py index 662d17be..0c9255ea 100644 --- a/e2e-tests/tests/basic/test_accessibility_table.py +++ b/e2e-tests/tests/basic/test_accessibility_table.py @@ -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) diff --git a/e2e-tests/tests/basic/test_language.py b/e2e-tests/tests/basic/test_language.py index 62a30798..2fbf51fd 100644 --- a/e2e-tests/tests/basic/test_language.py +++ b/e2e-tests/tests/basic/test_language.py @@ -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_default_category_filters def get_language_button(page: Page): @@ -71,6 +71,10 @@ 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") + # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka + # (bikes/pedestrians only). Clear it so both seeded locations are visible. + clear_default_category_filters(page) + # Click marker cluster to expand page.locator(".leaflet-marker-icon").first.click() diff --git a/e2e-tests/tests/basic/test_map.py b/e2e-tests/tests/basic/test_map.py index 5d830e84..5a084286 100644 --- a/e2e-tests/tests/basic/test_map.py +++ b/e2e-tests/tests/basic/test_map.py @@ -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) @@ -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) diff --git a/e2e-tests/tests/basic/test_mobile_box.py b/e2e-tests/tests/basic/test_mobile_box.py index 034bb620..3e27616a 100644 --- a/e2e-tests/tests/basic/test_mobile_box.py +++ b/e2e-tests/tests/basic/test_mobile_box.py @@ -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_default_category_filters from tests.helpers import EXPECTED_PLACE_ZWIERZYNIECKA, verify_popup_content, verify_problem_form @@ -32,6 +32,10 @@ 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") + # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka + # (bikes/pedestrians only). Clear it so both seeded locations are visible. + clear_default_category_filters(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 diff --git a/e2e-tests/tests/basic/test_popup.py b/e2e-tests/tests/basic/test_popup.py index 8c2043d6..c1de5db9 100644 --- a/e2e-tests/tests/basic/test_popup.py +++ b/e2e-tests/tests/basic/test_popup.py @@ -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_default_category_filters from tests.helpers import EXPECTED_PLACE_ZWIERZYNIECKA, verify_popup_content, verify_problem_form @@ -22,6 +22,10 @@ def test_displays_popup_title_subtitle_categories_and_cta(self, page: Page, wind """ page.goto(BASE_URL, wait_until="domcontentloaded") + # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka + # (bikes/pedestrians only). Clear it so both seeded locations are visible. + clear_default_category_filters(page) + # Click first marker to trigger cluster expansion first_marker = page.locator(".leaflet-marker-icon").first first_marker.click() @@ -78,6 +82,10 @@ def test_problem_form_on_desktop(self, page: Page, window_open_stub): """ page.goto(BASE_URL, wait_until="domcontentloaded") + # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka + # (bikes/pedestrians only). Clear it so both seeded locations are visible. + clear_default_category_filters(page) + # Click first marker to trigger cluster expansion first_marker = page.locator(".leaflet-marker-icon").first first_marker.click() diff --git a/e2e-tests/tests/basic/test_share.py b/e2e-tests/tests/basic/test_share.py index d5123e0b..2bdf1097 100644 --- a/e2e-tests/tests/basic/test_share.py +++ b/e2e-tests/tests/basic/test_share.py @@ -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_default_category_filters, +) class TestShareOnDesktop: @@ -26,6 +31,10 @@ def test_share_button_copies_link_to_clipboard(self, page: Page): # Grant clipboard permissions page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka + # (bikes/pedestrians only). Clear it so both seeded locations are visible. + clear_default_category_filters(page) + # Click first marker to trigger cluster expansion first_marker = page.locator(".leaflet-marker-icon").first first_marker.click() @@ -82,6 +91,11 @@ def test_shared_link_opens_popup_with_correct_content(self, page: Page): wait_until="domcontentloaded", ) + # Zwierzyniecka (the shared location) isn't accessible by cars, so it's + # excluded by the "accessible_by: cars" default filter. Clear it so the + # marker renders and the pending shared-location popup can open. + clear_default_category_filters(page) + # Verify popup is visible popup = page.locator(".leaflet-popup-content") expect(popup).to_be_visible(timeout=MARKER_LOAD_TIMEOUT) @@ -115,6 +129,10 @@ def test_share_button_triggers_native_share(self, mobile_page: Page, device_name mobile_page.goto(BASE_URL, wait_until="domcontentloaded") + # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka + # (bikes/pedestrians only). Clear it so both seeded locations are visible. + clear_default_category_filters(mobile_page) + # Click first marker to expand cluster first_marker = mobile_page.locator(".leaflet-marker-icon").first first_marker.evaluate("el => el.click()") @@ -170,6 +188,11 @@ def test_shared_link_opens_popup_on_mobile(self, mobile_page: Page, device_name: wait_until="domcontentloaded", ) + # Zwierzyniecka (the shared location) isn't accessible by cars, so it's + # excluded by the "accessible_by: cars" default filter. Clear it so the + # marker renders and the pending shared-location popup can open. + clear_default_category_filters(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) diff --git a/e2e-tests/tests/conftest.py b/e2e-tests/tests/conftest.py index 377b4fe6..2de1681d 100644 --- a/e2e-tests/tests/conftest.py +++ b/e2e-tests/tests/conftest.py @@ -70,6 +70,30 @@ } +def clear_default_category_filters(page: Page) -> None: + """ + Uncheck the "cars" accessible_by filter that's checked by default (see + categories_default_checked in the e2e test data). Several tests rely on + both seeded locations (Grunwaldzki and Zwierzyniecka) being visible, + which requires clearing this default filter first. + + Opens the mobile filter panel (if collapsed) to reach the checkbox, and + closes it again afterwards so it doesn't obscure subsequent interactions. + """ + 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) + cars_checkbox = page.locator("#filter-form input#cars") + if cars_checkbox.is_checked(): + cars_checkbox.uncheck() + + if opened_dialog: + page.locator('button[aria-label="Close left panel"]').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()) From e5e6205b8256a585717ad69093c560548c1c4887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 24 Jul 2026 16:53:58 +0200 Subject: [PATCH 09/12] fix for test --- e2e-tests/tests/conftest.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/e2e-tests/tests/conftest.py b/e2e-tests/tests/conftest.py index 2de1681d..6d61f598 100644 --- a/e2e-tests/tests/conftest.py +++ b/e2e-tests/tests/conftest.py @@ -79,6 +79,13 @@ def clear_default_category_filters(page: Page) -> None: Opens the mobile filter panel (if collapsed) to reach the checkbox, and closes it again afterwards so it doesn't obscure subsequent interactions. + + Unchecking the filter 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() @@ -91,7 +98,7 @@ def clear_default_category_filters(page: Page) -> None: cars_checkbox.uncheck() if opened_dialog: - page.locator('button[aria-label="Close left panel"]').click() + page.locator('button[aria-label="Close left panel"]').evaluate("el => el.click()") def _block_hmr(page: Page) -> None: From 058e279a3d11e8ed517b397bf67a71db7b1de870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 24 Jul 2026 18:02:55 +0200 Subject: [PATCH 10/12] cleaned up comments --- e2e-tests/tests/basic/test_language.py | 2 -- e2e-tests/tests/basic/test_mobile_box.py | 2 -- e2e-tests/tests/basic/test_popup.py | 4 ---- e2e-tests/tests/basic/test_share.py | 10 ---------- 4 files changed, 18 deletions(-) diff --git a/e2e-tests/tests/basic/test_language.py b/e2e-tests/tests/basic/test_language.py index 2fbf51fd..49db98ff 100644 --- a/e2e-tests/tests/basic/test_language.py +++ b/e2e-tests/tests/basic/test_language.py @@ -71,8 +71,6 @@ 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") - # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka - # (bikes/pedestrians only). Clear it so both seeded locations are visible. clear_default_category_filters(page) # Click marker cluster to expand diff --git a/e2e-tests/tests/basic/test_mobile_box.py b/e2e-tests/tests/basic/test_mobile_box.py index 3e27616a..1ef837b6 100644 --- a/e2e-tests/tests/basic/test_mobile_box.py +++ b/e2e-tests/tests/basic/test_mobile_box.py @@ -32,8 +32,6 @@ 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") - # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka - # (bikes/pedestrians only). Clear it so both seeded locations are visible. clear_default_category_filters(mobile_page) # Click first marker to expand cluster diff --git a/e2e-tests/tests/basic/test_popup.py b/e2e-tests/tests/basic/test_popup.py index c1de5db9..06ce55a1 100644 --- a/e2e-tests/tests/basic/test_popup.py +++ b/e2e-tests/tests/basic/test_popup.py @@ -22,8 +22,6 @@ def test_displays_popup_title_subtitle_categories_and_cta(self, page: Page, wind """ page.goto(BASE_URL, wait_until="domcontentloaded") - # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka - # (bikes/pedestrians only). Clear it so both seeded locations are visible. clear_default_category_filters(page) # Click first marker to trigger cluster expansion @@ -82,8 +80,6 @@ def test_problem_form_on_desktop(self, page: Page, window_open_stub): """ page.goto(BASE_URL, wait_until="domcontentloaded") - # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka - # (bikes/pedestrians only). Clear it so both seeded locations are visible. clear_default_category_filters(page) # Click first marker to trigger cluster expansion diff --git a/e2e-tests/tests/basic/test_share.py b/e2e-tests/tests/basic/test_share.py index 2bdf1097..57f18ace 100644 --- a/e2e-tests/tests/basic/test_share.py +++ b/e2e-tests/tests/basic/test_share.py @@ -31,8 +31,6 @@ def test_share_button_copies_link_to_clipboard(self, page: Page): # Grant clipboard permissions page.context.grant_permissions(["clipboard-read", "clipboard-write"]) - # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka - # (bikes/pedestrians only). Clear it so both seeded locations are visible. clear_default_category_filters(page) # Click first marker to trigger cluster expansion @@ -91,9 +89,6 @@ def test_shared_link_opens_popup_with_correct_content(self, page: Page): wait_until="domcontentloaded", ) - # Zwierzyniecka (the shared location) isn't accessible by cars, so it's - # excluded by the "accessible_by: cars" default filter. Clear it so the - # marker renders and the pending shared-location popup can open. clear_default_category_filters(page) # Verify popup is visible @@ -129,8 +124,6 @@ def test_share_button_triggers_native_share(self, mobile_page: Page, device_name mobile_page.goto(BASE_URL, wait_until="domcontentloaded") - # "accessible_by: cars" is checked by default, which excludes Zwierzyniecka - # (bikes/pedestrians only). Clear it so both seeded locations are visible. clear_default_category_filters(mobile_page) # Click first marker to expand cluster @@ -188,9 +181,6 @@ def test_shared_link_opens_popup_on_mobile(self, mobile_page: Page, device_name: wait_until="domcontentloaded", ) - # Zwierzyniecka (the shared location) isn't accessible by cars, so it's - # excluded by the "accessible_by: cars" default filter. Clear it so the - # marker renders and the pending shared-location popup can open. clear_default_category_filters(mobile_page) # On mobile, popup appears as Material-UI Dialog From 8f12c9040119fa6d5040b50dc9ae1525605694b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 24 Jul 2026 21:33:08 +0200 Subject: [PATCH 11/12] added clear filters button --- e2e-tests/tests/basic/test_language.py | 4 +-- e2e-tests/tests/basic/test_mobile_box.py | 4 +-- e2e-tests/tests/basic/test_popup.py | 6 ++-- e2e-tests/tests/basic/test_share.py | 10 +++--- e2e-tests/tests/conftest.py | 19 +++++------ .../components/FiltersForm/FiltersForm.jsx | 33 ++++++++++++++++++- 6 files changed, 53 insertions(+), 23 deletions(-) diff --git a/e2e-tests/tests/basic/test_language.py b/e2e-tests/tests/basic/test_language.py index 49db98ff..f536a4c6 100644 --- a/e2e-tests/tests/basic/test_language.py +++ b/e2e-tests/tests/basic/test_language.py @@ -10,7 +10,7 @@ import pytest from playwright.sync_api import Page, expect -from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, clear_default_category_filters +from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, clear_all_checkboxes def get_language_button(page: Page): @@ -71,7 +71,7 @@ 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_default_category_filters(page) + clear_all_checkboxes(page) # Click marker cluster to expand page.locator(".leaflet-marker-icon").first.click() diff --git a/e2e-tests/tests/basic/test_mobile_box.py b/e2e-tests/tests/basic/test_mobile_box.py index 1ef837b6..9c568efc 100644 --- a/e2e-tests/tests/basic/test_mobile_box.py +++ b/e2e-tests/tests/basic/test_mobile_box.py @@ -9,7 +9,7 @@ import pytest from playwright.sync_api import Page, expect -from tests.conftest import ALL_MOBILE_DEVICES, BASE_URL, clear_default_category_filters +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 @@ -32,7 +32,7 @@ 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_default_category_filters(mobile_page) + clear_all_checkboxes(mobile_page) # Click first marker to expand cluster # Use JavaScript click to bypass webpack overlay that may intercept clicks on CI diff --git a/e2e-tests/tests/basic/test_popup.py b/e2e-tests/tests/basic/test_popup.py index 06ce55a1..cafb4b0f 100644 --- a/e2e-tests/tests/basic/test_popup.py +++ b/e2e-tests/tests/basic/test_popup.py @@ -6,7 +6,7 @@ from playwright.sync_api import Page, expect -from tests.conftest import BASE_URL, MARKER_LOAD_TIMEOUT, clear_default_category_filters +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 @@ -22,7 +22,7 @@ def test_displays_popup_title_subtitle_categories_and_cta(self, page: Page, wind """ page.goto(BASE_URL, wait_until="domcontentloaded") - clear_default_category_filters(page) + clear_all_checkboxes(page) # Click first marker to trigger cluster expansion first_marker = page.locator(".leaflet-marker-icon").first @@ -80,7 +80,7 @@ def test_problem_form_on_desktop(self, page: Page, window_open_stub): """ page.goto(BASE_URL, wait_until="domcontentloaded") - clear_default_category_filters(page) + clear_all_checkboxes(page) # Click first marker to trigger cluster expansion first_marker = page.locator(".leaflet-marker-icon").first diff --git a/e2e-tests/tests/basic/test_share.py b/e2e-tests/tests/basic/test_share.py index 57f18ace..43911107 100644 --- a/e2e-tests/tests/basic/test_share.py +++ b/e2e-tests/tests/basic/test_share.py @@ -14,7 +14,7 @@ ALL_MOBILE_DEVICES, BASE_URL, MARKER_LOAD_TIMEOUT, - clear_default_category_filters, + clear_all_checkboxes, ) @@ -31,7 +31,7 @@ def test_share_button_copies_link_to_clipboard(self, page: Page): # Grant clipboard permissions page.context.grant_permissions(["clipboard-read", "clipboard-write"]) - clear_default_category_filters(page) + clear_all_checkboxes(page) # Click first marker to trigger cluster expansion first_marker = page.locator(".leaflet-marker-icon").first @@ -89,7 +89,7 @@ def test_shared_link_opens_popup_with_correct_content(self, page: Page): wait_until="domcontentloaded", ) - clear_default_category_filters(page) + clear_all_checkboxes(page) # Verify popup is visible popup = page.locator(".leaflet-popup-content") @@ -124,7 +124,7 @@ def test_share_button_triggers_native_share(self, mobile_page: Page, device_name mobile_page.goto(BASE_URL, wait_until="domcontentloaded") - clear_default_category_filters(mobile_page) + clear_all_checkboxes(mobile_page) # Click first marker to expand cluster first_marker = mobile_page.locator(".leaflet-marker-icon").first @@ -181,7 +181,7 @@ def test_shared_link_opens_popup_on_mobile(self, mobile_page: Page, device_name: wait_until="domcontentloaded", ) - clear_default_category_filters(mobile_page) + clear_all_checkboxes(mobile_page) # On mobile, popup appears as Material-UI Dialog dialog_content = mobile_page.locator(".MuiDialogContent-root") diff --git a/e2e-tests/tests/conftest.py b/e2e-tests/tests/conftest.py index 6d61f598..a06f855e 100644 --- a/e2e-tests/tests/conftest.py +++ b/e2e-tests/tests/conftest.py @@ -70,17 +70,18 @@ } -def clear_default_category_filters(page: Page) -> None: +def clear_all_checkboxes(page: Page) -> None: """ - Uncheck the "cars" accessible_by filter that's checked by default (see - categories_default_checked in the e2e test data). Several tests rely on - both seeded locations (Grunwaldzki and Zwierzyniecka) being visible, - which requires clearing this default filter first. + Click the "Clear filters" button to reset all selected category filters. - Opens the mobile filter panel (if collapsed) to reach the checkbox, and + 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. - Unchecking the filter can trigger a marker refetch that mounts a location + 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 @@ -93,9 +94,7 @@ def clear_default_category_filters(page: Page) -> None: toggle_button.click() page.wait_for_selector("#filter-form", timeout=MARKER_LOAD_TIMEOUT) - cars_checkbox = page.locator("#filter-form input#cars") - if cars_checkbox.is_checked(): - cars_checkbox.uncheck() + 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()") diff --git a/frontend/src/components/FiltersForm/FiltersForm.jsx b/frontend/src/components/FiltersForm/FiltersForm.jsx index 6528db09..352992c4 100644 --- a/frontend/src/components/FiltersForm/FiltersForm.jsx +++ b/frontend/src/components/FiltersForm/FiltersForm.jsx @@ -153,6 +153,22 @@ const RetryButton = styled.button` } `; +const ClearFiltersButton = styled.button` + font-size: 14px; + padding: 8px 12px; + margin-top: 8px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.5); + background: transparent; + color: inherit; + cursor: pointer; + width: 100%; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } +`; + /** * Filters form component that allows users to filter map locations by categories. * Fetches category data from the API and renders checkboxes for each filter option. @@ -201,6 +217,10 @@ export const FiltersForm = () => { }); }; + const handleClearFilters = () => { + setCategories({}); + }; + const fetchCategories = async () => { setIsLoading(true); setHasError(false); @@ -289,5 +309,16 @@ export const FiltersForm = () => { ); } - return
{sections}
; + return ( +
+ {sections} + + Clear filters + +
+ ); }; From 52197c15aa4f8ba4139dfe71e0602b0fc4c3488c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Ko=C5=82odzi=C5=84ski?= Date: Fri, 24 Jul 2026 22:10:02 +0200 Subject: [PATCH 12/12] try to get video --- e2e-tests/tests/conftest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/e2e-tests/tests/conftest.py b/e2e-tests/tests/conftest.py index a06f855e..8c08eb18 100644 --- a/e2e-tests/tests/conftest.py +++ b/e2e-tests/tests/conftest.py @@ -143,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. @@ -153,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 @@ -166,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,