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