Skip to content
Draft
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
113 changes: 113 additions & 0 deletions src/pages/components/LineSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { useAllRoutes } from 'src/hooks/useAllRoutes'
import i18n from 'src/locale/allTranslations'
import LineSelector from './LineSelector'

// Keep the real API client / gtfsService out of the test: LineSelector only
// reads `line` and `suffix` off each route, so a mocked hook is enough.
jest.mock('src/hooks/useAllRoutes')
const mockUseAllRoutes = jest.mocked(useAllRoutes)

const LINE_LABEL = i18n.t('choose_line')
const OPEN_BUTTON = /open/i
const AUTOCOMPLETE_INPUT_ROOT = '.MuiAutocomplete-inputRoot'
const CLEAR_INDICATOR = '.clear-indicator'
const DEBOUNCE_MS = 500

type RouteItem = ReturnType<typeof useAllRoutes>['routes'][number]

const route = (line: number, suffix = ''): RouteItem => ({
id: line,
line,
suffix,
start: 'start',
end: 'end',
routeKey: `${line}${suffix}-key`,
})

// useAllRoutes already returns routes sorted by line number; mirror that here.
const setRoutes = (routes: RouteItem[], isLoading = false) =>
mockUseAllRoutes.mockReturnValue({ routes, isLoading, error: false })

const renderSelector = (props: Partial<React.ComponentProps<typeof LineSelector>> = {}) =>
render(
<LineSelector
operatorId="3"
date="2026-07-01"
lineNumber={undefined}
setLineNumber={jest.fn()}
{...props}
/>,
)

beforeEach(() => {
setRoutes([])
})

afterEach(() => {
jest.useRealTimers()
})

describe('LineSelector', () => {
it('offers the distinct line numbers running for the operator + date, in order', () => {
setRoutes([route(1), route(5), route(5), route(18), route(18, 'א')])
renderSelector()

fireEvent.click(screen.getByRole('button', { name: OPEN_BUTTON }))

// The two direction rows of line 5 collapse to a single option.
expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['1', '5', '18', '18א'])
})

it('commits the line number when an option is picked', () => {
setRoutes([route(18), route(18, 'א'), route(480)])
const setLineNumber = jest.fn()
renderSelector({ setLineNumber })

fireEvent.click(screen.getByRole('button', { name: OPEN_BUTTON }))
fireEvent.click(screen.getByRole('option', { name: '480' }))

expect(setLineNumber).toHaveBeenCalledWith('480')
})

it('commits a freely typed line number after the debounce (does not restrict to options)', () => {
jest.useFakeTimers()
const setLineNumber = jest.fn()
setRoutes([route(1)])
renderSelector({ setLineNumber })

fireEvent.change(screen.getByRole('combobox', { name: LINE_LABEL }), {
target: { value: '42' },
})
expect(setLineNumber).not.toHaveBeenCalled() // debounced, not immediate

act(() => {
jest.advanceTimersByTime(DEBOUNCE_MS)
})
expect(setLineNumber).toHaveBeenCalledWith('42')
})

it('renders as a standard Autocomplete with the dropdown arrow (appearance parity guard)', () => {
setRoutes([route(1)])
renderSelector()

// `MuiAutocomplete-inputRoot` carries the compact vertical padding that keeps
// this field the same height as the other selectors — its absence was the
// 74px-vs-56px regression. `forcePopupIcon` keeps the dropdown arrow so it
// looks like OperatorSelector / RouteSelector rather than a bare text field.
expect(
screen.getByRole('combobox', { name: LINE_LABEL }).closest(AUTOCOMPLETE_INPUT_ROOT),
).not.toBeNull()
expect(screen.getByRole('button', { name: OPEN_BUTTON })).toBeInTheDocument()
})

it('tags the clear button with the `clear-indicator` class the e2e helper relies on', () => {
// The `clearInputField` Playwright helper (clearButton.spec.ts) finds the
// clear button by the repo-wide `.clear-indicator` class. MUI's built-in
// clear indicator only renders once the field has a value to clear.
setRoutes([route(5)])
renderSelector({ lineNumber: '5' })

expect(document.querySelector(CLEAR_INDICATOR)).not.toBeNull()
})
})
140 changes: 82 additions & 58 deletions src/pages/components/LineSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,82 @@
import { TextField } from '@mui/material'
import classNames from 'classnames'
import { debounce } from 'es-toolkit/compat'
import { useCallback, useLayoutEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClearButton from './ClearButton'
import './Selector.scss'

type LineSelectorProps = {
disabled?: boolean
lineNumber: string | undefined
setLineNumber: (lineNumber: string) => void
}

const LineSelector = ({ disabled, lineNumber, setLineNumber }: LineSelectorProps) => {
const [value, setValue] = useState<LineSelectorProps['lineNumber']>(lineNumber)
const debouncedSetLineNumber = useCallback(debounce(setLineNumber, 500), [setLineNumber])
const { t } = useTranslation()

useLayoutEffect(() => {
setValue(lineNumber)
}, [])

const handleClearInput = () => {
setValue('')
setLineNumber('')
}

const textFieldClass = classNames({
'selector-line-text-field': true,
'selector-line-text-field_visible': value,
'selector-line-text-field_hidden': !value,
})
return (
<TextField
disabled={disabled}
className={textFieldClass}
label={t('choose_line')}
type="text"
value={value && +value < 0 ? 0 : value}
onChange={(e) => {
setValue(e.target.value)
debouncedSetLineNumber(e.target.value)
}}
slotProps={{
inputLabel: {
shrink: true,
},
input: {
placeholder: t('line_placeholder'),
endAdornment: <ClearButton onClearInput={handleClearInput} />,
},
}}
/>
)
}

export default LineSelector
import { Autocomplete, TextField } from '@mui/material'
import { debounce } from 'es-toolkit/compat'
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAllRoutes } from 'src/hooks/useAllRoutes'

