diff --git a/README.md b/README.md index 7babf065..a2f7c349 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. +```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", 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_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_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/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()) 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", 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 c4124ee2..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. @@ -158,9 +177,10 @@ const LoadingSkeleton = () => ( ); export const FiltersForm = () => { - const { setCategories } = useCategories(); + 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; @@ -170,11 +190,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, @@ -185,13 +201,28 @@ export const FiltersForm = () => { }); }; - useEffect(() => { - const fetchCategories = async () => { - setIsLoading(true); - const categoriesData = await httpService.getCategoriesData(); - setCategoriesData(categoriesData); + 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(); }, []); @@ -208,6 +239,7 @@ export const FiltersForm = () => { type="checkbox" id={name} value={name} + checked={Boolean(selectedFilters[category]?.includes(name))} /> {translation} {tooltipData && ( @@ -246,5 +278,16 @@ export const FiltersForm = () => { ); } + if (hasError) { + return ( +
+ Failed to load filters. + + Retry + +
+ ); + } + return
{sections}
; }; 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 678a90c5..974f928c 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,14 @@ export const httpService = { } return [categoryTuple, options]; }); + + 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/FiltersForm.test.jsx b/frontend/tests/FiltersForm.test.jsx index cc4c2261..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'; @@ -19,22 +19,23 @@ const categories = [ ], ]; -httpService.getCategoriesData.mockResolvedValue(categories); +httpService.getCategoriesData.mockResolvedValue({ categories, defaultChecked: {} }); 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(), ); }); @@ -90,3 +91,28 @@ describe('Creates good filter_form box', () => { expect(queryByLabelText(/Help: Inaczej rodzaje/i)).toBeInTheDocument(); }); }); + +describe('Pre-checks options configured as default-checked', () => { + beforeEach(async () => { + httpService.getCategoriesData.mockResolvedValueOnce({ + categories, + defaultChecked: { types: ['shoes'] }, + }); + render( + + + , + ); + await waitFor(() => expect(document.querySelector('#shoes')).not.toBeNull()); + }); + + 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/frontend/tests/Map/MapComponent.test.jsx b/frontend/tests/Map/MapComponent.test.jsx index e41abbf8..c8bb0320 100644 --- a/frontend/tests/Map/MapComponent.test.jsx +++ b/frontend/tests/Map/MapComponent.test.jsx @@ -1,7 +1,8 @@ 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'; import { CategoriesProvider } from '../../src/components/Categories/CategoriesContext'; import { httpService } from '../../src/services/http/httpService'; @@ -39,16 +40,43 @@ describe('MapComponent', () => { jest.spyOn(globalThis, 'fetch').mockResolvedValue({ json: jest.fn().mockResolvedValue(categories), }); - return act(() => - render( - - - , - ), + render( + + + , ); }); 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 = {}; + }); + + // 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({ + categories, + defaultChecked: { types: ['shoes'] }, + }); + + render( + + + + , + ); + + await waitFor(() => expect(httpService.getLocations).toHaveBeenCalledTimes(1)); + expect(httpService.getLocations).toHaveBeenCalledWith({ types: ['shoes'] }); + }); }); diff --git a/goodmap/core_api.py b/goodmap/core_api.py index 2def128f..13ddb360 100644 --- a/goodmap/core_api.py +++ b/goodmap/core_api.py @@ -440,12 +440,18 @@ 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": [ + option + for option in categories_default_checked.get(key, []) + if option in options + ], } 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..cd090fa4 100644 --- a/tests/unit_tests/test_core_api.py +++ b/tests/unit_tests/test_core_api.py @@ -170,6 +170,59 @@ 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_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( + 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