From 71fb87d92d3925ed9e9179c0b8b682106016ca9d Mon Sep 17 00:00:00 2001 From: David Bretaud Date: Sun, 12 Jul 2026 16:46:21 +0200 Subject: [PATCH 1/4] feat(add-data): Add new feature to add data via file upload --- .../src/features/add-data/add-data-dialog.tsx | 214 ++++++++++++++++++ webapp/src/features/add-data/constants.ts | 24 ++ webapp/src/features/add-data/index.ts | 1 + webapp/src/features/add-data/use-add-data.ts | 91 ++++++++ webapp/src/shared/api/client.ts | 6 + .../shared/i18n/translations/en/common.json | 37 +++ .../shared/i18n/translations/fr/common.json | 37 +++ webapp/src/shared/ui/dialog.tsx | 120 ++++++++++ webapp/src/widgets/header/user-menu.tsx | 147 +++++++----- 9 files changed, 617 insertions(+), 60 deletions(-) create mode 100644 webapp/src/features/add-data/add-data-dialog.tsx create mode 100644 webapp/src/features/add-data/constants.ts create mode 100644 webapp/src/features/add-data/index.ts create mode 100644 webapp/src/features/add-data/use-add-data.ts create mode 100644 webapp/src/shared/ui/dialog.tsx diff --git a/webapp/src/features/add-data/add-data-dialog.tsx b/webapp/src/features/add-data/add-data-dialog.tsx new file mode 100644 index 00000000..38df7c14 --- /dev/null +++ b/webapp/src/features/add-data/add-data-dialog.tsx @@ -0,0 +1,214 @@ +import { AlertCircle, CheckCircle2, UploadIcon } from "lucide-react"; +import type { FC } from "react"; + +import { useTranslation } from "@i18n"; + +import { Alert, AlertDescription } from "@ui/alert"; +import { Button } from "@ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ui/dialog"; +import { Input } from "@ui/input"; +import { Label } from "@ui/label"; +import { RadioGroup, RadioGroupItem } from "@ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/select"; + +import { + ACCEPTED_EXTENSIONS, + ADD_DATA_FORMS, + type AddDataForm, + isCsvFileName, + RESOURCE_KINDS, + type ResourceKind, +} from "./constants"; +import { useAddData } from "./use-add-data"; + +interface AddDataDialogProps { + onOpenChange: (open: boolean) => void; + open: boolean; +} + +export const AddDataDialog: FC = ({ + onOpenChange, + open, +}) => { + const { t } = useTranslation("common"); + const { + error, + file, + form, + isLoading, + isSuccess, + kind, + reset, + resource, + setFile, + setForm, + setKind, + setResource, + submit, + } = useAddData(); + + // A single resource name only makes sense for CSV uploads. Excel/zip files + // can populate several resources, whose names coordo infers from the sheet + // (or inner file) names, so we hide the field and warn the user instead. + const isCsv = file ? isCsvFileName(file.name) : true; + + const handleOpenChange = (next: boolean) => { + if (!next) { + reset(); + } + onOpenChange(next); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + submit(); + }; + + return ( + + + + {t("addData.title")} + {t("addData.description")} + + +
+
+ + +
+ +
+ + setFile(event.target.files?.[0] ?? null)} + type="file" + /> +
+ +
+ + setKind(value as ResourceKind)} + value={kind} + > +
+ + +
+
+ + +
+
+
+ + {isCsv ? ( +
+ + setResource(event.target.value)} + placeholder={t("addData.field.resourcePlaceholder")} + value={resource} + /> +

+ {t("addData.resourceHint.csv")} +

+
+ ) : ( + + + + {t("addData.resourceHint.excel")} + + + )} + + {error && ( + + + {t(`addData.error.${error}`)} + + )} + + {isSuccess && ( + + + {t("addData.success")} + + )} + + + + +
+
+
+ ); +}; diff --git a/webapp/src/features/add-data/constants.ts b/webapp/src/features/add-data/constants.ts new file mode 100644 index 00000000..d02e02cd --- /dev/null +++ b/webapp/src/features/add-data/constants.ts @@ -0,0 +1,24 @@ +// Datapackage folders (under backend `catalog/`) a user can push new data into. +// The value is the catalog folder name, also used as the datapackage `form`. +export const ADD_DATA_FORMS = [ + "inventaire_for", + "inventaire_bio", + "enquete", + "seed", +] as const; + +export type AddDataForm = (typeof ADD_DATA_FORMS)[number]; + +// Kind of resource the uploaded file represents. +export const RESOURCE_KINDS = { + ExternalData: "external_data", + FormData: "form_data", +} as const; + +export type ResourceKind = (typeof RESOURCE_KINDS)[keyof typeof RESOURCE_KINDS]; + +// Extensions accepted by coordo's file loaders. +export const ACCEPTED_EXTENSIONS = ".csv,.xls,.xlsx,.zip"; + +export const isCsvFileName = (fileName: string): boolean => + fileName.toLowerCase().endsWith(".csv"); diff --git a/webapp/src/features/add-data/index.ts b/webapp/src/features/add-data/index.ts new file mode 100644 index 00000000..8cd2b8c6 --- /dev/null +++ b/webapp/src/features/add-data/index.ts @@ -0,0 +1 @@ +export { AddDataDialog } from "./add-data-dialog"; diff --git a/webapp/src/features/add-data/use-add-data.ts b/webapp/src/features/add-data/use-add-data.ts new file mode 100644 index 00000000..18d3d17c --- /dev/null +++ b/webapp/src/features/add-data/use-add-data.ts @@ -0,0 +1,91 @@ +import { useState } from "react"; + +import { useApi } from "@shared/hooks/useApi"; + +import { + type AddDataForm, + RESOURCE_KINDS, + type ResourceKind, +} from "./constants"; + +type AddDataError = "generic" | "missingFile" | "missingForm"; + +export const useAddData = () => { + const api = useApi(); + + const [form, setForm] = useState(""); + const [file, setFile] = useState(null); + const [kind, setKind] = useState(RESOURCE_KINDS.FormData); + const [resource, setResource] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setError(null); + setFile(null); + setForm(""); + setIsSuccess(false); + setKind(RESOURCE_KINDS.FormData); + setResource(""); + }; + + const submit = async () => { + setError(null); + setIsSuccess(false); + + if (!form) { + setError("missingForm"); + return; + } + if (!file) { + setError("missingFile"); + return; + } + + setIsLoading(true); + + // Field names are centralized here so they can be aligned with the + // reworked add-data endpoint in a single place. + const formData = new FormData(); + formData.append("action", "add"); + formData.append("file", file); + formData.append("form", form); + formData.append("kind", kind); + formData.append("package", `catalog/${form}`); + + // Optional: coordo infers the resource name from the file/sheet names + // when it is omitted. + const trimmedResource = resource.trim(); + if (trimmedResource) { + formData.append("resource", trimmedResource); + } + + try { + await api.addData(formData); + setFile(null); + setResource(""); + setIsSuccess(true); + } catch { + setError("generic"); + } finally { + setIsLoading(false); + } + }; + + return { + error, + file, + form, + isLoading, + isSuccess, + kind, + reset, + resource, + setFile, + setForm, + setKind, + setResource, + submit, + }; +}; diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index 3f2f4ada..2899c5ea 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -43,6 +43,12 @@ export const fetchJSONWithAuth = async ( ) => (await fetchWithAuth(endpoint, options, authToken)).json(); export const createApiClient = (authToken: string | null) => ({ + addData: (formData: FormData) => + fetchWithAuth( + `/maps/add-data/`, + { body: formData, method: "POST" }, + authToken, + ), getCatalogResource: (layerId: string, resourceName: string) => fetchJSONWithAuth(`/catalog/${layerId}/${resourceName}`, {}, authToken), getCatalogResourceList: (layerId: string, resourceList: string[]) => diff --git a/webapp/src/shared/i18n/translations/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index b519af44..287dd0c8 100644 --- a/webapp/src/shared/i18n/translations/en/common.json +++ b/webapp/src/shared/i18n/translations/en/common.json @@ -1,4 +1,40 @@ { + "addData": { + "button": { + "pending": "Uploading...", + "submit": "Add data" + }, + "description": "Upload a file to add data to a map layer.", + "error": { + "generic": "Upload failed. Please try again.", + "missingFile": "Please choose a file to upload.", + "missingForm": "Please select a layer to update." + }, + "field": { + "file": "File to upload", + "form": "Layer / Form to update", + "formPlaceholder": "Select a layer", + "kind": "Kind of resource", + "resource": "Resource name (optional)", + "resourcePlaceholder": "Leave empty to infer from file name" + }, + "forms": { + "enquete": "Household survey", + "inventaire_bio": "Biological inventory", + "inventaire_for": "Forest inventory", + "seed": "Reforestation" + }, + "kind": { + "externalData": "External data", + "formData": "Form data" + }, + "resourceHint": { + "csv": "Optional. If left empty, the resource name is inferred from the CSV file name.", + "excel": "Resource names will be inferred from the sheet names. Make sure your sheet names are correct before uploading." + }, + "success": "Data added successfully.", + "title": "Add data" + }, "auth": { "loginForm": { "button": { @@ -36,6 +72,7 @@ "system": "System" }, "menu": { + "addData": "Add data", "goToAdmin": "Admin", "language": "Language", "login": "Login", diff --git a/webapp/src/shared/i18n/translations/fr/common.json b/webapp/src/shared/i18n/translations/fr/common.json index facf510a..55275b32 100644 --- a/webapp/src/shared/i18n/translations/fr/common.json +++ b/webapp/src/shared/i18n/translations/fr/common.json @@ -1,4 +1,40 @@ { + "addData": { + "button": { + "pending": "Envoi en cours...", + "submit": "Ajouter les données" + }, + "description": "Importez un fichier pour ajouter des données à une couche de la carte.", + "error": { + "generic": "Échec de l'import. Veuillez réessayer.", + "missingFile": "Veuillez choisir un fichier à importer.", + "missingForm": "Veuillez sélectionner une couche à mettre à jour." + }, + "field": { + "file": "Fichier à importer", + "form": "Couche / Formulaire à mettre à jour", + "formPlaceholder": "Sélectionnez une couche", + "kind": "Type de ressource", + "resource": "Nom de la ressource (facultatif)", + "resourcePlaceholder": "Laissez vide pour déduire du nom du fichier" + }, + "forms": { + "enquete": "Enquête ménage", + "inventaire_bio": "Inventaire biologique", + "inventaire_for": "Inventaire forestier", + "seed": "Reboisement" + }, + "kind": { + "externalData": "Données externes", + "formData": "Données de formulaire" + }, + "resourceHint": { + "csv": "Facultatif. Si laissé vide, le nom de la ressource sera déduit du nom du fichier CSV.", + "excel": "Les noms des ressources seront déduits des noms des feuilles. Vérifiez les noms des feuilles avant l'import." + }, + "success": "Données ajoutées avec succès.", + "title": "Ajouter des données" + }, "auth": { "loginForm": { "button": { @@ -36,6 +72,7 @@ "system": "Système" }, "menu": { + "addData": "Ajouter des données", "goToAdmin": "Espace Admin", "language": "Langue", "login": "Connexion", diff --git a/webapp/src/shared/ui/dialog.tsx b/webapp/src/shared/ui/dialog.tsx new file mode 100644 index 00000000..0027b723 --- /dev/null +++ b/webapp/src/shared/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/webapp/src/widgets/header/user-menu.tsx b/webapp/src/widgets/header/user-menu.tsx index 66f04deb..6fe91b78 100644 --- a/webapp/src/widgets/header/user-menu.tsx +++ b/webapp/src/widgets/header/user-menu.tsx @@ -1,7 +1,15 @@ -import { DatabaseIcon, LogOutIcon, SettingsIcon, UserIcon } from "lucide-react"; +import { + DatabaseIcon, + LogOutIcon, + MapPinPlusIcon, + SettingsIcon, + UserIcon, +} from "lucide-react"; import type { FC } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { AddDataDialog } from "@features/add-data"; import { useCreateAdminSession } from "@features/admin/use-create-admin-session"; import { useAuth } from "@features/auth"; @@ -27,73 +35,92 @@ export const UserMenu: FC = () => { const { isAuthenticated, logout, token } = useAuth(); const { isLoading, createAdminSession } = useCreateAdminSession(token); + const [isAddDataOpen, setIsAddDataOpen] = useState(false); + const absoluteUrls = useAbsoluteUrls(); const onLogin = () => navigate(absoluteUrls.LOGIN); return ( - - - + + + - - User menu - - - - - - - - - - - {isAuthenticated ? ( - <> + + + + + + + {isAuthenticated ? ( + <> + setIsAddDataOpen(true)} + > + + {t("header.menu.addData")} + + + + + {t("header.menu.goToAdmin")} + + + alert("Not implemented yet")} + > + + {t("header.menu.seeDatabase")} + + + + + + + {t("header.menu.logout")} + + + ) : ( - - {t("header.menu.goToAdmin")} + + {t("header.menu.login")} - - alert("Not implemented yet")} - > - - {t("header.menu.seeDatabase")} - - - - - - - {t("header.menu.logout")} - - - ) : ( - - - {t("header.menu.login")} - - )} - - + )} + + + + {isAuthenticated && ( + + )} + ); }; From 639c37df52603651bc1ba2a8c58410c551a8968e Mon Sep 17 00:00:00 2001 From: David Bretaud Date: Sat, 18 Jul 2026 19:53:50 +0200 Subject: [PATCH 2/4] fix(add-data): Make layer config agnostic --- .../src/app/routes/app-router-all4trees.tsx | 15 +++++++++++++ .../src/features/add-data/add-data-dialog.tsx | 21 +++++++++++-------- webapp/src/features/add-data/constants.ts | 11 ---------- webapp/src/features/add-data/index.ts | 2 +- webapp/src/features/add-data/use-add-data.ts | 8 ++----- .../shared/i18n/translations/en/common.json | 6 ------ .../shared/i18n/translations/fr/common.json | 6 ------ webapp/src/widgets/header/index.tsx | 12 +++++++---- webapp/src/widgets/header/user-menu.tsx | 11 +++++++--- 9 files changed, 46 insertions(+), 46 deletions(-) diff --git a/webapp/src/app/routes/app-router-all4trees.tsx b/webapp/src/app/routes/app-router-all4trees.tsx index 27c454d7..0834e87e 100644 --- a/webapp/src/app/routes/app-router-all4trees.tsx +++ b/webapp/src/app/routes/app-router-all4trees.tsx @@ -2,12 +2,17 @@ import { lazy } from "react"; import { MapProviderAll4Trees } from "@app/providers/map-provider-all4trees"; +import { LAYERS } from "@shared/api/layers"; +import { useTranslation } from "@shared/i18n"; + import { AppRouterBase } from "./app-router-base"; const MainPage = lazy(() => import("@pages/all4trees/main")); const DashboardPage = lazy(() => import("@pages/all4trees/dashboard")); export const AppRouterAll4Trees = () => { + const { t } = useTranslation("all4trees"); + return ( { MapProvider={MapProviderAll4Trees} rootLayoutProps={{ hasDashboard: true, + layerOptions: [ + { + translation: t("filters.categories.actions.forestInventary"), + value: LAYERS.INVENTARY, + }, + { + translation: t("filters.categories.actions.socioEco"), + value: LAYERS.ENQUETE, + }, + ], logoSrc: "/logo_all4trees.png", }} /> diff --git a/webapp/src/features/add-data/add-data-dialog.tsx b/webapp/src/features/add-data/add-data-dialog.tsx index 38df7c14..3f2ef6b7 100644 --- a/webapp/src/features/add-data/add-data-dialog.tsx +++ b/webapp/src/features/add-data/add-data-dialog.tsx @@ -26,20 +26,23 @@ import { import { ACCEPTED_EXTENSIONS, - ADD_DATA_FORMS, - type AddDataForm, isCsvFileName, RESOURCE_KINDS, type ResourceKind, } from "./constants"; import { useAddData } from "./use-add-data"; -interface AddDataDialogProps { +// The value is the catalog folder name +export type LayerOptions = Array<{ value: string; translation: string }>; + +type AddDataDialogProps = { + layerOptions: LayerOptions; onOpenChange: (open: boolean) => void; open: boolean; -} +}; export const AddDataDialog: FC = ({ + layerOptions, onOpenChange, open, }) => { @@ -95,19 +98,19 @@ export const AddDataDialog: FC = ({
-
- - setFile(event.target.files?.[0] ?? null)} - type="file" - /> -
-
= ({
- {isCsv ? ( -
- - setResource(event.target.value)} - placeholder={t("addData.field.resourcePlaceholder")} - value={resource} - /> -

- {t("addData.resourceHint.csv")} -

-
- ) : ( - - - - {t("addData.resourceHint.excel")} - - - )} +
+ + setFile(event.target.files?.[0] ?? null)} + type="file" + /> +

