From 7efee9c48d67c9a7f1f21262f4e5ba5c237c84d5 Mon Sep 17 00:00:00 2001 From: Conor Brady Date: Wed, 11 Sep 2024 16:53:15 -0700 Subject: [PATCH 01/19] Validation on editable fields --- .vscode/settings.json | 1 + .../moreCast2/components/ColumnDefBuilder.tsx | 70 +++++++++++++---- .../moreCast2/components/DataGridColumns.tsx | 1 + .../moreCast2/components/EditInputCell.tsx | 71 +++++++++++++++++ .../moreCast2/components/MoreCast2Column.tsx | 78 ++++++++++++++++--- 5 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 web/src/features/moreCast2/components/EditInputCell.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 787b1a898..844895d45 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -67,6 +67,7 @@ "cffdrs", "colour", "cutline", + "CWFIS", "determinates", "excinfo", "fastapi", diff --git a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx index db2537f93..46cbb871a 100644 --- a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx +++ b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx @@ -3,7 +3,10 @@ import { GridCellParams, GridColDef, GridColumnHeaderParams, + GridEditCellProps, + GridPreProcessEditCellProps, GridRenderCellParams, + GridRenderEditCellParams, GridValueFormatterParams, GridValueGetterParams, GridValueSetterParams @@ -12,6 +15,7 @@ import { WeatherDeterminate, WeatherDeterminateType } from 'api/moreCast2API' import { modelColorClass, modelHeaderColorClass } from 'app/theme' import { GridComponentRenderer } from 'features/moreCast2/components/GridComponentRenderer' import { ColumnClickHandlerProps } from 'features/moreCast2/components/TabbedDataGrid' +import { EditInputCell } from '@/features/moreCast2/components/EditInputCell' export const DEFAULT_COLUMN_WIDTH = 80 export const DEFAULT_FORECAST_COLUMN_WIDTH = 145 @@ -44,17 +48,29 @@ export const GC_HEADER = 'GC' export interface ForecastColDefGenerator { getField: () => string - generateForecastColDef: (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => GridColDef - generateForecastSummaryColDef: (columnClickHandlerProps: ColumnClickHandlerProps) => GridColDef + generateForecastColDef: ( + columnClickHandlerProps: ColumnClickHandlerProps, + headerName?: string, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise + ) => GridColDef + generateForecastSummaryColDef: ( + columnClickHandlerProps: ColumnClickHandlerProps, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise + ) => GridColDef } export interface ColDefGenerator { getField: () => string - generateColDef: (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => GridColDef + generateColDef: ( + columnClickHandlerProps: ColumnClickHandlerProps, + headerName?: string, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise + ) => GridColDef generateColDefs: ( columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string, - includeBiasFields?: boolean + includeBiasFields?: boolean, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise ) => GridColDef[] } @@ -73,34 +89,48 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato return this.generateColDefWith(this.field, this.headerName, this.precision, DEFAULT_COLUMN_WIDTH) } - public generateForecastColDef = (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => { + private renderEditCell(params: GridRenderEditCellParams) { + return + } + + public generateForecastColDef = ( + columnClickHandlerProps: ColumnClickHandlerProps, + headerName?: string, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise + ) => { return this.generateForecastColDefWith( `${this.field}${WeatherDeterminate.FORECAST}`, headerName ?? this.headerName, this.precision, columnClickHandlerProps, - DEFAULT_FORECAST_COLUMN_WIDTH + DEFAULT_FORECAST_COLUMN_WIDTH, + preProcessEditCellProps ) } - public generateForecastSummaryColDef = (columnClickHandlerProps: ColumnClickHandlerProps) => { + public generateForecastSummaryColDef = ( + columnClickHandlerProps: ColumnClickHandlerProps, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise + ) => { return this.generateForecastSummaryColDefWith( `${this.field}${WeatherDeterminate.FORECAST}`, this.headerName, this.precision, columnClickHandlerProps, - DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH + DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH, + preProcessEditCellProps ) } public generateColDefs = ( columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string, - includeBiasFields = true + includeBiasFields = true, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise ) => { const gridColDefs: GridColDef[] = [] // Forecast columns have unique requirement (eg. column header menu, editable, etc.) - const forecastColDef = this.generateForecastColDef(columnClickHandlerProps, headerName) + const forecastColDef = this.generateForecastColDef(columnClickHandlerProps, headerName, preProcessEditCellProps) gridColDefs.push(forecastColDef) for (const colDef of this.generateNonForecastColDefs(includeBiasFields)) { @@ -119,7 +149,13 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato ) } - public generateColDefWith = (field: string, headerName: string, precision: number, width?: number) => { + public generateColDefWith = ( + field: string, + headerName: string, + precision: number, + width?: number, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise + ) => { return { field, disableColumnMenu: true, @@ -129,6 +165,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato sortable: false, type: 'number', width: width ?? DEFAULT_COLUMN_WIDTH, + renderEditCell: this.renderEditCell, + preProcessEditCellProps, cellClassName: (params: Pick) => { return modelColorClass(params) }, @@ -154,7 +192,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato headerName: string, precision: number, columnClickHandlerProps: ColumnClickHandlerProps, - width?: number + width?: number, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise ) => { const isGrassField = field.includes('grass') const isCalcField = field.includes('Calc') @@ -171,6 +210,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato sortable: false, type: 'number', width: width ?? DEFAULT_FORECAST_COLUMN_WIDTH, + renderEditCell: this.renderEditCell, + preProcessEditCellProps, renderHeader: (params: GridColumnHeaderParams) => { return isCalcField || isGrassField ? this.gridComponentRenderer.renderHeaderWith(params) @@ -196,7 +237,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato headerName: string, precision: number, columnClickHandlerProps: ColumnClickHandlerProps, - width?: number + width?: number, + preProcessEditCellProps?: (params: GridPreProcessEditCellProps) => GridEditCellProps | Promise ) => { const isGrassField = field.includes('grass') const isCalcField = field.includes('Calc') @@ -213,6 +255,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato sortable: false, type: 'number', width: width ?? DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH, + preProcessEditCellProps, + renderEditCell: this.renderEditCell, renderHeader: (params: GridColumnHeaderParams) => { return isCalcField || isGrassField ? this.gridComponentRenderer.renderHeaderWith(params) diff --git a/web/src/features/moreCast2/components/DataGridColumns.tsx b/web/src/features/moreCast2/components/DataGridColumns.tsx index 8f9157515..06dcf8d7d 100644 --- a/web/src/features/moreCast2/components/DataGridColumns.tsx +++ b/web/src/features/moreCast2/components/DataGridColumns.tsx @@ -83,6 +83,7 @@ export class DataGridColumns { tabColumns.push(gcForecastField) tabColumns.push(gcCwfisField) + tabColumns.map(column => column.preProcessEditCellProps) return tabColumns } diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx new file mode 100644 index 000000000..469ebdb39 --- /dev/null +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -0,0 +1,71 @@ +import { GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid-pro' +import { styled } from '@mui/material/styles' +import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip' +import React, { useRef, useEffect } from 'react' +import { TextField } from '@mui/material' +import { theme } from '@/app/theme' + +const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.error.main, + color: theme.palette.error.contrastText + } +})) + +export const EditInputCell = (props: GridRenderEditCellParams) => { + const { id, value, field, hasFocus, error } = props + const apiRef = useGridApiContext() + const inputRef = useRef(null) + + useEffect(() => { + if (hasFocus && inputRef.current) { + inputRef.current.focus() + } + }, [hasFocus]) + + const handleValueChange = (event: React.ChangeEvent) => { + const newValue = event.target.value // The new value entered by the user + apiRef.current.setEditCellValue({ id, field, value: newValue }) + } + + const handleBlur = () => { + // Commit the value when focus is lost + apiRef.current.stopCellEditMode({ id, field }) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + // Just exit edit mode without reverting changes + apiRef.current.stopCellEditMode({ id, field }) + } + } + + return ( + + + + ) +} diff --git a/web/src/features/moreCast2/components/MoreCast2Column.tsx b/web/src/features/moreCast2/components/MoreCast2Column.tsx index ffcbd4afc..8810cba9e 100644 --- a/web/src/features/moreCast2/components/MoreCast2Column.tsx +++ b/web/src/features/moreCast2/components/MoreCast2Column.tsx @@ -1,4 +1,4 @@ -import { GridValueFormatterParams } from '@mui/x-data-grid-pro' +import { GridEditCellProps, GridPreProcessEditCellProps, GridValueFormatterParams } from '@mui/x-data-grid-pro' import { DateTime } from 'luxon' import { ColDefGenerator, @@ -136,7 +136,10 @@ export class IndeterminateField implements ColDefGenerator, ForecastColDefGenera readonly headerName: string, readonly type: 'string' | 'number', readonly precision: number, - readonly includeBias: boolean + readonly includeBias: boolean, + readonly preProcessEditCellProps?: ( + params: GridPreProcessEditCellProps + ) => GridEditCellProps | Promise ) { this.colDefBuilder = new ColumnDefBuilder( this.field, @@ -152,28 +155,83 @@ export class IndeterminateField implements ColDefGenerator, ForecastColDefGenera } public generateForecastColDef = (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => { return { - ...this.colDefBuilder.generateForecastColDef(columnClickHandlerProps, headerName ?? this.headerName) + ...this.colDefBuilder.generateForecastColDef( + columnClickHandlerProps, + headerName ?? this.headerName, + this.preProcessEditCellProps + ) } } public generateForecastSummaryColDef = (columnClickHandlerProps: ColumnClickHandlerProps) => { - return this.colDefBuilder.generateForecastColDef(columnClickHandlerProps) + return this.colDefBuilder.generateForecastColDef(columnClickHandlerProps, undefined, this.preProcessEditCellProps) } public generateColDef = () => { - return this.colDefBuilder.generateColDefWith(this.field, this.headerName, this.precision) + return this.colDefBuilder.generateColDefWith( + this.field, + this.headerName, + this.precision, + undefined, + this.preProcessEditCellProps + ) } public generateColDefs = (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => { - return this.colDefBuilder.generateColDefs(columnClickHandlerProps, headerName ?? this.headerName, this.includeBias) + return this.colDefBuilder.generateColDefs( + columnClickHandlerProps, + headerName ?? this.headerName, + this.includeBias, + this.preProcessEditCellProps + ) } } -export const tempForecastField = new IndeterminateField('temp', TEMP_HEADER, 'number', 0, true) -export const rhForecastField = new IndeterminateField('rh', RH_HEADER, 'number', 0, true) -export const windDirForecastField = new IndeterminateField('windDirection', WIND_DIR_HEADER, 'number', 0, true) +export const tempForecastField = new IndeterminateField( + 'temp', + TEMP_HEADER, + 'number', + 0, + true, + (params: GridPreProcessEditCellProps) => { + const hasError = params.props.value < -60 || params.props.value > 60 + return { ...params.props, error: hasError } + } +) +export const rhForecastField = new IndeterminateField( + 'rh', + RH_HEADER, + 'number', + 0, + true, + (params: GridPreProcessEditCellProps) => { + const hasError = params.props.value < 0 || params.props.value > 100 + return { ...params.props, error: hasError } + } +) +export const windDirForecastField = new IndeterminateField( + 'windDirection', + WIND_DIR_HEADER, + 'number', + 0, + true, + (params: GridPreProcessEditCellProps) => { + const hasError = params.props.value < 0 || params.props.value > 360 + return { ...params.props, error: hasError } + } +) export const windSpeedForecastField = new IndeterminateField('windSpeed', WIND_SPEED_HEADER, 'number', 0, true) -export const precipForecastField = new IndeterminateField('precip', PRECIP_HEADER, 'number', 1, true) +export const precipForecastField = new IndeterminateField( + 'precip', + PRECIP_HEADER, + 'number', + 1, + true, + (params: GridPreProcessEditCellProps) => { + const hasError = params.props.value < 0.0 || params.props.value > 200.0 + return { ...params.props, error: hasError } + } +) export const gcForecastField = new IndeterminateField('grassCuring', GC_HEADER, 'number', 0, false) export const buiField = new IndeterminateField('buiCalc', 'BUI', 'number', 0, false) From ff8b983258d751d66e7146e032fb691c3a8ede21 Mon Sep 17 00:00:00 2001 From: Conor Brady Date: Wed, 11 Sep 2024 16:58:05 -0700 Subject: [PATCH 02/19] Undo unnecessary change --- web/src/features/moreCast2/components/DataGridColumns.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/features/moreCast2/components/DataGridColumns.tsx b/web/src/features/moreCast2/components/DataGridColumns.tsx index 06dcf8d7d..8f9157515 100644 --- a/web/src/features/moreCast2/components/DataGridColumns.tsx +++ b/web/src/features/moreCast2/components/DataGridColumns.tsx @@ -83,7 +83,6 @@ export class DataGridColumns { tabColumns.push(gcForecastField) tabColumns.push(gcCwfisField) - tabColumns.map(column => column.preProcessEditCellProps) return tabColumns } From 4ed4c4f4ef94f95559890318b1e8c2446e1626dd Mon Sep 17 00:00:00 2001 From: Conor Brady Date: Wed, 11 Sep 2024 16:59:35 -0700 Subject: [PATCH 03/19] Remove comments --- web/src/features/moreCast2/components/EditInputCell.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx index 469ebdb39..af45e2b61 100644 --- a/web/src/features/moreCast2/components/EditInputCell.tsx +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -26,18 +26,16 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { }, [hasFocus]) const handleValueChange = (event: React.ChangeEvent) => { - const newValue = event.target.value // The new value entered by the user + const newValue = event.target.value apiRef.current.setEditCellValue({ id, field, value: newValue }) } const handleBlur = () => { - // Commit the value when focus is lost apiRef.current.stopCellEditMode({ id, field }) } const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') { - // Just exit edit mode without reverting changes apiRef.current.stopCellEditMode({ id, field }) } } From b94d27f5975eea8bae9a266f0971e314d6496a74 Mon Sep 17 00:00:00 2001 From: Conor Brady Date: Wed, 11 Sep 2024 17:21:56 -0700 Subject: [PATCH 04/19] More explicit messages --- .../features/moreCast2/components/EditInputCell.tsx | 2 +- .../features/moreCast2/components/MoreCast2Column.tsx | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx index af45e2b61..bcdb843c3 100644 --- a/web/src/features/moreCast2/components/EditInputCell.tsx +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -41,7 +41,7 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { } return ( - + { - const hasError = params.props.value < -60 || params.props.value > 60 + const hasError = + params.props.value < -60 || params.props.value > 60 ? 'Temp must be between -60 and 60 degrees C' : false return { ...params.props, error: hasError } } ) @@ -205,7 +206,7 @@ export const rhForecastField = new IndeterminateField( 0, true, (params: GridPreProcessEditCellProps) => { - const hasError = params.props.value < 0 || params.props.value > 100 + const hasError = params.props.value < 0 || params.props.value > 100 ? 'RH must be between 0 and 100' : false return { ...params.props, error: hasError } } ) @@ -216,7 +217,8 @@ export const windDirForecastField = new IndeterminateField( 0, true, (params: GridPreProcessEditCellProps) => { - const hasError = params.props.value < 0 || params.props.value > 360 + const hasError = + params.props.value < 0 || params.props.value > 360 ? 'Wind direction must be between 0 and 360 degrees' : false return { ...params.props, error: hasError } } ) @@ -228,7 +230,8 @@ export const precipForecastField = new IndeterminateField( 1, true, (params: GridPreProcessEditCellProps) => { - const hasError = params.props.value < 0.0 || params.props.value > 200.0 + const hasError = + params.props.value < 0.0 || params.props.value > 200.0 ? 'Precip must be between 0 and 200 mm' : false return { ...params.props, error: hasError } } ) From 508674875d2f440ed9127ee2ec25a2e9b7b92502 Mon Sep 17 00:00:00 2001 From: Conor Brady Date: Thu, 12 Sep 2024 13:01:16 -0700 Subject: [PATCH 05/19] Force user to fix issue --- .../moreCast2/components/EditInputCell.tsx | 40 ++++++++++++------- .../moreCast2/components/MoreCast2Column.tsx | 13 +++++- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx index bcdb843c3..0b63ff00e 100644 --- a/web/src/features/moreCast2/components/EditInputCell.tsx +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -1,19 +1,9 @@ import { GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid-pro' -import { styled } from '@mui/material/styles' -import Tooltip, { tooltipClasses, TooltipProps } from '@mui/material/Tooltip' +import Tooltip from '@mui/material/Tooltip' import React, { useRef, useEffect } from 'react' import { TextField } from '@mui/material' import { theme } from '@/app/theme' -const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( - -))(({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - backgroundColor: theme.palette.error.main, - color: theme.palette.error.contrastText - } -})) - export const EditInputCell = (props: GridRenderEditCellParams) => { const { id, value, field, hasFocus, error } = props const apiRef = useGridApiContext() @@ -36,12 +26,27 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') { - apiRef.current.stopCellEditMode({ id, field }) + event.stopPropagation() + if (!error) { + apiRef.current.stopCellEditMode({ id, field }) + } else { + event.stopPropagation() + } } } return ( - + { '& fieldset': { borderColor: error ? theme.palette.error.main : '#737373', borderWidth: '2px' + }, + '&:hover fieldset': { + borderColor: error ? theme.palette.error.main : '#737373' + }, + '&.Mui-focused fieldset': { + borderColor: error ? theme.palette.error.main : '#737373', + borderWidth: '2px' } } }} @@ -64,6 +76,6 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { onBlur={handleBlur} onKeyDown={handleKeyDown} /> - + ) } diff --git a/web/src/features/moreCast2/components/MoreCast2Column.tsx b/web/src/features/moreCast2/components/MoreCast2Column.tsx index 7afb1d109..ea1213408 100644 --- a/web/src/features/moreCast2/components/MoreCast2Column.tsx +++ b/web/src/features/moreCast2/components/MoreCast2Column.tsx @@ -222,7 +222,18 @@ export const windDirForecastField = new IndeterminateField( return { ...params.props, error: hasError } } ) -export const windSpeedForecastField = new IndeterminateField('windSpeed', WIND_SPEED_HEADER, 'number', 0, true) +export const windSpeedForecastField = new IndeterminateField( + 'windSpeed', + WIND_SPEED_HEADER, + 'number', + 0, + true, + (params: GridPreProcessEditCellProps) => { + const hasError = + params.props.value < 0 || params.props.value > 360 ? 'Wind speed must be between 0 and 120 km/hr' : false + return { ...params.props, error: hasError } + } +) export const precipForecastField = new IndeterminateField( 'precip', PRECIP_HEADER, From 6228942c920f40a3e48d595b2827129a3cbe2e30 Mon Sep 17 00:00:00 2001 From: Conor Brady Date: Thu, 12 Sep 2024 13:49:38 -0700 Subject: [PATCH 06/19] Make error a string always --- .../moreCast2/components/EditInputCell.tsx | 6 +- .../moreCast2/components/MoreCast2Column.tsx | 26 ++-- .../components/editInputCell.test.tsx | 117 ++++++++++++++++++ 3 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 web/src/features/moreCast2/components/editInputCell.test.tsx diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx index 0b63ff00e..106e152ba 100644 --- a/web/src/features/moreCast2/components/EditInputCell.tsx +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -3,6 +3,7 @@ import Tooltip from '@mui/material/Tooltip' import React, { useRef, useEffect } from 'react' import { TextField } from '@mui/material' import { theme } from '@/app/theme' +import { isEmpty } from 'lodash' export const EditInputCell = (props: GridRenderEditCellParams) => { const { id, value, field, hasFocus, error } = props @@ -27,7 +28,7 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation() - if (!error) { + if (isEmpty(error)) { apiRef.current.stopCellEditMode({ id, field }) } else { event.stopPropagation() @@ -37,8 +38,9 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { return ( { - const hasError = - params.props.value < -60 || params.props.value > 60 ? 'Temp must be between -60 and 60 degrees C' : false - return { ...params.props, error: hasError } + const error = params.props.value < -60 || params.props.value > 60 ? 'Temp must be between -60 and 60 degrees C' : '' + return { ...params.props, error } } ) export const rhForecastField = new IndeterminateField( @@ -206,8 +205,8 @@ export const rhForecastField = new IndeterminateField( 0, true, (params: GridPreProcessEditCellProps) => { - const hasError = params.props.value < 0 || params.props.value > 100 ? 'RH must be between 0 and 100' : false - return { ...params.props, error: hasError } + const error = params.props.value < 0 || params.props.value > 100 ? 'RH must be between 0 and 100' : '' + return { ...params.props, error } } ) export const windDirForecastField = new IndeterminateField( @@ -217,9 +216,9 @@ export const windDirForecastField = new IndeterminateField( 0, true, (params: GridPreProcessEditCellProps) => { - const hasError = - params.props.value < 0 || params.props.value > 360 ? 'Wind direction must be between 0 and 360 degrees' : false - return { ...params.props, error: hasError } + const error = + params.props.value < 0 || params.props.value > 360 ? 'Wind direction must be between 0 and 360 degrees' : '' + return { ...params.props, error } } ) export const windSpeedForecastField = new IndeterminateField( @@ -229,9 +228,9 @@ export const windSpeedForecastField = new IndeterminateField( 0, true, (params: GridPreProcessEditCellProps) => { - const hasError = - params.props.value < 0 || params.props.value > 360 ? 'Wind speed must be between 0 and 120 km/hr' : false - return { ...params.props, error: hasError } + const error = + params.props.value < 0 || params.props.value > 360 ? 'Wind speed must be between 0 and 120 degrees' : '' + return { ...params.props, error } } ) export const precipForecastField = new IndeterminateField( @@ -241,9 +240,8 @@ export const precipForecastField = new IndeterminateField( 1, true, (params: GridPreProcessEditCellProps) => { - const hasError = - params.props.value < 0.0 || params.props.value > 200.0 ? 'Precip must be between 0 and 200 mm' : false - return { ...params.props, error: hasError } + const error = params.props.value < 0.0 || params.props.value > 200.0 ? 'Precip must be between 0 and 200 mm' : '' + return { ...params.props, error } } ) export const gcForecastField = new IndeterminateField('grassCuring', GC_HEADER, 'number', 0, false) diff --git a/web/src/features/moreCast2/components/editInputCell.test.tsx b/web/src/features/moreCast2/components/editInputCell.test.tsx new file mode 100644 index 000000000..4233e2b4a --- /dev/null +++ b/web/src/features/moreCast2/components/editInputCell.test.tsx @@ -0,0 +1,117 @@ +import { render, fireEvent, within } from '@testing-library/react' +import { GridApiContext, GridCellMode, GridTreeNodeWithRender } from '@mui/x-data-grid-pro' +import { EditInputCell } from '@/features/moreCast2/components/EditInputCell' +import { vi } from 'vitest' +import { GridApiCommunity, GridStateColDef } from '@mui/x-data-grid-pro/internals' + +const mockSetEditCellValue = vi.fn() +const mockStopCellEditMode = vi.fn() + +// Mock API context +const apiMock = { + current: { + setEditCellValue: mockSetEditCellValue, + stopCellEditMode: mockStopCellEditMode + } +} as unknown as GridApiCommunity + +// Mock GridRenderEditCellParams +const defaultProps = { + id: 1, + api: apiMock, + row: undefined, + rowNode: undefined as unknown as GridTreeNodeWithRender, + colDef: undefined as unknown as GridStateColDef, + cellMode: 'edit' as GridCellMode, + tabIndex: 0 as 0 | -1, + value: '10', + field: 'test', + hasFocus: false, + error: '' +} + +describe('EditInputCell Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('should focus input when hasFocus is true', () => { + const { getByTestId } = render( + + + + ) + const input = within(getByTestId('forecast-edit-cell')).getByRole('spinbutton') as HTMLInputElement + expect(input).toHaveFocus() + }) + + test('should call setEditCellValue on value change', () => { + const { getByTestId } = render( + + + + ) + + const input = within(getByTestId('forecast-edit-cell')).getByRole('spinbutton') as HTMLInputElement + expect(input.value).toBe('10') + // Change the value and fire the event + fireEvent.change(input, { target: { value: '20' } }) + expect(mockSetEditCellValue).toHaveBeenCalledWith({ id: 1, field: 'test', value: '20' }) + }) + + test('should call stopCellEditMode on blur', () => { + const { getByTestId } = render( + + + + ) + + const input = within(getByTestId('forecast-edit-cell')).getByRole('spinbutton') as HTMLInputElement + input.focus() + fireEvent.blur(input) + + expect(mockStopCellEditMode).toHaveBeenCalledWith({ id: 1, field: 'test' }) + }) + + test('should handle Escape key press', () => { + const { getByTestId } = render( + + + + ) + + const input = within(getByTestId('forecast-edit-cell')).getByRole('spinbutton') as HTMLInputElement + input.focus() + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', charCode: 27 }) + + expect(mockStopCellEditMode).toHaveBeenCalledWith({ id: 1, field: 'test' }) + }) + + test('should not call stopCellEditMode when Escape key is pressed and there is an error', () => { + const { getByTestId } = render( + + + + ) + + const input = within(getByTestId('forecast-edit-cell')).getByRole('spinbutton') as HTMLInputElement + input.focus() + // Simulate Escape key press + fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', charCode: 27 }) + + // Verify that stopCellEditMode was not called + expect(mockStopCellEditMode).not.toHaveBeenCalled() + }) + + test('should show tooltip with error and style correctly', () => { + const { getByRole } = render( + + + + ) + + const tooltip = getByRole('tooltip') + expect(tooltip).toBeVisible() + expect(tooltip).toHaveTextContent('Test error') + }) +}) From 2d6f0625c677c8b63e3fb0902e532233ef1d8280 Mon Sep 17 00:00:00 2001 From: Conor Brady Date: Thu, 12 Sep 2024 15:15:49 -0700 Subject: [PATCH 07/19] Add more tests, disable publish button when invalid --- web/src/app/rootReducer.ts | 4 +- .../moreCast2/components/EditInputCell.tsx | 6 +++ .../components/SaveForecastButton.tsx | 6 ++- .../moreCast2/components/TabbedDataGrid.tsx | 5 +- .../components/editInputCell.test.tsx | 48 ++++++++++++++++++- .../components/saveForecastButton.test.tsx | 21 ++++++++ .../moreCast2/slices/validInputSlice.ts | 26 ++++++++++ 7 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 web/src/features/moreCast2/slices/validInputSlice.ts diff --git a/web/src/app/rootReducer.ts b/web/src/app/rootReducer.ts index 1153a6667..0e6762cc2 100644 --- a/web/src/app/rootReducer.ts +++ b/web/src/app/rootReducer.ts @@ -17,6 +17,7 @@ import fireZoneElevationInfoSlice from 'features/fba/slices/fireZoneElevationInf import stationGroupsSlice from 'commonSlices/stationGroupsSlice' import selectedStationGroupsMembersSlice from 'commonSlices/selectedStationGroupMembers' import dataSlice from 'features/moreCast2/slices/dataSlice' +import morecastInputValidSlice from 'features/moreCast2/slices/validInputSlice' import selectedStationsSlice from 'features/moreCast2/slices/selectedStationsSlice' import provincialSummarySlice from 'features/fba/slices/provincialSummarySlice' import fireCentreTPIStatsSlice from 'features/fba/slices/fireCentreTPIStatsSlice' @@ -44,7 +45,8 @@ const rootReducer = combineReducers({ stationGroupsMembers: selectedStationGroupsMembersSlice, weatherIndeterminates: dataSlice, selectedStations: selectedStationsSlice, - provincialSummary: provincialSummarySlice + provincialSummary: provincialSummarySlice, + morecastInputValid: morecastInputValidSlice }) // Infer whatever gets returned from rootReducer and use it as the type of the root state diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx index 106e152ba..40502afc4 100644 --- a/web/src/features/moreCast2/components/EditInputCell.tsx +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -4,11 +4,17 @@ import React, { useRef, useEffect } from 'react' import { TextField } from '@mui/material' import { theme } from '@/app/theme' import { isEmpty } from 'lodash' +import { AppDispatch } from '@/app/store' +import { useDispatch } from 'react-redux' +import { setInputValid } from '@/features/moreCast2/slices/validInputSlice' export const EditInputCell = (props: GridRenderEditCellParams) => { const { id, value, field, hasFocus, error } = props const apiRef = useGridApiContext() const inputRef = useRef(null) + const dispatch: AppDispatch = useDispatch() + + dispatch(setInputValid(isEmpty(error))) useEffect(() => { if (hasFocus && inputRef.current) { diff --git a/web/src/features/moreCast2/components/SaveForecastButton.tsx b/web/src/features/moreCast2/components/SaveForecastButton.tsx index 140ad97da..5b75139f8 100644 --- a/web/src/features/moreCast2/components/SaveForecastButton.tsx +++ b/web/src/features/moreCast2/components/SaveForecastButton.tsx @@ -1,6 +1,8 @@ import React from 'react' import SaveIcon from '@mui/icons-material/Save' import { Button } from '@mui/material' +import { useSelector } from 'react-redux' +import { selectMorecastInputValid } from '@/features/moreCast2/slices/validInputSlice' export interface SubmitForecastButtonProps { className?: string @@ -10,12 +12,14 @@ export interface SubmitForecastButtonProps { } const SaveForecastButton = ({ className, enabled, label, onClick }: SubmitForecastButtonProps) => { + const isValid = useSelector(selectMorecastInputValid) + return (