diff --git a/.sonarcloud.properties b/.sonarcloud.properties index fe2770b85..991759efd 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -16,7 +16,7 @@ sonar.test.exclusions=*.feature sonar.tests.inclusions=**/*.test.tsx # Exclude duplication in fba tests due to many similar calculation numbers, ignore sample code as it's temporary, ignore sfms entrypoint, ignore util tests, ignore temporary fwi folder -sonar.cpd.exclusions=api/app/tests/fba_calc/*.py, api/app/weather_models/wind_direction_sample.py, web/src/features/moreCast2/util.test.ts, web/src/utils/fwi +sonar.cpd.exclusions=api/app/tests/fba_calc/*.py, api/app/weather_models/wind_direction_sample.py, web/src/features/moreCast2/util.test.ts, web/src/features/moreCast2/components/gridComponentRenderer.test.tsx, web/src/utils/fwi # Encoding of the source code. Default is default system encoding sonar.sourceEncoding=UTF-8 diff --git a/.vscode/settings.json b/.vscode/settings.json index 844895d45..6174d53ea 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -75,6 +75,7 @@ "ffmc", "fireweather", "firezone", + "FWIs", "GDPS", "geoalchemy", "GEOGCS", @@ -85,6 +86,7 @@ "grib", "gribs", "HAINES", + "Hasher", "hourlies", "HRDPS", "idir", diff --git a/web/src/features/moreCast2/components/EditInputCell.tsx b/web/src/features/moreCast2/components/EditInputCell.tsx index 873680c28..717d6c12e 100644 --- a/web/src/features/moreCast2/components/EditInputCell.tsx +++ b/web/src/features/moreCast2/components/EditInputCell.tsx @@ -14,7 +14,9 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { const inputRef = useRef(null) const dispatch: AppDispatch = useDispatch() - dispatch(setInputValid(isEmpty(error))) + useEffect(() => { + dispatch(setInputValid(isEmpty(error))) + }, []) useEffect(() => { if (hasFocus && inputRef.current) { @@ -43,7 +45,7 @@ export const EditInputCell = (props: GridRenderEditCellParams) => { } return ( - + diff --git a/web/src/features/moreCast2/components/GridComponentRenderer.tsx b/web/src/features/moreCast2/components/GridComponentRenderer.tsx index 957397a9a..7d596470a 100644 --- a/web/src/features/moreCast2/components/GridComponentRenderer.tsx +++ b/web/src/features/moreCast2/components/GridComponentRenderer.tsx @@ -22,7 +22,8 @@ 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' +import ValidatedGrassCureForecastCell from '@/features/moreCast2/components/ValidatedGrassCureForecastCell' +import ValidatedWindDirectionForecastCell from '@/features/moreCast2/components/ValidatedWindDirectionForecastCell' export const NOT_AVAILABLE = 'N/A' export const NOT_REPORTING = 'N/R' @@ -120,7 +121,16 @@ export class GridComponentRenderer { // The grass curing 'forecast' field is rendered differently if (isGrassField) { return ( - + ) + } else if (field.includes('windDirection')) { + return ( + { +const InvalidCellToolTip = ({ invalid, children }: InvalidCellToolTipProps) => { return ( void const TabbedDataGrid = ({ fromTo, setFromTo, fetchWeatherIndeterminates }: TabbedDataGridProps) => { + const dispatch: AppDispatch = useDispatch() const selectedStations = useSelector(selectSelectedStations) const loading = useSelector(selectWeatherIndeterminatesLoading) const { roles, isAuthenticated } = useSelector(selectAuthentication) @@ -445,8 +448,9 @@ const TabbedDataGrid = ({ fromTo, setFromTo, fetchWeatherIndeterminates }: Tabbe } const handleSaveClick = async () => { - if (isForecastValid(visibleRows)) { - const rowsToSave: MoreCast2ForecastRow[] = getRowsToSave(visibleRows) + const rowsToSave: MoreCast2ForecastRow[] = getRowsToSave(visibleRows) + + if (isRequiredInputSet(rowsToSave)) { const result = await submitMoreCastForecastRecords(rowsToSave) if (result.success) { setSnackbarMessage(FORECAST_SAVED_MESSAGE) @@ -459,6 +463,7 @@ const TabbedDataGrid = ({ fromTo, setFromTo, fetchWeatherIndeterminates }: Tabbe setSnackbarOpen(true) } } else { + dispatch(setRequiredInputEmpty({ empty: true })) setSnackbarMessage(FORECAST_WARN_MESSAGE) setSnackbarSeverity('warning') setSnackbarOpen(true) diff --git a/web/src/features/moreCast2/components/ValidatedCell.tsx b/web/src/features/moreCast2/components/ValidatedCell.tsx new file mode 100644 index 000000000..ffd6c5a7b --- /dev/null +++ b/web/src/features/moreCast2/components/ValidatedCell.tsx @@ -0,0 +1,54 @@ +import { theme } from '@/app/theme' +import InvalidCellToolTip from '@/features/moreCast2/components/InvalidCellToolTip' +import { TextField } from '@mui/material' +import { GridRenderCellParams } from '@mui/x-data-grid-pro' +import React from 'react' + +interface ValidatedCellProps { + disabled: boolean + label: string + error: boolean + invalid: string + value: Pick +} + +const ValidatedGrassCureForecastCell = ({ disabled, label, value, invalid, error }: ValidatedCellProps) => { + const testTag = error ? 'validated-forecast-cell-error' : 'validated-forecast-cell' + return ( + + + + ) +} + +export default React.memo(ValidatedGrassCureForecastCell) diff --git a/web/src/features/moreCast2/components/ValidatedForecastCell.tsx b/web/src/features/moreCast2/components/ValidatedForecastCell.tsx index e03208281..22e6527b4 100644 --- a/web/src/features/moreCast2/components/ValidatedForecastCell.tsx +++ b/web/src/features/moreCast2/components/ValidatedForecastCell.tsx @@ -1,8 +1,9 @@ import React from 'react' -import { TextField } from '@mui/material' import { GridRenderCellParams } from '@mui/x-data-grid-pro' -import { theme } from 'app/theme' -import InvalidCellToolTip from '@/features/moreCast2/components/InvalidCellToolTip' +import { selectMorecastRequiredInputEmpty } from '@/features/moreCast2/slices/validInputSlice' +import { useSelector } from 'react-redux' +import { isNil } from 'lodash' +import ValidatedCell from '@/features/moreCast2/components/ValidatedCell' interface ValidatedForecastCellProps { disabled: boolean @@ -12,41 +13,10 @@ interface ValidatedForecastCellProps { } const ValidatedForecastCell = ({ disabled, label, value, validator }: ValidatedForecastCellProps) => { - const error = validator ? validator(value as string) : '' - return ( - - - - ) + const isRequiredInputEmpty = useSelector(selectMorecastRequiredInputEmpty) + const invalid = validator ? validator(value as string) : '' + const error = (isRequiredInputEmpty.empty && (value as string) === '') || isNil(value) || invalid !== '' + return } export default React.memo(ValidatedForecastCell) diff --git a/web/src/features/moreCast2/components/ValidatedGrassCureForecastCell.tsx b/web/src/features/moreCast2/components/ValidatedGrassCureForecastCell.tsx new file mode 100644 index 000000000..90368fbcf --- /dev/null +++ b/web/src/features/moreCast2/components/ValidatedGrassCureForecastCell.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { GridRenderCellParams } from '@mui/x-data-grid-pro' +import ValidatedCell from '@/features/moreCast2/components/ValidatedCell' +import { Box } from '@mui/material' + +interface ValidatedGrassCureForecastCellProps { + disabled: boolean + label: string + value: Pick + validator?: (value: string) => string +} + +const ValidatedGrassCureForecastCell = ({ disabled, label, value, validator }: ValidatedGrassCureForecastCellProps) => { + const error = validator ? validator(value as string) : '' + return ( + + + + ) +} + +export default React.memo(ValidatedGrassCureForecastCell) diff --git a/web/src/features/moreCast2/components/ValidatedWindDirectionForecastCell.tsx b/web/src/features/moreCast2/components/ValidatedWindDirectionForecastCell.tsx new file mode 100644 index 000000000..273a62497 --- /dev/null +++ b/web/src/features/moreCast2/components/ValidatedWindDirectionForecastCell.tsx @@ -0,0 +1,28 @@ +import { Grid } from '@mui/material' +import { GridRenderCellParams } from '@mui/x-data-grid-pro' +import ValidatedCell from '@/features/moreCast2/components/ValidatedCell' + +interface ForecastCellProps { + disabled: boolean + label: string + value: Pick + validator?: (value: string) => string +} + +const ValidatedWindDirectionForecastCell = ({ disabled, label, value, validator }: ForecastCellProps) => { + const error = validator ? validator(value as string) : '' + + return ( + + + + + + ) +} + +export default ValidatedWindDirectionForecastCell diff --git a/web/src/features/moreCast2/components/forecastCell.test.tsx b/web/src/features/moreCast2/components/forecastCell.test.tsx index dd7e2d3c6..0617a4b8f 100644 --- a/web/src/features/moreCast2/components/forecastCell.test.tsx +++ b/web/src/features/moreCast2/components/forecastCell.test.tsx @@ -2,6 +2,9 @@ import { render } from '@testing-library/react' import ForecastCell from 'features/moreCast2/components/ForecastCell' import { GridRenderCellParams } from '@mui/x-data-grid-pro' import { vi } from 'vitest' +import { initialState } from '@/features/moreCast2/slices/validInputSlice' +import { Provider } from 'react-redux' +import { buildTestStore } from '@/features/moreCast2/components/testHelper' const params: Pick = { row: undefined, @@ -11,13 +14,15 @@ const params: Pick = { describe('ForecastCell', () => { it('should have input disabled when disabled prop is true', () => { const { container } = render( - + + + ) const inputElement = container.querySelector('input') @@ -26,13 +31,15 @@ describe('ForecastCell', () => { }) it('should have input enabled when disabled prop is false', () => { const { container } = render( - + + + ) const inputElement = container.querySelector('input') @@ -41,13 +48,15 @@ describe('ForecastCell', () => { }) it('should show less than icon when showLessThan is true', () => { const { queryByTestId } = render( - + + + ) const element = queryByTestId('forecast-cell-less-than-icon') @@ -58,26 +67,30 @@ describe('ForecastCell', () => { const consoleErrorFn = vi.spyOn(console, 'error').mockImplementation(() => vi.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') @@ -85,13 +98,15 @@ describe('ForecastCell', () => { }) it('should show greater than icon when showGreaterThan is true', () => { const { queryByTestId } = render( - + + + ) const element = queryByTestId('forecast-cell-greater-than-icon') @@ -99,13 +114,15 @@ describe('ForecastCell', () => { }) it('should not show less than icon when showLessThan is false', () => { const { queryByTestId } = render( - + + + ) const element = queryByTestId('forecast-cell-greater-than-icon') @@ -113,13 +130,15 @@ describe('ForecastCell', () => { }) 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') @@ -129,13 +148,15 @@ describe('ForecastCell', () => { }) it('should not show a label when none specified', () => { const { container } = render( - + + + ) const inputElement = container.querySelector('label') @@ -143,13 +164,15 @@ describe('ForecastCell', () => { }) it('should show a label when specified', () => { const { container } = render( - + + + ) const inputElement = container.querySelector('label') @@ -157,13 +180,15 @@ describe('ForecastCell', () => { }) it('should display the value when provided', () => { const { container } = render( - + + + ) const inputElement = container.querySelector('input') @@ -176,13 +201,15 @@ describe('ForecastCell', () => { formattedValue: undefined } const { container } = render( - + + + ) const inputElement = container.querySelector('input') diff --git a/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx b/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx index f9dcc3b86..53d61af3e 100644 --- a/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx +++ b/web/src/features/moreCast2/components/gridComponentRenderer.test.tsx @@ -1,3 +1,5 @@ +import { buildTestStore } from '@/features/moreCast2/components/testHelper' +import { initialState } from '@/features/moreCast2/slices/validInputSlice' import { GridColumnHeaderParams, GridValueSetterParams } from '@mui/x-data-grid-pro' import { GridStateColDef } from '@mui/x-data-grid-pro/internals' import { render } from '@testing-library/react' @@ -10,6 +12,7 @@ import { } from 'features/moreCast2/components/GridComponentRenderer' import { ColumnClickHandlerProps } from 'features/moreCast2/components/TabbedDataGrid' import { DateTime } from 'luxon' +import { Provider } from 'react-redux' import { vi } from 'vitest' describe('GridComponentRenderer', () => { @@ -48,13 +51,15 @@ describe('GridComponentRenderer', () => { const row = { [field]: NaN, [fieldActual]: NaN, forDate: DateTime.now().plus({ days: 2 }) } const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Forecast') const { getByRole } = render( - gridComponentRenderer.renderForecastCellWith( - { - row: row, - formattedValue: formattedValue - }, - field - ) + + {gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + )} + ) const renderedCell = getByRole('textbox') expect(renderedCell).toBeInTheDocument() @@ -67,13 +72,15 @@ describe('GridComponentRenderer', () => { const row = { [field]: NaN, forDate: DateTime.now().minus({ days: 2 }) } const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Forecast') const { getByRole } = render( - gridComponentRenderer.renderForecastCellWith( - { - row: row, - formattedValue: formattedValue - }, - field - ) + + {gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + )} + ) const renderedCell = getByRole('textbox') expect(renderedCell).toBeInTheDocument() @@ -87,13 +94,15 @@ describe('GridComponentRenderer', () => { const row = { [field]: NaN, [fieldActual]: 2, forDate: DateTime.now() } const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Forecast') const { getByRole } = render( - gridComponentRenderer.renderForecastCellWith( - { - row: row, - formattedValue: formattedValue - }, - field - ) + + {gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + )} + ) const renderedCell = getByRole('textbox') expect(renderedCell).toBeInTheDocument() @@ -107,13 +116,15 @@ describe('GridComponentRenderer', () => { const row = { [field]: NaN, [fieldActual]: NaN, forDate: DateTime.now().minus({ days: 2 }) } const formattedValue = gridComponentRenderer.valueGetter({ row: row, value: NaN }, 1, field, 'Actual') const { getByRole } = render( - gridComponentRenderer.renderForecastCellWith( - { - row: row, - formattedValue: formattedValue - }, - field - ) + + {gridComponentRenderer.renderForecastCellWith( + { + row: row, + formattedValue: formattedValue + }, + field + )} + ) const renderedCell = getByRole('textbox') expect(renderedCell).toBeInTheDocument() @@ -133,7 +144,9 @@ describe('GridComponentRenderer', () => { it('should render the forecast cell as editable with no actual', () => { const field = 'tempForecast' const { getByRole } = render( - gridComponentRenderer.renderForecastCellWith({ row: { [field]: 1 }, formattedValue: 1 }, field) + + {gridComponentRenderer.renderForecastCellWith({ row: { [field]: 1 }, formattedValue: 1 }, field)} + ) const renderedCell = getByRole('textbox') expect(renderedCell).toBeInTheDocument() @@ -157,7 +170,12 @@ describe('GridComponentRenderer', () => { const actualField = `tempActual` const { getByRole } = render( - gridComponentRenderer.renderForecastCellWith({ row: { [field]: 1, [actualField]: 2 }, formattedValue: 1 }, field) + + {gridComponentRenderer.renderForecastCellWith( + { row: { [field]: 1, [actualField]: 2 }, formattedValue: 1 }, + field + )} + ) const renderedCell = getByRole('textbox') expect(renderedCell).toBeInTheDocument() @@ -165,6 +183,30 @@ describe('GridComponentRenderer', () => { expect(renderedCell).toBeDisabled() }) + it('should render the wind direction forecast cell', () => { + const field = 'windDirectionForecast' + + const { getByTestId } = render( + + {gridComponentRenderer.renderForecastCellWith({ row: { [field]: 1 }, formattedValue: 1 }, field)} + + ) + const renderedCell = getByTestId('validated-winddir-forecast-cell') + expect(renderedCell).toBeInTheDocument() + }) + + it('should render the grass curing forecast cell', () => { + const field = 'grassCuringForecast' + + const { getByTestId } = render( + + {gridComponentRenderer.renderForecastCellWith({ row: { [field]: 1 }, formattedValue: 1 }, field)} + + ) + const renderedCell = getByTestId('validated-gc-forecast-cell') + expect(renderedCell).toBeInTheDocument() + }) + it('should set the row correctly', () => { const mockValueSetterParams: GridValueSetterParams = { value: 2, diff --git a/web/src/features/moreCast2/components/saveForecastButton.test.tsx b/web/src/features/moreCast2/components/saveForecastButton.test.tsx index 20425e12b..7f22a05f7 100644 --- a/web/src/features/moreCast2/components/saveForecastButton.test.tsx +++ b/web/src/features/moreCast2/components/saveForecastButton.test.tsx @@ -34,7 +34,10 @@ describe('SaveForecastButton', () => { reducer: rootReducer, preloadedState: { morecastInputValid: { - isValid: false + isValid: false, + isRequiredEmpty: { + empty: false + } } } }) diff --git a/web/src/features/moreCast2/components/testHelper.tsx b/web/src/features/moreCast2/components/testHelper.tsx new file mode 100644 index 000000000..0fdbc1e69 --- /dev/null +++ b/web/src/features/moreCast2/components/testHelper.tsx @@ -0,0 +1,15 @@ +import morecastInputValidSlice, { ValidInputState } from '@/features/moreCast2/slices/validInputSlice' +import { combineReducers, configureStore } from '@reduxjs/toolkit' + +export const buildTestStore = (initialState: ValidInputState) => { + const rootReducer = combineReducers({ + morecastInputValid: morecastInputValidSlice + }) + const testStore = configureStore({ + reducer: rootReducer, + preloadedState: { + morecastInputValid: initialState + } + }) + return testStore +} diff --git a/web/src/features/moreCast2/components/validatedForecastCell.test.tsx b/web/src/features/moreCast2/components/validatedForecastCell.test.tsx index 4827a04a7..63649eaf6 100644 --- a/web/src/features/moreCast2/components/validatedForecastCell.test.tsx +++ b/web/src/features/moreCast2/components/validatedForecastCell.test.tsx @@ -1,8 +1,9 @@ import { render } from '@testing-library/react' -import ForecastCell from 'features/moreCast2/components/ForecastCell' import { GridRenderCellParams } from '@mui/x-data-grid-pro' -import { vi } from 'vitest' import ValidatedForecastCell from '@/features/moreCast2/components/ValidatedForecastCell' +import { initialState } from '@/features/moreCast2/slices/validInputSlice' +import { Provider } from 'react-redux' +import { buildTestStore } from '@/features/moreCast2/components/testHelper' const params: Pick = { row: undefined, @@ -10,28 +11,51 @@ const params: Pick = { } describe('ValidatedForecastCell', () => { - it('should render a tooltip when value is invalid', async () => { - const { queryByText } = render( - 'tooltip-error'} - /> + it('should render a tooltip and be in error state when value is invalid', async () => { + const testStore = buildTestStore(initialState) + + const { queryByText, queryByTestId } = render( + + 'tooltip-error'} + /> + ) + expect(queryByTestId('validated-forecast-cell-error')).toBeInTheDocument() + expect(queryByTestId('validated-forecast-cell')).not.toBeInTheDocument() expect(queryByText('tooltip-error')).toBeInTheDocument() }) - it('should not render a tooltip when value is valid', async () => { - const { queryByText } = render( - (Number(v) > Number.MAX_VALUE ? 'tooltip-error' : '')} - /> + it('should render in an error state when value is empty and required', async () => { + const testStore = buildTestStore({ ...initialState, isRequiredEmpty: { empty: true } }) + + const params: Pick = { + row: undefined, + formattedValue: '' + } + + const { queryByText, queryByTestId } = render( + + + ) + expect(queryByTestId('validated-forecast-cell-error')).toBeInTheDocument() + expect(queryByTestId('validated-forecast-cell')).not.toBeInTheDocument() + expect(queryByText('tooltip-error')).not.toBeInTheDocument() + }) + it('should not render a tooltip and not be in an error state when value is valid', async () => { + const testStore = buildTestStore(initialState) + const { queryByText, queryByTestId } = render( + + ''} /> + + ) + expect(queryByTestId('validated-forecast-cell')).toBeInTheDocument() + expect(queryByTestId('validated-forecast-cell-error')).not.toBeInTheDocument() expect(queryByText('tooltip-error')).not.toBeInTheDocument() }) }) diff --git a/web/src/features/moreCast2/components/validatedGrassCureForecastCell.test.tsx b/web/src/features/moreCast2/components/validatedGrassCureForecastCell.test.tsx new file mode 100644 index 000000000..76c74d24b --- /dev/null +++ b/web/src/features/moreCast2/components/validatedGrassCureForecastCell.test.tsx @@ -0,0 +1,56 @@ +import { render } from '@testing-library/react' +import { GridRenderCellParams } from '@mui/x-data-grid-pro' +import { initialState } from '@/features/moreCast2/slices/validInputSlice' +import { Provider } from 'react-redux' +import ValidatedGrassCureForecastCell from '@/features/moreCast2/components/ValidatedGrassCureForecastCell' +import { buildTestStore } from '@/features/moreCast2/components/testHelper' + +const params: Pick = { + row: undefined, + formattedValue: '1' +} + +describe('ValidatedGrassCureForecastCell', () => { + it('should render a tooltip when value is invalid', async () => { + const testStore = buildTestStore(initialState) + + const { queryByText } = render( + + 'tooltip-error'} + /> + + ) + expect(queryByText('tooltip-error')).toBeInTheDocument() + }) + + it('should not render a tooltip when value is valid', async () => { + const testStore = buildTestStore(initialState) + const { queryByText } = render( + + ''} + /> + + ) + + expect(queryByText('tooltip-error')).not.toBeInTheDocument() + }) + + it('should not render a tooltip when value has no validator', async () => { + const testStore = buildTestStore(initialState) + const { queryByText } = render( + + + + ) + + expect(queryByText('tooltip-error')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/features/moreCast2/components/validatedWindDirectionForecastCell.test.tsx b/web/src/features/moreCast2/components/validatedWindDirectionForecastCell.test.tsx new file mode 100644 index 000000000..1f578101b --- /dev/null +++ b/web/src/features/moreCast2/components/validatedWindDirectionForecastCell.test.tsx @@ -0,0 +1,56 @@ +import { render } from '@testing-library/react' +import { GridRenderCellParams } from '@mui/x-data-grid-pro' +import { initialState } from '@/features/moreCast2/slices/validInputSlice' +import { Provider } from 'react-redux' +import ValidatedWindDirectionForecastCell from '@/features/moreCast2/components/ValidatedWindDirectionForecastCell' +import { buildTestStore } from '@/features/moreCast2/components/testHelper' + +const params: Pick = { + row: undefined, + formattedValue: '1' +} + +describe('ValidatedWindDirectionForecastCell', () => { + it('should render a tooltip when value is invalid', async () => { + const testStore = buildTestStore(initialState) + + const { queryByText } = render( + + 'tooltip-error'} + /> + + ) + expect(queryByText('tooltip-error')).toBeInTheDocument() + }) + + it('should not render a tooltip when value is valid', async () => { + const testStore = buildTestStore(initialState) + const { queryByText } = render( + + ''} + /> + + ) + + expect(queryByText('tooltip-error')).not.toBeInTheDocument() + }) + + it('should not render a tooltip when value has no validator', async () => { + const testStore = buildTestStore(initialState) + const { queryByText } = render( + + + + ) + + expect(queryByText('tooltip-error')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/features/moreCast2/saveForecast.test.ts b/web/src/features/moreCast2/saveForecast.test.ts index 6f72cca29..d3490312a 100644 --- a/web/src/features/moreCast2/saveForecast.test.ts +++ b/web/src/features/moreCast2/saveForecast.test.ts @@ -1,7 +1,6 @@ import { ModelChoice } from 'api/moreCast2API' -import { MoreCast2Row } from 'features/moreCast2/interfaces' -import { getRowsToSave, isForecastValid } from 'features/moreCast2/saveForecasts' -import { validForecastPredicate } from 'features/moreCast2/util' +import { MoreCast2ForecastRow, MoreCast2Row } from 'features/moreCast2/interfaces' +import { getRowsToSave, isRequiredInputSet } from 'features/moreCast2/saveForecasts' import { DateTime } from 'luxon' const baseRow = { @@ -86,66 +85,60 @@ const buildCompleteForecast = ( id: string, forDate: DateTime, stationCode: number, - stationName: string, - latitude: number, - longitude: number -): MoreCast2Row => ({ + stationName: string +): MoreCast2ForecastRow => ({ id, forDate, stationCode, stationName, - latitude, - longitude, ...baseRow, - precipForecast: { choice: ModelChoice.GDPS, value: 0 }, - rhForecast: { choice: ModelChoice.GDPS, value: 0 }, - tempForecast: { choice: ModelChoice.GDPS, value: 0 }, - windDirectionForecast: { choice: ModelChoice.GDPS, value: 0 }, - windSpeedForecast: { choice: ModelChoice.GDPS, value: 0 }, - grassCuringForecast: { choice: ModelChoice.NULL, value: 0 } + precip: { choice: ModelChoice.GDPS, value: 0 }, + rh: { choice: ModelChoice.GDPS, value: 0 }, + temp: { choice: ModelChoice.GDPS, value: 0 }, + windDirection: { choice: ModelChoice.GDPS, value: 0 }, + windSpeed: { choice: ModelChoice.GDPS, value: 0 }, + grassCuring: { choice: ModelChoice.NULL, value: 0 } }) const buildForecastMissingWindDirection = ( id: string, forDate: DateTime, stationCode: number, - stationName: string, - latitude: number, - longitude: number -): MoreCast2Row => ({ + stationName: string +): MoreCast2ForecastRow => ({ id, forDate, stationCode, stationName, - latitude, - longitude, ...baseRow, - precipForecast: { choice: ModelChoice.GDPS, value: 0 }, - rhForecast: { choice: ModelChoice.GDPS, value: 0 }, - tempForecast: { choice: ModelChoice.GDPS, value: 0 }, - windDirectionForecast: { choice: ModelChoice.NULL, value: NaN }, - windSpeedForecast: { choice: ModelChoice.GDPS, value: 0 }, - grassCuringForecast: { choice: ModelChoice.NULL, value: 0 } + precip: { choice: ModelChoice.GDPS, value: 0 }, + rh: { choice: ModelChoice.GDPS, value: 0 }, + temp: { choice: ModelChoice.GDPS, value: 0 }, + windDirection: { choice: ModelChoice.NULL, value: NaN }, + windSpeed: { choice: ModelChoice.GDPS, value: 0 }, + grassCuring: { choice: ModelChoice.NULL, value: 0 } }) const buildInvalidForecast = ( id: string, forDate: DateTime, stationCode: number, - stationName: string, - latitude: number, - longitude: number -): MoreCast2Row => ({ + stationName: string +): MoreCast2ForecastRow => ({ id, forDate, stationCode, stationName, - latitude, - longitude, - ...baseRow + ...baseRow, + precip: { choice: ModelChoice.NULL, value: NaN }, + rh: { choice: ModelChoice.NULL, value: NaN }, + temp: { choice: ModelChoice.NULL, value: NaN }, + windDirection: { choice: ModelChoice.NULL, value: NaN }, + windSpeed: { choice: ModelChoice.NULL, value: NaN }, + grassCuring: { choice: ModelChoice.NULL, value: NaN } }) -const buildNAForecast = ( +const buildForecastWithActuals = ( id: string, forDate: DateTime, stationCode: number, @@ -159,16 +152,16 @@ const buildNAForecast = ( stationName, latitude, longitude, - ...baseRow, - precipForecast: { choice: ModelChoice.NULL, value: NaN }, - rhForecast: { choice: ModelChoice.NULL, value: NaN }, - tempForecast: { choice: ModelChoice.NULL, value: NaN }, - windDirectionForecast: { choice: ModelChoice.NULL, value: NaN }, - windSpeedForecast: { choice: ModelChoice.NULL, value: NaN }, - grassCuringForecast: { choice: ModelChoice.NULL, value: NaN } + ...baseRowWithActuals, + precipForecast: { choice: ModelChoice.GDPS, value: 0 }, + rhForecast: { choice: ModelChoice.GDPS, value: 0 }, + tempForecast: { choice: ModelChoice.GDPS, value: 0 }, + windDirectionForecast: { choice: ModelChoice.GDPS, value: 0 }, + windSpeedForecast: { choice: ModelChoice.GDPS, value: 0 }, + grassCuringForecast: { choice: ModelChoice.NULL, value: 0 } }) -const buildForecastWithActuals = ( +const buildForecast = ( id: string, forDate: DateTime, stationCode: number, @@ -182,7 +175,7 @@ const buildForecastWithActuals = ( stationName, latitude, longitude, - ...baseRowWithActuals, + ...baseRow, precipForecast: { choice: ModelChoice.GDPS, value: 0 }, rhForecast: { choice: ModelChoice.GDPS, value: 0 }, tempForecast: { choice: ModelChoice.GDPS, value: 0 }, @@ -195,64 +188,57 @@ describe('saveForecasts', () => { describe('isForecastValid', () => { it('should return true if all forecasts fields are set', () => { expect( - isForecastValid([ - buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), - buildCompleteForecast('2', mockForDate, 2, 'two', 2, 2) + isRequiredInputSet([ + buildCompleteForecast('1', mockForDate, 1, 'one'), + buildCompleteForecast('2', mockForDate, 2, 'two') ]) ).toBe(true) }) it('should return true if all forecasts fields are set except windDirectionForecast', () => { expect( - isForecastValid([ - buildForecastMissingWindDirection('1', mockForDate, 1, 'one', 1, 1), - buildForecastMissingWindDirection('2', mockForDate, 2, 'two', 2, 2) + isRequiredInputSet([ + buildForecastMissingWindDirection('1', mockForDate, 1, 'one'), + buildForecastMissingWindDirection('2', mockForDate, 2, 'two') + ]) + ).toBe(true) + }) + it('should return true if all required fields are set and precip has the default value with NULL choice', () => { + expect( + isRequiredInputSet([ + { + id: '1', + forDate: mockForDate, + stationCode: 1, + stationName: 'one', + ...baseRow, + precip: { choice: ModelChoice.NULL, value: 0 }, + rh: { choice: ModelChoice.MANUAL, value: 1 }, + temp: { choice: ModelChoice.GDPS, value: 1 }, + windDirection: { choice: ModelChoice.NULL, value: NaN }, + windSpeed: { choice: ModelChoice.GDPS, value: 1 }, + grassCuring: { choice: ModelChoice.NULL, value: NaN } + } ]) ).toBe(true) }) it('should return false if any forecasts have missing forecast fields', () => { expect( - isForecastValid([ - buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), - buildInvalidForecast('2', mockForDate, 2, 'two', 2, 2) + isRequiredInputSet([ + buildCompleteForecast('1', mockForDate, 1, 'one'), + buildInvalidForecast('2', mockForDate, 2, 'two') ]) ).toBe(false) }) it('should return false if any forecasts have missing forecast fields set other than windDirectionForecast', () => { - expect(isForecastValid([buildNAForecast('1', mockForDate, 2, 'one', 1, 1)])).toBe(false) - }) - }) - describe('validForecastPredicate', () => { - it('should return false for a forecast with missing forecast fields', () => { - expect(validForecastPredicate(buildInvalidForecast('1', mockForDate, 1, 'one', 1, 1))).toBe(false) - }) - it('should return false for a forecast with forecasts but N/A values', () => { - expect(validForecastPredicate(buildNAForecast('1', mockForDate, 1, 'one', 1, 1))).toBe(false) + expect(isRequiredInputSet([buildInvalidForecast('1', mockForDate, 2, 'one')])).toBe(false) }) }) describe('getRowsToSave', () => { - it('should filter out invalid forecasts', () => { - const res = getRowsToSave([ - buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), - buildInvalidForecast('2', mockForDate, 2, 'two', 2, 2) - ]) - expect(res).toHaveLength(1) - expect(res[0].id).toBe('1') - }) - it('should filter out N/A forecasts', () => { - const res = getRowsToSave([ - buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), - buildNAForecast('2', mockForDate, 2, 'two', 2, 2) - ]) - expect(res).toHaveLength(1) - expect(res[0].id).toBe('1') - }) it('should filter out rows with actuals', () => { - const forecastWithActual = buildCompleteForecast('2', mockForDate, 2, 'two', 2, 2) - forecastWithActual.precipActual = 1 const res = getRowsToSave([ - buildCompleteForecast('1', mockForDate, 1, 'one', 1, 1), + buildForecast('1', mockForDate, 1, 'one', 1, 1), buildForecastWithActuals('2', mockForDate, 2, 'two', 2, 2) ]) expect(res).toHaveLength(1) diff --git a/web/src/features/moreCast2/saveForecasts.ts b/web/src/features/moreCast2/saveForecasts.ts index 1b8a7fb7f..57cb1eda0 100644 --- a/web/src/features/moreCast2/saveForecasts.ts +++ b/web/src/features/moreCast2/saveForecasts.ts @@ -1,6 +1,6 @@ import { ModelChoice } from 'api/moreCast2API' -import { MoreCast2ForecastRow, MoreCast2Row } from 'features/moreCast2/interfaces' -import { validForecastPredicate } from 'features/moreCast2/util' +import { MoreCast2ForecastRow, MoreCast2Row, PredictionItem } from 'features/moreCast2/interfaces' +import { isNil } from 'lodash' // Forecast rows contain all NaN values in their 'actual' fields export const isForecastRowPredicate = (row: MoreCast2Row) => @@ -15,16 +15,9 @@ export const getForecastRows = (rows: MoreCast2Row[]): MoreCast2Row[] => { return rows ? rows.filter(isForecastRowPredicate) : [] } -export const isForecastValid = (rows: MoreCast2Row[]) => { - const forecastRows = getForecastRows(rows) - const validForecastRows = forecastRows.filter(validForecastPredicate) - return forecastRows.length === validForecastRows.length -} - export const getRowsToSave = (rows: MoreCast2Row[]): MoreCast2ForecastRow[] => { const forecastRows = getForecastRows(rows) - const rowsToSave = forecastRows.filter(validForecastPredicate) - return rowsToSave.map(r => ({ + const rowsToSave = forecastRows.map(r => ({ id: r.id, stationCode: r.stationCode, stationName: r.stationName, @@ -36,4 +29,21 @@ export const getRowsToSave = (rows: MoreCast2Row[]): MoreCast2ForecastRow[] => { windSpeed: r.windSpeedForecast ?? { choice: ModelChoice.NULL, value: NaN }, grassCuring: r.grassCuringForecast ?? { choice: ModelChoice.NULL, value: NaN } })) + return rowsToSave +} + +export const isRequiredInputSet = (rowsToSave: MoreCast2ForecastRow[]) => { + const isNilPredictionItem = (item?: PredictionItem) => { + return isNil(item) || isNaN(item.value) + } + const res = rowsToSave.every(row => { + return ( + !isNilPredictionItem(row.precip) && + !isNilPredictionItem(row.rh) && + !isNilPredictionItem(row.temp) && + !isNilPredictionItem(row.windSpeed) + ) + }) + + return res } diff --git a/web/src/features/moreCast2/slices/dataSlice.ts b/web/src/features/moreCast2/slices/dataSlice.ts index 7e0258b42..5e5997da1 100644 --- a/web/src/features/moreCast2/slices/dataSlice.ts +++ b/web/src/features/moreCast2/slices/dataSlice.ts @@ -634,7 +634,6 @@ export const createEmptyMoreCast2Row = ( fwiCalcActual: NaN, dgrCalcActual: NaN, - // grassCuringActual: NaN, // GDPS model predictions diff --git a/web/src/features/moreCast2/slices/validInputSlice.test.ts b/web/src/features/moreCast2/slices/validInputSlice.test.ts new file mode 100644 index 000000000..760391603 --- /dev/null +++ b/web/src/features/moreCast2/slices/validInputSlice.test.ts @@ -0,0 +1,27 @@ +import validInputSliceReducer, { + initialState, + setInputValid, + setRequiredInputEmpty +} from 'features/moreCast2/slices/validInputSlice' + +describe('validInputSlice', () => { + it('initial state should be valid', () => { + expect(validInputSliceReducer(initialState, setInputValid(true))).toEqual(initialState) + }) + it('invalid flag should be reflected in state when set', () => { + expect(validInputSliceReducer(initialState, setInputValid(false))).toEqual({ ...initialState, isValid: false }) + }) + it('required input empty flag should be reflected in state when set', () => { + expect(validInputSliceReducer(initialState, setRequiredInputEmpty({ empty: true }))).toEqual({ + ...initialState, + isRequiredEmpty: { empty: true } + }) + }) + it('required input empty flag should be reflected in state when set', () => { + const invalidState = validInputSliceReducer(initialState, setInputValid(false)) + expect(validInputSliceReducer(invalidState, setRequiredInputEmpty({ empty: true }))).toEqual({ + ...invalidState, + isRequiredEmpty: { empty: true } + }) + }) +}) diff --git a/web/src/features/moreCast2/slices/validInputSlice.ts b/web/src/features/moreCast2/slices/validInputSlice.ts index ed2610dfb..835b8c530 100644 --- a/web/src/features/moreCast2/slices/validInputSlice.ts +++ b/web/src/features/moreCast2/slices/validInputSlice.ts @@ -3,10 +3,12 @@ import { RootState } from 'app/rootReducer' export interface ValidInputState { isValid: boolean + isRequiredEmpty: { empty: boolean } } export const initialState: ValidInputState = { - isValid: true + isValid: true, + isRequiredEmpty: { empty: false } } const morecastInputValidSlice = createSlice({ @@ -15,12 +17,16 @@ const morecastInputValidSlice = createSlice({ reducers: { setInputValid(state: ValidInputState, action: PayloadAction) { state.isValid = action.payload + }, + setRequiredInputEmpty(state: ValidInputState, action: PayloadAction<{ empty: boolean }>) { + state.isRequiredEmpty = action.payload } } }) -export const { setInputValid } = morecastInputValidSlice.actions +export const { setInputValid, setRequiredInputEmpty } = morecastInputValidSlice.actions export default morecastInputValidSlice.reducer export const selectMorecastInputValid = (state: RootState) => state.morecastInputValid.isValid +export const selectMorecastRequiredInputEmpty = (state: RootState) => state.morecastInputValid.isRequiredEmpty