type LineSelectorProps = {
disabled?: boolean
operatorId?: string
date?: string
lineNumber: string | undefined
setLineNumber: (lineNumber: string) => void
}

const LineSelector = ({
disabled,
operatorId,
date,
lineNumber,
setLineNumber,
}: LineSelectorProps) => {
const [value, setValue] = useState<string>(lineNumber ?? '')
const debouncedSetLineNumber = useCallback(debounce(setLineNumber, 500), [setLineNumber])
const { t } = useTranslation()
const { routes, isLoading } = useAllRoutes(operatorId, date)

useLayoutEffect(() => {
setValue(lineNumber ?? '')
}, [])

// Distinct line numbers running for the selected operator + date. `routes` is
// already sorted by line number in useAllRoutes, so a Set preserves that order.
const options = useMemo(() => {
const seen = new Set<string>()
const result: string[] = []
for (const route of routes) {
if (!route.line) continue
const label = `${route.line}${route.suffix}`
if (!seen.has(label)) {
seen.add(label)
result.push(label)
}
}
return result
}, [routes])

return (
<Autocomplete
freeSolo
forcePopupIcon
disablePortal
fullWidth
disabled={disabled}
loading={isLoading}
options={options}
// Tag MUI's built-in clear button with the repo-wide `clear-indicator`
// class (the same hook VehicleSelector's ClearButton and Selector.scss use)
// so the e2e `clearInputField` helper can find it.
slotProps={{ clearIndicator: { className: 'clear-indicator' } }}
inputValue={value}
onInputChange={(_event, newValue, reason) => {
setValue(newValue)
if (reason === 'input') {
debouncedSetLineNumber(newValue)
} else if (reason === 'clear') {
debouncedSetLineNumber.cancel()
setLineNumber('')
}
}}
onChange={(_event, newValue) => {
debouncedSetLineNumber.cancel()
setValue(newValue ?? '')
setLineNumber(newValue ?? '')
}}
renderInput={(params) => (
<TextField {...params} label={t('choose_line')} placeholder={t('line_placeholder')} />
)}
/>
)
}

