Skip to content

Commit c7588af

Browse files
authored
feat: Make line-selector an autocomplete (#1701)
1 parent f29d0d8 commit c7588af

14 files changed

Lines changed: 211 additions & 80 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { act, fireEvent, render, screen } from '@testing-library/react'
2+
import { useAllRoutes } from 'src/hooks/useAllRoutes'
3+
import i18n from 'src/locale/allTranslations'
4+
import LineSelector from './LineSelector'
5+
6+
vi.mock('src/hooks/useAllRoutes', () => ({ useAllRoutes: vi.fn() }))
7+
const mockUseAllRoutes = vi.mocked(useAllRoutes)
8+
9+
const LINE_LABEL = i18n.t('choose_line')
10+
const OPEN_BUTTON = /open/i
11+
const AUTOCOMPLETE_INPUT_ROOT = '.MuiAutocomplete-inputRoot'
12+
const CLEAR_INDICATOR = '.clear-indicator'
13+
const DEBOUNCE_MS = 500
14+
15+
type RouteItem = ReturnType<typeof useAllRoutes>['routes'][number]
16+
17+
const route = (line: number, suffix = ''): RouteItem => ({
18+
id: line,
19+
lineRef: line,
20+
line,
21+
suffix,
22+
start: 'start',
23+
end: 'end',
24+
routeKey: `${line}${suffix}-key`,
25+
})
26+
27+
const setRoutes = (routes: RouteItem[], isLoading = false) =>
28+
mockUseAllRoutes.mockReturnValue({ routes, isLoading, error: false })
29+
30+
const renderSelector = (props: Partial<React.ComponentProps<typeof LineSelector>> = {}) =>
31+
render(
32+
<LineSelector
33+
operatorId="3"
34+
date="2026-07-01"
35+
lineNumber={undefined}
36+
setLineNumber={vi.fn()}
37+
{...props}
38+
/>,
39+
)
40+
41+
beforeEach(() => {
42+
setRoutes([])
43+
})
44+
45+
afterEach(() => {
46+
vi.useRealTimers()
47+
})
48+
49+
describe('LineSelector', () => {
50+
it('offers the distinct line numbers running for the operator + date, in order', () => {
51+
setRoutes([route(1), route(5), route(5), route(18), route(18, 'א')])
52+
renderSelector()
53+
54+
fireEvent.click(screen.getByRole('button', { name: OPEN_BUTTON }))
55+
56+
// The two direction rows of line 5 collapse to a single option.
57+
expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['1', '5', '18', '18א'])
58+
})
59+
60+
it('commits the line number when an option is picked', () => {
61+
setRoutes([route(18), route(18, 'א'), route(480)])
62+
const setLineNumber = vi.fn()
63+
renderSelector({ setLineNumber })
64+
65+
fireEvent.click(screen.getByRole('button', { name: OPEN_BUTTON }))
66+
fireEvent.click(screen.getByRole('option', { name: '480' }))
67+
68+
expect(setLineNumber).toHaveBeenCalledWith('480')
69+
})
70+
71+
it('commits a freely typed line number after the debounce (does not restrict to options)', () => {
72+
vi.useFakeTimers()
73+
const setLineNumber = vi.fn()
74+
setRoutes([route(1)])
75+
renderSelector({ setLineNumber })
76+
77+
fireEvent.change(screen.getByRole('combobox', { name: LINE_LABEL }), {
78+
target: { value: '42' },
79+
})
80+
expect(setLineNumber).not.toHaveBeenCalled()
81+
82+
act(() => {
83+
vi.advanceTimersByTime(DEBOUNCE_MS)
84+
})
85+
expect(setLineNumber).toHaveBeenCalledWith('42')
86+
})
87+
88+
it('renders as a standard Autocomplete with the dropdown arrow (appearance parity guard)', () => {
89+
setRoutes([route(1)])
90+
renderSelector()
91+
92+
// `MuiAutocomplete-inputRoot` keeps this field the same height as the other selectors.
93+
expect(
94+
screen.getByRole('combobox', { name: LINE_LABEL }).closest(AUTOCOMPLETE_INPUT_ROOT),
95+
).not.toBeNull()
96+
expect(screen.getByRole('button', { name: OPEN_BUTTON })).toBeInTheDocument()
97+
})
98+
99+
it('tags the clear button with the `clear-indicator` class the e2e helper relies on', () => {
100+
// MUI only renders its clear indicator once the field has a value to clear.
101+
setRoutes([route(5)])
102+
renderSelector({ lineNumber: '5' })
103+
104+
expect(document.querySelector(CLEAR_INDICATOR)).not.toBeNull()
105+
})
106+
})
Lines changed: 80 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,80 @@
1-
import { TextField } from '@mui/material'
2-
import classNames from 'classnames'
3-
import { debounce } from 'es-toolkit/compat'
4-
import { useCallback, useLayoutEffect, useState } from 'react'
5-
import { useTranslation } from 'react-i18next'
6-
import ClearButton from './ClearButton'
7-
import './Selector.scss'
8-
9-
type LineSelectorProps = {
10-
disabled?: boolean
11-
lineNumber: string | undefined
12-
setLineNumber: (lineNumber: string) => void
13-
}
14-
15-
const LineSelector = ({ disabled, lineNumber, setLineNumber }: LineSelectorProps) => {
16-
const [value, setValue] = useState<LineSelectorProps['lineNumber']>(lineNumber)
17-
const debouncedSetLineNumber = useCallback(debounce(setLineNumber, 500), [setLineNumber])
18-
const { t } = useTranslation()
19-
20-
useLayoutEffect(() => {
21-
setValue(lineNumber)
22-
}, [])
23-
24-
const handleClearInput = () => {
25-
setValue('')
26-
setLineNumber('')
27-
}
28-
29-
const textFieldClass = classNames({
30-
'selector-line-text-field': true,
31-
'selector-line-text-field_visible': value,
32-
'selector-line-text-field_hidden': !value,
33-
})
34-
return (
35-
<TextField
36-
disabled={disabled}
37-
className={textFieldClass}
38-
label={t('choose_line')}
39-
type="text"
40-
value={value && +value < 0 ? 0 : value}
41-
onChange={(e) => {
42-
setValue(e.target.value)
43-
debouncedSetLineNumber(e.target.value)
44-
}}
45-
slotProps={{
46-
inputLabel: {
47-
shrink: true,
48-
},
49-
input: {
50-
placeholder: t('line_placeholder'),
51-
endAdornment: <ClearButton onClearInput={handleClearInput} />,
52-
},
53-
}}
54-
/>
55-
)
56-
}
57-
58-
export default LineSelector
1+
import { Autocomplete, TextField } from '@mui/material'
2+
import { debounce } from 'es-toolkit/compat'
3+
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
4+
import { useTranslation } from 'react-i18next'
5+
import { useAllRoutes } from 'src/hooks/useAllRoutes'
6+
7+
type LineSelectorProps = {
8+
disabled?: boolean
9+
operatorId?: string
10+
date?: string
11+
lineNumber: string | undefined
12+
setLineNumber: (lineNumber: string) => void
13+
}
14+
15+
const LineSelector = ({
16+
disabled,
17+
operatorId,
18+
date,
19+
lineNumber,
20+
setLineNumber,
21+
}: LineSelectorProps) => {
22+
const [value, setValue] = useState<string>(lineNumber ?? '')
23+
const debouncedSetLineNumber = useCallback(debounce(setLineNumber, 500), [setLineNumber])
24+
const { t } = useTranslation()
25+
const { routes, isLoading } = useAllRoutes(operatorId, date)
26+
27+
useLayoutEffect(() => {
28+
setValue(lineNumber ?? '')
29+
}, [])
30+
31+
// `routes` is already sorted by line number in useAllRoutes, so a Set preserves that order.
32+
const options = useMemo(() => {
33+
const seen = new Set<string>()
34+
const result: string[] = []
35+
for (const route of routes) {
36+
if (!route.line) continue
37+
const label = `${route.line}${route.suffix}`
38+
if (!seen.has(label)) {
39+
seen.add(label)
40+
result.push(label)
41+
}
42+
}
43+
return result
44+
}, [routes])
45+
46+
return (
47+
<Autocomplete
48+
freeSolo
49+
forcePopupIcon
50+
disablePortal
51+
fullWidth
52+
disabled={disabled}
53+
loading={isLoading}
54+
options={options}
55+
// The e2e `clearInputField` helper finds the clear button by the repo-wide
56+
// `clear-indicator` class, which MUI's built-in one doesn't carry.
57+
slotProps={{ clearIndicator: { className: 'clear-indicator' } }}
58+
inputValue={value}
59+
onInputChange={(_event, newValue, reason) => {
60+
setValue(newValue)
61+
if (reason === 'input') {
62+
debouncedSetLineNumber(newValue)
63+
} else if (reason === 'clear') {
64+
debouncedSetLineNumber.cancel()
65+
setLineNumber('')
66+
}
67+
}}
68+
onChange={(_event, newValue) => {
69+
debouncedSetLineNumber.cancel()
70+
setValue(newValue ?? '')
71+
setLineNumber(newValue ?? '')
72+
}}
73+
renderInput={(params) => (
74+
<TextField {...params} label={t('choose_line')} placeholder={t('line_placeholder')} />
75+
)}
76+
/>
77+
)
78+
}
79+
80+
export default LineSelector

