From 26fec6328b5b63b9f32a97926891f9978a0823e8 Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sat, 4 Jul 2026 18:52:09 +0200 Subject: [PATCH 01/10] Feat(Popup): Use external data to find categorical labels --- backend/all4trees/urls.py | 3 +- backend/all4trees/views.py | 39 +++- backend/maps/views.py | 3 +- webapp/src/app/providers/ApiProvider.tsx | 2 +- webapp/src/app/providers/AuthProvider.tsx | 2 +- .../fallback}/error-boundary-fallback.tsx | 0 .../loaded-popup-forest-inventory.tsx | 96 ++++++++++ .../popup-forest-inventory.tsx | 172 +++++++++++------- .../forest-inventory/use-external-data.ts | 10 + webapp/src/features/popup/renderPopup.tsx | 5 + webapp/src/shared/api/client.ts | 11 ++ webapp/src/widgets/dashboard/dashboard.tsx | 3 +- webapp/src/widgets/map/map-all4trees.tsx | 9 +- 13 files changed, 279 insertions(+), 76 deletions(-) rename webapp/src/{widgets/dashboard => features/fallback}/error-boundary-fallback.tsx (100%) create mode 100644 webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx create mode 100644 webapp/src/features/popup/forest-inventory/use-external-data.ts diff --git a/backend/all4trees/urls.py b/backend/all4trees/urls.py index 813096c3..64ab8ed7 100644 --- a/backend/all4trees/urls.py +++ b/backend/all4trees/urls.py @@ -24,6 +24,7 @@ path("admin/", admin.site.urls), path("api/", include("users.urls")), path("api/maps/", include("maps.urls")), - path("api/catalog//", views.catalog_view, name="catalog-data"), + path("api/catalog//", views.resource_view, name="get-catalog-resource"), + path("api/catalog/", views.resource_list_view, name="get-catalog-resources-list"), path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), ] diff --git a/backend/all4trees/views.py b/backend/all4trees/views.py index 362fcd67..d7a0caba 100644 --- a/backend/all4trees/views.py +++ b/backend/all4trees/views.py @@ -5,6 +5,7 @@ from rest_framework.response import Response import pandas as pd +from pandas import DataFrame import json catalog_path = settings.BASE_DIR / "catalog" @@ -12,7 +13,7 @@ @api_view(["GET"]) @permission_classes([IsAuthenticated]) -def catalog_view(request, layer_id, resource_name): +def resource_view(request, layer_id, resource_name): # authorization: ensure user has the custom add_data permission if not request.user.has_perm("users.view_data"): @@ -20,4 +21,38 @@ def catalog_view(request, layer_id, resource_name): target_path = catalog_path / f"{layer_id}" / f"{resource_name}.parquet" df = pd.read_parquet(target_path) - return Response(json.loads(df.to_json(orient="records"))) \ No newline at end of file + return Response(json.loads(df.to_json(orient="records"))) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def resource_list_view(request, layer_id): + # authorization: ensure user has the custom add_data permission + if not request.user.has_perm("users.view_data"): + return Response(status=status.HTTP_403_FORBIDDEN) + + try: + payload = json.loads(request.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return Response( + {"error": "Request body must be valid JSON"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + resource_list = payload.get("resources") + if not isinstance(resource_list, list) or len(resource_list) < 1: + return Response( + {"error": "Body should contain a non-empty list of resource names"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + resources_payload = {} + for resource_name in resource_list: + target_path = catalog_path / f"{layer_id}" / f"{resource_name}.parquet" + resource_df = pd.read_parquet(target_path) + resources_payload[resource_name] = json.loads(resource_df.to_json(orient="records")) + + if not resources_payload: + return Response({"error": "No parquet resources could be loaded"}, status=status.HTTP_404_NOT_FOUND) + + return Response(resources_payload) \ No newline at end of file diff --git a/backend/maps/views.py b/backend/maps/views.py index 5d8e711d..6ae8048d 100644 --- a/backend/maps/views.py +++ b/backend/maps/views.py @@ -103,5 +103,4 @@ def add_data_view(request): return Response({ 'message': 'File uploaded successfully', 'filename': uploaded_file.name, - }, status=status.HTTP_200_OK) - \ No newline at end of file + }, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/webapp/src/app/providers/ApiProvider.tsx b/webapp/src/app/providers/ApiProvider.tsx index bedf9447..6b6f12bb 100644 --- a/webapp/src/app/providers/ApiProvider.tsx +++ b/webapp/src/app/providers/ApiProvider.tsx @@ -13,5 +13,5 @@ export function ApiProvider({ children }: ApiProviderProps) { const { token } = useAuth(); const api = useMemo(() => createApiClient(token), [token]); - return {children}; + return {children}; } diff --git a/webapp/src/app/providers/AuthProvider.tsx b/webapp/src/app/providers/AuthProvider.tsx index a98c3ae2..e2d4f620 100644 --- a/webapp/src/app/providers/AuthProvider.tsx +++ b/webapp/src/app/providers/AuthProvider.tsx @@ -24,7 +24,7 @@ export function AuthProvider({ children }: AuthProviderProps) { try { const isValid = await verifyToken(token); if (isValid) { - setToken(storedToken); + setToken(token); setIsAuthenticated(true); } else { // Token invalide, nettoyer le localStorage diff --git a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx b/webapp/src/features/fallback/error-boundary-fallback.tsx similarity index 100% rename from webapp/src/widgets/dashboard/error-boundary-fallback.tsx rename to webapp/src/features/fallback/error-boundary-fallback.tsx diff --git a/webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx new file mode 100644 index 00000000..6ddd2d52 --- /dev/null +++ b/webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx @@ -0,0 +1,96 @@ +import { cx } from "class-variance-authority"; +import { TreesIcon } from "lucide-react"; +import { Activity, type FC, useState } from "react"; + +import { ICON_SIZE_HEADER } from "@features/indicators/components/constants"; +import { IndicatorElements } from "@features/indicators/components/indicator-elements"; +import { IndicatorScrollContainer } from "@features/indicators/components/indicator-scroll-container"; +import type { UseIndicatorReturnType } from "@features/indicators/components/types"; + +import { useTranslation } from "@shared/i18n"; +import { formatDate } from "@shared/lib/utils"; +import { GridSelector } from "@shared/ui/grid-selector"; + +import { IndicatorPopupHeader } from "../components/indicator-popup-header"; + +type LoadedPopupForestInventoryProps = { + className: string; + title: string; + subtitle: string; + headerProps: any; + biodiversityElements: UseIndicatorReturnType; + soilElements: UseIndicatorReturnType; +}; + +type TabKind = "biodiversity" | "soil"; + +const TABS: Record = { + BIODIVERSITY: "biodiversity", + SOIL: "soil", +} as const; + +export const LoadedPopupForestInventory: FC< + LoadedPopupForestInventoryProps +> = ({ + className, + title, + subtitle, + headerProps, + biodiversityElements, + soilElements, +}) => { + const { t } = useTranslation(["common", "all4trees"]); + const [selectedTab, setSelectedTab] = useState(TABS.BIODIVERSITY); + + const tabs = { + [TABS.BIODIVERSITY]: t("indicators.biodiversity.title", { + ns: "all4trees", + }), + [TABS.SOIL]: t("indicators.soil.title", { + ns: "all4trees", + }), + }; + + return ( +
+ } + subtitle={subtitle} + title={title} + {...headerProps} + /> + + setSelectedTab(value as TabKind)} + options={[ + { + id: TABS.BIODIVERSITY, + label: tabs[TABS.BIODIVERSITY], + }, + { + id: TABS.SOIL, + label: tabs[TABS.SOIL], + }, + ]} + value={selectedTab} + /> + + + + + + + + + + +
+ ); +}; diff --git a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx index bf34fd61..18dc8812 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -1,95 +1,133 @@ -import { cx } from "class-variance-authority"; -import { TreesIcon } from "lucide-react"; -import { Activity, type FC, useState } from "react"; +import { type FC, Suspense, useCallback, useMemo, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; -import { ICON_SIZE_HEADER } from "@features/indicators/components/constants"; -import { IndicatorElements } from "@features/indicators/components/indicator-elements"; -import { IndicatorScrollContainer } from "@features/indicators/components/indicator-scroll-container"; +import Loading from "@widgets/dashboard/loading"; + +import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; import { useSoilIndicatorElements } from "@features/indicators/soil"; -import { IndicatorPopupHeader } from "@features/popup/components/indicator-popup-header"; -import { findCategoricalLabel, formatDate } from "@shared/lib/utils"; -import { GridSelector } from "@shared/ui/grid-selector"; +import { LAYERS } from "@shared/api/layers"; +import { findCategoricalLabel } from "@shared/lib/utils"; import { useTranslation } from "@i18n"; import { useBiodiversityIndicatorElements } from "../../indicators/biodiversity/use-biodiversity-indicator-elements"; import type { RenderPopupProps } from "../renderPopup"; +import { LoadedPopupForestInventory } from "./loaded-popup-forest-inventory"; import type { ForestInventoryData } from "./types"; type ForestInventoryPopupContentProps = RenderPopupProps; -type TabKind = "biodiversity" | "soil"; +type ExternalData = Record; +type GetExternalData = ( + layerId: string, + resourceList: string[], +) => Promise; +type Layer = (typeof LAYERS)[keyof typeof LAYERS]; + +const EXTERNAL_RESOURCES = [ + "for_label", + "for_mf_tax1", + "for_mf_tax2", + "for_mf_tax3", + "for_score", +]; + +const cache = new WeakMap>>(); + +function getPerApiCache(getExternalData: GetExternalData) { + const perApiCache = cache.get(getExternalData); + if (perApiCache) { + return perApiCache; + } + const newPerApiCache = new Map>(); + cache.set(getExternalData, newPerApiCache); + return newPerApiCache; +} + +function fetchData({ + getExternalData, + layer, + resourceList, + force, +}: { + getExternalData: GetExternalData; + layer: Layer; + resourceList: string[]; + force?: boolean; +}): Promise { + const perLayerCache = getPerApiCache(getExternalData); + const cachedPromise = perLayerCache.get(layer); + + if (cachedPromise && !force) { + return cachedPromise; + } + const promise = getExternalData(layer, resourceList).catch((err) => { + // Don't cache failures forever; allow retries (e.g. after navigation / remount). + perLayerCache.delete(layer); + throw err; + }); + perLayerCache.set(layer, promise); -const TABS: Record = { - BIODIVERSITY: "biodiversity", - SOIL: "soil", -} as const; + return promise; +} export const ForestInventoryPopupContent: FC< ForestInventoryPopupContentProps -> = ({ data, metadata, className, ...headerProps }) => { +> = ({ data, metadata, api, className, ...headerProps }) => { const { t } = useTranslation(["common", "all4trees"]); - const [selectedTab, setSelectedTab] = useState(TABS.BIODIVERSITY); + + const [reloadKey, setReloadKey] = useState(0); + const externalData = useMemo( + () => + fetchData({ + force: reloadKey > 0, + getExternalData: api.getCatalogResourceList, + layer: LAYERS.INVENTARY, + resourceList: EXTERNAL_RESOURCES, + }), + [api, reloadKey], + ); const biodiversityElements = useBiodiversityIndicatorElements(data, metadata); const soilElements = useSoilIndicatorElements(data, metadata); + const retry = useCallback(() => { + console.log("Retrying") + setReloadKey((k) => k + 1); + }, []); + const fallbackRender = useMemo( + () => getFallbackRender({ retry, t }), + [retry, t], + ); + const title = t("popup.forestInventory.title", { id: data.id, ns: "all4trees", }); - const tabs = { - [TABS.BIODIVERSITY]: t("indicators.biodiversity.title", { - ns: "all4trees", - }), - [TABS.SOIL]: t("indicators.soil.title", { - ns: "all4trees", - }), - }; + + const subtitle = + findCategoricalLabel(metadata, "loc2", data.for) || + t("dataManagement.undefined", { ns: "common" }); return ( -
- } - subtitle={ - findCategoricalLabel(metadata, "loc2", data.for) || - t("dataManagement.undefined", { ns: "common" }) - } - title={title} - {...headerProps} - /> - - setSelectedTab(value as TabKind)} - options={[ - { - id: TABS.BIODIVERSITY, - label: tabs[TABS.BIODIVERSITY], - }, - { - id: TABS.SOIL, - label: tabs[TABS.SOIL], - }, - ]} - value={selectedTab} - /> - - - - - - - - - - -
+ { + // Log the error to your error reporting service + console.error("Error in forest invetory popup:", error, info); + }} + resetKeys={[externalData]} + > + }> + + + ); }; diff --git a/webapp/src/features/popup/forest-inventory/use-external-data.ts b/webapp/src/features/popup/forest-inventory/use-external-data.ts new file mode 100644 index 00000000..97d2c577 --- /dev/null +++ b/webapp/src/features/popup/forest-inventory/use-external-data.ts @@ -0,0 +1,10 @@ +import type { ApiClient } from "@shared/api/client"; + +export const fetchExternalData = ( + api: ApiClient, + externalResources: string[], +) => { + const { getCatalogResourceList } = api; +}; + +export type UseExternalDataReturnType = ReturnType; diff --git a/webapp/src/features/popup/renderPopup.tsx b/webapp/src/features/popup/renderPopup.tsx index b66f57ab..eddabdec 100644 --- a/webapp/src/features/popup/renderPopup.tsx +++ b/webapp/src/features/popup/renderPopup.tsx @@ -1,6 +1,7 @@ import type { FC } from "react"; import { createRoot } from "react-dom/client"; +import type { ApiClient } from "@shared/api/client"; import type { LayerMetadata, PopupOptions } from "@shared/lib/coordo"; import type { IndicatorPopupHeaderProps } from "./components/indicator-popup-header"; @@ -30,14 +31,17 @@ export type RenderPopupProps = { className: string; data: T; metadata: LayerMetadata; + api: ApiClient; } & Pick; export function getRenderPopupLayer({ Element, toggleShiftSize, + api, }: { Element: FC>; toggleShiftSize: IndicatorPopupHeaderProps["toggleShiftSize"]; + api?: any; }) { return (properties: Properties, metadata: LayerMetadata) => { const container = document.createElement("div"); @@ -48,6 +52,7 @@ export function getRenderPopupLayer({ root.render( (await fetchWithAuth(endpoint, options, authToken)).json(); export const createApiClient = (authToken: string | null) => ({ + getCatalogResource: (layerId: string, resourceName: string) => + fetchJSONWithAuth(`/catalog/${layerId}/${resourceName}`, {}, authToken), + getCatalogResourceList: (layerId: string, resourceList: string[]) => + fetchJSONWithAuth( + `/catalog/${layerId}`, + { + body: JSON.stringify({ resources: resourceList }), + method: "POST", + }, + authToken, + ), getDashboardData: (layerId: string) => fetchJSONWithAuth(`/maps/dashboard/${layerId}`, {}, authToken), }); diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 3d496a1c..97a095e9 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -1,12 +1,13 @@ import { Suspense, useCallback, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; -import { getFallbackRender } from "@widgets/dashboard/error-boundary-fallback"; import LoadedDashboard, { type DashboardData, } from "@widgets/dashboard/loaded-dashboard"; import Loading from "@widgets/dashboard/loading"; +import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; + import { LAYERS } from "@shared/api/layers"; import { useApi } from "@shared/hooks/useApi"; import { useTranslation } from "@shared/i18n"; diff --git a/webapp/src/widgets/map/map-all4trees.tsx b/webapp/src/widgets/map/map-all4trees.tsx index 0f509822..bdb12a3a 100644 --- a/webapp/src/widgets/map/map-all4trees.tsx +++ b/webapp/src/widgets/map/map-all4trees.tsx @@ -16,6 +16,7 @@ import { import { LAYERS } from "@shared/api/layers"; import { useMap } from "@shared/hooks/use-map-all4trees"; +import { useApi } from "@shared/hooks/useApi"; import pictoInventaire from "./assets/inventaire-icon.svg"; import pictoSocioEco from "./assets/socio-eco-icon.svg"; @@ -24,6 +25,9 @@ import { getIconSize } from "./utils"; export const MapAll4Trees: FC = () => { const { isReady, mapApiRef, forests, mapContainerRef } = useMap(); + // We need to pass the api as prop to the popup content, so we can fetch data from the catalog. + // We can't call useApi() from inside the popup as it is created dynamically and not part of the React tree. + const api = useApi(); const [isMaximizedPopupSize, setIsMaximizedPopupSize] = useState(false); useEffect(() => { @@ -49,6 +53,7 @@ export const MapAll4Trees: FC = () => { layerId: LAYERS.INVENTARY, popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ + api, Element: ForestInventoryPopupContent, toggleShiftSize, }), @@ -61,6 +66,7 @@ export const MapAll4Trees: FC = () => { layerId: LAYERS.ENQUETE, popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ + api, Element: SocioEcoIndicator, toggleShiftSize, }), @@ -73,12 +79,13 @@ export const MapAll4Trees: FC = () => { layerId: LAYERS.SEED, popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ + api, Element: SeedIndicator, toggleShiftSize, }), trigger: "click", }); - }, [isReady, mapApiRef]); + }, [isReady, mapApiRef, api]); const filterByForest = (forestId: string) => { mapApiRef.current?.setLayerFilters({ From dc61adae55f92674628ef083c84d78d895313ba1 Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sun, 5 Jul 2026 19:47:12 +0200 Subject: [PATCH 02/10] Generalize external data fetching --- .../fallback/error-boundary-fallback.tsx | 1 - .../loaded-popup-forest-inventory.tsx | 96 ---------- .../popup-forest-inventory.tsx | 179 +++++++----------- .../features/popup/forest-inventory/types.ts | 2 + .../forest-inventory/use-external-data.ts | 10 - webapp/src/features/popup/renderPopup.tsx | 29 +-- webapp/src/shared/api/client.ts | 13 +- webapp/src/widgets/map/map-all4trees.tsx | 8 +- webapp/src/widgets/map/map-seed.tsx | 8 +- webapp/src/widgets/map/utils.ts | 22 +++ 10 files changed, 132 insertions(+), 236 deletions(-) delete mode 100644 webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx delete mode 100644 webapp/src/features/popup/forest-inventory/use-external-data.ts diff --git a/webapp/src/features/fallback/error-boundary-fallback.tsx b/webapp/src/features/fallback/error-boundary-fallback.tsx index 29bb61d1..75f1812a 100644 --- a/webapp/src/features/fallback/error-boundary-fallback.tsx +++ b/webapp/src/features/fallback/error-boundary-fallback.tsx @@ -16,7 +16,6 @@ export function getFallbackRender({ }) { function FallbackRender({ error }: FallbackProps) { const errorMessage = getErrorMessage(error) ?? t("error.unknownMessage"); - return (

{t("error.title")}

diff --git a/webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx deleted file mode 100644 index 6ddd2d52..00000000 --- a/webapp/src/features/popup/forest-inventory/loaded-popup-forest-inventory.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { cx } from "class-variance-authority"; -import { TreesIcon } from "lucide-react"; -import { Activity, type FC, useState } from "react"; - -import { ICON_SIZE_HEADER } from "@features/indicators/components/constants"; -import { IndicatorElements } from "@features/indicators/components/indicator-elements"; -import { IndicatorScrollContainer } from "@features/indicators/components/indicator-scroll-container"; -import type { UseIndicatorReturnType } from "@features/indicators/components/types"; - -import { useTranslation } from "@shared/i18n"; -import { formatDate } from "@shared/lib/utils"; -import { GridSelector } from "@shared/ui/grid-selector"; - -import { IndicatorPopupHeader } from "../components/indicator-popup-header"; - -type LoadedPopupForestInventoryProps = { - className: string; - title: string; - subtitle: string; - headerProps: any; - biodiversityElements: UseIndicatorReturnType; - soilElements: UseIndicatorReturnType; -}; - -type TabKind = "biodiversity" | "soil"; - -const TABS: Record = { - BIODIVERSITY: "biodiversity", - SOIL: "soil", -} as const; - -export const LoadedPopupForestInventory: FC< - LoadedPopupForestInventoryProps -> = ({ - className, - title, - subtitle, - headerProps, - biodiversityElements, - soilElements, -}) => { - const { t } = useTranslation(["common", "all4trees"]); - const [selectedTab, setSelectedTab] = useState(TABS.BIODIVERSITY); - - const tabs = { - [TABS.BIODIVERSITY]: t("indicators.biodiversity.title", { - ns: "all4trees", - }), - [TABS.SOIL]: t("indicators.soil.title", { - ns: "all4trees", - }), - }; - - return ( -
- } - subtitle={subtitle} - title={title} - {...headerProps} - /> - - setSelectedTab(value as TabKind)} - options={[ - { - id: TABS.BIODIVERSITY, - label: tabs[TABS.BIODIVERSITY], - }, - { - id: TABS.SOIL, - label: tabs[TABS.SOIL], - }, - ]} - value={selectedTab} - /> - - - - - - - - - - -
- ); -}; diff --git a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx index 18dc8812..173f5d9c 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -1,104 +1,46 @@ -import { type FC, Suspense, useCallback, useMemo, useState } from "react"; -import { ErrorBoundary } from "react-error-boundary"; - -import Loading from "@widgets/dashboard/loading"; - -import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; +import { cx } from "class-variance-authority"; +import { TreesIcon } from "lucide-react"; +import { Activity, type FC, use, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { useBiodiversityIndicatorElements } from "@features/indicators/biodiversity"; +import { ICON_SIZE_HEADER } from "@features/indicators/components/constants"; +import { IndicatorElements } from "@features/indicators/components/indicator-elements"; +import { IndicatorScrollContainer } from "@features/indicators/components/indicator-scroll-container"; import { useSoilIndicatorElements } from "@features/indicators/soil"; -import { LAYERS } from "@shared/api/layers"; -import { findCategoricalLabel } from "@shared/lib/utils"; -import { useTranslation } from "@i18n"; +import { findCategoricalLabel, formatDate } from "@shared/lib/utils"; +import { GridSelector } from "@shared/ui/grid-selector"; -import { useBiodiversityIndicatorElements } from "../../indicators/biodiversity/use-biodiversity-indicator-elements"; +import { IndicatorPopupHeader } from "../components/indicator-popup-header"; import type { RenderPopupProps } from "../renderPopup"; -import { LoadedPopupForestInventory } from "./loaded-popup-forest-inventory"; import type { ForestInventoryData } from "./types"; type ForestInventoryPopupContentProps = RenderPopupProps; -type ExternalData = Record; -type GetExternalData = ( - layerId: string, - resourceList: string[], -) => Promise; -type Layer = (typeof LAYERS)[keyof typeof LAYERS]; - -const EXTERNAL_RESOURCES = [ - "for_label", - "for_mf_tax1", - "for_mf_tax2", - "for_mf_tax3", - "for_score", -]; - -const cache = new WeakMap>>(); - -function getPerApiCache(getExternalData: GetExternalData) { - const perApiCache = cache.get(getExternalData); - if (perApiCache) { - return perApiCache; - } - const newPerApiCache = new Map>(); - cache.set(getExternalData, newPerApiCache); - return newPerApiCache; -} +type TabKind = "biodiversity" | "soil"; -function fetchData({ - getExternalData, - layer, - resourceList, - force, -}: { - getExternalData: GetExternalData; - layer: Layer; - resourceList: string[]; - force?: boolean; -}): Promise { - const perLayerCache = getPerApiCache(getExternalData); - const cachedPromise = perLayerCache.get(layer); - - if (cachedPromise && !force) { - return cachedPromise; - } - const promise = getExternalData(layer, resourceList).catch((err) => { - // Don't cache failures forever; allow retries (e.g. after navigation / remount). - perLayerCache.delete(layer); - throw err; - }); - perLayerCache.set(layer, promise); - - return promise; -} +const TABS: Record = { + BIODIVERSITY: "biodiversity", + SOIL: "soil", +} as const; export const ForestInventoryPopupContent: FC< ForestInventoryPopupContentProps -> = ({ data, metadata, api, className, ...headerProps }) => { +> = ({ data, metadata, externalDataPromise, className, ...headerProps }) => { const { t } = useTranslation(["common", "all4trees"]); - - const [reloadKey, setReloadKey] = useState(0); - const externalData = useMemo( - () => - fetchData({ - force: reloadKey > 0, - getExternalData: api.getCatalogResourceList, - layer: LAYERS.INVENTARY, - resourceList: EXTERNAL_RESOURCES, - }), - [api, reloadKey], - ); - - const biodiversityElements = useBiodiversityIndicatorElements(data, metadata); - const soilElements = useSoilIndicatorElements(data, metadata); - - const retry = useCallback(() => { - console.log("Retrying") - setReloadKey((k) => k + 1); - }, []); - const fallbackRender = useMemo( - () => getFallbackRender({ retry, t }), - [retry, t], - ); + const [selectedTab, setSelectedTab] = useState(TABS.BIODIVERSITY); + const externalData = use(externalDataPromise); + + console.log("Loaded external data : ", externalData); + const tabs = { + [TABS.BIODIVERSITY]: t("indicators.biodiversity.title", { + ns: "all4trees", + }), + [TABS.SOIL]: t("indicators.soil.title", { + ns: "all4trees", + }), + }; const title = t("popup.forestInventory.title", { id: data.id, @@ -109,25 +51,48 @@ export const ForestInventoryPopupContent: FC< findCategoricalLabel(metadata, "loc2", data.for) || t("dataManagement.undefined", { ns: "common" }); + const biodiversityElements = useBiodiversityIndicatorElements(data, metadata); + const soilElements = useSoilIndicatorElements(data, metadata); return ( - { - // Log the error to your error reporting service - console.error("Error in forest invetory popup:", error, info); - }} - resetKeys={[externalData]} - > - }> - - - +
+ } + subtitle={subtitle} + title={title} + {...headerProps} + /> + + setSelectedTab(value as TabKind)} + options={[ + { + id: TABS.BIODIVERSITY, + label: tabs[TABS.BIODIVERSITY], + }, + { + id: TABS.SOIL, + label: tabs[TABS.SOIL], + }, + ]} + value={selectedTab} + /> + + + + + + + + + + +
); }; diff --git a/webapp/src/features/popup/forest-inventory/types.ts b/webapp/src/features/popup/forest-inventory/types.ts index 16b9fd10..fe29484b 100644 --- a/webapp/src/features/popup/forest-inventory/types.ts +++ b/webapp/src/features/popup/forest-inventory/types.ts @@ -9,3 +9,5 @@ export type ForestInventoryData = { taille_placette: number; } & SoilData & BiodiversityData; + +export type ExternalData = Record; diff --git a/webapp/src/features/popup/forest-inventory/use-external-data.ts b/webapp/src/features/popup/forest-inventory/use-external-data.ts deleted file mode 100644 index 97d2c577..00000000 --- a/webapp/src/features/popup/forest-inventory/use-external-data.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ApiClient } from "@shared/api/client"; - -export const fetchExternalData = ( - api: ApiClient, - externalResources: string[], -) => { - const { getCatalogResourceList } = api; -}; - -export type UseExternalDataReturnType = ReturnType; diff --git a/webapp/src/features/popup/renderPopup.tsx b/webapp/src/features/popup/renderPopup.tsx index eddabdec..f65a7ecf 100644 --- a/webapp/src/features/popup/renderPopup.tsx +++ b/webapp/src/features/popup/renderPopup.tsx @@ -1,10 +1,11 @@ import type { FC } from "react"; import { createRoot } from "react-dom/client"; -import type { ApiClient } from "@shared/api/client"; import type { LayerMetadata, PopupOptions } from "@shared/lib/coordo"; import type { IndicatorPopupHeaderProps } from "./components/indicator-popup-header"; +import { Popup } from "./components/popup"; +import type { ExternalData } from "./forest-inventory/types"; export const getPopupSizeCustomVariables = (isMaximizedPopupSize: boolean) => { return { @@ -31,33 +32,37 @@ export type RenderPopupProps = { className: string; data: T; metadata: LayerMetadata; - api: ApiClient; + externalDataPromise: Promise; } & Pick; +export type GetExternalData = () => Promise; + export function getRenderPopupLayer({ Element, toggleShiftSize, - api, + getExternalData, }: { Element: FC>; toggleShiftSize: IndicatorPopupHeaderProps["toggleShiftSize"]; - api?: any; + getExternalData: GetExternalData; }) { return (properties: Properties, metadata: LayerMetadata) => { const container = document.createElement("div"); const root = createRoot(container); - // Enforce custom/dynamic max-width at the inner container level container.className = "h-full w-full max-w-[var(--popup-max-width)]"; root.render( - root.unmount()} - toggleShiftSize={toggleShiftSize} + root.unmount(), + toggleShiftSize, + }} + PopupContent={Element} + promiseFunc={getExternalData} />, ); return container; diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index 53db10d9..3f2f4ada 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -1,3 +1,5 @@ +import type { APIError } from "../lib/types"; + export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; @@ -15,17 +17,18 @@ export const fetchWithAuth = async ( }); if (!res.ok) { - console.error(`Erreur API: ${res.status} ${res.statusText}`); const errorData = await res.json().catch(() => ({ details: [res.statusText], error: "Erreur de communication", })); - console.error("Détails de l'erreur:", JSON.stringify(errorData, null, 2)); - const error = new Error(`Erreur API: ${res.status}`); + const message = `Erreur API: ${res.status} ${res.statusText}`; + const cause = JSON.stringify(errorData, null, 2); + + console.error(message); + console.error("Détails de l'erreur:", cause); - // @ts-expect-error Property 'response' does not exist on type 'Error'. - error.response = { data: errorData }; + const error: APIError = { cause, message, status: res.status }; throw error; } diff --git a/webapp/src/widgets/map/map-all4trees.tsx b/webapp/src/widgets/map/map-all4trees.tsx index bdb12a3a..43000142 100644 --- a/webapp/src/widgets/map/map-all4trees.tsx +++ b/webapp/src/widgets/map/map-all4trees.tsx @@ -21,7 +21,7 @@ import { useApi } from "@shared/hooks/useApi"; import pictoInventaire from "./assets/inventaire-icon.svg"; import pictoSocioEco from "./assets/socio-eco-icon.svg"; import { MapBase } from "./map-base"; -import { getIconSize } from "./utils"; +import { getExternalDataPromiseByLayer, getIconSize } from "./utils"; export const MapAll4Trees: FC = () => { const { isReady, mapApiRef, forests, mapContainerRef } = useMap(); @@ -53,8 +53,8 @@ export const MapAll4Trees: FC = () => { layerId: LAYERS.INVENTARY, popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ - api, Element: ForestInventoryPopupContent, + getExternalData: getExternalDataPromiseByLayer(LAYERS.INVENTARY, api), toggleShiftSize, }), trigger: "click", @@ -66,8 +66,8 @@ export const MapAll4Trees: FC = () => { layerId: LAYERS.ENQUETE, popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ - api, Element: SocioEcoIndicator, + getExternalData: getExternalDataPromiseByLayer(LAYERS.ENQUETE, api), toggleShiftSize, }), trigger: "click", @@ -79,8 +79,8 @@ export const MapAll4Trees: FC = () => { layerId: LAYERS.SEED, popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ - api, Element: SeedIndicator, + getExternalData: getExternalDataPromiseByLayer(LAYERS.SEED_POINT, api), toggleShiftSize, }), trigger: "click", diff --git a/webapp/src/widgets/map/map-seed.tsx b/webapp/src/widgets/map/map-seed.tsx index d3695377..c7e6e810 100644 --- a/webapp/src/widgets/map/map-seed.tsx +++ b/webapp/src/widgets/map/map-seed.tsx @@ -8,11 +8,16 @@ import { import { LAYERS } from "@shared/api/layers"; import { useMap } from "@shared/hooks/use-map-seed"; +import { useApi } from "@shared/hooks/useApi"; import { MapBase } from "./map-base"; +import { getExternalDataPromiseByLayer } from "./utils"; export const MapSeed: FC = () => { const { isReady, mapContainerRef, mapApiRef } = useMap(); + // We need to pass the api as prop to the popup content, so we can fetch data from the catalog. + // We can't call useApi() from inside the popup as it is created dynamically and not part of the React tree. + const api = useApi(); const [isMaximizedPopupSize, setIsMaximizedPopupSize] = useState(false); useEffect(() => { @@ -26,11 +31,12 @@ export const MapSeed: FC = () => { popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ Element: SeedIndicator, + getExternalData: getExternalDataPromiseByLayer(LAYERS.SEED_POINT, api), toggleShiftSize, }), trigger: "click", }); - }, [isReady, mapApiRef]); + }, [isReady, mapApiRef, api]); return ( ([ + [ + LAYERS.INVENTARY, + ["for_label", "for_mf_tax1", "for_mf_tax2", "for_mf_tax3", "for_score"], + ], + [LAYERS.ENQUETE, [""]], + [LAYERS.SEED_POINT, [""]], +]); + +export const getExternalDataPromiseByLayer = ( + layerId: string, + client: ApiClient, +) => { + const resourceList = EXTERNAL_RESOURCES_BY_LAYER.get(layerId) || []; + return resourceList?.length > 0 + ? () => client.getCatalogResourceList(layerId, resourceList) + : () => Promise.resolve({}); +}; From ad3ac71cc5ba2d0884f13462f1cd0056a7423e3d Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sun, 5 Jul 2026 19:47:32 +0200 Subject: [PATCH 03/10] Add missing files --- .../src/features/popup/components/popup.tsx | 87 +++++++++++++++++++ webapp/src/shared/lib/types.ts | 5 ++ 2 files changed, 92 insertions(+) create mode 100644 webapp/src/features/popup/components/popup.tsx create mode 100644 webapp/src/shared/lib/types.ts diff --git a/webapp/src/features/popup/components/popup.tsx b/webapp/src/features/popup/components/popup.tsx new file mode 100644 index 00000000..037e3b86 --- /dev/null +++ b/webapp/src/features/popup/components/popup.tsx @@ -0,0 +1,87 @@ +import { type FC, Suspense, useCallback, useMemo, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { useTranslation } from "react-i18next"; + +import Loading from "@widgets/dashboard/loading"; + +import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; + +import type { RenderPopupProps } from "../renderPopup"; + +type PromiseFunc = () => Promise; + +type PopupProps = { + promiseFunc: PromiseFunc; + PopupContent: FC>; + childrenProps: any; +}; + +export const Popup: FC = ({ + promiseFunc, + PopupContent, + childrenProps, +}) => { + const { t } = useTranslation("common"); + // Retrieve external data before rendering popup + const [reloadKey, setReloadKey] = useState(0); + const externalDataPromise = useMemo( + () => + fetchData({ + force: reloadKey > 0, + promiseFunc, + }), + [reloadKey, promiseFunc], + ); + + const retry = useCallback(() => { + console.log("Retrying"); + setReloadKey((k) => k + 1); + }, []); + const fallbackRender = useMemo( + () => getFallbackRender({ retry, t }), + [retry, t], + ); + + return ( + { + // Log the error to your error reporting service + console.error("Error in forest invetory popup:", error, info); + }} + resetKeys={[externalDataPromise]} + > + }> + + + + ); +}; + +const cache = new WeakMap>(); + +function fetchData({ + promiseFunc, + force, +}: { + promiseFunc: () => Promise; + force?: boolean; +}): Promise { + const cachedPromise = cache.get(promiseFunc); + + if (cachedPromise && !force) { + return cachedPromise; + } + const promise = promiseFunc().catch((err) => { + // Don't cache failures forever; allow retries (e.g. after navigation / remount). + cache.delete(promiseFunc); + throw err; + }); + console.log("fetching new external data..."); + cache.set(promiseFunc, promise); + + return promise; +} diff --git a/webapp/src/shared/lib/types.ts b/webapp/src/shared/lib/types.ts new file mode 100644 index 00000000..c8246161 --- /dev/null +++ b/webapp/src/shared/lib/types.ts @@ -0,0 +1,5 @@ +export type APIError = { + status: number; + message: string; + cause: string; +}; From 3cff43102e47e266a8f84892f652e6297f175e5f Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sun, 5 Jul 2026 20:25:23 +0200 Subject: [PATCH 04/10] Improve Fallback Renderer --- .../fallback/error-boundary-fallback.tsx | 54 ++++++++++++++----- .../src/features/popup/components/popup.tsx | 10 ++-- .../shared/i18n/translations/en/common.json | 5 +- .../shared/i18n/translations/fr/common.json | 1 + 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/webapp/src/features/fallback/error-boundary-fallback.tsx b/webapp/src/features/fallback/error-boundary-fallback.tsx index 75f1812a..ccd4e49d 100644 --- a/webapp/src/features/fallback/error-boundary-fallback.tsx +++ b/webapp/src/features/fallback/error-boundary-fallback.tsx @@ -1,6 +1,18 @@ import type { TFunction } from "i18next"; +import { TriangleAlert } from "lucide-react"; import { type FallbackProps, getErrorMessage } from "react-error-boundary"; +import { + ICON_SIZE_HEADER, +} from "@features/indicators/components/constants"; + +import type { APIError } from "@shared/lib/types"; +import { cn } from "@shared/lib/utils"; +import { + Alert, + AlertTitle, +} from "@shared/ui/alert"; + import { Button } from "@ui/button"; // t must be passed to the fallback render function because the error boundary fallback component cannot call hooks like useTranslation @@ -15,20 +27,36 @@ export function getFallbackRender({ t: TFunction<"common", undefined>; }) { function FallbackRender({ error }: FallbackProps) { - const errorMessage = getErrorMessage(error) ?? t("error.unknownMessage"); + const apiError = error as APIError; + const errorMessage = + apiError.status === 401 + ? t("error.pleaseLogin") + : (getErrorMessage(error) ?? t("error.unknownMessage")); return ( -
-

{t("error.title")}

-

{errorMessage}

- {retry && ( - - )} -
+ <> + + + + {t("error.title")} + + +
+
+

{errorMessage}

+ {retry && ( + + )} +
+
+ ); } diff --git a/webapp/src/features/popup/components/popup.tsx b/webapp/src/features/popup/components/popup.tsx index 037e3b86..3329a59b 100644 --- a/webapp/src/features/popup/components/popup.tsx +++ b/webapp/src/features/popup/components/popup.tsx @@ -1,11 +1,15 @@ import { type FC, Suspense, useCallback, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import Loading from "@widgets/dashboard/loading"; import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; +import type { APIError } from "@shared/lib/types"; +import { useAbsoluteUrls } from "@shared/urls"; + import type { RenderPopupProps } from "../renderPopup"; type PromiseFunc = () => Promise; @@ -22,6 +26,7 @@ export const Popup: FC = ({ childrenProps, }) => { const { t } = useTranslation("common"); + // Retrieve external data before rendering popup const [reloadKey, setReloadKey] = useState(0); const externalDataPromise = useMemo( @@ -45,10 +50,6 @@ export const Popup: FC = ({ return ( { - // Log the error to your error reporting service - console.error("Error in forest invetory popup:", error, info); - }} resetKeys={[externalDataPromise]} > }> @@ -80,7 +81,6 @@ function fetchData({ cache.delete(promiseFunc); throw err; }); - console.log("fetching new external data..."); cache.set(promiseFunc, promise); return promise; diff --git a/webapp/src/shared/i18n/translations/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index 25e8ef70..f7ca58b0 100644 --- a/webapp/src/shared/i18n/translations/en/common.json +++ b/webapp/src/shared/i18n/translations/en/common.json @@ -2,8 +2,8 @@ "auth": { "loginForm": { "button": { - "login": "Login", - "pendingLogin": "Logging in..." + "login": "Sign in", + "pendingLogin": "Connecting..." }, "credentialsError": "Invalid credentials", "description": "Enter your username below to log in", @@ -22,6 +22,7 @@ "undefined": "not found" }, "error": { + "pleaseLogon": "Please sign in", "retry": "Retry", "title": "Error while loading data", "unknownMessage": "An unknown error occurred. Please try again later." diff --git a/webapp/src/shared/i18n/translations/fr/common.json b/webapp/src/shared/i18n/translations/fr/common.json index dd830b09..57013943 100644 --- a/webapp/src/shared/i18n/translations/fr/common.json +++ b/webapp/src/shared/i18n/translations/fr/common.json @@ -22,6 +22,7 @@ "undefined": "non trouvée" }, "error": { + "pleaseLogin": "Veuillez vous connecter", "retry": "Réessayer", "title": "Erreur lors du chargement des données", "unknownMessage": "Une erreur inconnue s'est produite. Veuillez réessayer plus tard." From bf6d9ea09521fd939726f9850fcddf384a2c51d3 Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sun, 5 Jul 2026 20:26:18 +0200 Subject: [PATCH 05/10] Fix lint --- webapp/src/features/fallback/error-boundary-fallback.tsx | 9 ++------- webapp/src/features/popup/components/popup.tsx | 4 ---- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/webapp/src/features/fallback/error-boundary-fallback.tsx b/webapp/src/features/fallback/error-boundary-fallback.tsx index ccd4e49d..461793b8 100644 --- a/webapp/src/features/fallback/error-boundary-fallback.tsx +++ b/webapp/src/features/fallback/error-boundary-fallback.tsx @@ -2,16 +2,11 @@ import type { TFunction } from "i18next"; import { TriangleAlert } from "lucide-react"; import { type FallbackProps, getErrorMessage } from "react-error-boundary"; -import { - ICON_SIZE_HEADER, -} from "@features/indicators/components/constants"; +import { ICON_SIZE_HEADER } from "@features/indicators/components/constants"; import type { APIError } from "@shared/lib/types"; import { cn } from "@shared/lib/utils"; -import { - Alert, - AlertTitle, -} from "@shared/ui/alert"; +import { Alert, AlertTitle } from "@shared/ui/alert"; import { Button } from "@ui/button"; diff --git a/webapp/src/features/popup/components/popup.tsx b/webapp/src/features/popup/components/popup.tsx index 3329a59b..679a086f 100644 --- a/webapp/src/features/popup/components/popup.tsx +++ b/webapp/src/features/popup/components/popup.tsx @@ -1,15 +1,11 @@ import { type FC, Suspense, useCallback, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; import Loading from "@widgets/dashboard/loading"; import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; -import type { APIError } from "@shared/lib/types"; -import { useAbsoluteUrls } from "@shared/urls"; - import type { RenderPopupProps } from "../renderPopup"; type PromiseFunc = () => Promise; From 8cdc2ee69bdf1548ccc62b715fdd19f9d7ee860c Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sun, 5 Jul 2026 22:48:35 +0200 Subject: [PATCH 06/10] Use external data for taxon & tree labels --- backend/Makefile | 2 +- webapp/src/app/providers/AuthProvider.tsx | 6 +-- webapp/src/features/auth/authClient.ts | 2 + .../biodiversity/chart-relative-abundance.tsx | 20 +++++++--- .../src/features/charts/soil/lib/sunburst.ts | 11 +++--- webapp/src/features/charts/soil/lib/taxon.ts | 39 ++++++++++++++----- webapp/src/features/charts/soil/types.ts | 6 +-- .../charts/soil/ui/chart-taxon-abundance.tsx | 6 +-- .../use-biodiversity-indicator-elements.tsx | 13 ++++--- .../features/indicators/soil/format-data.ts | 3 +- .../soil/use-soil-indicator-elements.tsx | 12 +++--- .../src/features/popup/components/popup.tsx | 1 - .../popup-forest-inventory.tsx | 19 ++++++--- .../features/popup/forest-inventory/types.ts | 19 ++++++++- webapp/src/shared/lib/utils.ts | 28 +++++++++++++ webapp/src/widgets/map/map-all4trees.tsx | 1 - 16 files changed, 136 insertions(+), 52 deletions(-) diff --git a/backend/Makefile b/backend/Makefile index c4cc6e4b..421ab14d 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -2,7 +2,7 @@ catalog: # All 4 Trees - Inventaire forestier coordo load kobotoolbox --action add data/all4trees/inventaire_for/20260519_InventaireForestier_QuestionnaireK.xlsx data/all4trees/inventaire_for/20260422_InventaireForestier_DonneesK.xlsx --package catalog/inventaire_for - coordo load file --action add data/all4trees/inventaire_for/20260422_InventaireForestier_DonneesExternes.xlsx --package catalog/inventaire_for + coordo load file --action add data/all4trees/inventaire_for/20260703_InventaireForestier_DonneesExternes.xlsx --package catalog/inventaire_for coordo add-foreignkey adu.decay for_dw.decay --package catalog/inventaire_for coordo add-foreignkey inv_for.proj for_samp.proj --package catalog/inventaire_for diff --git a/webapp/src/app/providers/AuthProvider.tsx b/webapp/src/app/providers/AuthProvider.tsx index e2d4f620..10d33c2f 100644 --- a/webapp/src/app/providers/AuthProvider.tsx +++ b/webapp/src/app/providers/AuthProvider.tsx @@ -29,11 +29,11 @@ export function AuthProvider({ children }: AuthProviderProps) { } else { // Token invalide, nettoyer le localStorage localStorage.removeItem("token"); - localStorage.removeItem("user"); + localStorage.removeItem("username"); } } catch { localStorage.removeItem("token"); - localStorage.removeItem("user"); + localStorage.removeItem("username"); } setIsAuthLoading(false); @@ -50,7 +50,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const logout = () => { localStorage.removeItem("token"); - localStorage.removeItem("user"); + localStorage.removeItem("username"); setToken(null); setIsAuthenticated(false); }; diff --git a/webapp/src/features/auth/authClient.ts b/webapp/src/features/auth/authClient.ts index 05a893a7..de73994a 100644 --- a/webapp/src/features/auth/authClient.ts +++ b/webapp/src/features/auth/authClient.ts @@ -10,6 +10,7 @@ export const fetchToken = async (username: string, password: string) => { throw new Error(`Erreur de connexion: ${res.status} ${res.statusText}`); } const data = await res.json(); + return data.access; }; @@ -19,6 +20,7 @@ export const verifyToken = async (token: string) => { headers: { "Content-Type": "application/json" }, method: "POST", }); + if (!res.ok) { console.error(`Token invalide: ${res.status} ${res.statusText}`); return false; diff --git a/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx b/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx index f0fc491f..96a67b23 100644 --- a/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx +++ b/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx @@ -1,19 +1,22 @@ +import type { ExternalData } from "@features/popup/forest-inventory/types"; + import { useTranslation } from "@shared/i18n"; -import type { LayerMetadata } from "@shared/lib/coordo"; -import { findCategoricalLabel, precise } from "@shared/lib/utils"; +import { findLabelInExternalData, precise } from "@shared/lib/utils"; import type { ChartConfig } from "@shared/ui/chart"; import type { ChartComponentType } from "../components/chart-component"; import { PieChartCategorical } from "../components/pie-chart-categorical"; type PieChartProps = { + proj: string; data: [string, number][]; - metadata: LayerMetadata; + externalData: ExternalData; }; export const ChartRelativeAbundance: ChartComponentType = ({ + proj, data, - metadata, + externalData, }) => { const { t } = useTranslation("all4trees"); const smallCategoriesSum = Number( @@ -37,8 +40,13 @@ export const ChartRelativeAbundance: ChartComponentType = ({ ...chartConfig, [element.name]: { label: - findCategoricalLabel(metadata, "reg_sp", element.name) || - element.name, + findLabelInExternalData( + externalData, + "for_label", + proj, + "ess", + Number(element.name), + ) || element.name, }, other: { label: t( diff --git a/webapp/src/features/charts/soil/lib/sunburst.ts b/webapp/src/features/charts/soil/lib/sunburst.ts index 0d75fc4f..dd572b8c 100644 --- a/webapp/src/features/charts/soil/lib/sunburst.ts +++ b/webapp/src/features/charts/soil/lib/sunburst.ts @@ -1,4 +1,5 @@ -import type { LayerMetadata } from "@shared/lib/coordo"; +import type { ExternalData } from "@features/popup/forest-inventory/types"; + import { getChartPalette } from "@shared/lib/palette"; import type { SunburstNode } from "../types"; @@ -6,8 +7,8 @@ import { formatTaxonLevelLabel } from "./taxon"; export function buildSunburstNodes( dataEntries: [string, number][], - metadata: LayerMetadata, - dataType: "tsbf" | "barbA", + metadata: ExternalData, + proj: string, ): SunburstNode[] { const nodes: SunburstNode[] = []; const seen = new Set(); @@ -16,7 +17,7 @@ export function buildSunburstNodes( const parts = key.split("-"); nodes.push({ id: key, - label: formatTaxonLevelLabel(key, metadata, dataType), + label: formatTaxonLevelLabel(key, metadata, proj), parent: parts.slice(0, -1).join("-") || "", value, }); @@ -27,7 +28,7 @@ export function buildSunburstNodes( if (!seen.has(parentPath)) { nodes.push({ id: parentPath, - label: formatTaxonLevelLabel(parentPath, metadata, dataType), + label: formatTaxonLevelLabel(parentPath, metadata, proj), parent: parts.slice(0, i).join("-") || "", value: 0, }); diff --git a/webapp/src/features/charts/soil/lib/taxon.ts b/webapp/src/features/charts/soil/lib/taxon.ts index 4d2a0fbb..741c8f69 100644 --- a/webapp/src/features/charts/soil/lib/taxon.ts +++ b/webapp/src/features/charts/soil/lib/taxon.ts @@ -1,30 +1,49 @@ -import type { LayerMetadata } from "@shared/lib/coordo"; -import { findCategoricalLabel } from "@shared/lib/utils"; +import type { ExternalData } from "@features/popup/forest-inventory/types"; + +import { findLabelInExternalData } from "@shared/lib/utils"; export function getTaxonLabels( element: string, - metadata: LayerMetadata, - dataType: "tsbf" | "barbA", + metadata: ExternalData, + proj: string, ): [string, string, string] { const [taxon1, taxon2, taxon3] = element.split("-"); const taxon1Label = - findCategoricalLabel(metadata, `${dataType}_tax1`, taxon1) || taxon1; + findLabelInExternalData( + metadata, + "for_label", + proj, + "tax1", + Number(taxon1), + ) || taxon1; const taxon2Label = - findCategoricalLabel(metadata, `${dataType}_tax2`, taxon2) || taxon2; + findLabelInExternalData( + metadata, + "for_label", + proj, + "tax2", + Number(taxon2), + ) || taxon2; const taxon3Label = - findCategoricalLabel(metadata, `${dataType}_tax3`, taxon3) || taxon3; + findLabelInExternalData( + metadata, + "for_label", + proj, + "tax3", + Number(taxon3), + ) || taxon3; return [taxon1Label, taxon2Label, taxon3Label]; } export function formatTaxonLevelLabel( element: string, - metadata: LayerMetadata, - dataType: "tsbf" | "barbA", + metadata: ExternalData, + proj: string, ): string { const [taxon1Label, taxon2Label, taxon3Label] = getTaxonLabels( element, metadata, - dataType, + proj, ); const parts = element.split("-"); return parts.length === 1 diff --git a/webapp/src/features/charts/soil/types.ts b/webapp/src/features/charts/soil/types.ts index ff8af292..a33fa1d2 100644 --- a/webapp/src/features/charts/soil/types.ts +++ b/webapp/src/features/charts/soil/types.ts @@ -1,9 +1,9 @@ -import type { LayerMetadata } from "@shared/lib/coordo"; +import type { ExternalData } from "@features/popup/forest-inventory/types"; export type PieChartProps = { data: Record; - metadata: LayerMetadata; - dataType: "tsbf" | "barbA"; + metadata: ExternalData; + proj: string; }; export type SunburstNode = { diff --git a/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx b/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx index 5442ca62..ce7ec49d 100644 --- a/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx +++ b/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx @@ -7,7 +7,7 @@ import { type ChartComponentType, } from "@features/charts/components/chart-component"; -import { useTranslation } from "@shared/i18n"; +import { i18nInstance, useTranslation } from "@shared/i18n"; import { SUNBURST_LAYOUT } from "../config"; import { buildNodeColors, buildSunburstNodes } from "../lib/sunburst"; @@ -16,7 +16,7 @@ import type { PieChartProps, SunburstTrace } from "../types"; export const ChartTaxonAbundance: ChartComponentType = ({ data, metadata, - dataType, + proj, }) => { const { t } = useTranslation(["common", "all4trees"]); @@ -30,7 +30,7 @@ export const ChartTaxonAbundance: ChartComponentType = ({ const filteredDataEntries = dataEntries.filter( ([key]) => key.trim() !== "0", ); - const nodes = buildSunburstNodes(filteredDataEntries, metadata, dataType); + const nodes = buildSunburstNodes(filteredDataEntries, metadata, proj); const nodeColors = buildNodeColors(nodes); sunburstData = [ diff --git a/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx b/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx index 7f4bc190..10284f1e 100644 --- a/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx +++ b/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx @@ -2,19 +2,21 @@ import { Gem, TreePine, Trees } from "lucide-react"; import { ChartForestPotential } from "@features/charts/biodiversity/chart-forest-potential"; import { ChartRelativeAbundance } from "@features/charts/biodiversity/chart-relative-abundance"; +import type { + ExternalData, + ForestInventoryData, +} from "@features/popup/forest-inventory/types"; -import type { LayerMetadata } from "@shared/lib/coordo"; import { useTranslation } from "@i18n"; import { ICON_SIZE } from "../components/constants"; import { IndicatorRawValue } from "../components/indicator-raw-value"; import type { UseIndicatorReturnType } from "../components/types"; import { useFormatBiodiversityData } from "./format-data"; -import type { BiodiversityData } from "./types"; export const useBiodiversityIndicatorElements = ( - rawData: BiodiversityData, - metadata: LayerMetadata, + rawData: ForestInventoryData, + metadata: ExternalData, ): UseIndicatorReturnType => { const { t } = useTranslation("all4trees"); const data = useFormatBiodiversityData(rawData); @@ -52,7 +54,8 @@ export const useBiodiversityIndicatorElements = ( /> ), diff --git a/webapp/src/features/indicators/soil/format-data.ts b/webapp/src/features/indicators/soil/format-data.ts index dcbb2ea8..a30677d1 100644 --- a/webapp/src/features/indicators/soil/format-data.ts +++ b/webapp/src/features/indicators/soil/format-data.ts @@ -6,6 +6,7 @@ import { UNITS, useFormatterWithUnit, } from "@features/indicators/utils"; +import type { ForestInventoryData } from "@features/popup/forest-inventory"; import type { NumericKeys } from "@shared/types"; @@ -27,7 +28,7 @@ const indicatorsToPreciseWithoutFallBack: NumericKeys[] = [ /** * Return data in a convenient way for UI rendering, handling units and fixing */ -export const useFormatSoilData = (data: SoilData) => { +export const useFormatSoilData = (data: ForestInventoryData) => { const { t } = useTranslation("common"); const { formatWithUnit } = useFormatterWithUnit(); diff --git a/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx b/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx index 486dabdb..50fb078f 100644 --- a/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx +++ b/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx @@ -5,17 +5,17 @@ import { ChartTaxonAbundance } from "@features/charts/soil/ui/chart-taxon-abunda import { ChartWindErosion } from "@features/charts/soil/ui/chart-wind-erosion"; import type { UseIndicatorReturnType } from "@features/indicators//components/types"; import { IndicatorRawValue } from "@features/indicators/components/indicator-raw-value"; +import type { ForestInventoryData } from "@features/popup/forest-inventory"; +import type { ExternalData } from "@features/popup/forest-inventory/types"; -import type { LayerMetadata } from "@shared/lib/coordo"; import { useTranslation } from "@i18n"; import { ICON_SIZE } from "../components/constants"; import { useFormatSoilData } from "./format-data"; -import type { SoilData } from "./types"; export const useSoilIndicatorElements = ( - rawData: SoilData, - metadata: LayerMetadata, + rawData: ForestInventoryData, + metadata: ExternalData, ): UseIndicatorReturnType => { const { t } = useTranslation("all4trees"); @@ -78,8 +78,8 @@ export const useSoilIndicatorElements = ( /> ), @@ -103,8 +103,8 @@ export const useSoilIndicatorElements = ( /> ), diff --git a/webapp/src/features/popup/components/popup.tsx b/webapp/src/features/popup/components/popup.tsx index 679a086f..a75a2ab8 100644 --- a/webapp/src/features/popup/components/popup.tsx +++ b/webapp/src/features/popup/components/popup.tsx @@ -35,7 +35,6 @@ export const Popup: FC = ({ ); const retry = useCallback(() => { - console.log("Retrying"); setReloadKey((k) => k + 1); }, []); const fallbackRender = useMemo( diff --git a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx index 173f5d9c..3b816419 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -9,7 +9,7 @@ import { IndicatorElements } from "@features/indicators/components/indicator-ele import { IndicatorScrollContainer } from "@features/indicators/components/indicator-scroll-container"; import { useSoilIndicatorElements } from "@features/indicators/soil"; -import { findCategoricalLabel, formatDate } from "@shared/lib/utils"; +import { findLabelInExternalData, formatDate } from "@shared/lib/utils"; import { GridSelector } from "@shared/ui/grid-selector"; import { IndicatorPopupHeader } from "../components/indicator-popup-header"; @@ -32,7 +32,6 @@ export const ForestInventoryPopupContent: FC< const [selectedTab, setSelectedTab] = useState(TABS.BIODIVERSITY); const externalData = use(externalDataPromise); - console.log("Loaded external data : ", externalData); const tabs = { [TABS.BIODIVERSITY]: t("indicators.biodiversity.title", { ns: "all4trees", @@ -48,11 +47,19 @@ export const ForestInventoryPopupContent: FC< }); const subtitle = - findCategoricalLabel(metadata, "loc2", data.for) || - t("dataManagement.undefined", { ns: "common" }); + findLabelInExternalData( + externalData, + "for_label", + data.projet, + "loc2", + Number(data.for), + ) || t("dataManagement.undefined", { ns: "common" }); - const biodiversityElements = useBiodiversityIndicatorElements(data, metadata); - const soilElements = useSoilIndicatorElements(data, metadata); + const biodiversityElements = useBiodiversityIndicatorElements( + data, + externalData, + ); + const soilElements = useSoilIndicatorElements(data, externalData); return (
; +export type ExternalData = { + for_label: LabelData[]; + for_mf_tax1: TaxonLabelData[]; + for_mf_tax2: any[]; + for_mf_tax3: any[]; + for_score: any[]; + // Index signature pour accepter d'autres clés dynamiques si besoin + [key: string]: any[]; +}; + +export type LabelData = { + proj: string; + list_name: string; + name: number; + label: string; +}; + +export type TaxonLabelData = {}; diff --git a/webapp/src/shared/lib/utils.ts b/webapp/src/shared/lib/utils.ts index 0fa24d05..0d0b4605 100644 --- a/webapp/src/shared/lib/utils.ts +++ b/webapp/src/shared/lib/utils.ts @@ -1,6 +1,8 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +import type { ExternalData } from "@features/popup/forest-inventory/types"; + import { i18nInstance } from "@shared/i18n"; import type { LayerMetadata } from "@shared/lib/coordo"; @@ -48,3 +50,29 @@ export function findCategoricalLabel( ?.schema.fields.find((f) => f.name === fieldName) ?.categories?.find((c) => c.value === fieldValue)?.label; } + +export function findLabelInExternalData( + externalData: ExternalData, + resourceName: string, + project: string, + fieldName: string, + fieldValue: any, +): string | undefined { + // Get the data array for the resource (e.g., for_label, for_mf_tax1, etc.) + const resourceData = externalData[resourceName]; + + if (!resourceData || !Array.isArray(resourceData)) { + return undefined; + } + + // Find the record matching all criteria: proj, list_name, and name + const record = resourceData.find((item) => { + return ( + item?.proj?.trim() === project.trim() && + item?.list_name?.trim() === fieldName.trim() && + item?.name === fieldValue + ); + }); + + return record?.label; +} diff --git a/webapp/src/widgets/map/map-all4trees.tsx b/webapp/src/widgets/map/map-all4trees.tsx index 43000142..ce7544b3 100644 --- a/webapp/src/widgets/map/map-all4trees.tsx +++ b/webapp/src/widgets/map/map-all4trees.tsx @@ -32,7 +32,6 @@ export const MapAll4Trees: FC = () => { useEffect(() => { if (!isReady || !mapApiRef.current) return; - const toggleShiftSize = () => setIsMaximizedPopupSize((prev) => !prev); mapApiRef.current.setLayerSymbol({ From e9b04b00f1e831ee4b634a31ecc8c8963ad57011 Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sun, 5 Jul 2026 23:14:30 +0200 Subject: [PATCH 07/10] Clean code --- .../biodiversity/chart-relative-abundance.tsx | 3 +- webapp/src/features/charts/soil/lib/taxon.ts | 3 +- .../charts/soil/ui/chart-taxon-abundance.tsx | 2 +- .../fallback}/loading.tsx | 0 webapp/src/features/indicators/utils.ts | 54 +++++++++++++++++++ .../src/features/popup/components/popup.tsx | 3 +- .../popup-forest-inventory.tsx | 3 +- .../features/popup/forest-inventory/types.ts | 4 +- .../popup/socio-eco/popup-socio-eco.tsx | 3 +- webapp/src/shared/lib/utils.ts | 54 ------------------- webapp/src/widgets/dashboard/dashboard.tsx | 2 +- 11 files changed, 65 insertions(+), 66 deletions(-) rename webapp/src/{widgets/dashboard => features/fallback}/loading.tsx (100%) diff --git a/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx b/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx index 96a67b23..26df6974 100644 --- a/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx +++ b/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx @@ -1,7 +1,8 @@ +import { findLabelInExternalData } from "@features/indicators/utils"; import type { ExternalData } from "@features/popup/forest-inventory/types"; import { useTranslation } from "@shared/i18n"; -import { findLabelInExternalData, precise } from "@shared/lib/utils"; +import { precise } from "@shared/lib/utils"; import type { ChartConfig } from "@shared/ui/chart"; import type { ChartComponentType } from "../components/chart-component"; diff --git a/webapp/src/features/charts/soil/lib/taxon.ts b/webapp/src/features/charts/soil/lib/taxon.ts index 741c8f69..af0467c6 100644 --- a/webapp/src/features/charts/soil/lib/taxon.ts +++ b/webapp/src/features/charts/soil/lib/taxon.ts @@ -1,7 +1,6 @@ +import { findLabelInExternalData } from "@features/indicators/utils"; import type { ExternalData } from "@features/popup/forest-inventory/types"; -import { findLabelInExternalData } from "@shared/lib/utils"; - export function getTaxonLabels( element: string, metadata: ExternalData, diff --git a/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx b/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx index ce7ec49d..a29c5323 100644 --- a/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx +++ b/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx @@ -7,7 +7,7 @@ import { type ChartComponentType, } from "@features/charts/components/chart-component"; -import { i18nInstance, useTranslation } from "@shared/i18n"; +import { useTranslation } from "@shared/i18n"; import { SUNBURST_LAYOUT } from "../config"; import { buildNodeColors, buildSunburstNodes } from "../lib/sunburst"; diff --git a/webapp/src/widgets/dashboard/loading.tsx b/webapp/src/features/fallback/loading.tsx similarity index 100% rename from webapp/src/widgets/dashboard/loading.tsx rename to webapp/src/features/fallback/loading.tsx diff --git a/webapp/src/features/indicators/utils.ts b/webapp/src/features/indicators/utils.ts index 04d0b768..7aefd1cd 100644 --- a/webapp/src/features/indicators/utils.ts +++ b/webapp/src/features/indicators/utils.ts @@ -1,4 +1,7 @@ +import type { ExternalData } from "@features/popup/forest-inventory/types"; + import { useTranslation } from "@shared/i18n"; +import type { LayerMetadata } from "@shared/lib/coordo"; import { precise } from "@shared/lib/utils"; import type { NumericKeys } from "@shared/types"; @@ -111,3 +114,54 @@ export function computeScore(value: number): number { return 4 - Math.floor(Math.log10(value)); } + +export function findCategoricalLabel( + metadata: LayerMetadata, + fieldName: string, + fieldValue: any, +): string | undefined { + // Searching field category in main resource schema + const resourceLabel = metadata?.resource?.schema?.fields + .find((f) => f.name === fieldName) + ?.categories?.find((c) => c.value === fieldValue)?.label; + + if (resourceLabel) { + return resourceLabel; + } + + // Searching field category in main resource's references' schemas + return metadata?.references + ?.find((ref) => + ref.schema.fields + .find((f) => f.name === fieldName) + ?.categories?.some((c) => c.value === fieldValue), + ) + ?.schema.fields.find((f) => f.name === fieldName) + ?.categories?.find((c) => c.value === fieldValue)?.label; +} + +export function findLabelInExternalData( + externalData: ExternalData, + resourceName: string, + project: string, + fieldName: string, + fieldValue: any, +): string | undefined { + // Get the data array for the resource (e.g., for_label, for_mf_tax1, etc.) + const resourceData = externalData[resourceName]; + + if (!resourceData || !Array.isArray(resourceData)) { + return undefined; + } + + // Find the record matching all criteria: proj, list_name, and name + const record = resourceData.find((item) => { + return ( + item?.proj?.trim() === project.trim() && + item?.list_name?.trim() === fieldName.trim() && + item?.name === fieldValue + ); + }); + + return record?.label; +} diff --git a/webapp/src/features/popup/components/popup.tsx b/webapp/src/features/popup/components/popup.tsx index a75a2ab8..938ea313 100644 --- a/webapp/src/features/popup/components/popup.tsx +++ b/webapp/src/features/popup/components/popup.tsx @@ -2,9 +2,8 @@ import { type FC, Suspense, useCallback, useMemo, useState } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; -import Loading from "@widgets/dashboard/loading"; - import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; +import Loading from "@features/fallback/loading"; import type { RenderPopupProps } from "../renderPopup"; diff --git a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx index 3b816419..2e9cfd58 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -8,8 +8,9 @@ import { ICON_SIZE_HEADER } from "@features/indicators/components/constants"; import { IndicatorElements } from "@features/indicators/components/indicator-elements"; import { IndicatorScrollContainer } from "@features/indicators/components/indicator-scroll-container"; import { useSoilIndicatorElements } from "@features/indicators/soil"; +import { findLabelInExternalData } from "@features/indicators/utils"; -import { findLabelInExternalData, formatDate } from "@shared/lib/utils"; +import { formatDate } from "@shared/lib/utils"; import { GridSelector } from "@shared/ui/grid-selector"; import { IndicatorPopupHeader } from "../components/indicator-popup-header"; diff --git a/webapp/src/features/popup/forest-inventory/types.ts b/webapp/src/features/popup/forest-inventory/types.ts index 77aff64b..d1d84374 100644 --- a/webapp/src/features/popup/forest-inventory/types.ts +++ b/webapp/src/features/popup/forest-inventory/types.ts @@ -12,7 +12,7 @@ export type ForestInventoryData = { export type ExternalData = { for_label: LabelData[]; - for_mf_tax1: TaxonLabelData[]; + for_mf_tax1: any[]; for_mf_tax2: any[]; for_mf_tax3: any[]; for_score: any[]; @@ -26,5 +26,3 @@ export type LabelData = { name: number; label: string; }; - -export type TaxonLabelData = {}; diff --git a/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx b/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx index 76af22a6..1f3c6b03 100644 --- a/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx +++ b/webapp/src/features/popup/socio-eco/popup-socio-eco.tsx @@ -7,10 +7,11 @@ import { IndicatorElements } from "@features/indicators/components/indicator-ele import { IndicatorScrollContainer } from "@features/indicators/components/indicator-scroll-container"; import { useEconomicIndicatorElements } from "@features/indicators/economy"; import { useSocialIndicatorElements } from "@features/indicators/social/use-social-indicator-elements"; +import { findCategoricalLabel } from "@features/indicators/utils"; import { IndicatorPopupHeader } from "@features/popup/components/indicator-popup-header"; import type { LayerMetadata } from "@shared/lib/coordo"; -import { findCategoricalLabel, formatDate } from "@shared/lib/utils"; +import { formatDate } from "@shared/lib/utils"; import { GridSelector } from "@shared/ui/grid-selector"; import { useTranslation } from "@i18n"; diff --git a/webapp/src/shared/lib/utils.ts b/webapp/src/shared/lib/utils.ts index 0d0b4605..438e42ec 100644 --- a/webapp/src/shared/lib/utils.ts +++ b/webapp/src/shared/lib/utils.ts @@ -1,10 +1,7 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -import type { ExternalData } from "@features/popup/forest-inventory/types"; - import { i18nInstance } from "@shared/i18n"; -import type { LayerMetadata } from "@shared/lib/coordo"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -25,54 +22,3 @@ export function formatDate(date: Date | string) { dateStyle: "short", }).format(typeof date === "string" ? new Date(date) : (date as Date)); } - -export function findCategoricalLabel( - metadata: LayerMetadata, - fieldName: string, - fieldValue: any, -): string | undefined { - // Searching field category in main resource schema - const resourceLabel = metadata?.resource?.schema?.fields - .find((f) => f.name === fieldName) - ?.categories?.find((c) => c.value === fieldValue)?.label; - - if (resourceLabel) { - return resourceLabel; - } - - // Searching field category in main resource's references' schemas - return metadata?.references - ?.find((ref) => - ref.schema.fields - .find((f) => f.name === fieldName) - ?.categories?.some((c) => c.value === fieldValue), - ) - ?.schema.fields.find((f) => f.name === fieldName) - ?.categories?.find((c) => c.value === fieldValue)?.label; -} - -export function findLabelInExternalData( - externalData: ExternalData, - resourceName: string, - project: string, - fieldName: string, - fieldValue: any, -): string | undefined { - // Get the data array for the resource (e.g., for_label, for_mf_tax1, etc.) - const resourceData = externalData[resourceName]; - - if (!resourceData || !Array.isArray(resourceData)) { - return undefined; - } - - // Find the record matching all criteria: proj, list_name, and name - const record = resourceData.find((item) => { - return ( - item?.proj?.trim() === project.trim() && - item?.list_name?.trim() === fieldName.trim() && - item?.name === fieldValue - ); - }); - - return record?.label; -} diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 97a095e9..7c2c3291 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -4,9 +4,9 @@ import { ErrorBoundary } from "react-error-boundary"; import LoadedDashboard, { type DashboardData, } from "@widgets/dashboard/loaded-dashboard"; -import Loading from "@widgets/dashboard/loading"; import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; +import Loading from "@features/fallback/loading"; import { LAYERS } from "@shared/api/layers"; import { useApi } from "@shared/hooks/useApi"; From 3a176a27acecaf7e67358a555b62ed1e0bb0431a Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Sun, 5 Jul 2026 23:15:51 +0200 Subject: [PATCH 08/10] Remove backend unused import --- backend/all4trees/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/all4trees/views.py b/backend/all4trees/views.py index d7a0caba..94c12213 100644 --- a/backend/all4trees/views.py +++ b/backend/all4trees/views.py @@ -5,7 +5,6 @@ from rest_framework.response import Response import pandas as pd -from pandas import DataFrame import json catalog_path = settings.BASE_DIR / "catalog" From 84ca74862a89ed272cf45f67e9698f0fcd5c5b93 Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Tue, 7 Jul 2026 08:33:48 +0200 Subject: [PATCH 09/10] Fix review --- backend/all4trees/views.py | 94 +++++++++++++------ backend/configs/config.json | 2 +- .../biodiversity/chart-relative-abundance.tsx | 6 +- .../src/features/charts/soil/lib/sunburst.ts | 6 +- webapp/src/features/charts/soil/lib/taxon.ts | 12 +-- webapp/src/features/charts/soil/types.ts | 2 +- .../charts/soil/ui/chart-taxon-abundance.tsx | 4 +- .../use-biodiversity-indicator-elements.tsx | 2 +- .../soil/use-soil-indicator-elements.tsx | 4 +- webapp/src/features/indicators/utils.ts | 16 ++-- .../src/features/popup/components/popup.tsx | 24 ++--- .../popup-forest-inventory.tsx | 2 +- .../features/popup/forest-inventory/types.ts | 2 +- .../shared/i18n/translations/en/common.json | 2 +- 14 files changed, 108 insertions(+), 70 deletions(-) diff --git a/backend/all4trees/views.py b/backend/all4trees/views.py index 94c12213..d38ab20b 100644 --- a/backend/all4trees/views.py +++ b/backend/all4trees/views.py @@ -1,57 +1,91 @@ from django.conf import settings +import json +import pandas as pd +from pathlib import Path + from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import NotFound, ValidationError from rest_framework.response import Response -import pandas as pd -import json - catalog_path = settings.BASE_DIR / "catalog" @api_view(["GET"]) -@permission_classes([IsAuthenticated]) def resource_view(request, layer_id, resource_name): - - # authorization: ensure user has the custom add_data permission - if not request.user.has_perm("users.view_data"): - return Response(status=status.HTTP_403_FORBIDDEN) - - target_path = catalog_path / f"{layer_id}" / f"{resource_name}.parquet" - df = pd.read_parquet(target_path) + resource_path = get_resource_path(catalog_path, layer_id, resource_name) + df = pd.read_parquet(resource_path) return Response(json.loads(df.to_json(orient="records"))) @api_view(["POST"]) -@permission_classes([IsAuthenticated]) def resource_list_view(request, layer_id): - # authorization: ensure user has the custom add_data permission - if not request.user.has_perm("users.view_data"): - return Response(status=status.HTTP_403_FORBIDDEN) + resource_list = parse_body(request.body) + package_path = get_package_path(catalog_path, layer_id) + + resources_payload, results, errors = load_resources(layer_id, package_path, resource_list) + if not resources_payload: + raise NotFound({"error": "None of the resources could be loaded", "results": results}) + elif errors: + return Response({"results": results}, status=status.HTTP_207_MULTI_STATUS) + + return Response(resources_payload) +def parse_body(body): try: - payload = json.loads(request.body.decode("utf-8")) + payload = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): - return Response( - {"error": "Request body must be valid JSON"}, - status=status.HTTP_400_BAD_REQUEST, - ) + raise ValidationError("Request body must be valid JSON") resource_list = payload.get("resources") if not isinstance(resource_list, list) or len(resource_list) < 1: - return Response( - {"error": "Body should contain a non-empty list of resource names"}, - status=status.HTTP_400_BAD_REQUEST, - ) + raise ValidationError("Body should contain a non-empty list of resource names") + return resource_list + + +def load_resources(layer_id, package_path, resource_list): resources_payload = {} + results = [] + errors = False for resource_name in resource_list: - target_path = catalog_path / f"{layer_id}" / f"{resource_name}.parquet" - resource_df = pd.read_parquet(target_path) + resource_path = package_path / f"{resource_name}.parquet" + if not Path(resource_path).exists(): + errors=True + results.append(not_found_result(layer_id, resource_name)) + continue + resource_df = pd.read_parquet(resource_path) resources_payload[resource_name] = json.loads(resource_df.to_json(orient="records")) + results.append(ok_result(layer_id, resource_name)) - if not resources_payload: - return Response({"error": "No parquet resources could be loaded"}, status=status.HTTP_404_NOT_FOUND) + return resources_payload, results, errors + +def get_resource_path(catalog_path, layer_id, resource_name): + package_path = get_package_path(catalog_path, layer_id) + resource_path = package_path / f"{resource_name}.parquet" + + if not Path(resource_path).exists(): + raise NotFound(f"Resource with name '{resource_name}' was not found in the package {layer_id}") + + return resource_path + +def get_package_path(catalog_path, layer_id): + package_path = catalog_path / f"{layer_id}" + + if not Path(package_path).exists(): + raise NotFound(f"Package with name '{layer_id}' was not found in the catalog") + + return package_path + +def not_found_result(layer_id, resource_name): + return { + "uri": f"{layer_id} / {resource_name}", + "status": status.HTTP_404_NOT_FOUND, + "reason": f"Resource with name '{resource_name}' was not found in the package {layer_id}" + } - return Response(resources_payload) \ No newline at end of file +def ok_result(layer_id, resource_name): + return { + "uri": f"{layer_id} / {resource_name}", + "status": status.HTTP_200_OK + } \ No newline at end of file diff --git a/backend/configs/config.json b/backend/configs/config.json index 9681148e..1745a353 100644 --- a/backend/configs/config.json +++ b/backend/configs/config.json @@ -53,7 +53,7 @@ "cod": "cod", "ecos": "ecos", "taille_placette": "20^2*pi()/10000", - "projet": "proj", + "project": "proj", "tree_pop": "count(1 if adu.cond = 1)", "biomass_volume": "adu.sum(0.0673 * (for_sp.dens * (meas1 + meas2 + meas3 + meas4 + meas5 + meas6 + meas7 + meas8 + meas9 + meas10)^2 * hgt_perp)^0.976 if cond = 1) / (20^2 * pi() / 10000) / 1000", "tree_density": "count(1 if adu.cond = 1) / (20^2 * pi() / 10000)", diff --git a/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx b/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx index 26df6974..9a7ddc7a 100644 --- a/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx +++ b/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx @@ -9,13 +9,13 @@ import type { ChartComponentType } from "../components/chart-component"; import { PieChartCategorical } from "../components/pie-chart-categorical"; type PieChartProps = { - proj: string; + project: string; data: [string, number][]; externalData: ExternalData; }; export const ChartRelativeAbundance: ChartComponentType = ({ - proj, + project, data, externalData, }) => { @@ -44,7 +44,7 @@ export const ChartRelativeAbundance: ChartComponentType = ({ findLabelInExternalData( externalData, "for_label", - proj, + project, "ess", Number(element.name), ) || element.name, diff --git a/webapp/src/features/charts/soil/lib/sunburst.ts b/webapp/src/features/charts/soil/lib/sunburst.ts index dd572b8c..3a5f3b57 100644 --- a/webapp/src/features/charts/soil/lib/sunburst.ts +++ b/webapp/src/features/charts/soil/lib/sunburst.ts @@ -8,7 +8,7 @@ import { formatTaxonLevelLabel } from "./taxon"; export function buildSunburstNodes( dataEntries: [string, number][], metadata: ExternalData, - proj: string, + project: string, ): SunburstNode[] { const nodes: SunburstNode[] = []; const seen = new Set(); @@ -17,7 +17,7 @@ export function buildSunburstNodes( const parts = key.split("-"); nodes.push({ id: key, - label: formatTaxonLevelLabel(key, metadata, proj), + label: formatTaxonLevelLabel(key, metadata, project), parent: parts.slice(0, -1).join("-") || "", value, }); @@ -28,7 +28,7 @@ export function buildSunburstNodes( if (!seen.has(parentPath)) { nodes.push({ id: parentPath, - label: formatTaxonLevelLabel(parentPath, metadata, proj), + label: formatTaxonLevelLabel(parentPath, metadata, project), parent: parts.slice(0, i).join("-") || "", value: 0, }); diff --git a/webapp/src/features/charts/soil/lib/taxon.ts b/webapp/src/features/charts/soil/lib/taxon.ts index af0467c6..b6de8068 100644 --- a/webapp/src/features/charts/soil/lib/taxon.ts +++ b/webapp/src/features/charts/soil/lib/taxon.ts @@ -4,14 +4,14 @@ import type { ExternalData } from "@features/popup/forest-inventory/types"; export function getTaxonLabels( element: string, metadata: ExternalData, - proj: string, + project: string, ): [string, string, string] { const [taxon1, taxon2, taxon3] = element.split("-"); const taxon1Label = findLabelInExternalData( metadata, "for_label", - proj, + project, "tax1", Number(taxon1), ) || taxon1; @@ -19,7 +19,7 @@ export function getTaxonLabels( findLabelInExternalData( metadata, "for_label", - proj, + project, "tax2", Number(taxon2), ) || taxon2; @@ -27,7 +27,7 @@ export function getTaxonLabels( findLabelInExternalData( metadata, "for_label", - proj, + project, "tax3", Number(taxon3), ) || taxon3; @@ -37,12 +37,12 @@ export function getTaxonLabels( export function formatTaxonLevelLabel( element: string, metadata: ExternalData, - proj: string, + project: string, ): string { const [taxon1Label, taxon2Label, taxon3Label] = getTaxonLabels( element, metadata, - proj, + project, ); const parts = element.split("-"); return parts.length === 1 diff --git a/webapp/src/features/charts/soil/types.ts b/webapp/src/features/charts/soil/types.ts index a33fa1d2..581102a3 100644 --- a/webapp/src/features/charts/soil/types.ts +++ b/webapp/src/features/charts/soil/types.ts @@ -3,7 +3,7 @@ import type { ExternalData } from "@features/popup/forest-inventory/types"; export type PieChartProps = { data: Record; metadata: ExternalData; - proj: string; + project: string; }; export type SunburstNode = { diff --git a/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx b/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx index a29c5323..a63f049f 100644 --- a/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx +++ b/webapp/src/features/charts/soil/ui/chart-taxon-abundance.tsx @@ -16,7 +16,7 @@ import type { PieChartProps, SunburstTrace } from "../types"; export const ChartTaxonAbundance: ChartComponentType = ({ data, metadata, - proj, + project, }) => { const { t } = useTranslation(["common", "all4trees"]); @@ -30,7 +30,7 @@ export const ChartTaxonAbundance: ChartComponentType = ({ const filteredDataEntries = dataEntries.filter( ([key]) => key.trim() !== "0", ); - const nodes = buildSunburstNodes(filteredDataEntries, metadata, proj); + const nodes = buildSunburstNodes(filteredDataEntries, metadata, project); const nodeColors = buildNodeColors(nodes); sunburstData = [ diff --git a/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx b/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx index 10284f1e..2a4c5114 100644 --- a/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx +++ b/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx @@ -55,7 +55,7 @@ export const useBiodiversityIndicatorElements = ( ), diff --git a/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx b/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx index 50fb078f..142f04bb 100644 --- a/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx +++ b/webapp/src/features/indicators/soil/use-soil-indicator-elements.tsx @@ -79,7 +79,7 @@ export const useSoilIndicatorElements = ( ), @@ -104,7 +104,7 @@ export const useSoilIndicatorElements = ( ), diff --git a/webapp/src/features/indicators/utils.ts b/webapp/src/features/indicators/utils.ts index 7aefd1cd..5efb824b 100644 --- a/webapp/src/features/indicators/utils.ts +++ b/webapp/src/features/indicators/utils.ts @@ -1,4 +1,7 @@ -import type { ExternalData } from "@features/popup/forest-inventory/types"; +import type { + ExternalData, + LabelData, +} from "@features/popup/forest-inventory/types"; import { useTranslation } from "@shared/i18n"; import type { LayerMetadata } from "@shared/lib/coordo"; @@ -154,14 +157,13 @@ export function findLabelInExternalData( return undefined; } - // Find the record matching all criteria: proj, list_name, and name - const record = resourceData.find((item) => { - return ( + // Find the record matching all criteria: project, list_name, and name + const record = resourceData.find( + (item: LabelData) => item?.proj?.trim() === project.trim() && item?.list_name?.trim() === fieldName.trim() && - item?.name === fieldValue - ); - }); + item?.name === fieldValue, + ); return record?.label; } diff --git a/webapp/src/features/popup/components/popup.tsx b/webapp/src/features/popup/components/popup.tsx index 938ea313..c52836cb 100644 --- a/webapp/src/features/popup/components/popup.tsx +++ b/webapp/src/features/popup/components/popup.tsx @@ -42,17 +42,19 @@ export const Popup: FC = ({ ); return ( - - }> - - - +
+ + }> + + + +
); }; diff --git a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx index 2e9cfd58..1eed5c83 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -51,7 +51,7 @@ export const ForestInventoryPopupContent: FC< findLabelInExternalData( externalData, "for_label", - data.projet, + data.project, "loc2", Number(data.for), ) || t("dataManagement.undefined", { ns: "common" }); diff --git a/webapp/src/features/popup/forest-inventory/types.ts b/webapp/src/features/popup/forest-inventory/types.ts index d1d84374..252caa7b 100644 --- a/webapp/src/features/popup/forest-inventory/types.ts +++ b/webapp/src/features/popup/forest-inventory/types.ts @@ -5,7 +5,7 @@ export type ForestInventoryData = { id: string; for: string; cod: number; - projet: string; + project: string; taille_placette: number; } & SoilData & BiodiversityData; diff --git a/webapp/src/shared/i18n/translations/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index f7ca58b0..00ddf154 100644 --- a/webapp/src/shared/i18n/translations/en/common.json +++ b/webapp/src/shared/i18n/translations/en/common.json @@ -22,7 +22,7 @@ "undefined": "not found" }, "error": { - "pleaseLogon": "Please sign in", + "pleaseLogin": "Please sign in", "retry": "Retry", "title": "Error while loading data", "unknownMessage": "An unknown error occurred. Please try again later." From 86365345f71366187b97d37abb9fa75198ad817f Mon Sep 17 00:00:00 2001 From: Arnaud Fournier Date: Tue, 7 Jul 2026 08:42:31 +0200 Subject: [PATCH 10/10] remove unused import --- backend/all4trees/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/all4trees/views.py b/backend/all4trees/views.py index d38ab20b..80eba1cb 100644 --- a/backend/all4trees/views.py +++ b/backend/all4trees/views.py @@ -4,7 +4,7 @@ from pathlib import Path from rest_framework import status -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view from rest_framework.exceptions import NotFound, ValidationError from rest_framework.response import Response