diff --git a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx index 21149da60..4d0245ee7 100644 --- a/web/src/features/moreCast2/components/ColumnDefBuilder.tsx +++ b/web/src/features/moreCast2/components/ColumnDefBuilder.tsx @@ -1,4 +1,5 @@ import { + GridAlignment, GridCellParams, GridColDef, GridColumnHeaderParams, @@ -12,7 +13,8 @@ import { modelColorClass, modelHeaderColorClass } from 'app/theme' import { GridComponentRenderer } from 'features/moreCast2/components/GridComponentRenderer' export const DEFAULT_COLUMN_WIDTH = 80 -export const DEFAULT_FORECAST_COLUMN_WIDTH = 120 +export const DEFAULT_FORECAST_COLUMN_WIDTH = 145 +export const DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH = 100 // Defines the order in which weather models display in the datagrid. export const ORDERED_COLUMN_HEADERS: WeatherDeterminateType[] = [ @@ -39,6 +41,7 @@ export const GC_HEADER = 'GC' export interface ForecastColDefGenerator { generateForecastColDef: (headerName?: string) => GridColDef + generateForecastSummaryColDef: () => GridColDef } export interface ColDefGenerator { @@ -68,6 +71,15 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato ) } + public generateForecastSummaryColDef = () => { + return this.generateForecastSummaryColDefWith( + `${this.field}${WeatherDeterminate.FORECAST}`, + this.headerName, + this.precision, + DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH + ) + } + public generateColDefs = (headerName?: string, includeBiasFields = true) => { const gridColDefs: GridColDef[] = [] // Forecast columns have unique requirement (eg. column header menu, editable, etc.) @@ -86,12 +98,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato ? ORDERED_COLUMN_HEADERS : ORDERED_COLUMN_HEADERS.filter(header => !header.endsWith('_BIAS')) return fields.map(header => - this.generateColDefWith( - `${this.field}${header}`, - header, - this.precision, - header.includes('Actual') ? DEFAULT_FORECAST_COLUMN_WIDTH : DEFAULT_COLUMN_WIDTH - ) + this.generateColDefWith(`${this.field}${header}`, header, this.precision, DEFAULT_COLUMN_WIDTH) ) } @@ -100,6 +107,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato field, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center' as GridAlignment, headerName, sortable: false, type: 'number', @@ -135,6 +143,7 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato disableColumnMenu: true, disableReorder: true, editable: true, + headerAlign: 'center' as GridAlignment, headerName: headerName, sortable: false, type: 'number', @@ -159,6 +168,42 @@ export class ColumnDefBuilder implements ColDefGenerator, ForecastColDefGenerato } } + public generateForecastSummaryColDefWith = (field: string, headerName: string, precision: number, width?: number) => { + const isGrassField = field.includes('grass') + const isCalcField = field.includes('Calc') + if (isGrassField || isCalcField) { + width = DEFAULT_COLUMN_WIDTH + } + return { + field: field, + disableColumnMenu: true, + disableReorder: true, + editable: true, + headerAlign: 'center' as GridAlignment, + headerName: headerName, + sortable: false, + type: 'number', + width: width ?? DEFAULT_FORECAST_SUMMARY_COLUMN_WIDTH, + renderHeader: (params: GridColumnHeaderParams) => { + return isCalcField || isGrassField + ? this.gridComponentRenderer.renderHeaderWith(params) + : this.gridComponentRenderer.renderForecastHeaderWith(params) + }, + renderCell: (params: Pick) => { + return isCalcField + ? this.gridComponentRenderer.renderCellWith(params) + : this.gridComponentRenderer.renderForecastSummaryCellWith(params) + }, + valueFormatter: (params: Pick) => { + return this.valueFormatterWith(params, precision) + }, + valueGetter: (params: Pick) => + this.gridComponentRenderer.valueGetter(params, precision, field, headerName), + valueSetter: (params: Pick) => + this.valueSetterWith(params, field, precision) + } + } + public valueFormatterWith = (params: Pick, precision: number) => this.gridComponentRenderer.predictionItemValueFormatter(params, precision) public valueGetter = ( diff --git a/web/src/features/moreCast2/components/DataGridColumns.tsx b/web/src/features/moreCast2/components/DataGridColumns.tsx index 46d9362ab..4bfbcfb90 100644 --- a/web/src/features/moreCast2/components/DataGridColumns.tsx +++ b/web/src/features/moreCast2/components/DataGridColumns.tsx @@ -85,7 +85,7 @@ export class DataGridColumns { public static getSummaryColumns(): GridColDef[] { return MORECAST2_STATION_DATE_FIELDS.map(field => field.generateColDef()).concat( - MORECAST2_FORECAST_FIELDS.map(forecastField => forecastField.generateForecastColDef()).concat( + MORECAST2_FORECAST_FIELDS.map(forecastField => forecastField.generateForecastSummaryColDef()).concat( MORECAST2_INDEX_FIELDS.map(field => field.generateForecastColDef()) ) ) diff --git a/web/src/features/moreCast2/components/ForecastCell.tsx b/web/src/features/moreCast2/components/ForecastCell.tsx new file mode 100644 index 000000000..8991bb914 --- /dev/null +++ b/web/src/features/moreCast2/components/ForecastCell.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { Grid, TextField, Tooltip } from '@mui/material' +import { GridRenderCellParams } from '@mui/x-data-grid' +import RemoveCircleIcon from '@mui/icons-material/RemoveCircle' +import AddBoxIcon from '@mui/icons-material/AddBox' +import { MEDIUM_GREY } from 'app/theme' + +interface ForecastCellProps { + disabled: boolean + label: string + showGreaterThan: boolean + showLessThan: boolean + value: Pick +} + +const ForecastCell = ({ disabled, label, showGreaterThan, showLessThan, value }: 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.') + } + return ( + + + {showLessThan && ( + + + + )} + + + + + + {showGreaterThan && ( + + + + )} + + + ) +} + +export default ForecastCell diff --git a/web/src/features/moreCast2/components/GridComponentRenderer.tsx b/web/src/features/moreCast2/components/GridComponentRenderer.tsx index ef6c3057f..644836bfa 100644 --- a/web/src/features/moreCast2/components/GridComponentRenderer.tsx +++ b/web/src/features/moreCast2/components/GridComponentRenderer.tsx @@ -19,6 +19,8 @@ import { WIND_SPEED_HEADER } from 'features/moreCast2/components/ColumnDefBuilder' import { theme } from 'app/theme' +import { isNumber } from 'lodash' +import ForecastCell from 'features/moreCast2/components/ForecastCell' export const NOT_AVAILABLE = 'N/A' export const NOT_REPORTING = 'N/R' @@ -104,20 +106,57 @@ export class GridComponentRenderer { // We can disable a cell if an Actual exists or the forDate is before today. // Both forDate and today are currently in the system's time zone const isPreviousDate = isPreviousToToday(params.row['forDate']) - const isGrassField = field.includes('grass') + const label = isGrassField || isPreviousDate ? '' : createWeatherModelLabel(params.row[field].choice) + const formattedValue = parseFloat(params.formattedValue) + const actualField = this.getActualField(field) + const actualValue = params.row[actualField] + let showLessThan = false + let showGreaterThan = false + // Only show + and - icons if an actual value exists, a forecast value exists and this is not a windDirection + // field. + if (!isNaN(actualValue) && isNumber(actualValue) && isNumber(formattedValue) && !field.includes('windDirection')) { + showLessThan = formattedValue < actualValue + showGreaterThan = formattedValue > actualValue + } + + // The grass curing 'forecast' field is rendered differently + if (isGrassField) { + return ( + + ) + } else { + // Forecast fields (except wind direction) have plus and minus icons indicating if the forecast was + // greater than or less than the actual + return ( + + ) + } + } + + public renderForecastSummaryCellWith = (params: Pick) => { + // 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 = this.rowContainsActual(params.row) + // We can disable a cell if an Actual exists or the forDate is before today. + // Both forDate and today are currently in the system's time zone + const isPreviousDate = isPreviousToToday(params.row['forDate']) - return ( - - ) + // The grass curing 'forecast' field and other weather parameter forecasts fields are rendered differently + return } public predictionItemValueSetter = ( diff --git a/web/src/features/moreCast2/components/MoreCast2Column.tsx b/web/src/features/moreCast2/components/MoreCast2Column.tsx index 219db916e..246b29fb5 100644 --- a/web/src/features/moreCast2/components/MoreCast2Column.tsx +++ b/web/src/features/moreCast2/components/MoreCast2Column.tsx @@ -139,6 +139,10 @@ export class IndeterminateField implements ColDefGenerator, ForecastColDefGenera return this.colDefBuilder.generateForecastColDef(headerName ?? this.headerName) } + public generateForecastSummaryColDef = () => { + return this.colDefBuilder.generateForecastSummaryColDef() + } + public generateColDef = () => { return this.colDefBuilder.generateColDefWith(this.field, this.headerName, this.precision) } diff --git a/web/src/features/moreCast2/components/colDefBuilder.test.tsx b/web/src/features/moreCast2/components/colDefBuilder.test.tsx index c2419bb3b..66791a867 100644 --- a/web/src/features/moreCast2/components/colDefBuilder.test.tsx +++ b/web/src/features/moreCast2/components/colDefBuilder.test.tsx @@ -1,5 +1,3 @@ -import React from 'react' -import { TextField } from '@mui/material' import { ModelChoice, WeatherDeterminate } from 'api/moreCast2API' import { ColumnDefBuilder, @@ -8,9 +6,7 @@ import { ORDERED_COLUMN_HEADERS } from 'features/moreCast2/components/ColumnDefBuilder' import { GridComponentRenderer } from 'features/moreCast2/components/GridComponentRenderer' -import { tempForecastField } from 'features/moreCast2/components/MoreCast2Column' -import { theme } from 'app/theme' - +import { gcForecastField, tempForecastField } from 'features/moreCast2/components/MoreCast2Column' describe('ColDefBuilder', () => { const colDefBuilder = new ColumnDefBuilder( tempForecastField.field, @@ -33,6 +29,7 @@ describe('ColDefBuilder', () => { field: 'temp', disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: 'Temp', sortable: false, type: 'number', @@ -49,6 +46,7 @@ describe('ColDefBuilder', () => { disableColumnMenu: true, disableReorder: true, editable: true, + headerAlign: 'center', headerName: tempForecastField.headerName, sortable: false, type: 'number', @@ -60,10 +58,11 @@ describe('ColDefBuilder', () => { field: `${tempForecastField.field}${determinate}`, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: determinate, sortable: false, type: 'number', - width: determinate === WeatherDeterminate.ACTUAL ? DEFAULT_FORECAST_COLUMN_WIDTH : DEFAULT_COLUMN_WIDTH + width: DEFAULT_COLUMN_WIDTH }) ) ) @@ -79,21 +78,13 @@ describe('ColDefBuilder', () => { field: testField, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: testHeader, sortable: false, type: 'number', width: testWidth }) ) - - expect(forecastColDef.renderCell({ formattedValue: 1 })).toEqual( - - ) expect(forecastColDef.valueFormatter({ value: 1.11 })).toEqual('1.1') }) }) @@ -108,6 +99,7 @@ describe('ColDefBuilder', () => { disableColumnMenu: true, disableReorder: true, editable: true, + headerAlign: 'center', headerName: tempForecastField.headerName, sortable: false, type: tempForecastField.type, @@ -127,6 +119,7 @@ describe('ColDefBuilder', () => { disableColumnMenu: true, disableReorder: true, editable: true, + headerAlign: 'center', headerName: header, sortable: false, type: tempForecastField.type, @@ -144,42 +137,13 @@ describe('ColDefBuilder', () => { disableColumnMenu: true, disableReorder: true, editable: true, + headerAlign: 'center', headerName: testHeader, sortable: false, type: 'number', width: testWidth }) ) - expect( - forecastColDef.renderCell({ row: { testField: { choice: ModelChoice.GDPS, value: 1 } }, formattedValue: 1 }) - ).toEqual( - - ) - - expect( - forecastColDef.renderCell({ - row: { testField: { choice: ModelChoice.GDPS, value: 1 } }, - formattedValue: 1 - }) - ).toEqual( - - ) expect(forecastColDef.valueFormatter({ value: 1.11 })).toEqual('1.1') expect( forecastColDef.valueGetter({ @@ -192,7 +156,7 @@ describe('ColDefBuilder', () => { ).toEqual({ testField: { choice: ModelChoice.MANUAL, value: 2 } }) }) - it('should generate col def with parameters correctly with a default width', () => { + it('should generate forecast col def with parameters correctly with a default width', () => { const forecastColDef = colDefBuilder.generateForecastColDefWith(testField, testHeader, testPrecision) expect(JSON.stringify(forecastColDef)).toEqual( @@ -201,10 +165,11 @@ describe('ColDefBuilder', () => { disableColumnMenu: true, disableReorder: true, editable: true, + headerAlign: 'center', headerName: testHeader, sortable: false, type: 'number', - width: 120 + width: 145 }) ) }) @@ -231,4 +196,29 @@ describe('ColDefBuilder', () => { ).toEqual({ testField: { choice: ModelChoice.MANUAL, value: 2 } }) }) }) + it('should generate grass curing column definition correctly', () => { + const gcColDefBuilder = new ColumnDefBuilder( + gcForecastField.field, + gcForecastField.headerName, + gcForecastField.type, + gcForecastField.precision, + new GridComponentRenderer() + ) + + const gcColDef = gcColDefBuilder.generateForecastColDef() + + expect(JSON.stringify(gcColDef)).toEqual( + JSON.stringify({ + field: `${gcForecastField.field}${WeatherDeterminate.FORECAST}`, + disableColumnMenu: true, + disableReorder: true, + editable: true, + headerAlign: 'center', + headerName: gcForecastField.headerName, + sortable: false, + type: gcForecastField.type, + width: DEFAULT_COLUMN_WIDTH + }) + ) + }) }) diff --git a/web/src/features/moreCast2/components/forecastCell.test.tsx b/web/src/features/moreCast2/components/forecastCell.test.tsx new file mode 100644 index 000000000..4edd380b8 --- /dev/null +++ b/web/src/features/moreCast2/components/forecastCell.test.tsx @@ -0,0 +1,192 @@ +import React from 'react' +import { render } from '@testing-library/react' +import ForecastCell from 'features/moreCast2/components/ForecastCell' +import { GridRenderCellParams } from '@mui/x-data-grid' + +const params: Pick = { + row: undefined, + formattedValue: '1' +} + +describe('ForecastCell', () => { + it('should have input disabled when disabled prop is true', () => { + const { container } = render( + + ) + + const inputElement = container.querySelector('input') + expect(inputElement).toBeInTheDocument() + expect(inputElement).toBeDisabled() + }) + it('should have input enabled when disabled prop is false', () => { + const { container } = render( + + ) + + const inputElement = container.querySelector('input') + expect(inputElement).toBeInTheDocument() + expect(inputElement).toBeEnabled() + }) + it('should show less than icon when showLessThan is true', () => { + const { queryByTestId } = render( + + ) + + const element = queryByTestId('forecast-cell-less-than-icon') + expect(element).toBeInTheDocument() + }) + it('should throw an error when showGreaterThan and showLessThan are both positive', () => { + // Suppres the console error message for an unhandled error + const consoleErrorFn = jest.spyOn(console, 'error').mockImplementation(() => jest.fn()) + expect(() => { + render( + + ) + }).toThrow('ForecastCell cannot show both greater than and less than icons at the same time.') + consoleErrorFn.mockRestore() + }) + it('should not show less than icon when showLessThan is false', () => { + const { queryByTestId } = render( + + ) + + const element = queryByTestId('forecast-cell-less-than-icon') + expect(element).not.toBeInTheDocument() + }) + it('should show greater than icon when showGreaterThan is true', () => { + const { queryByTestId } = render( + + ) + + const element = queryByTestId('forecast-cell-greater-than-icon') + expect(element).toBeInTheDocument() + }) + it('should not show less than icon when showLessThan is false', () => { + const { queryByTestId } = render( + + ) + + const element = queryByTestId('forecast-cell-greater-than-icon') + expect(element).not.toBeInTheDocument() + }) + it('should not show less than or greater than icons when showLessThan and showGreater than are both false', () => { + const { queryByTestId } = render( + + ) + + const greaterThanElement = queryByTestId('forecast-cell-greater-than-icon') + expect(greaterThanElement).not.toBeInTheDocument() + const lessThanElement = queryByTestId('forecast-cell-less-than-icon') + expect(lessThanElement).not.toBeInTheDocument() + }) + it('should not show a label when none specified', () => { + const { container } = render( + + ) + + const inputElement = container.querySelector('label') + expect(inputElement).not.toBeInTheDocument() + }) + it('should show a label when specified', () => { + const { container } = render( + + ) + + const inputElement = container.querySelector('label') + expect(inputElement).toBeInTheDocument() + }) + it('should display the value when provided', () => { + const { container } = render( + + ) + + const inputElement = container.querySelector('input') + expect(inputElement).toBeInTheDocument() + expect(inputElement!.value).toBe('1') + }) + it('should not display a value when none provided', () => { + const localParams: Pick = { + row: undefined, + formattedValue: undefined + } + const { container } = render( + + ) + + const inputElement = container.querySelector('input') + expect(inputElement).toBeInTheDocument() + expect(inputElement!.value).toBe('') + }) +}) diff --git a/web/src/features/moreCast2/components/moreCast2Column.test.tsx b/web/src/features/moreCast2/components/moreCast2Column.test.tsx index 1d03e107b..0a3b3f90c 100644 --- a/web/src/features/moreCast2/components/moreCast2Column.test.tsx +++ b/web/src/features/moreCast2/components/moreCast2Column.test.tsx @@ -60,6 +60,7 @@ describe('MoreCast2Column', () => { field: tempForecastField.field, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: tempForecastField.headerName, sortable: false, type: tempForecastField.type, @@ -77,6 +78,7 @@ describe('MoreCast2Column', () => { field: rhForecastField.field, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: rhForecastField.headerName, sortable: false, type: rhForecastField.type, @@ -94,6 +96,7 @@ describe('MoreCast2Column', () => { field: windDirForecastField.field, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: windDirForecastField.headerName, sortable: false, type: windDirForecastField.type, @@ -111,6 +114,7 @@ describe('MoreCast2Column', () => { field: windSpeedForecastField.field, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: windSpeedForecastField.headerName, sortable: false, type: windSpeedForecastField.type, @@ -128,6 +132,7 @@ describe('MoreCast2Column', () => { field: precipForecastField.field, disableColumnMenu: true, disableReorder: true, + headerAlign: 'center', headerName: precipForecastField.headerName, sortable: false, type: precipForecastField.type,