src/pages/components/Selector.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
.selector {
2-
&-minute,
3-
&-line {
2+
&-minute {
43
&-text-field {
54
width: 100%;
65

src/pages/gaps/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ const GapsPage = () => {
127127
<Grid size={{ sm: 6, xs: 12 }}>
128128
<LineNumberSelector
129129
disabled={!operatorId}
130+
operatorId={operatorId ?? undefined}
131+
date={date}
130132
lineNumber={lineNumber ?? undefined}
131133
setLineNumber={handleLineNumberChange}
132134
/>

src/pages/gapsPatterns/GapsPatternsPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ const GapsPatternsPage = () => {
300300
</Grid>
301301
<Grid size={{ xs: 12, sm: 8 }}>
302302
<LineNumberSelector
303+
operatorId={operatorId ?? undefined}
304+
date={startDate}
303305
lineNumber={lineNumber ?? undefined}
304306
setLineNumber={(number) => setSearch((current) => ({ ...current, lineNumber: number }))}
305307
/>

src/pages/historicTimeline/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ const TimelinePage = () => {
147147
{/* choose line */}
148148
<Grid size={{ lg: 4, md: 6, xs: 12 }}>
149149
<LineNumberSelector
150+
operatorId={operatorId ?? undefined}
151+
date={date}
150152
lineNumber={lineNumber ?? undefined}
151153
setLineNumber={(number) => setSearch((prev) => ({ ...prev, lineNumber: number }))}
152154
/>

src/pages/singleLineMap/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ const SingleLineMapPage = () => {
146146
<Grid size={{ sm: 4, xs: 12 }}>
147147
<LineNumberSelector
148148
disabled={!operatorId}
149+
operatorId={operatorId ?? undefined}
150+
date={date}
149151
lineNumber={lineNumber ?? undefined}
150152
setLineNumber={handleLineNumberChange}
151153
/>

src/test_pages/TimelinePage.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ class TimelinePage extends BasePage {
4949
public get stationList() {
5050
return this.page.locator('ul#stop-select-listbox')
5151
}
52-
get closeButton() {
53-
return this.page.locator('svg.clear-indicator')
54-
}
55-
5652
get lineNumberField() {
5753
return this.page.locator("//input[@placeholder='לדוגמה: 17א']")
5854
}

tests/interlink.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const RIDE_TOKEN_URL = '00-30' // same time carried in the share URL (dash form)
2424
async function selectRoute(page: Page) {
2525
await page.getByLabel('חברה מפעילה').click()
2626
await page.getByRole('option', { name: OPERATOR, exact: true }).click()
27-
await page.getByRole('textbox', { name: 'מספר קו' }).fill(LINE)
27+
await page.getByRole('combobox', { name: 'מספר קו' }).fill(LINE)
2828
// Type-to-filter the route Autocomplete so the target option renders even when
2929
// the line has many variants (MUI virtualizes long option lists).
3030
await page.getByLabel(/בחירת מסלול נסיעה/).fill(ROUTE_FILTER)

tests/missingRides.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const FULL_DAY_TIMES = ['04:30', '17:00', '04:47'] as const
1717
async function selectGapsRoute(page: import('@playwright/test').Page) {
1818
await page.getByLabel('חברה מפעילה').click()
1919
await page.getByRole('option', { name: GAPS_OPERATOR, exact: true }).click()
20-
await page.getByRole('textbox', { name: 'מספר קו' }).fill(GAPS_LINE_NUMBER)
20+
await page.getByRole('combobox', { name: 'מספר קו' }).fill(GAPS_LINE_NUMBER)
2121
await page.getByLabel(/בחירת מסלול נסיעה/).click()
2222
await page.getByRole('option', { name: GAPS_ROUTE }).click()
2323
}

0 commit comments

Comments
 (0)