diff --git a/e2e/costing-permalink.spec.ts b/e2e/costing-permalink.spec.ts new file mode 100644 index 00000000..b1b1b132 --- /dev/null +++ b/e2e/costing-permalink.spec.ts @@ -0,0 +1,91 @@ +import { test, expect, type Page } from '@playwright/test'; + +const BASE_URL = 'http://localhost:3000'; + +async function openSettingsPanel(page: Page) { + await page.getByTestId('tab-directions-button').click(); + await page.getByTestId('show-hide-settings-btn').click(); + // wait for the settings panel content to be visible + await expect( + page.getByRole('checkbox', { name: 'Shortest', exact: true }) + ).toBeVisible(); +} + +test.beforeEach(async ({ page }) => { + await page.goto(`${BASE_URL}/directions?profile=car`); + await openSettingsPanel(page); +}); + +test('costing options appear in URL when a setting is changed', async ({ + page, +}) => { + await page.getByRole('checkbox', { name: 'Shortest', exact: true }).click(); + + await expect(page).toHaveURL(/costing=/); + const url = new URL(page.url()); + const costing = JSON.parse( + decodeURIComponent(url.searchParams.get('costing')!) + ); + // costing is stored as a plain object in the URL (not double-encoded) + expect(costing).toHaveProperty('shortest', true); +}); + +test('costing options persist after page refresh', async ({ page }) => { + await page.getByRole('checkbox', { name: 'Shortest', exact: true }).click(); + await expect(page).toHaveURL(/costing=/); + + await page.reload(); + await openSettingsPanel(page); + + await expect( + page.getByRole('checkbox', { name: 'Shortest', exact: true }) + ).toBeChecked(); + await expect(page).toHaveURL(/costing=/); +}); + +test('costing param is removed from URL when settings are reset to defaults', async ({ + page, +}) => { + await page.getByRole('checkbox', { name: 'Shortest', exact: true }).click(); + await expect(page).toHaveURL(/costing=/); + + await page.getByRole('button', { name: 'Reset', exact: true }).last().click(); + + await expect(page).not.toHaveURL(/costing=/); + await expect( + page.getByRole('checkbox', { name: 'Shortest', exact: true }) + ).not.toBeChecked(); +}); + +test('costing param is cleared when switching profiles', async ({ page }) => { + await page.getByRole('checkbox', { name: 'Shortest', exact: true }).click(); + await expect(page).toHaveURL(/costing=/); + + await page.getByTestId('close-settings-button').click(); + await page.getByTestId('profile-button-bicycle').click(); + + await expect(page).not.toHaveURL(/costing=/); +}); + +test('page loads correctly with costing options in URL', async ({ page }) => { + // costing is a plain JSON object in the URL (TanStack Router serializes objects natively) + const costing = encodeURIComponent(JSON.stringify({ shortest: true })); + await page.goto(`${BASE_URL}/directions?profile=car&costing=${costing}`); + await openSettingsPanel(page); + + await expect( + page.getByRole('checkbox', { name: 'Shortest', exact: true }) + ).toBeChecked(); +}); + +test('page loads correctly with invalid costing param in URL', async ({ + page, +}) => { + // An invalid (non-object) costing param is ignored by the fallback; page loads normally + await page.goto(`${BASE_URL}/directions?profile=car&costing=not-valid-json`); + await openSettingsPanel(page); + + await expect( + page.getByRole('checkbox', { name: 'Shortest', exact: true }) + ).not.toBeChecked(); +}); diff --git a/src/components/route-planner.tsx b/src/components/route-planner.tsx index a492fa44..6f71a976 100644 --- a/src/components/route-planner.tsx +++ b/src/components/route-planner.tsx @@ -75,7 +75,7 @@ export const RoutePlanner = () => { const handleProfileChange = (value: Profile) => { navigate({ - search: (prev) => ({ ...prev, profile: value }), + search: (prev) => ({ ...prev, profile: value, costing: undefined }), replace: true, }); diff --git a/src/components/settings-panel/settings-panel.spec.tsx b/src/components/settings-panel/settings-panel.spec.tsx index 2a6c1c88..c6fb5e54 100644 --- a/src/components/settings-panel/settings-panel.spec.tsx +++ b/src/components/settings-panel/settings-panel.spec.tsx @@ -32,10 +32,12 @@ const mockRefetchIsochrones = vi.fn(); const mockUseParams = vi.fn(() => ({ activeTab: 'directions' })); const mockUseSearch = vi.fn(() => ({ profile: 'bicycle' })); +const mockNavigate = vi.fn(); vi.mock('@tanstack/react-router', () => ({ useParams: () => mockUseParams(), useSearch: () => mockUseSearch(), + useNavigate: () => mockNavigate, })); vi.mock('@/stores/common-store', () => ({ diff --git a/src/components/settings-panel/settings-panel.tsx b/src/components/settings-panel/settings-panel.tsx index 38968588..0af93ee4 100644 --- a/src/components/settings-panel/settings-panel.tsx +++ b/src/components/settings-panel/settings-panel.tsx @@ -35,6 +35,7 @@ import { import { useParams, useSearch } from '@tanstack/react-router'; import { useDirectionsQuery } from '@/hooks/use-directions-queries'; import { useIsochronesQuery } from '@/hooks/use-isochrones-queries'; +import { useSettingsUrlSync } from '@/hooks/use-settings-url-sync'; import { CollapsibleSection } from '@/components/ui/collapsible-section'; import { ServerSettings } from '@/components/settings-panel/server-settings'; import { MultiSelectSetting } from '../ui/multiselect-setting'; @@ -53,6 +54,8 @@ export const SettingsPanel = () => { const { refetch: refetchDirections } = useDirectionsQuery(); const { refetch: refetchIsochrones } = useIsochronesQuery(); + useSettingsUrlSync(); + const [language, setLanguage] = useState(() => getDirectionsLanguage() ); diff --git a/src/hooks/use-settings-url-sync.ts b/src/hooks/use-settings-url-sync.ts new file mode 100644 index 00000000..efc37510 --- /dev/null +++ b/src/hooks/use-settings-url-sync.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { useCommonStore, type Profile } from '@/stores/common-store'; +import type { PossibleSettings } from '@/components/types'; +import { + serializeCostingOptions, + deserializeCostingOptions, +} from '@/utils/costing-url'; + +// flow- +// on mount-use the existing params and apply to settings +// on change in setting we keep the url in sync +export function useSettingsUrlSync() { + const settings = useCommonStore((state) => state.settings); + const updateSettings = useCommonStore((state) => state.updateSettings); + const { profile, costing: urlCosting } = useSearch({ from: '/$activeTab' }); + const navigate = useNavigate({ from: '/$activeTab' }); + + // for the initial costing value from the URL before any effects run + const initialCostingRef = useRef(urlCosting); + const initializedRef = useRef(false); + + useEffect(() => { + const costingOptions = deserializeCostingOptions(initialCostingRef.current); + for (const [key, value] of Object.entries(costingOptions)) { + updateSettings( + key as keyof PossibleSettings, + value as PossibleSettings[keyof PossibleSettings] + ); + } + initializedRef.current = true; + }, []); + + useEffect(() => { + if (!initializedRef.current) return; + + const costing = serializeCostingOptions( + settings, + (profile || 'bicycle') as Profile + ); + navigate({ + search: (prev) => ({ ...prev, costing }), + replace: true, + }); + }, [settings, profile, navigate]); +} diff --git a/src/routes.tsx b/src/routes.tsx index 40b2c653..547b95d5 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -40,7 +40,7 @@ const activeTabRoute = createRoute({ component: App, validateSearch: zodValidator(searchParamsSchema), search: { - middlewares: [retainSearchParams(['profile', 'style'])], + middlewares: [retainSearchParams(['profile', 'style', 'costing'])], }, beforeLoad: ({ params, search }) => { if (!isValidTab(params.activeTab)) { diff --git a/src/utils/costing-url.ts b/src/utils/costing-url.ts new file mode 100644 index 00000000..40323829 --- /dev/null +++ b/src/utils/costing-url.ts @@ -0,0 +1,47 @@ +import { + settingsInit, + settingsInitTruckOverride, +} from '@/components/settings-panel/settings-options'; +import type { PossibleSettings } from '@/components/types'; +import type { Profile } from '@/stores/common-store'; + +// Keys too large to serialize in the URL +const EXCLUDED_KEYS = new Set(['exclude_polygons']); + +function getDefaultSettings(profile: Profile): typeof settingsInit { + return profile === 'truck' ? settingsInitTruckOverride : settingsInit; +} + +// syncs only the non-default costing options to an object for use in the url +// returns undefined if all settings are at their defaults. +export function serializeCostingOptions( + settings: PossibleSettings, + profile: Profile +): Record | undefined { + const defaults = getDefaultSettings(profile) as PossibleSettings; + const nonDefault: Record = {}; + + for (const key of Object.keys(settings) as (keyof PossibleSettings)[]) { + if (EXCLUDED_KEYS.has(key)) continue; + + const value = settings[key]; + const defaultValue = defaults[key]; + + if (JSON.stringify(value) !== JSON.stringify(defaultValue)) { + nonDefault[key] = value; + } + } + + if (Object.keys(nonDefault).length === 0) return undefined; + return nonDefault; +} + +// if the costing param is not a plain object, return empty obj +export function deserializeCostingOptions( + costing: Record | undefined +): Partial { + if (!costing || typeof costing !== 'object' || Array.isArray(costing)) { + return {}; + } + return costing as Partial; +} diff --git a/src/utils/route-schemas.ts b/src/utils/route-schemas.ts index dd26dede..e3b1b00f 100644 --- a/src/utils/route-schemas.ts +++ b/src/utils/route-schemas.ts @@ -11,6 +11,7 @@ export const searchParamsSchema = z.object({ generalize: z.number().optional(), denoise: z.number().optional(), style: mapStyleSchema.optional(), + costing: fallback(z.record(z.unknown()).optional(), undefined), }); export type SearchParamsSchema = z.infer;