export default LineSelector
2 changes: 2 additions & 0 deletions src/pages/gaps/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ const GapsPage = () => {
</Grid>
<Grid size={{ xs: 8 }}>
<LineNumberSelector
operatorId={operatorId ?? undefined}
date={date}
lineNumber={lineNumber ?? undefined}
setLineNumber={handleLineNumberChange}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/pages/gapsPatterns/GapsPatternsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ const GapsPatternsPage = () => {
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<LineNumberSelector
operatorId={operatorId ?? undefined}
date={startDate.format('YYYY-MM-DD')}
lineNumber={lineNumber ?? undefined}
setLineNumber={(number) => setSearch((current) => ({ ...current, lineNumber: number }))}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/pages/historicTimeline/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ const TimelinePage = () => {
{/* choose line */}
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
<LineNumberSelector
operatorId={operatorId ?? undefined}
date={date}
lineNumber={lineNumber ?? undefined}
setLineNumber={(number) => setSearch((prev) => ({ ...prev, lineNumber: number }))}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/pages/singleLineMap/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ const SingleLineMapPage = () => {
<Grid size={{ sm: 4, xs: 12 }}>
<LineNumberSelector
disabled={!operatorId}
operatorId={operatorId ?? undefined}
date={date}
lineNumber={lineNumber ?? undefined}
setLineNumber={handleLineNumberChange}
/>
Expand Down
4 changes: 0 additions & 4 deletions src/test_pages/TimelinePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ class TimelinePage extends BasePage {
public get stationList() {
return this.page.locator('ul#stop-select-listbox')
}
get closeButton() {
return this.page.locator("span[aria-label='סגור'], span[aria-label='close']")
}

get lineNumberField() {
return this.page.locator("//input[@placeholder='לדוגמה: 17א']")
}
Expand Down
2 changes: 1 addition & 1 deletion tests/interlink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const PM_MOON = '🌙' // next-night marker shown beside post-midnight times in
async function selectRoute(page: Page) {
await page.getByLabel('חברה מפעילה').click()
await page.getByRole('option', { name: OPERATOR, exact: true }).click()
await page.getByRole('textbox', { name: 'מספר קו' }).fill(LINE)
await page.getByRole('combobox', { name: 'מספר קו' }).fill(LINE)
// Type-to-filter the route Autocomplete so the target option renders even when
// the line has many variants (MUI virtualizes long option lists).
await page.getByLabel(/בחירת מסלול נסיעה/).fill(ROUTE_FILTER)
Expand Down
2 changes: 1 addition & 1 deletion tests/missingRides.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const FULL_SERVICE_DAY_TIMES = ['04:30', '17:00', '04:47'] as const
async function selectGapsRoute(page: import('@playwright/test').Page) {
await page.getByLabel('חברה מפעילה').click()
await page.getByRole('option', { name: GAPS_OPERATOR, exact: true }).click()
await page.getByRole('textbox', { name: 'מספר קו' }).fill(GAPS_LINE_NUMBER)
await page.getByRole('combobox', { name: 'מספר קו' }).fill(GAPS_LINE_NUMBER)
await page.getByLabel(/בחירת מסלול נסיעה/).click()
await page.getByRole('option', { name: GAPS_ROUTE }).click()
}
Expand Down
10 changes: 5 additions & 5 deletions tests/recordHAR.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ test.describe('Record HAR files', () => {
}

// Fill line 16 (triggers gtfs_routes/list with route_short_name=16)
await page.getByRole('textbox', { name: 'מספר קו' }).fill('16')
await page.getByRole('combobox', { name: 'מספר קו' }).fill('16')
await page.waitForLoadState('networkidle')

// Select a route (triggers gtfs_rides/list, gtfs_ride_stops/list, gtfs_stops/get, siri data)
Expand Down Expand Up @@ -203,7 +203,7 @@ test.describe('Record HAR files', () => {
}

// Fill line 9999 to record the empty routes response
await page.getByRole('textbox', { name: 'מספר קו' }).fill('9999')
await page.getByRole('combobox', { name: 'מספר קו' }).fill('9999')
await page.waitForTimeout(3000)
await page.waitForLoadState('networkidle')

Expand Down Expand Up @@ -253,7 +253,7 @@ test.describe('Record HAR files', () => {
await goToPage(page, '/gaps')
await page.getByLabel('חברה מפעילה').click()
await page.getByRole('option', { name: 'אגד', exact: true }).click()
await page.getByRole('textbox', { name: 'מספר קו' }).fill('402')
await page.getByRole('combobox', { name: 'מספר קו' }).fill('402')
await page.waitForLoadState('networkidle')
// Type-to-filter the route Autocomplete (defeats option virtualization on lines
// with many variants), then pick the line_ref 33267 direction ('...הורדה...').
Expand All @@ -278,7 +278,7 @@ test.describe('Record HAR files', () => {
await goToPage(page, '/single-line-map')
await page.getByLabel('חברה מפעילה').click()
await page.getByRole('option', { name: 'אגד', exact: true }).click()
await page.getByRole('textbox', { name: 'מספר קו' }).fill('402')
await page.getByRole('combobox', { name: 'מספר קו' }).fill('402')
await page.waitForLoadState('networkidle')
await page.getByLabel(/בחירת מסלול נסיעה/).fill('הורדה')
await page.waitForLoadState('networkidle')
Expand Down Expand Up @@ -323,7 +323,7 @@ test.describe('Record HAR files', () => {
return
}

await page.getByRole('textbox', { name: 'מספר קו' }).fill('16')
await page.getByRole('combobox', { name: 'מספר קו' }).fill('16')
await page.waitForLoadState('networkidle')

await page.getByLabel(/בחירת מסלול נסיעה/).click()
Expand Down
8 changes: 4 additions & 4 deletions tests/singlelineTest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async function selectOperator(page: Page, operatorName = 'אודליה מוני
}

async function fillLineNumber(page: Page, lineNumber = '16') {
await page.getByRole('textbox', { name: 'מספר קו' }).fill(lineNumber)
await page.getByRole('combobox', { name: 'מספר קו' }).fill(lineNumber)
}

async function selectRoute(
Expand All @@ -44,10 +44,10 @@ test.describe('Single line page tests', () => {
})

test('should allow selecting operator company options', async ({ page }) => {
await expect(page.getByRole('textbox', { name: 'מספר קו' })).not.toBeEditable()
await expect(page.getByRole('combobox', { name: 'מספר קו' })).not.toBeEditable()
await selectOperator(page)
await expect(page.getByLabel('חברה מפעילה')).toHaveValue('אודליה מוניות בעמ')
await expect(page.getByRole('textbox', { name: 'מספר קו' })).toBeEditable()
await expect(page.getByRole('combobox', { name: 'מספר קו' })).toBeEditable()
})

test('should show and enable "choose route" dropdown after selecting line', async ({ page }) => {
Expand All @@ -62,7 +62,7 @@ test.describe('Single line page tests', () => {
await expect(page.locator('#route-select')).not.toBeEditable()
await fillLineNumber(page)
await expect(page.locator('#route-select')).toBeEditable()
await clearInputField(page.getByRole('textbox', { name: 'מספר קו' }))
await clearInputField(page.getByRole('combobox', { name: 'מספר קו' }))
await expect(page.locator('#route-select')).not.toBeEditable()
})

Expand Down
Loading
Loading