diff --git a/webapp/package-lock.json b/webapp/package-lock.json index c516722650..809c088d3d 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -20,6 +20,7 @@ "axios": "1.7.7", "clsx": "2.1.1", "d3": "5.16.0", + "date-fns": "4.1.0", "debug": "4.3.7", "draft-convert": "2.1.13", "draft-js": "0.11.7", @@ -72,6 +73,7 @@ "@testing-library/user-event": "14.5.2", "@total-typescript/ts-reset": "0.6.1", "@types/d3": "5.16.0", + "@types/date-fns": "2.6.0", "@types/debug": "4.1.12", "@types/draft-convert": "2.1.8", "@types/draft-js": "0.11.18", @@ -3732,6 +3734,16 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/date-fns": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.6.0.tgz", + "integrity": "sha512-9DSw2ZRzV0Tmpa6PHHJbMcZn79HHus+BBBohcOaDzkK/G3zMjDUDYjJIWBFLbkh+1+/IOS0A59BpQfdr37hASg==", + "deprecated": "This is a stub types definition for date-fns (https://github.com/date-fns/date-fns). date-fns provides its own type definitions, so you don't need @types/date-fns installed!", + "dev": true, + "dependencies": { + "date-fns": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -6148,6 +6160,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", diff --git a/webapp/package.json b/webapp/package.json index da78848498..117add1312 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -27,6 +27,7 @@ "clsx": "2.1.1", "d3": "5.16.0", "debug": "4.3.7", + "date-fns": "4.1.0", "draft-convert": "2.1.13", "draft-js": "0.11.7", "draftjs-to-html": "0.9.1", @@ -83,6 +84,7 @@ "@types/draft-js": "0.11.18", "@types/draftjs-to-html": "0.8.4", "@types/js-cookie": "3.0.6", + "@types/date-fns": "2.6.0", "@types/jsoneditor": "9.9.5", "@types/lodash": "4.17.9", "@types/node": "22.7.3", diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index eb81c6c734..cd400940b1 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -80,6 +80,7 @@ "global.time.weekly": "Weekly", "global.time.monthly": "Monthly", "global.time.annual": "Annual", + "global.time.weekShort": "W.", "global.update.success": "Update successful", "global.errorLogs": "Error logs", "global.error.emptyName": "Name cannot be empty", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index a22f7ce299..99c53ce430 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -80,6 +80,7 @@ "global.time.weekly": "Hebdomadaire", "global.time.monthly": "Mensuel", "global.time.annual": "Annuel", + "global.time.weekShort": "S.", "global.update.success": "Mise à jour réussie", "global.errorLogs": "Logs d'erreurs", "global.error.emptyName": "Le nom ne peut pas être vide", diff --git a/webapp/src/common/types.ts b/webapp/src/common/types.ts index 9966fdf62c..a58766e444 100644 --- a/webapp/src/common/types.ts +++ b/webapp/src/common/types.ts @@ -661,3 +661,24 @@ export interface TaskView { } export type ValidationReturn = string | true; + +export interface MatrixConfig { + url: string; + titleKey: string; + columnsNames?: string[]; +} + +export interface SplitMatrixContent { + type: "split"; + matrices: [MatrixConfig, MatrixConfig]; +} + +export interface SingleMatrixContent { + type: "single"; + matrix: MatrixConfig; +} + +export interface MatrixItem { + titleKey: string; + content: SplitMatrixContent | SingleMatrixContent; +} diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx index 4957e3a88e..8c791964ab 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx @@ -12,20 +12,11 @@ * This file is part of the Antares project. */ -import { MatrixStats } from "../../../../../../common/types"; -import MatrixInput from "../../../../../common/MatrixInput"; +import Matrix from "../../../../../common/Matrix"; import type { DataCompProps } from "../utils"; -function Matrix({ studyId, filename, filePath, canEdit }: DataCompProps) { - return ( - - ); +function DebugMatrix({ studyId, filename, filePath, canEdit }: DataCompProps) { + return ; } -export default Matrix; +export default DebugMatrix; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/index.tsx index 8231ad0613..fc50dff4a8 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/index.tsx @@ -27,7 +27,7 @@ import { } from "./utils"; import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; import HydroMatrixDialog from "../HydroMatrixDialog"; -import { HydroMatrixType } from "../utils"; +import { HydroMatrix } from "../utils"; import { FormBox, FormPaper } from "../style"; import ViewMatrixButton from "../ViewMatrixButton"; @@ -84,7 +84,7 @@ function Allocation() { {matrixDialogOpen && ( setMatrixDialogOpen(false)} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/utils.ts index 46041fdcc2..5d5d054a11 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Allocation/utils.ts @@ -12,12 +12,9 @@ * This file is part of the Antares project. */ -import { - StudyMetadata, - Area, - MatrixType, -} from "../../../../../../../../common/types"; +import { StudyMetadata, Area } from "../../../../../../../../common/types"; import client from "../../../../../../../../services/api/client"; +import { MatrixDataDTO } from "../../../../../../../common/Matrix/shared/types"; import { AreaCoefficientItem } from "../utils"; //////////////////////////////////////////////////////////////// @@ -58,7 +55,7 @@ export async function setAllocationFormFields( export const getAllocationMatrix = async ( studyId: StudyMetadata["id"], -): Promise => { +): Promise => { const res = await client.get( `v1/studies/${studyId}/areas/hydro/allocation/matrix`, ); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/index.tsx index d6dce25747..df566ba68d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/index.tsx @@ -27,7 +27,7 @@ import { } from "./utils"; import Fields from "./Fields"; import HydroMatrixDialog from "../HydroMatrixDialog"; -import { HydroMatrixType } from "../utils"; +import { HydroMatrix } from "../utils"; import { FormBox, FormPaper } from "../style"; import ViewMatrixButton from "../ViewMatrixButton"; @@ -79,7 +79,7 @@ function Correlation() { {matrixDialogOpen && ( setMatrixDialogOpen(false)} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/utils.ts index dc2b547e63..253963b1c6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/Correlation/utils.ts @@ -12,12 +12,9 @@ * This file is part of the Antares project. */ -import { - StudyMetadata, - Area, - MatrixType, -} from "../../../../../../../../common/types"; +import { StudyMetadata, Area } from "../../../../../../../../common/types"; import client from "../../../../../../../../services/api/client"; +import { MatrixDataDTO } from "../../../../../../../common/Matrix/shared/types"; import { AreaCoefficientItem } from "../utils"; //////////////////////////////////////////////////////////////// @@ -58,7 +55,7 @@ export async function setCorrelationFormFields( export async function getCorrelationMatrix( studyId: StudyMetadata["id"], -): Promise { +): Promise { const res = await client.get( `v1/studies/${studyId}/areas/hydro/correlation/matrix`, ); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrix.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrix.tsx index ff6218de35..372ae3cfce 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrix.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrix.tsx @@ -12,20 +12,17 @@ * This file is part of the Antares project. */ -import { useOutletContext } from "react-router"; -import { StudyMetadata } from "../../../../../../../common/types"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; -import MatrixInput from "../../../../../../common/MatrixInput"; -import { Root } from "./style"; import { MATRICES, HydroMatrixType } from "./utils"; +import Matrix from "../../../../../../common/Matrix"; +import { Box } from "@mui/material"; interface Props { type: HydroMatrixType; } function HydroMatrix({ type }: Props) { - const { study } = useOutletContext<{ study: StudyMetadata }>(); const areaId = useAppSelector(getCurrentAreaId); const hydroMatrix = MATRICES[type]; @@ -35,19 +32,19 @@ function HydroMatrix({ type }: Props) { //////////////////////////////////////////////////////////////// return ( - - + - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrixDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrixDialog.tsx index 85223c55db..73b109f7dc 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrixDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/HydroMatrixDialog.tsx @@ -12,13 +12,27 @@ * This file is part of the Antares project. */ -import { Button, Box } from "@mui/material"; +import { Button, Box, Skeleton } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { useState, useEffect } from "react"; import BasicDialog, { BasicDialogProps, } from "../../../../../../common/dialogs/BasicDialog"; -import HydroMatrix from "./HydroMatrix"; +import Matrix from "../../../../../../common/Matrix"; import { HydroMatrixType } from "./utils"; +import { getAllocationMatrix } from "./Allocation/utils"; +import { getCorrelationMatrix } from "./Correlation/utils"; +import { useOutletContext } from "react-router"; +import { StudyMetadata } from "../../../../../../../common/types"; +import { MatrixDataDTO } from "@/components/common/Matrix/shared/types"; +import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; +import { AxiosError } from "axios"; + +interface AdaptedMatrixData { + data: number[][]; + columns: string[]; + index: string[]; +} interface Props { open: boolean; @@ -26,8 +40,63 @@ interface Props { type: HydroMatrixType; } +const MATRIX_FETCHERS = { + Allocation: getAllocationMatrix, + Correlation: getCorrelationMatrix, +} as const; + function HydroMatrixDialog({ open, onClose, type }: Props) { + /** + * !TEMPORARY SOLUTION - Matrix Data Model Adaptation + * + * This component handles a specific case (Allocation, Correlation). + * They receive their columns and row headers from the backend as numeric arrays. + * This differs from the standard Matrix component usage where these properties expect string arrays + * representing actual header labels. + * + * Current scenario: + * - Backend returns {columns: number[], index: number[]} for these specific matrices + * - Matrix component expects {columns: string[], rowHeaders: string[]} for its headers + * + * Future API model update will: + * - Rename 'index' to 'rowHeaders' to better reflect its purpose + * - Return properly formatted string arrays for header labels + * - Allow using the standard HydroMatrix component without this adapter + * + * TODO - Once the API is updated: + * 1. The model adapter layer will be removed + * 2. These matrices will use the HydroMatrix component directly + * 3. All matrices will follow the same data structure pattern + */ + const { t } = useTranslation(); + const { study } = useOutletContext<{ study: StudyMetadata }>(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const fetchFn = MATRIX_FETCHERS[type as keyof typeof MATRIX_FETCHERS]; + const [matrix, setMatrix] = useState( + undefined, + ); + + const matrixModelAdapter = (apiData: MatrixDataDTO): AdaptedMatrixData => ({ + data: apiData.data, + columns: apiData.columns.map(String), + index: apiData.index.map(String), // Will be renamed to rowHeaders in future API version + }); + + useEffect(() => { + const fetchData = async () => { + try { + const data = await fetchFn(study.id); + setMatrix(matrixModelAdapter(data)); + } catch (error) { + enqueueErrorSnackbar(t("data.error.matrix"), error as AxiosError); + } + }; + + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [study.id, type, t]); + const dialogProps: BasicDialogProps = { open, onClose, @@ -38,6 +107,10 @@ function HydroMatrixDialog({ open, onClose, type }: Props) { ), }; + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + return ( - + {matrix ? ( + + ) : ( + + )} ); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx index 4209e439bf..215b3b5954 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx @@ -50,7 +50,9 @@ function Hydro() { // JSX //////////////////////////////////////////////////////////////// - return ; + return ( + + ); } export default Hydro; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/style.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/style.ts index b707cafec5..b329679cc6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/style.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/style.ts @@ -14,16 +14,6 @@ import { styled, Box, Paper } from "@mui/material"; -export const Root = styled(Box)(({ theme }) => ({ - width: "100%", - height: "100%", - padding: theme.spacing(2), - paddingTop: 0, - marginTop: theme.spacing(1), - display: "flex", - overflowY: "auto", -})); - export const FormBox = styled(Box)(({ theme }) => ({ width: "100%", height: "100%", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts index 316745826e..db3d8802a0 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/utils.ts @@ -12,7 +12,10 @@ * This file is part of the Antares project. */ -import { MatrixStats, MatrixType } from "../../../../../../../common/types"; +import { + MatrixDataDTO, + AggregateConfig, +} from "../../../../../../common/Matrix/shared/types"; import { SplitViewProps } from "../../../../../../common/SplitView"; import { getAllocationMatrix } from "./Allocation/utils"; import { getCorrelationMatrix } from "./Correlation/utils"; @@ -22,42 +25,44 @@ import InflowStructure from "./InflowStructure"; // Enums //////////////////////////////////////////////////////////////// -export enum HydroMatrixType { - Dailypower, - EnergyCredits, - ReservoirLevels, - WaterValues, - HydroStorage, - RunOfRiver, - MinGen, - InflowPattern, - OverallMonthlyHydro, - Allocation, - Correlation, -} +export const HydroMatrix = { + Dailypower: "Dailypower", + EnergyCredits: "EnergyCredits", + ReservoirLevels: "ReservoirLevels", + WaterValues: "WaterValues", + HydroStorage: "HydroStorage", + RunOfRiver: "RunOfRiver", + MinGen: "MinGen", + InflowPattern: "InflowPattern", + OverallMonthlyHydro: "OverallMonthlyHydro", + Allocation: "Allocation", + Correlation: "Correlation", +} as const; //////////////////////////////////////////////////////////////// // Types //////////////////////////////////////////////////////////////// -export type fetchMatrixFn = (studyId: string) => Promise; +export type fetchMatrixFn = (studyId: string) => Promise; +export type HydroMatrixType = (typeof HydroMatrix)[keyof typeof HydroMatrix]; export interface HydroMatrixProps { title: string; url: string; - cols?: string[]; - rows?: string[]; - stats: MatrixStats; + columns?: string[]; + rowHeaders?: string[]; fetchFn?: fetchMatrixFn; - disableEdit?: boolean; - enablePercentDisplay?: boolean; + aggregates?: AggregateConfig; + enableDateTimeColumn?: boolean; + readOnly?: boolean; + showPercent?: boolean; } type Matrices = Record; export interface HydroRoute { path: string; - type: number; + type: HydroMatrixType; isSplitView?: boolean; splitConfig?: { direction: SplitViewProps["direction"]; @@ -79,112 +84,105 @@ export interface AreaCoefficientItem { export const HYDRO_ROUTES: HydroRoute[] = [ { path: "inflow-structure", - type: HydroMatrixType.InflowPattern, + type: HydroMatrix.InflowPattern, isSplitView: true, splitConfig: { direction: "horizontal", - partnerType: HydroMatrixType.OverallMonthlyHydro, + partnerType: HydroMatrix.OverallMonthlyHydro, sizes: [50, 50], }, form: InflowStructure, }, { path: "dailypower&energy", - type: HydroMatrixType.Dailypower, + type: HydroMatrix.Dailypower, isSplitView: true, splitConfig: { direction: "vertical", - partnerType: HydroMatrixType.EnergyCredits, + partnerType: HydroMatrix.EnergyCredits, sizes: [30, 70], }, }, { path: "reservoirlevels", - type: HydroMatrixType.ReservoirLevels, + type: HydroMatrix.ReservoirLevels, }, { path: "watervalues", - type: HydroMatrixType.WaterValues, + type: HydroMatrix.WaterValues, }, { path: "hydrostorage", - type: HydroMatrixType.HydroStorage, + type: HydroMatrix.HydroStorage, }, { path: "ror", - type: HydroMatrixType.RunOfRiver, + type: HydroMatrix.RunOfRiver, }, { path: "mingen", - type: HydroMatrixType.MinGen, + type: HydroMatrix.MinGen, }, ]; export const MATRICES: Matrices = { - [HydroMatrixType.Dailypower]: { + [HydroMatrix.Dailypower]: { title: "Credit Modulations", url: "input/hydro/common/capacity/creditmodulations_{areaId}", - cols: generateColumns("%"), - rows: ["Generating Power", "Pumping Power"], - stats: MatrixStats.NOCOL, - enablePercentDisplay: true, + columns: generateColumns("%"), + rowHeaders: ["Generating Power", "Pumping Power"], + enableDateTimeColumn: false, }, - [HydroMatrixType.EnergyCredits]: { + [HydroMatrix.EnergyCredits]: { title: "Standard Credits", url: "input/hydro/common/capacity/maxpower_{areaId}", - cols: [ + columns: [ "Generating Max Power (MW)", "Generating Max Energy (Hours at Pmax)", "Pumping Max Power (MW)", "Pumping Max Energy (Hours at Pmax)", ], - stats: MatrixStats.NOCOL, }, - [HydroMatrixType.ReservoirLevels]: { + [HydroMatrix.ReservoirLevels]: { title: "Reservoir Levels", url: "input/hydro/common/capacity/reservoir_{areaId}", - cols: ["Lev Low (%)", "Lev Avg (%)", "Lev High (%)"], - stats: MatrixStats.NOCOL, - enablePercentDisplay: true, + columns: ["Lev Low (%)", "Lev Avg (%)", "Lev High (%)"], }, - [HydroMatrixType.WaterValues]: { + [HydroMatrix.WaterValues]: { title: "Water Values", url: "input/hydro/common/capacity/waterValues_{areaId}", - cols: generateColumns("%"), - stats: MatrixStats.NOCOL, + // columns: generateColumns("%"), // TODO this causes Runtime error to be fixed }, - [HydroMatrixType.HydroStorage]: { + [HydroMatrix.HydroStorage]: { title: "Hydro Storage", url: "input/hydro/series/{areaId}/mod", - stats: MatrixStats.STATS, + aggregates: "stats", }, - [HydroMatrixType.RunOfRiver]: { + [HydroMatrix.RunOfRiver]: { title: "Run Of River", url: "input/hydro/series/{areaId}/ror", - stats: MatrixStats.STATS, + aggregates: "stats", }, - [HydroMatrixType.MinGen]: { + [HydroMatrix.MinGen]: { title: "Min Gen", url: "input/hydro/series/{areaId}/mingen", - stats: MatrixStats.STATS, }, - [HydroMatrixType.InflowPattern]: { + [HydroMatrix.InflowPattern]: { title: "Inflow Pattern", url: "input/hydro/common/capacity/inflowPattern_{areaId}", - cols: ["Inflow Pattern (X)"], - stats: MatrixStats.NOCOL, + columns: ["Inflow Pattern (X)"], }, - [HydroMatrixType.OverallMonthlyHydro]: { + [HydroMatrix.OverallMonthlyHydro]: { title: "Overall Monthly Hydro", url: "input/hydro/prepro/{areaId}/energy", - cols: [ + columns: [ "Expectation (MWh)", "Std Deviation (MWh)", "Min. (MWh)", "Max. (MWh)", "ROR Share", ], - rows: [ + rowHeaders: [ "January", "February", "March", @@ -198,23 +196,20 @@ export const MATRICES: Matrices = { "November", "December", ], - stats: MatrixStats.NOCOL, }, - [HydroMatrixType.Allocation]: { + [HydroMatrix.Allocation]: { title: "Allocation", url: "", - stats: MatrixStats.NOCOL, fetchFn: getAllocationMatrix, - disableEdit: true, - enablePercentDisplay: true, + enableDateTimeColumn: false, + readOnly: true, }, - [HydroMatrixType.Correlation]: { + [HydroMatrix.Correlation]: { title: "Correlation", url: "", - stats: MatrixStats.NOCOL, fetchFn: getCorrelationMatrix, - disableEdit: true, - enablePercentDisplay: true, + enableDateTimeColumn: false, + readOnly: true, }, }; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx index a9d67519de..9581fae79d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx @@ -14,7 +14,7 @@ import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; -import Matrix from "../../../../../common/MatrixGrid/Matrix"; +import Matrix from "../../../../../common/Matrix"; function Load() { const currentArea = useAppSelector(getCurrentAreaId); @@ -24,7 +24,7 @@ function Load() { // JSX //////////////////////////////////////////////////////////////// - return ; + return ; } export default Load; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx index 46451653a9..8cdf4946af 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx @@ -12,18 +12,14 @@ * This file is part of the Antares project. */ -import { useOutletContext } from "react-router"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; -import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; -import MatrixInput from "../../../../../common/MatrixInput"; -import { Root } from "./style"; +import Matrix from "../../../../../common/Matrix"; function MiscGen() { - const { study } = useOutletContext<{ study: StudyMetadata }>(); const currentArea = useAppSelector(getCurrentAreaId); const url = `input/misc-gen/miscgen-${currentArea}`; - const colmunsNames = [ + const columns = [ "CHP", "Bio Mass", "Bio Gaz", @@ -39,14 +35,7 @@ function MiscGen() { //////////////////////////////////////////////////////////////// return ( - - - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx index 0d3b21cf17..a180b0fe05 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Form.tsx @@ -81,23 +81,16 @@ function Renewables() { config={{ defaultValues }} onSubmit={handleSubmit} enableUndoRedo + sx={{ height: "50%" }} > - + diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Matrix.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Matrix.tsx index 4de557f4e5..47ff9dcf82 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Matrix.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Matrix.tsx @@ -12,56 +12,22 @@ * This file is part of the Antares project. */ -import * as React from "react"; -import Tabs from "@mui/material/Tabs"; -import Tab from "@mui/material/Tab"; -import Box from "@mui/material/Box"; -import { useTranslation } from "react-i18next"; -import { - Cluster, - MatrixStats, - StudyMetadata, -} from "../../../../../../../common/types"; -import MatrixInput from "../../../../../../common/MatrixInput"; +import { Cluster } from "../../../../../../../common/types"; +import Matrix from "../../../../../../common/Matrix"; interface Props { - study: StudyMetadata; areaId: string; clusterId: Cluster["id"]; } -function Matrix({ study, areaId, clusterId }: Props) { - const [t] = useTranslation(); - const [value, setValue] = React.useState(0); - +function RenewablesMatrix({ areaId, clusterId }: Props) { return ( - - setValue(v)} sx={{ width: 1 }}> - - - - - - + ); } -export default Matrix; +export default RenewablesMatrix; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx index 0aae3af44d..926286613e 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx @@ -12,33 +12,26 @@ * This file is part of the Antares project. */ -import { useOutletContext } from "react-router"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; -import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; -import MatrixInput from "../../../../../common/MatrixInput"; -import { Root } from "./style"; +import Matrix from "../../../../../common/Matrix"; function Reserve() { - const { study } = useOutletContext<{ study: StudyMetadata }>(); const currentArea = useAppSelector(getCurrentAreaId); const url = `input/reserves/${currentArea}`; - const colmunsNames = [ + const columns = [ "Primary Res. (draft)", "Strategic Res. (draft)", "DSM", "Day Ahead", ]; + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + return ( - - - + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx index 2aeddedd2d..7a2a3a8284 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx @@ -12,15 +12,11 @@ * This file is part of the Antares project. */ -import { useOutletContext } from "react-router"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; -import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; -import MatrixInput from "../../../../../common/MatrixInput"; -import { Root } from "./style"; +import Matrix from "../../../../../common/Matrix"; function Solar() { - const { study } = useOutletContext<{ study: StudyMetadata }>(); const currentArea = useAppSelector(getCurrentAreaId); const url = `input/solar/series/solar_${currentArea}`; @@ -28,11 +24,7 @@ function Solar() { // JSX //////////////////////////////////////////////////////////////// - return ( - - - - ); + return ; } export default Solar; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx index daa773bf9d..22899502df 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Form.tsx @@ -95,16 +95,13 @@ function Storages() { }} onSubmit={handleSubmit} enableUndoRedo + sx={{ height: "50%" }} > { + setActiveTab(newValue); + }; + + const MATRICES: MatrixItem[] = useMemo( + () => [ + { + titleKey: "modulation", + content: { + type: "split", + matrices: [ + { + url: `input/st-storage/series/${areaId}/${storageId}/pmax_injection`, + titleKey: "injectionModulation", + }, + { + url: `input/st-storage/series/${areaId}/${storageId}/pmax_withdrawal`, + titleKey: "withdrawalModulation", + }, + ], + }, + }, + { + titleKey: "ruleCurves", + content: { + type: "split", + matrices: [ + { + url: `input/st-storage/series/${areaId}/${storageId}/lower_rule_curve`, + titleKey: "lowerRuleCurve", + }, + { + url: `input/st-storage/series/${areaId}/${storageId}/upper_rule_curve`, + titleKey: "upperRuleCurve", + }, + ], + }, + }, + { + titleKey: "inflows", + content: { + type: "single", + matrix: { + url: `input/st-storage/series/${areaId}/${storageId}/inflows`, + titleKey: "inflows", + }, + }, + }, + ], + [areaId, storageId], + ); //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// return ( - - setValue(v)}> - - - + + + {MATRICES.map(({ titleKey }) => ( + + ))} - - {R.cond([ - [ - () => value === 0, - () => ( - - } - right={ - + {MATRICES.map( + ({ titleKey, content }) => + activeTab === titleKey && ( + + {content.type === "split" ? ( + + {content.matrices.map(({ url, titleKey }) => ( + + + + ))} + + ) : ( + - } - sx={{ - mt: 1, - ".SplitLayoutView__Left": { - width: "50%", - }, - ".SplitLayoutView__Right": { - height: 1, - width: "50%", - }, - }} - /> + )} + ), - ], - [ - () => value === 1, - () => ( - - } - right={ - - } - sx={{ - mt: 1, - ".SplitLayoutView__Left": { - width: "50%", - }, - ".SplitLayoutView__Right": { - height: 1, - width: "50%", - }, - }} - /> - ), - ], - [ - R.T, - () => ( - - ), - ], - ])()} + )} ); } -export default Matrix; +export default StorageMatrices; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Matrix.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Matrix.tsx index e735f20402..8d46249fac 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Matrix.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Matrix.tsx @@ -17,13 +17,9 @@ import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; import Box from "@mui/material/Box"; import { useTranslation } from "react-i18next"; -import { - Cluster, - MatrixStats, - StudyMetadata, -} from "../../../../../../../common/types"; -import MatrixInput from "../../../../../../common/MatrixInput"; +import { Cluster, StudyMetadata } from "../../../../../../../common/types"; import { COMMON_MATRIX_COLS, TS_GEN_MATRIX_COLS } from "./utils"; +import Matrix from "../../../../../../common/Matrix"; interface Props { study: StudyMetadata; @@ -31,7 +27,7 @@ interface Props { clusterId: Cluster["id"]; } -function Matrix({ study, areaId, clusterId }: Props) { +function ThermalMatrices({ study, areaId, clusterId }: Props) { const [t] = useTranslation(); const [value, setValue] = useState("common"); const studyVersion = Number(study.version); @@ -62,6 +58,7 @@ function Matrix({ study, areaId, clusterId }: Props) { { url: `input/thermal/series/${areaId}/${clusterId}/series`, titleKey: "availability", + aggregates: "stats" as const, // avg, min, max }, { url: `input/thermal/series/${areaId}/${clusterId}/fuelCost`, @@ -111,15 +108,14 @@ function Matrix({ study, areaId, clusterId }: Props) { {filteredMatrices.map( - ({ url, titleKey, columns }) => + ({ url, titleKey, columns, aggregates }) => value === titleKey && ( - ), )} @@ -128,4 +124,4 @@ function Matrix({ study, areaId, clusterId }: Props) { ); } -export default Matrix; +export default ThermalMatrices; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx index 39339779c4..68291addcf 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx @@ -12,15 +12,11 @@ * This file is part of the Antares project. */ -import { useOutletContext } from "react-router"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; -import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; -import MatrixInput from "../../../../../common/MatrixInput"; -import { Root } from "./style"; +import Matrix from "../../../../../common/Matrix"; function Wind() { - const { study } = useOutletContext<{ study: StudyMetadata }>(); const currentArea = useAppSelector(getCurrentAreaId); const url = `input/wind/series/wind_${currentArea}`; @@ -28,11 +24,7 @@ function Wind() { // JSX //////////////////////////////////////////////////////////////// - return ( - - - - ); + return ; } export default Wind; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintFields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintFields.tsx index 49063ced45..285c3fd11e 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintFields.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/BindingConstView/ConstraintFields.tsx @@ -27,10 +27,10 @@ import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; import { useFormContextPlus } from "../../../../../../common/Form"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import Matrix from "./Matrix"; import { Box, Button } from "@mui/material"; import { Dataset } from "@mui/icons-material"; import { validateString } from "@/utils/validation/string"; +import ConstraintMatrix from "./Matrix"; interface Props { study: StudyMetadata; @@ -174,7 +174,7 @@ function Fields({ study, constraintId }: Props) { {matrixDialogOpen && ( - void; } -// TODO rename MatrixDialog or ConstraintMatrixDialog -function Matrix({ study, operator, constraintId, open, onClose }: Props) { +function ConstraintMatrix({ + study, + operator, + constraintId, + open, + onClose, +}: Props) { const { t } = useTranslation(); const dialogProps: BasicDialogProps = { open, @@ -59,63 +64,51 @@ function Matrix({ study, operator, constraintId, open, onClose }: Props) { {Number(study.version) >= 870 ? ( <> {operator === "less" && ( - )} {operator === "equal" && ( - )} {operator === "greater" && ( - )} {operator === "both" && ( - - )} ) : ( - ", "="]} - computStats={MatrixStats.NOCOL} + customColumns={["<", ">", "="]} /> )} ); } -export default Matrix; +export default ConstraintMatrix; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx index 6bae8f2f63..9721b9b82c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx @@ -22,16 +22,12 @@ import Fieldset from "../../../../../../common/Fieldset"; import { AutoSubmitHandler } from "../../../../../../common/Form/types"; import { getLinkPath, LinkFields } from "./utils"; import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; -import { - LinkElement, - MatrixStats, - StudyMetadata, -} from "../../../../../../../common/types"; +import { LinkElement, StudyMetadata } from "../../../../../../../common/types"; import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; -import MatrixInput from "../../../../../../common/MatrixInput"; import LinkMatrixView from "./LinkMatrixView"; import OutputFilters from "../../../common/OutputFilters"; import { useFormContextPlus } from "../../../../../../common/Form"; +import Matrix from "../../../../../../common/Matrix"; interface Props { link: LinkElement; @@ -251,11 +247,9 @@ function LinkForm(props: Props) { {isTabMatrix ? ( ) : ( - )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx index 7cb84b4ae6..ce0960ac7d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx @@ -12,20 +12,14 @@ * This file is part of the Antares project. */ -import * as React from "react"; -import { Divider, styled } from "@mui/material"; +import { SyntheticEvent, useMemo, useState } from "react"; import Tabs from "@mui/material/Tabs"; import Tab from "@mui/material/Tab"; import Box from "@mui/material/Box"; import { useTranslation } from "react-i18next"; -import { MatrixStats, StudyMetadata } from "../../../../../../../common/types"; -import MatrixInput from "../../../../../../common/MatrixInput"; - -export const StyledTab = styled(Tabs)({ - width: "100%", - borderBottom: 1, - borderColor: "divider", -}); +import { MatrixItem, StudyMetadata } from "../../../../../../../common/types"; +import SplitView from "../../../../../../common/SplitView"; +import Matrix from "../../../../../../common/Matrix"; interface Props { study: StudyMetadata; @@ -33,78 +27,103 @@ interface Props { area2: string; } -function LinkMatrixView(props: Props) { - const [t] = useTranslation(); - const { study, area1, area2 } = props; - const [value, setValue] = React.useState(0); - - const columnsNames = [ - `${t( - "study.modelization.links.matrix.columns.hurdleCostsDirect", - )} (${area1}->${area2})`, - `${t( - "study.modelization.links.matrix.columns.hurdleCostsIndirect", - )} (${area2}->${area1})`, - t("study.modelization.links.matrix.columns.impedances"), - t("study.modelization.links.matrix.columns.loopFlow"), - t("study.modelization.links.matrix.columns.pShiftMin"), - t("study.modelization.links.matrix.columns.pShiftMax"), - ]; +function LinkMatrixView({ area1, area2 }: Props) { + const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState("parameters"); - const handleChange = (event: React.SyntheticEvent, newValue: number) => { - setValue(newValue); + const handleTabChange = (event: SyntheticEvent, newValue: string) => { + setActiveTab(newValue); }; + + const MATRICES: MatrixItem[] = useMemo( + () => [ + { + titleKey: "parameters", + content: { + type: "single", + matrix: { + url: `input/links/${area1.toLowerCase()}/${area2.toLowerCase()}_parameters`, + titleKey: "parameters", + columnsNames: [ + `${t( + "study.modelization.links.matrix.columns.hurdleCostsDirect", + )} (${area1}->${area2})`, + `${t( + "study.modelization.links.matrix.columns.hurdleCostsIndirect", + )} (${area2}->${area1})`, + t("study.modelization.links.matrix.columns.impedances"), + t("study.modelization.links.matrix.columns.loopFlow"), + t("study.modelization.links.matrix.columns.pShiftMin"), + t("study.modelization.links.matrix.columns.pShiftMax"), + ], + }, + }, + }, + { + titleKey: "capacities", + content: { + type: "split", + matrices: [ + { + url: `input/links/${area1.toLowerCase()}/capacities/${area2.toLowerCase()}_direct`, + titleKey: "transCapaDirect", + }, + { + url: `input/links/${area1.toLowerCase()}/capacities/${area2.toLowerCase()}_indirect`, + titleKey: "transCapaIndirect", + }, + ], + }, + }, + ], + [area1, area2, t], + ); + return ( - - - - - - - {value === 0 ? ( - + + {MATRICES.map(({ titleKey }) => ( + - ) : ( - <> - ${area2})`} - url={`input/links/${area1.toLowerCase()}/capacities/${area2.toLowerCase()}_direct`} - computStats={MatrixStats.NOCOL} - /> - - ${area1})`} - url={`input/links/${area1.toLowerCase()}/capacities/${area2.toLowerCase()}_indirect`} - computStats={MatrixStats.NOCOL} - /> - + ))} + + + {MATRICES.map( + ({ titleKey, content }) => + activeTab === titleKey && ( + + {content.type === "split" ? ( + + {content.matrices.map(({ url, titleKey }) => ( + + + + ))} + + ) : ( + + )} + + ), )} diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx new file mode 100644 index 0000000000..d6295c3127 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { Box } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { DataType, Timestep } from "./utils"; +import BooleanFE from "../../../../../common/fieldEditors/BooleanFE"; +import SelectFE from "../../../../../common/fieldEditors/SelectFE"; +import NumberFE from "../../../../../common/fieldEditors/NumberFE"; +import DownloadMatrixButton from "../../../../../common/buttons/DownloadMatrixButton"; + +interface Props { + year: number; + setYear: (year: number) => void; + dataType: DataType; + setDataType: (dataType: DataType) => void; + timestep: Timestep; + setTimestep: (timestep: Timestep) => void; + maxYear: number; + studyId: string; + path: string; +} + +function ResultFilters({ + year, + setYear, + dataType, + setDataType, + timestep, + setTimestep, + maxYear, + studyId, + path, +}: Props) { + const { t } = useTranslation(); + + const filters = [ + { + label: `${t("study.results.mc")}:`, + field: ( + <> + { + setYear(event?.target.value ? -1 : 1); + }} + /> + {year > 0 && ( + { + setYear(Number(event.target.value)); + }} + /> + )} + + ), + }, + { + label: `${t("study.results.display")}:`, + field: ( + { + setDataType(event?.target.value as DataType); + }} + /> + ), + }, + { + label: `${t("study.results.temporality")}:`, + field: ( + { + setTimestep(event?.target.value as Timestep); + }} + /> + ), + }, + ]; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + {filters.map(({ label, field }) => ( + + + {label} + + {field} + + ))} + + + ); +} + +export default ResultFilters; diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index 7beaf0ec2a..b252e4196a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -22,7 +22,6 @@ import { import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useOutletContext, useParams } from "react-router"; -import axios from "axios"; import GridOffIcon from "@mui/icons-material/GridOff"; import { Area, @@ -39,9 +38,7 @@ import { } from "../../../../../../redux/selectors"; import { getStudyData } from "../../../../../../services/api/study"; import { isSearchMatching } from "../../../../../../utils/stringUtils"; -import EditableMatrix from "../../../../../common/EditableMatrix"; import PropertiesView from "../../../../../common/PropertiesView"; -import SplitLayoutView from "../../../../../common/SplitLayoutView"; import ListElement from "../../common/ListElement"; import { createPath, @@ -56,11 +53,17 @@ import UsePromiseCond, { } from "../../../../../common/utils/UsePromiseCond"; import useStudySynthesis from "../../../../../../redux/hooks/useStudySynthesis"; import ButtonBack from "../../../../../common/ButtonBack"; -import BooleanFE from "../../../../../common/fieldEditors/BooleanFE"; -import SelectFE from "../../../../../common/fieldEditors/SelectFE"; -import NumberFE from "../../../../../common/fieldEditors/NumberFE"; -import moment from "moment"; -import DownloadMatrixButton from "../../../../../common/buttons/DownloadMatrixButton.tsx"; +import MatrixGrid from "../../../../../common/Matrix/components/MatrixGrid/index.tsx"; +import { + generateCustomColumns, + generateDateTime, +} from "../../../../../common/Matrix/shared/utils.ts"; +import { Column } from "@/components/common/Matrix/shared/constants.ts"; +import SplitView from "../../../../../common/SplitView/index.tsx"; +import ResultFilters from "./ResultFilters.tsx"; +import { toError } from "../../../../../../utils/fnUtils.ts"; +import EmptyView from "../../../../../common/page/SimpleContent.tsx"; +import { getStudyMatrixIndex } from "../../../../../../services/api/matrix.ts"; function ResultDetails() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -151,6 +154,15 @@ function ResultDetails() { }, ); + const { data: dateTimeMetadata } = usePromise( + () => getStudyMatrixIndex(study.id, path), + { + deps: [study.id, path], + }, + ); + + const dateTime = dateTimeMetadata && generateDateTime(dateTimeMetadata); + const synthesisRes = usePromise( () => { if (outputId && selectedItem && isSynthesis) { @@ -164,47 +176,6 @@ function ResultDetails() { }, ); - // !NOTE: Workaround to display the date in the correct format, to be replaced by a proper solution. - const dateTimeFromIndex = useMemo(() => { - if (!matrixRes.data) { - return []; - } - - // Annual format has a static string - if (timestep === Timestep.Annual) { - return ["Annual"]; - } - - // Directly use API's week index (handles 53 weeks) as no formatting is required. - // !NOTE: Suboptimal: Assumes API consistency, lacks flexibility. - if (timestep === Timestep.Weekly) { - return matrixRes.data.index.map((weekNumber) => weekNumber.toString()); - } - - // Original date/time format mapping for moment parsing - const parseFormat = { - [Timestep.Hourly]: "MM/DD HH:mm", - [Timestep.Daily]: "MM/DD", - [Timestep.Monthly]: "MM", - }[timestep]; - - // Output formats for each timestep to match legacy UI requirements - const outputFormat = { - [Timestep.Hourly]: "DD MMM HH:mm I", - [Timestep.Daily]: "DD MMM I", - [Timestep.Monthly]: "MMM", - }[timestep]; - - const needsIndex = - timestep === Timestep.Hourly || timestep === Timestep.Daily; - - return matrixRes.data.index.map((dateTime, i) => - moment(dateTime, parseFormat).format( - outputFormat.replace("I", needsIndex ? ` - ${i + 1}` : ""), - ), - ); - }, [matrixRes.data, timestep]); - //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -223,16 +194,12 @@ function ResultDetails() { //////////////////////////////////////////////////////////////// return ( - + {/* Left */} + + navigate("..")} /> } @@ -267,188 +234,82 @@ function ResultDetails() { } onSearchFilterChange={setSearchValue} /> - } - right={ - isSynthesis ? ( - - ( - - )} - ifResolved={(matrix) => - matrix && ( - - ) - } - /> - + + {/* Right */} + + + {isSynthesis ? ( + } + ifResolved={(matrix) => + matrix && ( + + ) + } + /> ) : ( - - - {( - [ - [ - `${t("study.results.mc")}:`, - () => ( - <> - { - setYear(event?.target.value ? -1 : 1); - }} - /> - {year > 0 && ( - { - setYear(Number(event.target.value)); - }} - /> - )} - - ), - ], - [ - `${t("study.results.display")}:`, - () => ( - { - setDataType(event?.target.value as DataType); - }} - /> - ), - ], - [ - `${t("study.results.temporality")}:`, - () => ( - { - setTimestep(event?.target.value as Timestep); - }} - /> - ), - ], - ] as const - ).map(([label, Field]) => ( - - - {label} - - - - ))} - - - - ( - - )} - ifResolved={([, matrix]) => - matrix && ( - - ) + } + ifResolved={([, matrix]) => + matrix && ( + + ) + } + ifRejected={(err) => ( + ( - - {axios.isAxiosError(err) && err.response?.status === 404 ? ( - <> - - {t("study.results.noData")} - - ) : ( - t("data.error.matrix") - )} - - )} + icon={GridOffIcon} /> - - - ) - } - /> + )} + /> + )} + + ); } diff --git a/webapp/src/components/common/Matrix/components/MatrixActions/MatrixActions.test.tsx b/webapp/src/components/common/Matrix/components/MatrixActions/MatrixActions.test.tsx new file mode 100644 index 0000000000..f2077c21aa --- /dev/null +++ b/webapp/src/components/common/Matrix/components/MatrixActions/MatrixActions.test.tsx @@ -0,0 +1,180 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import MatrixActions from "."; + +vi.mock("../buttons/SplitButton", () => ({ + default: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock("../buttons/DownloadMatrixButton", () => ({ + default: ({ disabled, label }: { disabled: boolean; label?: string }) => ( + + ), +})); + +describe("MatrixActions", () => { + const defaultProps = { + onImport: vi.fn(), + onSave: vi.fn(), + studyId: "study1", + path: "/path/to/matrix", + disabled: false, + pendingUpdatesCount: 0, + isSubmitting: false, + undo: vi.fn(), + redo: vi.fn(), + canUndo: true, + canRedo: true, + }; + + type RenderOptions = Partial; + + const renderMatrixActions = (props: RenderOptions = {}) => { + return render(); + }; + + const getButton = (label: string) => { + const element = screen.getByText(label); + const button = element.closest("button"); + + if (!button) { + throw new Error(`Button with label "${label}" not found`); + } + + return button; + }; + + const getActionButton = (label: string) => { + const element = screen.getByLabelText(label); + const button = element.querySelector("button"); + + if (!button) { + throw new Error(`Action button "${label}" not found`); + } + + return button; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("rendering", () => { + test("renders all buttons and controls", () => { + renderMatrixActions(); + + expect(screen.getByLabelText("global.undo")).toBeInTheDocument(); + expect(screen.getByLabelText("global.redo")).toBeInTheDocument(); + expect(screen.getByText("(0)")).toBeInTheDocument(); + expect(screen.getByText("global.import")).toBeInTheDocument(); + expect(screen.getByText("global.export")).toBeInTheDocument(); + }); + }); + + describe("undo/redo functionality", () => { + test("manages undo button state correctly", () => { + const { rerender } = renderMatrixActions(); + expect(getActionButton("global.undo")).not.toBeDisabled(); + + rerender(); + expect(getActionButton("global.undo")).toBeDisabled(); + }); + + test("manages redo button state correctly", () => { + const { rerender } = renderMatrixActions(); + expect(getActionButton("global.redo")).not.toBeDisabled(); + + rerender(); + expect(getActionButton("global.redo")).toBeDisabled(); + }); + + test("handles undo/redo button clicks", async () => { + const user = userEvent.setup(); + renderMatrixActions(); + + await user.click(getActionButton("global.undo")); + expect(defaultProps.undo).toHaveBeenCalledTimes(1); + + await user.click(getActionButton("global.redo")); + expect(defaultProps.redo).toHaveBeenCalledTimes(1); + }); + }); + + describe("save functionality", () => { + test("manages save button state based on pending updates", () => { + const { rerender } = renderMatrixActions(); + expect(getButton("(0)")).toBeDisabled(); + + rerender(); + expect(getButton("(1)")).not.toBeDisabled(); + }); + + test("handles save button click", async () => { + const user = userEvent.setup(); + renderMatrixActions({ pendingUpdatesCount: 1 }); + + await user.click(getButton("(1)")); + expect(defaultProps.onSave).toHaveBeenCalledTimes(1); + }); + + test("disables save button during submission", () => { + renderMatrixActions({ + isSubmitting: true, + pendingUpdatesCount: 1, + }); + expect(getButton("(1)")).toBeDisabled(); + }); + }); + + describe("import/export functionality", () => { + test("handles import button click", async () => { + const user = userEvent.setup(); + renderMatrixActions(); + + await user.click(getButton("global.import")); + expect(defaultProps.onImport).toHaveBeenCalledTimes(1); + }); + + test("manages button states during submission", () => { + renderMatrixActions({ isSubmitting: true }); + + expect(getButton("global.import")).toBeDisabled(); + expect(getButton("global.export")).toBeDisabled(); + }); + + test("manages export button state based on disabled prop", () => { + const { rerender } = renderMatrixActions(); + expect(getButton("global.export")).not.toBeDisabled(); + + rerender(); + expect(getButton("global.export")).toBeDisabled(); + }); + }); +}); diff --git a/webapp/src/components/common/MatrixGrid/MatrixActions.tsx b/webapp/src/components/common/Matrix/components/MatrixActions/index.tsx similarity index 88% rename from webapp/src/components/common/MatrixGrid/MatrixActions.tsx rename to webapp/src/components/common/Matrix/components/MatrixActions/index.tsx index 46cae239cc..b5faa9b785 100644 --- a/webapp/src/components/common/MatrixGrid/MatrixActions.tsx +++ b/webapp/src/components/common/Matrix/components/MatrixActions/index.tsx @@ -13,13 +13,11 @@ */ import { Box, Divider, IconButton, Tooltip } from "@mui/material"; -import SplitButton from "../buttons/SplitButton"; -import FileDownload from "@mui/icons-material/FileDownload"; +import SplitButton from "@/components/common/buttons/SplitButton"; +import DownloadMatrixButton from "@/components/common/buttons/DownloadMatrixButton"; +import { FileDownload, Save, Undo, Redo } from "@mui/icons-material"; import { useTranslation } from "react-i18next"; import { LoadingButton } from "@mui/lab"; -import Save from "@mui/icons-material/Save"; -import { Undo, Redo } from "@mui/icons-material"; -import DownloadMatrixButton from "../buttons/DownloadMatrixButton"; interface MatrixActionsProps { onImport: VoidFunction; @@ -33,6 +31,7 @@ interface MatrixActionsProps { redo: VoidFunction; canUndo: boolean; canRedo: boolean; + isImportDisabled?: boolean; } function MatrixActions({ @@ -47,6 +46,7 @@ function MatrixActions({ redo, canUndo, canRedo, + isImportDisabled = false, }: MatrixActionsProps) { const { t } = useTranslation(); @@ -99,7 +99,7 @@ function MatrixActions({ ButtonProps={{ startIcon: , }} - disabled={isSubmitting} + disabled={isSubmitting || isImportDisabled} > {t("global.import")} diff --git a/webapp/src/components/common/Matrix/components/MatrixGrid/MatrixGrid.test.tsx b/webapp/src/components/common/Matrix/components/MatrixGrid/MatrixGrid.test.tsx new file mode 100644 index 0000000000..7f3f44aa7b --- /dev/null +++ b/webapp/src/components/common/Matrix/components/MatrixGrid/MatrixGrid.test.tsx @@ -0,0 +1,231 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Box from "@mui/material/Box"; +import MatrixGrid, { MatrixGridProps } from "."; +import SplitView from "../../../SplitView"; +import type { EnhancedGridColumn } from "../../shared/types"; +import { mockGetBoundingClientRect } from "../../../../../tests/mocks/mockGetBoundingClientRect"; +import { mockHTMLCanvasElement } from "../../../../../tests/mocks/mockHTMLCanvasElement"; +import { Column } from "../../shared/constants"; + +interface RenderMatrixOptions { + width?: string; + height?: string; + data?: MatrixGridProps["data"]; + columns?: EnhancedGridColumn[]; + rows?: number; +} + +const setupMocks = () => { + mockHTMLCanvasElement(); + mockGetBoundingClientRect(); + vi.clearAllMocks(); +}; + +const COLUMNS: EnhancedGridColumn[] = [ + { + id: "col1", + title: "Column 1", + width: 100, + type: Column.Number, + editable: true, + }, + { + id: "col2", + title: "Column 2", + width: 100, + type: Column.Number, + editable: true, + }, + { + id: "col3", + title: "Column 3", + width: 100, + type: Column.Number, + editable: true, + }, +]; + +const DATA = [ + [1, 2, 3], + [4, 5, 6], +]; + +const renderMatrixGrid = ({ + width = "450px", + height = "500px", + data = DATA, + columns = COLUMNS, + rows = 2, +}: RenderMatrixOptions = {}) => { + return render( + + + , + ); +}; + +const assertDimensions = ( + element: HTMLElement, + expectedWidth: number, + expectedHeight: number, +) => { + const rect = element.getBoundingClientRect(); + expect(rect.width).toBe(expectedWidth); + expect(rect.height).toBe(expectedHeight); +}; + +const getMatrixElement = (container: HTMLElement) => { + const matrix = container.firstChild; + + if (!(matrix instanceof HTMLElement)) { + throw new Error("Expected an HTMLElement but received a different node."); + } + + return matrix; +}; + +describe("MatrixGrid", () => { + beforeEach(setupMocks); + + describe("rendering", () => { + test("should match container dimensions", () => { + const { container } = renderMatrixGrid(); + const matrix = getMatrixElement(container); + + expect(matrix).toBeInTheDocument(); + assertDimensions(matrix, 450, 500); + }); + + test("should render with empty data", () => { + const { container } = renderMatrixGrid({ data: [], rows: 0 }); + const matrix = getMatrixElement(container); + + expect(matrix).toBeInTheDocument(); + assertDimensions(matrix, 450, 500); + }); + + test("should update dimensions when resized", () => { + const { container, rerender } = renderMatrixGrid(); + let matrix = getMatrixElement(container); + assertDimensions(matrix, 450, 500); + + rerender( + + + , + ); + + matrix = getMatrixElement(container); + assertDimensions(matrix, 300, 400); + }); + }); + + describe("portal management", () => { + const renderSplitView = () => { + return render( + + + {[0, 1].map((index) => ( + + + + ))} + + , + ); + }; + + const getPortal = () => document.getElementById("portal"); + + test("should manage portal visibility on mount", () => { + renderMatrixGrid(); + expect(getPortal()).toBeInTheDocument(); + expect(getPortal()?.style.display).toBe("none"); + }); + + test("should toggle portal visibility on mouse events", async () => { + const user = userEvent.setup(); + const { container } = renderMatrixGrid(); + const matrix = container.querySelector(".matrix-container"); + expect(matrix).toBeInTheDocument(); + + await user.hover(matrix!); + expect(getPortal()?.style.display).toBe("block"); + + await user.unhover(matrix!); + expect(getPortal()?.style.display).toBe("none"); + }); + + test("should handle portal in split view", async () => { + const user = userEvent.setup(); + renderSplitView(); + const matrices = document.querySelectorAll(".matrix-container"); + + // Test portal behavior with multiple matrices + await user.hover(matrices[0]); + expect(getPortal()?.style.display).toBe("block"); + + await user.hover(matrices[1]); + expect(getPortal()?.style.display).toBe("block"); + + await user.unhover(matrices[1]); + expect(getPortal()?.style.display).toBe("none"); + }); + + test("should maintain portal state when switching between matrices", async () => { + const user = userEvent.setup(); + renderSplitView(); + const matrices = document.querySelectorAll(".matrix-container"); + + for (const matrix of [matrices[0], matrices[1], matrices[0]]) { + await user.hover(matrix); + expect(getPortal()?.style.display).toBe("block"); + } + + await user.unhover(matrices[0]); + expect(getPortal()?.style.display).toBe("none"); + }); + + test("should handle unmounting correctly", () => { + const { unmount } = renderSplitView(); + expect(getPortal()).toBeInTheDocument(); + + unmount(); + expect(getPortal()).toBeInTheDocument(); + expect(getPortal()?.style.display).toBe("none"); + }); + }); +}); diff --git a/webapp/src/components/common/MatrixGrid/index.tsx b/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx similarity index 54% rename from webapp/src/components/common/MatrixGrid/index.tsx rename to webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx index d894b8d4ab..48cc0ba540 100644 --- a/webapp/src/components/common/MatrixGrid/index.tsx +++ b/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx @@ -18,33 +18,40 @@ import DataEditor, { EditableGridCell, EditListItem, GridCellKind, + GridColumn, GridSelection, Item, } from "@glideapps/glide-data-grid"; -import { useGridCellContent } from "./useGridCellContent"; +import { useGridCellContent } from "../../hooks/useGridCellContent"; import { useMemo, useState } from "react"; -import { EnhancedGridColumn, GridUpdate } from "./types"; -import { darkTheme, readOnlyDarkTheme } from "./utils"; -import { useColumnMapping } from "./useColumnMapping"; +import { + type EnhancedGridColumn, + type GridUpdate, + type MatrixAggregates, +} from "../../shared/types"; +import { useColumnMapping } from "../../hooks/useColumnMapping"; +import { useMatrixPortal } from "../../hooks/useMatrixPortal"; +import { darkTheme, readOnlyDarkTheme } from "./styles"; export interface MatrixGridProps { data: number[][]; rows: number; columns: EnhancedGridColumn[]; dateTime?: string[]; - aggregates?: Record; + aggregates?: Partial; rowHeaders?: string[]; width?: string; height?: string; onCellEdit?: (update: GridUpdate) => void; onMultipleCellsEdit?: (updates: GridUpdate[]) => void; - readOnly?: boolean; + isReadOnly?: boolean; + isPercentDisplayEnabled?: boolean; } function MatrixGrid({ data, rows, - columns, + columns: initialColumns, dateTime, aggregates, rowHeaders, @@ -52,8 +59,10 @@ function MatrixGrid({ height = "100%", onCellEdit, onMultipleCellsEdit, - readOnly = false, + isReadOnly, + isPercentDisplayEnabled, }: MatrixGridProps) { + const [columns, setColumns] = useState(initialColumns); const [selection, setSelection] = useState({ columns: CompactSelection.empty(), rows: CompactSelection.empty(), @@ -61,8 +70,17 @@ function MatrixGrid({ const { gridToData } = useColumnMapping(columns); + // Due to a current limitation of Glide Data Grid, only one id="portal" is active on the DOM + // This is an issue on splited matrices, the second matrix does not have an id="portal" + // Causing the overlay editor to not behave correctly on click + // This hook manage portal creation and cleanup for matrices in split views + // TODO: add a prop to detect matrices in split views and enable this conditionnaly + // !Workaround: a proper solution should be replacing this in the future + const { containerRef, handleMouseEnter, handleMouseLeave } = + useMatrixPortal(); + const theme = useMemo(() => { - if (readOnly) { + if (isReadOnly) { return { ...darkTheme, ...readOnlyDarkTheme, @@ -70,7 +88,7 @@ function MatrixGrid({ } return darkTheme; - }, [readOnly]); + }, [isReadOnly]); const getCellContent = useGridCellContent( data, @@ -79,13 +97,27 @@ function MatrixGrid({ dateTime, aggregates, rowHeaders, - readOnly, + isReadOnly, + isPercentDisplayEnabled, ); //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// + const handleColumnResize = ( + column: GridColumn, + newSize: number, + colIndex: number, + newSizeWithGrow: number, + ) => { + const newColumns = columns.map((col, index) => + index === colIndex ? { ...col, width: newSize } : col, + ); + + setColumns(newColumns); + }; + const handleCellEdited = (coordinates: Item, value: EditableGridCell) => { if (value.kind !== GridCellKind.Number) { // Invalid numeric value @@ -141,23 +173,39 @@ function MatrixGrid({ return ( <> - -
+
+ +
); } diff --git a/webapp/src/components/common/Matrix/components/MatrixGrid/styles.ts b/webapp/src/components/common/Matrix/components/MatrixGrid/styles.ts new file mode 100644 index 0000000000..ba084c94cb --- /dev/null +++ b/webapp/src/components/common/Matrix/components/MatrixGrid/styles.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { Theme } from "@glideapps/glide-data-grid"; + +export const darkTheme: Theme = { + accentColor: "rgba(255, 184, 0, 0.9)", + accentLight: "rgba(255, 184, 0, 0.2)", + accentFg: "#FFFFFF", + textDark: "#FFFFFF", + textMedium: "#C1C3D9", + textLight: "#A1A5B9", + textBubble: "#FFFFFF", + bgIconHeader: "#1E1F2E", + fgIconHeader: "#FFFFFF", + textHeader: "#FFFFFF", + textGroupHeader: "#C1C3D9", + bgCell: "#262737", // main background color + bgCellMedium: "#2E2F42", + bgHeader: "#1E1F2E", + bgHeaderHasFocus: "#2E2F42", + bgHeaderHovered: "#333447", + bgBubble: "#333447", + bgBubbleSelected: "#3C3E57", + bgSearchResult: "#6366F133", + borderColor: "rgba(255, 255, 255, 0.12)", + drilldownBorder: "rgba(255, 255, 255, 0.35)", + linkColor: "#818CF8", + headerFontStyle: "bold 11px", + baseFontStyle: "13px", + fontFamily: "Inter, sans-serif", + editorFontSize: "13px", + lineHeight: 1.5, + textHeaderSelected: "#FFFFFF", + cellHorizontalPadding: 8, + cellVerticalPadding: 5, + headerIconSize: 16, + markerFontStyle: "normal", +}; + +export const readOnlyDarkTheme: Partial = { + bgCell: "#1A1C2A", + bgCellMedium: "#22243A", + textDark: "#FAF9F6", + textMedium: "#808080", + textLight: "#606060", + accentColor: "#4A4C66", + accentLight: "rgba(74, 76, 102, 0.2)", + borderColor: "rgba(255, 255, 255, 0.08)", + drilldownBorder: "rgba(255, 255, 255, 0.2)", +}; + +export const aggregatesTheme: Partial = { + bgCell: "#3D3E5F", + bgCellMedium: "#383A5C", + textDark: "#FFFFFF", + fontFamily: "Inter, sans-serif", + baseFontStyle: "bold 13px", + editorFontSize: "13px", + headerFontStyle: "bold 11px", +}; diff --git a/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/fixtures.ts b/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/fixtures.ts new file mode 100644 index 0000000000..7573b84251 --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/fixtures.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { Column } from "../../../shared/constants"; +import { createColumn, createNumericColumn } from "./utils"; + +export const COLUMNS = { + mixed: [ + createColumn("text", Column.Text), + createColumn("date", Column.DateTime), + createNumericColumn("num1"), + createNumericColumn("num2"), + createColumn("agg", Column.Aggregate), + ], + nonData: [ + createColumn("text", Column.Text), + createColumn("date", Column.DateTime), + ], + dataOnly: [createNumericColumn("num1"), createNumericColumn("num2")], +}; diff --git a/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/useColumnMapping.test.ts b/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/useColumnMapping.test.ts new file mode 100644 index 0000000000..8d16c51b0d --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/useColumnMapping.test.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { renderHook } from "@testing-library/react"; +import { useColumnMapping } from ".."; +import { Item } from "@glideapps/glide-data-grid"; +import { createCoordinate, renderColumnMapping } from "./utils"; +import { COLUMNS } from "./fixtures"; + +describe("useColumnMapping", () => { + describe("hook initialization", () => { + test("should create mapping functions", () => { + const { result } = renderColumnMapping(COLUMNS.mixed); + + expect(result.current.gridToData).toBeInstanceOf(Function); + expect(result.current.dataToGrid).toBeInstanceOf(Function); + }); + + test("should memoize the result", () => { + const { result, rerender } = renderHook( + (props) => useColumnMapping(props.columns), + { initialProps: { columns: COLUMNS.mixed } }, + ); + + const initialResult = result.current; + rerender({ columns: COLUMNS.mixed }); + expect(result.current).toBe(initialResult); + }); + }); + + describe("gridToData mapping", () => { + describe("with mixed columns", () => { + test("should return null for non-data columns", () => { + const { result } = renderColumnMapping(COLUMNS.mixed); + + const nonDataCoordinates: Item[] = [ + createCoordinate(0, 0), // Text column + createCoordinate(1, 0), // DateTime column + createCoordinate(4, 0), // Aggregate column + ]; + + nonDataCoordinates.forEach((coord) => { + expect(result.current.gridToData(coord)).toBeNull(); + }); + }); + + test("should map data columns correctly", () => { + const { result } = renderColumnMapping(COLUMNS.mixed); + + const mappings = [ + { grid: createCoordinate(2, 0), expected: createCoordinate(0, 0) }, // First Number column + { grid: createCoordinate(3, 1), expected: createCoordinate(1, 1) }, // Second Number column + ]; + + mappings.forEach(({ grid, expected }) => { + expect(result.current.gridToData(grid)).toEqual(expected); + }); + }); + }); + + describe("with specific column configurations", () => { + test("should handle non-data columns only", () => { + const { result } = renderColumnMapping(COLUMNS.nonData); + const coord = createCoordinate(0, 0); + + expect(result.current.gridToData(coord)).toBeNull(); + expect(result.current.gridToData(createCoordinate(1, 0))).toBeNull(); + expect(result.current.dataToGrid(coord)).toEqual([undefined, 0]); + }); + + test("should handle data columns only", () => { + const { result } = renderColumnMapping(COLUMNS.dataOnly); + + const mappings = [ + { grid: createCoordinate(0, 0), data: createCoordinate(0, 0) }, + { grid: createCoordinate(1, 1), data: createCoordinate(1, 1) }, + ]; + + mappings.forEach(({ grid, data }) => { + expect(result.current.gridToData(grid)).toEqual(data); + expect(result.current.dataToGrid(data)).toEqual(grid); + }); + }); + }); + }); + + describe("dataToGrid mapping", () => { + test("should map data coordinates to grid coordinates", () => { + const { result } = renderColumnMapping(COLUMNS.mixed); + const mappings = [ + { data: createCoordinate(0, 0), expected: createCoordinate(2, 0) }, // First data column + { data: createCoordinate(1, 1), expected: createCoordinate(3, 1) }, // Second data column + ]; + + mappings.forEach(({ data, expected }) => { + expect(result.current.dataToGrid(data)).toEqual(expected); + }); + }); + }); +}); diff --git a/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/utils.ts b/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/utils.ts new file mode 100644 index 0000000000..eaf97da516 --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useColumnMapping/__tests__/utils.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { Item } from "@glideapps/glide-data-grid"; +import { renderHook } from "@testing-library/react"; +import type { ColumnType, EnhancedGridColumn } from "../../../shared/types"; +import { useColumnMapping } from ".."; +import { Column } from "../../../shared/constants"; + +export const createCoordinate = (col: number, row: number): Item => + [col, row] as Item; + +export const createColumn = ( + id: string, + type: ColumnType, + editable = false, +): EnhancedGridColumn => ({ + id, + title: id.charAt(0).toUpperCase() + id.slice(1), + type, + width: 100, + editable, +}); + +export const createNumericColumn = (id: string) => + createColumn(id, Column.Number, true); + +export const renderColumnMapping = (columns: EnhancedGridColumn[]) => + renderHook(() => useColumnMapping(columns)); diff --git a/webapp/src/components/common/MatrixGrid/useColumnMapping.ts b/webapp/src/components/common/Matrix/hooks/useColumnMapping/index.ts similarity index 94% rename from webapp/src/components/common/MatrixGrid/useColumnMapping.ts rename to webapp/src/components/common/Matrix/hooks/useColumnMapping/index.ts index 522f93d025..b9ceb95ebf 100644 --- a/webapp/src/components/common/MatrixGrid/useColumnMapping.ts +++ b/webapp/src/components/common/Matrix/hooks/useColumnMapping/index.ts @@ -14,7 +14,8 @@ import { useMemo } from "react"; import { Item } from "@glideapps/glide-data-grid"; -import { EnhancedGridColumn, ColumnTypes } from "./types"; +import { EnhancedGridColumn } from "../../shared/types"; +import { Column } from "../../shared/constants"; /** * A custom hook that provides coordinate mapping functions for a grid with mixed column types. @@ -48,7 +49,7 @@ import { EnhancedGridColumn, ColumnTypes } from "./types"; export function useColumnMapping(columns: EnhancedGridColumn[]) { return useMemo(() => { const dataColumnIndices = columns.reduce((acc, col, index) => { - if (col.type === ColumnTypes.Number) { + if (col.type === Column.Number) { acc.push(index); } return acc; diff --git a/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/assertions.ts b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/assertions.ts new file mode 100644 index 0000000000..4311c9085d --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/assertions.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { GridCell } from "@glideapps/glide-data-grid"; + +export const assertNumberCell = ( + cell: GridCell, + expectedValue: number | undefined, + message: string, +) => { + if (cell.kind === "number" && "data" in cell) { + expect(cell.data).toBe(expectedValue); + } else { + throw new Error(message); + } +}; + +export const assertTextCell = ( + cell: GridCell, + expectedValue: string, + message: string, +) => { + if (cell.kind === "text" && "displayData" in cell) { + expect(cell.displayData).toBe(expectedValue); + } else { + throw new Error(message); + } +}; + +export const assertDateCell = ( + cell: GridCell, + expectedValue: string, + message: string, +) => { + if (cell.kind === "text" && "displayData" in cell) { + expect(cell.displayData).toBe(expectedValue); + } else { + throw new Error(message); + } +}; diff --git a/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/fixtures.ts b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/fixtures.ts new file mode 100644 index 0000000000..c13aa938c5 --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/fixtures.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import type { ColumnType, EnhancedGridColumn } from "../../../shared/types"; +import { Column } from "../../../shared/constants"; +import type { TestCase } from "../types"; + +export const createColumn = ( + id: string, + type: ColumnType, + editable = false, +): EnhancedGridColumn => ({ + id, + title: id.charAt(0).toUpperCase() + id.slice(1), + type, + width: type === Column.DateTime ? 150 : 50, + editable, +}); + +export const AGGREGATE_DATA = { + columns: [createColumn("total", Column.Aggregate)], + data: [ + [10, 20, 30], + [15, 25, 35], + [5, 15, 25], + ], + aggregates: { + min: [5, 15, 25], + max: [15, 25, 35], + avg: [10, 20, 30], + total: [30, 60, 90], + }, +}; + +export const MIXED_DATA = { + columns: [ + createColumn("rowHeader", Column.Text), + createColumn("date", Column.DateTime), + createColumn("data1", Column.Number, true), + createColumn("data2", Column.Number, true), + createColumn("total", Column.Aggregate), + ], + data: [ + [100, 200], + [150, 250], + ], + dateTime: ["2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"], + rowHeaders: ["Row 1", "Row 2"], + aggregates: { + min: [100, 200], + max: [150, 250], + avg: [125, 225], + total: [250, 450], + }, +}; + +export const EDGE_CASES = { + emptyData: { + columns: [createColumn("data1", Column.Number, true)], + data: [], + }, + singleCell: { + columns: [createColumn("data1", Column.Number, true)], + data: [[100]], + }, +}; + +export const FORMAT_TEST_CASES: TestCase[] = [ + { + desc: "formats regular numbers", + value: 1234567.89, + expected: "1 234 567.89", + }, + { + desc: "handles very large numbers", + value: 1e20, + expected: "100 000 000 000 000 000 000", + }, + { + desc: "handles very small numbers", + value: 0.00001, + expected: "0.00001", + }, + { + desc: "handles negative numbers", + value: -1234567.89, + expected: "-1 234 567.89", + }, + { + desc: "handles zero", + value: 0, + expected: "0", + }, +]; diff --git a/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/useGridCellContent.test.ts b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/useGridCellContent.test.ts new file mode 100644 index 0000000000..e2103c53e7 --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/useGridCellContent.test.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { Column } from "../../../shared/constants"; +import { + createColumn, + AGGREGATE_DATA, + MIXED_DATA, + FORMAT_TEST_CASES, +} from "./fixtures"; +import { createCoordinate, renderGridCellContent } from "./utils"; +import { assertNumberCell, assertTextCell } from "./assertions"; + +describe("useGridCellContent", () => { + describe("Aggregate columns", () => { + test.each([ + [0, 30], + [1, 60], + [2, 90], + ])("returns correct aggregate for row %i", (row, expected) => { + const getCellContent = renderGridCellContent(AGGREGATE_DATA); + const cell = getCellContent(createCoordinate(0, row)); + assertNumberCell( + cell, + expected, + `Expected aggregate value ${expected} at row ${row}`, + ); + }); + }); + + describe("Mixed column types", () => { + test("handles all column types correctly", () => { + const getCellContent = renderGridCellContent(MIXED_DATA); + + assertTextCell( + getCellContent(createCoordinate(0, 0)), + "Row 1", + "Expected row header text", + ); + + assertNumberCell( + getCellContent(createCoordinate(2, 0)), + 100, + "Expected first data column value", + ); + + assertNumberCell( + getCellContent(createCoordinate(3, 0)), + 200, + "Expected second data column value", + ); + + assertNumberCell( + getCellContent(createCoordinate(4, 0)), + 250, + "Expected aggregate value", + ); + }); + }); + + describe("Edge cases", () => { + test("handles empty data array", () => { + const options = { + columns: [createColumn("data1", Column.Number, true)], + data: [], + }; + + const getCellContent = renderGridCellContent(options); + const cell = getCellContent(createCoordinate(0, 0)); + assertNumberCell(cell, undefined, "Expected undefined for empty data"); + }); + + test("handles out of bounds access", () => { + const options = { + columns: [createColumn("data1", Column.Number, true)], + data: [[100]], + }; + + const getCellContent = renderGridCellContent(options); + + // Column out of bounds + const colCell = getCellContent(createCoordinate(1, 0)); + assertTextCell(colCell, "N/A", "Expected N/A for column out of bounds"); + + // Row out of bounds + const rowCell = getCellContent(createCoordinate(0, 1)); + assertNumberCell( + rowCell, + undefined, + "Expected undefined for row out of bounds", + ); + }); + }); + + describe("Number formatting", () => { + test.each(FORMAT_TEST_CASES)("$desc", ({ value, expected }) => { + const options = { + columns: [createColumn("number", Column.Number, true)], + data: [[value]], + }; + + const getCellContent = renderGridCellContent(options); + const cell = getCellContent(createCoordinate(0, 0)); + + if (cell.kind === "number" && "data" in cell) { + expect(cell.data).toBe(value); + expect(cell.displayData).toBe(expected); + } else { + throw new Error(`Expected formatted number cell for value ${value}`); + } + }); + }); +}); diff --git a/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/utils.ts b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/utils.ts new file mode 100644 index 0000000000..a75d80f3ea --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useGridCellContent/__tests__/utils.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { renderHook } from "@testing-library/react"; +import { type Item } from "@glideapps/glide-data-grid"; +import { useColumnMapping } from "../../useColumnMapping"; +import { useGridCellContent } from ".."; +import type { RenderOptions } from "../types"; + +export const createCoordinate = (col: number, row: number): Item => + [col, row] as Item; + +export const renderGridCellContent = ({ + data, + columns, + dateTime, + aggregates, + rowHeaders, +}: RenderOptions) => { + const { result: mappingResult } = renderHook(() => useColumnMapping(columns)); + + const { gridToData } = mappingResult.current; + + const { result } = renderHook(() => + useGridCellContent( + data, + columns, + gridToData, + dateTime, + aggregates, + rowHeaders, + ), + ); + + return result.current; +}; diff --git a/webapp/src/components/common/MatrixGrid/useGridCellContent.ts b/webapp/src/components/common/Matrix/hooks/useGridCellContent/index.ts similarity index 73% rename from webapp/src/components/common/MatrixGrid/useGridCellContent.ts rename to webapp/src/components/common/Matrix/hooks/useGridCellContent/index.ts index ca610950a8..0e20b0b2b9 100644 --- a/webapp/src/components/common/MatrixGrid/useGridCellContent.ts +++ b/webapp/src/components/common/Matrix/hooks/useGridCellContent/index.ts @@ -14,25 +14,21 @@ import { useCallback, useMemo } from "react"; import { GridCell, GridCellKind, Item } from "@glideapps/glide-data-grid"; -import { type EnhancedGridColumn, type ColumnType, ColumnTypes } from "./types"; -import { formatDateTime } from "./utils"; - -type CellContentGenerator = ( - row: number, - col: number, - column: EnhancedGridColumn, - data: number[][], - dateTime?: string[], - aggregates?: Record, - rowHeaders?: string[], -) => GridCell; +import { + type EnhancedGridColumn, + type ColumnType, + MatrixAggregates, +} from "../../shared/types"; +import { formatGridNumber } from "../../shared/utils"; +import { Column } from "../../shared/constants"; +import { type CellContentGenerator } from "./types"; /** * Map of cell content generators for each column type. * Each generator function creates the appropriate GridCell based on the column type and data. */ const cellContentGenerators: Record = { - [ColumnTypes.Text]: ( + [Column.Text]: ( row, col, column, @@ -47,33 +43,37 @@ const cellContentGenerators: Record = { readonly: !column.editable, allowOverlay: false, }), - [ColumnTypes.DateTime]: (row, col, column, data, dateTime) => ({ + [Column.DateTime]: (row, col, column, data, dateTime) => ({ kind: GridCellKind.Text, data: "", // Date/time columns are not editable - displayData: formatDateTime(dateTime?.[row] ?? ""), + displayData: dateTime?.[row] ?? "", readonly: !column.editable, allowOverlay: false, }), - [ColumnTypes.Number]: (row, col, column, data) => { + [Column.Number]: (row, col, column, data) => { const value = data?.[row]?.[col]; return { kind: GridCellKind.Number, data: value, - displayData: value?.toString(), + displayData: formatGridNumber({ value, maxDecimals: 6 }), readonly: !column.editable, allowOverlay: true, + decimalSeparator: ".", + thousandSeparator: " ", }; }, - [ColumnTypes.Aggregate]: (row, col, column, data, dateTime, aggregates) => { - const value = aggregates?.[column.id]?.[row]; + [Column.Aggregate]: (row, col, column, data, dateTime, aggregates) => { + const value = aggregates?.[column.id as keyof MatrixAggregates]?.[row]; return { kind: GridCellKind.Number, data: value, - displayData: value?.toString() ?? "", + displayData: formatGridNumber({ value, maxDecimals: 3 }), readonly: !column.editable, allowOverlay: false, + decimalSeparator: ".", + thousandSeparator: " ", }; }, }; @@ -100,7 +100,8 @@ const cellContentGenerators: Record = { * @param dateTime - Optional array of date-time strings for date columns. * @param aggregates - Optional object mapping column IDs to arrays of aggregated values. * @param rowHeaders - Optional array of row header labels. - * @param readOnly - Whether the grid is read-only (default is false). + * @param isReadOnly - Whether the grid is read-only (default is false). + * @param isPercentDisplayEnabled - Whether to display number values as percentages (default is false). * @returns A function that accepts a grid item and returns the configured grid cell content. */ export function useGridCellContent( @@ -108,9 +109,10 @@ export function useGridCellContent( columns: EnhancedGridColumn[], gridToData: (cell: Item) => Item | null, dateTime?: string[], - aggregates?: Record, + aggregates?: Partial, rowHeaders?: string[], - readOnly = false, + isReadOnly = false, + isPercentDisplayEnabled = false, ): (cell: Item) => GridCell { const columnMap = useMemo(() => { return new Map(columns.map((column, index) => [index, column])); @@ -149,7 +151,7 @@ export function useGridCellContent( // accounting for any non-data columns in the grid let adjustedCol = col; - if (column.type === ColumnTypes.Number && gridToData) { + if (column.type === Column.Number && gridToData) { // Map grid cell to data array index const dataCell = gridToData(cell); @@ -168,8 +170,18 @@ export function useGridCellContent( rowHeaders, ); + // Display number values as percentages if enabled + if (isPercentDisplayEnabled && gridCell.kind === GridCellKind.Number) { + return { + ...gridCell, + displayData: `${gridCell.data}%`, + // If ReadOnly is enabled, we don't want to allow overlay + allowOverlay: !isReadOnly, + }; + } + // Prevent updates for read-only grids - if (readOnly) { + if (isReadOnly) { return { ...gridCell, allowOverlay: false, @@ -178,7 +190,16 @@ export function useGridCellContent( return gridCell; }, - [columnMap, gridToData, data, dateTime, aggregates, rowHeaders, readOnly], + [ + columnMap, + gridToData, + data, + dateTime, + aggregates, + rowHeaders, + isReadOnly, + isPercentDisplayEnabled, + ], ); return getCellContent; diff --git a/webapp/src/components/common/Matrix/hooks/useGridCellContent/types.ts b/webapp/src/components/common/Matrix/hooks/useGridCellContent/types.ts new file mode 100644 index 0000000000..9858e11beb --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useGridCellContent/types.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { GridCell, Item } from "@glideapps/glide-data-grid"; +import type { EnhancedGridColumn, MatrixAggregates } from "../../shared/types"; + +export type GridToDataFunction = (cell: Item) => Item | null; + +export interface UseGridCellContentOptions { + data: number[][]; + columns: EnhancedGridColumn[]; + gridToData: GridToDataFunction; + dateTime?: string[]; + aggregates?: MatrixAggregates; + rowHeaders?: string[]; +} + +export type CellContentGenerator = ( + row: number, + col: number, + column: EnhancedGridColumn, + data: number[][], + dateTime?: string[], + aggregates?: Partial, + rowHeaders?: string[], +) => GridCell; + +export interface RenderOptions { + data: number[][]; + columns: EnhancedGridColumn[]; + dateTime?: string[]; + aggregates?: MatrixAggregates; + rowHeaders?: string[]; +} + +export interface TestCase { + desc: string; + value: number; + expected: string; +} diff --git a/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts new file mode 100644 index 0000000000..b781a9400c --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts @@ -0,0 +1,323 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AxiosError } from "axios"; +import { enqueueSnackbar } from "notistack"; +import { t } from "i18next"; +import { MatrixIndex } from "../../../../../common/types"; +import useEnqueueErrorSnackbar from "../../../../../hooks/useEnqueueErrorSnackbar"; +import { + getStudyMatrixIndex, + updateMatrix, +} from "../../../../../services/api/matrix"; +import { getStudyData } from "../../../../../services/api/study"; +import { + EnhancedGridColumn, + MatrixDataDTO, + GridUpdate, + MatrixUpdateDTO, + MatrixAggregates, + AggregateConfig, +} from "../../shared/types"; +import { + calculateMatrixAggregates, + generateDataColumns, + generateDateTime, + getAggregateTypes, +} from "../../shared/utils"; +import useUndo from "use-undo"; +import { GridCellKind } from "@glideapps/glide-data-grid"; +import { importFile } from "../../../../../services/api/studies/raw"; +import { fetchMatrixFn } from "../../../../App/Singlestudy/explore/Modelization/Areas/Hydro/utils"; +import usePrompt from "../../../../../hooks/usePrompt"; +import { Aggregate, Column, Operation } from "../../shared/constants"; +import { aggregatesTheme } from "../../components/MatrixGrid/styles"; + +interface DataState { + data: MatrixDataDTO["data"]; + aggregates: Partial; + pendingUpdates: MatrixUpdateDTO[]; + updateCount: number; +} + +export function useMatrix( + studyId: string, + url: string, + enableDateTimeColumn: boolean, + enableTimeSeriesColumns: boolean, + enableRowHeaders?: boolean, + aggregatesConfig?: AggregateConfig, + customColumns?: string[] | readonly string[], + colWidth?: number, + fetchMatrixData?: fetchMatrixFn, +) { + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const [columnCount, setColumnCount] = useState(0); + const [index, setIndex] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(undefined); + const [{ present: currentState }, { set: setState, undo, redo, canRedo }] = + useUndo({ + data: [], + aggregates: { min: [], max: [], avg: [], total: [] }, + pendingUpdates: [], + updateCount: 0, + }); + + // Determine the aggregate types to display in the matrix + const aggregateTypes = useMemo( + () => getAggregateTypes(aggregatesConfig || []), + [aggregatesConfig], + ); + + // Display warning prompts to prevent unintended navigation + // 1. When the matrix is currently being submitted + usePrompt(t("form.submit.inProgress"), isSubmitting); + // 2. When there are unsaved changes in the matrix + usePrompt(t("form.changeNotSaved"), currentState.pendingUpdates.length > 0); + + const fetchMatrix = async (loadingState = true) => { + // !NOTE This is a temporary solution to ensure the matrix is up to date + // TODO: Remove this once the matrix API is updated to return the correct data + if (loadingState) { + setIsLoading(true); + } + + try { + const [matrix, index] = await Promise.all([ + fetchMatrixData + ? // If a custom fetch function is provided, use it + fetchMatrixData(studyId) + : getStudyData(studyId, url, 1), + getStudyMatrixIndex(studyId, url), + ]); + + setState({ + data: matrix.data, + aggregates: calculateMatrixAggregates(matrix.data, aggregateTypes), + pendingUpdates: [], + updateCount: 0, + }); + setColumnCount(matrix.columns.length); + setIndex(index); + setIsLoading(false); + + return { + matrix, + index, + }; + } catch (error) { + setError(new Error(t("data.error.matrix"))); + enqueueErrorSnackbar(t("data.error.matrix"), error as AxiosError); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchMatrix(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [studyId, url, aggregateTypes, fetchMatrixData]); + + const dateTime = useMemo(() => { + return index ? generateDateTime(index) : []; + }, [index]); + + const columns = useMemo(() => { + if (!currentState.data) { + return []; + } + + const baseColumns: EnhancedGridColumn[] = []; + + if (enableDateTimeColumn) { + baseColumns.push({ + id: "date", + title: "Date", + type: Column.DateTime, + editable: false, + themeOverride: { bgCell: "#2D2E40" }, + }); + } + + if (enableRowHeaders) { + baseColumns.unshift({ + id: "rowHeaders", + title: "", + type: Column.Text, + editable: false, + }); + } + + const dataColumns = generateDataColumns( + enableTimeSeriesColumns, + columnCount, + customColumns, + colWidth, + ); + + const aggregatesColumns: EnhancedGridColumn[] = aggregateTypes.map( + (aggregateType) => ({ + id: aggregateType, + title: aggregateType.charAt(0).toUpperCase() + aggregateType.slice(1), // Capitalize first letter + type: Column.Aggregate, + editable: false, + themeOverride: + aggregateType === Aggregate.Avg + ? aggregatesTheme + : { ...aggregatesTheme, bgCell: "#464770" }, + }), + ); + + return [...baseColumns, ...dataColumns, ...aggregatesColumns]; + }, [ + currentState.data, + enableDateTimeColumn, + enableRowHeaders, + enableTimeSeriesColumns, + columnCount, + customColumns, + colWidth, + aggregateTypes, + ]); + + // Apply updates to the matrix data and store them in the pending updates list + const applyUpdates = useCallback( + (updates: GridUpdate[]) => { + const updatedData = currentState.data.map((col) => [...col]); + + const newUpdates: MatrixUpdateDTO[] = updates + .map(({ coordinates: [row, col], value }) => { + if (value.kind === GridCellKind.Number && value.data !== undefined) { + updatedData[col][row] = value.data; + + return { + coordinates: [[col, row]], + operation: { + operation: Operation.Eq, + value: value.data, + }, + }; + } + + return null; + }) + .filter( + (update): update is NonNullable => update !== null, + ); + + // Recalculate aggregates with the updated data + const newAggregates = calculateMatrixAggregates( + updatedData, + aggregateTypes, + ); + + setState({ + data: updatedData, + aggregates: newAggregates, + pendingUpdates: [...currentState.pendingUpdates, ...newUpdates], + updateCount: currentState.updateCount + 1 || 1, + }); + }, + [ + currentState.data, + currentState.pendingUpdates, + currentState.updateCount, + aggregateTypes, + setState, + ], + ); + + const handleCellEdit = function (update: GridUpdate) { + applyUpdates([update]); + }; + + const handleMultipleCellsEdit = function (updates: GridUpdate[]) { + applyUpdates(updates); + }; + + const handleImport = async (file: File) => { + try { + await importFile({ file, studyId, path: url }); + await fetchMatrix(); + } catch (e) { + enqueueErrorSnackbar(t("matrix.error.import"), e as Error); + } + }; + + const handleSaveUpdates = async () => { + if (!currentState.pendingUpdates.length) { + return; + } + + setIsSubmitting(true); + + try { + await updateMatrix(studyId, url, currentState.pendingUpdates); + + setState({ + ...currentState, + pendingUpdates: [], + updateCount: 0, + }); + + enqueueSnackbar(t("matrix.success.matrixUpdate"), { + variant: "success", + }); + + // !NOTE This is a temporary solution to ensure the matrix is up to date + // TODO: Remove this once the matrix API is updated to return the correct data + await fetchMatrix(false); + } catch (error) { + setError(new Error(t("matrix.error.matrixUpdate"))); + enqueueErrorSnackbar(t("matrix.error.matrixUpdate"), error as AxiosError); + } finally { + setIsSubmitting(false); + } + }; + + const handleUndo = useCallback(() => { + undo(); + }, [undo]); + + const handleRedo = useCallback(() => { + redo(); + }, [redo]); + + const canUndoChanges = useMemo( + () => currentState.pendingUpdates.length > 0, + [currentState.pendingUpdates], + ); + + return { + data: currentState.data, + aggregates: currentState.aggregates, + error, + isLoading, + isSubmitting, + columns, + dateTime, + handleCellEdit, + handleMultipleCellsEdit, + handleImport, + handleSaveUpdates, + pendingUpdatesCount: currentState.updateCount, + undo: handleUndo, + redo: handleRedo, + canUndo: canUndoChanges, + canRedo, + }; +} diff --git a/webapp/src/components/common/Matrix/hooks/useMatrix/useMatrix.test.tsx b/webapp/src/components/common/Matrix/hooks/useMatrix/useMatrix.test.tsx new file mode 100644 index 0000000000..7dabcb97a5 --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useMatrix/useMatrix.test.tsx @@ -0,0 +1,239 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useMatrix } from "../useMatrix"; +import * as apiMatrix from "@/services/api/matrix"; +import * as apiStudy from "@/services/api/study"; +import * as rawStudy from "@/services/api/studies/raw"; +import { + MatrixEditDTO, + MatrixIndex, + Operator, + StudyOutputDownloadLevelDTO, +} from "@/common/types"; +import { GridUpdate, MatrixDataDTO } from "../../shared/types"; +import { GridCellKind } from "@glideapps/glide-data-grid"; + +vi.mock("@/services/api/matrix"); +vi.mock("@/services/api/study"); +vi.mock("@/services/api/studies/raw"); +vi.mock("@/hooks/usePrompt"); + +// TODO: refactor fixtures, utils functions, and types in dedicated files +const DATA = { + studyId: "study123", + url: "https://studies/study123/matrix", + matrixData: { + data: [ + [1, 2], + [3, 4], + ], + columns: [0, 1], + index: [0, 1], + } as MatrixDataDTO, + matrixIndex: { + start_date: "2023-01-01", + steps: 2, + first_week_size: 7, + level: StudyOutputDownloadLevelDTO.DAILY, + } as MatrixIndex, +}; + +const createGridUpdate = ( + row: number, + col: number, + value: number, +): GridUpdate => ({ + coordinates: [row, col], + value: { + kind: GridCellKind.Number, + data: value, + displayData: value.toString(), + allowOverlay: true, + }, +}); + +interface SetupOptions { + mockData?: MatrixDataDTO; + mockIndex?: MatrixIndex; +} + +const setupHook = async ({ + mockData = DATA.matrixData, + mockIndex = DATA.matrixIndex, +}: SetupOptions = {}) => { + vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockData); + vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockIndex); + + const hook = renderHook(() => + useMatrix(DATA.studyId, DATA.url, true, true, true), + ); + + await waitFor(() => { + expect(hook.result.current.isLoading).toBe(false); + }); + + return hook; +}; + +const performEdit = async ( + hook: Awaited>, + updates: GridUpdate | GridUpdate[], +) => { + act(() => { + if (Array.isArray(updates)) { + hook.result.current.handleMultipleCellsEdit(updates); + } else { + hook.result.current.handleCellEdit(updates); + } + }); +}; + +describe("useMatrix", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Initialization", () => { + test("should fetch and initialize matrix data", async () => { + const hook = await setupHook(); + + expect(hook.result.current.data).toEqual(DATA.matrixData.data); + expect(hook.result.current.columns.length).toBeGreaterThan(0); + expect(hook.result.current.dateTime.length).toBeGreaterThan(0); + expect(hook.result.current.isLoading).toBe(false); + }); + }); + + describe("Edit operations", () => { + test("should handle single cell edit", async () => { + const hook = await setupHook(); + const update = createGridUpdate(0, 1, 5); + + await performEdit(hook, update); + + expect(hook.result.current.data[1][0]).toBe(5); + expect(hook.result.current.pendingUpdatesCount).toBe(1); + }); + + test("should handle multiple cell edits", async () => { + const hook = await setupHook(); + const updates = [createGridUpdate(0, 1, 5), createGridUpdate(1, 0, 6)]; + + await performEdit(hook, updates); + + expect(hook.result.current.data[1][0]).toBe(5); + expect(hook.result.current.data[0][1]).toBe(6); + expect(hook.result.current.pendingUpdatesCount).toBe(1); + }); + + test("should save updates correctly", async () => { + vi.mocked(apiMatrix.updateMatrix).mockResolvedValue(undefined); + const hook = await setupHook(); + + await performEdit(hook, createGridUpdate(0, 1, 5)); + + await act(async () => { + await hook.result.current.handleSaveUpdates(); + }); + + const expectedEdit: MatrixEditDTO = { + coordinates: [[1, 0]], + operation: { operation: Operator.EQ, value: 5 }, + }; + + expect(apiMatrix.updateMatrix).toHaveBeenCalledWith( + DATA.studyId, + DATA.url, + [expectedEdit], + ); + expect(hook.result.current.pendingUpdatesCount).toBe(0); + }); + }); + + describe("File operations", () => { + test("should handle file import", async () => { + const mockFile = new File([""], "test.csv", { type: "text/csv" }); + vi.mocked(rawStudy.importFile).mockResolvedValue(); + + const hook = await setupHook(); + + await act(async () => { + await hook.result.current.handleImport(mockFile); + }); + + expect(rawStudy.importFile).toHaveBeenCalledWith({ + file: mockFile, + studyId: DATA.studyId, + path: DATA.url, + }); + }); + }); + + describe("Undo/Redo functionality", () => { + test("should initialize with correct undo/redo states", async () => { + const hook = await setupHook(); + + expect(hook.result.current.canUndo).toBe(false); + expect(hook.result.current.canRedo).toBe(false); + }); + + test("should update states after edit operations", async () => { + const hook = await setupHook(); + const update = createGridUpdate(0, 1, 5); + + // Initial edit + await performEdit(hook, update); + expect(hook.result.current.canUndo).toBe(true); + expect(hook.result.current.canRedo).toBe(false); + + // Undo + act(() => hook.result.current.undo()); + expect(hook.result.current.canUndo).toBe(false); + expect(hook.result.current.canRedo).toBe(true); + + // Redo + act(() => hook.result.current.redo()); + expect(hook.result.current.canUndo).toBe(true); + expect(hook.result.current.canRedo).toBe(false); + }); + + test("should clear redo history after new edit", async () => { + const hook = await setupHook(); + + // Create edit history + await performEdit(hook, createGridUpdate(0, 1, 5)); + act(() => hook.result.current.undo()); + expect(hook.result.current.canRedo).toBe(true); + + // New edit should clear redo history + await performEdit(hook, createGridUpdate(1, 0, 6)); + expect(hook.result.current.canUndo).toBe(true); + expect(hook.result.current.canRedo).toBe(false); + }); + + test("should restore initial state after full undo", async () => { + const hook = await setupHook(); + const initialData = [...DATA.matrixData.data]; + + await performEdit(hook, createGridUpdate(0, 1, 5)); + act(() => hook.result.current.undo()); + + expect(hook.result.current.data).toEqual(initialData); + expect(hook.result.current.canUndo).toBe(false); + expect(hook.result.current.canRedo).toBe(true); + }); + }); +}); diff --git a/webapp/src/components/common/Matrix/hooks/useMatrixPortal/index.ts b/webapp/src/components/common/Matrix/hooks/useMatrixPortal/index.ts new file mode 100644 index 0000000000..c6174d39d2 --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useMatrixPortal/index.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { useEffect, useRef, useState } from "react"; + +/** + * Custom hook to manage portal creation and cleanup for matrices in split views. + * This hook is specifically designed to work around a limitation where multiple + * Glide Data Grid instances compete for a single portal with id="portal". + * + * @returns Hook handlers and ref to be applied to the matrix container + * + * @example + * ```tsx + * function MatrixGrid() { + * const { containerRef, handleMouseEnter, handleMouseLeave } = useMatrixPortal(); + * + * return ( + *
+ * + *
+ * ); + * } + * ``` + */ +export function useMatrixPortal() { + const [isActive, setIsActive] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + let portal = document.getElementById("portal"); + + if (!portal) { + portal = document.createElement("div"); + portal.id = "portal"; + portal.style.position = "fixed"; + portal.style.left = "0"; + portal.style.top = "0"; + portal.style.zIndex = "9999"; + portal.style.display = "none"; + document.body.appendChild(portal); + } + + // Update visibility based on active state + if (containerRef.current && isActive) { + portal.style.display = "block"; + } else { + portal.style.display = "none"; + } + }, [isActive]); + + const handleMouseEnter = () => { + setIsActive(true); + }; + + const handleMouseLeave = () => { + setIsActive(false); + }; + + return { + containerRef, + handleMouseEnter, + handleMouseLeave, + }; +} diff --git a/webapp/src/components/common/Matrix/hooks/useMatrixPortal/useMatrixPortal.test.tsx b/webapp/src/components/common/Matrix/hooks/useMatrixPortal/useMatrixPortal.test.tsx new file mode 100644 index 0000000000..8b3bdfda32 --- /dev/null +++ b/webapp/src/components/common/Matrix/hooks/useMatrixPortal/useMatrixPortal.test.tsx @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMatrixPortal } from "../useMatrixPortal"; + +function NonRefComponent() { + return
Test Container
; +} + +function TestComponent() { + const { containerRef, handleMouseEnter, handleMouseLeave } = + useMatrixPortal(); + return ( +
+ Test Container +
+ ); +} + +describe("useMatrixPortal", () => { + beforeEach(() => { + cleanup(); + const existingPortal = document.getElementById("portal"); + if (existingPortal) { + existingPortal.remove(); + } + }); + + test("should create hidden portal initially", () => { + render(); + const portal = document.getElementById("portal"); + expect(portal).toBeInTheDocument(); + expect(portal?.style.display).toBe("none"); + }); + + test("should show portal with correct styles when mouse enters", async () => { + const user = userEvent.setup(); + render(); + + const container = screen.getByTestId("ref-container"); + await user.hover(container); + + const portal = document.getElementById("portal"); + expect(portal).toBeInTheDocument(); + expect(portal?.style.display).toBe("block"); + expect(portal?.style.position).toBe("fixed"); + expect(portal?.style.left).toBe("0px"); + expect(portal?.style.top).toBe("0px"); + expect(portal?.style.zIndex).toBe("9999"); + }); + + test("should hide portal when mouse leaves", async () => { + const user = userEvent.setup(); + render(); + + const container = screen.getByTestId("ref-container"); + + // Show portal + await user.hover(container); + expect(document.getElementById("portal")?.style.display).toBe("block"); + + // Hide portal + await user.unhover(container); + expect(document.getElementById("portal")?.style.display).toBe("none"); + }); + + test("should keep portal hidden if containerRef is null", async () => { + const user = userEvent.setup(); + + // First render test component to create portal + render(); + const portal = document.getElementById("portal"); + expect(portal?.style.display).toBe("none"); + + cleanup(); // Clean up the test component + + // Then render component without ref + render(); + const container = screen.getByTestId("non-ref-container"); + await user.hover(container); + + // Portal should stay hidden + expect(portal?.style.display).toBe("none"); + }); + + test("should handle multiple mouse enter/leave cycles", async () => { + const user = userEvent.setup(); + render(); + + const container = screen.getByTestId("ref-container"); + const portal = document.getElementById("portal"); + + // First cycle + await user.hover(container); + expect(portal?.style.display).toBe("block"); + + await user.unhover(container); + expect(portal?.style.display).toBe("none"); + + // Second cycle + await user.hover(container); + expect(portal?.style.display).toBe("block"); + + await user.unhover(container); + expect(portal?.style.display).toBe("none"); + }); + + test("should handle rapid mouse events", async () => { + const user = userEvent.setup(); + render(); + + const container = screen.getByTestId("ref-container"); + const portal = document.getElementById("portal"); + + // Rapid sequence + await user.hover(container); + await user.unhover(container); + await user.hover(container); + + expect(portal?.style.display).toBe("block"); + }); + + test("should maintain portal existence across multiple component instances", () => { + // Render first instance + const { unmount } = render(); + expect(document.getElementById("portal")).toBeInTheDocument(); + + // Unmount first instance + unmount(); + + // Render second instance + render(); + expect(document.getElementById("portal")).toBeInTheDocument(); + }); +}); diff --git a/webapp/src/components/common/MatrixGrid/Matrix.tsx b/webapp/src/components/common/Matrix/index.tsx similarity index 68% rename from webapp/src/components/common/MatrixGrid/Matrix.tsx rename to webapp/src/components/common/Matrix/index.tsx index 128bf480cc..014703be76 100644 --- a/webapp/src/components/common/MatrixGrid/Matrix.tsx +++ b/webapp/src/components/common/Matrix/index.tsx @@ -13,29 +13,49 @@ */ import { Divider, Skeleton } from "@mui/material"; -import MatrixGrid from "."; -import { useMatrix } from "./useMatrix"; +import MatrixGrid from "./components/MatrixGrid"; +import { useMatrix } from "./hooks/useMatrix"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import ImportDialog from "../dialogs/ImportDialog"; import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../common/types"; -import { MatrixContainer, MatrixHeader, MatrixTitle } from "./style"; -import MatrixActions from "./MatrixActions"; +import { MatrixContainer, MatrixHeader, MatrixTitle } from "./styles"; +import MatrixActions from "./components/MatrixActions"; import EmptyView from "../page/SimpleContent"; +import { fetchMatrixFn } from "../../App/Singlestudy/explore/Modelization/Areas/Hydro/utils"; +import { AggregateConfig } from "./shared/types"; interface MatrixProps { url: string; title?: string; + customRowHeaders?: string[]; + enableDateTimeColumn?: boolean; enableTimeSeriesColumns?: boolean; - enableAggregateColumns?: boolean; + aggregateColumns?: AggregateConfig; + rowHeaders?: boolean; + showPercent?: boolean; + readOnly?: boolean; + customColumns?: string[] | readonly string[]; + colWidth?: number; + fetchMatrixData?: fetchMatrixFn; + isImportDisabled?: boolean; } function Matrix({ url, title = "global.timeSeries", + customRowHeaders = [], + enableDateTimeColumn = true, enableTimeSeriesColumns = true, - enableAggregateColumns = false, + aggregateColumns = false, + rowHeaders = customRowHeaders.length > 0, + showPercent = false, + readOnly = false, + customColumns, + colWidth, + fetchMatrixData, + isImportDisabled = false, }: MatrixProps) { const { t } = useTranslation(); const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -43,6 +63,7 @@ function Matrix({ const { data, + aggregates, error, isLoading, isSubmitting, @@ -57,7 +78,17 @@ function Matrix({ redo, canUndo, canRedo, - } = useMatrix(study.id, url, enableTimeSeriesColumns, enableAggregateColumns); + } = useMatrix( + study.id, + url, + enableDateTimeColumn, + enableTimeSeriesColumns, + rowHeaders, + aggregateColumns, + customColumns, + colWidth, + fetchMatrixData, + ); //////////////////////////////////////////////////////////////// // JSX @@ -68,10 +99,10 @@ function Matrix({ } if (error) { - return ; + return ; } - if (!data || data.length === 0) { + if (!data[0]?.length) { return ; } @@ -91,17 +122,21 @@ function Matrix({ redo={redo} canUndo={canUndo} canRedo={canRedo} + isImportDisabled={isImportDisabled} /> {openImportDialog && ( = [ + { + name: "integer numbers", + cases: [ + { + description: "formats large number", + value: 1234567, + expected: "1 234 567", + }, + { + description: "formats million", + value: 1000000, + expected: "1 000 000", + }, + { description: "formats single digit", value: 1, expected: "1" }, + ], + }, + { + name: "decimal numbers", + cases: [ + { + description: "formats with 2 decimals", + value: 1234.56, + maxDecimals: 2, + expected: "1 234.56", + }, + { + description: "formats with 3 decimals", + value: 1000000.123, + maxDecimals: 3, + expected: "1 000 000.123", + }, + ], + }, + { + name: "special cases", + cases: [ + { + description: "handles undefined", + value: undefined, + maxDecimals: 3, + expected: "", + }, + { description: "handles zero", value: 0, expected: "0" }, + { + description: "handles negative", + value: -1234567, + expected: "-1 234 567", + }, + { + description: "handles very large number", + value: 1e20, + expected: "100 000 000 000 000 000 000", + }, + ], + }, +]; + +export const AGGREGATE_CONFIG_CASES = [ + { + name: "stats configuration", + aggregates: "stats" as const, + expected: [Aggregate.Avg, Aggregate.Min, Aggregate.Max], + }, + { + name: "all configuration", + aggregates: "all" as const, + expected: [Aggregate.Min, Aggregate.Max, Aggregate.Avg, Aggregate.Total], + }, + { + name: "custom configuration", + aggregates: [Aggregate.Min, Aggregate.Max], + expected: [Aggregate.Min, Aggregate.Max], + }, +]; diff --git a/webapp/src/components/common/Matrix/shared/__tests__/types.ts b/webapp/src/components/common/Matrix/shared/__tests__/types.ts new file mode 100644 index 0000000000..72310190b6 --- /dev/null +++ b/webapp/src/components/common/Matrix/shared/__tests__/types.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +export interface FormatTestCase { + description: string; + value: number | undefined; + maxDecimals?: number; + expected: string; +} diff --git a/webapp/src/components/common/Matrix/shared/__tests__/utils.test.ts b/webapp/src/components/common/Matrix/shared/__tests__/utils.test.ts new file mode 100644 index 0000000000..ace38e0f34 --- /dev/null +++ b/webapp/src/components/common/Matrix/shared/__tests__/utils.test.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { Column } from "../constants"; +import { + calculateMatrixAggregates, + formatGridNumber, + generateCustomColumns, + generateDateTime, + generateTimeSeriesColumns, + getAggregateTypes, +} from "../utils"; +import { + DATE_TIME_TEST_CASES, + COLUMN_TEST_CASES, + AGGREGATE_TEST_CASES, + FORMAT_TEST_CASES, + AGGREGATE_CONFIG_CASES, +} from "./fixtures"; + +describe("Matrix Utils", () => { + beforeAll(() => { + vi.mock("date-fns", async () => { + const actual = (await vi.importActual( + "date-fns", + )) as typeof import("date-fns"); + return { + ...actual, + format: vi.fn((date: Date, formatString: string) => { + if (formatString.includes("ww")) { + const weekNumber = actual.getWeek(date); + return `W. ${weekNumber.toString().padStart(2, "0")}`; + } + return actual.format(date, formatString); + }), + }; + }); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2023-01-01 00:00:00")); + }); + + describe("DateTime Generation", () => { + test.each(DATE_TIME_TEST_CASES)( + "generates correct $name", + ({ config, expected }) => { + const result = generateDateTime(config); + expect(result).toEqual(expected); + }, + ); + }); + + describe("Time Series Column Generation", () => { + test.each(COLUMN_TEST_CASES)( + "handles $name correctly", + ({ input, expectedLength }) => { + const result = generateTimeSeriesColumns(input); + expect(result).toHaveLength(expectedLength); + + if (expectedLength > 0) { + if (input.startIndex) { + expect(result[0].id).toBe(`data${input.startIndex}`); + expect(result[0].title).toBe( + `${input.prefix || "TS"} ${input.startIndex}`, + ); + } else { + expect(result[0]).toEqual({ + id: "data1", + title: "TS 1", + type: Column.Number, + style: "normal", + editable: input.editable ?? true, + }); + } + } + }, + ); + }); + + describe("Matrix Aggregates Calculation", () => { + test.each(AGGREGATE_TEST_CASES)( + "calculates $name correctly", + ({ matrix, aggregates, expected }) => { + const aggregatesTypes = getAggregateTypes(aggregates); + const result = calculateMatrixAggregates(matrix, aggregatesTypes); + expect(result).toEqual(expected); + }, + ); + }); + + describe("Number Formatting", () => { + describe.each(FORMAT_TEST_CASES)("$name", ({ cases }) => { + test.each(cases)("$description", ({ value, maxDecimals, expected }) => { + expect(formatGridNumber({ value, maxDecimals })).toBe(expected); + }); + }); + }); + + describe("Custom Column Generation", () => { + test("generates columns with correct properties", () => { + const titles = ["Custom 1", "Custom 2", "Custom 3"]; + const width = 100; + + const result = generateCustomColumns({ titles, width }); + expect(result).toHaveLength(3); + + result.forEach((column, index) => { + expect(column).toEqual({ + id: `custom${index + 1}`, + title: titles[index], + type: Column.Number, + style: "normal", + width, + editable: true, + }); + }); + }); + }); + + describe("Aggregate Type Configuration", () => { + test.each(AGGREGATE_CONFIG_CASES)( + "handles $name correctly", + ({ aggregates, expected }) => { + expect(getAggregateTypes(aggregates)).toEqual(expected); + }, + ); + }); +}); diff --git a/webapp/src/components/common/Matrix/shared/constants.ts b/webapp/src/components/common/Matrix/shared/constants.ts new file mode 100644 index 0000000000..026d91bcf3 --- /dev/null +++ b/webapp/src/components/common/Matrix/shared/constants.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { + DateIncrementFunction, + FormatFunction, + TimeFrequencyType, +} from "./types"; +import { + addYears, + addMonths, + addWeeks, + startOfWeek, + FirstWeekContainsDate, + addDays, + addHours, + format, +} from "date-fns"; +import { t } from "i18next"; +import { getLocale } from "./utils"; + +//////////////////////////////////////////////////////////////// +// Enums +//////////////////////////////////////////////////////////////// + +export const Column = { + DateTime: "datetime", + Number: "number", + Text: "text", + Aggregate: "aggregate", +} as const; + +export const Operation = { + Add: "+", + Sub: "-", + Mul: "*", + Div: "/", + Abs: "ABS", + Eq: "=", +} as const; + +// !NOTE: Keep lowercase to match Glide Data Grid column ids +export const Aggregate = { + Min: "min", + Max: "max", + Avg: "avg", + Total: "total", +} as const; + +export const TimeFrequency = { + Annual: "annual", + Monthly: "monthly", + Weekly: "weekly", + Daily: "daily", + Hourly: "hourly", +} as const; + +//////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////// + +/** + * Configuration object for different time frequencies + * + * This object defines how to increment and format dates for various time frequencies. + * The WEEKLY frequency is of particular interest as it implements custom week starts + * and handles ISO week numbering. + */ +export const TIME_FREQUENCY_CONFIG: Record< + TimeFrequencyType, + { + increment: DateIncrementFunction; + format: FormatFunction; + } +> = { + [TimeFrequency.Annual]: { + increment: addYears, + format: () => t("global.time.annual"), + }, + [TimeFrequency.Monthly]: { + increment: addMonths, + format: (date: Date) => format(date, "MMM", { locale: getLocale() }), + }, + [TimeFrequency.Weekly]: { + increment: addWeeks, + format: (date: Date, firstWeekSize: number) => { + const weekStart = startOfWeek(date, { locale: getLocale() }); + + return format(weekStart, `'${t("global.time.weekShort")}' ww`, { + locale: getLocale(), + weekStartsOn: firstWeekSize === 1 ? 0 : 1, + firstWeekContainsDate: firstWeekSize as FirstWeekContainsDate, + }); + }, + }, + [TimeFrequency.Daily]: { + increment: addDays, + format: (date: Date) => format(date, "EEE d MMM", { locale: getLocale() }), + }, + [TimeFrequency.Hourly]: { + increment: addHours, + format: (date: Date) => + format(date, "EEE d MMM HH:mm", { locale: getLocale() }), + }, +}; diff --git a/webapp/src/components/common/Matrix/shared/types.ts b/webapp/src/components/common/Matrix/shared/types.ts new file mode 100644 index 0000000000..915602d0e1 --- /dev/null +++ b/webapp/src/components/common/Matrix/shared/types.ts @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { + BaseGridColumn, + EditableGridCell, + Item, +} from "@glideapps/glide-data-grid"; +import { Aggregate, Column, Operation, TimeFrequency } from "./constants"; + +// Derived types +export type ColumnType = (typeof Column)[keyof typeof Column]; +export type OperationType = (typeof Operation)[keyof typeof Operation]; +export type AggregateType = (typeof Aggregate)[keyof typeof Aggregate]; +export type TimeFrequencyType = + (typeof TimeFrequency)[keyof typeof TimeFrequency]; + +export type DateIncrementFunction = (date: Date, amount: number) => Date; +export type FormatFunction = (date: Date, firstWeekSize: number) => string; + +// !NOTE: This is temporary, date/time array should be generated by the API +export interface DateTimeMetadataDTO { + start_date: string; + steps: number; + first_week_size: number; + level: TimeFrequencyType; +} + +export interface TimeSeriesColumnOptions { + count: number; + startIndex?: number; + prefix?: string; + width?: number; + editable?: boolean; + style?: BaseGridColumn["style"]; +} + +export interface CustomColumnOptions { + titles: string[] | readonly string[]; + width?: number; +} + +export interface FormatGridNumberOptions { + value?: number; + maxDecimals?: number; +} + +export interface EnhancedGridColumn extends BaseGridColumn { + id: string; + width?: number; + type: ColumnType; + editable: boolean; +} + +export type AggregateConfig = AggregateType[] | boolean | "stats" | "all"; + +export interface MatrixAggregates { + min: number[]; + max: number[]; + avg: number[]; + total: number[]; +} + +// Represents data coming from the API +export interface MatrixDataDTO { + data: number[][]; + columns: number[]; + index: number[]; +} + +export type Coordinates = [number, number]; + +// Shape of updates provided by Glide Data Grid +export interface GridUpdate { + coordinates: Item; // The cell being updated + value: EditableGridCell; +} + +// Shape of updates to be sent to the API +export interface MatrixUpdate { + operation: OperationType; + value: number; +} + +// Shape of multiple updates to be sent to the API +export interface MatrixUpdateDTO { + coordinates: number[][]; // Array of [col, row] pairs + operation: MatrixUpdate; +} diff --git a/webapp/src/components/common/Matrix/shared/utils.ts b/webapp/src/components/common/Matrix/shared/utils.ts new file mode 100644 index 0000000000..de9b24277c --- /dev/null +++ b/webapp/src/components/common/Matrix/shared/utils.ts @@ -0,0 +1,284 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { + type EnhancedGridColumn, + type TimeSeriesColumnOptions, + type CustomColumnOptions, + type MatrixAggregates, + type AggregateType, + type AggregateConfig, + type DateTimeMetadataDTO, + type FormatGridNumberOptions, +} from "./types"; +import { parseISO, Locale } from "date-fns"; +import { fr, enUS } from "date-fns/locale"; +import { getCurrentLanguage } from "@/utils/i18nUtils"; +import { Aggregate, Column, TIME_FREQUENCY_CONFIG } from "./constants"; + +/** + * Formats a number for display in a grid cell by adding thousand separators and handling decimals. + * + * This function is particularly useful for displaying load factors, + * which are numbers between 0 and 1. For load factors, a maximum of + * 6 decimal places should be displayed. For statistics, a maximum of + * 3 decimal places is recommended. + * + * @example + * ```typescript + * formatGridNumber({ value: 1234567.89, maxDecimals: 2 }) // "1 234 567.89" + * formatGridNumber({ value: 0, maxDecimals: 6 }) // "0" + * formatGridNumber({ value: undefined }) // "" + * formatGridNumber({ value: NaN }) // "" + * ``` + * @param options - The formatting options + * @param options.value - The number to format. + * @param options.maxDecimals - Maximum number of decimal places to show. + * @returns A formatted string representation of the number with proper separators. + */ +export function formatGridNumber({ + value, + maxDecimals = 0, +}: FormatGridNumberOptions): string { + if (value === undefined) { + return ""; + } + + const numValue = Number(value); + + if (isNaN(numValue)) { + return ""; + } + + const stringValue = value.toString(); + const dotIndex = stringValue.indexOf("."); + const hasDecimals = dotIndex !== -1; + const shouldFormatDecimals = + hasDecimals && + maxDecimals > 0 && + stringValue.length - dotIndex - 1 > maxDecimals; + + const formattedValue = shouldFormatDecimals + ? numValue.toFixed(maxDecimals) + : stringValue; + + const [integerPart, decimalPart] = formattedValue.split("."); + + const formattedInteger = integerPart + .split("") + .reverse() + .reduce((acc, digit, index) => { + if (index > 0 && index % 3 === 0) { + return digit + " " + acc; + } + return digit + acc; + }, ""); + + return decimalPart ? `${formattedInteger}.${decimalPart}` : formattedInteger; +} + +/** + * Retrieves the current locale based on the user's language setting. + * + * @returns Returns either the French (fr) or English US (enUS) locale object + * depending on whether the current language starts with "fr" + */ +export function getLocale(): Locale { + const lang = getCurrentLanguage(); + return lang && lang.startsWith("fr") ? fr : enUS; +} + +/** + * Generates an array of formatted date/time strings based on the provided configuration + * + * This function handles various time frequencies, with special attention to weekly formatting. + * For weekly frequency, it respects custom week starts while maintaining ISO week numbering. + * + * @param config - Configuration object for date/time generation + * @param config.start_date - The starting date for generation + * @param config.steps - Number of increments to generate + * @param config.first_week_size - Defines the number of days for the first the week (from 1 to 7) + * @param config.level - The time frequency level (ANNUAL, MONTHLY, WEEKLY, DAILY, HOURLY) + * @returns An array of formatted date/time strings + */ +export const generateDateTime = (config: DateTimeMetadataDTO): string[] => { + // eslint-disable-next-line camelcase + const { start_date, steps, first_week_size, level } = config; + const { increment, format } = TIME_FREQUENCY_CONFIG[level]; + const initialDate = parseISO(start_date); + + return Array.from({ length: steps }, (_, index) => { + const date = increment(initialDate, index); + return format(date, first_week_size); + }); +}; + +/** + * Generates an array of EnhancedGridColumn objects representing time series data columns. + * + * @param options - The options for generating time series columns. + * @param options.count - The number of time series columns to generate. + * @param [options.startIndex=1] - The starting index for the time series columns (default is 1). + * @param [options.prefix="TS"] - The prefix to use for the column titles (default is "TS"). + * @param [options.width=50] - The width of each column (default is 50). + * @param [options.editable=true] - Whether the columns should be editable (default is true). + * @param [options.style="normal"] - The style of the columns (default is "normal"). + * @returns An array of EnhancedGridColumn objects representing time series data columns. + * + * @example Usage within a column definition array + * const columns = [ + * { id: "rowHeaders", title: "", type: Column.Text, ... }, + * { id: "date", title: "Date", type: Column.DateTime, ... }, + * ...generateTimeSeriesColumns({ count: 60 }), + * { id: "min", title: "Min", type: Column.Aggregate, ... }, + * { id: "max", title: "Max", type: Column.Aggregate, ... }, + * { id: "avg", title: "Avg", type: Column.Aggregate, ... } + * ]; + */ +export function generateTimeSeriesColumns({ + count, + startIndex = 1, + prefix = "TS", + width, + editable = true, + style = "normal", +}: TimeSeriesColumnOptions): EnhancedGridColumn[] { + return Array.from({ length: count }, (_, index) => ({ + id: `data${startIndex + index}`, + title: `${prefix} ${startIndex + index}`, + type: Column.Number, + style, + width, + editable, + })); +} + +/** + * Generates custom columns for a matrix grid. + * + * @param customColumns - An array of strings representing the custom column titles. + * @param customColumns.titles - The titles of the custom columns. + * @param customColumns.width - The width of each custom column. + * @returns An array of EnhancedGridColumn objects representing the generated custom columns. + */ +export function generateCustomColumns({ + titles, + width, +}: CustomColumnOptions): EnhancedGridColumn[] { + return titles.map((title, index) => ({ + id: `custom${index + 1}`, + title, + type: Column.Number, + style: "normal", + width, + editable: true, + })); +} + +/** + * Generates an array of data columns for a matrix grid. + * + * @param enableTimeSeriesColumns - A boolean indicating whether to enable time series columns. + * @param columnCount - The number of columns to generate. + * @param customColumns - An optional array of custom column titles. + * @param colWidth - The width of each column. + * @returns An array of EnhancedGridColumn objects representing the generated data columns. + */ +export function generateDataColumns( + enableTimeSeriesColumns: boolean, + columnCount: number, + customColumns?: string[] | readonly string[], + colWidth?: number, +): EnhancedGridColumn[] { + // If custom columns are provided, use them + if (customColumns) { + return generateCustomColumns({ titles: customColumns, width: colWidth }); + } + + // Else, generate time series columns if enabled + if (enableTimeSeriesColumns) { + return generateTimeSeriesColumns({ count: columnCount }); + } + + return []; +} + +/** + * Determines the aggregate types based on the provided configuration. + * + * @param aggregateConfig - The configuration for aggregates. + * @returns An array of AggregateType. + */ +export function getAggregateTypes( + aggregateConfig: AggregateConfig, +): AggregateType[] { + if (aggregateConfig === "stats") { + return [Aggregate.Avg, Aggregate.Min, Aggregate.Max]; + } + + if (aggregateConfig === "all") { + return [Aggregate.Min, Aggregate.Max, Aggregate.Avg, Aggregate.Total]; + } + + if (Array.isArray(aggregateConfig)) { + return aggregateConfig; + } + + return []; +} + +/** + * Calculates matrix aggregates based on the provided matrix and aggregate types. + * + * @param matrix - The input matrix of numbers. + * @param aggregateTypes - The types of aggregates to calculate. + * @returns An object containing the calculated aggregates. + */ +export function calculateMatrixAggregates( + matrix: number[][], + aggregateTypes: AggregateType[], +): Partial { + const aggregates: Partial = {}; + + matrix.forEach((row) => { + if (aggregateTypes.includes(Aggregate.Min)) { + aggregates.min = aggregates.min || []; + aggregates.min.push(Math.min(...row)); + } + + if (aggregateTypes.includes(Aggregate.Max)) { + aggregates.max = aggregates.max || []; + aggregates.max.push(Math.max(...row)); + } + + if ( + aggregateTypes.includes(Aggregate.Avg) || + aggregateTypes.includes(Aggregate.Total) + ) { + const sum = row.reduce((acc, num) => acc + num, 0); + + if (aggregateTypes.includes(Aggregate.Avg)) { + aggregates.avg = aggregates.avg || []; + aggregates.avg.push(Number((sum / row.length).toFixed())); + } + + if (aggregateTypes.includes(Aggregate.Total)) { + aggregates.total = aggregates.total || []; + aggregates.total.push(Number(sum.toFixed())); + } + } + }); + + return aggregates; +} diff --git a/webapp/src/components/common/MatrixGrid/style.ts b/webapp/src/components/common/Matrix/styles.ts similarity index 100% rename from webapp/src/components/common/MatrixGrid/style.ts rename to webapp/src/components/common/Matrix/styles.ts diff --git a/webapp/src/components/common/MatrixGrid/index.test.tsx b/webapp/src/components/common/MatrixGrid/index.test.tsx deleted file mode 100644 index 363ae7b8c2..0000000000 --- a/webapp/src/components/common/MatrixGrid/index.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { render } from "@testing-library/react"; -import MatrixGrid, { MatrixGridProps } from "."; -import Box from "@mui/material/Box"; -import { mockGetBoundingClientRect } from "../../../tests/mocks/mockGetBoundingClientRect"; -import { type EnhancedGridColumn, ColumnTypes } from "./types"; -import { mockHTMLCanvasElement } from "../../../tests/mocks/mockHTMLCanvasElement"; - -beforeEach(() => { - mockHTMLCanvasElement(); - mockGetBoundingClientRect(); - vi.clearAllMocks(); -}); - -function renderMatrixGrid( - width: string, - height: string, - data: MatrixGridProps["data"], - columns: EnhancedGridColumn[], - rows: number, -) { - return render( - - - , - ); -} - -function assertDimensions( - element: HTMLElement, - expectedWidth: number, - expectedHeight: number, -) { - const rect = element.getBoundingClientRect(); - expect(rect.width).toBe(expectedWidth); - expect(rect.height).toBe(expectedHeight); -} - -describe("MatrixGrid rendering", () => { - test("MatrixGrid should be rendered within a 450x500px container and match these dimensions", () => { - const data = [ - [1, 2, 3], - [4, 5, 6], - ]; - - const columns = [ - { - id: "col1", - title: "Column 1", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 0, - }, - { - id: "col2", - title: "Column 2", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 1, - }, - { - id: "col3", - title: "Column 3", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 2, - }, - ]; - - const rows = 2; - - // Render the MatrixGrid inside a parent container with specific dimensions - const { container } = renderMatrixGrid( - "450px", // Use inline style for exact measurement - "500px", - data, - columns, - rows, - ); - - const matrix = container.firstChild; - - if (matrix instanceof HTMLElement) { - expect(matrix).toBeInTheDocument(); - assertDimensions(matrix, 450, 500); - } else { - throw new Error("Expected an HTMLElement but received a different node."); - } - }); - - test("MatrixGrid should render correctly with no data", () => { - const data: MatrixGridProps["data"] = []; - - const columns = [ - { - id: "col1", - title: "Column 1", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 0, - }, - { - id: "col2", - title: "Column 2", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 1, - }, - { - id: "col3", - title: "Column 3", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 2, - }, - ]; - - const rows = 0; - - const { container } = renderMatrixGrid( - "450px", - "500px", - data, - columns, - rows, - ); - - const matrix = container.firstChild; - - if (matrix instanceof HTMLElement) { - expect(matrix).toBeInTheDocument(); - assertDimensions(matrix, 450, 500); - } else { - throw new Error("Expected an HTMLElement but received a different node."); - } - }); - - test("MatrixGrid should match the provided dimensions when resized", () => { - const data = [ - [1, 2, 3], - [4, 5, 6], - ]; - - const columns = [ - { - id: "col1", - title: "Column 1", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 0, - }, - { - id: "col2", - title: "Column 2", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 1, - }, - { - id: "col3", - title: "Column 3", - width: 100, - type: ColumnTypes.Number, - editable: true, - order: 2, - }, - ]; - - const rows = 2; - - const { container, rerender } = renderMatrixGrid( - "450px", - "500px", - data, - columns, - rows, - ); - - let matrix = container.firstChild; - - if (matrix instanceof HTMLElement) { - assertDimensions(matrix, 450, 500); - } else { - throw new Error("Expected an HTMLElement but received a different node."); - } - - rerender( - - - , - ); - - matrix = container.firstChild; - - if (matrix instanceof HTMLElement) { - assertDimensions(matrix, 300, 400); - } else { - throw new Error("Expected an HTMLElement but received a different node."); - } - }); -}); diff --git a/webapp/src/components/common/MatrixGrid/types.ts b/webapp/src/components/common/MatrixGrid/types.ts deleted file mode 100644 index af94b98d97..0000000000 --- a/webapp/src/components/common/MatrixGrid/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { - BaseGridColumn, - EditableGridCell, - Item, -} from "@glideapps/glide-data-grid"; - -//////////////////////////////////////////////////////////////// -// Enums -//////////////////////////////////////////////////////////////// - -export const ColumnTypes = { - DateTime: "datetime", - Number: "number", - Text: "text", - Aggregate: "aggregate", -} as const; - -export const Operations = { - ADD: "+", - SUB: "-", - MUL: "*", - DIV: "/", - ABS: "ABS", - EQ: "=", -} as const; - -//////////////////////////////////////////////////////////////// -// Types -//////////////////////////////////////////////////////////////// - -// Derived types -export type ColumnType = (typeof ColumnTypes)[keyof typeof ColumnTypes]; -export type Operation = (typeof Operations)[keyof typeof Operations]; - -export interface EnhancedGridColumn extends BaseGridColumn { - id: string; - width?: number; - type: ColumnType; - editable: boolean; -} -// Represents data coming from the API -export interface MatrixDataDTO { - data: number[][]; - columns: number[]; - index: number[]; -} - -export type Coordinates = [number, number]; - -// Shape of updates provided by Glide Data Grid -export interface GridUpdate { - coordinates: Item; // The cell being updated - value: EditableGridCell; -} - -// Shape of updates to be sent to the API -export interface MatrixUpdate { - operation: Operation; - value: number; -} - -// Shape of multiple updates to be sent to the API -export interface MatrixUpdateDTO { - coordinates: number[][]; // Array of [col, row] pairs - operation: MatrixUpdate; -} - -export type DateIncrementStrategy = ( - date: moment.Moment, - step: number, -) => moment.Moment; diff --git a/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts b/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts deleted file mode 100644 index e88473b9a6..0000000000 --- a/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { renderHook } from "@testing-library/react"; -import { describe, test, expect } from "vitest"; -import { useColumnMapping } from "./useColumnMapping"; -import { EnhancedGridColumn, ColumnTypes } from "./types"; - -describe("useColumnMapping", () => { - const testColumns: EnhancedGridColumn[] = [ - { - id: "text", - title: "Text", - type: ColumnTypes.Text, - width: 100, - editable: false, - }, - { - id: "date", - title: "Date", - type: ColumnTypes.DateTime, - width: 100, - editable: false, - }, - { - id: "num1", - title: "Number 1", - type: ColumnTypes.Number, - width: 100, - editable: true, - }, - { - id: "num2", - title: "Number 2", - type: ColumnTypes.Number, - width: 100, - editable: true, - }, - { - id: "agg", - title: "Aggregate", - type: ColumnTypes.Aggregate, - width: 100, - editable: false, - }, - ]; - - test("should create gridToData and dataToGrid functions", () => { - const { result } = renderHook(() => useColumnMapping(testColumns)); - expect(result.current.gridToData).toBeDefined(); - expect(result.current.dataToGrid).toBeDefined(); - }); - - describe("gridToData", () => { - test("should return null for non-data columns", () => { - const { result } = renderHook(() => useColumnMapping(testColumns)); - expect(result.current.gridToData([0, 0])).toBeNull(); // Text column - expect(result.current.gridToData([1, 0])).toBeNull(); // DateTime column - expect(result.current.gridToData([4, 0])).toBeNull(); // Aggregate column - }); - - test("should map grid coordinates to data coordinates for data columns", () => { - const { result } = renderHook(() => useColumnMapping(testColumns)); - expect(result.current.gridToData([2, 0])).toEqual([0, 0]); // First Number column - expect(result.current.gridToData([3, 1])).toEqual([1, 1]); // Second Number column - }); - }); - - describe("dataToGrid", () => { - test("should map data coordinates to grid coordinates", () => { - const { result } = renderHook(() => useColumnMapping(testColumns)); - expect(result.current.dataToGrid([0, 0])).toEqual([2, 0]); // First data column - expect(result.current.dataToGrid([1, 1])).toEqual([3, 1]); // Second data column - }); - }); - - test("should handle columns with only non-data types", () => { - const nonDataColumns: EnhancedGridColumn[] = [ - { - id: "text", - title: "Text", - type: ColumnTypes.Text, - width: 100, - editable: false, - }, - { - id: "date", - title: "Date", - type: ColumnTypes.DateTime, - width: 100, - editable: false, - }, - ]; - const { result } = renderHook(() => useColumnMapping(nonDataColumns)); - expect(result.current.gridToData([0, 0])).toBeNull(); - expect(result.current.gridToData([1, 0])).toBeNull(); - expect(result.current.dataToGrid([0, 0])).toEqual([undefined, 0]); // No data columns, so this should return an invalid grid coordinate - }); - - test("should handle columns with only data types", () => { - const dataOnlyColumns: EnhancedGridColumn[] = [ - { - id: "num1", - title: "Number 1", - type: ColumnTypes.Number, - width: 100, - editable: true, - }, - { - id: "num2", - title: "Number 2", - type: ColumnTypes.Number, - width: 100, - editable: true, - }, - ]; - const { result } = renderHook(() => useColumnMapping(dataOnlyColumns)); - expect(result.current.gridToData([0, 0])).toEqual([0, 0]); - expect(result.current.gridToData([1, 1])).toEqual([1, 1]); - expect(result.current.dataToGrid([0, 0])).toEqual([0, 0]); - expect(result.current.dataToGrid([1, 1])).toEqual([1, 1]); - }); - - test("should memoize the result", () => { - const { result, rerender } = renderHook( - (props) => useColumnMapping(props.columns), - { initialProps: { columns: testColumns } }, - ); - const initialResult = result.current; - rerender({ columns: testColumns }); - expect(result.current).toBe(initialResult); - }); -}); diff --git a/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts b/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts deleted file mode 100644 index 084a52d429..0000000000 --- a/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts +++ /dev/null @@ -1,540 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { renderHook } from "@testing-library/react"; -import { useGridCellContent } from "./useGridCellContent"; -import { ColumnTypes, type EnhancedGridColumn } from "./types"; -import { useColumnMapping } from "./useColumnMapping"; - -// Mocking i18next -vi.mock("i18next", () => { - const i18n = { - language: "fr", - use: vi.fn().mockReturnThis(), - init: vi.fn(), - t: vi.fn((key) => key), - changeLanguage: vi.fn((lang) => { - i18n.language = lang; - return Promise.resolve(); - }), - on: vi.fn(), - }; - return { default: i18n }; -}); - -// Mocking react-i18next -vi.mock("react-i18next", async (importOriginal) => { - const actual = await importOriginal(); - return Object.assign({}, actual, { - useTranslation: () => ({ - t: vi.fn((key) => key), - i18n: { - changeLanguage: vi.fn(), - language: "fr", - }, - }), - initReactI18next: { - type: "3rdParty", - init: vi.fn(), - }, - }); -}); - -function renderGridCellContent( - data: number[][], - columns: EnhancedGridColumn[], - dateTime?: string[], - aggregates?: Record, - rowHeaders?: string[], -) { - const { result: mappingResult } = renderHook(() => useColumnMapping(columns)); - const { gridToData } = mappingResult.current; - - const { result } = renderHook(() => - useGridCellContent( - data, - columns, - gridToData, - dateTime, - aggregates, - rowHeaders, - ), - ); - - return result.current; -} - -describe("useGridCellContent", () => { - test("returns correct text cell content for DateTime columns", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "date", - title: "Date", - type: ColumnTypes.DateTime, - width: 150, - editable: false, - }, - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - ]; - - const dateTime = ["2024-01-07T00:00:00Z", "2024-01-02T00:00:00Z"]; - - const data = [ - [11, 10], - [12, 15], - ]; - - const getCellContent = renderGridCellContent(data, columns, dateTime); - const cell = getCellContent([0, 0]); - - if ("displayData" in cell) { - expect(cell.kind).toBe("text"); - expect(cell.displayData).toBe("7 janv. 2024, 00:00"); - } else { - throw new Error("Expected a text cell with displayData"); - } - }); - - describe("returns correct cell content for Aggregate columns", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "total", - title: "Total", - type: ColumnTypes.Aggregate, - width: 100, - editable: false, - }, - ]; - - const data = [ - [10, 20, 30], - [15, 25, 35], - [5, 15, 25], - ]; - - const aggregates = { - total: [60, 75, 45], - }; - - // Tests for each row in the aggregates array - test.each([ - [0, 60], // Row index 0, expected sum 60 - [1, 75], // Row index 1, expected sum 75 - [2, 45], // Row index 2, expected sum 45 - ])( - "ensures the correct numeric cell content is returned for aggregates at row %i", - (row, expectedData) => { - const getCellContent = renderGridCellContent( - data, - columns, - undefined, - aggregates, - ); - - const cell = getCellContent([0, row]); // Column index is 0 because we only have one column of aggregates - - if ("data" in cell) { - expect(cell.kind).toBe("number"); - expect(cell.data).toBe(expectedData); - } else { - throw new Error(`Expected a number cell with data at row [${row}]`); - } - }, - ); - }); - - test("returns correct content for DateTime, Number, and Aggregate columns", () => { - const columns = [ - { - id: "date", - title: "Date", - type: ColumnTypes.DateTime, - width: 150, - editable: false, - }, - { - id: "ts1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - { - id: "ts2", - title: "TS 2", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - { - id: "total", - title: "Total", - type: ColumnTypes.Aggregate, - width: 100, - editable: false, - }, - ]; - - const dateTime = ["2021-01-01T00:00:00Z", "2021-01-02T00:00:00Z"]; - - const data = [ - [100, 200], - [150, 250], - ]; - - const aggregates = { - total: [300, 400], - }; - - const getCellContent = renderGridCellContent( - data, - columns, - dateTime, - aggregates, - ); - - const dateTimeCell = getCellContent([0, 0]); - - if (dateTimeCell.kind === "text" && "displayData" in dateTimeCell) { - expect(dateTimeCell.data).toBe(""); - expect(dateTimeCell.displayData).toBe("1 janv. 2021, 00:00"); - } else { - throw new Error( - "Expected a DateTime cell with displayData containing the year 2021", - ); - } - - const numberCell = getCellContent([1, 0]); - - if (numberCell.kind === "number" && "data" in numberCell) { - expect(numberCell.data).toBe(100); - } else { - throw new Error("Expected a Number cell with data"); - } - - const aggregateCell = getCellContent([3, 0]); - - if (aggregateCell.kind === "number" && "data" in aggregateCell) { - expect(aggregateCell.data).toBe(300); - } else { - throw new Error("Expected an Aggregate cell with data"); - } - }); -}); - -describe("useGridCellContent with mixed column types", () => { - test("handles non-data columns correctly and accesses data columns properly", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "rowHeader", - title: "Row", - type: ColumnTypes.Text, - width: 100, - editable: false, - }, - { - id: "date", - title: "Date", - type: ColumnTypes.DateTime, - width: 150, - editable: false, - }, - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - { - id: "data2", - title: "TS 2", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - { - id: "total", - title: "Total", - type: ColumnTypes.Aggregate, - width: 100, - editable: false, - }, - ]; - - const rowHeaders = ["Row 1", "Row 2"]; - const dateTime = ["2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"]; - const data = [ - [100, 200], - [150, 250], - ]; - const aggregates = { - total: [300, 400], - }; - - const getCellContent = renderGridCellContent( - data, - columns, - dateTime, - aggregates, - rowHeaders, - ); - - // Test row header (Text column) - const rowHeaderCell = getCellContent([0, 0]); - - if (rowHeaderCell.kind === "text" && "displayData" in rowHeaderCell) { - expect(rowHeaderCell.displayData).toBe("Row 1"); - } else { - throw new Error("Expected a text cell with data for row header"); - } - - // Test date column (DateTime column) - const dateCell = getCellContent([1, 0]); - if (dateCell.kind === "text" && "displayData" in dateCell) { - expect(dateCell.data).toBe(""); - expect(dateCell.displayData).toBe("1 janv. 2024, 00:00"); - } else { - throw new Error("Expected a text cell with data for date"); - } - - // Test first data column (Number column) - const firstDataCell = getCellContent([2, 0]); - - if (firstDataCell.kind === "number" && "data" in firstDataCell) { - expect(firstDataCell.data).toBe(100); - } else { - throw new Error("Expected a number cell with data for first data column"); - } - - // Test second data column (Number column) - const secondDataCell = getCellContent([3, 0]); - - if (secondDataCell.kind === "number" && "data" in secondDataCell) { - expect(secondDataCell.data).toBe(200); - } else { - throw new Error( - "Expected a number cell with data for second data column", - ); - } - - // Test aggregate column - const aggregateCell = getCellContent([4, 0]); - - if (aggregateCell.kind === "number" && "data" in aggregateCell) { - expect(aggregateCell.data).toBe(300); - } else { - throw new Error("Expected a number cell with data for aggregate column"); - } - }); - - test("correctly handles data columns when non-data columns are removed", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - { - id: "data2", - title: "TS 2", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - { - id: "data3", - title: "TS 3", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - ]; - - const data = [ - [100, 200, 300], - [150, 250, 350], - ]; - - const getCellContent = renderGridCellContent(data, columns); - - // Test all data columns - for (let i = 0; i < 3; i++) { - const cell = getCellContent([i, 0]); - if (cell.kind === "number" && "data" in cell) { - expect(cell.data).toBe(data[0][i]); - } else { - throw new Error(`Expected a number cell with data for column ${i}`); - } - } - }); -}); - -describe("useGridCellContent additional tests", () => { - test("handles empty data array correctly", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - ]; - const data: number[][] = []; - - const getCellContent = renderGridCellContent(data, columns); - - const cell = getCellContent([0, 0]); - if (cell.kind === "number" && "data" in cell) { - expect(cell.data).toBeUndefined(); - } else { - throw new Error("Expected a number cell with undefined data"); - } - }); - - test("handles column access out of bounds", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - ]; - const data = [[100]]; - - const getCellContent = renderGridCellContent(data, columns); - - const cell = getCellContent([1, 0]); // Accessing column index 1 which doesn't exist - expect(cell.kind).toBe("text"); - if ("displayData" in cell) { - expect(cell.displayData).toBe("N/A"); - } else { - throw new Error("Expected a text cell with 'N/A' displayData"); - } - }); - - test("handles row access out of bounds", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - ]; - const data = [[100]]; - - const getCellContent = renderGridCellContent(data, columns); - - const cell = getCellContent([0, 1]); // Accessing row index 1 which doesn't exist - if (cell.kind === "number" && "data" in cell) { - expect(cell.data).toBeUndefined(); - } else { - throw new Error("Expected a number cell with undefined data"); - } - }); - - test("handles missing aggregates correctly", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "total", - title: "Total", - type: ColumnTypes.Aggregate, - width: 100, - editable: false, - }, - ]; - const data = [[100]]; - // No aggregates provided - - const getCellContent = renderGridCellContent(data, columns); - - const cell = getCellContent([0, 0]); - if (cell.kind === "number" && "data" in cell) { - expect(cell.data).toBeUndefined(); - } else { - throw new Error( - "Expected a number cell with undefined data for missing aggregate", - ); - } - }); - - test("handles mixed editable and non-editable columns", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - { - id: "data2", - title: "TS 2", - type: ColumnTypes.Number, - width: 50, - editable: false, - }, - ]; - const data = [[100, 200]]; - - const getCellContent = renderGridCellContent(data, columns); - - const editableCell = getCellContent([0, 0]); - const nonEditableCell = getCellContent([1, 0]); - - if (editableCell.kind === "number" && nonEditableCell.kind === "number") { - expect(editableCell.readonly).toBe(false); - expect(nonEditableCell.readonly).toBe(true); - } else { - throw new Error("Expected number cells with correct readonly property"); - } - }); - - test("handles very large numbers correctly", () => { - const columns: EnhancedGridColumn[] = [ - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - width: 50, - editable: true, - }, - ]; - const largeNumber = 1e20; - const data = [[largeNumber]]; - - const getCellContent = renderGridCellContent(data, columns); - - const cell = getCellContent([0, 0]); - if (cell.kind === "number" && "data" in cell) { - expect(cell.data).toBe(largeNumber); - expect(cell.displayData).toBe(largeNumber.toString()); - } else { - throw new Error("Expected a number cell with correct large number data"); - } - }); -}); diff --git a/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx b/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx deleted file mode 100644 index aad25a253f..0000000000 --- a/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { renderHook, act, waitFor } from "@testing-library/react"; -import { vi, describe, expect, beforeEach } from "vitest"; -import { useMatrix } from "./useMatrix"; -import * as apiMatrix from "../../../services/api/matrix"; -import * as apiStudy from "../../../services/api/study"; -import * as rawStudy from "../../../services/api/studies/raw"; -import { - MatrixEditDTO, - MatrixIndex, - Operator, - StudyOutputDownloadLevelDTO, -} from "../../../common/types"; -import { GridUpdate, MatrixDataDTO } from "./types"; -import { GridCellKind } from "@glideapps/glide-data-grid"; - -vi.mock("../../../services/api/matrix"); -vi.mock("../../../services/api/study"); -vi.mock("../../../services/api/studies/raw"); - -describe("useMatrix", () => { - const mockStudyId = "study123"; - const mockUrl = "https://studies/study123/matrix"; - - const mockMatrixData: MatrixDataDTO = { - data: [ - [1, 2], - [3, 4], - ], - columns: [0, 1], - index: [0, 1], - }; - - const mockMatrixIndex: MatrixIndex = { - start_date: "2023-01-01", - steps: 2, - first_week_size: 7, - level: StudyOutputDownloadLevelDTO.DAILY, - }; - - // Helper function to set up the hook and wait for initial loading - const setupHook = async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); - - const { result } = renderHook(() => - useMatrix(mockStudyId, mockUrl, true, true), - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - return result; - }; - - // Helper function to create a grid update object - const createGridUpdate = ( - row: number, - col: number, - value: number, - ): GridUpdate => ({ - coordinates: [row, col], - value: { - kind: GridCellKind.Number, - data: value, - displayData: value.toString(), - allowOverlay: true, - }, - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - test("should fetch matrix data and index on mount", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.data).toEqual(mockMatrixData.data); - expect(result.current.columns.length).toBeGreaterThan(0); - expect(result.current.dateTime.length).toBeGreaterThan(0); - }); - - test("should handle cell edit", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - act(() => { - result.current.handleCellEdit(createGridUpdate(0, 1, 5)); - }); - - expect(result.current.data[1][0]).toBe(5); - expect(result.current.pendingUpdatesCount).toBe(1); - }); - - test("should handle multiple cells edit", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - act(() => { - result.current.handleMultipleCellsEdit([ - createGridUpdate(0, 1, 5), - createGridUpdate(1, 0, 6), - ]); - }); - - expect(result.current.data[1][0]).toBe(5); - expect(result.current.data[0][1]).toBe(6); - expect(result.current.pendingUpdatesCount).toBe(2); - }); - - test("should handle save updates", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); - vi.mocked(apiMatrix.updateMatrix).mockResolvedValue(undefined); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - act(() => { - result.current.handleCellEdit(createGridUpdate(0, 1, 5)); - }); - - await act(async () => { - await result.current.handleSaveUpdates(); - }); - - const expectedEdit: MatrixEditDTO = { - coordinates: [[1, 0]], - operation: { - operation: Operator.EQ, - value: 5, - }, - }; - - expect(apiMatrix.updateMatrix).toHaveBeenCalledWith(mockStudyId, mockUrl, [ - expectedEdit, - ]); - expect(result.current.pendingUpdatesCount).toBe(0); - }); - - test("should handle file import", async () => { - const mockFile = new File([""], "test.csv", { type: "text/csv" }); - vi.mocked(rawStudy.importFile).mockResolvedValue(); - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); - - const result = await setupHook(); - - await act(async () => { - await result.current.handleImport(mockFile); - }); - - expect(rawStudy.importFile).toHaveBeenCalledWith({ - file: mockFile, - studyId: mockStudyId, - path: mockUrl, - }); - }); - - describe("Undo and Redo functionality", () => { - test("should have correct initial undo/redo states", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue( - mockMatrixIndex, - ); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.canUndo).toBe(false); - expect(result.current.canRedo).toBe(false); - }); - - test("should update canUndo and canRedo states correctly after edits", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue( - mockMatrixIndex, - ); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - act(() => { - result.current.handleCellEdit(createGridUpdate(0, 1, 5)); - }); - - expect(result.current.canUndo).toBe(true); - expect(result.current.canRedo).toBe(false); - - act(() => { - result.current.undo(); - }); - - expect(result.current.canUndo).toBe(false); - expect(result.current.canRedo).toBe(true); - - act(() => { - result.current.redo(); - }); - - expect(result.current.canUndo).toBe(true); - expect(result.current.canRedo).toBe(false); - }); - - test("should reset redo state after a new edit", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue( - mockMatrixIndex, - ); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - act(() => { - result.current.handleCellEdit(createGridUpdate(0, 1, 5)); - }); - - act(() => { - result.current.undo(); - }); - - expect(result.current.canRedo).toBe(true); - - act(() => { - result.current.handleCellEdit(createGridUpdate(1, 0, 6)); - }); - - expect(result.current.canUndo).toBe(true); - expect(result.current.canRedo).toBe(false); - }); - - test("should handle undo to initial state", async () => { - vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); - vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue( - mockMatrixIndex, - ); - - const result = await setupHook(); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - act(() => { - result.current.handleCellEdit(createGridUpdate(0, 1, 5)); - }); - - act(() => { - result.current.undo(); - }); - - expect(result.current.data).toEqual(mockMatrixData.data); - expect(result.current.canUndo).toBe(false); - expect(result.current.canRedo).toBe(true); - }); - }); -}); diff --git a/webapp/src/components/common/MatrixGrid/useMatrix.ts b/webapp/src/components/common/MatrixGrid/useMatrix.ts deleted file mode 100644 index 3332bcfa6e..0000000000 --- a/webapp/src/components/common/MatrixGrid/useMatrix.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { AxiosError } from "axios"; -import { enqueueSnackbar } from "notistack"; -import { t } from "i18next"; -import { MatrixIndex, Operator } from "../../../common/types"; -import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; -import { - getStudyMatrixIndex, - updateMatrix, -} from "../../../services/api/matrix"; -import { getStudyData } from "../../../services/api/study"; -import { - EnhancedGridColumn, - MatrixDataDTO, - ColumnTypes, - GridUpdate, - MatrixUpdateDTO, -} from "./types"; -import { generateDateTime, generateTimeSeriesColumns } from "./utils"; -import useUndo from "use-undo"; -import { GridCellKind } from "@glideapps/glide-data-grid"; -import { importFile } from "../../../services/api/studies/raw"; - -interface DataState { - data: number[][]; - pendingUpdates: MatrixUpdateDTO[]; -} - -export function useMatrix( - studyId: string, - url: string, - enableTimeSeriesColumns: boolean, - enableAggregateColumns: boolean, -) { - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const [columnCount, setColumnCount] = useState(0); - const [index, setIndex] = useState(undefined); - const [isLoading, setIsLoading] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(undefined); - const [{ present: currentState }, { set: setState, undo, redo, canRedo }] = - useUndo({ data: [], pendingUpdates: [] }); - - const fetchMatrix = useCallback(async () => { - setIsLoading(true); - try { - const [matrix, index] = await Promise.all([ - getStudyData(studyId, url), - getStudyMatrixIndex(studyId, url), - ]); - - setState({ data: matrix.data, pendingUpdates: [] }); - setColumnCount(matrix.columns.length); - setIndex(index); - setIsLoading(false); - } catch (error) { - setError(new Error(t("data.error.matrix"))); - enqueueErrorSnackbar(t("data.error.matrix"), error as AxiosError); - } finally { - setIsLoading(false); - } - }, [enqueueErrorSnackbar, setState, studyId, url]); - - useEffect(() => { - fetchMatrix(); - }, [fetchMatrix]); - - const dateTime = useMemo(() => { - return index ? generateDateTime(index) : []; - }, [index]); - - const columns: EnhancedGridColumn[] = useMemo(() => { - if (!currentState.data) { - return []; - } - - const baseColumns = [ - { - id: "date", - title: "Date", - type: ColumnTypes.DateTime, - editable: false, - }, - ]; - - const dataColumns = enableTimeSeriesColumns - ? generateTimeSeriesColumns({ count: columnCount }) - : []; - - const aggregateColumns = enableAggregateColumns - ? [ - { - id: "min", - title: "Min", - type: ColumnTypes.Aggregate, - width: 50, - editable: false, - }, - { - id: "max", - title: "Max", - type: ColumnTypes.Aggregate, - width: 50, - editable: false, - }, - { - id: "avg", - title: "Avg", - type: ColumnTypes.Aggregate, - width: 50, - editable: false, - }, - ] - : []; - - return [...baseColumns, ...dataColumns, ...aggregateColumns]; - }, [ - currentState.data, - enableTimeSeriesColumns, - columnCount, - enableAggregateColumns, - ]); - - // Apply updates to the matrix data and store them in the pending updates list - const applyUpdates = useCallback( - (updates: GridUpdate[]) => { - const updatedData = currentState.data.map((col) => [...col]); - - const newUpdates: MatrixUpdateDTO[] = updates - .map(({ coordinates: [row, col], value }) => { - if (value.kind === GridCellKind.Number && value.data) { - updatedData[col][row] = value.data; - - return { - coordinates: [[col, row]], - operation: { - operation: Operator.EQ, - value: value.data, - }, - }; - } - - return null; - }) - .filter( - (update): update is NonNullable => update !== null, - ); - - setState({ - data: updatedData, - pendingUpdates: [...currentState.pendingUpdates, ...newUpdates], - }); - }, - [currentState, setState], - ); - - const handleCellEdit = function (update: GridUpdate) { - applyUpdates([update]); - }; - - const handleMultipleCellsEdit = function (updates: GridUpdate[]) { - applyUpdates(updates); - }; - - const handleImport = async (file: File) => { - try { - await importFile({ file, studyId, path: url }); - await fetchMatrix(); - } catch (e) { - enqueueErrorSnackbar(t("matrix.error.import"), e as Error); - } - }; - - const handleSaveUpdates = async () => { - if (!currentState.pendingUpdates.length) { - return; - } - - setIsSubmitting(true); - try { - await updateMatrix(studyId, url, currentState.pendingUpdates); - setState({ data: currentState.data, pendingUpdates: [] }); - enqueueSnackbar(t("matrix.success.matrixUpdate"), { - variant: "success", - }); - } catch (error) { - setError(new Error(t("matrix.error.matrixUpdate"))); - enqueueErrorSnackbar(t("matrix.error.matrixUpdate"), error as AxiosError); - } finally { - setIsSubmitting(false); - } - }; - - const handleUndo = useCallback(() => { - undo(); - }, [undo]); - - const handleRedo = useCallback(() => { - redo(); - }, [redo]); - - const canUndoChanges = useMemo( - () => currentState.pendingUpdates.length > 0, - [currentState.pendingUpdates], - ); - - return { - data: currentState.data, - error, - isLoading, - isSubmitting, - columns, - dateTime, - handleCellEdit, - handleMultipleCellsEdit, - handleImport, - handleSaveUpdates, - pendingUpdatesCount: currentState.pendingUpdates.length, - undo: handleUndo, - redo: handleRedo, - canUndo: canUndoChanges, - canRedo, - }; -} diff --git a/webapp/src/components/common/MatrixGrid/utils.test.ts b/webapp/src/components/common/MatrixGrid/utils.test.ts deleted file mode 100644 index afe4e21c5c..0000000000 --- a/webapp/src/components/common/MatrixGrid/utils.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { - MatrixIndex, - StudyOutputDownloadLevelDTO, -} from "../../../common/types"; -import { ColumnTypes } from "./types"; -import { generateDateTime, generateTimeSeriesColumns } from "./utils"; - -describe("generateDateTime", () => { - test("generates correct number of dates", () => { - const metadata: MatrixIndex = { - start_date: "2023-01-01T00:00:00Z", - steps: 5, - first_week_size: 7, - level: StudyOutputDownloadLevelDTO.DAILY, - }; - const result = generateDateTime(metadata); - expect(result).toHaveLength(5); - }); - - test.each([ - { - level: "hourly", - start: "2023-01-01T00:00:00Z", - expected: [ - "2023-01-01T00:00:00.000Z", - "2023-01-01T01:00:00.000Z", - "2023-01-01T02:00:00.000Z", - ], - }, - { - level: "daily", - start: "2023-01-01T00:00:00Z", - expected: [ - "2023-01-01T00:00:00.000Z", - "2023-01-02T00:00:00.000Z", - "2023-01-03T00:00:00.000Z", - ], - }, - { - level: "weekly", - start: "2023-01-01T00:00:00Z", - expected: [ - "2023-01-01T00:00:00.000Z", - "2023-01-08T00:00:00.000Z", - "2023-01-15T00:00:00.000Z", - ], - }, - { - level: "monthly", - start: "2023-01-15T00:00:00Z", - expected: [ - "2023-01-15T00:00:00.000Z", - "2023-02-15T00:00:00.000Z", - "2023-03-15T00:00:00.000Z", - ], - }, - { - level: "annual", - start: "2020-02-29T00:00:00Z", - expected: ["2020-02-29T00:00:00.000Z", "2021-02-28T00:00:00.000Z"], - }, - ] as const)( - "generates correct dates for $level level", - ({ level, start, expected }) => { - const metadata: MatrixIndex = { - start_date: start, - steps: expected.length, - first_week_size: 7, - level: level as MatrixIndex["level"], - }; - - const result = generateDateTime(metadata); - - expect(result).toEqual(expected); - }, - ); - - test("handles edge cases", () => { - const metadata: MatrixIndex = { - start_date: "2023-12-31T23:59:59Z", - steps: 2, - first_week_size: 7, - level: StudyOutputDownloadLevelDTO.HOURLY, - }; - const result = generateDateTime(metadata); - expect(result).toEqual([ - "2023-12-31T23:59:59.000Z", - "2024-01-01T00:59:59.000Z", - ]); - }); -}); - -describe("generateTimeSeriesColumns", () => { - test("generates correct number of columns", () => { - const result = generateTimeSeriesColumns({ count: 5 }); - expect(result).toHaveLength(5); - }); - - test("generates columns with default options", () => { - const result = generateTimeSeriesColumns({ count: 3 }); - expect(result).toEqual([ - { - id: "data1", - title: "TS 1", - type: ColumnTypes.Number, - style: "normal", - width: 50, - editable: true, - }, - { - id: "data2", - title: "TS 2", - type: ColumnTypes.Number, - style: "normal", - width: 50, - editable: true, - }, - { - id: "data3", - title: "TS 3", - type: ColumnTypes.Number, - style: "normal", - width: 50, - editable: true, - }, - ]); - }); - - test("generates columns with custom options", () => { - const result = generateTimeSeriesColumns({ - count: 2, - startIndex: 10, - prefix: "Data", - width: 80, - editable: false, - }); - expect(result).toEqual([ - { - id: "data10", - title: "Data 10", - type: ColumnTypes.Number, - style: "normal", - width: 80, - editable: false, - }, - { - id: "data11", - title: "Data 11", - type: ColumnTypes.Number, - style: "normal", - width: 80, - editable: false, - }, - ]); - }); - - test("handles zero count", () => { - const result = generateTimeSeriesColumns({ count: 0 }); - expect(result).toEqual([]); - }); - - test("handles large count", () => { - const result = generateTimeSeriesColumns({ count: 1000 }); - expect(result).toHaveLength(1000); - expect(result[999].id).toBe("data1000"); - expect(result[999].title).toBe("TS 1000"); - }); - - test("maintains consistent type and style", () => { - const result = generateTimeSeriesColumns({ count: 1000 }); - result.forEach((column) => { - expect(column.type).toBe(ColumnTypes.Number); - expect(column.style).toBe("normal"); - }); - }); -}); diff --git a/webapp/src/components/common/MatrixGrid/utils.ts b/webapp/src/components/common/MatrixGrid/utils.ts deleted file mode 100644 index ed3e1094ee..0000000000 --- a/webapp/src/components/common/MatrixGrid/utils.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import moment from "moment"; -import { - DateIncrementStrategy, - EnhancedGridColumn, - ColumnTypes, -} from "./types"; -import { getCurrentLanguage } from "../../../utils/i18nUtils"; -import { Theme } from "@glideapps/glide-data-grid"; -import { MatrixIndex } from "../../../common/types"; - -export const darkTheme: Theme = { - accentColor: "rgba(255, 184, 0, 0.9)", - accentLight: "rgba(255, 184, 0, 0.2)", - accentFg: "#FFFFFF", - textDark: "#FFFFFF", - textMedium: "#C1C3D9", - textLight: "#A1A5B9", - textBubble: "#FFFFFF", - bgIconHeader: "#1E1F2E", - fgIconHeader: "#FFFFFF", - textHeader: "#FFFFFF", - textGroupHeader: "#C1C3D9", - bgCell: "#262737", // main background color - bgCellMedium: "#2E2F42", - bgHeader: "#1E1F2E", - bgHeaderHasFocus: "#2E2F42", - bgHeaderHovered: "#333447", - bgBubble: "#333447", - bgBubbleSelected: "#3C3E57", - bgSearchResult: "#6366F133", - borderColor: "rgba(255, 255, 255, 0.12)", - drilldownBorder: "rgba(255, 255, 255, 0.35)", - linkColor: "#818CF8", - headerFontStyle: "bold 11px", - baseFontStyle: "13px", - fontFamily: "Inter, sans-serif", - editorFontSize: "13px", - lineHeight: 1.5, - textHeaderSelected: "#FFFFFF", - cellHorizontalPadding: 8, - cellVerticalPadding: 5, - headerIconSize: 16, - markerFontStyle: "normal", -}; - -export const readOnlyDarkTheme: Partial = { - bgCell: "#1A1C2A", - bgCellMedium: "#22243A", - textDark: "#A0A0A0", - textMedium: "#808080", - textLight: "#606060", - accentColor: "#4A4C66", - accentLight: "rgba(74, 76, 102, 0.2)", - borderColor: "rgba(255, 255, 255, 0.08)", - drilldownBorder: "rgba(255, 255, 255, 0.2)", -}; - -const dateIncrementStrategies: Record< - MatrixIndex["level"], - DateIncrementStrategy -> = { - hourly: (date, step) => date.clone().add(step, "hours"), - daily: (date, step) => date.clone().add(step, "days"), - weekly: (date, step) => date.clone().add(step, "weeks"), - monthly: (date, step) => date.clone().add(step, "months"), - annual: (date, step) => date.clone().add(step, "years"), -}; - -const dateTimeFormatOptions: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - timeZone: "UTC", // Ensures consistent UTC-based time representation -}; - -//////////////////////////////////////////////////////////////// -// Functions -//////////////////////////////////////////////////////////////// - -/** - * Formats a date and time string using predefined locale and format options. - * - * This function takes a date/time string, creates a Date object from it, - * and then formats it according to the specified options. The formatting - * is done using the French locale as the primary choice, falling back to - * English if French is not available. - * - * Important: This function will always return the time in UTC, regardless - * of the system's local time zone. This behavior is controlled by the - * 'timeZone' option in dateTimeFormatOptions. - * - * @param dateTime - The date/time string to format. This should be an ISO 8601 string (e.g., "2024-01-01T00:00:00Z"). - * @returns The formatted date/time string in the format specified by dateTimeFormatOptions, always in UTC. - * - * @example returns "1 janv. 2024, 00:00" (French locale) - * formatDateTime("2024-01-01T00:00:00Z") - * - * @example returns "Jan 1, 2024, 12:00 AM" (English locale) - * formatDateTime("2024-01-01T00:00:00Z") - */ -export function formatDateTime(dateTime: string): string { - const date = moment.utc(dateTime); - const currentLocale = getCurrentLanguage(); - const locales = [currentLocale, "en-US"]; - - return date.toDate().toLocaleString(locales, dateTimeFormatOptions); -} - -/** - * Generates an array of date-time strings based on the provided time metadata. - * - * This function creates a series of date-time strings, starting from the given start date - * and incrementing based on the specified level (hourly, daily, weekly, monthly, or yearly). - * It uses the Moment.js library for date manipulation and the ISO 8601 format for date-time strings. - * - * @param timeMetadata - The time metadata object. - * @param timeMetadata.start_date - The starting date-time in ISO 8601 format (e.g., "2023-01-01T00:00:00Z"). - * @param timeMetadata.steps - The number of date-time strings to generate. - * @param timeMetadata.level - The increment level for date-time generation. - * - * @returns An array of ISO 8601 formatted date-time strings. - * - * @example - * const result = generateDateTime({ - * start_date: "2023-01-01T00:00:00Z", - * steps: 3, - * level: "daily" - * }); - * - * Returns: [ - * "2023-01-01T00:00:00.000Z", - * "2023-01-02T00:00:00.000Z", - * "2023-01-03T00:00:00.000Z" - * ] - * - * @see {@link MatrixIndex} for the structure of the timeMetadata object. - * @see {@link DateIncrementStrategy} for the date increment strategy type. - */ -export function generateDateTime({ - // eslint-disable-next-line camelcase - start_date, - steps, - level, -}: MatrixIndex): string[] { - const startDate = moment.utc(start_date, "YYYY-MM-DD HH:mm:ss"); - const incrementStrategy = dateIncrementStrategies[level]; - - return Array.from({ length: steps }, (_, i) => - incrementStrategy(startDate, i).toISOString(), - ); -} - -/** - * Generates an array of EnhancedGridColumn objects representing time series data columns. - * - * @param options - The options for generating time series columns. - * @param options.count - The number of time series columns to generate. - * @param [options.startIndex=1] - The starting index for the time series columns (default is 1). - * @param [options.prefix="TS"] - The prefix to use for the column titles (default is "TS"). - * @param [options.width=50] - The width of each column (default is 50). - * @param [options.editable=true] - Whether the columns should be editable (default is true). - * @param [options.style="normal"] - The style of the columns (default is "normal"). - * @returns An array of EnhancedGridColumn objects representing time series data columns. - * - * @example Usage within a column definition array - * const columns = [ - * { id: "rowHeaders", title: "", type: ColumnTypes.Text, ... }, - * { id: "date", title: "Date", type: ColumnTypes.DateTime, ... }, - * ...generateTimeSeriesColumns({ count: 60 }), - * { id: "min", title: "Min", type: ColumnTypes.Aggregate, ... }, - * { id: "max", title: "Max", type: ColumnTypes.Aggregate, ... }, - * { id: "avg", title: "Avg", type: ColumnTypes.Aggregate, ... } - * ]; - */ -export function generateTimeSeriesColumns({ - count, - startIndex = 1, - prefix = "TS", - width = 50, - editable = true, - style = "normal", -}: { - count: number; - startIndex?: number; - prefix?: string; - width?: number; - editable?: boolean; - style?: "normal" | "highlight"; -}): EnhancedGridColumn[] { - return Array.from({ length: count }, (_, index) => ({ - id: `data${startIndex + index}`, - title: `${prefix} ${startIndex + index}`, - type: ColumnTypes.Number, - style: style, - width: width, - editable: editable, - })); -} diff --git a/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx b/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx deleted file mode 100644 index 4ca1978912..0000000000 --- a/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { Box, Button, Divider, Typography } from "@mui/material"; -import { AxiosError } from "axios"; -import { useSnackbar } from "notistack"; -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { MatrixInfoDTO, StudyMetadata } from "../../../common/types"; -import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; -import usePromiseWithSnackbarError from "../../../hooks/usePromiseWithSnackbarError"; -import { getMatrix, getMatrixList } from "../../../services/api/matrix"; -import { appendCommands } from "../../../services/api/variant"; -import DataPropsView from "../../App/Data/DataPropsView"; -import { CommandEnum } from "../../App/Singlestudy/Commands/Edition/commandTypes"; -import ButtonBack from "../ButtonBack"; -import BasicDialog, { BasicDialogProps } from "../dialogs/BasicDialog"; -import EditableMatrix from "../EditableMatrix"; -import FileTable from "../FileTable"; -import UsePromiseCond from "../utils/UsePromiseCond"; -import SplitView from "../SplitView"; - -interface Props { - studyId: StudyMetadata["id"]; - path: string; - open: BasicDialogProps["open"]; - onClose: VoidFunction; -} - -function MatrixAssignDialog(props: Props) { - const [t] = useTranslation(); - const { studyId, path, open, onClose } = props; - const [selectedItem, setSelectedItem] = useState(""); - const [currentMatrix, setCurrentMatrix] = useState(); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const { enqueueSnackbar } = useSnackbar(); - - const resList = usePromiseWithSnackbarError(() => getMatrixList(), { - errorMessage: t("data.error.matrixList"), - }); - - const resMatrix = usePromiseWithSnackbarError( - async () => { - if (currentMatrix) { - const res = await getMatrix(currentMatrix.id); - return res; - } - }, - { - errorMessage: t("data.error.matrix"), - deps: [currentMatrix], - }, - ); - - useEffect(() => { - setCurrentMatrix(undefined); - }, [selectedItem]); - - const dataSet = resList.data?.find((item) => item.id === selectedItem); - const matrices = dataSet?.matrices; - const matrixName = `${t("global.matrices")} - ${dataSet?.name}`; - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleMatrixClick = async (id: string) => { - if (matrices) { - setCurrentMatrix({ - id, - name: matrices.find((o) => o.id === id)?.name || "", - }); - } - }; - - const handleAssignation = async (matrixId: string) => { - try { - await appendCommands(studyId, [ - { - action: CommandEnum.REPLACE_MATRIX, - args: { - target: path, - matrix: matrixId, - }, - }, - ]); - enqueueSnackbar(t("data.success.matrixAssignation"), { - variant: "success", - }); - onClose(); - } catch (e) { - enqueueErrorSnackbar(t("data.error.matrixAssignation"), e as AxiosError); - } - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - {t("global.close")}} - contentProps={{ - sx: { width: "1200px", height: "700px" }, - }} - > - - dataset && ( - - - - {selectedItem && !currentMatrix && ( - - - {matrixName} - - - } - content={matrices || []} - onRead={handleMatrixClick} - onAssign={handleAssignation} - /> - )} - - matrix && ( - <> - - - {matrixName} - - - - setCurrentMatrix(undefined)} - /> - - - - - - ) - } - /> - - - ) - } - /> - - ); -} - -export default MatrixAssignDialog; diff --git a/webapp/src/components/common/MatrixInput/index.tsx b/webapp/src/components/common/MatrixInput/index.tsx deleted file mode 100644 index 62219a43d8..0000000000 --- a/webapp/src/components/common/MatrixInput/index.tsx +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { useTranslation } from "react-i18next"; -import { useSnackbar } from "notistack"; -import { useState } from "react"; -import { AxiosError } from "axios"; -import { Typography, Box, Divider } from "@mui/material"; -import FileDownloadIcon from "@mui/icons-material/FileDownload"; -import GridOffIcon from "@mui/icons-material/GridOff"; -import { - MatrixEditDTO, - MatrixStats, - StudyMetadata, -} from "../../../common/types"; -import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; -import { getStudyData } from "../../../services/api/study"; -import usePromiseWithSnackbarError from "../../../hooks/usePromiseWithSnackbarError"; -import { editMatrix, getStudyMatrixIndex } from "../../../services/api/matrix"; -import { Root, Content, Header } from "./style"; -import SimpleLoader from "../loaders/SimpleLoader"; -import EmptyView from "../page/SimpleContent"; -import EditableMatrix from "../EditableMatrix"; -import ImportDialog from "../dialogs/ImportDialog"; -import MatrixAssignDialog from "./MatrixAssignDialog"; -import { fetchMatrixFn } from "../../App/Singlestudy/explore/Modelization/Areas/Hydro/utils"; -import SplitButton from "../buttons/SplitButton"; -import DownloadMatrixButton from "../buttons/DownloadMatrixButton.tsx"; -import { importFile } from "../../../services/api/studies/raw/index.ts"; - -interface Props { - study: StudyMetadata | StudyMetadata["id"]; - url: string; - columnsNames?: string[] | readonly string[]; - rowNames?: string[]; - title?: string; - computStats: MatrixStats; - fetchFn?: fetchMatrixFn; - disableEdit?: boolean; - disableImport?: boolean; - enablePercentDisplay?: boolean; -} - -function MatrixInput({ - study, - url, - columnsNames, - rowNames: initialRowNames, - title, - computStats, - fetchFn, - disableEdit = false, - disableImport = false, - enablePercentDisplay, -}: Props) { - const { enqueueSnackbar } = useSnackbar(); - const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); - const [t] = useTranslation(); - const [openImportDialog, setOpenImportDialog] = useState(false); - const [openMatrixAsignDialog, setOpenMatrixAsignDialog] = useState(false); - const studyId = typeof study === "string" ? study : study.id; - - const { - data: matrixData, - isLoading, - reload: reloadMatrix, - } = usePromiseWithSnackbarError(fetchMatrixData, { - errorMessage: t("data.error.matrix"), - deps: [studyId, url, fetchFn], - }); - - const { data: matrixIndex } = usePromiseWithSnackbarError( - async () => { - if (fetchFn) { - return matrixData?.index; - } - return getStudyMatrixIndex(studyId, url); - }, - { - errorMessage: t("matrix.error.failedToretrieveIndex"), - deps: [study, url, fetchFn, matrixData], - }, - ); - - /** - * If fetchFn is provided, custom row names (area names) are used from the matrixData's index property. - * Otherwise, default row numbers and timestamps are displayed using initialRowNames. - */ - const rowNames = fetchFn ? matrixIndex : initialRowNames; - const columnsLength = matrixData?.columns?.length ?? 0; - - //////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////// - - async function fetchMatrixData() { - const res = fetchFn - ? await fetchFn(studyId) - : await getStudyData(studyId, url); - if (typeof res === "string") { - const fixed = res - .replace(/NaN/g, '"NaN"') - .replace(/Infinity/g, '"Infinity"'); - return JSON.parse(fixed); - } - return res; - } - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleUpdate = async (change: MatrixEditDTO[], source: string) => { - if (source !== "loadData" && source !== "updateData") { - try { - if (change.length > 0) { - const sanitizedUrl = url.startsWith("/") ? url.substring(1) : url; - await editMatrix(studyId, sanitizedUrl, change); - enqueueSnackbar(t("matrix.success.matrixUpdate"), { - variant: "success", - }); - } - } catch (e) { - enqueueErrorSnackbar(t("matrix.error.matrixUpdate"), e as AxiosError); - } - } - }; - - const handleImport = async (file: File) => { - await importFile({ file, studyId, path: url }); - reloadMatrix(); - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - - -
- - {title || t("xpansion.timeSeries")} - - - {!disableImport && ( - { - if (index === 0) { - setOpenImportDialog(true); - } else { - setOpenMatrixAsignDialog(true); - } - }} - size="small" - ButtonProps={{ - startIcon: , - }} - > - {t("global.import")} - - )} - - -
- - {isLoading && } - {!isLoading && columnsLength >= 1 && matrixIndex ? ( - - ) : ( - !isLoading && ( - - ) - )} -
- {openImportDialog && ( - setOpenImportDialog(false)} - onImport={handleImport} - accept={{ "text/*": [".csv", ".tsv", ".txt"] }} - /> - )} - {openMatrixAsignDialog && ( - { - setOpenMatrixAsignDialog(false); - reloadMatrix(); - }} - /> - )} -
- ); -} - -export default MatrixInput; diff --git a/webapp/src/components/common/MatrixInput/style.ts b/webapp/src/components/common/MatrixInput/style.ts deleted file mode 100644 index 148f1b191d..0000000000 --- a/webapp/src/components/common/MatrixInput/style.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { styled, Box, Button } from "@mui/material"; - -export const Root = styled(Box)(({ theme }) => ({ - flex: 1, - height: "100%", - width: "100%", - display: "flex", - flexFlow: "column nowrap", - justifyContent: "flex-start", - alignItems: "center", -})); - -export const Header = styled(Box)(({ theme }) => ({ - width: "100%", - display: "flex", - flexFlow: "row wrap", - gap: theme.spacing(1), - justifyContent: "space-between", - alignItems: "flex-end", -})); - -export const Content = styled(Box)(({ theme }) => ({ - boxSizing: "border-box", - flex: 1, - width: "100%", - display: "flex", - flexFlow: "column nowrap", - justifyContent: "flex-start", - alignItems: "flex-start", - overflow: "auto", - position: "relative", -})); - -export const StyledButton = styled(Button)(({ theme }) => ({ - backgroundColor: "rgba(180, 180, 180, 0.09)", - color: "white", - borderRight: "none !important", - "&:hover": { - color: "white", - backgroundColor: theme.palette.secondary.main, - }, - "&:disabled": { - backgroundColor: theme.palette.secondary.dark, - color: "white !important", - }, -})); diff --git a/webapp/src/services/api/matrix.ts b/webapp/src/services/api/matrix.ts index e7d6bee7a5..0f026c44a6 100644 --- a/webapp/src/services/api/matrix.ts +++ b/webapp/src/services/api/matrix.ts @@ -24,7 +24,7 @@ import { } from "../../common/types"; import { FileDownloadTask } from "./downloads"; import { getConfig } from "../config"; -import { MatrixUpdateDTO } from "../../components/common/MatrixGrid/types"; +import { MatrixUpdateDTO } from "../../components/common/Matrix/shared/types"; export const getMatrixList = async ( name = "", diff --git a/webapp/src/utils/validation/array.test.ts b/webapp/src/utils/validation/__tests__/array.test.ts similarity index 98% rename from webapp/src/utils/validation/array.test.ts rename to webapp/src/utils/validation/__tests__/array.test.ts index 406c853548..d05086e7a8 100644 --- a/webapp/src/utils/validation/array.test.ts +++ b/webapp/src/utils/validation/__tests__/array.test.ts @@ -12,7 +12,7 @@ * This file is part of the Antares project. */ -import { validateArray } from "./array"; +import { validateArray } from "../array"; vi.mock("i18next", () => ({ t: vi.fn((key) => key), diff --git a/webapp/src/utils/validation/number.test.ts b/webapp/src/utils/validation/__tests__/number.test.ts similarity index 98% rename from webapp/src/utils/validation/number.test.ts rename to webapp/src/utils/validation/__tests__/number.test.ts index eb19efcafe..a0377a921f 100644 --- a/webapp/src/utils/validation/number.test.ts +++ b/webapp/src/utils/validation/__tests__/number.test.ts @@ -12,7 +12,7 @@ * This file is part of the Antares project. */ -import { validateNumber } from "./number"; +import { validateNumber } from "../number"; // Mock i18next vi.mock("i18next", () => ({ diff --git a/webapp/src/utils/validation/string.test.ts b/webapp/src/utils/validation/__tests__/string.test.ts similarity index 98% rename from webapp/src/utils/validation/string.test.ts rename to webapp/src/utils/validation/__tests__/string.test.ts index dfc164cc36..c6aede434d 100644 --- a/webapp/src/utils/validation/string.test.ts +++ b/webapp/src/utils/validation/__tests__/string.test.ts @@ -12,7 +12,7 @@ * This file is part of the Antares project. */ -import { validatePassword, validateString } from "./string"; +import { validatePassword, validateString } from "../string"; vi.mock("i18next", () => ({ t: vi.fn((key, options) => { diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 73b06c2ba0..ba4fe1e0f4 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -30,6 +30,13 @@ export default defineConfig(({ mode }) => { // Not working in dev without `JSON.stringify` __BUILD_TIMESTAMP__: JSON.stringify(Date.now()), }, + build: { + // Exclude test files and directories from production builds + // This improves build performance and reduces bundle size + rollupOptions: { + external: ["**/__tests__/**", "**/*.test.ts", "**/*.test.tsx"], + }, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), // Relative imports from the src directory