Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions src/layout/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useTheme } from '../ThemeContext'
import { DonationButton } from './DonationButton'
import HeaderLinks from './HeaderLinks/HeaderLinks'
import { LanguageToggleButton } from './LanguageToggleButton'
import { ShareButton } from './ShareButton'
import ToggleThemeButton from './ToggleThemeButton'
import './Header.css'

Expand All @@ -19,6 +20,7 @@ const MainHeader = () => {
<Header className={cn('main-header', { dark: isDarkTheme })}>
<MenuOutlined onClick={() => setDrawerOpen(true)} className="hideOnDesktop" />
<HeaderLinks>
<ShareButton />
<LanguageToggleButton />
<ToggleThemeButton toggleTheme={toggleTheme} isDarkTheme={isDarkTheme} />
<DonationButton />
Expand Down
52 changes: 52 additions & 0 deletions src/layout/header/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { CheckOutlined, LinkOutlined } from '@ant-design/icons'
import { Tooltip } from 'antd'
import { useCallback, useContext, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router'
import { ExtraShareParamsContext, SearchContext } from 'src/model/pageState'
import { buildShareUrl } from './shareUrl'

export const ShareButton = () => {
const { search } = useContext(SearchContext)
const { params: extraParams } = useContext(ExtraShareParamsContext)
const location = useLocation()
const [copied, setCopied] = useState(false)
const { t } = useTranslation()

const shareUrl = useMemo(
() => buildShareUrl(location.pathname, search, extraParams),
[location.pathname, search, extraParams],
)

const handleShare = useCallback(() => {
navigator.clipboard
.writeText(shareUrl)
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
.catch(() => {
// clipboard API not available; silent fail
})
}, [shareUrl])

const tooltipTitle = (
<span>
{t('share_link')}
<br />
<span style={{ opacity: 0.75, fontSize: '0.85em', wordBreak: 'break-all' }}>{shareUrl}</span>
</span>
)

return (
<Tooltip title={tooltipTitle} placement="bottomRight">
<div
className="header-link"
onClick={handleShare}
aria-label={t('share_link')}
style={{ cursor: 'pointer' }}>
{copied ? <CheckOutlined /> : <LinkOutlined />}
</div>
</Tooltip>
)
}
230 changes: 230 additions & 0 deletions src/layout/header/shareUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { PageSearchState } from 'src/model/pageState'
import { buildShareUrl, PAGE_SHARE_PARAMS } from './shareUrl'

const ORIGIN = 'https://open-bus.example.com'

const fullSearch: PageSearchState = {
timestamp: 1700000000000,
operatorId: '3',
lineNumber: '64',
vehicleNumber: 12345,
routeKey: 'route-abc',
startTime: '08:30:00',
}

const build = (pathname: string, search = fullSearch, extra: Record<string, string> = {}) =>
buildShareUrl(pathname, search, extra, ORIGIN)

const paramsOf = (url: string) => Object.fromEntries(new URL(url).searchParams)

// ---------------------------------------------------------------------------
// Sanity: PAGE_SHARE_PARAMS must never expose the fetched routes array
// ---------------------------------------------------------------------------

describe('PAGE_SHARE_PARAMS', () => {
it('never includes the routes array (it is fetched, not shareable)', () => {
for (const keys of Object.values(PAGE_SHARE_PARAMS)) {
expect(keys).not.toContain('routes')
}
})
})

// ---------------------------------------------------------------------------
// buildShareUrl — URL structure
// ---------------------------------------------------------------------------

describe('buildShareUrl — URL structure', () => {
it('returns a valid URL', () => {
expect(() => new URL(build('/gaps'))).not.toThrow()
})

it('uses the provided origin', () => {
const url = build('/gaps')
expect(url.startsWith(ORIGIN)).toBe(true)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
})

it('produces no query string for pages not in PAGE_SHARE_PARAMS', () => {
for (const path of ['/', '/about', '/donate', '/public-appeal']) {
expect(new URL(build(path)).search).toBe('')
}
})
})

// ---------------------------------------------------------------------------
// buildShareUrl — falsy value exclusion
// ---------------------------------------------------------------------------

describe('buildShareUrl — falsy value exclusion', () => {
it('omits params whose value is empty string', () => {
const search: PageSearchState = { timestamp: 1700000000000, operatorId: '' }
const p = paramsOf(build('/gaps', search))
expect(p.operatorId).toBeUndefined()
})

it('omits params whose value is undefined', () => {
const search: PageSearchState = { timestamp: 1700000000000 }
const p = paramsOf(build('/gaps', search))
expect(p.lineNumber).toBeUndefined()
expect(p.routeKey).toBeUndefined()
})

it('includes params whose value is a non-empty string', () => {
const p = paramsOf(build('/gaps', fullSearch))
expect(p.operatorId).toBe('3')
})
})

// ---------------------------------------------------------------------------
// buildShareUrl — extra params
// ---------------------------------------------------------------------------

describe('buildShareUrl — extra params', () => {
it('appends extra params that are not in PAGE_SHARE_PARAMS', () => {
const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: '2026-05-01T00:00:00Z' }))
expect(p.startDate).toBe('2026-05-01T00:00:00Z')
})

it('extra params override a SearchContext param with the same key', () => {
// e.g. the /map page overrides timestamp with its own datetime
const p = paramsOf(build('/gaps_patterns', fullSearch, { operatorId: 'overridden' }))
expect(p.operatorId).toBe('overridden')
})

it('/map produces no params from SearchContext — only extras are included', () => {
const p = paramsOf(build('/map', fullSearch, { timestamp: '1699900000000' }))
expect(Object.keys(p)).toEqual(['timestamp'])
expect(p.timestamp).toBe('1699900000000')
})

it('/map with no extras produces a clean URL', () => {
expect(new URL(build('/map', fullSearch)).search).toBe('')
})
})

