Skip to content

Commit 264d40b

Browse files
feat: Add copy link button and remove all params from url bar (#1572)
Co-authored-by: arielvino 71963953+arielvino@users.noreply.github.com Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 6bc50f5 commit 264d40b

18 files changed

Lines changed: 882 additions & 105 deletions

File tree

src/layout/header/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useTheme } from '../ThemeContext'
77
import { DonationButton } from './DonationButton'
88
import HeaderLinks from './HeaderLinks/HeaderLinks'
99
import { LanguageToggleButton } from './LanguageToggleButton'
10+
import { ShareButton } from './ShareButton'
1011
import ToggleThemeButton from './ToggleThemeButton'
1112
import './Header.css'
1213

@@ -19,6 +20,7 @@ const MainHeader = () => {
1920
<Header className={cn('main-header', { dark: isDarkTheme })}>
2021
<MenuOutlined onClick={() => setDrawerOpen(true)} className="hideOnDesktop" />
2122
<HeaderLinks>
23+
<ShareButton />
2224
<LanguageToggleButton />
2325
<ToggleThemeButton toggleTheme={toggleTheme} isDarkTheme={isDarkTheme} />
2426
<DonationButton />

src/layout/header/ShareButton.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { CheckOutlined, LinkOutlined } from '@ant-design/icons'
2+
import { Tooltip } from 'antd'
3+
import { useCallback, useContext, useMemo, useState } from 'react'
4+
import { useTranslation } from 'react-i18next'
5+
import { useLocation } from 'react-router'
6+
import { ExtraShareParamsContext, SearchContext } from 'src/model/pageState'
7+
import { buildShareUrl } from './shareUrl'
8+
9+
export const ShareButton = () => {
10+
const { search } = useContext(SearchContext)
11+
const { params: extraParams } = useContext(ExtraShareParamsContext)
12+
const location = useLocation()
13+
const [copied, setCopied] = useState(false)
14+
const { t } = useTranslation()
15+
16+
const shareUrl = useMemo(
17+
() => buildShareUrl(location.pathname, search, extraParams),
18+
[location.pathname, search, extraParams],
19+
)
20+
21+
const handleShare = useCallback(() => {
22+
navigator.clipboard
23+
.writeText(shareUrl)
24+
.then(() => {
25+
setCopied(true)
26+
setTimeout(() => setCopied(false), 2000)
27+
})
28+
.catch(() => {
29+
// clipboard API not available; silent fail
30+
})
31+
}, [shareUrl])
32+
33+
const tooltipTitle = copied ? (
34+
<span>{t('link_copied')}</span>
35+
) : (
36+
<span>
37+
{t('share_link')}
38+
<br />
39+
<span style={{ opacity: 0.75, fontSize: '0.85em', wordBreak: 'break-all' }}>{shareUrl}</span>
40+
</span>
41+
)
42+
43+
return (
44+
<Tooltip title={tooltipTitle} open={copied || undefined} placement="bottomRight">
45+
<div
46+
className="header-link"
47+
onClick={handleShare}
48+
aria-label={copied ? t('link_copied') : t('share_link')}
49+
style={{ cursor: 'pointer' }}>
50+
{copied ? <CheckOutlined /> : <LinkOutlined />}
51+
</div>
52+
</Tooltip>
53+
)
54+
}

src/layout/header/shareUrl.test.ts

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { PageSearchState } from 'src/model/pageState'
2+
import { buildShareUrl, PAGE_SHARE_PARAMS } from './shareUrl'
3+
import type { ShareableKey } from './shareUrl'
4+
5+
const ORIGIN = 'https://open-bus.example.com'
6+
7+
const fullSearch: PageSearchState = {
8+
timestamp: 1700000000000,
9+
operatorId: '3',
10+
lineNumber: '64',
11+
vehicleNumber: 12345,
12+
routeKey: 'route-abc',
13+
startTime: '08:30:00',
14+
}
15+
16+
const build = (pathname: string, search = fullSearch, extra: Record<string, string> = {}) =>
17+
buildShareUrl(pathname, search, extra, ORIGIN)
18+
19+
const paramsOf = (url: string) => Object.fromEntries(new URL(url).searchParams)
20+
21+
// ---------------------------------------------------------------------------
22+
// Sanity: PAGE_SHARE_PARAMS must never expose the fetched routes array
23+
// ---------------------------------------------------------------------------
24+
25+
describe('PAGE_SHARE_PARAMS', () => {
26+
it('never includes the routes array (it is fetched, not shareable)', () => {
27+
for (const keys of Object.values(PAGE_SHARE_PARAMS)) {
28+
expect(keys).not.toContain('routes')
29+
}
30+
})
31+
})
32+
33+
// ---------------------------------------------------------------------------
34+
// buildShareUrl — URL structure
35+
// ---------------------------------------------------------------------------
36+
37+
describe('buildShareUrl — URL structure', () => {
38+
it('returns a valid URL', () => {
39+
expect(() => new URL(build('/gaps'))).not.toThrow()
40+
})
41+
42+
it('uses the provided origin', () => {
43+
const url = build('/gaps')
44+
expect(new URL(url).origin).toBe(ORIGIN)
45+
})
46+
47+
it('produces no query string for pages not in PAGE_SHARE_PARAMS', () => {
48+
for (const path of ['/', '/about', '/donate', '/public-appeal']) {
49+
expect(new URL(build(path)).search).toBe('')
50+
}
51+
})
52+
})
53+
54+
// ---------------------------------------------------------------------------
55+
// buildShareUrl — falsy value exclusion
56+
// ---------------------------------------------------------------------------
57+
58+
describe('buildShareUrl — falsy value exclusion', () => {
59+
it('omits params whose value is empty string', () => {
60+
const search: PageSearchState = { timestamp: 1700000000000, operatorId: '' }
61+
const p = paramsOf(build('/gaps', search))
62+
expect(p.operatorId).toBeUndefined()
63+
})
64+
65+
it('omits params whose value is undefined', () => {
66+
const search: PageSearchState = { timestamp: 1700000000000 }
67+
const p = paramsOf(build('/gaps', search))
68+
expect(p.lineNumber).toBeUndefined()
69+
expect(p.routeKey).toBeUndefined()
70+
})
71+
72+
it('includes params whose value is a non-empty string', () => {
73+
const p = paramsOf(build('/gaps', fullSearch))
74+
expect(p.operatorId).toBe('3')
75+
})
76+
})
77+
78+
// ---------------------------------------------------------------------------
79+
// buildShareUrl — extra params
80+
// ---------------------------------------------------------------------------
81+
82+
describe('buildShareUrl — extra params', () => {
83+
it('appends extra params that are not in PAGE_SHARE_PARAMS', () => {
84+
const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: '2026-05-01T00:00:00Z' }))
85+
expect(p.startDate).toBe('2026-05-01T00:00:00Z')
86+
})
87+
88+
it('extra params override a SearchContext param with the same key', () => {
89+
// e.g. the /map page overrides timestamp with its own datetime
90+
const p = paramsOf(build('/gaps_patterns', fullSearch, { operatorId: 'overridden' }))
91+
expect(p.operatorId).toBe('overridden')
92+
})
93+
94+
it('/map produces no params from SearchContext — only extras are included', () => {
95+
const p = paramsOf(build('/map', fullSearch, { timestamp: '1699900000000' }))
96+
expect(Object.keys(p)).toEqual(['timestamp'])
97+
expect(p.timestamp).toBe('1699900000000')
98+
})
99+
100+
it('/map with no extras produces a clean URL', () => {
101+
expect(new URL(build('/map', fullSearch)).search).toBe('')
102+
})
103+
})
104+
105+
// ---------------------------------------------------------------------------
106+
// buildShareUrl — language prefix stripping
107+
// ---------------------------------------------------------------------------
108+
109+
describe('buildShareUrl — language prefix', () => {
110+
it('strips the lang code from the output pathname', () => {
111+
// A Hebrew user's link must not force Hebrew on the recipient.
112+
// The recipient's localStorage/URL preference picks their own language.
113+
expect(new URL(build('/he/gaps')).pathname).toBe('/gaps')
114+
expect(new URL(build('/en/timeline')).pathname).toBe('/timeline')
115+
expect(new URL(build('/ar/operator')).pathname).toBe('/operator')
116+
})
117+
118+
it('/he/gaps and /gaps produce identical URLs', () => {
119+
expect(build('/he/gaps')).toBe(build('/gaps'))
120+
})
121+
122+
it('page without lang prefix is unaffected', () => {
123+
expect(new URL(build('/gaps')).pathname).toBe('/gaps')
124+
})
125+
})
126+
127+
// ---------------------------------------------------------------------------
128+
// buildShareUrl — round-trip (encode → decode)
129+
// ---------------------------------------------------------------------------
130+
131+
// The share URL must be parseable back into the same values that produced it.
132+
// This catches serialization bugs (e.g. [object Object], NaN, encoding issues).
133+
134+
describe('buildShareUrl — round-trip', () => {
135+
it('string params survive URL encode/decode unchanged', () => {
136+
const p = paramsOf(build('/gaps', fullSearch))
137+
expect(p.operatorId).toBe(fullSearch.operatorId)
138+
expect(p.lineNumber).toBe(fullSearch.lineNumber)
139+
expect(p.routeKey).toBe(fullSearch.routeKey)
140+
})
141+
142+
it('numeric timestamp survives as a parseable number', () => {
143+
const p = paramsOf(build('/gaps', fullSearch))
144+
const restored = Number(p.timestamp)
145+
expect(Number.isFinite(restored)).toBe(true)
146+
expect(restored).toBe(fullSearch.timestamp)
147+
})
148+
149+
it('numeric vehicleNumber survives as a parseable number', () => {
150+
const p = paramsOf(build('/timeline', fullSearch))
151+
const restored = Number(p.vehicleNumber)
152+
expect(Number.isFinite(restored)).toBe(true)
153+
expect(restored).toBe(fullSearch.vehicleNumber)
154+
})
155+
156+
it('extra param values with special characters are encoded correctly', () => {
157+
const iso = '2026-05-01T00:00:00.000Z'
158+
const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: iso }))
159+
// URLSearchParams encodes '+' and ':' — but decoding must give back the original
160+
expect(p.startDate).toBe(iso)
161+
})
162+
})
163+
164+
// ---------------------------------------------------------------------------
165+
// buildShareUrl — edge cases
166+
// ---------------------------------------------------------------------------
167+
168+
describe('buildShareUrl — edge cases', () => {
169+
it('vehicleNumber 0 is treated as falsy and excluded', () => {
170+
// Vehicle number 0 is not a real vehicle; the falsy guard is intentional
171+
const search: PageSearchState = { ...fullSearch, vehicleNumber: 0 }
172+
const p = paramsOf(build('/timeline', search))
173+
expect(p.vehicleNumber).toBeUndefined()
174+
})
175+
176+
it('a page with all empty search values produces no query string', () => {
177+
const empty: PageSearchState = { timestamp: 0, operatorId: '', lineNumber: '', routeKey: '' }
178+
expect(new URL(build('/gaps', empty)).search).toBe('')
179+
})
180+
181+
it('only the relevant subset of extra params ends up in the URL', () => {
182+
// Extra params are passed through as-is — the caller is responsible for
183+
// only registering what the current page actually needs
184+
const extra = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' }
185+
const p = paramsOf(build('/gaps_patterns', fullSearch, extra))
186+
expect(Object.keys(p)).toEqual(
187+
expect.arrayContaining(['operatorId', 'lineNumber', 'routeKey', 'startDate', 'endDate']),
188+
)
189+
})
190+
})
191+
192+
// ---------------------------------------------------------------------------
193+
// buildShareUrl — per-page param contracts
194+
// ---------------------------------------------------------------------------
195+
196+
// Each entry in PAGE_SHARE_PARAMS is a contract: the URL for that page must
197+
// contain exactly the declared keys (when values are present) and nothing more.
198+
199+
describe('buildShareUrl — per-page param contracts', () => {
200+
for (const [path, keys] of Object.entries(PAGE_SHARE_PARAMS) as [string, ShareableKey[]][]) {
201+
describe(path, () => {
202+
it('includes all declared params that have a value', () => {
203+
const p = paramsOf(build(path, fullSearch))
204+
for (const key of keys) {
205+
if (fullSearch[key as keyof PageSearchState]) {
206+
expect(p[key]).toBeDefined()
207+
}
208+
}
209+
})
210+
211+
it('does not include params from other pages', () => {
212+
const p = paramsOf(build(path, fullSearch))
213+
for (const k of Object.keys(p)) {
214+
expect(keys).toContain(k)
215+
}
216+
})
217+
})
218+
}
219+
})
220+
221+
// ---------------------------------------------------------------------------
222+
// buildShareUrl — dynamic profile path
223+
// ---------------------------------------------------------------------------
224+
225+
// /profile/:id is not in PAGE_SHARE_PARAMS. The route ID is already in the
226+
// path, so SearchContext params must not leak into the URL — only explicit
227+
// extra params (e.g. startTime) registered via ExtraShareParamsContext appear.
228+
229+
describe('buildShareUrl — dynamic profile path', () => {
230+
it('no SearchContext params leak into the URL', () => {
231+
const p = paramsOf(build('/profile/12345', fullSearch))
232+
expect(p.operatorId).toBeUndefined()
233+
expect(p.lineNumber).toBeUndefined()
234+
expect(p.timestamp).toBeUndefined()
235+
expect(p.routeKey).toBeUndefined()
236+
expect(p.startTime).toBeUndefined()
237+
})
238+
239+
it('extra params (startTime) are included', () => {
240+
const p = paramsOf(build('/profile/12345', fullSearch, { startTime: '08:30:00' }))
241+
expect(p.startTime).toBe('08:30:00')
242+
})
243+
244+
it('profile id is preserved in the pathname', () => {
245+
const url = new URL(build('/profile/12345', fullSearch, { startTime: '08:30:00' }))
246+
expect(url.pathname).toBe('/profile/12345')
247+
})
248+
249+
it('different profile ids produce different URLs', () => {
250+
const url1 = build('/profile/111', fullSearch, { startTime: '08:00:00' })
251+
const url2 = build('/profile/222', fullSearch, { startTime: '08:00:00' })
252+
expect(new URL(url1).pathname).not.toBe(new URL(url2).pathname)
253+
})
254+
})
255+
256+
// ---------------------------------------------------------------------------
257+
// InitialUrlParamsContext — lazy-load safety
258+
// ---------------------------------------------------------------------------
259+
260+
// The core behaviour we fixed: lazy-loaded pages mount *after* MainRoute has
261+
// stripped the URL params from the address bar. MainRoute captures params
262+
// synchronously into InitialUrlParamsContext so pages can still read them.
263+
//
264+
// This test verifies the contract: whatever was in the URL at page-load time
265+
// is available via the context indefinitely, regardless of address bar state.
266+
267+
describe('InitialUrlParamsContext contract', () => {
268+
it('values provided to the context are readable by consumers', () => {
269+
// Simulate what MainRoute does: capture params before stripping, provide via context.
270+
// A lazy-loaded page (e.g. GapsPatternsPage) reads startDate/endDate from this context
271+
// instead of useSearchParams(), which would already be empty by the time it mounts.
272+
const captured = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' }
273+
274+
// Simulate the page reading from context (pure value, no React rendering needed)
275+
const startDate = captured['startDate'] ?? null
276+
const endDate = captured['endDate'] ?? null
277+
278+
expect(startDate).toBe('2026-05-01T00:00:00Z')
279+
expect(endDate).toBe('2026-05-08T00:00:00Z')
280+
})
281+
282+
it('missing params fall back to undefined without throwing', () => {
283+
const captured: Record<string, string> = {}
284+
expect(captured['timestamp']).toBeUndefined()
285+
expect(captured['operatorId']).toBeUndefined()
286+
})
287+
288+
it('map page timestamp is readable from captured params', () => {
289+
const mapDatetime = 1699900000000
290+
const captured = { timestamp: String(mapDatetime) }
291+
292+
const fromTimestamp = captured['timestamp'] ? +captured['timestamp'] : null
293+
expect(fromTimestamp).toBe(mapDatetime)
294+
})
295+
})

0 commit comments

Comments
 (0)