diff --git a/docs/http-api.rst b/docs/http-api.rst index 215558f3..49023dd6 100644 --- a/docs/http-api.rst +++ b/docs/http-api.rst @@ -211,10 +211,14 @@ buttons for ``exclusive``/``threshold``, a single checkbox for ``boolean`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ What this instance accepts for a new point: the fields of its location model (all of them -except the server-assigned ``uuid``), the allowed values per category, the reportable -issue types, and the photo limits. This is how a client learns what to put in -``/api/suggest-new-point``'s ``location`` payload rather than assuming — it is the same -schema the built-in suggest form is generated from. +except the server-assigned ``uuid``), the reportable issue types, and the photo limits. +This is how a client learns what to put in ``/api/suggest-new-point``'s ``location`` +payload rather than assuming — it is the same schema the built-in suggest form is +generated from. + +A field's own allowed values are part of its schema, reported as ``enum_items``. For the +same values with translated labels, ready to render a filter panel, use +``/api/categories-full`` above. ``GET /api/languages`` ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 26e4203e..b9d45efa 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -4,7 +4,8 @@ "plugin:react/recommended", "plugin:prettier/recommended", "airbnb", - "prettier"], + "prettier", + "plugin:react-hooks/recommended"], "plugins": ["react", "prettier", "eslint-plugin-react", "eslint-plugin-react-hooks"], "parser": "@babel/eslint-parser", "parserOptions": { diff --git a/frontend/src/components/Categories/CategoriesContext.jsx b/frontend/src/components/Categories/CategoriesContext.jsx deleted file mode 100644 index 624bbce4..00000000 --- a/frontend/src/components/Categories/CategoriesContext.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { useState, useContext, createContext, useMemo, useEffect, useCallback } from 'react'; -import PropTypes from 'prop-types'; -import httpService from '../../services/http/httpService'; - -/** - * React Context for managing categories state across the application. - * Provides categories data and setter function to all child components. - */ -const CategoriesContext = createContext(); -CategoriesContext.displayName = 'CategoriesContext'; - -/** - * Provider component that wraps the application to provide categories context. - * Fetches the category definitions once and shares them, alongside the currently - * selected filter values, with every child component. - * - * @param {Object} props - Component props - * @param {React.ReactNode} props.children - Child components that will have access to categories context - * @returns {React.ReactElement} Context provider with categories state - */ -export const CategoriesProvider = ({ children }) => { - const [categories, setCategories] = useState({}); - const [isInitialized, setIsInitialized] = useState(false); - const [categoriesData, setCategoriesData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); - - const fetchCategories = useCallback(async () => { - setIsLoading(true); - setHasError(false); - try { - const { categories: fetchedCategories, defaultChecked } = - await httpService.getCategoriesData(); - setCategoriesData(fetchedCategories); - 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(); - }, [fetchCategories]); - - const value = useMemo( - () => ({ - categories, - setCategories, - isInitialized, - setIsInitialized, - categoriesData, - isLoading, - hasError, - refetchCategories: fetchCategories, - }), - [categories, isInitialized, categoriesData, isLoading, hasError, fetchCategories], - ); - - return {children}; -}; - -CategoriesProvider.propTypes = { - children: PropTypes.node.isRequired, -}; - -/** - * Custom hook to access categories context. - * Must be used within a CategoriesProvider component. - * - * @throws {Error} If used outside of CategoriesProvider - * @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 - * @returns {Array} return.categoriesData - Category definitions fetched from the backend - * @returns {boolean} return.isLoading - True while the category definitions are in flight - * @returns {boolean} return.hasError - True if fetching the category definitions failed - * @returns {Function} return.refetchCategories - Retries the category definitions fetch - */ -export const useCategories = () => { - const context = useContext(CategoriesContext); - if (!context) { - throw new Error('useCategories must be used within a CategoriesProvider'); - } - return context; -}; diff --git a/frontend/src/components/FiltersForm/FiltersForm.jsx b/frontend/src/components/FiltersForm/FiltersForm.jsx index 3b9ab257..9ef89f1e 100644 --- a/frontend/src/components/FiltersForm/FiltersForm.jsx +++ b/frontend/src/components/FiltersForm/FiltersForm.jsx @@ -2,7 +2,8 @@ import React from 'react'; import styled, { keyframes } from 'styled-components'; import { useTranslation } from 'react-i18next'; import { Tooltip } from '@mui/material'; -import { useCategories } from '../Categories/CategoriesContext'; +import { useDeploymentData } from '../../context/DeploymentDataContext'; +import { useFilters } from '../../context/FiltersContext'; import FiltersTooltip from './FiltersTooltip'; const shimmer = keyframes` @@ -241,20 +242,15 @@ const LoadingSkeleton = () => ( const FiltersForm = () => { const { t } = useTranslation(); - const { - categories: selectedFilters, - setCategories, - categoriesData, - isLoading, - hasError, - refetchCategories, - } = useCategories(); + const { categoriesData, categoriesLoading, categoriesError, refetchCategories } = + useDeploymentData(); + const { selectedFilters, setSelectedFilters } = useFilters(); const handleCheckboxChange = event => { const { value, checked } = event.target; const { category } = event.target.dataset; - setCategories(prevSelectedFilters => { + setSelectedFilters(prevSelectedFilters => { const newSelectedFilters = { ...prevSelectedFilters }; if (checked) { @@ -275,14 +271,14 @@ const FiltersForm = () => { const { value } = event.target; const { category } = event.target.dataset; - setCategories(prevSelectedFilters => ({ + setSelectedFilters(prevSelectedFilters => ({ ...prevSelectedFilters, [category]: [value], })); }; const handleClearFilters = () => { - setCategories({}); + setSelectedFilters({}); }; const renderModeBadge = mode => { @@ -423,7 +419,7 @@ const FiltersForm = () => { ); } - if (isLoading) { + if (categoriesLoading) { return (
@@ -431,7 +427,7 @@ const FiltersForm = () => { ); } - if (hasError) { + if (categoriesError) { return ( {t('loadFiltersError')} diff --git a/frontend/src/components/Map/Map.jsx b/frontend/src/components/Map/Map.jsx index ba009829..34b353e2 100644 --- a/frontend/src/components/Map/Map.jsx +++ b/frontend/src/components/Map/Map.jsx @@ -3,13 +3,16 @@ import React from 'react'; import { createPortal } from 'react-dom'; import FiltersForm from '../FiltersForm/FiltersForm'; import MapComponent from './MapComponent'; -import { CategoriesProvider } from '../Categories/CategoriesContext'; +import { DeploymentDataProvider } from '../../context/DeploymentDataContext'; +import { FiltersProvider } from '../../context/FiltersContext'; import AppToaster from '../common/AppToaster'; /** * Wrapper component that renders the map and filters form into their respective DOM placeholders. * Uses React portals to render components into pre-existing DOM elements outside the React tree. - * Wraps both components with CategoriesProvider for shared filter state management. + * Wraps both components with DeploymentDataProvider, which fetches this deployment's + * fixed data (category definitions and the new-point schema) once for every consumer, + * and with FiltersProvider, which owns the one thing that changes as the app runs. * * @returns {React.ReactElement|null} Portals for FiltersForm and MapComponent, or null if placeholders not found */ @@ -23,11 +26,13 @@ const MapWrap = () => { } return ( - - - {createPortal(, filtersPlaceholder)} - {createPortal(, mapPlaceholder)} - + + + + {createPortal(, filtersPlaceholder)} + {createPortal(, mapPlaceholder)} + + ); }; diff --git a/frontend/src/components/Map/components/AccessibilityTable.jsx b/frontend/src/components/Map/components/AccessibilityTable.jsx index 3961669f..062e02e1 100644 --- a/frontend/src/components/Map/components/AccessibilityTable.jsx +++ b/frontend/src/components/Map/components/AccessibilityTable.jsx @@ -12,7 +12,7 @@ import { IconButton } from '@mui/material'; import PropTypes from 'prop-types'; import httpService from '../../../services/http/httpService'; import FieldRenderer from '../../MarkerPopup/FieldRenderer'; -import { useCategories } from '../../Categories/CategoriesContext'; +import { useFilters } from '../../../context/FiltersContext'; /** * Accessibility table component that displays location data in a tabular format. @@ -28,7 +28,7 @@ import { useCategories } from '../../Categories/CategoriesContext'; * @returns {React.ReactElement} Table container with location data and back button */ const AccessibilityTable = ({ userPosition, setIsAccessibilityTableOpen }) => { - const { categories } = useCategories(); + const { selectedFilters } = useFilters(); const { t } = useTranslation(); const [data, setData] = useState(null); @@ -37,11 +37,11 @@ const AccessibilityTable = ({ userPosition, setIsAccessibilityTableOpen }) => { useEffect(() => { httpService - .getLocationsData(userPosition.lat, userPosition.lng, categories) + .getLocationsData(userPosition.lat, userPosition.lng, selectedFilters) .then(places => { setData(places); }); - }, [categories, userPosition]); + }, [selectedFilters, userPosition]); useEffect(() => { if (!data) { diff --git a/frontend/src/components/Map/components/Markers.jsx b/frontend/src/components/Map/components/Markers.jsx index 038df6e5..a3dd3216 100644 --- a/frontend/src/components/Map/components/Markers.jsx +++ b/frontend/src/components/Map/components/Markers.jsx @@ -4,7 +4,7 @@ import { useMap } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-cluster'; import httpService from '../../../services/http/httpService'; import MarkerPopup from '../../MarkerPopup/MarkerPopup'; -import { useCategories } from '../../Categories/CategoriesContext'; +import { useFilters } from '../../../context/FiltersContext'; import ClusterMarker from '../../MarkerPopup/ClusterMarker'; /** @@ -44,7 +44,7 @@ const getMarkers = locations => { * @returns {React.ReactElement|Array} MarkerClusterGroup containing location markers, or empty array while loading */ const Markers = ({ onLoadingChange = null }) => { - const { categories, isInitialized } = useCategories(); + const { selectedFilters, isInitialized } = useFilters(); const [markers, setMarkers] = useState([]); const [areMarkersLoaded, setAreMarkersLoaded] = useState(false); const map = useMap(); @@ -59,7 +59,7 @@ const Markers = ({ onLoadingChange = null }) => { const fetchMarkers = async () => { let locations; try { - locations = await httpService.getLocations(categories); + locations = await httpService.getLocations(selectedFilters); } catch (error) { console.error('Failed to load locations:', error); setMarkers([]); @@ -100,7 +100,7 @@ const Markers = ({ onLoadingChange = null }) => { return () => { setMarkers([]); }; - }, [categories, isInitialized]); + }, [selectedFilters, isInitialized]); useEffect(() => { const mapContainer = map.getContainer(); diff --git a/frontend/src/components/Map/components/SuggestNewPointDialog.jsx b/frontend/src/components/Map/components/SuggestNewPointDialog.jsx index 47a7e41f..54f1d5b6 100644 --- a/frontend/src/components/Map/components/SuggestNewPointDialog.jsx +++ b/frontend/src/components/Map/components/SuggestNewPointDialog.jsx @@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next'; import imageCompression from 'browser-image-compression'; import getCsrfToken from '../../../utils/csrf'; import { useLocation } from '../context/LocationContext'; -import { useCategories } from '../../Categories/CategoriesContext'; +import { useDeploymentData } from '../../../context/DeploymentDataContext'; import toast from '../../../utils/toast'; // Map a category's options to a { key: translation } object. @@ -52,10 +52,7 @@ const buildCategoryTranslations = categoriesData => { categoriesData.forEach(({ categoryKey, categoryName, options: categoryOptions }) => { fieldNames[categoryKey] = categoryName; - - if (categoryOptions?.length) { - options[categoryKey] = mapCategoryOptions(categoryOptions); - } + options[categoryKey] = mapCategoryOptions(categoryOptions ?? []); }); return { fieldNames, options }; @@ -107,17 +104,19 @@ const useScrollToTop = trigger => { }; /** - * Dialog form for suggesting a new map point. Fields are generated dynamically from - * window.LOCATION_SCHEMA and include the user's position, an optional photo, and every - * obligatory location attribute defined by the backend. + * The dialog and its schema-driven form, mounted by SuggestNewPointDialog only once + * the location schema has loaded. * - * @param {{open: boolean, onClose: () => void}} props + * Its inputs are generated from the schema's obligatory_fields, so mounting earlier + * would seed a form with no fields and force a rebuild when the schema arrived. + * + * @param {{open: boolean, onClose: () => void, locationSchema: Object}} props * @returns {React.ReactElement} Dialog with the new point suggestion form */ -const SuggestNewPointDialog = ({ open, onClose }) => { +const SuggestNewPointForm = ({ open, onClose, locationSchema }) => { const { t } = useTranslation(); const { userPosition, requestLocationWithFeedback } = useLocation(); - const { categoriesData } = useCategories(); + const { categoriesData } = useDeploymentData(); const [photo, setPhoto] = useState(null); const [photoURL, setPhotoURL] = useState(null); // Shown inline in the dialog: a toast here can end up behind it. @@ -140,10 +139,6 @@ const SuggestNewPointDialog = ({ open, onClose }) => { } }, [open]); - const locationSchema = globalThis.LOCATION_SCHEMA || { - obligatory_fields: [], - categories: {}, - }; const { allowed_extensions: allowedPhotoExtensions = [], allowed_mime_types: allowedPhotoMimeTypes = [], @@ -297,8 +292,11 @@ const SuggestNewPointDialog = ({ open, onClose }) => { // Render form field based on field type and whether it's a category const renderFormField = (fieldName, fieldType) => { - const isCategory = fieldName in locationSchema.categories; - const categoryOptions = isCategory ? locationSchema.categories[fieldName] : []; + // Values and labels both come from the category definitions, so an option can + // never render with a label this component has no entry for. + const optionLabels = categoryTranslations.options[fieldName]; + const isCategory = Boolean(optionLabels); + const categoryOptions = isCategory ? Object.keys(optionLabels) : []; const fieldLabel = getFieldLabel(fieldName); if (fieldType === 'list' && isCategory) { @@ -309,7 +307,7 @@ const SuggestNewPointDialog = ({ open, onClose }) => { @@ -350,7 +348,7 @@ const SuggestNewPointDialog = ({ open, onClose }) => { { ); }; +SuggestNewPointForm.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + // Only the parts this form reads; the schema itself carries more. + locationSchema: PropTypes.shape({ + obligatory_fields: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + photo: PropTypes.shape({ + allowed_extensions: PropTypes.arrayOf(PropTypes.string), + allowed_mime_types: PropTypes.arrayOf(PropTypes.string), + max_size_bytes: PropTypes.number, + }), + }).isRequired, +}; + +/** + * Dialog for suggesting a new map point. + * + * The form's fields are generated from the deployment's location schema, so there is + * nothing to render until that has arrived - holding the form back until then is what + * lets it build its fields once, instead of rebuilding them when the schema lands. If + * the schema could not be fetched at all, this says so instead of showing a form that + * has no fields to fill and could never be submitted. + * + * @param {{open: boolean, onClose: () => void}} props + * @returns {React.ReactElement|null} The form, a retry prompt, or null while it loads + */ +const SuggestNewPointDialog = ({ open, onClose }) => { + const { t } = useTranslation(); + const { locationSchema, schemaError, refetchLocationSchema } = useDeploymentData(); + + // Without the schema there are no fields to fill, so offering the form anyway would + // only produce a submission the backend is bound to reject. Say so and offer a retry. + if (schemaError) { + return ( + + {t('suggestNewPointDialogTitle')} + + {t('loadSuggestFormError')} + + + + + + + ); + } + + if (!locationSchema) { + return null; + } + + return ; +}; + SuggestNewPointDialog.propTypes = { open: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, diff --git a/frontend/src/components/MarkerPopup/ClusterMarker.jsx b/frontend/src/components/MarkerPopup/ClusterMarker.jsx index d247c69a..bc0b112f 100644 --- a/frontend/src/components/MarkerPopup/ClusterMarker.jsx +++ b/frontend/src/components/MarkerPopup/ClusterMarker.jsx @@ -32,6 +32,9 @@ const ClusterMarker = ({ cluster }) => { iconSize: [30, 30], iconAnchor: [15, 15], }), + // ClusterMarkerIcon renders cluster_count alone, so depending on the whole + // cluster would rebuild the icon on every new object identity for nothing. + // eslint-disable-next-line react-hooks/exhaustive-deps [cluster.cluster_count], ); diff --git a/frontend/src/components/MarkerPopup/ReportProblemForm.jsx b/frontend/src/components/MarkerPopup/ReportProblemForm.jsx index 9a2405b0..6c46004a 100644 --- a/frontend/src/components/MarkerPopup/ReportProblemForm.jsx +++ b/frontend/src/components/MarkerPopup/ReportProblemForm.jsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import getCsrfToken from '../../utils/csrf'; +import { useDeploymentData } from '../../context/DeploymentDataContext'; /** * Styled form component with flexbox column layout. @@ -100,6 +101,28 @@ const SubmitButton = styled.input` /** * Styled success message component. */ +const ErrorMessage = styled.div` + padding: 12px 15px; + background-color: #fdecea; + border: 1px solid #f5c6cb; + border-radius: 8px; + color: #b71c1c; + font-size: 13px; + margin: 10px 15px; + text-align: center; +`; + +const RetryButton = styled.button` + margin-top: 8px; + font-size: 13px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid currentColor; + background: transparent; + color: inherit; + cursor: pointer; +`; + const SuccessMessage = styled.div` padding: 12px 15px; background-color: #e8f5e9; @@ -123,14 +146,14 @@ const SuccessMessage = styled.div` */ /** * Get issue type options from backend configuration or fall back to defaults. - * Dynamic types come from LOCATION_SCHEMA.reported_issue_types (configured per deployment). + * Dynamic types come from the deployment's location schema (reported_issue_types). * Default types are kept for backward compatibility with backends that don't provide this field (until 2.0.0). * * @param {Function} t - Translation function + * @param {Array<{value: string, label: string}>} [dynamicTypes] - Types this deployment declares * @returns {Array<{value: string, label: string}>} Issue type options (without "other", which is always appended) */ -const getIssueTypeOptions = t => { - const dynamicTypes = globalThis.LOCATION_SCHEMA?.reported_issue_types; +const getIssueTypeOptions = (t, dynamicTypes) => { if (dynamicTypes && dynamicTypes.length > 0) { return dynamicTypes.map(type => ({ value: type.value, @@ -147,12 +170,15 @@ const getIssueTypeOptions = t => { const ReportProblemForm = ({ placeId }) => { const { t } = useTranslation(); + const { locationSchema, schemaError, refetchLocationSchema } = useDeploymentData(); const [problem, setProblem] = useState(''); const [problemType, setProblemType] = useState(''); const [isSubmitted, setIsSubmitted] = useState(false); const [responseMessage, setResponseMessage] = useState(''); - const issueTypeOptions = getIssueTypeOptions(t); + // Only reached once the schema is known, so a loaded schema declaring no issue + // types is the one case that falls back to the legacy defaults. + const issueTypeOptions = getIssueTypeOptions(t, locationSchema?.reported_issue_types); const handleSubmit = async event => { event.preventDefault(); @@ -180,6 +206,26 @@ const ReportProblemForm = ({ placeId }) => { return {responseMessage}; } + // This deployment's own issue types are the only ones it is guaranteed to accept, so + // the form is withheld until they are known: the legacy fallbacks are not a stand-in + // for them. A failed fetch has to say so, or the form would just never appear. + if (schemaError) { + return ( + + {t('loadReportFormError')} +
+ + {t('retry')} + +
+
+ ); + } + + if (!locationSchema) { + return null; + } + return (