diff --git a/web/apps/wps-web/src/features/fireWatch/components/CreateFireWatch.tsx b/web/apps/wps-web/src/features/fireWatch/components/CreateFireWatch.tsx index 8c1e8437e..02ef63770 100644 --- a/web/apps/wps-web/src/features/fireWatch/components/CreateFireWatch.tsx +++ b/web/apps/wps-web/src/features/fireWatch/components/CreateFireWatch.tsx @@ -115,11 +115,10 @@ const CreateFireWatch = ({ } } - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fetch on mount only useEffect(() => { dispatch(fetchWxStations(getStations, StationSource.wildfire_one)) dispatch(fetchFireWatchFireCentres()) - }, []) + }, [dispatch]) const isEditMode = !Number.isNaN(fireWatch.id) diff --git a/web/apps/wps-web/src/features/fireWatch/components/FireWatchDashboard.tsx b/web/apps/wps-web/src/features/fireWatch/components/FireWatchDashboard.tsx index 62809aed2..f34cee973 100644 --- a/web/apps/wps-web/src/features/fireWatch/components/FireWatchDashboard.tsx +++ b/web/apps/wps-web/src/features/fireWatch/components/FireWatchDashboard.tsx @@ -142,10 +142,9 @@ const FireWatchDashboard = () => { } ] - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — fetch on mount only useEffect(() => { dispatch(fetchBurnForecasts()) - }, []) + }, [dispatch]) const getDetailPanelContent = React.useCallback>( ({ row }) => , diff --git a/web/apps/wps-web/src/features/fireWatch/components/createFireWatch.test.tsx b/web/apps/wps-web/src/features/fireWatch/components/createFireWatch.test.tsx new file mode 100644 index 000000000..d4b6509fd --- /dev/null +++ b/web/apps/wps-web/src/features/fireWatch/components/createFireWatch.test.tsx @@ -0,0 +1,49 @@ +import { render, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CreateFireWatch from '@/features/fireWatch/components/CreateFireWatch' +import { createTestStore } from '@/test/testUtils' + +vi.mock('@wps/api/stationAPI', () => ({ + getStations: vi.fn(() => Promise.resolve([])), + StationSource: { wildfire_one: 'wildfire_one', unspecified: 'unspecified' } +})) + +vi.mock('@wps/api/psuAPI', () => ({ + getFireCentres: vi.fn(() => Promise.resolve({ fire_centres: [] })) +})) + +describe('CreateFireWatch', () => { + let testStore: ReturnType + + beforeEach(() => { + testStore = createTestStore() + }) + + const renderCreateFireWatch = () => + render( + + + + ) + + it('dispatches fetchWxStations and fetchFireWatchFireCentres on mount', async () => { + const dispatchSpy = vi.spyOn(testStore, 'dispatch') + renderCreateFireWatch() + + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalled() + }) + }) + + it('renders the stepper with all steps', () => { + const { getByText } = renderCreateFireWatch() + + expect(getByText('Info')).toBeInTheDocument() + expect(getByText('Location')).toBeInTheDocument() + expect(getByText('Weather')).toBeInTheDocument() + expect(getByText('Fuel Info')).toBeInTheDocument() + expect(getByText('FBP Indices')).toBeInTheDocument() + expect(getByText('Submit')).toBeInTheDocument() + }) +}) diff --git a/web/apps/wps-web/src/features/fireWatch/components/steps/LocationStep.tsx b/web/apps/wps-web/src/features/fireWatch/components/steps/LocationStep.tsx index 15b2a220a..0363e0a5e 100644 --- a/web/apps/wps-web/src/features/fireWatch/components/steps/LocationStep.tsx +++ b/web/apps/wps-web/src/features/fireWatch/components/steps/LocationStep.tsx @@ -12,7 +12,7 @@ import VectorLayer from 'ol/layer/Vector.js' import { fromLonLat, toLonLat } from 'ol/proj' import VectorSource from 'ol/source/Vector.js' import { Icon, Style } from 'ol/style' -import React, { type SetStateAction, useEffect, useRef, useState } from 'react' +import React, { type SetStateAction, useCallback, useEffect, useRef, useState } from 'react' import { FORM_MAX_WIDTH } from '@/features/fireWatch/constants' import type { FireWatch } from '@/features/fireWatch/interfaces' @@ -41,16 +41,22 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => { const [lonInput, setLonInput] = useState(isValidGeometry ? toLonLat(fireWatch.geometry)[0].toFixed(6) : '') const [editingField, setEditingField] = useState<'lat' | 'lon' | null>(null) + const handleFormUpdate = useCallback( + (partialFireWatch: Partial) => { + setFireWatch(prev => ({ ...prev, ...partialFireWatch })) + }, + [setFireWatch] + ) + // Clear all interactions in order to remove the Translate interaction // and restore the original interactions in the correct order. - const resetMapInteractions = () => { + const resetMapInteractions = useCallback(() => { map?.getInteractions().clear() defaultInteractions({}).forEach(interaction => { map?.addInteraction(interaction) }) - } + }, [map]) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only re-run when marker changes useEffect(() => { // Clear and update the feature source so the newly created feature renders on the map. featureSource.clear() @@ -65,9 +71,8 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => { handleFormUpdate({ geometry: evt.coordinate }) }) map?.addInteraction(newTranslate) - }, [marker]) + }, [marker, featureSource, resetMapInteractions, handleFormUpdate, map]) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — map init runs once useEffect(() => { if (!mapRef.current) { return @@ -100,12 +105,14 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => { return () => { mapObject.setTarget('') } - }, []) + }, [featureSource]) - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only re-run when map instance changes useEffect(() => { // Click handler to allow user to click on map to place a marker. const handleMapClick = (evt: MapBrowserEvent) => { + const [lon, lat] = toLonLat(evt.coordinate) + setLatInput(lat.toFixed(6)) + setLonInput(lon.toFixed(6)) handleFormUpdate({ geometry: evt.coordinate }) const newFeature = new Feature({ geometry: new Point(evt.coordinate) @@ -113,25 +120,7 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => { setMarker([newFeature]) } map?.on('singleclick', evt => handleMapClick(evt)) - - // Allow dragging of the marker. - const translate = new Translate({ - features: new Collection(marker) - }) - translate.on('translateend', evt => { - handleFormUpdate({ geometry: evt.coordinate }) - }) - map?.addInteraction(translate) - }, [map]) - - // sync textfields with marker coords - useEffect(() => { - if (marker.length && marker[0].getGeometry()) { - const [lon, lat] = toLonLat((marker[0].getGeometry() as Point).getCoordinates()) - setLatInput(lat.toFixed(6)) - setLonInput(lon.toFixed(6)) - } - }, [marker]) + }, [map, handleFormUpdate]) // when user finishes editing both fields, update marker const updateMarkerFromInputs = () => { @@ -140,20 +129,17 @@ const LocationStep = ({ fireWatch, setFireWatch }: LocationStepProps) => { handleFormUpdate({ geometry: undefined }) return } - const lat = parseFloat(latInput) - const lon = parseFloat(lonInput) + const lat = Number.parseFloat(latInput) + const lon = Number.parseFloat(lonInput) if (!Number.isNaN(lat) && !Number.isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { const coords = fromLonLat([lon, lat]) + setLatInput(lat.toFixed(6)) + setLonInput(lon.toFixed(6)) setMarker([new Feature({ geometry: new Point(coords) })]) handleFormUpdate({ geometry: coords }) } } - const handleFormUpdate = (partialFireWatch: Partial) => { - const newFireWatch = { ...fireWatch, ...partialFireWatch } - setFireWatch(newFireWatch) - } - return ( diff --git a/web/apps/wps-web/src/features/fireWatch/components/steps/ReviewSubmitStep.tsx b/web/apps/wps-web/src/features/fireWatch/components/steps/ReviewSubmitStep.tsx index 3fe891a12..49e70e97c 100644 --- a/web/apps/wps-web/src/features/fireWatch/components/steps/ReviewSubmitStep.tsx +++ b/web/apps/wps-web/src/features/fireWatch/components/steps/ReviewSubmitStep.tsx @@ -52,7 +52,6 @@ const ReviewSubmitStep = ({ fireWatch, setActiveStep }: ReviewSubmitStepProps) = return `${fireWatch.fuelType} ${postfix}` } - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — map init runs once useEffect(() => { if (!mapRef.current) { return @@ -88,7 +87,7 @@ const ReviewSubmitStep = ({ fireWatch, setActiveStep }: ReviewSubmitStepProps) = return () => { mapObject.setTarget('') } - }, []) + }, [fireWatch]) return ( diff --git a/web/apps/wps-web/src/features/fireWatch/components/steps/locationStep.test.tsx b/web/apps/wps-web/src/features/fireWatch/components/steps/locationStep.test.tsx new file mode 100644 index 000000000..59ba18a43 --- /dev/null +++ b/web/apps/wps-web/src/features/fireWatch/components/steps/locationStep.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { Dispatch, SetStateAction } from 'react' +import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import LocationStep from '@/features/fireWatch/components/steps/LocationStep' +import type { FireWatch } from '@/features/fireWatch/interfaces' +import { getBlankFireWatch } from '@/features/fireWatch/utils' + +vi.mock('ol', () => ({ + Collection: class {}, + Map: class { + setTarget = vi.fn() + on = vi.fn() + getInteractions = vi.fn(() => ({ clear: vi.fn() })) + addInteraction = vi.fn() + }, + View: class {} +})) + +vi.mock('ol/Feature.js', () => ({ + default: class { + getGeometry = vi.fn(() => null) + } +})) + +vi.mock('ol/geom', () => ({ + Point: class { + coords: number[] + constructor(coords: number[]) { + this.coords = coords + } + getCoordinates = vi.fn(function (this: { coords: number[] }) { + return this.coords + }) + } +})) + +vi.mock('ol/interaction/defaults', () => ({ + defaults: vi.fn(() => ({ forEach: vi.fn() })) +})) + +vi.mock('ol/interaction/Translate.js', () => ({ + default: class { + on = vi.fn() + } +})) + +vi.mock('ol/layer/Tile', () => ({ default: class {} })) +vi.mock('ol/layer/Vector.js', () => ({ default: class {} })) + +vi.mock('ol/proj', () => ({ + fromLonLat: vi.fn(([lon, lat]: number[]) => [lon * 1000, lat * 1000]), + toLonLat: vi.fn(([x, y]: number[]) => [x / 1000, y / 1000]) +})) + +vi.mock('ol/source/Vector.js', () => ({ + default: class { + clear = vi.fn() + addFeatures = vi.fn() + } +})) + +vi.mock('ol/style', () => ({ + Icon: class {}, + Style: class {} +})) + +vi.mock('features/fireWeather/components/maps/constants', () => ({ + source: {} +})) + +describe('LocationStep', () => { + let mockSetFireWatch: Mock>> + + beforeEach(() => { + mockSetFireWatch = vi.fn>>() + }) + + it('renders lat and lon text fields', () => { + render() + + expect(screen.getByTestId('lat-input')).toBeInTheDocument() + expect(screen.getByTestId('lon-input')).toBeInTheDocument() + }) + + it('formats and commits lat/lon when valid values are entered and the field is blurred', async () => { + render() + + const latInput = screen.getByTestId('lat-input') + const lonInput = screen.getByTestId('lon-input') + + fireEvent.change(latInput, { target: { value: '50' } }) + fireEvent.change(lonInput, { target: { value: '-120' } }) + fireEvent.blur(lonInput) + + await waitFor(() => { + expect(latInput).toHaveValue('50.000000') + expect(lonInput).toHaveValue('-120.000000') + expect(mockSetFireWatch).toHaveBeenCalledOnce() + }) + }) + + it('clears geometry when lat or lon is empty on blur', async () => { + let currentFireWatch = getBlankFireWatch() + const setFireWatch = vi.fn>>().mockImplementation(updater => { + currentFireWatch = typeof updater === 'function' ? updater(currentFireWatch) : updater + }) + + render() + + fireEvent.blur(screen.getByTestId('lon-input')) + + await waitFor(() => { + expect(setFireWatch).toHaveBeenCalledOnce() + }) + expect(currentFireWatch.geometry).toBeUndefined() + }) +})