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/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..80eba1cb 100644 --- a/backend/all4trees/views.py +++ b/backend/all4trees/views.py @@ -1,23 +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.decorators import api_view +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 catalog_view(request, layer_id, resource_name): +def resource_view(request, layer_id, resource_name): + 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"]) +def resource_list_view(request, layer_id): + 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(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + raise ValidationError("Request body must be valid JSON") + + resource_list = payload.get("resources") + if not isinstance(resource_list, list) or len(resource_list) < 1: + 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: + 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)) + + 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 - # 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) +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}" + } - 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 +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/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..10d33c2f 100644 --- a/webapp/src/app/providers/AuthProvider.tsx +++ b/webapp/src/app/providers/AuthProvider.tsx @@ -24,16 +24,16 @@ 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 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..9a7ddc7a 100644 --- a/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx +++ b/webapp/src/features/charts/biodiversity/chart-relative-abundance.tsx @@ -1,19 +1,23 @@ +import { findLabelInExternalData } from "@features/indicators/utils"; +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 { 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 = { + project: string; data: [string, number][]; - metadata: LayerMetadata; + externalData: ExternalData; }; export const ChartRelativeAbundance: ChartComponentType = ({ + project, data, - metadata, + externalData, }) => { const { t } = useTranslation("all4trees"); const smallCategoriesSum = Number( @@ -37,8 +41,13 @@ export const ChartRelativeAbundance: ChartComponentType = ({ ...chartConfig, [element.name]: { label: - findCategoricalLabel(metadata, "reg_sp", element.name) || - element.name, + findLabelInExternalData( + externalData, + "for_label", + project, + "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..3a5f3b57 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, + project: 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, project), 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, 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 4d2a0fbb..b6de8068 100644 --- a/webapp/src/features/charts/soil/lib/taxon.ts +++ b/webapp/src/features/charts/soil/lib/taxon.ts @@ -1,30 +1,48 @@ -import type { LayerMetadata } from "@shared/lib/coordo"; -import { findCategoricalLabel } from "@shared/lib/utils"; +import { findLabelInExternalData } from "@features/indicators/utils"; +import type { ExternalData } from "@features/popup/forest-inventory/types"; export function getTaxonLabels( element: string, - metadata: LayerMetadata, - dataType: "tsbf" | "barbA", + metadata: ExternalData, + project: string, ): [string, string, string] { const [taxon1, taxon2, taxon3] = element.split("-"); const taxon1Label = - findCategoricalLabel(metadata, `${dataType}_tax1`, taxon1) || taxon1; + findLabelInExternalData( + metadata, + "for_label", + project, + "tax1", + Number(taxon1), + ) || taxon1; const taxon2Label = - findCategoricalLabel(metadata, `${dataType}_tax2`, taxon2) || taxon2; + findLabelInExternalData( + metadata, + "for_label", + project, + "tax2", + Number(taxon2), + ) || taxon2; const taxon3Label = - findCategoricalLabel(metadata, `${dataType}_tax3`, taxon3) || taxon3; + findLabelInExternalData( + metadata, + "for_label", + project, + "tax3", + Number(taxon3), + ) || taxon3; return [taxon1Label, taxon2Label, taxon3Label]; } export function formatTaxonLevelLabel( element: string, - metadata: LayerMetadata, - dataType: "tsbf" | "barbA", + metadata: ExternalData, + project: string, ): string { const [taxon1Label, taxon2Label, taxon3Label] = getTaxonLabels( element, metadata, - dataType, + 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 ff8af292..581102a3 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; + 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 5442ca62..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, - dataType, + 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, dataType); + const nodes = buildSunburstNodes(filteredDataEntries, metadata, project); const nodeColors = buildNodeColors(nodes); sunburstData = [ diff --git a/webapp/src/features/fallback/error-boundary-fallback.tsx b/webapp/src/features/fallback/error-boundary-fallback.tsx new file mode 100644 index 00000000..461793b8 --- /dev/null +++ b/webapp/src/features/fallback/error-boundary-fallback.tsx @@ -0,0 +1,59 @@ +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 +// See https://react.dev/warnings/invalid-hook-call-warning +// > 🔴 Do not call Hooks in class components. +// while ErrorBoundary is a class component: https://github.com/bvaughn/react-error-boundary/blob/main/lib/components/ErrorBoundary.tsx#L45 +export function getFallbackRender({ + retry, + t, +}: { + retry?: () => void; + t: TFunction<"common", undefined>; +}) { + function FallbackRender({ error }: FallbackProps) { + const apiError = error as APIError; + const errorMessage = + apiError.status === 401 + ? t("error.pleaseLogin") + : (getErrorMessage(error) ?? t("error.unknownMessage")); + return ( + <> + + + + {t("error.title")} + + +
+
+

{errorMessage}

+ {retry && ( + + )} +
+
+ + ); + } + + return FallbackRender; +} 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/biodiversity/use-biodiversity-indicator-elements.tsx b/webapp/src/features/indicators/biodiversity/use-biodiversity-indicator-elements.tsx index 7f4bc190..2a4c5114 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..142f04bb 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/indicators/utils.ts b/webapp/src/features/indicators/utils.ts index 04d0b768..5efb824b 100644 --- a/webapp/src/features/indicators/utils.ts +++ b/webapp/src/features/indicators/utils.ts @@ -1,4 +1,10 @@ +import type { + ExternalData, + LabelData, +} 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 +117,53 @@ 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: 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, + ); + + return record?.label; +} diff --git a/webapp/src/features/popup/components/popup.tsx b/webapp/src/features/popup/components/popup.tsx new file mode 100644 index 00000000..c52836cb --- /dev/null +++ b/webapp/src/features/popup/components/popup.tsx @@ -0,0 +1,83 @@ +import { type FC, Suspense, useCallback, useMemo, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { useTranslation } from "react-i18next"; + +import { getFallbackRender } from "@features/fallback/error-boundary-fallback"; +import Loading from "@features/fallback/loading"; + +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(() => { + setReloadKey((k) => k + 1); + }, []); + const fallbackRender = useMemo( + () => getFallbackRender({ retry, t }), + [retry, t], + ); + + return ( +
+ + }> + + + +
+ ); +}; + +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; + }); + cache.set(promiseFunc, promise); + + return promise; +} 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..1eed5c83 100644 --- a/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx +++ b/webapp/src/features/popup/forest-inventory/popup-forest-inventory.tsx @@ -1,18 +1,19 @@ import { cx } from "class-variance-authority"; import { TreesIcon } from "lucide-react"; -import { Activity, type FC, useState } from "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 { IndicatorPopupHeader } from "@features/popup/components/indicator-popup-header"; +import { findLabelInExternalData } from "@features/indicators/utils"; -import { findCategoricalLabel, formatDate } from "@shared/lib/utils"; +import { formatDate } from "@shared/lib/utils"; import { GridSelector } from "@shared/ui/grid-selector"; -import { useTranslation } from "@i18n"; -import { useBiodiversityIndicatorElements } from "../../indicators/biodiversity/use-biodiversity-indicator-elements"; +import { IndicatorPopupHeader } from "../components/indicator-popup-header"; import type { RenderPopupProps } from "../renderPopup"; import type { ForestInventoryData } from "./types"; @@ -27,17 +28,11 @@ const TABS: Record = { export const ForestInventoryPopupContent: FC< ForestInventoryPopupContentProps -> = ({ data, metadata, className, ...headerProps }) => { +> = ({ data, metadata, externalDataPromise, className, ...headerProps }) => { const { t } = useTranslation(["common", "all4trees"]); const [selectedTab, setSelectedTab] = useState(TABS.BIODIVERSITY); + const externalData = use(externalDataPromise); - const biodiversityElements = useBiodiversityIndicatorElements(data, metadata); - const soilElements = useSoilIndicatorElements(data, metadata); - - const title = t("popup.forestInventory.title", { - id: data.id, - ns: "all4trees", - }); const tabs = { [TABS.BIODIVERSITY]: t("indicators.biodiversity.title", { ns: "all4trees", @@ -47,6 +42,25 @@ export const ForestInventoryPopupContent: FC< }), }; + const title = t("popup.forestInventory.title", { + id: data.id, + ns: "all4trees", + }); + + const subtitle = + findLabelInExternalData( + externalData, + "for_label", + data.project, + "loc2", + Number(data.for), + ) || t("dataManagement.undefined", { ns: "common" }); + + const biodiversityElements = useBiodiversityIndicatorElements( + data, + externalData, + ); + const soilElements = useSoilIndicatorElements(data, externalData); return (
} - subtitle={ - findCategoricalLabel(metadata, "loc2", data.for) || - t("dataManagement.undefined", { ns: "common" }) - } + subtitle={subtitle} title={title} {...headerProps} /> diff --git a/webapp/src/features/popup/forest-inventory/types.ts b/webapp/src/features/popup/forest-inventory/types.ts index 16b9fd10..252caa7b 100644 --- a/webapp/src/features/popup/forest-inventory/types.ts +++ b/webapp/src/features/popup/forest-inventory/types.ts @@ -5,7 +5,24 @@ export type ForestInventoryData = { id: string; for: string; cod: number; - projet: string; + project: string; taille_placette: number; } & SoilData & BiodiversityData; + +export type ExternalData = { + for_label: LabelData[]; + for_mf_tax1: any[]; + 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; +}; diff --git a/webapp/src/features/popup/renderPopup.tsx b/webapp/src/features/popup/renderPopup.tsx index b66f57ab..f65a7ecf 100644 --- a/webapp/src/features/popup/renderPopup.tsx +++ b/webapp/src/features/popup/renderPopup.tsx @@ -4,6 +4,8 @@ import { createRoot } from "react-dom/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 { @@ -30,29 +32,37 @@ export type RenderPopupProps = { className: string; data: T; metadata: LayerMetadata; + externalDataPromise: Promise; } & Pick; +export type GetExternalData = () => Promise; + export function getRenderPopupLayer({ Element, toggleShiftSize, + getExternalData, }: { Element: FC>; toggleShiftSize: IndicatorPopupHeaderProps["toggleShiftSize"]; + 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/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/api/client.ts b/webapp/src/shared/api/client.ts index 038c75e0..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; } @@ -40,6 +43,17 @@ export const fetchJSONWithAuth = async ( ) => (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/shared/i18n/translations/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index 25e8ef70..00ddf154 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": { + "pleaseLogin": "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." 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; +}; diff --git a/webapp/src/shared/lib/utils.ts b/webapp/src/shared/lib/utils.ts index 0fa24d05..438e42ec 100644 --- a/webapp/src/shared/lib/utils.ts +++ b/webapp/src/shared/lib/utils.ts @@ -2,7 +2,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import { i18nInstance } from "@shared/i18n"; -import type { LayerMetadata } from "@shared/lib/coordo"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -23,28 +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; -} diff --git a/webapp/src/widgets/dashboard/dashboard.tsx b/webapp/src/widgets/dashboard/dashboard.tsx index 3d496a1c..7c2c3291 100644 --- a/webapp/src/widgets/dashboard/dashboard.tsx +++ b/webapp/src/widgets/dashboard/dashboard.tsx @@ -1,11 +1,12 @@ 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 Loading from "@features/fallback/loading"; import { LAYERS } from "@shared/api/layers"; import { useApi } from "@shared/hooks/useApi"; diff --git a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx b/webapp/src/widgets/dashboard/error-boundary-fallback.tsx deleted file mode 100644 index 29bb61d1..00000000 --- a/webapp/src/widgets/dashboard/error-boundary-fallback.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { TFunction } from "i18next"; -import { type FallbackProps, getErrorMessage } from "react-error-boundary"; - -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 -// See https://react.dev/warnings/invalid-hook-call-warning -// > 🔴 Do not call Hooks in class components. -// while ErrorBoundary is a class component: https://github.com/bvaughn/react-error-boundary/blob/main/lib/components/ErrorBoundary.tsx#L45 -export function getFallbackRender({ - retry, - t, -}: { - retry?: () => void; - t: TFunction<"common", undefined>; -}) { - function FallbackRender({ error }: FallbackProps) { - const errorMessage = getErrorMessage(error) ?? t("error.unknownMessage"); - - return ( -
-