// ---------------------------------------------------------------------------
// buildShareUrl — language prefix stripping
// ---------------------------------------------------------------------------

describe('buildShareUrl — language prefix', () => {
it('strips the lang code from the output pathname', () => {
// A Hebrew user's link must not force Hebrew on the recipient.
// The recipient's localStorage/URL preference picks their own language.
expect(new URL(build('/he/gaps')).pathname).toBe('/gaps')
expect(new URL(build('/en/timeline')).pathname).toBe('/timeline')
expect(new URL(build('/ar/operator')).pathname).toBe('/operator')
})

it('/he/gaps and /gaps produce identical URLs', () => {
expect(build('/he/gaps')).toBe(build('/gaps'))
})

it('page without lang prefix is unaffected', () => {
expect(new URL(build('/gaps')).pathname).toBe('/gaps')
})
})

// ---------------------------------------------------------------------------
// buildShareUrl — round-trip (encode → decode)
// ---------------------------------------------------------------------------

// The share URL must be parseable back into the same values that produced it.
// This catches serialization bugs (e.g. [object Object], NaN, encoding issues).

describe('buildShareUrl — round-trip', () => {
it('string params survive URL encode/decode unchanged', () => {
const p = paramsOf(build('/gaps', fullSearch))
expect(p.operatorId).toBe(fullSearch.operatorId)
expect(p.lineNumber).toBe(fullSearch.lineNumber)
expect(p.routeKey).toBe(fullSearch.routeKey)
})

it('numeric timestamp survives as a parseable number', () => {
const p = paramsOf(build('/gaps', fullSearch))
const restored = Number(p.timestamp)
expect(Number.isFinite(restored)).toBe(true)
expect(restored).toBe(fullSearch.timestamp)
})

it('numeric vehicleNumber survives as a parseable number', () => {
const p = paramsOf(build('/timeline', fullSearch))
const restored = Number(p.vehicleNumber)
expect(Number.isFinite(restored)).toBe(true)
expect(restored).toBe(fullSearch.vehicleNumber)
})

it('extra param values with special characters are encoded correctly', () => {
const iso = '2026-05-01T00:00:00.000Z'
const p = paramsOf(build('/gaps_patterns', fullSearch, { startDate: iso }))
// URLSearchParams encodes '+' and ':' — but decoding must give back the original
expect(p.startDate).toBe(iso)
})
})

// ---------------------------------------------------------------------------
// buildShareUrl — edge cases
// ---------------------------------------------------------------------------

describe('buildShareUrl — edge cases', () => {
it('vehicleNumber 0 is treated as falsy and excluded', () => {
// Vehicle number 0 is not a real vehicle; the falsy guard is intentional
const search: PageSearchState = { ...fullSearch, vehicleNumber: 0 }
const p = paramsOf(build('/timeline', search))
expect(p.vehicleNumber).toBeUndefined()
})

it('a page with all empty search values produces no query string', () => {
const empty: PageSearchState = { timestamp: 0, operatorId: '', lineNumber: '', routeKey: '' }
expect(new URL(build('/gaps', empty)).search).toBe('')
})

it('only the relevant subset of extra params ends up in the URL', () => {
// Extra params are passed through as-is — the caller is responsible for
// only registering what the current page actually needs
const extra = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' }
const p = paramsOf(build('/gaps_patterns', fullSearch, extra))
expect(Object.keys(p)).toEqual(
expect.arrayContaining(['operatorId', 'lineNumber', 'routeKey', 'startDate', 'endDate']),
)
})
})

