Skip to content

Commit

Permalink
Morecast: Validation on editable fields (#3919)
Browse files Browse the repository at this point in the history
Adds an optional `validator` function for `IndeterminateField` that gets called in `preProcessEditCellProps` callback for cells in edit mode to set the error prop if validation doesn't pass for the forecast parameter. The same `validator` function is passed down to cells in view mode to do the same thing.

Adds the `EditInputCell` component as the render component for cells in edit mode that gets rendered by the `renderEditCell` callback on the `GridColDef`. `EditInputCell` props include an empty or non-empty `error` string that drives the visibility of the tooltip and red border.

Similar behaviour for the `renderCell` callback for cells in view mode, except it doesn't receive the `error` string in props so it calls `validator` itself. This is to keep the same behaviour whether the user is actively editing or not and the current value is invalid.

Opted to prevent edits from committing locally when invalid so that invalid values aren't stored in drafts.
  • Loading branch information
conbrad authored Sep 16, 2024
1 parent b7d7bc0 commit b541b68
Show file tree
Hide file tree
Showing 16 changed files with 619 additions and 72 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"cffdrs",
"colour",
"cutline",
"CWFIS",
"determinates",
"excinfo",
"fastapi",
Expand Down
4 changes: 3 additions & 1 deletion web/src/app/rootReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
77 changes: 63 additions & 14 deletions web/src/features/moreCast2/components/ColumnDefBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
GridCellParams,
GridColDef,
GridColumnHeaderParams,
GridPreProcessEditCellProps,
GridRenderCellParams,
GridRenderEditCellParams,
GridValueFormatterParams,
GridValueGetterParams,
GridValueSetterParams
Expand All @@ -12,6 +14,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
Expand Down Expand Up @@ -44,17 +47,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,
validator?: (value: string) => string
) => GridColDef
generateForecastSummaryColDef: (
columnClickHandlerProps: ColumnClickHandlerProps,
validator?: (value: string) => string
) => GridColDef
}

export interface ColDefGenerator {
getField: () => string
generateColDef: (columnClickHandlerProps: ColumnClickHandlerProps, headerName?: string) => GridColDef
generateColDef: (
columnClickHandlerProps: ColumnClickHandlerProps,
headerName?: string,
validator?: (value: string) => string
) => GridColDef
generateColDefs: (
columnClickHandlerProps: ColumnClickHandlerProps,
headerName?: string,
includeBiasFields?: boolean
includeBiasFields?: boolean,
validator?: (value: string) => string
) => GridColDef[]
}

Expand All @@ -73,34 +88,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 <EditInputCell {...params} />
}

public generateForecastColDef = (
columnClickHandlerProps: ColumnClickHandlerProps,
headerName?: string,
validator?: (value: string) => string
) => {
return this.generateForecastColDefWith(
`${this.field}${WeatherDeterminate.FORECAST}`,
headerName ?? this.headerName,
this.precision,
columnClickHandlerProps,
DEFAULT_FORECAST_COLUMN_WIDTH
DEFAULT_FORECAST_COLUMN_WIDTH,
validator
)
}