{t("error.title")}

-

{errorMessage}

- {retry && ( - - )} -
- ); - } - - return FallbackRender; -} diff --git a/webapp/src/widgets/map/map-all4trees.tsx b/webapp/src/widgets/map/map-all4trees.tsx index 0f509822..ce7544b3 100644 --- a/webapp/src/widgets/map/map-all4trees.tsx +++ b/webapp/src/widgets/map/map-all4trees.tsx @@ -16,19 +16,22 @@ 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"; import { MapBase } from "./map-base"; -import { getIconSize } from "./utils"; +import { getExternalDataPromiseByLayer, 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(() => { if (!isReady || !mapApiRef.current) return; - const toggleShiftSize = () => setIsMaximizedPopupSize((prev) => !prev); mapApiRef.current.setLayerSymbol({ @@ -50,6 +53,7 @@ export const MapAll4Trees: FC = () => { popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ Element: ForestInventoryPopupContent, + getExternalData: getExternalDataPromiseByLayer(LAYERS.INVENTARY, api), toggleShiftSize, }), trigger: "click", @@ -62,6 +66,7 @@ export const MapAll4Trees: FC = () => { popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ Element: SocioEcoIndicator, + getExternalData: getExternalDataPromiseByLayer(LAYERS.ENQUETE, api), toggleShiftSize, }), trigger: "click", @@ -74,11 +79,12 @@ export const MapAll4Trees: FC = () => { popupConfig: DEFAULT_POPUP_CONFIG, renderCallback: getRenderPopupLayer({ Element: SeedIndicator, + getExternalData: getExternalDataPromiseByLayer(LAYERS.SEED_POINT, api), toggleShiftSize, }), trigger: "click", }); - }, [isReady, mapApiRef]); + }, [isReady, mapApiRef, api]); const filterByForest = (forestId: string) => { mapApiRef.current?.setLayerFilters({ 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({}); +};