diff --git a/webapp/.npmrc b/webapp/.npmrc
index 4fd021952d..519835e9e2 100644
--- a/webapp/.npmrc
+++ b/webapp/.npmrc
@@ -1 +1,2 @@
-engine-strict=true
\ No newline at end of file
+engine-strict=true
+save-prefix= # prevent the caret (^) symbol use when installing new dependencies
\ No newline at end of file
diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json
index f839e170eb..05c1685d59 100644
--- a/webapp/public/locales/en/main.json
+++ b/webapp/public/locales/en/main.json
@@ -88,6 +88,7 @@
"global.error.delete": "Deletion failed",
"global.area.add": "Add an area",
"global.add": "Add",
+ "global.timeSeries": "Time Series",
"login.error": "Failed to authenticate",
"tasks.title": "Tasks",
"api.title": "API",
diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json
index 2b8bcd3cbf..45b1bf3b6c 100644
--- a/webapp/public/locales/fr/main.json
+++ b/webapp/public/locales/fr/main.json
@@ -88,6 +88,7 @@
"global.error.delete": "La suppression a échoué",
"global.area.add": "Ajouter une zone",
"global.add": "Ajouter",
+ "global.timeSeries": "Séries temporelles",
"login.error": "Échec de l'authentification",
"tasks.title": "Tâches",
"api.title": "API",
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 030a09e0ee..0271e3b5a8 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx
@@ -1,12 +1,8 @@
-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/MatrixGrid/Matrix";
function Load() {
- const { study } = useOutletContext<{ study: StudyMetadata }>();
const currentArea = useAppSelector(getCurrentAreaId);
const url = `input/load/series/load_${currentArea}`;
@@ -14,11 +10,7 @@ function Load() {
// JSX
////////////////////////////////////////////////////////////////
- return (
-
-
-
- );
+ return ;
}
export default Load;
diff --git a/webapp/src/components/common/MatrixGrid/Matrix.tsx b/webapp/src/components/common/MatrixGrid/Matrix.tsx
new file mode 100644
index 0000000000..97973650d1
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/Matrix.tsx
@@ -0,0 +1,106 @@
+import { Divider, Skeleton } from "@mui/material";
+import MatrixGrid from ".";
+import { useMatrix } from "./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 EmptyView from "../page/SimpleContent";
+
+interface MatrixProps {
+ url: string;
+ title?: string;
+ enableTimeSeriesColumns?: boolean;
+ enableAggregateColumns?: boolean;
+}
+
+function Matrix({
+ url,
+ title = "global.timeSeries",
+ enableTimeSeriesColumns = true,
+ enableAggregateColumns = false,
+}: MatrixProps) {
+ const { t } = useTranslation();
+ const { study } = useOutletContext<{ study: StudyMetadata }>();
+ const [openImportDialog, setOpenImportDialog] = useState(false);
+
+ const {
+ data,
+ error,
+ isLoading,
+ isSubmitting,
+ columns,
+ dateTime,
+ handleCellEdit,
+ handleMultipleCellsEdit,
+ handleImport,
+ handleSaveUpdates,
+ pendingUpdatesCount,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ } = useMatrix(study.id, url, enableTimeSeriesColumns, enableAggregateColumns);
+
+ ////////////////////////////////////////////////////////////////
+ // JSX
+ ////////////////////////////////////////////////////////////////
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (!data || data.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+ {t(title)}
+ setOpenImportDialog(true)}
+ onSave={handleSaveUpdates}
+ studyId={study.id}
+ path={url}
+ disabled={data.length === 0}
+ pendingUpdatesCount={pendingUpdatesCount}
+ isSubmitting={isSubmitting}
+ undo={undo}
+ redo={redo}
+ canUndo={canUndo}
+ canRedo={canRedo}
+ />
+
+
+
+ {openImportDialog && (
+ setOpenImportDialog(false)}
+ onImport={handleImport}
+ accept={{ "text/*": [".csv", ".tsv", ".txt"] }}
+ />
+ )}
+
+ );
+}
+
+export default Matrix;
diff --git a/webapp/src/components/common/MatrixGrid/MatrixActions.tsx b/webapp/src/components/common/MatrixGrid/MatrixActions.tsx
new file mode 100644
index 0000000000..4d135c6da2
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/MatrixActions.tsx
@@ -0,0 +1,101 @@
+import { Box, Divider, IconButton, Tooltip } from "@mui/material";
+import SplitButton from "../buttons/SplitButton";
+import DownloadMatrixButton from "../DownloadMatrixButton";
+import FileDownload from "@mui/icons-material/FileDownload";
+import { useTranslation } from "react-i18next";
+import { LoadingButton } from "@mui/lab";
+import Save from "@mui/icons-material/Save";
+import { Undo, Redo } from "@mui/icons-material";
+
+interface MatrixActionsProps {
+ onImport: VoidFunction;
+ onSave: VoidFunction;
+ studyId: string;
+ path: string;
+ disabled: boolean;
+ pendingUpdatesCount: number;
+ isSubmitting: boolean;
+ undo: VoidFunction;
+ redo: VoidFunction;
+ canUndo: boolean;
+ canRedo: boolean;
+}
+
+function MatrixActions({
+ onImport,
+ onSave,
+ studyId,
+ path,
+ disabled,
+ pendingUpdatesCount,
+ isSubmitting,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+}: MatrixActionsProps) {
+ const { t } = useTranslation();
+
+ ////////////////////////////////////////////////////////////////
+ // JSX
+ ////////////////////////////////////////////////////////////////
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ variant="contained"
+ size="small"
+ disabled={pendingUpdatesCount === 0}
+ >
+ ({pendingUpdatesCount})
+
+
+ ,
+ }}
+ disabled={isSubmitting}
+ >
+ {t("global.import")}
+
+
+
+ );
+}
+
+export default MatrixActions;
diff --git a/webapp/src/components/common/MatrixGrid/index.test.tsx b/webapp/src/components/common/MatrixGrid/index.test.tsx
new file mode 100644
index 0000000000..e7189ad93c
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/index.test.tsx
@@ -0,0 +1,220 @@
+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/index.tsx b/webapp/src/components/common/MatrixGrid/index.tsx
new file mode 100644
index 0000000000..70acbabd1e
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/index.tsx
@@ -0,0 +1,151 @@
+import "@glideapps/glide-data-grid/dist/index.css";
+import DataEditor, {
+ CompactSelection,
+ EditableGridCell,
+ EditListItem,
+ GridCellKind,
+ GridSelection,
+ Item,
+} from "@glideapps/glide-data-grid";
+import { useGridCellContent } from "./useGridCellContent";
+import { useMemo, useState } from "react";
+import { EnhancedGridColumn, GridUpdate } from "./types";
+import { darkTheme, readOnlyDarkTheme } from "./utils";
+import { useColumnMapping } from "./useColumnMapping";
+
+export interface MatrixGridProps {
+ data: number[][];
+ rows: number;
+ columns: EnhancedGridColumn[];
+ dateTime?: string[];
+ aggregates?: Record;
+ rowHeaders?: string[];
+ width?: string;
+ height?: string;
+ onCellEdit?: (update: GridUpdate) => void;
+ onMultipleCellsEdit?: (updates: GridUpdate[]) => void;
+ readOnly?: boolean;
+}
+
+function MatrixGrid({
+ data,
+ rows,
+ columns,
+ dateTime,
+ aggregates,
+ rowHeaders,
+ width = "100%",
+ height = "100%",
+ onCellEdit,
+ onMultipleCellsEdit,
+ readOnly = false,
+}: MatrixGridProps) {
+ const [selection, setSelection] = useState({
+ columns: CompactSelection.empty(),
+ rows: CompactSelection.empty(),
+ });
+
+ const { gridToData } = useColumnMapping(columns);
+
+ const theme = useMemo(() => {
+ if (readOnly) {
+ return {
+ ...darkTheme,
+ ...readOnlyDarkTheme,
+ };
+ }
+
+ return darkTheme;
+ }, [readOnly]);
+
+ const getCellContent = useGridCellContent(
+ data,
+ columns,
+ gridToData,
+ dateTime,
+ aggregates,
+ rowHeaders,
+ readOnly,
+ );
+
+ ////////////////////////////////////////////////////////////////
+ // Event Handlers
+ ////////////////////////////////////////////////////////////////
+
+ const handleCellEdited = (coordinates: Item, value: EditableGridCell) => {
+ if (value.kind !== GridCellKind.Number) {
+ // Invalid numeric value
+ return;
+ }
+
+ const dataCoordinates = gridToData(coordinates);
+
+ if (dataCoordinates && onCellEdit) {
+ onCellEdit({ coordinates: dataCoordinates, value });
+ }
+ };
+
+ const handleCellsEdited = (newValues: readonly EditListItem[]) => {
+ const updates = newValues
+ .map((edit): GridUpdate | null => {
+ const dataCoordinates = gridToData(edit.location);
+
+ if (edit.value.kind !== GridCellKind.Number || !dataCoordinates) {
+ return null;
+ }
+
+ return {
+ coordinates: dataCoordinates,
+ value: edit.value,
+ };
+ })
+ .filter((update): update is GridUpdate => update !== null);
+
+ if (updates.length === 0) {
+ // No valid updates
+ return;
+ }
+
+ if (onCellEdit && updates.length === 1) {
+ // If only one cell is edited,`onCellEdit` is called
+ // we don't need to call `onMultipleCellsEdit`
+ return;
+ }
+
+ if (onMultipleCellsEdit) {
+ onMultipleCellsEdit(updates);
+ }
+
+ // Return true to prevent calling `onCellEdit`
+ // for each cell after`onMultipleCellsEdit` is called
+ return true;
+ };
+
+ ////////////////////////////////////////////////////////////////
+ // JSX
+ ////////////////////////////////////////////////////////////////
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export default MatrixGrid;
diff --git a/webapp/src/components/common/MatrixGrid/style.ts b/webapp/src/components/common/MatrixGrid/style.ts
new file mode 100644
index 0000000000..2b0d3f76ff
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/style.ts
@@ -0,0 +1,25 @@
+import styled from "@emotion/styled";
+import { Box, Typography } from "@mui/material";
+
+export const MatrixContainer = styled(Box)(() => ({
+ width: "100%",
+ height: "100%",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ overflow: "hidden",
+}));
+
+export const MatrixHeader = styled(Box)(() => ({
+ width: "100%",
+ display: "flex",
+ flexFlow: "row wrap",
+ justifyContent: "space-between",
+ alignItems: "flex-end",
+}));
+
+export const MatrixTitle = styled(Typography)(() => ({
+ fontSize: 20,
+ fontWeight: 400,
+ lineHeight: 1,
+}));
diff --git a/webapp/src/components/common/MatrixGrid/types.ts b/webapp/src/components/common/MatrixGrid/types.ts
new file mode 100644
index 0000000000..2183801db5
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/types.ts
@@ -0,0 +1,71 @@
+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
new file mode 100644
index 0000000000..df476b482d
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts
@@ -0,0 +1,130 @@
+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/useColumnMapping.ts b/webapp/src/components/common/MatrixGrid/useColumnMapping.ts
new file mode 100644
index 0000000000..ab46b4db5e
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/useColumnMapping.ts
@@ -0,0 +1,55 @@
+import { useMemo } from "react";
+import { Item } from "@glideapps/glide-data-grid";
+import { EnhancedGridColumn, ColumnTypes } from "./types";
+
+/**
+ * A custom hook that provides coordinate mapping functions for a grid with mixed column types.
+ *
+ * @description
+ * This hook addresses a common issue in grid components that display both data and non-data columns:
+ * the mismatch between grid coordinates (visual position) and data coordinates (position in the data array).
+ *
+ * The problem arises when a grid includes non-data columns (e.g., row headers, date/time columns)
+ * alongside editable data columns. In such cases, the index of a column in the grid doesn't
+ * directly correspond to its index in the data array. This can lead to issues where:
+ * 1. The wrong data is displayed in cells.
+ * 2. Edits are applied to incorrect data points.
+ * 3. Non-editable columns are mistakenly treated as editable.
+ *
+ * This hook solves these issues by providing two mapping functions:
+ * - gridToData: Converts grid coordinates to data array coordinates.
+ * - dataToGrid: Converts data array coordinates to grid coordinates.
+ *
+ * By using these functions, components can ensure that they're always working with the correct
+ * coordinates, whether they're displaying data, handling edits, or managing selection.
+ *
+ * @param columns - An array of column definitions, including their types.
+ *
+ * @returns An object containing two functions:
+ * - gridToData: (gridCoord: Item) => Item | null
+ * Converts grid coordinates to data coordinates. Returns null for non-data columns.
+ * - dataToGrid: (dataCoord: Item) => Item
+ * Converts data coordinates to grid coordinates.
+ */
+export function useColumnMapping(columns: EnhancedGridColumn[]) {
+ return useMemo(() => {
+ const dataColumnIndices = columns.reduce((acc, col, index) => {
+ if (col.type === ColumnTypes.Number) {
+ acc.push(index);
+ }
+ return acc;
+ }, [] as number[]);
+
+ const gridToData = ([col, row]: Item): Item | null => {
+ const dataColIndex = dataColumnIndices.indexOf(col);
+ return dataColIndex !== -1 ? [dataColIndex, row] : null;
+ };
+
+ const dataToGrid = ([col, row]: Item): Item => [
+ dataColumnIndices[col],
+ row,
+ ];
+
+ return { gridToData, dataToGrid };
+ }, [columns]);
+}
diff --git a/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts b/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts
new file mode 100644
index 0000000000..ccb0736fb5
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts
@@ -0,0 +1,526 @@
+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/useGridCellContent.ts b/webapp/src/components/common/MatrixGrid/useGridCellContent.ts
new file mode 100644
index 0000000000..09f6d06451
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/useGridCellContent.ts
@@ -0,0 +1,171 @@
+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;
+
+/**
+ * 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]: (
+ row,
+ col,
+ column,
+ data,
+ dateTime,
+ aggregates,
+ rowHeaders,
+ ) => ({
+ kind: GridCellKind.Text,
+ data: "", // Custom row headers are not editable
+ displayData: rowHeaders?.[row] ?? "",
+ readonly: !column.editable,
+ allowOverlay: false,
+ }),
+ [ColumnTypes.DateTime]: (row, col, column, data, dateTime) => ({
+ kind: GridCellKind.Text,
+ data: "", // Date/time columns are not editable
+ displayData: formatDateTime(dateTime?.[row] ?? ""),
+ readonly: !column.editable,
+ allowOverlay: false,
+ }),
+ [ColumnTypes.Number]: (row, col, column, data) => {
+ const value = data?.[row]?.[col];
+
+ return {
+ kind: GridCellKind.Number,
+ data: value,
+ displayData: value?.toString(),
+ readonly: !column.editable,
+ allowOverlay: true,
+ };
+ },
+ [ColumnTypes.Aggregate]: (row, col, column, data, dateTime, aggregates) => {
+ const value = aggregates?.[column.id]?.[row];
+
+ return {
+ kind: GridCellKind.Number,
+ data: value,
+ displayData: value?.toString() ?? "",
+ readonly: !column.editable,
+ allowOverlay: false,
+ };
+ },
+};
+
+/**
+ * Custom hook to generate cell content for the DataEditor grid.
+ *
+ * This hook addresses the challenge of mapping different types of data (numbers, dates, text, aggregates)
+ * to the correct columns in a grid, regardless of the column arrangement. It's especially useful when
+ * the grid structure is dynamic and may include special columns like row headers or date/time columns
+ * that are not part of the main data array.
+ *
+ * The hook creates a flexible mapping system that:
+ * 1. Identifies the type of each column (number, text, date, aggregate).
+ * 2. For number columns, maps their position in the grid to their index in the data array.
+ * 3. Generates appropriate cell content based on the column type and data source.
+ *
+ * This approach allows for a dynamic grid structure where columns can be added, removed, or rearranged
+ * without needing to modify the underlying data access logic.
+ *
+ * @param data - The matrix of numerical data, where each sub-array represents a row.
+ * @param columns - Array of column configurations.
+ * @param gridToData - Optional function to map grid cell coordinates to data array indices.
+ * @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).
+ * @returns A function that accepts a grid item and returns the configured grid cell content.
+ */
+export function useGridCellContent(
+ data: number[][],
+ columns: EnhancedGridColumn[],
+ gridToData: (cell: Item) => Item | null,
+ dateTime?: string[],
+ aggregates?: Record,
+ rowHeaders?: string[],
+ readOnly = false,
+): (cell: Item) => GridCell {
+ const columnMap = useMemo(() => {
+ return new Map(columns.map((column, index) => [index, column]));
+ }, [columns]);
+
+ const getCellContent = useCallback(
+ (cell: Item): GridCell => {
+ const [col, row] = cell;
+ const column = columnMap.get(col);
+
+ if (!column) {
+ return {
+ kind: GridCellKind.Text,
+ data: "",
+ displayData: "N/A",
+ readonly: true,
+ allowOverlay: false,
+ };
+ }
+
+ const generator = cellContentGenerators[column.type];
+
+ if (!generator) {
+ console.error(`No generator found for column type: ${column.type}`);
+ return {
+ kind: GridCellKind.Text,
+ data: "",
+ displayData: "Error",
+ readonly: true,
+ allowOverlay: false,
+ };
+ }
+
+ // Adjust column index for Number type columns (data columns)
+ // This ensures we access the correct index in the data array,
+ // accounting for any non-data columns in the grid
+ let adjustedCol = col;
+
+ if (column.type === ColumnTypes.Number && gridToData) {
+ // Map grid cell to data array index
+ const dataCell = gridToData(cell);
+
+ if (dataCell) {
+ adjustedCol = dataCell[0];
+ }
+ }
+
+ const gridCell = generator(
+ row,
+ adjustedCol,
+ column,
+ data,
+ dateTime,
+ aggregates,
+ rowHeaders,
+ );
+
+ // Prevent updates for read-only grids
+ if (readOnly) {
+ return {
+ ...gridCell,
+ allowOverlay: false,
+ };
+ }
+
+ return gridCell;
+ },
+ [columnMap, gridToData, data, dateTime, aggregates, rowHeaders, readOnly],
+ );
+
+ return getCellContent;
+}
diff --git a/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx b/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx
new file mode 100644
index 0000000000..2fe6599aca
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx
@@ -0,0 +1,286 @@
+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 {
+ 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");
+
+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(apiStudy.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(apiStudy.importFile).toHaveBeenCalledWith(
+ mockFile,
+ mockStudyId,
+ 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
new file mode 100644
index 0000000000..ff16f05b09
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/useMatrix.ts
@@ -0,0 +1,223 @@
+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, importFile } 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";
+
+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, 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
new file mode 100644
index 0000000000..26e42ff6eb
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/utils.test.ts
@@ -0,0 +1,176 @@
+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
new file mode 100644
index 0000000000..8dfefd802f
--- /dev/null
+++ b/webapp/src/components/common/MatrixGrid/utils.ts
@@ -0,0 +1,200 @@
+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/services/api/matrix.ts b/webapp/src/services/api/matrix.ts
index 1eff05f7c9..54e028b2ef 100644
--- a/webapp/src/services/api/matrix.ts
+++ b/webapp/src/services/api/matrix.ts
@@ -10,6 +10,7 @@ import {
} from "../../common/types";
import { FileDownloadTask } from "./downloads";
import { getConfig } from "../config";
+import { MatrixUpdateDTO } from "../../components/common/MatrixGrid/types";
export const getMatrixList = async (
name = "",
@@ -91,16 +92,37 @@ export const deleteDataSet = async (id: string): Promise => {
return res.data;
};
+/**
+ * @deprecated Use `updateMatrix` instead.
+ *
+ * @param sid - The study ID.
+ * @param path - The path of the matrix.
+ * @param matrixEdit - The matrix edit data.
+ */
export const editMatrix = async (
sid: string,
path: string,
matrixEdit: MatrixEditDTO[],
): Promise => {
- const res = await client.put(
- `/v1/studies/${sid}/matrix?path=${encodeURIComponent(path)}`,
+ const sanitizedPath = path.startsWith("/") ? path.substring(1) : path;
+
+ await client.put(
+ `/v1/studies/${sid}/matrix?path=${encodeURIComponent(sanitizedPath)}`,
matrixEdit,
);
- return res.data;
+};
+
+export const updateMatrix = async (
+ studyId: string,
+ path: string,
+ updates: MatrixUpdateDTO[],
+): Promise => {
+ const sanitizedPath = path.startsWith("/") ? path.substring(1) : path;
+
+ await client.put(
+ `/v1/studies/${studyId}/matrix?path=${encodeURIComponent(sanitizedPath)}`,
+ updates,
+ );
};
export const getStudyMatrixIndex = async (
diff --git a/webapp/src/tests/mocks/mockGetBoundingClientRect.ts b/webapp/src/tests/mocks/mockGetBoundingClientRect.ts
new file mode 100644
index 0000000000..9297a4b1fe
--- /dev/null
+++ b/webapp/src/tests/mocks/mockGetBoundingClientRect.ts
@@ -0,0 +1,76 @@
+/**
+ * Mocks the `getBoundingClientRect` method of the Element prototype.
+ *
+ * This utility function overrides the default `getBoundingClientRect` method
+ * to return predetermined values. This is particularly useful in test environments
+ * where the dimensions and positioning of elements cannot be accurately measured
+ * as they would be in a fully rendered browser environment. By mocking these values,
+ * tests can assert layout and dimension-related properties in a controlled and
+ * predictable manner.
+ *
+ * Usage:
+ * This mock should be set up in the setup phase of your tests, typically in a
+ * `beforeEach` block, to ensure that every test runs with the same initial conditions.
+ * Remember to restore or reset the original function after your tests to avoid
+ * side effects, especially if other tests depend on the original behavior.
+ *
+ * @example
+ * describe('Your Test Suite', () => {
+ * beforeEach(() => {
+ * mockGetBoundingClientRect();
+ * });
+ *
+ * afterEach(() => {
+ * vi.restoreAllMocks();
+ * });
+ *
+ * test('your test', () => {
+ * // your test code here
+ * });
+ * });
+ *
+ * The mock is implemented by replacing `Element.prototype.getBoundingClientRect`
+ * with a custom function that returns fixed dimensions:
+ * - `width`: Calculated from the computed style of the element.
+ * - `height`: Calculated from the computed style of the element.
+ * - `top`: Always 0, simulating the element's position relative to the viewport.
+ * - `left`: Always 0, simulating the element's position relative to the viewport.
+ * - `bottom`: Calculated as the height, simulating the element's lower boundary relative to the viewport.
+ * - `right`: Calculated as the width, simulating the element's right boundary relative to the viewport.
+ * - `x`: Always 0, representing the x-coordinate of the element's bounding box.
+ * - `y`: Always 0, representing the y-coordinate of the element's bounding box.
+ * - `toJSON`: A function that returns an object representation of the bounding box.
+ *
+ * Note that the computed dimensions are based on the element's computed style at the time
+ * of the function call, so tests should ensure that relevant styles are appropriately set
+ * or mocked to reflect expected values.
+ * @see https://developer.mozilla.org/fr/docs/Web/API/Element/getBoundingClientRect
+ */
+export const mockGetBoundingClientRect = () => {
+ Element.prototype.getBoundingClientRect = vi.fn(function (this: Element) {
+ const { width, height } = window.getComputedStyle(this);
+ const rectWidth = parseInt(width, 10);
+ const rectHeight = parseInt(height, 10);
+
+ return {
+ width: rectWidth,
+ height: rectHeight,
+ top: 0,
+ left: 0,
+ bottom: rectHeight,
+ right: rectWidth,
+ x: 0,
+ y: 0,
+ toJSON: () => ({
+ width: rectWidth,
+ height: rectHeight,
+ top: 0,
+ left: 0,
+ bottom: rectHeight,
+ right: rectWidth,
+ x: 0,
+ y: 0,
+ }),
+ };
+ });
+};
diff --git a/webapp/src/tests/mocks/mockHTMLCanvasElement.ts b/webapp/src/tests/mocks/mockHTMLCanvasElement.ts
new file mode 100644
index 0000000000..a31b128d40
--- /dev/null
+++ b/webapp/src/tests/mocks/mockHTMLCanvasElement.ts
@@ -0,0 +1,56 @@
+/**
+ * Creates a mock for the HTML Canvas API in a testing environment.
+ *
+ * This function addresses the problem of testing components that rely on the
+ * Canvas API in a Node.js environment, where the DOM and Canvas are not natively
+ * available. Specifically, it solves issues related to testing components that
+ * use libraries like @glideapps/glide-data-grid, which depend on canvas functionality.
+ *
+ * The mock provides stub implementations for commonly used CanvasRenderingContext2D
+ * methods, allowing tests to run without throwing "not implemented" errors that
+ * would typically occur when canvas methods are called in a non-browser environment.
+ *
+ * @returns An object containing the mocked context and getContext function.
+ */
+export const mockHTMLCanvasElement = () => {
+ /**
+ * A partial mock of CanvasRenderingContext2D.
+ * Only the most commonly used methods are mocked to keep the implementation lean.
+ * Additional methods can be added here as needed.
+ */
+ const contextMock: Partial = {
+ measureText: vi.fn().mockReturnValue({
+ width: 0,
+ actualBoundingBoxAscent: 0,
+ actualBoundingBoxDescent: 0,
+ actualBoundingBoxLeft: 0,
+ actualBoundingBoxRight: 0,
+ fontBoundingBoxAscent: 0,
+ fontBoundingBoxDescent: 0,
+ }),
+ fillRect: vi.fn(),
+ clearRect: vi.fn(),
+ getImageData: vi
+ .fn()
+ .mockReturnValue({ data: new Uint8ClampedArray(), width: 0, height: 0 }),
+ save: vi.fn(),
+ fillText: vi.fn(),
+ restore: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ closePath: vi.fn(),
+ translate: vi.fn(),
+ scale: vi.fn(),
+ rotate: vi.fn(),
+ arc: vi.fn(),
+ rect: vi.fn(),
+ };
+
+ const getContextMock = vi.fn().mockReturnValue(contextMock);
+
+ // Override the getContext method on the HTMLCanvasElement prototype
+ window.HTMLCanvasElement.prototype.getContext = getContextMock;
+
+ return { contextMock, getContextMock };
+};
diff --git a/webapp/src/tests/mocks/mockResizeObserver.ts b/webapp/src/tests/mocks/mockResizeObserver.ts
new file mode 100644
index 0000000000..fc4c132eba
--- /dev/null
+++ b/webapp/src/tests/mocks/mockResizeObserver.ts
@@ -0,0 +1,21 @@
+/**
+ * This is a mock implementation of the global `ResizeObserver`.
+ * ResizeObserver is a web API used to monitor changes in an element's size.
+ * As Vitest runs in a Node environment where certain browser-specific APIs like ResizeObserver are not available,
+ * we need to mock these APIs to prevent errors during testing and ensure that components relying on them can be tested.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/ResizeObserver
+ */
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ // The `observe` method is responsible for starting the observation of size changes on the specified element.
+ // We mock this method to simply track calls (since actual DOM measurements aren't possible in Jest or Vitest environments).
+ observe: vi.fn(),
+
+ // The `unobserve` method stops the observation of an element. It's essential for cleanup in real usage to avoid memory leaks,
+ // but here it's just a mock to ensure we can verify calls to it during tests.
+ unobserve: vi.fn(),
+
+ // The `disconnect` method stops all observations by this instance of ResizeObserver.
+ // This is used to clean up observers when a component unmounts. The mock helps to simulate and test these cleanup behaviors.
+ disconnect: vi.fn(),
+}));
diff --git a/webapp/src/tests/setup.ts b/webapp/src/tests/setup.ts
index d1e9ef6bae..8f6da9b8c2 100644
--- a/webapp/src/tests/setup.ts
+++ b/webapp/src/tests/setup.ts
@@ -1,7 +1,8 @@
-import * as matchers from "@testing-library/jest-dom/matchers";
-import "@testing-library/jest-dom";
+import "@testing-library/jest-dom/vitest";
+import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
-import { expect } from "vitest";
+import * as matchers from "@testing-library/jest-dom/matchers";
+import "./mocks/mockResizeObserver";
// Extend Vitest's expect function with jest-dom matchers for enhanced DOM assertions.
expect.extend(matchers);
diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts
index 7cce8d327b..1dfeadfd8a 100644
--- a/webapp/vite.config.ts
+++ b/webapp/vite.config.ts
@@ -30,7 +30,7 @@ export default defineConfig(({ mode }) => {
},
},
test: {
- globals: true, // Use the APIs globally
+ globals: true, // Use the APIs globally,
environment: "jsdom",
css: true,
setupFiles: "./src/tests/setup.ts",