diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 0205b8a0fd..f3d8e241a9 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -70,6 +70,7 @@ "global.assign": "Assign", "global.undo": "Undo", "global.redo": "Redo", + "global.total": "Total", "global.time.hourly": "Hourly", "global.time.daily": "Daily", "global.time.weekly": "Weekly", @@ -81,6 +82,8 @@ "global.error.failedtoretrievejobs": "Failed to retrieve job information", "global.error.failedtoretrievelogs": "Failed to retrieve job logs", "global.error.failedtoretrievedownloads": "Failed to retrieve downloads list", + "global.error.create": "Creation failed", + "global.error.delete": "Deletion failed", "global.area.add": "Add an area", "login.error": "Failed to authenticate", "tasks.title": "Tasks", @@ -94,6 +97,7 @@ "data.title": "Data", "dialog.title.confirmation": "Confirmation", "dialog.message.logout": "Are you sure you want to logout?", + "dialog.message.confirmDelete": "Do you confirm the deletion?", "button.collapse": "Collapse", "button.expand": "Expand", "button.yes": "Yes", @@ -117,7 +121,7 @@ "form.submit.inProgress": "The form is being submitted. Are you sure you want to leave the page?", "form.asyncDefaultValues.error": "Failed to get values", "form.field.required": "Field required", - "form.field.duplicate": "Value already exists: {{0}}", + "form.field.duplicate": "Value already exists", "form.field.minLength": "{{0}} character(s) minimum", "form.field.minValue": "The minimum value is {{0}}", "form.field.maxValue": "The maximum value is {{0}}", @@ -486,8 +490,8 @@ "study.modelization.clusters.matrix.timeSeries": "Time-Series", "study.modelization.clusters.backClusterList": "Back to cluster list", "study.modelization.clusters.tsInterpretation": "TS interpretation", - "study.modelization.clusters.group": "Group", - "studies.modelization.clusters.question.delete": "Are you sure you want to delete this cluster?", + "studies.modelization.clusters.question.delete_one": "Are you sure you want to delete this cluster?", + "studies.modelization.clusters.question.delete_other": "Are you sure you want to delete these {{count}} clusters?", "study.modelization.bindingConst.comments": "Comments", "study.modelization.bindingConst.type": "Type", "study.modelization.bindingConst.constraints": "Constraints", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 86768663c2..bec0e911cc 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -70,6 +70,7 @@ "global.assign": "Assigner", "global.undo": "Annuler", "global.redo": "Rétablir", + "global.total": "Total", "global.time.hourly": "Horaire", "global.time.daily": "Journalier", "global.time.weekly": "Hebdomadaire", @@ -81,6 +82,8 @@ "global.error.failedtoretrievejobs": "Échec de la récupération des tâches", "global.error.failedtoretrievelogs": "Échec de la récupération des logs", "global.error.failedtoretrievedownloads": "Échec de la récupération des exports", + "global.error.create": "La création a échoué", + "global.error.delete": "La suppression a échoué", "global.area.add": "Ajouter une zone", "login.error": "Échec de l'authentification", "tasks.title": "Tâches", @@ -94,6 +97,7 @@ "data.title": "Données", "dialog.title.confirmation": "Confirmation", "dialog.message.logout": "Êtes vous sûr de vouloir vous déconnecter ?", + "dialog.message.confirmDelete": "Confirmez-vous la suppression ?", "button.collapse": "Réduire", "button.expand": "Étendre", "button.yes": "Oui", @@ -117,7 +121,7 @@ "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sûr de vouloir quitter la page ?", "form.asyncDefaultValues.error": "Impossible d'obtenir les valeurs", "form.field.required": "Champ requis", - "form.field.duplicate": "Cette valeur existe déjà: {{0}}", + "form.field.duplicate": "Cette valeur existe déjà", "form.field.minLength": "{{0}} caractère(s) minimum", "form.field.minValue": "La valeur minimum est {{0}}", "form.field.maxValue": "La valeur maximum est {{0}}", @@ -486,8 +490,8 @@ "study.modelization.clusters.matrix.timeSeries": "Séries temporelles", "study.modelization.clusters.backClusterList": "Retour à la liste des clusters", "study.modelization.clusters.tsInterpretation": "TS interpretation", - "study.modelization.clusters.group": "Groupes", - "studies.modelization.clusters.question.delete": "Êtes-vous sûr de vouloir supprimer ce cluster ?", + "studies.modelization.clusters.question.delete_one": "Êtes-vous sûr de vouloir supprimer ce cluster ?", + "studies.modelization.clusters.question.delete_other": "Êtes-vous sûr de vouloir supprimer ces {{count}} clusters ?", "study.modelization.bindingConst.comments": "Commentaires", "study.modelization.bindingConst.type": "Type", "study.modelization.bindingConst.constraints": "Contraintes", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx index 16e45f7c53..7b7ea9774c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx @@ -29,7 +29,7 @@ function Fields() { disabled /> <SelectFE - label={t("study.modelization.clusters.group")} + label={t("global.group")} name="group" control={control} options={RENEWABLE_GROUPS} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx index c66c2b388a..d6f987753a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx @@ -1,144 +1,100 @@ -import { useMemo } from "react"; -import { MRT_ColumnDef } from "material-react-table"; -import { Box, Chip } from "@mui/material"; +import { useMemo, useState } from "react"; +import { createMRTColumnHelper } from "material-react-table"; +import { Box } from "@mui/material"; import { useLocation, useNavigate, useOutletContext } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { StudyMetadata } from "../../../../../../../common/types"; import { RENEWABLE_GROUPS, - RenewableCluster, - RenewableClusterWithCapacity, + RenewableGroup, createRenewableCluster, deleteRenewableClusters, + duplicateRenewableCluster, getRenewableClusters, + type RenewableClusterWithCapacity, } from "./utils"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import GroupedDataTable from "../../../../../../common/GroupedDataTable"; import { + addClusterCapacity, capacityAggregationFn, - useClusterDataWithCapacity, -} from "../common/utils"; -import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; -import SimpleContent from "../../../../../../common/page/SimpleContent"; -import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; + getClustersWithCapacityTotals, +} from "../common/clustersUtils"; +import { TRow } from "../../../../../../common/GroupedDataTable/types"; +import BooleanCell from "../../../../../../common/GroupedDataTable/cellRenderers/BooleanCell"; +import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError"; + +const columnHelper = createMRTColumnHelper<RenewableClusterWithCapacity>(); function Renewables() { const { study } = useOutletContext<{ study: StudyMetadata }>(); - const [t] = useTranslation(); - const areaId = useAppSelector(getCurrentAreaId); + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); + const areaId = useAppSelector(getCurrentAreaId); - const { - clusters, - clustersWithCapacity, - totalUnitCount, - totalInstalledCapacity, - totalEnabledCapacity, - } = useClusterDataWithCapacity<RenewableCluster>( - () => getRenewableClusters(study.id, areaId), - t("studies.error.retrieveData"), - [study.id, areaId], - ); - - const columns = useMemo<Array<MRT_ColumnDef<RenewableClusterWithCapacity>>>( - () => [ - { - accessorKey: "name", - header: "Name", - muiTableHeadCellProps: { - align: "left", - }, - muiTableBodyCellProps: { - align: "left", - }, - size: 100, - Cell: ({ renderedCellValue, row }) => { - const clusterId = row.original.id; - return ( - <Box - sx={{ - cursor: "pointer", - "&:hover": { - color: "primary.main", - textDecoration: "underline", - }, - }} - onClick={() => navigate(`${location.pathname}/${clusterId}`)} - > - {renderedCellValue} - </Box> - ); - }, + const { data: clustersWithCapacity = [], isLoading } = + usePromiseWithSnackbarError<RenewableClusterWithCapacity[]>( + async () => { + const clusters = await getRenewableClusters(study.id, areaId); + return clusters?.map(addClusterCapacity); }, { - accessorKey: "group", - header: "Group", - size: 50, - filterVariant: "select", - filterSelectOptions: [...RENEWABLE_GROUPS], - muiTableHeadCellProps: { - align: "left", - }, - muiTableBodyCellProps: { - align: "left", - }, - Footer: () => ( - <Box sx={{ display: "flex", alignItems: "flex-start" }}>Total:</Box> - ), + resetDataOnReload: true, + errorMessage: t("studies.error.retrieveData"), + deps: [study.id, areaId], }, - { - accessorKey: "enabled", + ); + + const [totals, setTotals] = useState( + getClustersWithCapacityTotals(clustersWithCapacity), + ); + + const columns = useMemo(() => { + const { totalUnitCount, totalEnabledCapacity, totalInstalledCapacity } = + totals; + + return [ + columnHelper.accessor("enabled", { header: "Enabled", size: 50, filterVariant: "checkbox", - Cell: ({ cell }) => ( - <Chip - label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")} - color={cell.getValue<boolean>() ? "success" : "error"} - size="small" - sx={{ minWidth: 40 }} - /> - ), - }, - { - accessorKey: "tsInterpretation", + Cell: BooleanCell, + }), + columnHelper.accessor("tsInterpretation", { header: "TS Interpretation", size: 50, - }, - { - accessorKey: "unitCount", + }), + columnHelper.accessor("unitCount", { header: "Unit Count", size: 50, aggregationFn: "sum", AggregatedCell: ({ cell }) => ( <Box sx={{ color: "info.main", fontWeight: "bold" }}> - {cell.getValue<number>()} + {cell.getValue()} </Box> ), Footer: () => <Box color="warning.main">{totalUnitCount}</Box>, - }, - { - accessorKey: "nominalCapacity", + }), + columnHelper.accessor("nominalCapacity", { header: "Nominal Capacity (MW)", - size: 200, - Cell: ({ cell }) => Math.floor(cell.getValue<number>()), - }, - { - accessorKey: "installedCapacity", + size: 220, + Cell: ({ cell }) => Math.floor(cell.getValue()), + }), + columnHelper.accessor("installedCapacity", { header: "Enabled / Installed (MW)", - size: 200, + size: 220, aggregationFn: capacityAggregationFn(), AggregatedCell: ({ cell }) => ( <Box sx={{ color: "info.main", fontWeight: "bold" }}> - {cell.getValue<string>() ?? ""} + {cell.getValue() ?? ""} </Box> ), Cell: ({ row }) => ( <> - {Math.floor(row.original.enabledCapacity ?? 0)} /{" "} - {Math.floor(row.original.installedCapacity ?? 0)} + {Math.floor(row.original.enabledCapacity)} /{" "} + {Math.floor(row.original.installedCapacity)} </> ), Footer: () => ( @@ -146,53 +102,68 @@ function Renewables() { {totalEnabledCapacity} / {totalInstalledCapacity} </Box> ), - }, - ], - [ - location.pathname, - navigate, - t, - totalEnabledCapacity, - totalInstalledCapacity, - totalUnitCount, - ], - ); + }), + ]; + }, [totals]); //////////////////////////////////////////////////////////////// // Event handlers //////////////////////////////////////////////////////////////// - const handleCreateRow = ({ - id, - installedCapacity, - enabledCapacity, - ...cluster - }: RenewableClusterWithCapacity) => { - return createRenewableCluster(study.id, areaId, cluster); + const handleCreate = async (values: TRow<RenewableGroup>) => { + const cluster = await createRenewableCluster(study.id, areaId, values); + return addClusterCapacity(cluster); }; - const handleDeleteSelection = (ids: string[]) => { + const handleDuplicate = async ( + row: RenewableClusterWithCapacity, + newName: string, + ) => { + const cluster = await duplicateRenewableCluster( + study.id, + areaId, + row.id, + newName, + ); + + return { ...row, ...cluster }; + }; + + const handleDelete = (rows: RenewableClusterWithCapacity[]) => { + const ids = rows.map((row) => row.id); return deleteRenewableClusters(study.id, areaId, ids); }; + const handleNameClick = (row: RenewableClusterWithCapacity) => { + navigate(`${location.pathname}/${row.id}`); + }; + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// return ( - <UsePromiseCond - response={clusters} - ifPending={() => <SimpleLoader />} - ifResolved={() => ( - <GroupedDataTable - data={clustersWithCapacity} - columns={columns} - groups={RENEWABLE_GROUPS} - onCreate={handleCreateRow} - onDelete={handleDeleteSelection} - /> - )} - ifRejected={(error) => <SimpleContent title={error?.toString()} />} + <GroupedDataTable + isLoading={isLoading} + data={clustersWithCapacity} + columns={columns} + groups={[...RENEWABLE_GROUPS]} + onCreate={handleCreate} + onDuplicate={handleDuplicate} + onDelete={handleDelete} + onNameClick={handleNameClick} + deleteConfirmationMessage={(count) => + t("studies.modelization.clusters.question.delete", { count }) + } + fillPendingRow={(row) => ({ + unitCount: 0, + enabledCapacity: 0, + installedCapacity: 0, + ...row, + })} + onDataChange={(data) => { + setTotals(getClustersWithCapacityTotals(data)); + }} /> ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts index 074a19c84f..0c0418d8d3 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts @@ -4,6 +4,8 @@ import { StudyMetadata, } from "../../../../../../../common/types"; import client from "../../../../../../../services/api/client"; +import type { PartialExceptFor } from "../../../../../../../utils/tsUtils"; +import type { ClusterWithCapacity } from "../common/clustersUtils"; //////////////////////////////////////////////////////////////// // Constants @@ -30,8 +32,9 @@ export const TS_INTERPRETATION_OPTIONS = [ // Types //////////////////////////////////////////////////////////////// +export type RenewableGroup = (typeof RENEWABLE_GROUPS)[number]; + type TimeSeriesInterpretation = (typeof TS_INTERPRETATION_OPTIONS)[number]; -type RenewableGroup = (typeof RENEWABLE_GROUPS)[number]; export interface RenewableFormFields { name: string; @@ -52,10 +55,8 @@ export interface RenewableCluster { nominalCapacity: number; } -export interface RenewableClusterWithCapacity extends RenewableCluster { - installedCapacity: number; - enabledCapacity: number; -} +export type RenewableClusterWithCapacity = + ClusterWithCapacity<RenewableCluster>; //////////////////////////////////////////////////////////////// // Functions @@ -72,34 +73,29 @@ const getClusterUrl = ( clusterId: Cluster["id"], ): string => `${getClustersUrl(studyId, areaId)}/${clusterId}`; -async function makeRequest<T>( - method: "get" | "post" | "patch" | "delete", - url: string, - data?: Partial<RenewableCluster> | { data: Array<Cluster["id"]> }, -): Promise<T> { - const res = await client[method]<T>(url, data); - return res.data; -} +//////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////// export async function getRenewableClusters( studyId: StudyMetadata["id"], areaId: Area["name"], -): Promise<RenewableCluster[]> { - return makeRequest<RenewableCluster[]>( - "get", +) { + const res = await client.get<RenewableCluster[]>( getClustersUrl(studyId, areaId), ); + return res.data; } export async function getRenewableCluster( studyId: StudyMetadata["id"], areaId: Area["name"], clusterId: Cluster["id"], -): Promise<RenewableCluster> { - return makeRequest<RenewableCluster>( - "get", +) { + const res = await client.get<RenewableCluster>( getClusterUrl(studyId, areaId, clusterId), ); + return res.data; } export async function updateRenewableCluster( @@ -107,32 +103,44 @@ export async function updateRenewableCluster( areaId: Area["name"], clusterId: Cluster["id"], data: Partial<RenewableCluster>, -): Promise<RenewableCluster> { - return makeRequest<RenewableCluster>( - "patch", +) { + const res = await client.patch<RenewableCluster>( getClusterUrl(studyId, areaId, clusterId), data, ); + return res.data; } export async function createRenewableCluster( studyId: StudyMetadata["id"], areaId: Area["name"], - data: Partial<RenewableCluster>, -): Promise<RenewableClusterWithCapacity> { - return makeRequest<RenewableClusterWithCapacity>( - "post", + data: PartialExceptFor<RenewableCluster, "name">, +) { + const res = await client.post<RenewableCluster>( getClustersUrl(studyId, areaId), data, ); + return res.data; +} + +export async function duplicateRenewableCluster( + studyId: StudyMetadata["id"], + areaId: Area["name"], + sourceClusterId: RenewableCluster["id"], + newName: RenewableCluster["name"], +) { + const res = await client.post<RenewableCluster>( + `/v1/studies/${studyId}/areas/${areaId}/renewables/${sourceClusterId}`, + null, + { params: { newName } }, + ); + return res.data; } -export function deleteRenewableClusters( +export async function deleteRenewableClusters( studyId: StudyMetadata["id"], areaId: Area["name"], clusterIds: Array<Cluster["id"]>, -): Promise<void> { - return makeRequest<void>("delete", getClustersUrl(studyId, areaId), { - data: clusterIds, - }); +) { + await client.delete(getClustersUrl(studyId, areaId), { data: clusterIds }); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx index 8485fd29e6..9d6935b3fe 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx @@ -25,7 +25,7 @@ function Fields() { disabled /> <SelectFE - label={t("study.modelization.clusters.group")} + label={t("global.group")} name="group" control={control} options={STORAGE_GROUPS} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx index 06e6db36db..721409091a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx @@ -1,109 +1,52 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { MRT_ColumnDef } from "material-react-table"; -import { Box, Chip, Tooltip } from "@mui/material"; +import { createMRTColumnHelper } from "material-react-table"; +import { Box, Tooltip } from "@mui/material"; import { useLocation, useNavigate, useOutletContext } from "react-router-dom"; import { StudyMetadata } from "../../../../../../../common/types"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import GroupedDataTable from "../../../../../../common/GroupedDataTable"; -import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; import { Storage, getStorages, deleteStorages, createStorage, STORAGE_GROUPS, + StorageGroup, + duplicateStorage, + getStoragesTotals, } from "./utils"; -import SimpleContent from "../../../../../../common/page/SimpleContent"; -import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError"; +import type { TRow } from "../../../../../../common/GroupedDataTable/types"; +import BooleanCell from "../../../../../../common/GroupedDataTable/cellRenderers/BooleanCell"; + +const columnHelper = createMRTColumnHelper<Storage>(); function Storages() { const { study } = useOutletContext<{ study: StudyMetadata }>(); - const [t] = useTranslation(); + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const areaId = useAppSelector(getCurrentAreaId); - const storages = usePromiseWithSnackbarError( + const { data: storages = [], isLoading } = usePromiseWithSnackbarError( () => getStorages(study.id, areaId), { + resetDataOnReload: true, errorMessage: t("studies.error.retrieveData"), deps: [study.id, areaId], }, ); - const { totalWithdrawalNominalCapacity, totalInjectionNominalCapacity } = - useMemo(() => { - if (!storages.data) { - return { - totalWithdrawalNominalCapacity: 0, - totalInjectionNominalCapacity: 0, - }; - } + const [totals, setTotals] = useState(getStoragesTotals(storages)); - return storages.data.reduce( - (acc, { withdrawalNominalCapacity, injectionNominalCapacity }) => { - acc.totalWithdrawalNominalCapacity += withdrawalNominalCapacity; - acc.totalInjectionNominalCapacity += injectionNominalCapacity; - return acc; - }, - { - totalWithdrawalNominalCapacity: 0, - totalInjectionNominalCapacity: 0, - }, - ); - }, [storages]); + const columns = useMemo(() => { + const { totalInjectionNominalCapacity, totalWithdrawalNominalCapacity } = + totals; - const columns = useMemo<Array<MRT_ColumnDef<Storage>>>( - () => [ - { - accessorKey: "name", - header: t("global.name"), - muiTableHeadCellProps: { - align: "left", - }, - muiTableBodyCellProps: { - align: "left", - }, - size: 100, - Cell: ({ renderedCellValue, row }) => { - const storageId = row.original.id; - return ( - <Box - sx={{ - cursor: "pointer", - "&:hover": { - color: "primary.main", - textDecoration: "underline", - }, - }} - onClick={() => navigate(`${location.pathname}/${storageId}`)} - > - {renderedCellValue} - </Box> - ); - }, - }, - { - accessorKey: "group", - header: t("global.group"), - size: 50, - filterVariant: "select", - filterSelectOptions: [...STORAGE_GROUPS], - muiTableHeadCellProps: { - align: "left", - }, - muiTableBodyCellProps: { - align: "left", - }, - Footer: () => ( - <Box sx={{ display: "flex", alignItems: "flex-start" }}>Total:</Box> - ), - }, - { - accessorKey: "injectionNominalCapacity", + return [ + columnHelper.accessor("injectionNominalCapacity", { header: t("study.modelization.storages.injectionNominalCapacity"), Header: ({ column }) => ( <Tooltip @@ -117,20 +60,20 @@ function Storages() { </Tooltip> ), size: 100, - Cell: ({ cell }) => Math.floor(cell.getValue<number>()), + aggregationFn: "sum", AggregatedCell: ({ cell }) => ( <Box sx={{ color: "info.main", fontWeight: "bold" }}> - {Math.floor(cell.getValue<number>())} + {Math.floor(cell.getValue())} </Box> ), + Cell: ({ cell }) => Math.floor(cell.getValue()), Footer: () => ( <Box color="warning.main"> {Math.floor(totalInjectionNominalCapacity)} </Box> ), - }, - { - accessorKey: "withdrawalNominalCapacity", + }), + columnHelper.accessor("withdrawalNominalCapacity", { header: t("study.modelization.storages.withdrawalNominalCapacity"), Header: ({ column }) => ( <Tooltip @@ -147,18 +90,17 @@ function Storages() { aggregationFn: "sum", AggregatedCell: ({ cell }) => ( <Box sx={{ color: "info.main", fontWeight: "bold" }}> - {Math.floor(cell.getValue<number>())} + {Math.floor(cell.getValue())} </Box> ), - Cell: ({ cell }) => Math.floor(cell.getValue<number>()), + Cell: ({ cell }) => Math.floor(cell.getValue()), Footer: () => ( <Box color="warning.main"> {Math.floor(totalWithdrawalNominalCapacity)} </Box> ), - }, - { - accessorKey: "reservoirCapacity", + }), + columnHelper.accessor("reservoirCapacity", { header: t("study.modelization.storages.reservoirCapacity"), Header: ({ column }) => ( <Tooltip @@ -170,74 +112,73 @@ function Storages() { </Tooltip> ), size: 100, - Cell: ({ cell }) => `${cell.getValue<number>()}`, - }, - { - accessorKey: "efficiency", + Cell: ({ cell }) => `${cell.getValue()}`, + }), + columnHelper.accessor("efficiency", { header: t("study.modelization.storages.efficiency"), size: 50, - Cell: ({ cell }) => `${Math.floor(cell.getValue<number>() * 100)}`, - }, - { - accessorKey: "initialLevel", + Cell: ({ cell }) => `${Math.floor(cell.getValue() * 100)}`, + }), + columnHelper.accessor("initialLevel", { header: t("study.modelization.storages.initialLevel"), size: 50, - Cell: ({ cell }) => `${Math.floor(cell.getValue<number>() * 100)}`, - }, - { - accessorKey: "initialLevelOptim", + Cell: ({ cell }) => `${Math.floor(cell.getValue() * 100)}`, + }), + columnHelper.accessor("initialLevelOptim", { header: t("study.modelization.storages.initialLevelOptim"), - size: 180, + size: 200, filterVariant: "checkbox", - Cell: ({ cell }) => ( - <Chip - label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")} - color={cell.getValue<boolean>() ? "success" : "error"} - size="small" - sx={{ minWidth: 40 }} - /> - ), - }, - ], - [ - location.pathname, - navigate, - t, - totalInjectionNominalCapacity, - totalWithdrawalNominalCapacity, - ], - ); + Cell: BooleanCell, + }), + ]; + }, [t, totals]); //////////////////////////////////////////////////////////////// // Event handlers //////////////////////////////////////////////////////////////// - const handleCreateRow = ({ id, ...storage }: Storage) => { - return createStorage(study.id, areaId, storage); + const handleCreate = (values: TRow<StorageGroup>) => { + return createStorage(study.id, areaId, values); + }; + + const handleDuplicate = (row: Storage, newName: string) => { + return duplicateStorage(study.id, areaId, row.id, newName); }; - const handleDeleteSelection = (ids: string[]) => { + const handleDelete = (rows: Storage[]) => { + const ids = rows.map((row) => row.id); return deleteStorages(study.id, areaId, ids); }; + const handleNameClick = (row: Storage) => { + navigate(`${location.pathname}/${row.id}`); + }; + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// return ( - <UsePromiseCond - response={storages} - ifPending={() => <SimpleLoader />} - ifResolved={(data) => ( - <GroupedDataTable - data={data} - columns={columns} - groups={STORAGE_GROUPS} - onCreate={handleCreateRow} - onDelete={handleDeleteSelection} - /> - )} - ifRejected={(error) => <SimpleContent title={error?.toString()} />} + <GroupedDataTable + isLoading={isLoading} + data={storages || []} + columns={columns} + groups={[...STORAGE_GROUPS]} + onCreate={handleCreate} + onDuplicate={handleDuplicate} + onDelete={handleDelete} + onNameClick={handleNameClick} + deleteConfirmationMessage={(count) => + t("studies.modelization.clusters.question.delete", { count }) + } + fillPendingRow={(row) => ({ + withdrawalNominalCapacity: 0, + injectionNominalCapacity: 0, + ...row, + })} + onDataChange={(data) => { + setTotals(getStoragesTotals(data)); + }} /> ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts index 1226bcac66..48466da646 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts @@ -1,5 +1,6 @@ import { StudyMetadata, Area } from "../../../../../../../common/types"; import client from "../../../../../../../services/api/client"; +import type { PartialExceptFor } from "../../../../../../../utils/tsUtils"; //////////////////////////////////////////////////////////////// // Constants @@ -39,6 +40,20 @@ export interface Storage { // Functions //////////////////////////////////////////////////////////////// +export function getStoragesTotals(storages: Storage[]) { + return storages.reduce( + (acc, { withdrawalNominalCapacity, injectionNominalCapacity }) => { + acc.totalWithdrawalNominalCapacity += withdrawalNominalCapacity; + acc.totalInjectionNominalCapacity += injectionNominalCapacity; + return acc; + }, + { + totalWithdrawalNominalCapacity: 0, + totalInjectionNominalCapacity: 0, + }, + ); +} + const getStoragesUrl = ( studyId: StudyMetadata["id"], areaId: Area["name"], @@ -50,28 +65,27 @@ const getStorageUrl = ( storageId: Storage["id"], ): string => `${getStoragesUrl(studyId, areaId)}/${storageId}`; -async function makeRequest<T>( - method: "get" | "post" | "patch" | "delete", - url: string, - data?: Partial<Storage> | { data: Array<Storage["id"]> }, -): Promise<T> { - const res = await client[method]<T>(url, data); - return res.data; -} +//////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////// export async function getStorages( studyId: StudyMetadata["id"], areaId: Area["name"], -): Promise<Storage[]> { - return makeRequest<Storage[]>("get", getStoragesUrl(studyId, areaId)); +) { + const res = await client.get<Storage[]>(getStoragesUrl(studyId, areaId)); + return res.data; } export async function getStorage( studyId: StudyMetadata["id"], areaId: Area["name"], storageId: Storage["id"], -): Promise<Storage> { - return makeRequest<Storage>("get", getStorageUrl(studyId, areaId, storageId)); +) { + const res = await client.get<Storage>( + getStorageUrl(studyId, areaId, storageId), + ); + return res.data; } export async function updateStorage( @@ -79,28 +93,41 @@ export async function updateStorage( areaId: Area["name"], storageId: Storage["id"], data: Partial<Storage>, -): Promise<Storage> { - return makeRequest<Storage>( - "patch", +) { + const res = await client.patch<Storage>( getStorageUrl(studyId, areaId, storageId), data, ); + return res.data; } export async function createStorage( studyId: StudyMetadata["id"], areaId: Area["name"], - data: Partial<Storage>, -): Promise<Storage> { - return makeRequest<Storage>("post", getStoragesUrl(studyId, areaId), data); + data: PartialExceptFor<Storage, "name">, +) { + const res = await client.post<Storage>(getStoragesUrl(studyId, areaId), data); + return res.data; +} + +export async function duplicateStorage( + studyId: StudyMetadata["id"], + areaId: Area["name"], + sourceClusterId: Storage["id"], + newName: Storage["name"], +) { + const res = await client.post<Storage>( + `/v1/studies/${studyId}/areas/${areaId}/storages/${sourceClusterId}`, + null, + { params: { newName } }, + ); + return res.data; } -export function deleteStorages( +export async function deleteStorages( studyId: StudyMetadata["id"], areaId: Area["name"], storageIds: Array<Storage["id"]>, -): Promise<void> { - return makeRequest<void>("delete", getStoragesUrl(studyId, areaId), { - data: storageIds, - }); +) { + await client.delete(getStoragesUrl(studyId, areaId), { data: storageIds }); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx index cf5cb2fc66..ec5d6fc632 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx @@ -35,7 +35,7 @@ function Fields() { disabled /> <SelectFE - label={t("study.modelization.clusters.group")} + label={t("global.group")} name="group" control={control} options={THERMAL_GROUPS} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx index e403b52e5f..0b36a6903a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx @@ -1,6 +1,6 @@ -import { useMemo } from "react"; -import { MRT_ColumnDef } from "material-react-table"; -import { Box, Chip } from "@mui/material"; +import { useMemo, useState } from "react"; +import { createMRTColumnHelper } from "material-react-table"; +import { Box } from "@mui/material"; import { useLocation, useNavigate, useOutletContext } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { StudyMetadata } from "../../../../../../../common/types"; @@ -8,146 +8,95 @@ import { getThermalClusters, createThermalCluster, deleteThermalClusters, - ThermalClusterWithCapacity, THERMAL_GROUPS, - ThermalCluster, + ThermalGroup, + duplicateThermalCluster, + type ThermalClusterWithCapacity, } from "./utils"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import GroupedDataTable from "../../../../../../common/GroupedDataTable"; -import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; -import SimpleContent from "../../../../../../common/page/SimpleContent"; import { + addClusterCapacity, capacityAggregationFn, - useClusterDataWithCapacity, -} from "../common/utils"; -import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; + getClustersWithCapacityTotals, +} from "../common/clustersUtils"; +import { TRow } from "../../../../../../common/GroupedDataTable/types"; +import BooleanCell from "../../../../../../common/GroupedDataTable/cellRenderers/BooleanCell"; +import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError"; + +const columnHelper = createMRTColumnHelper<ThermalClusterWithCapacity>(); function Thermal() { const { study } = useOutletContext<{ study: StudyMetadata }>(); - const [t] = useTranslation(); + const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const areaId = useAppSelector(getCurrentAreaId); - const { - clusters, - clustersWithCapacity, - totalUnitCount, - totalInstalledCapacity, - totalEnabledCapacity, - } = useClusterDataWithCapacity<ThermalCluster>( - () => getThermalClusters(study.id, areaId), - t("studies.error.retrieveData"), - [study.id, areaId], - ); - - const columns = useMemo<Array<MRT_ColumnDef<ThermalClusterWithCapacity>>>( - () => [ - { - accessorKey: "name", - header: "Name", - size: 100, - muiTableHeadCellProps: { - align: "left", - }, - muiTableBodyCellProps: { - align: "left", - }, - Cell: ({ renderedCellValue, row }) => { - const clusterId = row.original.id; - return ( - <Box - sx={{ - cursor: "pointer", - "&:hover": { - color: "primary.main", - textDecoration: "underline", - }, - }} - onClick={() => navigate(`${location.pathname}/${clusterId}`)} - > - {renderedCellValue} - </Box> - ); - }, + const { data: clustersWithCapacity = [], isLoading } = + usePromiseWithSnackbarError<ThermalClusterWithCapacity[]>( + async () => { + const clusters = await getThermalClusters(study.id, areaId); + return clusters?.map(addClusterCapacity); }, { - accessorKey: "group", - header: "Group", - size: 50, - filterVariant: "select", - filterSelectOptions: [...THERMAL_GROUPS], - muiTableHeadCellProps: { - align: "left", - }, - muiTableBodyCellProps: { - align: "left", - }, - Footer: () => ( - <Box sx={{ display: "flex", alignItems: "flex-start" }}>Total:</Box> - ), + resetDataOnReload: true, + errorMessage: t("studies.error.retrieveData"), + deps: [study.id, areaId], }, - { - accessorKey: "enabled", + ); + + const [totals, setTotals] = useState( + getClustersWithCapacityTotals(clustersWithCapacity), + ); + + const columns = useMemo(() => { + const { totalUnitCount, totalEnabledCapacity, totalInstalledCapacity } = + totals; + + return [ + columnHelper.accessor("enabled", { header: "Enabled", size: 50, filterVariant: "checkbox", - Cell: ({ cell }) => ( - <Chip - label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")} - color={cell.getValue<boolean>() ? "success" : "error"} - size="small" - sx={{ minWidth: 40 }} - /> - ), - }, - { - accessorKey: "mustRun", + Cell: BooleanCell, + }), + columnHelper.accessor("mustRun", { header: "Must Run", size: 50, filterVariant: "checkbox", - Cell: ({ cell }) => ( - <Chip - label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")} - color={cell.getValue<boolean>() ? "success" : "error"} - size="small" - sx={{ minWidth: 40 }} - /> - ), - }, - { - accessorKey: "unitCount", + Cell: BooleanCell, + }), + columnHelper.accessor("unitCount", { header: "Unit Count", size: 50, aggregationFn: "sum", AggregatedCell: ({ cell }) => ( <Box sx={{ color: "info.main", fontWeight: "bold" }}> - {cell.getValue<number>()} + {cell.getValue()} </Box> ), Footer: () => <Box color="warning.main">{totalUnitCount}</Box>, - }, - { - accessorKey: "nominalCapacity", + }), + columnHelper.accessor("nominalCapacity", { header: "Nominal Capacity (MW)", - size: 200, - Cell: ({ cell }) => cell.getValue<number>().toFixed(1), - }, - { - accessorKey: "installedCapacity", + size: 220, + Cell: ({ cell }) => cell.getValue().toFixed(1), + }), + columnHelper.accessor("installedCapacity", { header: "Enabled / Installed (MW)", - size: 200, + size: 220, aggregationFn: capacityAggregationFn(), AggregatedCell: ({ cell }) => ( <Box sx={{ color: "info.main", fontWeight: "bold" }}> - {cell.getValue<string>() ?? ""} + {cell.getValue() ?? ""} </Box> ), Cell: ({ row }) => ( <> - {Math.floor(row.original.enabledCapacity ?? 0)} /{" "} - {Math.floor(row.original.installedCapacity ?? 0)} + {Math.floor(row.original.enabledCapacity)} /{" "} + {Math.floor(row.original.installedCapacity)} </> ), Footer: () => ( @@ -155,59 +104,73 @@ function Thermal() { {totalEnabledCapacity} / {totalInstalledCapacity} </Box> ), - }, - { - accessorKey: "marketBidCost", + }), + columnHelper.accessor("marketBidCost", { header: "Market Bid (€/MWh)", size: 50, - Cell: ({ cell }) => <>{cell.getValue<number>().toFixed(2)}</>, - }, - ], - [ - location.pathname, - navigate, - t, - totalEnabledCapacity, - totalInstalledCapacity, - totalUnitCount, - ], - ); + Cell: ({ cell }) => <>{cell.getValue().toFixed(2)}</>, + }), + ]; + }, [totals]); //////////////////////////////////////////////////////////////// // Event handlers //////////////////////////////////////////////////////////////// - const handleCreateRow = ({ - id, - installedCapacity, - enabledCapacity, - ...cluster - }: ThermalClusterWithCapacity) => { - return createThermalCluster(study.id, areaId, cluster); + const handleCreate = async (values: TRow<ThermalGroup>) => { + const cluster = await createThermalCluster(study.id, areaId, values); + return addClusterCapacity(cluster); }; - const handleDeleteSelection = (ids: string[]) => { + const handleDuplicate = async ( + row: ThermalClusterWithCapacity, + newName: string, + ) => { + const cluster = await duplicateThermalCluster( + study.id, + areaId, + row.id, + newName, + ); + + return { ...row, ...cluster }; + }; + + const handleDelete = (rows: ThermalClusterWithCapacity[]) => { + const ids = rows.map((row) => row.id); return deleteThermalClusters(study.id, areaId, ids); }; + const handleNameClick = (row: ThermalClusterWithCapacity) => { + navigate(`${location.pathname}/${row.id}`); + }; + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// return ( - <UsePromiseCond - response={clusters} - ifPending={() => <SimpleLoader />} - ifResolved={() => ( - <GroupedDataTable - data={clustersWithCapacity} - columns={columns} - groups={THERMAL_GROUPS} - onCreate={handleCreateRow} - onDelete={handleDeleteSelection} - /> - )} - ifRejected={(error) => <SimpleContent title={error?.toString()} />} + <GroupedDataTable + isLoading={isLoading} + data={clustersWithCapacity} + columns={columns} + groups={[...THERMAL_GROUPS]} + onCreate={handleCreate} + onDuplicate={handleDuplicate} + onDelete={handleDelete} + onNameClick={handleNameClick} + deleteConfirmationMessage={(count) => + t("studies.modelization.clusters.question.delete", { count }) + } + fillPendingRow={(row) => ({ + unitCount: 0, + enabledCapacity: 0, + installedCapacity: 0, + ...row, + })} + onDataChange={(data) => { + setTotals(getClustersWithCapacityTotals(data)); + }} /> ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts index d113e06c4f..8d5836a4e0 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts @@ -4,6 +4,8 @@ import { StudyMetadata, } from "../../../../../../../common/types"; import client from "../../../../../../../services/api/client"; +import type { PartialExceptFor } from "../../../../../../../utils/tsUtils"; +import type { ClusterWithCapacity } from "../common/clustersUtils"; //////////////////////////////////////////////////////////////// // Constants @@ -51,7 +53,8 @@ export const TS_LAW_OPTIONS = ["geometric", "uniform"] as const; // Types //////////////////////////////////////////////////////////////// -type ThermalGroup = (typeof THERMAL_GROUPS)[number]; +export type ThermalGroup = (typeof THERMAL_GROUPS)[number]; + type LocalTSGenerationBehavior = (typeof TS_GENERATION_OPTIONS)[number]; type TimeSeriesLawOption = (typeof TS_LAW_OPTIONS)[number]; @@ -83,10 +86,7 @@ export interface ThermalCluster extends ThermalPollutants { lawPlanned: TimeSeriesLawOption; } -export interface ThermalClusterWithCapacity extends ThermalCluster { - enabledCapacity: number; - installedCapacity: number; -} +export type ThermalClusterWithCapacity = ClusterWithCapacity<ThermalCluster>; //////////////////////////////////////////////////////////////// // Functions @@ -103,31 +103,29 @@ const getClusterUrl = ( clusterId: Cluster["id"], ): string => `${getClustersUrl(studyId, areaId)}/${clusterId}`; -async function makeRequest<T>( - method: "get" | "post" | "patch" | "delete", - url: string, - data?: Partial<ThermalCluster> | { data: Array<Cluster["id"]> }, -): Promise<T> { - const res = await client[method]<T>(url, data); - return res.data; -} +//////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////// export async function getThermalClusters( studyId: StudyMetadata["id"], areaId: Area["name"], -): Promise<ThermalCluster[]> { - return makeRequest<ThermalCluster[]>("get", getClustersUrl(studyId, areaId)); +) { + const res = await client.get<ThermalCluster[]>( + getClustersUrl(studyId, areaId), + ); + return res.data; } export async function getThermalCluster( studyId: StudyMetadata["id"], areaId: Area["name"], clusterId: Cluster["id"], -): Promise<ThermalCluster> { - return makeRequest<ThermalCluster>( - "get", +) { + const res = await client.get<ThermalCluster>( getClusterUrl(studyId, areaId, clusterId), ); + return res.data; } export async function updateThermalCluster( @@ -135,32 +133,44 @@ export async function updateThermalCluster( areaId: Area["name"], clusterId: Cluster["id"], data: Partial<ThermalCluster>, -): Promise<ThermalCluster> { - return makeRequest<ThermalCluster>( - "patch", +) { + const res = await client.patch<ThermalCluster>( getClusterUrl(studyId, areaId, clusterId), data, ); + return res.data; } export async function createThermalCluster( studyId: StudyMetadata["id"], areaId: Area["name"], - data: Partial<ThermalCluster>, -): Promise<ThermalClusterWithCapacity> { - return makeRequest<ThermalClusterWithCapacity>( - "post", + data: PartialExceptFor<ThermalCluster, "name">, +) { + const res = await client.post<ThermalCluster>( getClustersUrl(studyId, areaId), data, ); + return res.data; +} + +export async function duplicateThermalCluster( + studyId: StudyMetadata["id"], + areaId: Area["name"], + sourceClusterId: ThermalCluster["id"], + newName: ThermalCluster["name"], +) { + const res = await client.post<ThermalCluster>( + `/v1/studies/${studyId}/areas/${areaId}/thermals/${sourceClusterId}`, + null, + { params: { newName } }, + ); + return res.data; } -export function deleteThermalClusters( +export async function deleteThermalClusters( studyId: StudyMetadata["id"], areaId: Area["name"], clusterIds: Array<Cluster["id"]>, -): Promise<void> { - return makeRequest<void>("delete", getClustersUrl(studyId, areaId), { - data: clusterIds, - }); +) { + await client.delete(getClustersUrl(studyId, areaId), { data: clusterIds }); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/clustersUtils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/clustersUtils.ts new file mode 100644 index 0000000000..a035dfa07f --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/clustersUtils.ts @@ -0,0 +1,87 @@ +import { MRT_AggregationFn } from "material-react-table"; +import { ThermalClusterWithCapacity } from "../Thermal/utils"; +import { RenewableClusterWithCapacity } from "../Renewables/utils"; + +/** + * Custom aggregation function summing the values of each row, + * to display enabled and installed capacity in the same cell. This function is + * designed for use with Material React Table's custom aggregation feature, allowing + * the combination of enabled and installed capacities into a single cell. + * + * @returns A string representing the sum of enabled and installed capacity in the format "enabled/installed". + * @example + * Assuming an aggregation of rows where enabled capacities sum to 100 and installed capacities sum to 200 + * "100/200" + * + * @see https://www.material-react-table.com/docs/guides/aggregation-and-grouping#custom-aggregation-functions for more information on custom aggregation functions in Material React Table. + */ +export const capacityAggregationFn = < + T extends ThermalClusterWithCapacity | RenewableClusterWithCapacity, +>(): MRT_AggregationFn<T> => { + return (columnId, leafRows) => { + const { enabledCapacitySum, installedCapacitySum } = leafRows.reduce( + (acc, row) => { + acc.enabledCapacitySum += row.original.enabledCapacity; + acc.installedCapacitySum += row.original.installedCapacity; + + return acc; + }, + { enabledCapacitySum: 0, installedCapacitySum: 0 }, + ); + + return `${Math.floor(enabledCapacitySum)} / ${Math.floor( + installedCapacitySum, + )}`; + }; +}; + +interface BaseCluster { + name: string; + group: string; + unitCount: number; + nominalCapacity: number; + enabled: boolean; +} + +export type ClusterWithCapacity<T extends BaseCluster> = T & { + installedCapacity: number; + enabledCapacity: number; +}; + +/** + * Adds the installed and enabled capacity fields to a cluster. + * + * @param cluster - The cluster to add the capacity fields to. + * @returns The cluster with the installed and enabled capacity fields added. + */ +export function addClusterCapacity<T extends BaseCluster>(cluster: T) { + const { unitCount, nominalCapacity, enabled } = cluster; + const installedCapacity = unitCount * nominalCapacity; + const enabledCapacity = enabled ? installedCapacity : 0; + return { ...cluster, installedCapacity, enabledCapacity }; +} + +/** + * Gets the totals for unit count, installed capacity, and enabled capacity + * for the specified clusters. + * + * @param clusters - The clusters to get the totals for. + * @returns An object containing the totals. + */ +export function getClustersWithCapacityTotals<T extends BaseCluster>( + clusters: Array<ClusterWithCapacity<T>>, +) { + return clusters.reduce( + (acc, { unitCount, installedCapacity, enabledCapacity }) => { + acc.totalUnitCount += unitCount; + acc.totalInstalledCapacity += installedCapacity; + acc.totalEnabledCapacity += enabledCapacity; + return acc; + }, + { + totalUnitCount: 0, + totalInstalledCapacity: 0, + totalEnabledCapacity: 0, + }, + ); +} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts deleted file mode 100644 index 9231804f53..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { DependencyList, useMemo } from "react"; -import * as R from "ramda"; -import { MRT_AggregationFn } from "material-react-table"; -import { StudyMetadata } from "../../../../../../../common/types"; -import { editStudy } from "../../../../../../../services/api/study"; -import { ThermalClusterWithCapacity } from "../Thermal/utils"; -import { RenewableClusterWithCapacity } from "../Renewables/utils"; -import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError"; -import { UsePromiseResponse } from "../../../../../../../hooks/usePromise"; - -export const saveField = R.curry( - ( - studyId: StudyMetadata["id"], - path: string, - data: Record<string, unknown>, - ): Promise<void> => { - return editStudy(data, studyId, path); - }, -); - -/** - * Custom aggregation function summing the values of each row, - * to display enabled and installed capacity in the same cell. This function is - * designed for use with Material React Table's custom aggregation feature, allowing - * the combination of enabled and installed capacities into a single cell. - * - * @returns A string representing the sum of enabled and installed capacity in the format "enabled/installed". - * @example - * Assuming an aggregation of rows where enabled capacities sum to 100 and installed capacities sum to 200 - * "100/200" - * - * @see https://www.material-react-table.com/docs/guides/aggregation-and-grouping#custom-aggregation-functions for more information on custom aggregation functions in Material React Table. - */ -export const capacityAggregationFn = < - T extends ThermalClusterWithCapacity | RenewableClusterWithCapacity, ->(): MRT_AggregationFn<T> => { - return (_colHeader, rows) => { - const { enabledCapacitySum, installedCapacitySum } = rows.reduce( - (acc, row) => { - acc.enabledCapacitySum += row.original.enabledCapacity ?? 0; - acc.installedCapacitySum += row.original.installedCapacity ?? 0; - return acc; - }, - { enabledCapacitySum: 0, installedCapacitySum: 0 }, - ); - - return `${Math.floor(enabledCapacitySum)} / ${Math.floor( - installedCapacitySum, - )}`; - }; -}; - -interface BaseCluster { - name: string; - group: string; - unitCount: number; - nominalCapacity: number; - enabled: boolean; -} - -type ClusterWithCapacity<T extends BaseCluster> = T & { - installedCapacity: number; - enabledCapacity: number; -}; - -interface UseClusterDataWithCapacityReturn<T extends BaseCluster> { - clusters: UsePromiseResponse<T[]>; - clustersWithCapacity: Array<ClusterWithCapacity<T>>; - totalUnitCount: number; - totalInstalledCapacity: number; - totalEnabledCapacity: number; -} - -export const useClusterDataWithCapacity = <T extends BaseCluster>( - fetchFn: () => Promise<T[]>, - errorMessage: string, - deps: DependencyList, -): UseClusterDataWithCapacityReturn<T> => { - const clusters: UsePromiseResponse<T[]> = usePromiseWithSnackbarError( - fetchFn, - { - errorMessage, - deps, - }, - ); - - const clustersWithCapacity: Array<ClusterWithCapacity<T>> = useMemo( - () => - clusters.data?.map((cluster) => { - const { unitCount, nominalCapacity, enabled } = cluster; - const installedCapacity = unitCount * nominalCapacity; - const enabledCapacity = enabled ? installedCapacity : 0; - return { ...cluster, installedCapacity, enabledCapacity }; - }) || [], - [clusters.data], - ); - - const { totalUnitCount, totalInstalledCapacity, totalEnabledCapacity } = - useMemo(() => { - return clustersWithCapacity.reduce( - (acc, { unitCount, nominalCapacity, enabled }) => { - acc.totalUnitCount += unitCount; - acc.totalInstalledCapacity += unitCount * nominalCapacity; - acc.totalEnabledCapacity += enabled ? unitCount * nominalCapacity : 0; - return acc; - }, - { - totalUnitCount: 0, - totalInstalledCapacity: 0, - totalEnabledCapacity: 0, - }, - ); - }, [clustersWithCapacity]); - - return { - clusters, - clustersWithCapacity, - totalUnitCount: Math.floor(totalUnitCount), - totalInstalledCapacity: Math.floor(totalInstalledCapacity), - totalEnabledCapacity: Math.floor(totalEnabledCapacity), - }; -}; diff --git a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx index ec7e9149c3..256d5cfa3a 100644 --- a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx @@ -84,7 +84,6 @@ function TabWrapper({ display: "flex", flexDirection: "column", justifyContent: "flex-start", - alignItems: "center", }, sx, )} diff --git a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx index 5c8313a352..9df4f4e2c5 100644 --- a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx @@ -1,48 +1,38 @@ -import { t } from "i18next"; import AddCircleIcon from "@mui/icons-material/AddCircle"; import FormDialog from "../dialogs/FormDialog"; import StringFE from "../fieldEditors/StringFE"; import Fieldset from "../Fieldset"; import { SubmitHandlerPlus } from "../Form/types"; import SelectFE from "../fieldEditors/SelectFE"; -import { nameToId } from "../../../services/utils"; -import { TRow } from "./utils"; import { validateString } from "../../../utils/validationUtils"; +import type { TRow } from "./types"; +import { useTranslation } from "react-i18next"; -interface Props<TData extends TRow> { +interface Props { open: boolean; onClose: VoidFunction; - onSubmit: (values: TData) => Promise<void>; - groups: string[] | readonly string[]; - existingNames: Array<TData["name"]>; + onSubmit: (values: TRow) => Promise<void>; + groups: string[]; + existingNames: Array<TRow["name"]>; } -const defaultValues = { - name: "", - group: "", -}; - -function CreateDialog<TData extends TRow>({ +function CreateDialog({ open, onClose, onSubmit, groups, existingNames, -}: Props<TData>) { +}: Props) { + const { t } = useTranslation(); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleSubmit = async ({ - values, - }: SubmitHandlerPlus<typeof defaultValues>) => { - await onSubmit({ - ...values, - id: nameToId(values.name), - name: values.name.trim(), - } as TData); - - onClose(); + const handleSubmit = ({ + values: { name, group }, + }: SubmitHandlerPlus<TRow>) => { + return onSubmit({ name: name.trim(), group }); }; //////////////////////////////////////////////////////////////// @@ -56,7 +46,6 @@ function CreateDialog<TData extends TRow>({ open={open} onCancel={onClose} onSubmit={handleSubmit} - config={{ defaultValues }} > {({ control }) => ( <Fieldset fullFieldWidth> @@ -72,14 +61,11 @@ function CreateDialog<TData extends TRow>({ sx={{ m: 0 }} /> <SelectFE - label={t("study.modelization.clusters.group")} + label={t("global.group")} name="group" control={control} options={groups} - required - sx={{ - alignSelf: "center", - }} + rules={{ required: t("form.field.required") }} /> </Fieldset> )} diff --git a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx index 34664b8f3a..099748ef9a 100644 --- a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import ControlPointDuplicateIcon from "@mui/icons-material/ControlPointDuplicate"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import Fieldset from "../Fieldset"; import FormDialog from "../dialogs/FormDialog"; import { SubmitHandlerPlus } from "../Form/types"; @@ -38,7 +38,7 @@ function DuplicateDialog(props: Props) { <FormDialog open={open} title={t("global.duplicate")} - titleIcon={ControlPointDuplicateIcon} + titleIcon={ContentCopyIcon} onCancel={onClose} onSubmit={handleSubmit} config={{ defaultValues }} diff --git a/webapp/src/components/common/GroupedDataTable/cellRenderers/BooleanCell.tsx b/webapp/src/components/common/GroupedDataTable/cellRenderers/BooleanCell.tsx new file mode 100644 index 0000000000..deb28e01a2 --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/cellRenderers/BooleanCell.tsx @@ -0,0 +1,22 @@ +import { Chip } from "@mui/material"; +import type { MRT_Cell, MRT_RowData } from "material-react-table"; +import { useTranslation } from "react-i18next"; + +interface Props<T extends MRT_RowData> { + cell: MRT_Cell<T, boolean>; +} + +function BooleanCell<T extends MRT_RowData>({ cell }: Props<T>) { + const { t } = useTranslation(); + + return ( + <Chip + label={cell.getValue() ? t("button.yes") : t("button.no")} + color={cell.getValue() ? "success" : "error"} + size="small" + sx={{ minWidth: 40 }} + /> + ); +} + +export default BooleanCell; diff --git a/webapp/src/components/common/GroupedDataTable/index.tsx b/webapp/src/components/common/GroupedDataTable/index.tsx index 5bc91534f9..6aed12cc32 100644 --- a/webapp/src/components/common/GroupedDataTable/index.tsx +++ b/webapp/src/components/common/GroupedDataTable/index.tsx @@ -1,69 +1,303 @@ import Box from "@mui/material/Box"; import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; -import ControlPointDuplicateIcon from "@mui/icons-material/ControlPointDuplicate"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteIcon from "@mui/icons-material/Delete"; -import { Button } from "@mui/material"; +import { Button, Skeleton } from "@mui/material"; import { MaterialReactTable, MRT_ToggleFiltersButton, MRT_ToggleGlobalFilterButton, + useMaterialReactTable, type MRT_RowSelectionState, type MRT_ColumnDef, } from "material-react-table"; import { useTranslation } from "react-i18next"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import CreateDialog from "./CreateDialog"; import ConfirmationDialog from "../dialogs/ConfirmationDialog"; -import { TRow, generateUniqueValue } from "./utils"; +import { generateUniqueValue, getTableOptionsForAlign } from "./utils"; import DuplicateDialog from "./DuplicateDialog"; +import { translateWithColon } from "../../../utils/i18nUtils"; +import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef"; +import * as R from "ramda"; +import * as RA from "ramda-adjunct"; +import { PromiseAny } from "../../../utils/tsUtils"; +import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; +import { toError } from "../../../utils/fnUtils"; +import useOperationInProgressCount from "../../../hooks/useOperationInProgressCount"; +import type { TRow } from "./types"; -export interface GroupedDataTableProps<TData extends TRow> { +export interface GroupedDataTableProps< + TGroups extends string[], + TData extends TRow<TGroups[number]>, +> { data: TData[]; - columns: Array<MRT_ColumnDef<TData>>; - groups: string[] | readonly string[]; - onCreate?: (values: TData) => Promise<TData>; - onDelete?: (ids: string[]) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: Array<MRT_ColumnDef<TData, any>>; + groups: TGroups; + onCreate?: (values: TRow<TGroups[number]>) => Promise<TData>; + onDuplicate?: (row: TData, newName: string) => Promise<TData>; + onDelete?: (rows: TData[]) => PromiseAny | void; + onNameClick?: (row: TData) => void; + onDataChange?: (data: TData[]) => void; + isLoading?: boolean; + deleteConfirmationMessage?: string | ((count: number) => string); + fillPendingRow?: ( + pendingRow: TRow<TGroups[number]>, + ) => TRow<TGroups[number]> & Partial<TData>; } -function GroupedDataTable<TData extends TRow>({ +// Use ids to identify default columns (instead of `accessorKey`), +// to have a unique identifier. It is more likely to have a duplicate +// `accessorKey` with `columns` prop. +const GROUP_COLUMN_ID = "_group"; +const NAME_COLUMN_ID = "_name"; + +function GroupedDataTable< + TGroups extends string[], + TData extends TRow<TGroups[number]>, +>({ data, columns, groups, onCreate, + onDuplicate, onDelete, -}: GroupedDataTableProps<TData>) { + onNameClick, + onDataChange, + isLoading, + deleteConfirmationMessage, + fillPendingRow, +}: GroupedDataTableProps<TGroups, TData>) { const { t } = useTranslation(); const [openDialog, setOpenDialog] = useState< "add" | "duplicate" | "delete" | "" >(""); const [tableData, setTableData] = useState(data); const [rowSelection, setRowSelection] = useState<MRT_RowSelectionState>({}); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + // Allow to use the last version of `onNameClick` in `tableColumns` + const callbacksRef = useAutoUpdateRef({ onNameClick }); + const pendingRows = useRef<Array<TRow<TGroups[number]>>>([]); + const { createOps, deleteOps, totalOps } = useOperationInProgressCount(); - const isAnyRowSelected = useMemo( - () => Object.values(rowSelection).some((value) => value), - [rowSelection], - ); + useEffect(() => setTableData(data), [data]); - const isOneRowSelected = useMemo( - () => Object.values(rowSelection).filter((value) => value).length === 1, - [rowSelection], - ); - - const selectedRow = useMemo(() => { - if (isOneRowSelected) { - const selectedIndex = Object.keys(rowSelection).find( - (key) => rowSelection[key], - ); - return selectedIndex && tableData[+selectedIndex]; - } - }, [isOneRowSelected, rowSelection, tableData]); + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => onDataChange?.(tableData), [tableData]); const existingNames = useMemo( () => tableData.map((row) => row.name.toLowerCase()), [tableData], ); + const tableColumns = useMemo<Array<MRT_ColumnDef<TData>>>( + () => [ + { + accessorKey: "group", + header: t("global.group"), + id: GROUP_COLUMN_ID, + size: 50, + filterVariant: "autocomplete", + filterSelectOptions: groups, + footer: translateWithColon("global.total"), + ...getTableOptionsForAlign("left"), + }, + { + accessorKey: "name", + header: t("global.name"), + id: NAME_COLUMN_ID, + size: 100, + filterVariant: "autocomplete", + filterSelectOptions: existingNames, + Cell: + callbacksRef.current.onNameClick && + (({ renderedCellValue, row }) => { + if (isPendingRow(row.original)) { + return renderedCellValue; + } + + return ( + <Box + sx={{ + display: "inline", + "&:hover": { + color: "primary.main", + textDecoration: "underline", + }, + }} + onClick={() => callbacksRef.current.onNameClick?.(row.original)} + > + {renderedCellValue} + </Box> + ); + }), + ...getTableOptionsForAlign("left"), + }, + ...columns.map( + (column) => + ({ + ...column, + Cell: (props) => { + const { row, renderedCellValue } = props; + // Use JSX instead of call it directly to remove React warning: + // 'Warning: Internal React error: Expected static flag was missing.' + const CellComp = column.Cell; + + if (isPendingRow(row.original)) { + return ( + <Skeleton + width={80} + height={24} + sx={{ display: "inline-block" }} + /> + ); + } + + return CellComp ? <CellComp {...props} /> : renderedCellValue; + }, + }) as MRT_ColumnDef<TData>, + ), + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [columns, t, ...groups], + ); + + const table = useMaterialReactTable({ + data: tableData, + columns: tableColumns, + initialState: { + grouping: [GROUP_COLUMN_ID], + density: "compact", + expanded: true, + columnPinning: { left: [GROUP_COLUMN_ID] }, + }, + state: { isLoading, isSaving: totalOps > 0, rowSelection }, + enableGrouping: true, + enableStickyFooter: true, + enableStickyHeader: true, + enableColumnDragging: false, + enableColumnActions: false, + enableBottomToolbar: false, + enablePagination: false, + positionToolbarAlertBanner: "none", + // Rows + muiTableBodyRowProps: ({ row }) => { + const isPending = isPendingRow(row.original); + + return { + onClick: () => { + if (isPending) { + return; + } + + const isGrouped = row.getIsGrouped(); + const rowIds = isGrouped + ? row.getLeafRows().map((r) => r.id) + : [row.id]; + + setRowSelection((prev) => { + const newValue = isGrouped + ? !rowIds.some((id) => prev[id]) // Select/Deselect all + : !prev[row.id]; + + return { + ...prev, + ...rowIds.reduce((acc, id) => ({ ...acc, [id]: newValue }), {}), + }; + }); + }, + selected: rowSelection[row.id], + sx: { cursor: isPending ? "wait" : "pointer" }, + }; + }, + // Toolbars + renderTopToolbarCustomActions: ({ table }) => ( + <Box sx={{ display: "flex", gap: 1 }}> + {onCreate && ( + <Button + startIcon={<AddCircleOutlineIcon />} + variant="contained" + size="small" + onClick={() => setOpenDialog("add")} + > + {t("button.add")} + </Button> + )} + {onDuplicate && ( + <Button + startIcon={<ContentCopyIcon />} + variant="outlined" + size="small" + onClick={() => setOpenDialog("duplicate")} + disabled={table.getSelectedRowModel().rows.length !== 1} + > + {t("global.duplicate")} + </Button> + )} + {onDelete && ( + <Button + startIcon={<DeleteOutlineIcon />} + color="error" + variant="outlined" + size="small" + onClick={() => setOpenDialog("delete")} + disabled={table.getSelectedRowModel().rows.length === 0} + > + {t("global.delete")} + </Button> + )} + </Box> + ), + renderToolbarInternalActions: ({ table }) => ( + <> + <MRT_ToggleGlobalFilterButton table={table} /> + <MRT_ToggleFiltersButton table={table} /> + </> + ), + onRowSelectionChange: setRowSelection, + // Styles + muiTablePaperProps: { sx: { display: "flex", flexDirection: "column" } }, // Allow to have scroll + ...R.mergeDeepRight(getTableOptionsForAlign("right"), { + muiTableBodyCellProps: { + sx: { borderBottom: "1px solid rgba(224, 224, 224, 0.3)" }, + }, + }), + }); + + const selectedRows = table + .getSelectedRowModel() + .rows.map((row) => row.original); + const selectedRow = selectedRows.length === 1 ? selectedRows[0] : null; + + //////////////////////////////////////////////////////////////// + // Optimistic + //////////////////////////////////////////////////////////////// + + const addPendingRow = (row: TRow<TGroups[number]>) => { + const pendingRow = fillPendingRow?.(row) || row; + + pendingRows.current.push(pendingRow); + + // Type can be asserted as `TData` because the row will be checked in cell renders + // and `fillPendingRow` allows to add needed data + setTableData((prev) => [...prev, pendingRow as TData]); + + return pendingRow; + }; + + const removePendingRow = (row: TRow<TGroups[number]>) => { + if (isPendingRow(row)) { + pendingRows.current = pendingRows.current.filter((r) => r !== row); + setTableData((prev) => prev.filter((r) => r !== row)); + } + }; + + function isPendingRow(row: TRow<TGroups[number]>) { + return pendingRows.current.includes(row); + } + //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// @@ -74,51 +308,80 @@ function GroupedDataTable<TData extends TRow>({ // Event Handlers //////////////////////////////////////////////////////////////// - const handleCreate = async (values: TData) => { - if (onCreate) { - const newRow = await onCreate(values); - setTableData((prevTableData) => [...prevTableData, newRow]); - } - }; + const handleCreate = async (values: TRow<TGroups[number]>) => { + closeDialog(); - const handleDelete = () => { - if (!onDelete) { + if (!onCreate) { return; } - const rowIndexes = Object.keys(rowSelection) - .map(Number) - // ignore groups names - .filter(Number.isInteger); + createOps.increment(); + const pendingRow = addPendingRow(values); - const rowIdsToDelete = rowIndexes.map((index) => tableData[index].id); + try { + const newRow = await onCreate(values); + setTableData((prev) => [...prev, newRow]); + } catch (error) { + enqueueErrorSnackbar(t("global.error.create"), toError(error)); + } - onDelete(rowIdsToDelete); - setTableData((prevTableData) => - prevTableData.filter((row) => !rowIdsToDelete.includes(row.id)), - ); - setRowSelection({}); - closeDialog(); + removePendingRow(pendingRow); + createOps.decrement(); }; - const handleDuplicate = async (name: string) => { - if (!selectedRow) { + const handleDuplicate = async (newName: string) => { + closeDialog(); + + if (!onDuplicate || !selectedRow) { return; } - const id = generateUniqueValue("id", name, tableData); + setRowSelection({}); const duplicatedRow = { ...selectedRow, - id, - name, + name: newName, }; - if (onCreate) { - const newRow = await onCreate(duplicatedRow); - setTableData((prevTableData) => [...prevTableData, newRow]); - setRowSelection({}); + createOps.increment(); + const pendingRow = addPendingRow(duplicatedRow); + + try { + const newRow = await onDuplicate(selectedRow, newName); + setTableData((prev) => [...prev, newRow]); + } catch (error) { + enqueueErrorSnackbar(t("global.error.create"), toError(error)); } + + removePendingRow(pendingRow); + createOps.decrement(); + }; + + const handleDelete = async () => { + closeDialog(); + + if (!onDelete) { + return; + } + + setRowSelection({}); + + const rowsToDelete = selectedRows; + + setTableData((prevTableData) => + prevTableData.filter((row) => !rowsToDelete.includes(row)), + ); + + deleteOps.increment(); + + try { + await onDelete(rowsToDelete); + } catch (error) { + enqueueErrorSnackbar(t("global.error.delete"), toError(error)); + setTableData((prevTableData) => [...prevTableData, ...rowsToDelete]); + } + + deleteOps.decrement(); }; //////////////////////////////////////////////////////////////// @@ -127,106 +390,7 @@ function GroupedDataTable<TData extends TRow>({ return ( <> - <MaterialReactTable - data={tableData} - columns={columns} - initialState={{ - grouping: ["group"], - density: "compact", - expanded: true, - columnPinning: { left: ["group"] }, - }} - enablePinning - enableExpanding - enableGrouping - muiTableBodyRowProps={({ row: { id, groupingColumnId } }) => { - const handleRowClick = () => { - // prevent group rows to be selected - if (groupingColumnId === undefined) { - setRowSelection((prev) => ({ - ...prev, - [id]: !prev[id], - })); - } - }; - - return { - onClick: handleRowClick, - selected: rowSelection[id], - sx: { - cursor: "pointer", - }, - }; - }} - state={{ rowSelection }} - enableColumnDragging={false} - enableColumnActions={false} - positionToolbarAlertBanner="none" - enableBottomToolbar={false} - enableStickyFooter - enableStickyHeader - enablePagination={false} - renderTopToolbarCustomActions={() => ( - <Box sx={{ display: "flex", gap: 1 }}> - {onCreate && ( - <Button - startIcon={<AddCircleOutlineIcon />} - variant="contained" - size="small" - onClick={() => setOpenDialog("add")} - > - {t("button.add")} - </Button> - )} - <Button - startIcon={<ControlPointDuplicateIcon />} - variant="outlined" - size="small" - onClick={() => setOpenDialog("duplicate")} - disabled={!isOneRowSelected} - > - {t("global.duplicate")} - </Button> - {onDelete && ( - <Button - startIcon={<DeleteOutlineIcon />} - variant="outlined" - size="small" - onClick={() => setOpenDialog("delete")} - disabled={!isAnyRowSelected} - > - {t("global.delete")} - </Button> - )} - </Box> - )} - renderToolbarInternalActions={({ table }) => ( - <> - <MRT_ToggleGlobalFilterButton table={table} /> - <MRT_ToggleFiltersButton table={table} /> - </> - )} - muiTableHeadCellProps={{ - align: "right", - }} - muiTableBodyCellProps={{ - align: "right", - sx: { - borderBottom: "1px solid rgba(224, 224, 224, 0.3)", - }, - }} - muiTableFooterCellProps={{ - align: "right", - }} - muiTablePaperProps={{ - sx: { - width: 1, - display: "flex", - flexDirection: "column", - overflow: "auto", - }, - }} - /> + <MaterialReactTable table={table} /> {openDialog === "add" && ( <CreateDialog open @@ -242,7 +406,7 @@ function GroupedDataTable<TData extends TRow>({ onClose={closeDialog} onSubmit={handleDuplicate} existingNames={existingNames} - defaultName={generateUniqueValue("name", selectedRow.name, tableData)} + defaultName={generateUniqueValue(selectedRow.name, tableData)} /> )} {openDialog === "delete" && ( @@ -254,7 +418,9 @@ function GroupedDataTable<TData extends TRow>({ onConfirm={handleDelete} alert="warning" > - {t("studies.modelization.clusters.question.delete")} + {RA.isFunction(deleteConfirmationMessage) + ? deleteConfirmationMessage(selectedRows.length) + : deleteConfirmationMessage ?? t("dialog.message.confirmDelete")} </ConfirmationDialog> )} </> diff --git a/webapp/src/components/common/GroupedDataTable/types.ts b/webapp/src/components/common/GroupedDataTable/types.ts new file mode 100644 index 0000000000..6f91852cb4 --- /dev/null +++ b/webapp/src/components/common/GroupedDataTable/types.ts @@ -0,0 +1,4 @@ +export interface TRow<T = string> { + name: string; + group: T; +} diff --git a/webapp/src/components/common/GroupedDataTable/utils.ts b/webapp/src/components/common/GroupedDataTable/utils.ts index df119f0fa8..f209d83bae 100644 --- a/webapp/src/components/common/GroupedDataTable/utils.ts +++ b/webapp/src/components/common/GroupedDataTable/utils.ts @@ -1,15 +1,6 @@ import * as R from "ramda"; -import { nameToId } from "../../../services/utils"; - -//////////////////////////////////////////////////////////////// -// Types -//////////////////////////////////////////////////////////////// - -export interface TRow { - id: string; - name: string; - group: string; -} +import { TableCellProps } from "@mui/material"; +import type { TRow } from "./types"; //////////////////////////////////////////////////////////////// // Functions @@ -58,24 +49,22 @@ export const generateNextValue = ( * * This function leverages `generateNextValue` to ensure the uniqueness of the value. * - * @param property - The property for which the unique value is generated, either "name" or "id". * @param originalValue - The original value of the specified property. * @param tableData - The existing table data to check against for ensuring uniqueness. * @returns A unique value for the specified property. */ export const generateUniqueValue = ( - property: "name" | "id", originalValue: string, tableData: TRow[], ): string => { - let baseValue: string; - - if (property === "name") { - baseValue = `${originalValue} - copy`; - } else { - baseValue = nameToId(originalValue); - } - - const existingValues = tableData.map((row) => row[property]); - return generateNextValue(baseValue, existingValues); + const existingValues = tableData.map((row) => row.name); + return generateNextValue(`${originalValue} - copy`, existingValues); }; + +export function getTableOptionsForAlign(align: TableCellProps["align"]) { + return { + muiTableHeadCellProps: { align }, + muiTableBodyCellProps: { align }, + muiTableFooterCellProps: { align }, + }; +} diff --git a/webapp/src/hooks/useOperationInProgressCount.ts b/webapp/src/hooks/useOperationInProgressCount.ts new file mode 100644 index 0000000000..bc71fb677a --- /dev/null +++ b/webapp/src/hooks/useOperationInProgressCount.ts @@ -0,0 +1,51 @@ +import { useMemo, useState } from "react"; +import * as R from "ramda"; + +/** + * Hook to tracks the number of CRUD operations in progress. + * + * @returns An object containing methods to increment, decrement, + * and retrieve the count of each operation type. + */ +function useOperationInProgressCount() { + const [opsInProgressCount, setOpsInProgressCount] = useState({ + create: 0, + read: 0, + update: 0, + delete: 0, + }); + + const makeOperationMethods = ( + operation: keyof typeof opsInProgressCount, + ) => ({ + increment: (number = 1) => { + setOpsInProgressCount((prev) => ({ + ...prev, + [operation]: prev[operation] + number, + })); + }, + decrement: (number = 1) => { + setOpsInProgressCount((prev) => ({ + ...prev, + [operation]: Math.max(prev[operation] - number, 0), + })); + }, + total: opsInProgressCount[operation], + }); + + const methods = useMemo( + () => ({ + createOps: makeOperationMethods("create"), + readOps: makeOperationMethods("read"), + updateOps: makeOperationMethods("update"), + deleteOps: makeOperationMethods("delete"), + totalOps: Object.values(opsInProgressCount).reduce(R.add, 0), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [opsInProgressCount], + ); + + return methods; +} + +export default useOperationInProgressCount; diff --git a/webapp/src/hooks/useUpdateEffectOnce.ts b/webapp/src/hooks/useUpdateEffectOnce.ts new file mode 100644 index 0000000000..61fedd115e --- /dev/null +++ b/webapp/src/hooks/useUpdateEffectOnce.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from "react"; +import { useUpdateEffect } from "react-use"; + +/** + * Hook that runs the effect only at the first dependencies update. + * It behaves like the `useEffect` hook, but it skips the initial run, + * and the runs following the first update. + * + * @param effect - The effect function to run. + * @param deps - An array of dependencies to watch for changes. + */ +const useUpdateEffectOnce: typeof useEffect = (effect, deps) => { + const hasUpdated = useRef(false); + + useUpdateEffect(() => { + if (!hasUpdated.current) { + hasUpdated.current = true; + return effect(); + } + }, deps); +}; + +export default useUpdateEffectOnce; diff --git a/webapp/src/i18n.ts b/webapp/src/i18n.ts index d1d95a0574..980cffbf89 100644 --- a/webapp/src/i18n.ts +++ b/webapp/src/i18n.ts @@ -2,34 +2,35 @@ import i18n from "i18next"; import Backend from "i18next-http-backend"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; +import { version } from "../package.json"; -export default function i18nInit(version = "unknown") { - i18n - // load translation using xhr -> see /public/locales - // learn more: https://github.com/i18next/i18next-xhr-backend - .use(Backend) - // detect user language - // learn more: https://github.com/i18next/i18next-browser-languageDetector - .use(LanguageDetector) - // pass the i18n instance to react-i18next. - .use(initReactI18next) - // init i18next - // for all options read: https://www.i18next.com/overview/configuration-options - .init({ - fallbackLng: "en", - backend: { - loadPath: `${ - import.meta.env.BASE_URL - }locales/{{lng}}/{{ns}}.json?v=${version}`, - }, - react: { - useSuspense: false, - }, - interpolation: { - escapeValue: false, // not needed for react as it escapes by default - }, - ns: ["main"], - defaultNS: "main", - returnNull: false, - }); -} +i18n + // load translation using xhr -> see /public/locales + // learn more: https://github.com/i18next/i18next-xhr-backend + .use(Backend) + // detect user language + // learn more: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) + // pass the i18n instance to react-i18next. + .use(initReactI18next) + // init i18next + // for all options read: https://www.i18next.com/overview/configuration-options + .init({ + fallbackLng: "en", + backend: { + loadPath: `${ + import.meta.env.BASE_URL + }locales/{{lng}}/{{ns}}.json?v=${version}`, + }, + react: { + useSuspense: false, + }, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + ns: ["main"], + defaultNS: "main", + returnNull: false, + }); + +export default i18n; diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 80dec85813..2c6792f7a8 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -1,7 +1,6 @@ import { createRoot } from "react-dom/client"; import { Provider } from "react-redux"; import { StyledEngineProvider } from "@mui/material"; -import i18nInit from "./i18n"; import "./index.css"; import App from "./components/App"; import { Config, initConfig } from "./services/config"; @@ -15,8 +14,6 @@ initConfig((config: Config) => { window.location.reload(); } - i18nInit(config.version.gitcommit); - const container = document.getElementById("root") as HTMLElement; const root = createRoot(container); diff --git a/webapp/src/utils/fnUtils.ts b/webapp/src/utils/fnUtils.ts index 226e58c836..dd32ac4c97 100644 --- a/webapp/src/utils/fnUtils.ts +++ b/webapp/src/utils/fnUtils.ts @@ -11,3 +11,22 @@ export function voidFn<TArgs extends unknown[]>(...args: TArgs) { // Intentionally empty, as its purpose is to do nothing. } + +/** + * A utility function that converts an unknown value to an Error object. + * If the value is already an Error object, it is returned as is. + * If the value is a string, it is used as the message for the new Error object. + * If the value is anything else, a new Error object with a generic message is created. + * + * @param error - The value to convert to an Error object. + * @returns An Error object. + */ +export function toError(error: unknown) { + if (error instanceof Error) { + return error; + } + if (typeof error === "string") { + return new Error(error); + } + return new Error("An unknown error occurred"); +} diff --git a/webapp/src/utils/i18nUtils.ts b/webapp/src/utils/i18nUtils.ts new file mode 100644 index 0000000000..c613deab68 --- /dev/null +++ b/webapp/src/utils/i18nUtils.ts @@ -0,0 +1,22 @@ +import i18n from "../i18n"; + +/** + * Gets the current language used in the application. + * + * @returns The current language. + */ +export function getCurrentLanguage() { + return i18n.language; +} + +/** + * Translates the given key and appends a colon (:) at the end + * with the appropriate spacing for the current language. + * + * @param key - The translation key. + * @returns The translated string with a colon (:) appended. + */ +export function translateWithColon(key: string): string { + const lang = i18n.language; + return `${i18n.t(key)}${lang.startsWith("fr") ? " " : ""}:`; +} diff --git a/webapp/src/utils/tsUtils.ts b/webapp/src/utils/tsUtils.ts index eb60713aa8..7acf6465a2 100644 --- a/webapp/src/utils/tsUtils.ts +++ b/webapp/src/utils/tsUtils.ts @@ -1,3 +1,16 @@ +import { O } from "ts-toolbelt"; + +/** + * Allow to use `any` with `Promise` type without disabling ESLint rule. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type PromiseAny = Promise<any>; + +/** + * Make all properties in T optional, except for those specified by K. + */ +export type PartialExceptFor<T, K extends keyof T> = O.Required<Partial<T>, K>; + export function tuple<T extends unknown[]>(...items: T): T { return items; } diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 94f1f95c30..9af316cbba 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -99,7 +99,7 @@ export function validateString( // Check for duplication against existing values. if (existingValues.map(normalize).includes(comparisonValue)) { - return t("form.field.duplicate", { 0: value }); + return t("form.field.duplicate"); } // Check for inclusion in the list of excluded values.