+ {isFormData + ? t("addData.hint.formData") + : t("addData.hint.externalData")} +

+
{error && ( - {t(`addData.error.${error}`)} + + {t(`addData.error.${error}`)} + )} {isSuccess && ( - {t("addData.success")} + + {t("addData.success")} + )} diff --git a/webapp/src/features/add-data/constants.ts b/webapp/src/features/add-data/constants.ts index f1a5bed3..6b182de4 100644 --- a/webapp/src/features/add-data/constants.ts +++ b/webapp/src/features/add-data/constants.ts @@ -6,8 +6,15 @@ export const RESOURCE_KINDS = { export type ResourceKind = (typeof RESOURCE_KINDS)[keyof typeof RESOURCE_KINDS]; -// Extensions accepted by coordo's file loaders. -export const ACCEPTED_EXTENSIONS = ".csv,.xls,.xlsx,.zip"; +// coordo loader the backend selects, per resource kind. cf LoaderType +export const RESOURCE_TYPE_BY_KIND = { + [RESOURCE_KINDS.ExternalData]: "file", + [RESOURCE_KINDS.FormData]: "kobotoolbox", +} as const; -export const isCsvFileName = (fileName: string): boolean => - fileName.toLowerCase().endsWith(".csv"); +// Accepted upload extensions per kind (coordo's file / kobotoolbox loaders). +// Form data is the multi-sheet KoboToolbox answers workbook (.xlsx). +export const ACCEPTED_EXTENSIONS_BY_KIND = { + [RESOURCE_KINDS.ExternalData]: ".csv,.xls,.xlsx", + [RESOURCE_KINDS.FormData]: ".xlsx", +} as const; diff --git a/webapp/src/features/add-data/use-add-data.ts b/webapp/src/features/add-data/use-add-data.ts index 8acc2a8d..da28bd15 100644 --- a/webapp/src/features/add-data/use-add-data.ts +++ b/webapp/src/features/add-data/use-add-data.ts @@ -2,7 +2,11 @@ import { useState } from "react"; import { useApi } from "@shared/hooks/useApi"; -import { RESOURCE_KINDS, type ResourceKind } from "./constants"; +import { + RESOURCE_KINDS, + RESOURCE_TYPE_BY_KIND, + type ResourceKind, +} from "./constants"; type AddDataError = "generic" | "missingFile" | "missingForm"; @@ -12,7 +16,6 @@ export const useAddData = () => { const [form, setForm] = useState(""); const [file, setFile] = useState(null); const [kind, setKind] = useState(RESOURCE_KINDS.FormData); - const [resource, setResource] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [error, setError] = useState(null); @@ -23,7 +26,6 @@ export const useAddData = () => { setForm(""); setIsSuccess(false); setKind(RESOURCE_KINDS.FormData); - setResource(""); }; const submit = async () => { @@ -41,26 +43,14 @@ export const useAddData = () => { setIsLoading(true); - // Field names are centralized here so they can be aligned with the - // reworked add-data endpoint in a single place. const formData = new FormData(); - formData.append("action", "add"); - formData.append("file", file); - formData.append("form", form); - formData.append("kind", kind); + formData.append("resource_type", RESOURCE_TYPE_BY_KIND[kind]); formData.append("package", `catalog/${form}`); - - // Optional: coordo infers the resource name from the file/sheet names - // when it is omitted. - const trimmedResource = resource.trim(); - if (trimmedResource) { - formData.append("resource", trimmedResource); - } + formData.append("data", file); try { - await api.addData(formData); + await api.appendData(formData); setFile(null); - setResource(""); setIsSuccess(true); } catch { setError("generic"); @@ -77,11 +67,9 @@ export const useAddData = () => { isSuccess, kind, reset, - resource, setFile, setForm, setKind, - setResource, submit, }; }; diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index 2899c5ea..e496dd04 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -43,9 +43,9 @@ export const fetchJSONWithAuth = async ( ) => (await fetchWithAuth(endpoint, options, authToken)).json(); export const createApiClient = (authToken: string | null) => ({ - addData: (formData: FormData) => + appendData: (formData: FormData) => fetchWithAuth( - `/maps/add-data/`, + `/maps/append-data/`, { body: formData, method: "POST" }, authToken, ), diff --git a/webapp/src/shared/i18n/translations/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index 3b81d641..6f20ab36 100644 --- a/webapp/src/shared/i18n/translations/en/common.json +++ b/webapp/src/shared/i18n/translations/en/common.json @@ -14,18 +14,16 @@ "file": "File to upload", "form": "Layer / Form to update", "formPlaceholder": "Select a layer", - "kind": "Kind of resource", - "resource": "Resource name (optional)", - "resourcePlaceholder": "Leave empty to infer from file name" + "kind": "Kind of resource" + }, + "hint": { + "externalData": "The file name must match the target resource (e.g. \"for_dw.csv\"); Excel sheets are matched by sheet name.", + "formData": "Upload the KoboToolbox answers workbook (e.g. \"…DonneesK.xlsx\"). Rows are appended to the layer's resources." }, "kind": { "externalData": "External data", "formData": "Form data" }, - "resourceHint": { - "csv": "Optional. If left empty, the resource name is inferred from the CSV file name.", - "excel": "Resource names will be inferred from the sheet names. Make sure your sheet names are correct before uploading." - }, "success": "Data added successfully.", "title": "Add data" }, diff --git a/webapp/src/shared/i18n/translations/fr/common.json b/webapp/src/shared/i18n/translations/fr/common.json index afbbad69..342dff2a 100644 --- a/webapp/src/shared/i18n/translations/fr/common.json +++ b/webapp/src/shared/i18n/translations/fr/common.json @@ -14,18 +14,16 @@ "file": "Fichier à importer", "form": "Couche / Formulaire à mettre à jour", "formPlaceholder": "Sélectionnez une couche", - "kind": "Type de ressource", - "resource": "Nom de la ressource (facultatif)", - "resourcePlaceholder": "Laissez vide pour déduire du nom du fichier" + "kind": "Type de ressource" + }, + "hint": { + "externalData": "Le nom du fichier doit correspondre à la ressource cible (ex. « for_dw.csv ») ; les feuilles Excel sont associées par nom de feuille.", + "formData": "Importez le classeur de réponses KoboToolbox (ex. « …DonneesK.xlsx »). Les lignes sont ajoutées aux ressources de la couche." }, "kind": { "externalData": "Données externes", "formData": "Données de formulaire" }, - "resourceHint": { - "csv": "Facultatif. Si laissé vide, le nom de la ressource sera déduit du nom du fichier CSV.", - "excel": "Les noms des ressources seront déduits des noms des feuilles. Vérifiez les noms des feuilles avant l'import." - }, "success": "Données ajoutées avec succès.", "title": "Ajouter des données" }, From 87cfef357d20da1a133cf11b2613255b9894b8a2 Mon Sep 17 00:00:00 2001 From: David Bretaud Date: Sat, 25 Jul 2026 19:27:24 +0200 Subject: [PATCH 4/4] fix: Improve translations, add bio layer, fix backend --- backend/maps/views.py | 2 +- webapp/src/app/routes/app-router-all4trees.tsx | 8 ++++++-- webapp/src/app/styles/all4trees.css | 4 ++++ webapp/src/app/styles/index.css | 2 ++ webapp/src/features/add-data/add-data-dialog.tsx | 15 ++++++++++++--- webapp/src/features/add-data/constants.ts | 15 +++++++++++++-- webapp/src/features/add-data/use-add-data.ts | 1 + .../src/shared/i18n/translations/en/common.json | 11 ++++++----- .../src/shared/i18n/translations/fr/common.json | 11 ++++++----- webapp/src/shared/ui/alert.tsx | 2 ++ 10 files changed, 53 insertions(+), 18 deletions(-) diff --git a/backend/maps/views.py b/backend/maps/views.py index d0a4bfe2..152bab8b 100644 --- a/backend/maps/views.py +++ b/backend/maps/views.py @@ -109,7 +109,7 @@ def remove_foreign_key_view(request): def get_map(user): user_map = copy(map) filter = '' - if user.is_authenticated: + if user.is_authenticated and bool(user.project): project = user.project if (project.lower() != ADMIN_PROJECT): filter = f"proj = '{project}' or conf = 1" diff --git a/webapp/src/app/routes/app-router-all4trees.tsx b/webapp/src/app/routes/app-router-all4trees.tsx index 0834e87e..73ef6785 100644 --- a/webapp/src/app/routes/app-router-all4trees.tsx +++ b/webapp/src/app/routes/app-router-all4trees.tsx @@ -22,8 +22,12 @@ export const AppRouterAll4Trees = () => { hasDashboard: true, layerOptions: [ { - translation: t("filters.categories.actions.forestInventary"), - value: LAYERS.INVENTARY, + translation: t("filters.categories.actions.forestInventory"), + value: LAYERS.INVENTORY_FOR, + }, + { + translation: t("filters.categories.actions.treeDiversity"), + value: LAYERS.INVENTORY_BIO, }, { translation: t("filters.categories.actions.socioEco"), diff --git a/webapp/src/app/styles/all4trees.css b/webapp/src/app/styles/all4trees.css index cf57a531..1f17d62e 100644 --- a/webapp/src/app/styles/all4trees.css +++ b/webapp/src/app/styles/all4trees.css @@ -91,6 +91,8 @@ --destructive: #dc2626; --info: var(--custom-color-blue-light); --info-foreground: var(--custom-color-blue-dark); + --success: rgb(237, 247, 237); + --success-foreground: #2e7d32; --border: rgba(0, 0, 0, 0.15); --input: #ffffff; @@ -135,6 +137,8 @@ --destructive: #ef4444; --info: var(--custom-color-blue-dark); --info-foreground: var(--custom-color-blue-light); + --success: rgb(12, 19, 13); + --success-foreground: #66bb6a; --border: rgba(255, 255, 255, 0.15); --input: rgba(255, 255, 255, 0.05); diff --git a/webapp/src/app/styles/index.css b/webapp/src/app/styles/index.css index 49f14edb..0f90e381 100644 --- a/webapp/src/app/styles/index.css +++ b/webapp/src/app/styles/index.css @@ -58,6 +58,8 @@ --color-destructive: var(--destructive); --color-info: var(--info); --color-info-foreground: var(--info-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); --color-border: var(--border); --color-input: var(--input); diff --git a/webapp/src/features/add-data/add-data-dialog.tsx b/webapp/src/features/add-data/add-data-dialog.tsx index 3d250aac..d58fe6fd 100644 --- a/webapp/src/features/add-data/add-data-dialog.tsx +++ b/webapp/src/features/add-data/add-data-dialog.tsx @@ -3,7 +3,7 @@ import type { FC } from "react"; import { useTranslation } from "@i18n"; -import { Alert, AlertDescription } from "@ui/alert"; +import { Alert, AlertAction, AlertDescription } from "@ui/alert"; import { Button } from "@ui/button"; import { Dialog, @@ -147,7 +147,7 @@ export const AddDataDialog: FC = ({
setFile(event.target.files?.[0] ?? null)} @@ -170,11 +170,20 @@ export const AddDataDialog: FC = ({ )} {isSuccess && ( - + {t("addData.success")} + + + )} diff --git a/webapp/src/features/add-data/constants.ts b/webapp/src/features/add-data/constants.ts index 6b182de4..b3a6c084 100644 --- a/webapp/src/features/add-data/constants.ts +++ b/webapp/src/features/add-data/constants.ts @@ -15,6 +15,17 @@ export const RESOURCE_TYPE_BY_KIND = { // Accepted upload extensions per kind (coordo's file / kobotoolbox loaders). // Form data is the multi-sheet KoboToolbox answers workbook (.xlsx). export const ACCEPTED_EXTENSIONS_BY_KIND = { - [RESOURCE_KINDS.ExternalData]: ".csv,.xls,.xlsx", - [RESOURCE_KINDS.FormData]: ".xlsx", + [RESOURCE_KINDS.ExternalData]: [ + ".csv", + ".tsv", + ".tab", + ".xlsx", + ".xls", + ".xlsm", + ".xlsb", + ".odf", + ".ods", + ".odt", + ], + [RESOURCE_KINDS.FormData]: [".csv", ".xlsx"], } as const; diff --git a/webapp/src/features/add-data/use-add-data.ts b/webapp/src/features/add-data/use-add-data.ts index da28bd15..7f99b6d7 100644 --- a/webapp/src/features/add-data/use-add-data.ts +++ b/webapp/src/features/add-data/use-add-data.ts @@ -51,6 +51,7 @@ export const useAddData = () => { try { await api.appendData(formData); setFile(null); + setForm(""); setIsSuccess(true); } catch { setError("generic"); diff --git a/webapp/src/shared/i18n/translations/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index 6f20ab36..f9d753ad 100644 --- a/webapp/src/shared/i18n/translations/en/common.json +++ b/webapp/src/shared/i18n/translations/en/common.json @@ -4,26 +4,27 @@ "pending": "Uploading...", "submit": "Add data" }, - "description": "Upload a file to add data to a map layer.", + "description": "Upload a file to add data to the map.", "error": { "generic": "Upload failed. Please try again.", "missingFile": "Please choose a file to upload.", - "missingForm": "Please select a layer to update." + "missingForm": "Please select a data source to update." }, "field": { "file": "File to upload", - "form": "Layer / Form to update", - "formPlaceholder": "Select a layer", + "form": "Data source to update", + "formPlaceholder": "Select a data source", "kind": "Kind of resource" }, "hint": { "externalData": "The file name must match the target resource (e.g. \"for_dw.csv\"); Excel sheets are matched by sheet name.", - "formData": "Upload the KoboToolbox answers workbook (e.g. \"…DonneesK.xlsx\"). Rows are appended to the layer's resources." + "formData": "Upload the KoboToolbox answers workbook (e.g. \"…DonneesK.xlsx\"). Rows are appended to the data source." }, "kind": { "externalData": "External data", "formData": "Form data" }, + "refreshData": "Reload", "success": "Data added successfully.", "title": "Add data" }, diff --git a/webapp/src/shared/i18n/translations/fr/common.json b/webapp/src/shared/i18n/translations/fr/common.json index 342dff2a..ba82f8e2 100644 --- a/webapp/src/shared/i18n/translations/fr/common.json +++ b/webapp/src/shared/i18n/translations/fr/common.json @@ -4,26 +4,27 @@ "pending": "Envoi en cours...", "submit": "Ajouter les données" }, - "description": "Importez un fichier pour ajouter des données à une couche de la carte.", + "description": "Importez un fichier pour ajouter des données à la carte.", "error": { "generic": "Échec de l'import. Veuillez réessayer.", "missingFile": "Veuillez choisir un fichier à importer.", - "missingForm": "Veuillez sélectionner une couche à mettre à jour." + "missingForm": "Veuillez sélectionner une source de données à mettre à jour." }, "field": { "file": "Fichier à importer", - "form": "Couche / Formulaire à mettre à jour", - "formPlaceholder": "Sélectionnez une couche", + "form": "Source de données à mettre à jour", + "formPlaceholder": "Sélectionnez une source de données", "kind": "Type de ressource" }, "hint": { "externalData": "Le nom du fichier doit correspondre à la ressource cible (ex. « for_dw.csv ») ; les feuilles Excel sont associées par nom de feuille.", - "formData": "Importez le classeur de réponses KoboToolbox (ex. « …DonneesK.xlsx »). Les lignes sont ajoutées aux ressources de la couche." + "formData": "Importez le classeur de réponses KoboToolbox (ex. « …DonneesK.xlsx »). Les lignes sont ajoutées à la source de données." }, "kind": { "externalData": "Données externes", "formData": "Données de formulaire" }, + "refreshData": "Rafraichir", "success": "Données ajoutées avec succès.", "title": "Ajouter des données" }, diff --git a/webapp/src/shared/ui/alert.tsx b/webapp/src/shared/ui/alert.tsx index f5550958..48decbc0 100644 --- a/webapp/src/shared/ui/alert.tsx +++ b/webapp/src/shared/ui/alert.tsx @@ -18,6 +18,8 @@ const alertVariants = cva( destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", info: "bg-info text-info-foreground [&>svg]:text-info-foreground", + success: + "bg-success text-success-foreground [&>svg]:text-success-foreground", }, }, },