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",