public generateForecastSummaryColDef = (columnClickHandlerProps: ColumnClickHandlerProps) => {
public generateForecastSummaryColDef = (
columnClickHandlerProps: ColumnClickHandlerProps,
validator?: (value: string) => string
) => {
return this.generateForecastSummaryColDefWith(
`${this.field}${WeatherDeterminate.FORECAST}`,
this.headerName,
this.precision,
columnClickHandlerProps,
DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH
DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH,
validator
)
}

public generateColDefs = (
columnClickHandlerProps: ColumnClickHandlerProps,
headerName?: string,
includeBiasFields = true
includeBiasFields = true,
validator?: (value: string) => string
) => {
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, validator)
gridColDefs.push(forecastColDef)

for (const colDef of this.generateNonForecastColDefs(includeBiasFields)) {
Expand All @@ -119,7 +148,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,
validator?: (value: string) => string
) => {
return {
field,
disableColumnMenu: true,
Expand All @@ -129,6 +164,10 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato
sortable: false,
type: 'number',
width: width ?? DEFAULT_COLUMN_WIDTH,
renderEditCell: this.renderEditCell,
preProcessEditCellProps: (params: GridPreProcessEditCellProps) => {
return { ...params.props, error: validator ? validator(params.props.value) : '' }
},
cellClassName: (params: Pick<GridCellParams, 'colDef' | 'field'>) => {
return modelColorClass(params)
},
Expand All @@ -154,7 +193,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato
headerName: string,
precision: number,
columnClickHandlerProps: ColumnClickHandlerProps,
width?: number
width?: number,
validator?: (value: string) => string
) => {
const isGrassField = field.includes('grass')
const isCalcField = field.includes('Calc')
Expand All @@ -171,6 +211,10 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato
sortable: false,
type: 'number',
width: width ?? DEFAULT_FORECAST_COLUMN_WIDTH,
renderEditCell: this.renderEditCell,
preProcessEditCellProps: (params: GridPreProcessEditCellProps) => {
return { ...params.props, error: validator ? validator(params.props.value) : '' }
},
renderHeader: (params: GridColumnHeaderParams) => {
return isCalcField || isGrassField
? this.gridComponentRenderer.renderHeaderWith(params)
Expand All @@ -179,7 +223,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato
renderCell: (params: Pick<GridRenderCellParams, 'row' | 'formattedValue'>) => {
return isCalcField
? this.gridComponentRenderer.renderCellWith(params)
: this.gridComponentRenderer.renderForecastCellWith(params, field)
: this.gridComponentRenderer.renderForecastCellWith(params, field, validator)
},
valueFormatter: (params: Pick<GridValueFormatterParams, 'value'>) => {
return this.valueFormatterWith(params, precision)
Expand All @@ -196,7 +240,8 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato
headerName: string,
precision: number,
columnClickHandlerProps: ColumnClickHandlerProps,
width?: number
width?: number,
validator?: (value: string) => string
) => {
const isGrassField = field.includes('grass')
const isCalcField = field.includes('Calc')
Expand All @@ -213,6 +258,10 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato
sortable: false,
type: 'number',
width: width ?? DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH,
preProcessEditCellProps: (params: GridPreProcessEditCellProps) => {
return { ...params.props, error: validator ? validator(params.props.value) : '' }
},
renderEditCell: this.renderEditCell,
renderHeader: (params: GridColumnHeaderParams) => {
return isCalcField || isGrassField
? this.gridComponentRenderer.renderHeaderWith(params)
Expand Down
78 changes: 78 additions & 0 deletions web/src/features/moreCast2/components/EditInputCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { GridRenderEditCellParams, useGridApiContext } from '@mui/x-data-grid-pro'
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'
import InvalidCellToolTip from '@/features/moreCast2/components/InvalidCellToolTip'

export const EditInputCell = (props: GridRenderEditCellParams) => {
const { id, value, field, hasFocus, error } = props
const apiRef = useGridApiContext()
const inputRef = useRef<HTMLInputElement | null>(null)
const dispatch: AppDispatch = useDispatch()

dispatch(setInputValid(isEmpty(error)))

useEffect(() => {
if (hasFocus && inputRef.current) {
inputRef.current.focus()
}
}, [hasFocus])

const handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = event.target.value
apiRef.current.setEditCellValue({ id, field, value: newValue })
}

const handleBlur = () => {
apiRef.current.stopCellEditMode({ id, field })
}

const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation()
if (isEmpty(error)) {
apiRef.current.stopCellEditMode({ id, field })
} else {
event.stopPropagation()
}
}
}

return (
<InvalidCellToolTip error={error}>
<TextField
data-testid="forecast-edit-cell"
type="number"
inputMode="numeric"
inputRef={inputRef}
size="small"
InputLabelProps={{
shrink: true
}}
sx={{
'& .MuiOutlinedInput-root': {
'& 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'
}
}
}}
value={value}
onChange={handleValueChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
</InvalidCellToolTip>
)
}
34 changes: 6 additions & 28 deletions web/src/features/moreCast2/components/ForecastCell.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import React from 'react'
import { Grid, TextField, Tooltip } from '@mui/material'
import { Grid, Tooltip } from '@mui/material'
import { GridRenderCellParams } from '@mui/x-data-grid-pro'
import RemoveCircleIcon from '@mui/icons-material/RemoveCircle'
import AddBoxIcon from '@mui/icons-material/AddBox'
import { MEDIUM_GREY, theme } from 'app/theme'
import { MEDIUM_GREY } from 'app/theme'
import ValidatedForecastCell from '@/features/moreCast2/components/ValidatedForecastCell'

interface ForecastCellProps {
disabled: boolean
label: string
showGreaterThan: boolean
showLessThan: boolean
value: Pick<GridRenderCellParams, 'formattedValue'>
validator?: (value: string) => string
}

const ForecastCell = ({ disabled, label, showGreaterThan, showLessThan, value }: ForecastCellProps) => {
const ForecastCell = ({ disabled, label, showGreaterThan, showLessThan, value, validator }: ForecastCellProps) => {
// We should never display both less than and greater than icons at the same time
if (showGreaterThan && showLessThan) {
throw Error('ForecastCell cannot show both greater than and less than icons at the same time.')
Expand All @@ -31,30 +32,7 @@ const ForecastCell = ({ disabled, label, showGreaterThan, showLessThan, value }:
)}
</Grid>
<Grid item xs={8}>
<TextField
data-testid="forecast-cell-text-field"
disabled={disabled}
size="small"
label={label}
InputLabelProps={{
shrink: true
}}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: `${theme.palette.common.white}`,
'& fieldset': {
borderColor: '#737373',
borderWidth: '2px'
}
},
'& .Mui-disabled': {
'& fieldset': {
borderWidth: '1px'
}
}
}}
value={value}
></TextField>
<ValidatedForecastCell disabled={disabled} label={label} value={value} validator={validator} />
</Grid>
<Grid item xs={2} sx={{ marginLeft: 'auto' }}>
{showGreaterThan && (
Expand Down
22 changes: 10 additions & 12 deletions web/src/features/moreCast2/components/GridComponentRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import ForecastHeader from 'features/moreCast2/components/ForecastHeader'
import { ColumnClickHandlerProps } from 'features/moreCast2/components/TabbedDataGrid'
import { cloneDeep, isNumber } from 'lodash'
import ForecastCell from 'features/moreCast2/components/ForecastCell'
import ValidatedForecastCell from '@/features/moreCast2/components/ValidatedForecastCell'

export const NOT_AVAILABLE = 'N/A'
export const NOT_REPORTING = 'N/R'
Expand Down Expand Up @@ -92,7 +93,11 @@ export class GridComponentRenderer {
} else return isNaN(value) ? noDataField : Number(value).toFixed(precision)
}

public renderForecastCellWith = (params: Pick<GridRenderCellParams, 'row' | 'formattedValue'>, field: string) => {
public renderForecastCellWith = (
params: Pick<GridRenderCellParams, 'row' | 'formattedValue'>,
field: string,
validator?: (value: string) => string
) => {
// If a single cell in a row contains an Actual, no Forecast will be entered into the row anymore, so we can disable the whole row.
const isActual = rowContainsActual(params.row)
// We can disable a cell if an Actual exists or the forDate is before today.
Expand All @@ -115,20 +120,12 @@ export class GridComponentRenderer {
// The grass curing 'forecast' field is rendered differently
if (isGrassField) {
return (
<TextField
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: `${theme.palette.common.white}`
}
}}
<ValidatedForecastCell
disabled={isActual || isPreviousDate}
size="small"
label={label}
InputLabelProps={{
shrink: true
}}
value={params.formattedValue}
></TextField>
validator={validator}
/>
)
} else {
// Forecast fields (except wind direction) have plus and minus icons indicating if the forecast was
Expand All @@ -140,6 +137,7 @@ export class GridComponentRenderer {
showGreaterThan={showGreaterThan}
showLessThan={showLessThan}
value={params.formattedValue}
validator={validator}
/>
)
}
Expand Down
Loading

0 comments on commit b541b68

Please sign in to comment.