diff --git a/webapp/src/components/common/MatrixGrid/MatrixActions.tsx b/webapp/src/components/common/MatrixGrid/MatrixActions.tsx index 6b0b6d27f5..4d135c6da2 100644 --- a/webapp/src/components/common/MatrixGrid/MatrixActions.tsx +++ b/webapp/src/components/common/MatrixGrid/MatrixActions.tsx @@ -85,10 +85,15 @@ function MatrixActions({ ButtonProps={{ startIcon: , }} + disabled={isSubmitting} > {t("global.import")} - + ); } diff --git a/webapp/src/components/common/MatrixGrid/index.test.tsx b/webapp/src/components/common/MatrixGrid/index.test.tsx index b1858c518f..e7189ad93c 100644 --- a/webapp/src/components/common/MatrixGrid/index.test.tsx +++ b/webapp/src/components/common/MatrixGrid/index.test.tsx @@ -2,8 +2,7 @@ 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"; +import { type EnhancedGridColumn, ColumnTypes } from "./types"; import { mockHTMLCanvasElement } from "../../../tests/mocks/mockHTMLCanvasElement"; beforeEach(() => { @@ -54,7 +53,7 @@ describe("MatrixGrid rendering", () => { id: "col1", title: "Column 1", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 0, }, @@ -62,7 +61,7 @@ describe("MatrixGrid rendering", () => { id: "col2", title: "Column 2", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 1, }, @@ -70,7 +69,7 @@ describe("MatrixGrid rendering", () => { id: "col3", title: "Column 3", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 2, }, @@ -105,7 +104,7 @@ describe("MatrixGrid rendering", () => { id: "col1", title: "Column 1", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 0, }, @@ -113,7 +112,7 @@ describe("MatrixGrid rendering", () => { id: "col2", title: "Column 2", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 1, }, @@ -121,7 +120,7 @@ describe("MatrixGrid rendering", () => { id: "col3", title: "Column 3", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 2, }, @@ -158,7 +157,7 @@ describe("MatrixGrid rendering", () => { id: "col1", title: "Column 1", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 0, }, @@ -166,7 +165,7 @@ describe("MatrixGrid rendering", () => { id: "col2", title: "Column 2", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 1, }, @@ -174,7 +173,7 @@ describe("MatrixGrid rendering", () => { id: "col3", title: "Column 3", width: 100, - type: ColumnDataType.Number, + type: ColumnTypes.Number, editable: true, order: 2, }, diff --git a/webapp/src/components/common/MatrixGrid/index.tsx b/webapp/src/components/common/MatrixGrid/index.tsx index 48c736df0b..70acbabd1e 100644 --- a/webapp/src/components/common/MatrixGrid/index.tsx +++ b/webapp/src/components/common/MatrixGrid/index.tsx @@ -1,14 +1,15 @@ import "@glideapps/glide-data-grid/dist/index.css"; import DataEditor, { CompactSelection, - EditListItem, EditableGridCell, + EditListItem, + GridCellKind, GridSelection, Item, } from "@glideapps/glide-data-grid"; import { useGridCellContent } from "./useGridCellContent"; import { useMemo, useState } from "react"; -import { type CellFillPattern, type EnhancedGridColumn } from "./types"; +import { EnhancedGridColumn, GridUpdate } from "./types"; import { darkTheme, readOnlyDarkTheme } from "./utils"; import { useColumnMapping } from "./useColumnMapping"; @@ -21,11 +22,8 @@ export interface MatrixGridProps { rowHeaders?: string[]; width?: string; height?: string; - onCellEdit?: (cell: Item, newValue: number) => void; - onMultipleCellsEdit?: ( - updates: Array<{ coordinates: Item; value: number }>, - fillPattern?: CellFillPattern, - ) => void; + onCellEdit?: (update: GridUpdate) => void; + onMultipleCellsEdit?: (updates: GridUpdate[]) => void; readOnly?: boolean; } @@ -74,36 +72,34 @@ function MatrixGrid({ // Event Handlers //////////////////////////////////////////////////////////////// - const handleCellEdited = (cell: Item, value: EditableGridCell) => { - const updatedValue = value.data; - - if (typeof updatedValue !== "number" || isNaN(updatedValue)) { + const handleCellEdited = (coordinates: Item, value: EditableGridCell) => { + if (value.kind !== GridCellKind.Number) { // Invalid numeric value return; } - const dataCell = gridToData(cell); + const dataCoordinates = gridToData(coordinates); - if (dataCell && onCellEdit) { - onCellEdit(dataCell, updatedValue); + if (dataCoordinates && onCellEdit) { + onCellEdit({ coordinates: dataCoordinates, value }); } }; 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; + .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 { coordinates: Item; value: number } => - update !== null, - ); + .filter((update): update is GridUpdate => update !== null); if (updates.length === 0) { // No valid updates diff --git a/webapp/src/components/common/MatrixGrid/types.ts b/webapp/src/components/common/MatrixGrid/types.ts index 507466b495..2183801db5 100644 --- a/webapp/src/components/common/MatrixGrid/types.ts +++ b/webapp/src/components/common/MatrixGrid/types.ts @@ -1,16 +1,36 @@ import { BaseGridColumn, - FillPatternEventArgs, + EditableGridCell, + Item, } from "@glideapps/glide-data-grid"; -import { ColumnDataType } from "./utils"; -export interface MatrixData { - data: number[][]; - columns: number[]; - index: number[]; -} +//////////////////////////////////////////////////////////////// +// Enums +//////////////////////////////////////////////////////////////// -export type ColumnType = (typeof ColumnDataType)[keyof typeof ColumnDataType]; +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; @@ -18,15 +38,31 @@ export interface EnhancedGridColumn extends BaseGridColumn { type: ColumnType; editable: boolean; } +// Represents data coming from the API +export interface MatrixDataDTO { + data: number[][]; + columns: number[]; + index: number[]; +} + +export type Coordinates = [number, number]; -export type CellFillPattern = Omit; +// 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; +} -// TODO see MatrixIndex type, rundundant types -export interface TimeMetadataDTO { - start_date: string; - steps: number; - first_week_size: number; - level: "hourly" | "daily" | "weekly" | "monthly" | "yearly"; +// 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 = ( diff --git a/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts b/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts index 04003f3cb5..df476b482d 100644 --- a/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts +++ b/webapp/src/components/common/MatrixGrid/useColumnMapping.test.ts @@ -1,43 +1,42 @@ import { renderHook } from "@testing-library/react"; import { describe, test, expect } from "vitest"; import { useColumnMapping } from "./useColumnMapping"; -import { EnhancedGridColumn } from "./types"; -import { ColumnDataType } from "./utils"; +import { EnhancedGridColumn, ColumnTypes } from "./types"; describe("useColumnMapping", () => { const testColumns: EnhancedGridColumn[] = [ { id: "text", title: "Text", - type: ColumnDataType.Text, + type: ColumnTypes.Text, width: 100, editable: false, }, { id: "date", title: "Date", - type: ColumnDataType.DateTime, + type: ColumnTypes.DateTime, width: 100, editable: false, }, { id: "num1", title: "Number 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 100, editable: true, }, { id: "num2", title: "Number 2", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 100, editable: true, }, { id: "agg", title: "Aggregate", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 100, editable: false, }, @@ -77,14 +76,14 @@ describe("useColumnMapping", () => { { id: "text", title: "Text", - type: ColumnDataType.Text, + type: ColumnTypes.Text, width: 100, editable: false, }, { id: "date", title: "Date", - type: ColumnDataType.DateTime, + type: ColumnTypes.DateTime, width: 100, editable: false, }, @@ -100,14 +99,14 @@ describe("useColumnMapping", () => { { id: "num1", title: "Number 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 100, editable: true, }, { id: "num2", title: "Number 2", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 100, editable: true, }, diff --git a/webapp/src/components/common/MatrixGrid/useColumnMapping.ts b/webapp/src/components/common/MatrixGrid/useColumnMapping.ts index 0ec81ec29f..ab46b4db5e 100644 --- a/webapp/src/components/common/MatrixGrid/useColumnMapping.ts +++ b/webapp/src/components/common/MatrixGrid/useColumnMapping.ts @@ -1,7 +1,6 @@ import { useMemo } from "react"; import { Item } from "@glideapps/glide-data-grid"; -import { EnhancedGridColumn } from "./types"; -import { ColumnDataType } from "./utils"; +import { EnhancedGridColumn, ColumnTypes } from "./types"; /** * A custom hook that provides coordinate mapping functions for a grid with mixed column types. @@ -35,7 +34,7 @@ import { ColumnDataType } from "./utils"; export function useColumnMapping(columns: EnhancedGridColumn[]) { return useMemo(() => { const dataColumnIndices = columns.reduce((acc, col, index) => { - if (col.type === ColumnDataType.Number) { + if (col.type === ColumnTypes.Number) { acc.push(index); } return acc; diff --git a/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts b/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts index bfe0acf122..ccb0736fb5 100644 --- a/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts +++ b/webapp/src/components/common/MatrixGrid/useGridCellContent.test.ts @@ -1,7 +1,6 @@ import { renderHook } from "@testing-library/react"; import { useGridCellContent } from "./useGridCellContent"; -import { type EnhancedGridColumn } from "./types"; -import { ColumnDataType } from "./utils"; +import { ColumnTypes, type EnhancedGridColumn } from "./types"; import { useColumnMapping } from "./useColumnMapping"; // Mocking i18next @@ -68,14 +67,14 @@ describe("useGridCellContent", () => { { id: "date", title: "Date", - type: ColumnDataType.DateTime, + type: ColumnTypes.DateTime, width: 150, editable: false, }, { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, @@ -104,7 +103,7 @@ describe("useGridCellContent", () => { { id: "total", title: "Total", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 100, editable: false, }, @@ -152,28 +151,28 @@ describe("useGridCellContent", () => { { id: "date", title: "Date", - type: ColumnDataType.DateTime, + type: ColumnTypes.DateTime, width: 150, editable: false, }, { id: "ts1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, { id: "ts2", title: "TS 2", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, { id: "total", title: "Total", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 100, editable: false, }, @@ -232,35 +231,35 @@ describe("useGridCellContent with mixed column types", () => { { id: "rowHeader", title: "Row", - type: ColumnDataType.Text, + type: ColumnTypes.Text, width: 100, editable: false, }, { id: "date", title: "Date", - type: ColumnDataType.DateTime, + type: ColumnTypes.DateTime, width: 150, editable: false, }, { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, { id: "data2", title: "TS 2", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, { id: "total", title: "Total", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 100, editable: false, }, @@ -337,21 +336,21 @@ describe("useGridCellContent with mixed column types", () => { { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, { id: "data2", title: "TS 2", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, { id: "data3", title: "TS 3", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, @@ -382,7 +381,7 @@ describe("useGridCellContent additional tests", () => { { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, @@ -404,7 +403,7 @@ describe("useGridCellContent additional tests", () => { { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, @@ -427,7 +426,7 @@ describe("useGridCellContent additional tests", () => { { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, @@ -449,7 +448,7 @@ describe("useGridCellContent additional tests", () => { { id: "total", title: "Total", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 100, editable: false, }, @@ -474,14 +473,14 @@ describe("useGridCellContent additional tests", () => { { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, { id: "data2", title: "TS 2", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: false, }, @@ -506,7 +505,7 @@ describe("useGridCellContent additional tests", () => { { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, width: 50, editable: true, }, diff --git a/webapp/src/components/common/MatrixGrid/useGridCellContent.ts b/webapp/src/components/common/MatrixGrid/useGridCellContent.ts index 725cae2291..09f6d06451 100644 --- a/webapp/src/components/common/MatrixGrid/useGridCellContent.ts +++ b/webapp/src/components/common/MatrixGrid/useGridCellContent.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { GridCell, GridCellKind, Item } from "@glideapps/glide-data-grid"; -import { type EnhancedGridColumn, type ColumnType } from "./types"; -import { ColumnDataType, formatDateTime } from "./utils"; +import { type EnhancedGridColumn, type ColumnType, ColumnTypes } from "./types"; +import { formatDateTime } from "./utils"; type CellContentGenerator = ( row: number, @@ -18,7 +18,7 @@ type CellContentGenerator = ( * Each generator function creates the appropriate GridCell based on the column type and data. */ const cellContentGenerators: Record = { - [ColumnDataType.Text]: ( + [ColumnTypes.Text]: ( row, col, column, @@ -33,14 +33,14 @@ const cellContentGenerators: Record = { readonly: !column.editable, allowOverlay: false, }), - [ColumnDataType.DateTime]: (row, col, column, data, dateTime) => ({ + [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, }), - [ColumnDataType.Number]: (row, col, column, data) => { + [ColumnTypes.Number]: (row, col, column, data) => { const value = data?.[row]?.[col]; return { @@ -51,14 +51,7 @@ const cellContentGenerators: Record = { allowOverlay: true, }; }, - [ColumnDataType.Aggregate]: ( - row, - col, - column, - data, - dateTime, - aggregates, - ) => { + [ColumnTypes.Aggregate]: (row, col, column, data, dateTime, aggregates) => { const value = aggregates?.[column.id]?.[row]; return { @@ -142,7 +135,7 @@ export function useGridCellContent( // accounting for any non-data columns in the grid let adjustedCol = col; - if (column.type === ColumnDataType.Number && gridToData) { + if (column.type === ColumnTypes.Number && gridToData) { // Map grid cell to data array index const dataCell = gridToData(cell); diff --git a/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx b/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx index b15ca61642..2fe6599aca 100644 --- a/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx +++ b/webapp/src/components/common/MatrixGrid/useMatrix.test.tsx @@ -9,7 +9,8 @@ import { Operator, StudyOutputDownloadLevelDTO, } from "../../../common/types"; -import { MatrixData } from "./types"; +import { GridUpdate, MatrixDataDTO } from "./types"; +import { GridCellKind } from "@glideapps/glide-data-grid"; vi.mock("../../../services/api/matrix"); vi.mock("../../../services/api/study"); @@ -18,7 +19,7 @@ describe("useMatrix", () => { const mockStudyId = "study123"; const mockUrl = "https://studies/study123/matrix"; - const mockMatrixData: MatrixData = { + const mockMatrixData: MatrixDataDTO = { data: [ [1, 2], [3, 4], @@ -50,6 +51,21 @@ describe("useMatrix", () => { 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(); }); @@ -80,7 +96,7 @@ describe("useMatrix", () => { }); act(() => { - result.current.handleCellEdit([0, 1], 5); + result.current.handleCellEdit(createGridUpdate(0, 1, 5)); }); expect(result.current.data[1][0]).toBe(5); @@ -99,8 +115,8 @@ describe("useMatrix", () => { act(() => { result.current.handleMultipleCellsEdit([ - { coordinates: [0, 1], value: 5 }, - { coordinates: [1, 0], value: 6 }, + createGridUpdate(0, 1, 5), + createGridUpdate(1, 0, 6), ]); }); @@ -112,7 +128,7 @@ describe("useMatrix", () => { test("should handle save updates", async () => { vi.mocked(apiStudy.getStudyData).mockResolvedValue(mockMatrixData); vi.mocked(apiMatrix.getStudyMatrixIndex).mockResolvedValue(mockMatrixIndex); - vi.mocked(apiMatrix.editMatrix).mockResolvedValue(undefined); + vi.mocked(apiMatrix.updateMatrix).mockResolvedValue(undefined); const result = await setupHook(); @@ -121,7 +137,7 @@ describe("useMatrix", () => { }); act(() => { - result.current.handleCellEdit([0, 1], 5); + result.current.handleCellEdit(createGridUpdate(0, 1, 5)); }); await act(async () => { @@ -136,7 +152,7 @@ describe("useMatrix", () => { }, }; - expect(apiMatrix.editMatrix).toHaveBeenCalledWith(mockStudyId, mockUrl, [ + expect(apiMatrix.updateMatrix).toHaveBeenCalledWith(mockStudyId, mockUrl, [ expectedEdit, ]); expect(result.current.pendingUpdatesCount).toBe(0); @@ -191,7 +207,7 @@ describe("useMatrix", () => { }); act(() => { - result.current.handleCellEdit([0, 1], 5); + result.current.handleCellEdit(createGridUpdate(0, 1, 5)); }); expect(result.current.canUndo).toBe(true); @@ -225,7 +241,7 @@ describe("useMatrix", () => { }); act(() => { - result.current.handleCellEdit([0, 1], 5); + result.current.handleCellEdit(createGridUpdate(0, 1, 5)); }); act(() => { @@ -235,7 +251,7 @@ describe("useMatrix", () => { expect(result.current.canRedo).toBe(true); act(() => { - result.current.handleCellEdit([1, 0], 6); + result.current.handleCellEdit(createGridUpdate(1, 0, 6)); }); expect(result.current.canUndo).toBe(true); @@ -255,7 +271,7 @@ describe("useMatrix", () => { }); act(() => { - result.current.handleCellEdit([0, 1], 5); + result.current.handleCellEdit(createGridUpdate(0, 1, 5)); }); act(() => { diff --git a/webapp/src/components/common/MatrixGrid/useMatrix.ts b/webapp/src/components/common/MatrixGrid/useMatrix.ts index 4df22528c8..ff16f05b09 100644 --- a/webapp/src/components/common/MatrixGrid/useMatrix.ts +++ b/webapp/src/components/common/MatrixGrid/useMatrix.ts @@ -1,28 +1,28 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Item } from "@glideapps/glide-data-grid"; import { AxiosError } from "axios"; import { enqueueSnackbar } from "notistack"; import { t } from "i18next"; -import { MatrixEditDTO, MatrixIndex, Operator } from "../../../common/types"; +import { MatrixIndex, Operator } from "../../../common/types"; import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; -import { getStudyMatrixIndex, editMatrix } from "../../../services/api/matrix"; +import { + getStudyMatrixIndex, + updateMatrix, +} from "../../../services/api/matrix"; import { getStudyData, importFile } from "../../../services/api/study"; import { EnhancedGridColumn, - CellFillPattern, - TimeMetadataDTO, - MatrixData, + MatrixDataDTO, + ColumnTypes, + GridUpdate, + MatrixUpdateDTO, } from "./types"; -import { - generateDateTime, - ColumnDataType, - generateTimeSeriesColumns, -} from "./utils"; +import { generateDateTime, generateTimeSeriesColumns } from "./utils"; import useUndo from "use-undo"; +import { GridCellKind } from "@glideapps/glide-data-grid"; interface DataState { data: number[][]; - pendingUpdates: MatrixEditDTO[]; + pendingUpdates: MatrixUpdateDTO[]; } export function useMatrix( @@ -44,7 +44,7 @@ export function useMatrix( setIsLoading(true); try { const [matrix, index] = await Promise.all([ - getStudyData(studyId, url), + getStudyData(studyId, url), getStudyMatrixIndex(studyId, url), ]); @@ -65,7 +65,7 @@ export function useMatrix( }, [fetchMatrix]); const dateTime = useMemo(() => { - return index ? generateDateTime(index as TimeMetadataDTO) : []; + return index ? generateDateTime(index) : []; }, [index]); const columns: EnhancedGridColumn[] = useMemo(() => { @@ -77,7 +77,7 @@ export function useMatrix( { id: "date", title: "Date", - type: ColumnDataType.DateTime, + type: ColumnTypes.DateTime, editable: false, }, ]; @@ -91,21 +91,21 @@ export function useMatrix( { id: "min", title: "Min", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 50, editable: false, }, { id: "max", title: "Max", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 50, editable: false, }, { id: "avg", title: "Avg", - type: ColumnDataType.Aggregate, + type: ColumnTypes.Aggregate, width: 50, editable: false, }, @@ -122,24 +122,28 @@ export function useMatrix( // Apply updates to the matrix data and store them in the pending updates list const applyUpdates = useCallback( - (updates: Array<{ coordinates: Item; value: number }>) => { - if (!currentState.data) { - return; - } - + (updates: GridUpdate[]) => { const updatedData = currentState.data.map((col) => [...col]); - const newUpdates = updates.map(({ coordinates: [row, col], value }) => { - updatedData[col][row] = value; - - return { - coordinates: [[col, row]], - operation: { - operation: Operator.EQ, - value, - }, - }; - }); + 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, @@ -149,22 +153,18 @@ export function useMatrix( [currentState, setState], ); - const handleCellEdit = function (coordinates: Item, newValue: number) { - applyUpdates([{ coordinates, value: newValue }]); + const handleCellEdit = function (update: GridUpdate) { + applyUpdates([update]); }; - const handleMultipleCellsEdit = function ( - newValues: Array<{ coordinates: Item; value: number }>, - fillPattern?: CellFillPattern, - ) { - applyUpdates(newValues); + const handleMultipleCellsEdit = function (updates: GridUpdate[]) { + applyUpdates(updates); }; const handleImport = async (file: File) => { try { await importFile(file, studyId, url); - - enqueueSnackbar(t("matrix.success.import"), { variant: "success" }); + await fetchMatrix(); } catch (e) { enqueueErrorSnackbar(t("matrix.error.import"), e as Error); } @@ -177,7 +177,7 @@ export function useMatrix( setIsSubmitting(true); try { - await editMatrix(studyId, url, currentState.pendingUpdates); + await updateMatrix(studyId, url, currentState.pendingUpdates); setState({ data: currentState.data, pendingUpdates: [] }); enqueueSnackbar(t("matrix.success.matrixUpdate"), { variant: "success", diff --git a/webapp/src/components/common/MatrixGrid/utils.test.ts b/webapp/src/components/common/MatrixGrid/utils.test.ts index 636918fa0e..26e42ff6eb 100644 --- a/webapp/src/components/common/MatrixGrid/utils.test.ts +++ b/webapp/src/components/common/MatrixGrid/utils.test.ts @@ -1,17 +1,17 @@ -import { TimeMetadataDTO } from "./types"; import { - ColumnDataType, - generateDateTime, - generateTimeSeriesColumns, -} from "./utils"; + MatrixIndex, + StudyOutputDownloadLevelDTO, +} from "../../../common/types"; +import { ColumnTypes } from "./types"; +import { generateDateTime, generateTimeSeriesColumns } from "./utils"; describe("generateDateTime", () => { test("generates correct number of dates", () => { - const metadata: TimeMetadataDTO = { + const metadata: MatrixIndex = { start_date: "2023-01-01T00:00:00Z", steps: 5, first_week_size: 7, - level: "daily", + level: StudyOutputDownloadLevelDTO.DAILY, }; const result = generateDateTime(metadata); expect(result).toHaveLength(5); @@ -55,18 +55,18 @@ describe("generateDateTime", () => { ], }, { - level: "yearly", + 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: TimeMetadataDTO = { + const metadata: MatrixIndex = { start_date: start, steps: expected.length, first_week_size: 7, - level: level as TimeMetadataDTO["level"], + level: level as MatrixIndex["level"], }; const result = generateDateTime(metadata); @@ -76,11 +76,11 @@ describe("generateDateTime", () => { ); test("handles edge cases", () => { - const metadata: TimeMetadataDTO = { + const metadata: MatrixIndex = { start_date: "2023-12-31T23:59:59Z", steps: 2, first_week_size: 7, - level: "hourly", + level: StudyOutputDownloadLevelDTO.HOURLY, }; const result = generateDateTime(metadata); expect(result).toEqual([ @@ -102,7 +102,7 @@ describe("generateTimeSeriesColumns", () => { { id: "data1", title: "TS 1", - type: ColumnDataType.Number, + type: ColumnTypes.Number, style: "normal", width: 50, editable: true, @@ -110,7 +110,7 @@ describe("generateTimeSeriesColumns", () => { { id: "data2", title: "TS 2", - type: ColumnDataType.Number, + type: ColumnTypes.Number, style: "normal", width: 50, editable: true, @@ -118,7 +118,7 @@ describe("generateTimeSeriesColumns", () => { { id: "data3", title: "TS 3", - type: ColumnDataType.Number, + type: ColumnTypes.Number, style: "normal", width: 50, editable: true, @@ -138,7 +138,7 @@ describe("generateTimeSeriesColumns", () => { { id: "data10", title: "Data 10", - type: ColumnDataType.Number, + type: ColumnTypes.Number, style: "normal", width: 80, editable: false, @@ -146,7 +146,7 @@ describe("generateTimeSeriesColumns", () => { { id: "data11", title: "Data 11", - type: ColumnDataType.Number, + type: ColumnTypes.Number, style: "normal", width: 80, editable: false, @@ -169,7 +169,7 @@ describe("generateTimeSeriesColumns", () => { test("maintains consistent type and style", () => { const result = generateTimeSeriesColumns({ count: 1000 }); result.forEach((column) => { - expect(column.type).toBe(ColumnDataType.Number); + 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 index 66278fe838..8dfefd802f 100644 --- a/webapp/src/components/common/MatrixGrid/utils.ts +++ b/webapp/src/components/common/MatrixGrid/utils.ts @@ -1,26 +1,12 @@ import moment from "moment"; import { - TimeMetadataDTO, DateIncrementStrategy, EnhancedGridColumn, + ColumnTypes, } from "./types"; import { getCurrentLanguage } from "../../../utils/i18nUtils"; import { Theme } from "@glideapps/glide-data-grid"; - -//////////////////////////////////////////////////////////////// -// Enums -//////////////////////////////////////////////////////////////// - -export const ColumnDataType = { - DateTime: "datetime", - Number: "number", - Text: "text", - Aggregate: "aggregate", -} as const; - -//////////////////////////////////////////////////////////////// -// Utils -//////////////////////////////////////////////////////////////// +import { MatrixIndex } from "../../../common/types"; export const darkTheme: Theme = { accentColor: "rgba(255, 184, 0, 0.9)", @@ -45,7 +31,7 @@ export const darkTheme: Theme = { borderColor: "rgba(255, 255, 255, 0.12)", drilldownBorder: "rgba(255, 255, 255, 0.35)", linkColor: "#818CF8", - headerFontStyle: "bold 13px", + headerFontStyle: "bold 11px", baseFontStyle: "13px", fontFamily: "Inter, sans-serif", editorFontSize: "13px", @@ -70,14 +56,14 @@ export const readOnlyDarkTheme: Partial = { }; const dateIncrementStrategies: Record< - TimeMetadataDTO["level"], + 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"), - yearly: (date, step) => date.clone().add(step, "years"), + annual: (date, step) => date.clone().add(step, "years"), }; const dateTimeFormatOptions: Intl.DateTimeFormatOptions = { @@ -117,7 +103,6 @@ const dateTimeFormatOptions: Intl.DateTimeFormatOptions = { 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); @@ -150,7 +135,7 @@ export function formatDateTime(dateTime: string): string { * "2023-01-03T00:00:00.000Z" * ] * - * @see {@link TimeMetadataDTO} for the structure of the timeMetadata object. + * @see {@link MatrixIndex} for the structure of the timeMetadata object. * @see {@link DateIncrementStrategy} for the date increment strategy type. */ export function generateDateTime({ @@ -158,7 +143,7 @@ export function generateDateTime({ start_date, steps, level, -}: TimeMetadataDTO): string[] { +}: MatrixIndex): string[] { const startDate = moment.utc(start_date, "YYYY-MM-DD HH:mm:ss"); const incrementStrategy = dateIncrementStrategies[level]; @@ -181,12 +166,12 @@ export function generateDateTime({ * * @example Usage within a column definition array * const columns = [ - * { id: "rowHeaders", title: "", type: ColumnDataType.Text, ... }, - * { id: "date", title: "Date", type: ColumnDataType.DateTime, ... }, + * { id: "rowHeaders", title: "", type: ColumnTypes.Text, ... }, + * { id: "date", title: "Date", type: ColumnTypes.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, ... } + * { id: "min", title: "Min", type: ColumnTypes.Aggregate, ... }, + * { id: "max", title: "Max", type: ColumnTypes.Aggregate, ... }, + * { id: "avg", title: "Avg", type: ColumnTypes.Aggregate, ... } * ]; */ export function generateTimeSeriesColumns({ @@ -207,7 +192,7 @@ export function generateTimeSeriesColumns({ return Array.from({ length: count }, (_, index) => ({ id: `data${startIndex + index}`, title: `${prefix} ${startIndex + index}`, - type: ColumnDataType.Number, + 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 ad80cfa92c..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,6 +92,13 @@ 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, @@ -104,6 +112,19 @@ export const editMatrix = async ( ); }; +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 ( sid: string, path?: string,