Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
354 changes: 231 additions & 123 deletions backend/packages/wps-api/src/app/tests/hfi/test_hfi_admin.py

Large diffs are not rendered by default.

21 changes: 11 additions & 10 deletions backend/packages/wps-shared/src/wps_shared/db/crud/hfi_calc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import List, Tuple

from sqlalchemy import desc, insert, update
from sqlalchemy import desc, insert, tuple_, update
from sqlalchemy.engine import Row
from sqlalchemy.engine.cursor import CursorResult
from sqlalchemy.orm import Session
Expand Down Expand Up @@ -198,21 +198,22 @@ def get_planning_areas(session: Session, fire_centre_id: int):
def get_stations_for_removal(session: Session, station_requests: List[HFIAdminRemovedStation]):
"""Returns the station model requested to remove, along with all
stations in in planning area, in ascending order."""
remove_request_planning_area_ids = [request.planning_area_id for request in station_requests]
remove_request_station_codes = [request.station_code for request in station_requests]
remove_request_keys = [
(request.planning_area_id, request.station_code) for request in station_requests
]

# ordering is 1-based, row_id is 0-based
remove_request_orders = [request.row_id + 1 for request in station_requests]
if not remove_request_keys:
return []

stations_to_remove = (
session.query(PlanningWeatherStation)
.filter(PlanningWeatherStation.planning_area_id.in_(remove_request_planning_area_ids))
.filter(PlanningWeatherStation.station_code.in_(remove_request_station_codes))
.filter(
PlanningWeatherStation.order_of_appearance_in_planning_area_list.in_(
remove_request_orders
)
tuple_(
PlanningWeatherStation.planning_area_id,
PlanningWeatherStation.station_code,
).in_(remove_request_keys)
)
.filter(PlanningWeatherStation.is_deleted == False)
.all()
)
return stations_to_remove
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const ManageStationsModal = ({
planningAreas={planningAreas}
fuelTypes={fuelTypes}
existingPlanningAreaStations={existingStations}
stationUpdateError={stationsUpdatedError}
addStationOptions={{
planningAreaOptions: planning_areas,
stationOptions: stations,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface SaveStationUpdatesButtonProps {
testId?: string
addedStations: StationAdminRow[]
removedStations: StationAdminRow[]
handleSave: () => void
handleSave: () => void | Promise<void>
}

const SaveStationUpdatesButton = ({ addedStations, removedStations, handleSave }: SaveStationUpdatesButtonProps) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box } from '@mui/material'
import { Alert, Box } from '@mui/material'
import type { FuelType, PlanningArea } from '@wps/api/hfiCalculatorAPI'
import type { AppDispatch } from 'app/store'
import AdminCancelButton from 'features/hfiCalculator/components/stationAdmin/AdminCancelButton'
Expand All @@ -10,7 +10,7 @@ import type {
import PlanningAreaAdmin from 'features/hfiCalculator/components/stationAdmin/PlanningAreaAdmin'
import SaveStationUpdatesButton from 'features/hfiCalculator/components/stationAdmin/SaveStationUpdatesButton'
import { fetchAddOrUpdateStations } from 'features/hfiCalculator/slices/hfiCalculatorSlice'
import { every, findIndex, isUndefined, maxBy, sortBy } from 'lodash'
import { every, isUndefined, maxBy, sortBy } from 'lodash'
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'

Expand All @@ -28,6 +28,7 @@ export interface StationListAdminProps {
fuelTypes: Pick<FuelType, 'id' | 'abbrev'>[]
addStationOptions?: AddStationOptions
existingPlanningAreaStations: { [key: string]: StationAdminRow[] }
stationUpdateError?: string | null
handleClose: () => void
}

Expand All @@ -36,76 +37,79 @@ const StationListAdmin = ({
planningAreas,
addStationOptions,
existingPlanningAreaStations,
stationUpdateError,
handleClose
}: StationListAdminProps) => {
const dispatch: AppDispatch = useDispatch()

const [addedStations, setAddedStations] = useState<{ [key: string]: StationAdminRow[] }>(
Object.fromEntries(Object.keys(existingPlanningAreaStations).map(key => [key, []]))
)
const emptyRowsByPlanningArea = Object.fromEntries(planningAreas.map(planningArea => [planningArea.id, []]))
const [addedStations, setAddedStations] = useState<{ [key: string]: StationAdminRow[] }>(emptyRowsByPlanningArea)
const [removedStations, setRemovedStations] = useState<{
[key: string]: { planningAreaId: number; rowId: number; station: BasicWFWXStation }[]
}>(Object.fromEntries(Object.keys(existingPlanningAreaStations).map(key => [key, []])))
}>(emptyRowsByPlanningArea)

/** Adds net new stations */
const handleAddStation = (planningAreaId: number) => {
const maxRowId = maxBy(addedStations[planningAreaId], 'rowId')?.rowId
const currentRows = addedStations[planningAreaId] ?? []
const maxRowId = maxBy(currentRows, 'rowId')?.rowId
const lastRowId = maxRowId ? maxRowId : 0
const currentRow = addedStations[planningAreaId].concat([{ planningAreaId, rowId: lastRowId + 1 }])
setAddedStations({
...addedStations,
[planningAreaId]: currentRow
[planningAreaId]: currentRows.concat([{ planningAreaId, rowId: lastRowId + 1 }])
})
}

/** Removes net new stations */
const handleRemoveStation = (planningAreaId: number, rowId: number) => {
const currentlyAdded = addedStations[planningAreaId]
const idx = findIndex(currentlyAdded, r => r.rowId === rowId)
currentlyAdded.splice(idx, 1)
setAddedStations({
...addedStations,
[planningAreaId]: currentlyAdded
[planningAreaId]: (addedStations[planningAreaId] ?? []).filter(row => row.rowId !== rowId)
})
}

/** Edits net new stations */
const handleEditStation = (planningAreaId: number, rowId: number, row: StationAdminRow) => {
const currentlyAdded = addedStations[planningAreaId]
const idx = findIndex(currentlyAdded, r => r.rowId === rowId)
currentlyAdded.splice(idx, 1, row)
setAddedStations({
...addedStations,
[planningAreaId]: currentlyAdded
[planningAreaId]: (addedStations[planningAreaId] ?? []).map(currentRow =>
currentRow.rowId === rowId ? row : currentRow
)
})
}

/** Removes existing stations, not net new ones */
const handleRemoveExistingStation = (planningAreaId: number, rowId: number, station: BasicWFWXStation) => {
const currentRow = removedStations[planningAreaId].concat({ planningAreaId, rowId, station })
const currentRows = removedStations[planningAreaId] ?? []
setRemovedStations({
...removedStations,
[planningAreaId]: currentRow
[planningAreaId]: currentRows.concat({ planningAreaId, rowId, station })
})
}

const handleSave = () => {
const handleSave = async () => {
const allAdded = Object.values(addedStations).flat()
const allRemoved = Object.values(removedStations).flat()
if (every(allAdded, addedStation => !isUndefined(addedStation.station) && !isUndefined(addedStation.fuelType))) {
dispatch(
const saved = await dispatch(
fetchAddOrUpdateStations(
fireCentreId,
allAdded as Required<StationAdminRow>[],
allRemoved as Required<StationAdminRow>[]
allRemoved as Required<Pick<StationAdminRow, 'planningAreaId' | 'rowId' | 'station'>>[]
)
)
handleClose()
if (saved) {
handleClose()
}
}
}

return (
<Box sx={{ width: '100%', pl: 4 }} aria-labelledby="planning-areas-admin">
{stationUpdateError && (
<Alert severity="error" sx={{ mb: 2, mr: 4 }} data-testid="station-update-error">
{stationUpdateError}
</Alert>
)}
{sortBy(planningAreas, planningArea => planningArea.order_of_appearance_in_list).map(area => (
<PlanningAreaAdmin
key={`planning-area-admin-${area.id}`}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { render, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { type PlanningArea, updateStations } from '@wps/api/hfiCalculatorAPI'
import StationListAdmin from 'features/hfiCalculator/components/stationAdmin/StationListAdmin'
import { Provider } from 'react-redux'
import { vi } from 'vitest'
import { createTestStore } from '@/test/testUtils'

vi.mock('@wps/api/hfiCalculatorAPI', async importOriginal => {
const actual = await importOriginal<typeof import('@wps/api/hfiCalculatorAPI')>()
return {
...actual,
updateStations: vi.fn()
}
})

const updateStationsMock = vi.mocked(updateStations)

describe('StationListAdmin', () => {
beforeEach(() => {
updateStationsMock.mockReset()
})

const planningAreas = [
{
id: 1,
name: 'Empty Planning Area',
fire_centre_id: 1,
order_of_appearance_in_list: 1,
stations: []
}
] as PlanningArea[]

it('can add a station row to a planning area with no existing stations', async () => {
const user = userEvent.setup()

const { getByTestId } = render(
<Provider store={createTestStore()}>
<StationListAdmin
fireCentreId={1}
planningAreas={planningAreas}
fuelTypes={[]}
existingPlanningAreaStations={{}}
handleClose={vi.fn()}
/>
</Provider>
)

await user.click(getByTestId('admin-add-station-button'))

expect(getByTestId('new-pa-admin-station-1-1')).toBeInTheDocument()
})

it('renders station update errors locally', () => {
const { getByTestId } = render(
<Provider store={createTestStore()}>
<StationListAdmin
fireCentreId={1}
planningAreas={planningAreas}
fuelTypes={[]}
existingPlanningAreaStations={{}}
stationUpdateError="station update failed"
handleClose={vi.fn()}
/>
</Provider>
)

expect(getByTestId('station-update-error')).toHaveTextContent('station update failed')
})

it('keeps the modal open when station updates fail', async () => {
const user = userEvent.setup()
const handleClose = vi.fn()
updateStationsMock.mockRejectedValue(new Error('station update failed'))

const { getByTestId } = render(
<Provider store={createTestStore()}>
<StationListAdmin
fireCentreId={1}
planningAreas={planningAreas}
fuelTypes={[]}
existingPlanningAreaStations={{
1: [{ planningAreaId: 1, rowId: 1, station: { code: 1, name: 'Station 1' } }]
}}
handleClose={handleClose}
/>
</Provider>
)

await user.click(getByTestId('admin-remove-button'))
await user.click(getByTestId('save-new-station-button'))

await waitFor(() => expect(updateStationsMock).toHaveBeenCalledTimes(1))
expect(handleClose).not.toHaveBeenCalled()
})

it('closes the modal when station updates succeed', async () => {
const user = userEvent.setup()
const handleClose = vi.fn()
updateStationsMock.mockResolvedValue(200)

const { getByTestId } = render(
<Provider store={createTestStore()}>
<StationListAdmin
fireCentreId={1}
planningAreas={planningAreas}
fuelTypes={[]}
existingPlanningAreaStations={{
1: [{ planningAreaId: 1, rowId: 1, station: { code: 1, name: 'Station 1' } }]
}}
handleClose={handleClose}
/>
</Provider>
)

await user.click(getByTestId('admin-remove-button'))
await user.click(getByTestId('save-new-station-button'))

await waitFor(() => expect(handleClose).toHaveBeenCalledTimes(1))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import hfiCalculatorDailiesReducer, {
fetchFuelTypesStart,
getHFIResultFailed,
initialState,
loadStationUpdateEnd,
loadStationUpdateStart,
pdfDownloadEnd,
pdfDownloadStart
} from 'features/hfiCalculator/slices/hfiCalculatorSlice'
Expand Down Expand Up @@ -31,6 +33,23 @@ describe('hfiCalculatorSlice', () => {
pdfLoading: false
})
})
it('should set stationsUpdateLoading = true and clear station update errors when loadStationUpdateStart is called', () => {
expect(
hfiCalculatorDailiesReducer({ ...initialState, stationsUpdatedError: dummyError }, loadStationUpdateStart())
).toEqual({
...initialState,
stationsUpdateLoading: true,
stationsUpdatedError: null
})
})
it('should set stationsUpdateLoading = false when loadStationUpdateEnd is called', () => {
expect(
hfiCalculatorDailiesReducer({ ...initialState, stationsUpdateLoading: true }, loadStationUpdateEnd())
).toEqual({
...initialState,
stationsUpdateLoading: false
})
})
it('should set a value for error state when fetchFuelTypesFailed is called', () => {
expect(hfiCalculatorDailiesReducer(initialState, fetchFuelTypesFailed(dummyError)).error).not.toBeNull()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import {
updateStations
} from '@wps/api/hfiCalculatorAPI'
import { logError } from '@wps/utils/error'
import { getErrorMessage } from '@wps/utils/getError'
import type { AppThunk } from 'app/store'
import axios from 'axios'
import type {
AddStationOptions,
StationAdminRow
Expand Down Expand Up @@ -83,6 +83,7 @@ const dailiesSlice = createSlice({
},
loadStationUpdateStart(state: HFICalculatorState) {
state.stationsUpdateLoading = true
state.stationsUpdatedError = null
},
loadStationUpdateEnd(state: HFICalculatorState) {
state.stationsUpdateLoading = false
Expand Down Expand Up @@ -276,20 +277,20 @@ export const fetchAddOrUpdateStations =
fireCentreId: number,
addedStations: Required<StationAdminRow>[],
removedStations: Required<Pick<StationAdminRow, 'planningAreaId' | 'rowId' | 'station'>>[]
): AppThunk =>
): AppThunk<Promise<boolean>> =>
async dispatch => {
try {
dispatch(loadStationUpdateStart())
await updateStations(fireCentreId, addedStations, removedStations)
dispatch(loadStationUpdateEnd())
dispatch(setChangeSaved(true))
return true
} catch (err) {
if (axios.isAxiosError(err)) {
dispatch(getHFIResultFailed(err.response?.data.detail))
} else {
dispatch(getHFIResultFailed((err as Error).toString()))
}
dispatch(loadStationUpdateEnd())
const errorMessage = getErrorMessage(err)
dispatch(setStationsUpdatedFailed(errorMessage))
Comment thread
conbrad marked this conversation as resolved.
logError(err)
return false
}
}

Expand Down
1 change: 1 addition & 0 deletions web/packages/api/src/hfiCalculatorAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ export interface HFIAdminAddedStation {
}
export interface HFIAdminRemovedStation {
planning_area_id: number
station_code: number
row_id: number
}

Expand Down
Loading
Loading