Skip to content
Open
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
91 changes: 91 additions & 0 deletions e2e/costing-permalink.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
2 changes: 1 addition & 1 deletion src/components/route-planner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 2 additions & 0 deletions src/components/settings-panel/settings-panel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
3 changes: 3 additions & 0 deletions src/components/settings-panel/settings-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -53,6 +54,8 @@ export const SettingsPanel = () => {
const { refetch: refetchDirections } = useDirectionsQuery();
const { refetch: refetchIsochrones } = useIsochronesQuery();

useSettingsUrlSync();

const [language, setLanguage] = useState<DirectionsLanguage>(() =>
getDirectionsLanguage()
);
Expand Down
46 changes: 46 additions & 0 deletions src/hooks/use-settings-url-sync.ts
Original file line number Diff line number Diff line change
@@ -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;
}, []);

Check warning on line 32 in src/hooks/use-settings-url-sync.ts

View workflow job for this annotation

GitHub Actions / test

React Hook useEffect has a missing dependency: 'updateSettings'. Either include it or remove the dependency array

useEffect(() => {
if (!initializedRef.current) return;

const costing = serializeCostingOptions(
settings,
(profile || 'bicycle') as Profile
);
navigate({
search: (prev) => ({ ...prev, costing }),
replace: true,
});
}, [settings, profile, navigate]);
}
2 changes: 1 addition & 1 deletion src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
47 changes: 47 additions & 0 deletions src/utils/costing-url.ts
Original file line number Diff line number Diff line change
@@ -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<keyof PossibleSettings>(['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<string, unknown> | undefined {
const defaults = getDefaultSettings(profile) as PossibleSettings;
const nonDefault: Record<string, unknown> = {};

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<string, unknown> | undefined
): Partial<PossibleSettings> {
if (!costing || typeof costing !== 'object' || Array.isArray(costing)) {
return {};
}
return costing as Partial<PossibleSettings>;
}
1 change: 1 addition & 0 deletions src/utils/route-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof searchParamsSchema>;
Expand Down
Loading