Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

As a user i want to be able to choose the forecast window #303

Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.forecast-window-select{
width: 6.6rem;
color: black;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import '@testing-library/jest-dom'
import { render, screen, waitFor } from '@testing-library/react'

import { ForecastWindowSelector } from './ForecastWindowSelector'

describe('ForecastWindowSelector component', () => {
it('Select box label is present', async () => {
render(
<ForecastWindowSelector
setSelectedForecastWindowState={() => {}}
selectedForecastWindow={{ value: 1, label: '1' }}
/>,
)
await waitFor(() => {
expect(screen.getByText('Forecast Window')).toBeInTheDocument()
})
})
it('Select box is present', async () => {
render(
<ForecastWindowSelector
setSelectedForecastWindowState={() => {}}
selectedForecastWindow={{ value: 1, label: '1' }}
/>,
)
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
})
})
46 changes: 46 additions & 0 deletions air-quality-ui/src/components/header/ForecastWindowSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Select from 'react-select'

import classes from './ForecastWindowSelector.module.css'

export type ForecastWindowOption = {
value: number
label: string
}

const forecastWindowOptions: ForecastWindowOption[] = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 4, label: '4' },
{ value: 5, label: '5' },
]

export interface ForecastWindowSelectorProps {
setSelectedForecastWindowState: (value: ForecastWindowOption) => void
selectedForecastWindow: ForecastWindowOption
}

export const ForecastWindowSelector = (props: ForecastWindowSelectorProps) => {
return (
<div>
<label className={classes['forecast-window-label']}>
Forecast Window
</label>
<Select
className={classes['forecast-window-select']}
inputId="forecast-window-select"
name="forecast-window-select"
data-testid="forecast-window-select"
onChange={(value) => {
if (value) {
props.setSelectedForecastWindowState(value)
}
}}
options={forecastWindowOptions}
value={props.selectedForecastWindow}
/>
</div>
)
}

export default ForecastWindowSelector
16 changes: 16 additions & 0 deletions air-quality-ui/src/components/header/Toolbar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@
.forecast-base-date-picker-div{
margin-left: auto;
}
.forecast-window-main-div{
padding-left: 1rem;
padding-bottom: 1rem;
}
.forecast-window-label{
font-family: "Roboto","Helvetica","Arial",sans-serif;
font-weight: 400;
font-size: 0.8rem;
line-height: 1.4375em;
letter-spacing: 0.00938em;
transform-origin: top left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: calc(133% - 32px);
}
60 changes: 60 additions & 0 deletions air-quality-ui/src/components/header/Toolbar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DateTime, Settings } from 'luxon'

import { ForecastBaseDatePickerProps } from './ForecastBaseDatePicker'
import {
ForecastWindowOption,
ForecastWindowSelectorProps,
} from './ForecastWindowSelector'
import { Toolbar } from './Toolbar'

const mockSetForecastBaseDate: (val: DateTime) => void = jest.fn()
const mockSetMaxForecastDate: (val: number) => void = jest.fn()
const mockSetMaxInSituDate: (val: number) => void = jest.fn()

const dateNow = DateTime.fromISO('2024-06-01T12:00:00', { zone: 'UTC' })
Settings.now = () => dateNow.toMillis()
Expand All @@ -15,6 +21,8 @@ jest.mock('../../context', () => ({
forecastBaseDate: DateTime.fromISO('2024-06-01T12:00:00', { zone: 'UTC' }),
maxInSituDate: DateTime.fromISO('2024-06-10T09:00:00', { zone: 'utc' }),
setForecastBaseDate: (x: DateTime) => mockSetForecastBaseDate(x),
setMaxForecastDate: (x: number) => mockSetMaxForecastDate(x),
setMaxInSituDate: (x: number) => mockSetMaxInSituDate(x),
}),
}))

Expand All @@ -35,6 +43,20 @@ jest.mock('./ForecastBaseDatePicker', () => ({
},
}))

const mockForecastWindowSelector = jest
.fn()
.mockReturnValue(<span>mock ForecastBaseDatePicker</span>)

