From b60de7db5ae25f3b52eaf90e1ba96bc490cbb0c9 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 3 Sep 2024 16:32:46 +0200 Subject: [PATCH] feat(ui): add `MatrixGrid` --- webapp/public/locales/en/main.json | 1 + webapp/public/locales/fr/main.json | 1 + .../explore/Modelization/Areas/Load.tsx | 16 +- .../components/common/MatrixGrid/Matrix.tsx | 84 +++ .../common/MatrixGrid/MatrixActions.tsx | 43 ++ .../common/MatrixGrid/index.test.tsx | 218 ++++++++ .../components/common/MatrixGrid/index.tsx | 157 ++++++ .../src/components/common/MatrixGrid/style.ts | 25 + .../src/components/common/MatrixGrid/types.ts | 35 ++ .../MatrixGrid/useColumnMapping.test.ts | 131 +++++ .../common/MatrixGrid/useColumnMapping.ts | 56 ++ .../MatrixGrid/useGridCellContent.test.ts | 493 ++++++++++++++++++ .../common/MatrixGrid/useGridCellContent.ts | 230 ++++++++ .../common/MatrixGrid/useMatrix.test.tsx | 123 +++++ .../components/common/MatrixGrid/useMatrix.ts | 168 ++++++ .../common/MatrixGrid/utils.test.ts | 176 +++++++ .../src/components/common/MatrixGrid/utils.ts | 158 ++++++ webapp/src/services/api/matrix.ts | 7 +- webapp/src/tests/setup.ts | 6 +- 19 files changed, 2108 insertions(+), 20 deletions(-) create mode 100644 webapp/src/components/common/MatrixGrid/Matrix.tsx create mode 100644 webapp/src/components/common/MatrixGrid/MatrixActions.tsx create mode 100644 webapp/src/components/common/MatrixGrid/index.test.tsx create mode 100644 webapp/src/components/common/MatrixGrid/index.tsx create mode 100644 webapp/src/components/common/MatrixGrid/style.ts create mode 100644 webapp/src/components/common/MatrixGrid/types.ts create mode 100644 webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts create mode 100644 webapp/src/components/common/MatrixGrid/useColumnMapping.ts create mode 100644 webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts create mode 100644 webapp/src/components/common/MatrixGrid/useGridCellContent.ts create mode 100644 webapp/src/components/common/MatrixGrid/useMatrix.test.tsx create mode 100644 webapp/src/components/common/MatrixGrid/useMatrix.ts create mode 100644 webapp/src/components/common/MatrixGrid/utils.test.ts create mode 100644 webapp/src/components/common/MatrixGrid/utils.ts 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..c8737dd13a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx @@ -1,24 +1,12 @@ -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}`; - //////////////////////////////////////////////////////////////// - // 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..7c3f4e3c95 --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/Matrix.tsx @@ -0,0 +1,84 @@ +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"; + +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 { + matrixData, + isLoading, + columns, + dateTime, + handleCellEdit, + handleMultipleCellsEdit, + handleImport, + } = useMatrix(study.id, url, enableTimeSeriesColumns, enableAggregateColumns); + + if (isLoading || !matrixData) { + return ; + } + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + + {t(title)} + setOpenImportDialog(true)} + studyId={study.id} + path={url} + disabled={matrixData.data.length === 0} + /> + + + + + + + {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..9a8e9af4c5 --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/MatrixActions.tsx @@ -0,0 +1,43 @@ +import { Box } from "@mui/material"; +import SplitButton from "../buttons/SplitButton"; +import DownloadMatrixButton from "../DownloadMatrixButton"; +import FileDownload from "@mui/icons-material/FileDownload"; +import { useTranslation } from "react-i18next"; + +interface MatrixActionsProps { + onImport: () => void; + studyId: string; + path: string; + disabled: boolean; +} + +function MatrixActions({ + onImport, + studyId, + path, + disabled, +}: MatrixActionsProps) { + const { t } = useTranslation(); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + , + }} + > + {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..123b9579ad --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/index.test.tsx @@ -0,0 +1,218 @@ +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 } from "./types"; +import { ColumnDataType } from "./utils"; + +beforeEach(() => { + mockGetBoundingClientRect(); +}); + +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: ColumnDataType.Number, + editable: true, + order: 0, + }, + { + id: "col2", + title: "Column 2", + width: 100, + type: ColumnDataType.Number, + editable: true, + order: 1, + }, + { + id: "col3", + title: "Column 3", + width: 100, + type: ColumnDataType.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: ColumnDataType.Number, + editable: true, + order: 0, + }, + { + id: "col2", + title: "Column 2", + width: 100, + type: ColumnDataType.Number, + editable: true, + order: 1, + }, + { + id: "col3", + title: "Column 3", + width: 100, + type: ColumnDataType.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: ColumnDataType.Number, + editable: true, + order: 0, + }, + { + id: "col2", + title: "Column 2", + width: 100, + type: ColumnDataType.Number, + editable: true, + order: 1, + }, + { + id: "col3", + title: "Column 3", + width: 100, + type: ColumnDataType.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..9dd96eb7ae --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/index.tsx @@ -0,0 +1,157 @@ +import "@glideapps/glide-data-grid/dist/index.css"; +import DataEditor, { + CompactSelection, + EditListItem, + EditableGridCell, + GridSelection, + Item, +} from "@glideapps/glide-data-grid"; +import { useGridCellContent } from "./useGridCellContent"; +import { useRef, useState } from "react"; +import { type CellFillPattern, type EnhancedGridColumn } from "./types"; +import { darkTheme } 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?: (cell: Item, newValue: number) => void; + onMultipleCellsEdit?: ( + updates: Array<{ coordinates: Item; value: number }>, + fillPattern?: CellFillPattern, + ) => void; +} + +function MatrixGrid({ + data, + rows, + columns, + dateTime, + aggregates, + rowHeaders, + width = "100%", + height = "100%", + onCellEdit, + onMultipleCellsEdit, +}: MatrixGridProps) { + const [selection, setSelection] = useState({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); + + const fillPatternRef = useRef(null); + + const { gridToData } = useColumnMapping(columns); + + const getCellContent = useGridCellContent( + data, + columns, + gridToData, + dateTime, + aggregates, + rowHeaders, + ); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleCellEdited = (cell: Item, value: EditableGridCell) => { + const updatedValue = value.data; + + if (typeof updatedValue !== "number" || isNaN(updatedValue)) { + // Invalid numeric value + return; + } + + const dataCell = gridToData(cell); + + if (dataCell && onCellEdit) { + onCellEdit(dataCell, updatedValue); + } + }; + + const handleCellsEdited = (newValues: readonly EditListItem[]) => { + const updates = newValues + .map((edit) => { + const dataCell = gridToData(edit.location); + return dataCell + ? { + coordinates: dataCell, + value: edit.value.data as number, + } + : null; + }) + .filter( + (update): update is { coordinates: Item; value: number } => + 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, fillPatternRef.current || undefined); + } + + // Reset fillPatternRef after use + fillPatternRef.current = null; + + // Return true to prevent calling `onCellEdit` + // for each cell after`onMultipleCellsEdit` is called + return true; + }; + + // Used for fill handle updates to send one batch update object + // instead of an array of updates using `onCellsEdited` callback + const handleFillPattern = ({ + patternSource, + fillDestination, + }: CellFillPattern) => { + fillPatternRef.current = { patternSource, fillDestination }; + // Don't prevent default, allow the grid to apply the fill pattern + }; + + //////////////////////////////////////////////////////////////// + // 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..507466b495 --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/types.ts @@ -0,0 +1,35 @@ +import { + BaseGridColumn, + FillPatternEventArgs, +} from "@glideapps/glide-data-grid"; +import { ColumnDataType } from "./utils"; + +export interface MatrixData { + data: number[][]; + columns: number[]; + index: number[]; +} + +export type ColumnType = (typeof ColumnDataType)[keyof typeof ColumnDataType]; + +export interface EnhancedGridColumn extends BaseGridColumn { + id: string; + width?: number; + type: ColumnType; + editable: boolean; +} + +export type CellFillPattern = Omit; + +// TODO see MatrixIndex type, rundundant types +export interface TimeMetadataDTO { + start_date: string; + steps: number; + first_week_size: number; + level: "hourly" | "daily" | "weekly" | "monthly" | "yearly"; +} + +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..7d90c9ba24 --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts @@ -0,0 +1,131 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { useColumnMapping } from "./useColumnMapping"; +import { EnhancedGridColumn } from "./types"; +import { ColumnDataType } from "./utils"; + +describe("useColumnMapping", () => { + const testColumns: EnhancedGridColumn[] = [ + { + id: "text", + title: "Text", + type: ColumnDataType.Text, + width: 100, + editable: false, + }, + { + id: "date", + title: "Date", + type: ColumnDataType.DateTime, + width: 100, + editable: false, + }, + { + id: "num1", + title: "Number 1", + type: ColumnDataType.Number, + width: 100, + editable: true, + }, + { + id: "num2", + title: "Number 2", + type: ColumnDataType.Number, + width: 100, + editable: true, + }, + { + id: "agg", + title: "Aggregate", + type: ColumnDataType.Aggregate, + width: 100, + editable: false, + }, + ]; + + it("should create gridToData and dataToGrid functions", () => { + const { result } = renderHook(() => useColumnMapping(testColumns)); + expect(result.current.gridToData).toBeDefined(); + expect(result.current.dataToGrid).toBeDefined(); + }); + + describe("gridToData", () => { + it("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 + }); + + it("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", () => { + it("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 + }); + }); + + it("should handle columns with only non-data types", () => { + const nonDataColumns: EnhancedGridColumn[] = [ + { + id: "text", + title: "Text", + type: ColumnDataType.Text, + width: 100, + editable: false, + }, + { + id: "date", + title: "Date", + type: ColumnDataType.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 + }); + + it("should handle columns with only data types", () => { + const dataOnlyColumns: EnhancedGridColumn[] = [ + { + id: "num1", + title: "Number 1", + type: ColumnDataType.Number, + width: 100, + editable: true, + }, + { + id: "num2", + title: "Number 2", + type: ColumnDataType.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]); + }); + + it("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..0ec81ec29f --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/useColumnMapping.ts @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import { Item } from "@glideapps/glide-data-grid"; +import { EnhancedGridColumn } from "./types"; +import { ColumnDataType } from "./utils"; + +/** + * 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 === ColumnDataType.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..ed26c1e2e9 --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts @@ -0,0 +1,493 @@ +import { renderHook } from "@testing-library/react"; +import { useGridCellContent } from "./useGridCellContent"; +import { type EnhancedGridColumn } from "./types"; +import { ColumnDataType } from "./utils"; +import { useColumnMapping } from "./useColumnMapping"; + +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: ColumnDataType.DateTime, + width: 150, + editable: false, + }, + { + id: "data1", + title: "TS 1", + type: ColumnDataType.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: ColumnDataType.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: ColumnDataType.DateTime, + width: 150, + editable: false, + }, + { + id: "ts1", + title: "TS 1", + type: ColumnDataType.Number, + width: 50, + editable: true, + }, + { + id: "ts2", + title: "TS 2", + type: ColumnDataType.Number, + width: 50, + editable: true, + }, + { + id: "total", + title: "Total", + type: ColumnDataType.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: ColumnDataType.Text, + width: 100, + editable: false, + }, + { + id: "date", + title: "Date", + type: ColumnDataType.DateTime, + width: 150, + editable: false, + }, + { + id: "data1", + title: "TS 1", + type: ColumnDataType.Number, + width: 50, + editable: true, + }, + { + id: "data2", + title: "TS 2", + type: ColumnDataType.Number, + width: 50, + editable: true, + }, + { + id: "total", + title: "Total", + type: ColumnDataType.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: ColumnDataType.Number, + width: 50, + editable: true, + }, + { + id: "data2", + title: "TS 2", + type: ColumnDataType.Number, + width: 50, + editable: true, + }, + { + id: "data3", + title: "TS 3", + type: ColumnDataType.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: ColumnDataType.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: ColumnDataType.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: ColumnDataType.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: ColumnDataType.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: ColumnDataType.Number, + width: 50, + editable: true, + }, + { + id: "data2", + title: "TS 2", + type: ColumnDataType.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: ColumnDataType.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..9889cd5110 --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/useGridCellContent.ts @@ -0,0 +1,230 @@ +import { useCallback, useMemo } from "react"; +import { GridCell, GridCellKind, Item } from "@glideapps/glide-data-grid"; +import { type EnhancedGridColumn, type ColumnType } from "./types"; +import { ColumnDataType } from "./utils"; + +/** + * Options for formatting date and time strings. + * + * Note on Time Zone Handling: + * + * The 'timeZone' option is set to "UTC" to ensure consistent date and time + * representation across different systems and geographical locations. This is + * crucial for several reasons: + * + * 1. Consistency: UTC provides a universal time standard, eliminating + * discrepancies caused by daylight saving time or different time zones. + * + * 2. Data Integrity: Many systems store timestamps in UTC. By displaying in UTC, + * we maintain fidelity to the original data without implicit conversions. + * + * 3. Global Applications: For applications used across multiple time zones, + * UTC ensures all users see the same time representation. + * + * 4. Debugging and Logging: UTC timestamps are easier to compare and analyze, + * especially when dealing with events occurring across different time zones. + * + * 5. Testing: Using UTC in tests provides consistent results regardless of where + * the tests are run, enhancing test reliability and reproducibility. + * + */ +const dateTimeFormatOptions: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + timeZone: "UTC", // Ensures consistent UTC-based time representation +}; + +/** + * 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" (assuming French locale is available) + * formatDateTime("2024-01-01T00:00:00Z") + * + * @example + * // returns "Jan 1, 2024, 00:00" (if French locale is not available) + * formatDateTime("2024-01-01T00:00:00Z") + */ +function formatDateTime(dateTime: string): string { + return new Date(dateTime).toLocaleDateString( + ["fr", "en"], // TODO check if i18n locale switch this if not fix it + dateTimeFormatOptions, + ); +} + +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 = { + [ColumnDataType.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, + }), + [ColumnDataType.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, + }), + [ColumnDataType.Number]: (row, col, column, data) => { + const value = data?.[row]?.[col]; + + return { + kind: GridCellKind.Number, + data: value, + displayData: value?.toString(), + readonly: !column.editable, + allowOverlay: true, + }; + }, + [ColumnDataType.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. + * @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[], +): (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 === ColumnDataType.Number && gridToData) { + // Map grid cell to data array index + const dataCell = gridToData(cell); + + if (dataCell) { + adjustedCol = dataCell[0]; + } + } + + return generator( + row, + adjustedCol, + column, + data, + dateTime, + aggregates, + rowHeaders, + ); + }, + [columnMap, data, dateTime, aggregates, rowHeaders, gridToData], + ); + + 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..403bea5f8b --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx @@ -0,0 +1,123 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { vi, describe, it, 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 { MatrixData } from "./types"; + +vi.mock("../../../services/api/matrix"); +vi.mock("../../../services/api/study"); + +describe("useMatrix", () => { + const mockStudyId = "study123"; + const mockUrl = "http://studies/study123/matrix"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockMatrixData: MatrixData = { + 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, // TODO remove this, fix the type + }; + + it("should fetch matrix data and index on mount", 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); + }); + + expect(result.current.matrixData).toEqual(mockMatrixData); + expect(result.current.columns.length).toBeGreaterThan(0); + expect(result.current.dateTime.length).toBeGreaterThan(0); + }); + + it("should handle cell edit", async () => { + const mockMatrixData: MatrixData = { + 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, + }; + + vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); + vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); + vi.mocked(apiMatrix.editMatrix).mockResolvedValue(undefined); + + const { result } = renderHook(() => + useMatrix(mockStudyId, mockUrl, true, true), + ); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.handleCellEdit([0, 1], 5); + }); + + const expectedEdit: MatrixEditDTO = { + coordinates: [[1, 0]], + operation: { + operation: Operator.EQ, + value: 5, + }, + }; + + expect(apiMatrix.editMatrix).toHaveBeenCalledWith(mockStudyId, mockUrl, [ + expectedEdit, + ]); + }); + + it("should handle file import", async () => { + const mockFile = new File([""], "test.csv", { type: "text/csv" }); + const mockImportFile = vi.fn().mockResolvedValue({}); + vi.mocked(apiStudy.importFile).mockImplementation(mockImportFile); + + const { result } = renderHook(() => + useMatrix(mockStudyId, mockUrl, true, true), + ); + + await act(async () => { + await result.current.handleImport(mockFile); + }); + + expect(apiStudy.importFile).toHaveBeenCalledWith( + mockFile, + mockStudyId, + mockUrl, + ); + }); +}); diff --git a/webapp/src/components/common/MatrixGrid/useMatrix.ts b/webapp/src/components/common/MatrixGrid/useMatrix.ts new file mode 100644 index 0000000000..36ab7b5471 --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/useMatrix.ts @@ -0,0 +1,168 @@ +import { useMemo } from "react"; +import { Item } from "@glideapps/glide-data-grid"; +import { AxiosError } from "axios"; +import { enqueueSnackbar } from "notistack"; +import { t } from "i18next"; +import { MatrixEditDTO, Operator } from "../../../common/types"; +import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; +import usePromiseWithSnackbarError from "../../../hooks/usePromiseWithSnackbarError"; +import { getStudyMatrixIndex, editMatrix } from "../../../services/api/matrix"; +import { getStudyData, importFile } from "../../../services/api/study"; +import { + EnhancedGridColumn, + CellFillPattern, + TimeMetadataDTO, + MatrixData, +} from "./types"; +import { + generateDateTime, + ColumnDataType, + generateTimeSeriesColumns, +} from "./utils"; + +export function useMatrix( + studyId: string, + url: string, + enableTimeSeriesColumns: boolean, + enableAggregateColumns: boolean, +) { + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + + const { + data: matrixData, + isLoading: isLoadingMatrix, + reload: reloadMatrix, + } = usePromiseWithSnackbarError( + () => getStudyData(studyId, url), + { + errorMessage: t("data.error.matrix"), + deps: [studyId, url], + }, + ); + + const { data: matrixIndex, isLoading: isLoadingIndex } = + usePromiseWithSnackbarError(() => getStudyMatrixIndex(studyId, url), { + errorMessage: t("matrix.error.failedToretrieveIndex"), + deps: [studyId, url, matrixData], + }); + + const dateTime = useMemo(() => { + return matrixIndex ? generateDateTime(matrixIndex as TimeMetadataDTO) : []; + }, [matrixIndex]); + + const columns: EnhancedGridColumn[] = useMemo(() => { + if (!matrixData) { + return []; + } + + const baseColumns = [ + { + id: "date", + title: "Date", + type: ColumnDataType.DateTime, + width: 150, + editable: false, + }, + ]; + + const dataColumns = enableTimeSeriesColumns + ? generateTimeSeriesColumns({ count: matrixData.columns.length }) + : []; + + const aggregateColumns = enableAggregateColumns + ? [ + { + id: "min", + title: "Min", + type: ColumnDataType.Aggregate, + width: 50, + editable: false, + }, + { + id: "max", + title: "Max", + type: ColumnDataType.Aggregate, + width: 50, + editable: false, + }, + { + id: "avg", + title: "Avg", + type: ColumnDataType.Aggregate, + width: 50, + editable: false, + }, + ] + : []; + + return [...baseColumns, ...dataColumns, ...aggregateColumns]; + }, [matrixData, enableTimeSeriesColumns, enableAggregateColumns]); + + const handleCellEdit = async function (coordinates: Item, newValue: number) { + const [row, col] = coordinates; + + const update: MatrixEditDTO[] = [ + { + coordinates: [[col, row]], + operation: { + operation: Operator.EQ, + value: newValue, + }, + }, + ]; + + try { + await editMatrix(studyId, url, update); + reloadMatrix(); + enqueueSnackbar(t("matrix.success.matrixUpdate"), { + variant: "success", + }); + } catch (e) { + enqueueErrorSnackbar(t("matrix.error.matrixUpdate"), e as AxiosError); + } + }; + + const handleMultipleCellsEdit = async function ( + newValues: Array<{ coordinates: Item; value: number }>, + fillPattern?: CellFillPattern, + ) { + const updates = newValues.map(({ coordinates, value }) => ({ + coordinates: [[coordinates[1], coordinates[0]]], + operation: { + operation: Operator.EQ, + value, + }, + })); + + try { + await editMatrix(studyId, url, updates); + reloadMatrix(); + enqueueSnackbar(t("matrix.success.matrixUpdate"), { + variant: "success", + }); + } catch (e) { + enqueueErrorSnackbar(t("matrix.error.matrixUpdate"), e as AxiosError); + } + }; + + const handleImport = async (file: File) => { + try { + await importFile(file, studyId, url); + reloadMatrix(); + enqueueSnackbar(t("matrix.success.import"), { variant: "success" }); + } catch (e) { + enqueueErrorSnackbar(t("matrix.error.import"), e as Error); + } + }; + + return { + matrixData, + isLoading: isLoadingMatrix || isLoadingIndex, + columns, + dateTime, + handleCellEdit, + handleMultipleCellsEdit, + handleImport, + reloadMatrix, + }; +} 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..636918fa0e --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/utils.test.ts @@ -0,0 +1,176 @@ +import { TimeMetadataDTO } from "./types"; +import { + ColumnDataType, + generateDateTime, + generateTimeSeriesColumns, +} from "./utils"; + +describe("generateDateTime", () => { + test("generates correct number of dates", () => { + const metadata: TimeMetadataDTO = { + start_date: "2023-01-01T00:00:00Z", + steps: 5, + first_week_size: 7, + level: "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: "yearly", + 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: TimeMetadataDTO = { + start_date: start, + steps: expected.length, + first_week_size: 7, + level: level as TimeMetadataDTO["level"], + }; + + const result = generateDateTime(metadata); + + expect(result).toEqual(expected); + }, + ); + + test("handles edge cases", () => { + const metadata: TimeMetadataDTO = { + start_date: "2023-12-31T23:59:59Z", + steps: 2, + first_week_size: 7, + level: "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: ColumnDataType.Number, + style: "normal", + width: 50, + editable: true, + }, + { + id: "data2", + title: "TS 2", + type: ColumnDataType.Number, + style: "normal", + width: 50, + editable: true, + }, + { + id: "data3", + title: "TS 3", + type: ColumnDataType.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: ColumnDataType.Number, + style: "normal", + width: 80, + editable: false, + }, + { + id: "data11", + title: "Data 11", + type: ColumnDataType.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(ColumnDataType.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..5652ea8c4f --- /dev/null +++ b/webapp/src/components/common/MatrixGrid/utils.ts @@ -0,0 +1,158 @@ +import moment from "moment"; +import { + TimeMetadataDTO, + DateIncrementStrategy, + EnhancedGridColumn, +} from "./types"; + +//////////////////////////////////////////////////////////////// +// Enums +//////////////////////////////////////////////////////////////// + +export const ColumnDataType = { + DateTime: "datetime", + Number: "number", + Text: "text", + Aggregate: "aggregate", +} as const; + +//////////////////////////////////////////////////////////////// +// Utils +//////////////////////////////////////////////////////////////// + +const dateIncrementStrategies: Record< + TimeMetadataDTO["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"), + yearly: (date, step) => date.clone().add(step, "years"), +}; + +export const darkTheme = { + accentColor: "#6366F1", + accentFg: "#FFFFFF", + accentLight: "rgba(99, 102, 241, 0.2)", + 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 13px", + baseFontStyle: "13px", + fontFamily: "Inter, sans-serif", + editorFontSize: "13px", + lineHeight: 1.5, +}; + +//////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////// + +/** + * 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 TimeMetadataDTO} 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, +}: TimeMetadataDTO): string[] { + const startDate = moment(start_date); + 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: ColumnDataType.Text, ... }, + * { id: "date", title: "Date", type: ColumnDataType.DateTime, ... }, + * ...generateTimeSeriesColumns({ count: 60 }), + * { id: "min", title: "Min", type: ColumnDataType.Aggregate, ... }, + * { id: "max", title: "Max", type: ColumnDataType.Aggregate, ... }, + * { id: "avg", title: "Avg", type: ColumnDataType.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: ColumnDataType.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..ad80cfa92c 100644 --- a/webapp/src/services/api/matrix.ts +++ b/webapp/src/services/api/matrix.ts @@ -96,11 +96,12 @@ export const editMatrix = async ( 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 getStudyMatrixIndex = async ( diff --git a/webapp/src/tests/setup.ts b/webapp/src/tests/setup.ts index 11f7f94684..206483a805 100644 --- a/webapp/src/tests/setup.ts +++ b/webapp/src/tests/setup.ts @@ -1,7 +1,7 @@ -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 "vitest-canvas-mock"; import "./mocks/mockResizeObserver";