Skip to content

Commit

Permalink
Morecast Draft Forecast Save (#3469)
Browse files Browse the repository at this point in the history
- Saves draft forecast on every edit
- Add reset button to clear drafts and reset cells
- Closes #2995
  • Loading branch information
brettedw authored Mar 15, 2024
1 parent 486160a commit 5868bdb
Show file tree
Hide file tree
Showing 11 changed files with 453 additions and 48 deletions.
29 changes: 8 additions & 21 deletions web/src/features/moreCast2/components/GridComponentRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import {
GridValueSetterParams
} from '@mui/x-data-grid'
import { ModelChoice, WeatherDeterminate } from 'api/moreCast2API'
import { createWeatherModelLabel, isPreviousToToday } from 'features/moreCast2/util'
import { MoreCast2Row } from 'features/moreCast2/interfaces'
import { createWeatherModelLabel, isBeforeToday, rowContainsActual } from 'features/moreCast2/util'
import {
GC_HEADER,
PRECIP_HEADER,
Expand Down Expand Up @@ -57,18 +56,6 @@ export class GridComponentRenderer {
return actualField
}

public rowContainsActual = (row: MoreCast2Row): boolean => {
for (const key in row) {
if (key.includes(WeatherDeterminate.ACTUAL)) {
const value = row[key as keyof MoreCast2Row]
if (typeof value === 'number' && !isNaN(value)) {
return true
}
}
}
return false
}

public valueGetter = (
params: Pick<GridValueGetterParams, 'row' | 'value'>,
precision: number,
Expand All @@ -89,23 +76,23 @@ export class GridComponentRenderer {
// The 'Actual' column will show N/R for Not Reporting, instead of N/A
const noDataField = headerName === WeatherDeterminate.ACTUAL ? NOT_REPORTING : NOT_AVAILABLE

const isPreviousDate = isPreviousToToday(params.row['forDate'])
const isPreviousDate = isBeforeToday(params.row['forDate'])
const isForecastColumn = this.isForecastColumn(headerName)
const rowContainsActual = this.rowContainsActual(params.row)
const containsActual = rowContainsActual(params.row)

// If a cell has no value, belongs to a Forecast column, is a future forDate, and the row doesn't contain any Actuals from today,
// we can leave it blank, so it's obvious that it can have a value entered into it.
if (isNaN(value) && !isPreviousDate && isForecastColumn && !rowContainsActual) {
if (isNaN(value) && !isPreviousDate && isForecastColumn && !containsActual) {
return ''
} else return isNaN(value) ? noDataField : Number(value).toFixed(precision)
}

public renderForecastCellWith = (params: Pick<GridRenderCellParams, 'row' | 'formattedValue'>, field: 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 = this.rowContainsActual(params.row)
const isActual = 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'])
const isPreviousDate = isBeforeToday(params.row['forDate'])
const isGrassField = field.includes('grass')
const label = isGrassField || isPreviousDate ? '' : createWeatherModelLabel(params.row[field].choice)
const formattedValue = parseFloat(params.formattedValue)
Expand Down Expand Up @@ -150,10 +137,10 @@ export class GridComponentRenderer {

public renderForecastSummaryCellWith = (params: Pick<GridRenderCellParams, 'row' | 'formattedValue'>) => {
// 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)
const isActual = 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'])
const isPreviousDate = isBeforeToday(params.row['forDate'])

// The grass curing 'forecast' field and other weather parameter forecasts fields are rendered differently
return <TextField disabled={isActual || isPreviousDate} size="small" value={params.formattedValue}></TextField>
Expand Down
52 changes: 52 additions & 0 deletions web/src/features/moreCast2/components/ResetForecastButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from 'react'
import { Button } from '@mui/material'
import { fillGrassCuringForecast } from 'features/moreCast2/util'
import { MoreCast2Row, PredictionItem } from 'features/moreCast2/interfaces'
import { ModelChoice, WeatherDeterminate } from 'api/moreCast2API'

export interface ResetForecastButtonProps {
className?: string
enabled: boolean
label: string
onClick: () => void
}

/**
* Reset forecast rows to their default state. Temp, RH, Wind Dir & Speed are cleared,
* Precip is set to 0, and GC is carried forward from last submitted value.
*/
export const resetForecastRows = (rows: MoreCast2Row[]) => {
const resetRows = rows.map(row => {
const rowToReset = { ...row }
Object.keys(rowToReset).forEach(key => {
if (key.includes(WeatherDeterminate.FORECAST)) {
const isPrecipField = key.includes('precip')
const field = rowToReset[key as keyof MoreCast2Row] as PredictionItem
// Submitted forecasts have a ModelChoice.FORECAST, we don't want to reset those
if (field.choice != ModelChoice.FORECAST && !isNaN(field.value)) {
field.value = isPrecipField ? 0 : NaN
field.choice = ''
}
}
})
return rowToReset
})
fillGrassCuringForecast(resetRows)
return resetRows
}

const ResetForecastButton = ({ className, enabled, label, onClick }: ResetForecastButtonProps) => {
return (
<Button
className={className}
variant="contained"
data-testid={'reset-forecast-button'}
disabled={!enabled}
onClick={onClick}
>
{label}
</Button>
)
}

export default React.memo(ResetForecastButton)
61 changes: 43 additions & 18 deletions web/src/features/moreCast2/components/TabbedDataGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AlertColor, List, Stack } from '@mui/material'
import { AlertColor, Grid, List, Stack, Typography } from '@mui/material'
import { styled } from '@mui/material/styles'
import {
GridCellParams,
Expand Down Expand Up @@ -40,25 +40,25 @@ import { AppDispatch } from 'app/store'
import { deepClone } from '@mui/x-data-grid/utils/utils'
import { filterAllVisibleRowsForSimulation } from 'features/moreCast2/rowFilters'
import { mapForecastChoiceLabels } from 'features/moreCast2/util'
import { MoreCastParams } from 'app/theme'
import { MoreCastParams, theme } from 'app/theme'
import { MorecastDraftForecast } from 'features/moreCast2/forecastDraft'
import ResetForecastButton, { resetForecastRows } from 'features/moreCast2/components/ResetForecastButton'
import { getDateTimeNowPST } from 'utils/date'

export const Root = styled('div')({
display: 'flex',
flexGrow: 1,
flexDirection: 'column'
})

export const SaveButton = styled(SaveForecastButton)(({ theme }) => ({
position: 'absolute',
right: theme.spacing(2)
}))

const FORECAST_ERROR_MESSAGE = 'The forecast was not saved; an unexpected error occurred.'
const FORECAST_SAVED_MESSAGE = 'Forecast was successfully saved and sent to Wildfire One.'
const FORECAST_WARN_MESSAGE = 'Forecast not submitted. A forecast can only contain N/A values for the Wind Direction.'

const SHOW_HIDE_COLUMNS_LOCAL_STORAGE_KEY = 'showHideColumnsModel'

const storedDraftForecast = new MorecastDraftForecast(localStorage)

interface TabbedDataGridProps {
morecast2Rows: MoreCast2Row[]
fromTo: DateRange
Expand Down Expand Up @@ -424,6 +424,7 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp
setSnackbarMessage(FORECAST_SAVED_MESSAGE)
setSnackbarSeverity('success')
setSnackbarOpen(true)
storedDraftForecast.deleteRowsFromStoredDraft(rowsToSave, getDateTimeNowPST())
} else {
setSnackbarMessage(result.errorMessage ?? FORECAST_ERROR_MESSAGE)
setSnackbarSeverity('error')
Expand All @@ -436,6 +437,12 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp
}
}

const handleResetClick = () => {
const resetRows = resetForecastRows(allRows)
dispatch(storeUserEditedRows(resetRows))
storedDraftForecast.clearDraftForecasts()
}

// Checks if the displayed rows includes non-Actual rows
const hasForecastRow = () => {
return visibleRows.filter(isForecastRowPredicate).length > 0
Expand All @@ -455,17 +462,35 @@ const TabbedDataGrid = ({ morecast2Rows, fromTo, setFromTo }: TabbedDataGridProp

return (
<Root>
<MoreCast2DateRangePicker dateRange={fromTo} setDateRange={setFromTo} />
<SaveButton
enabled={
isAuthenticated &&
roles.includes(ROLES.MORECAST_2.WRITE_FORECAST) &&
hasForecastRow() &&
forecastSummaryVisible
}
label={'Save Forecast'}
onClick={handleSaveClick}
/>
<Grid container justifyContent="space-between" alignItems={'center'}>
<Grid item>
<MoreCast2DateRangePicker dateRange={fromTo} setDateRange={setFromTo} />
</Grid>
<Grid item sx={{ marginRight: theme.spacing(2), marginBottom: theme.spacing(6) }}>
<Stack direction="row" spacing={theme.spacing(2)} alignItems={'center'}>
{storedDraftForecast.getLastSavedDraftDateTime() && (
<Typography sx={{ fontSize: 12 }}>
Draft saved {storedDraftForecast.getLastSavedDraftDateTime()}
</Typography>
)}
<ResetForecastButton
label={'Reset'}
enabled={storedDraftForecast.hasDraftForecastStored()}
onClick={handleResetClick}
/>
<SaveForecastButton
enabled={
isAuthenticated &&
roles.includes(ROLES.MORECAST_2.WRITE_FORECAST) &&
hasForecastRow() &&
forecastSummaryVisible
}
label={'Publish to WF1'}
onClick={handleSaveClick}
/>
</Stack>
</Grid>
</Grid>
<List component={Stack} direction="row">
<SelectableButton
dataTestId="temp-tab-button"
Expand Down
71 changes: 71 additions & 0 deletions web/src/features/moreCast2/components/resetForecastButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import store from 'app/store'
import ResetForecastButton, { resetForecastRows } from 'features/moreCast2/components/ResetForecastButton'
import { buildValidActualRow, buildValidForecastRow } from 'features/moreCast2/rowFilters.test'
import { DateTime } from 'luxon'
import React from 'react'
import { Provider } from 'react-redux'

const TEST_DATE = DateTime.fromISO('2023-04-27T20:00:00+00:00')

describe('SaveForecastButton', () => {
it('should render the button as enabled', () => {
const { getByTestId } = render(
<Provider store={store}>
<ResetForecastButton enabled={true} label="test" onClick={() => undefined} />
</Provider>
)

const resetForecastButton = getByTestId('reset-forecast-button')
expect(resetForecastButton).toBeInTheDocument()
expect(resetForecastButton).toBeEnabled()
})
it('should render the button as disabled', () => {
const { getByTestId } = render(
<Provider store={store}>
<ResetForecastButton enabled={false} label="test" onClick={() => undefined} />
</Provider>
)

const manageStationsButton = getByTestId('reset-forecast-button')
expect(manageStationsButton).toBeInTheDocument()
expect(manageStationsButton).toBeDisabled()
})
it('should reset the forecast rows to their initial load state', () => {
const mockRowData = [
buildValidForecastRow(111, TEST_DATE.plus({ days: 1 }), 'MANUAL'),
buildValidForecastRow(222, TEST_DATE.plus({ days: 1 }), 'MANUAL'),
buildValidActualRow(222, TEST_DATE.minus({ days: 1 }))
]

const resetRows = resetForecastRows(mockRowData)

expect(resetRows[0].tempForecast?.value).toBe(NaN)
expect(resetRows[0].rhForecast?.value).toBe(NaN)
expect(resetRows[0].windSpeedForecast?.value).toBe(NaN)
expect(resetRows[0].precipForecast?.value).toBe(0)
})
it('should not reset rows with submitted forecasts or actuals', () => {
const mockRowData = [
buildValidForecastRow(111, TEST_DATE.plus({ days: 1 }), 'FORECAST'),
buildValidForecastRow(222, TEST_DATE.plus({ days: 1 }), 'FORECAST'),
buildValidActualRow(222, TEST_DATE.minus({ days: 1 }))
]

const resetRows = resetForecastRows(mockRowData)

expect(resetRows).toEqual(mockRowData)
})
it('should call the reset click handler when clicked', async () => {
const handleResetClickMock = jest.fn()
const { getByTestId } = render(
<Provider store={store}>
<ResetForecastButton enabled={true} label="test" onClick={handleResetClickMock} />
</Provider>
)
const resetForecastButton = getByTestId('reset-forecast-button')
userEvent.click(resetForecastButton)
await waitFor(() => expect(handleResetClickMock).toHaveBeenCalledTimes(1))
})
})
75 changes: 75 additions & 0 deletions web/src/features/moreCast2/forecastDraft.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { MorecastDraftForecast } from 'features/moreCast2/forecastDraft'
import { DraftMorecast2Rows } from 'features/moreCast2/interfaces'
import { buildValidActualRow, buildValidForecastRow } from 'features/moreCast2/rowFilters.test'
import { DateTime } from 'luxon'
import * as DateUtils from 'utils/date'

const TEST_DATE = DateTime.fromISO('2024-01-01T00:00:00.000-08:00')

describe('MorecastDraftForecast', () => {
afterEach(() => {
jest.restoreAllMocks()
})

const localStorageMock: Storage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
length: 0,
clear: jest.fn(),
key: jest.fn()
}

const draftForecast = new MorecastDraftForecast(localStorageMock)

const mockRowData = [
buildValidForecastRow(111, TEST_DATE.plus({ days: 1 })),
buildValidForecastRow(111, TEST_DATE.plus({ days: 2 })),
buildValidForecastRow(222, TEST_DATE.plus({ days: 1 })),
buildValidForecastRow(222, TEST_DATE.plus({ days: 2 })),
buildValidActualRow(222, TEST_DATE.minus({ days: 1 })),
buildValidActualRow(222, TEST_DATE.minus({ days: 2 })),
buildValidForecastRow(111, TEST_DATE.minus({ days: 1 }))
]

it('should only store current forecast rows', () => {
jest.spyOn(DateUtils, 'getDateTimeNowPST').mockReturnValue(TEST_DATE)
const toBeStored: DraftMorecast2Rows = { rows: mockRowData.slice(0, 4), lastEdited: TEST_DATE }
const setSpy = jest.spyOn(localStorageMock, 'setItem')

draftForecast.updateStoredDraftForecasts(mockRowData, TEST_DATE)

expect(setSpy).toHaveBeenCalledWith(draftForecast.STORAGE_KEY, JSON.stringify(toBeStored))
})
it('should call getItem upon retrieval of a forecast', () => {
const getSpy = jest.spyOn(localStorageMock, 'getItem')
draftForecast.getStoredDraftForecasts()

expect(getSpy).toHaveBeenCalledWith(draftForecast.STORAGE_KEY)
})
it('should delete saved rows from storage', () => {
const storedDraft: DraftMorecast2Rows = { rows: mockRowData.slice(0, 4), lastEdited: TEST_DATE }
const toBeStored: DraftMorecast2Rows = { rows: mockRowData.slice(2, 4), lastEdited: TEST_DATE }
const savedRows = mockRowData.slice(0, 2)

jest.spyOn(localStorageMock, 'getItem').mockReturnValue(JSON.stringify(storedDraft))
const setSpy = jest.spyOn(localStorageMock, 'setItem')

draftForecast.deleteRowsFromStoredDraft(savedRows, TEST_DATE)

expect(setSpy).toHaveBeenCalledWith(draftForecast.STORAGE_KEY, JSON.stringify(toBeStored))
})
it('should return true if a draft forecast is stored', () => {
const storedDraft: DraftMorecast2Rows = { rows: mockRowData.slice(0, 4), lastEdited: TEST_DATE }
jest.spyOn(localStorageMock, 'getItem').mockReturnValue(JSON.stringify(storedDraft))

const draftStored = draftForecast.hasDraftForecastStored()
expect(draftStored).toBe(true)
})
it('should return false if a draft forecast is not stored', () => {
jest.spyOn(localStorageMock, 'getItem').mockReturnValue('')

const draftStored = draftForecast.hasDraftForecastStored()
expect(draftStored).toBe(false)
})
})
Loading

0 comments on commit 5868bdb

Please sign in to comment.