let setSelectedForecastWindow: (val: ForecastWindowOption) => void
let selectedForecastWindow: ForecastWindowOption
jest.mock('./ForecastWindowSelector', () => ({
ForecastWindowSelector: (props: ForecastWindowSelectorProps) => {
setSelectedForecastWindow = props.setSelectedForecastWindowState
selectedForecastWindow = props.selectedForecastWindow
return mockForecastWindowSelector(props)
},
}))

describe('Toolbar component', () => {
it('renders toolbar', () => {
render(<Toolbar />)
Expand Down Expand Up @@ -85,4 +107,42 @@ describe('Toolbar component', () => {
)
})
})
;[
{
testData: { value: 1, label: '1' },
expected: 1,
},
{
testData: { value: 2, label: '2' },
expected: 2,
},
{
testData: { value: 3, label: '3' },
expected: 3,
},

{
testData: { value: 4, label: '4' },
expected: 4,
},
{
testData: { value: 5, label: '5' },
expected: 5,
},
].forEach(({ testData, expected }) => {
it(`When forecast window selector is changed to ${testData}, when the ok button is clicked, selectedForecastWindow is ${expected}`, async () => {
render(<Toolbar />)
const beforeSetting = selectedForecastWindow
await waitFor(() => {
setSelectedForecastWindow(testData)
})
fireEvent.click(screen.getByText('Ok'))
await waitFor(() => {
expect(beforeSetting).toStrictEqual({ value: 1, label: '1' })
})
await waitFor(() => {
expect(mockSetMaxForecastDate).toHaveBeenCalledWith(expected)
})
})
})
})
21 changes: 20 additions & 1 deletion air-quality-ui/src/components/header/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ import { useState } from 'react'

import { Breadcrumbs } from './Breadcrumbs'
import { ForecastBaseDatePicker } from './ForecastBaseDatePicker'
import {
ForecastWindowOption,
ForecastWindowSelector,
} from './ForecastWindowSelector'
import classes from './Toolbar.module.css'
import { useForecastContext } from '../../context'
import { VAirifyButton } from '../common/button/VAirifyButton'