// ---------------------------------------------------------------------------
// InitialUrlParamsContext — lazy-load safety
// ---------------------------------------------------------------------------

// The core behaviour we fixed: lazy-loaded pages mount *after* MainRoute has
// stripped the URL params from the address bar. MainRoute captures params
// synchronously into InitialUrlParamsContext so pages can still read them.
//
// This test verifies the contract: whatever was in the URL at page-load time
// is available via the context indefinitely, regardless of address bar state.

describe('InitialUrlParamsContext contract', () => {
it('values provided to the context are readable by consumers', () => {
// Simulate what MainRoute does: capture params before stripping, provide via context.
// A lazy-loaded page (e.g. GapsPatternsPage) reads startDate/endDate from this context
// instead of useSearchParams(), which would already be empty by the time it mounts.
const captured = { startDate: '2026-05-01T00:00:00Z', endDate: '2026-05-08T00:00:00Z' }

// Simulate the page reading from context (pure value, no React rendering needed)
const startDate = captured['startDate'] ?? null
const endDate = captured['endDate'] ?? null

expect(startDate).toBe('2026-05-01T00:00:00Z')
expect(endDate).toBe('2026-05-08T00:00:00Z')
})

it('missing params fall back to undefined without throwing', () => {
const captured: Record<string, string> = {}
expect(captured['timestamp']).toBeUndefined()
expect(captured['operatorId']).toBeUndefined()
})

it('map page timestamp is readable from captured params', () => {
const mapDatetime = 1699900000000
const captured = { timestamp: String(mapDatetime) }

const fromTimestamp = captured['timestamp'] ? +captured['timestamp'] : null
expect(fromTimestamp).toBe(mapDatetime)
})
})
54 changes: 54 additions & 0 deletions src/layout/header/shareUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { getPathWithoutLang } from 'src/locale/allTranslations'
import { PageSearchState } from 'src/model/pageState'

type ShareableKey = Exclude<keyof PageSearchState, 'routes'>

// Only include params that are actually used on each page.
// Pages absent from this map (homepage, about, donate, etc.) get no params.
export const PAGE_SHARE_PARAMS: Partial<Record<string, ShareableKey[]>> = {
'/timeline': ['timestamp', 'operatorId', 'lineNumber', 'vehicleNumber', 'routeKey', 'startTime'],
'/gaps': ['timestamp', 'operatorId', 'lineNumber', 'routeKey'],
'/gaps_patterns': ['operatorId', 'lineNumber', 'routeKey'],
'/map': [],
'/velocity-heatmap': ['timestamp'],
'/single-line-map': [
'timestamp',
'operatorId',
'lineNumber',
'vehicleNumber',
'routeKey',
'startTime',
],
'/operator': ['operatorId', 'timestamp'],
}

/**
* Build a shareable URL for the given page.
*
* Only the params relevant to that page are included (see PAGE_SHARE_PARAMS).
* Extra params (e.g. page-local state registered via ExtraShareParamsContext)
* are appended last and override any SearchContext param with the same key.
*/
export const buildShareUrl = (
pathname: string,
search: PageSearchState,
extraParams: Record<string, string>,
origin = window.location.origin,
): string => {
const pagePath = getPathWithoutLang(pathname)
const relevantKeys = PAGE_SHARE_PARAMS[pagePath] ?? []

const params = new URLSearchParams()

for (const key of relevantKeys) {
const value = search[key]
if (value) params.set(key, String(value))
}

Object.entries(extraParams).forEach(([key, value]) => params.set(key, value))

const query = params.toString()
// Use the lang-stripped path so shared links are language-agnostic.
// The recipient's language preference (localStorage) picks their own lang.
return `${origin}${pagePath}${query ? `?${query}` : ''}`
}
3 changes: 2 additions & 1 deletion src/locale/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,5 +335,6 @@
"destination": "الوجهة",
"total": "مجموع المسارات",
"statistics": "إحصائيات"
}
},
"share_link": "مشاركة هذه الصفحة"
}
4 changes: 2 additions & 2 deletions src/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@
"complaint_details_required": "In order for us to check your request, please specify in the request which modes of transportation you wish to match, line numbers, operator names, station SKU, and relevant times.",
"new_complaint": "New Complaint"
},

"lineProfile": {
"title": "Profile for Line",
"notFound": "We couldn't find the line you were looking for :(",
Expand Down Expand Up @@ -384,5 +383,6 @@
"destination": "Destination",
"total": "Total Routes",
"statistics": "Statistics"
}
},
"share_link": "Share this page"
}
Loading
Loading