export const Toolbar = () => {
const { forecastBaseDate, setForecastBaseDate } = useForecastContext()
const {
forecastBaseDate,
setForecastBaseDate,
setMaxForecastDate,
setMaxInSituDate,
} = useForecastContext()
const [selectedForecastBaseDate, setSelectedForecastBaseDate] =
useState<DateTime<boolean>>(forecastBaseDate)
const [selectedForecastWindow, setSelectedForecastWindow] =
useState<ForecastWindowOption>({ value: 1, label: '1' })
const [isInvalidDateTime, setIsInvalidDateTime] = useState<boolean>(false)

return (
Expand All @@ -29,12 +40,20 @@ export const Toolbar = () => {
forecastBaseDate={forecastBaseDate}
/>
</div>
<div className={classes['forecast-window-main-div']}>
<ForecastWindowSelector
setSelectedForecastWindowState={setSelectedForecastWindow}
selectedForecastWindow={selectedForecastWindow}
/>
</div>
<div className={classes['forecast-base-date-picker-button-div']}>
<VAirifyButton
onClick={() => {
if (selectedForecastBaseDate) {
setForecastBaseDate(selectedForecastBaseDate)
}
setMaxForecastDate(selectedForecastWindow.value)
setMaxInSituDate(selectedForecastWindow.value)
}}
text={'Ok'}
isButtonDisabled={isInvalidDateTime}
Expand Down
15 changes: 13 additions & 2 deletions air-quality-ui/src/components/single-city/SingleCity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,32 @@
] = useQueries({
queries: [
{
queryKey: [forecastBaseDate, locationName],
queryKey: [
forecastBaseDate,
locationName,
maxInSituDate,
maxForecastDate,
],
queryFn: () =>

Check warning on line 51 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
getForecastData(
forecastBaseDate,
maxForecastDate,
forecastBaseDate,
locationName,

Check warning on line 56 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
),
},
{
queryKey: ['measurements', locationName, forecastBaseDate],
queryKey: [
'measurements',
locationName,
forecastBaseDate,
maxInSituDate,
maxForecastDate,
],
queryFn: () =>

Check warning on line 67 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
getMeasurements(forecastBaseDate, maxInSituDate, 'city', [
locationName,
]),

Check warning on line 70 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
},
],
})
Expand All @@ -67,7 +78,7 @@
const sites = Array.from(
measurementData
?.map((measurement) => getSiteName(measurement))
.reduce((sites, name) => sites.add(name), new Set<string>()) ?? [],

Check warning on line 81 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
).map((name) => ({
value: name,
label: name,
Expand All @@ -84,7 +95,7 @@
) => {
if (
detail.action === 'remove-value' ||
detail.action === 'select-option'

Check warning on line 98 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
) {
setSelectedSites(changeValue)
}
Expand Down Expand Up @@ -159,15 +170,15 @@
colorsBySite[value] = color
}
setSiteColors(colorsBySite)
}

Check warning on line 173 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
updateColors()

Check warning on line 174 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
}, [sites])

Check warning on line 175 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 175 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 175 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function

Check warning on line 176 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
const deselectSite = useCallback((siteName: string) => {
setSelectedSites((current) =>
current.filter(({ value }) => value !== siteName),

Check warning on line 179 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
)

Check warning on line 180 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
}, [])

Check warning on line 181 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 182 in air-quality-ui/src/components/single-city/SingleCity.tsx

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
if (forecastDataError || measurementDataError) {
return <>An error occurred</>
Expand Down
72 changes: 62 additions & 10 deletions air-quality-ui/src/context.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { DateTime } from 'luxon'
import { createContext, useContext, useState } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'

import {
getLatestBaseForecastTime,
Expand All @@ -8,9 +14,11 @@ import {

type ForecastContextType = {
forecastBaseDate: DateTime
setForecastBaseDate: (arg: DateTime) => void
maxInSituDate: DateTime
maxForecastDate: DateTime
maxInSituDate: DateTime
setForecastBaseDate: (arg: DateTime) => void
setMaxForecastDate: (arg: number) => void
setMaxInSituDate: (arg: number) => void
}

const ForecastContext = createContext<ForecastContextType | undefined>(
Expand All @@ -27,26 +35,70 @@ export const ForecastContextProvider = (props: any) => {

const [forecastBaseDate, setForecastBaseDateState] =
useState<ForecastContextType['forecastBaseDate']>(defaultValue)

const [maxForecastDate, setMaxForecastDateState] = useState<
ForecastContextType['maxForecastDate']
>(forecastBaseDate.plus({ days: 1 }))

const [maxInSituDate, setMaxInSituDateState] = useState<
ForecastContextType['maxInSituDate']
>(
DateTime.min(
getNearestValidForecastTime(DateTime.utc()),
forecastBaseDate.plus({ days: 1 }),
),
)

const [forecastWindow, setForecastWindowState] = useState(1)
const [inSituWindow, setInSituWindowState] = useState(1)

const setForecastBaseDate: ForecastContextType['setForecastBaseDate'] = (
value,
) => {
setForecastBaseDateState(value)
}
const maxInSituDate: ForecastContextType['maxInSituDate'] = DateTime.min(
getNearestValidForecastTime(DateTime.utc()),
forecastBaseDate.plus({ days: 5 }),

const setMaxForecastDate: ForecastContextType['setMaxForecastDate'] =
useCallback(
(value: number) => {
setForecastWindowState(value)
setMaxForecastDateState(forecastBaseDate.plus({ days: value }))
},
[forecastBaseDate],
)
const setMaxInSituDate: ForecastContextType['setMaxInSituDate'] = useCallback(
(value: number) => {
setInSituWindowState(value)
setMaxInSituDateState(
DateTime.min(
getNearestValidForecastTime(DateTime.utc()),
forecastBaseDate.plus({ days: value }),
),
)
},
[forecastBaseDate],
)

const maxForecastDate: ForecastContextType['maxForecastDate'] =
forecastBaseDate.plus({ days: 5 })
useEffect(() => {
setMaxForecastDate(forecastWindow)
setMaxInSituDate(inSituWindow)
}, [
forecastBaseDate,
forecastWindow,
inSituWindow,
setMaxForecastDate,
setMaxInSituDate,
])

return (
<ForecastContext.Provider
value={{
forecastBaseDate,
setForecastBaseDate,
maxInSituDate,
maxForecastDate,
maxInSituDate,
setForecastBaseDate,
setMaxInSituDate,
setMaxForecastDate,
}}
>
{props.children}
Expand Down
Loading