diff --git a/package-lock.json b/package-lock.json index 30ba43cf..7e0ceba0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "valhalla-web", "version": "1.0.0", + "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/src/components/map/constants.ts b/src/components/map/constants.ts index 37931085..3d37da9b 100644 --- a/src/components/map/constants.ts +++ b/src/components/map/constants.ts @@ -1,5 +1,3 @@ -import { VALHALLA_OSM_URL } from '@/utils/valhalla'; - const centerCoords = import.meta.env.VITE_CENTER_COORDS?.split(',') || []; export const DEFAULT_CENTER: [number, number] = [ @@ -13,11 +11,8 @@ export const maxBounds: [[number, number], [number, number]] | undefined = undefined; export const routeObjects = { - [VALHALLA_OSM_URL!]: { - color: '#0066ff', - alternativeColor: '#66a3ff', - name: 'OSM', - }, + color: '#0066ff', + alternativeColor: '#66a3ff', }; export const MAP_STYLE_STORAGE_KEY = 'selectedMapStyle'; diff --git a/src/components/map/index.tsx b/src/components/map/index.tsx index 99897c25..9c217e5e 100644 --- a/src/components/map/index.tsx +++ b/src/components/map/index.tsx @@ -16,7 +16,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'; import axios from 'axios'; import { throttle } from 'throttle-debounce'; import { - VALHALLA_OSM_URL, + getValhallaUrl, buildHeightRequest, buildLocateRequest, } from '@/utils/valhalla'; @@ -262,7 +262,7 @@ export const MapComponent = () => { const getHeight = useCallback((lng: number, lat: number) => { setIsHeightLoading(true); axios - .post(VALHALLA_OSM_URL + '/height', buildHeightRequest([[lat, lng]]), { + .post(getValhallaUrl() + '/height', buildHeightRequest([[lat, lng]]), { headers: { 'Content-Type': 'application/json', }, @@ -285,7 +285,7 @@ export const MapComponent = () => { setIsLocateLoading(true); axios .post( - VALHALLA_OSM_URL + '/locate', + getValhallaUrl() + '/locate', buildLocateRequest({ lng, lat }, profile || 'bicycle'), { headers: { @@ -336,7 +336,7 @@ export const MapComponent = () => { setIsHeightLoading(true); setHeightPayload(heightPayloadNew); axios - .post(VALHALLA_OSM_URL + '/height', heightPayloadNew, { + .post(getValhallaUrl() + '/height', heightPayloadNew, { headers: { 'Content-Type': 'application/json', }, diff --git a/src/components/map/parts/route-lines.spec.tsx b/src/components/map/parts/route-lines.spec.tsx index 7adeb159..22a5a85d 100644 --- a/src/components/map/parts/route-lines.spec.tsx +++ b/src/components/map/parts/route-lines.spec.tsx @@ -23,19 +23,6 @@ vi.mock('@/stores/directions-store', () => ({ mockUseDirectionsStore(selector), })); -vi.mock('@/utils/valhalla', () => ({ - VALHALLA_OSM_URL: 'https://valhalla.example.com', -})); - -vi.mock('../constants', () => ({ - routeObjects: { - 'https://valhalla.example.com': { - color: '#0066ff', - alternativeColor: '#999999', - }, - }, -})); - const createMockState = (overrides = {}) => ({ results: { data: { diff --git a/src/components/map/parts/route-lines.tsx b/src/components/map/parts/route-lines.tsx index 1b74c1a9..2841187e 100644 --- a/src/components/map/parts/route-lines.tsx +++ b/src/components/map/parts/route-lines.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { Source, Layer } from 'react-map-gl/maplibre'; import { useDirectionsStore } from '@/stores/directions-store'; -import { VALHALLA_OSM_URL } from '@/utils/valhalla'; import { routeObjects } from '../constants'; import type { Feature, FeatureCollection, LineString } from 'geojson'; import type { ParsedDirectionsGeometry } from '@/components/types'; @@ -34,7 +33,7 @@ export function RouteLines() { coordinates: coords.map((c) => [c[1] ?? 0, c[0] ?? 0]), }, properties: { - color: routeObjects[VALHALLA_OSM_URL!]!.alternativeColor, + color: routeObjects.alternativeColor, type: 'alternate', summary, }, @@ -53,7 +52,7 @@ export function RouteLines() { coordinates: coords.map((c) => [c[1] ?? 0, c[0] ?? 0]), }, properties: { - color: routeObjects[VALHALLA_OSM_URL!]!.color, + color: routeObjects.color, type: 'main', summary, }, diff --git a/src/components/route-planner.tsx b/src/components/route-planner.tsx index c7dcffb1..d9f9fddf 100644 --- a/src/components/route-planner.tsx +++ b/src/components/route-planner.tsx @@ -3,7 +3,7 @@ import { format } from 'date-fns'; import { DirectionsControl } from './directions/directions'; import { IsochronesControl } from './isochrones/isochrones'; import { useCommonStore } from '@/stores/common-store'; -import { VALHALLA_OSM_URL } from '@/utils/valhalla'; +import { getValhallaUrl } from '@/utils/valhalla'; import { Sheet, SheetContent, @@ -40,7 +40,7 @@ export const RoutePlanner = () => { } = useQuery({ queryKey: ['lastUpdate'], queryFn: async () => { - const response = await fetch(`${VALHALLA_OSM_URL}/status`); + const response = await fetch(`${getValhallaUrl()}/status`); const data = await response.json(); return new Date(data.tileset_last_modified * 1000); }, diff --git a/src/components/settings-panel/server-settings.tsx b/src/components/settings-panel/server-settings.tsx new file mode 100644 index 00000000..a342951b --- /dev/null +++ b/src/components/settings-panel/server-settings.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + getBaseUrl, + setBaseUrl, + getDefaultBaseUrl, + validateBaseUrl, + normalizeBaseUrl, + testConnection, +} from '@/utils/base-url'; +import { CollapsibleSection } from '@/components/ui/collapsible-section'; +import { Server, RotateCcw, Loader2 } from 'lucide-react'; +import { + Field, + FieldLabel, + FieldDescription, + FieldError, +} from '@/components/ui/field'; + +export const ServerSettings = () => { + const [baseUrl, setBaseUrlState] = useState(() => getBaseUrl()); + const [isOpen, setIsOpen] = useState(false); + + const connectionMutation = useMutation({ + mutationFn: testConnection, + onSuccess: (result, url) => { + if (result.reachable) { + const normalizedUrl = normalizeBaseUrl(url); + setBaseUrl(normalizedUrl); + setBaseUrlState(normalizedUrl); + } + }, + }); + + const getErrorMessage = (): string | null => { + if (connectionMutation.error) { + return connectionMutation.error.message || 'Connection failed'; + } + if (connectionMutation.data && !connectionMutation.data.reachable) { + return connectionMutation.data.error || 'Server unreachable'; + } + return null; + }; + + const errorMessage = getErrorMessage(); + + const handleBaseUrlChange = (e: React.ChangeEvent) => { + setBaseUrlState(e.target.value); + connectionMutation.reset(); + }; + + const handleBaseUrlBlur = () => { + const currentStoredUrl = getBaseUrl(); + const trimmedUrl = baseUrl.trim(); + + if (trimmedUrl === currentStoredUrl) { + return; + } + + const lastTestedUrl = connectionMutation.variables; + if (lastTestedUrl === trimmedUrl && errorMessage) { + return; + } + + if (trimmedUrl === '' || trimmedUrl === getDefaultBaseUrl()) { + setBaseUrl(trimmedUrl); + setBaseUrlState(trimmedUrl || getDefaultBaseUrl()); + connectionMutation.reset(); + return; + } + + const validation = validateBaseUrl(trimmedUrl); + if (!validation.valid) { + connectionMutation.mutate(trimmedUrl); + return; + } + + connectionMutation.mutate(trimmedUrl); + }; + + const handleResetBaseUrl = () => { + const defaultUrl = getDefaultBaseUrl(); + setBaseUrlState(defaultUrl); + setBaseUrl(defaultUrl); + connectionMutation.reset(); + }; + + return ( + +
+ + Base URL + + The Valhalla server URL for routing and isochrone requests + +
+
+ + {connectionMutation.isPending && ( +
+ +
+ )} +
+
+ {errorMessage} +
+ +
+
+ ); +}; diff --git a/src/components/settings-panel/settings-panel.spec.tsx b/src/components/settings-panel/settings-panel.spec.tsx index 636867b3..73d21753 100644 --- a/src/components/settings-panel/settings-panel.spec.tsx +++ b/src/components/settings-panel/settings-panel.spec.tsx @@ -1,9 +1,29 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SettingsPanel } from './settings-panel'; import { DIRECTIONS_LANGUAGE_STORAGE_KEY } from './settings-options'; +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + +const renderWithQueryClient = (ui: React.ReactElement) => { + const testQueryClient = createTestQueryClient(); + return render( + {ui} + ); +}; + const mockUpdateSettings = vi.fn(); const mockResetSettings = vi.fn(); const mockToggleSettings = vi.fn(); @@ -63,6 +83,8 @@ vi.mock('@/hooks/use-isochrones-queries', () => ({ })), })); +const BASE_URL_STORAGE_KEY = 'valhalla_base_url'; + describe('SettingsPanel', () => { const originalNavigator = global.navigator; @@ -81,22 +103,22 @@ describe('SettingsPanel', () => { }); it('should render without crashing', () => { - expect(() => render()).not.toThrow(); + expect(() => renderWithQueryClient()).not.toThrow(); }); it('should render the settings title', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Settings')).toBeInTheDocument(); }); it('should render close button', () => { - render(); + renderWithQueryClient(); expect(screen.getByTestId('close-settings-button')).toBeInTheDocument(); }); it('should call toggleSettings when close button is clicked', async () => { const user = userEvent.setup(); - render(); + renderWithQueryClient(); await user.click(screen.getByTestId('close-settings-button')); @@ -104,78 +126,80 @@ describe('SettingsPanel', () => { }); it('should render Profile Settings section for bicycle profile', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Profile Settings')).toBeInTheDocument(); }); it('should render General Settings section', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('General Settings')).toBeInTheDocument(); }); it('should display current profile name', () => { - render(); - expect(screen.getByText('bicycle')).toBeInTheDocument(); + renderWithQueryClient(); + expect(screen.getByText('(bicycle)')).toBeInTheDocument(); }); it('should render Copy to Clipboard button', () => { - render(); + renderWithQueryClient(); expect( screen.getByRole('button', { name: /Copy to Clipboard/i }) ).toBeInTheDocument(); }); it('should render Reset button', () => { - render(); - expect(screen.getByRole('button', { name: /Reset/i })).toBeInTheDocument(); + renderWithQueryClient(); + expect( + screen.getByRole('button', { name: /^Reset$/i }) + ).toBeInTheDocument(); }); it('should render bicycle profile settings like Cycling Speed', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Cycling Speed')).toBeInTheDocument(); }); it('should render Shortest checkbox for bicycle profile', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Shortest')).toBeInTheDocument(); }); it('should render Bicycle Type select for bicycle profile', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Bicycle Type')).toBeInTheDocument(); }); it('should render general settings like Use Ferries', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Use Ferries')).toBeInTheDocument(); }); it('should render Alternates setting from all general settings', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Alternates')).toBeInTheDocument(); }); it('should call resetSettings with current profile when Reset is clicked', async () => { const user = userEvent.setup(); - render(); + renderWithQueryClient(); - await user.click(screen.getByRole('button', { name: /Reset/i })); + await user.click(screen.getByRole('button', { name: /^Reset$/i })); expect(mockResetSettings).toHaveBeenCalledWith('bicycle'); }); it('should call refetchDirections after reset', async () => { const user = userEvent.setup(); - render(); + renderWithQueryClient(); - await user.click(screen.getByRole('button', { name: /Reset/i })); + await user.click(screen.getByRole('button', { name: /^Reset$/i })); expect(mockRefetchDirections).toHaveBeenCalled(); }); it('should show Copied! feedback after clicking Copy to Clipboard', async () => { const user = userEvent.setup(); - render(); + renderWithQueryClient(); await user.click( screen.getByRole('button', { name: /Copy to Clipboard/i }) @@ -188,7 +212,7 @@ describe('SettingsPanel', () => { it('should toggle shortest checkbox and trigger refetch', async () => { const user = userEvent.setup(); - render(); + renderWithQueryClient(); const shortestCheckbox = screen.getByRole('checkbox', { name: /Shortest/i, @@ -200,7 +224,7 @@ describe('SettingsPanel', () => { }); it('should render all expected profile numeric settings for bicycle', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Cycling Speed')).toBeInTheDocument(); expect(screen.getByText('Use Roads')).toBeInTheDocument(); @@ -209,46 +233,307 @@ describe('SettingsPanel', () => { }); it('should render all expected general numeric settings for bicycle', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Use Ferries')).toBeInTheDocument(); expect(screen.getByText('Use Living Streets')).toBeInTheDocument(); expect(screen.getByText('Turn Penalty')).toBeInTheDocument(); }); + describe('Server Settings', () => { + it('should render Server Settings section', () => { + renderWithQueryClient(); + expect(screen.getByText('Server Settings')).toBeInTheDocument(); + }); + + it('should render Base URL label when expanded', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + expect(screen.getByText('Base URL')).toBeInTheDocument(); + }); + + it('should render base URL input when expanded', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + expect( + screen.getByRole('textbox', { name: /Base URL/i }) + ).toBeInTheDocument(); + }); + + it('should render Reset Base URL button when expanded', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + expect( + screen.getByRole('button', { name: /Reset Base URL/i }) + ).toBeInTheDocument(); + }); + + it('should display stored base URL from localStorage', async () => { + const user = userEvent.setup(); + const customUrl = 'https://custom.valhalla.com'; + localStorage.setItem(BASE_URL_STORAGE_KEY, customUrl); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + expect(input).toHaveValue(customUrl); + }); + + it('should update input value when typing', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'https://new.valhalla.com'); + + expect(input).toHaveValue('https://new.valhalla.com'); + }); + + it('should not save to localStorage while typing', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'https://test.com'); + + expect(localStorage.getItem(BASE_URL_STORAGE_KEY)).toBeNull(); + }); + + it('should show error for invalid URL format on blur', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'not-a-valid-url'); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText('Invalid URL format')).toBeInTheDocument(); + }); + }); + + it('should show error for non-http protocol on blur', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'ftp://example.com'); + await user.tab(); + + await waitFor(() => { + expect( + screen.getByText('URL must use HTTP or HTTPS protocol') + ).toBeInTheDocument(); + }); + }); + + it('should clear error when typing after error', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'invalid'); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText('Invalid URL format')).toBeInTheDocument(); + }); + + await user.type(input, 'https://valid.com'); + + await waitFor(() => { + expect( + screen.queryByText('Invalid URL format') + ).not.toBeInTheDocument(); + }); + }); + + it('should have aria-invalid attribute when there is an error', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'invalid'); + await user.tab(); + + await waitFor(() => { + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + }); + + it('should reset base URL to default when Reset Base URL is clicked', async () => { + const user = userEvent.setup(); + const customUrl = 'https://custom.valhalla.com'; + localStorage.setItem(BASE_URL_STORAGE_KEY, customUrl); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const resetButton = screen.getByRole('button', { + name: /Reset Base URL/i, + }); + await user.click(resetButton); + + expect(localStorage.getItem(BASE_URL_STORAGE_KEY)).toBeNull(); + }); + + it('should disable Reset Base URL button when URL equals default', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const resetButton = screen.getByRole('button', { + name: /Reset Base URL/i, + }); + expect(resetButton).toBeDisabled(); + }); + + it('should enable Reset Base URL button when URL differs from default', async () => { + const user = userEvent.setup(); + const customUrl = 'https://custom.valhalla.com'; + localStorage.setItem(BASE_URL_STORAGE_KEY, customUrl); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const resetButton = screen.getByRole('button', { + name: /Reset Base URL/i, + }); + expect(resetButton).toBeEnabled(); + }); + + it('should not re-send request on blur when input is in error state and value unchanged', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'invalid-url'); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText('Invalid URL format')).toBeInTheDocument(); + }); + + await user.click(input); + await user.tab(); + + expect(screen.getByText('Invalid URL format')).toBeInTheDocument(); + }); + + it('should re-send request on blur after user modifies the error input value', async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'invalid-url'); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText('Invalid URL format')).toBeInTheDocument(); + }); + + await user.type(input, '-modified'); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText('Invalid URL format')).toBeInTheDocument(); + }); + }); + + it('should clear error state when reset button is clicked after error', async () => { + const user = userEvent.setup(); + const customUrl = 'https://custom.valhalla.com'; + localStorage.setItem(BASE_URL_STORAGE_KEY, customUrl); + renderWithQueryClient(); + + await user.click(screen.getByText('Server Settings')); + + const input = screen.getByRole('textbox', { name: /Base URL/i }); + await user.clear(input); + await user.type(input, 'invalid-url'); + await user.tab(); + + await waitFor(() => { + expect(screen.getByText('Invalid URL format')).toBeInTheDocument(); + }); + + const resetButton = screen.getByRole('button', { + name: /Reset Base URL/i, + }); + await user.click(resetButton); + + expect(screen.queryByText('Invalid URL format')).not.toBeInTheDocument(); + }); + }); + describe('Language Picker', () => { it('should render Directions Language section when activeTab is directions', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Directions Language')).toBeInTheDocument(); expect(screen.getByText('Language')).toBeInTheDocument(); }); it('should not render Directions Language section when activeTab is isochrones', () => { mockUseParams.mockReturnValue({ activeTab: 'isochrones' }); - render(); + renderWithQueryClient(); expect(screen.queryByText('Directions Language')).not.toBeInTheDocument(); }); it('should use system locale when no language is stored', () => { vi.stubGlobal('navigator', { language: 'fr-FR' }); - render(); + renderWithQueryClient(); expect(screen.getByText('French (France)')).toBeInTheDocument(); }); it('should fall back to en-US when system locale is not supported', () => { vi.stubGlobal('navigator', { language: 'xx-XX' }); - render(); + renderWithQueryClient(); expect(screen.getByText('English (United States)')).toBeInTheDocument(); }); it('should use stored language from localStorage on initial render', () => { localStorage.setItem(DIRECTIONS_LANGUAGE_STORAGE_KEY, 'de-DE'); - render(); + renderWithQueryClient(); expect(screen.getByText('German (Germany)')).toBeInTheDocument(); }); it('should render language select with correct id', () => { - render(); + renderWithQueryClient(); const languageSelect = screen.getByRole('combobox', { name: /Language/i, }); @@ -256,7 +541,7 @@ describe('SettingsPanel', () => { }); it('should render language description in help tooltip', () => { - render(); + renderWithQueryClient(); expect(screen.getByText('Language')).toBeInTheDocument(); }); }); diff --git a/src/components/settings-panel/settings-panel.tsx b/src/components/settings-panel/settings-panel.tsx index 7197aadd..6dfaac32 100644 --- a/src/components/settings-panel/settings-panel.tsx +++ b/src/components/settings-panel/settings-panel.tsx @@ -23,13 +23,20 @@ import { SheetHeader, SheetTitle, } from '@/components/ui/sheet'; -import { X, Copy, RotateCcw } from 'lucide-react'; -import { Separator } from '@/components/ui/separator'; +import { + X, + Copy, + RotateCcw, + Languages, + SlidersHorizontal, + Settings2, +} from 'lucide-react'; import { useParams, useSearch } from '@tanstack/react-router'; import { useDirectionsQuery } from '@/hooks/use-directions-queries'; import { useIsochronesQuery } from '@/hooks/use-isochrones-queries'; +import { CollapsibleSection } from '@/components/ui/collapsible-section'; +import { ServerSettings } from '@/components/settings-panel/server-settings'; -// Define the profile keys that have settings (excluding 'auto') type ProfileWithSettings = Exclude; export const SettingsPanel = () => { @@ -48,6 +55,10 @@ export const SettingsPanel = () => { getDirectionsLanguage() ); + const [languageSettingsOpen, setLanguageSettingsOpen] = useState(true); + const [profileSettingsOpen, setProfileSettingsOpen] = useState(true); + const [generalSettingsOpen, setGeneralSettingsOpen] = useState(true); + const handleLanguageChange = useCallback( (value: string) => { const newLanguage = value as DirectionsLanguage; @@ -112,7 +123,7 @@ export const SettingsPanel = () => { Settings @@ -125,120 +136,38 @@ export const SettingsPanel = () => { -
-
- {activeTab === 'directions' && ( - <> -
-

- Directions Language -

- -
- - - )} - - {hasProfileSettings && ( -
-
-

- Profile Settings -

- - {profile} - -
-
- {profileSettings[profile as ProfileWithSettings].numeric.map( - (option, key) => ( - { - updateSettings(option.param, values[0] ?? 0); - }} - onValueCommit={handleMakeRequest} - onInputChange={(values) => { - let value = values[0] ?? 0; - if (isNaN(value)) value = option.settings.min; - value = Math.max( - option.settings.min, - Math.min(value, option.settings.max) - ); - handleUpdateSettings({ - name: option.param, - value, - }); - }} - /> - ) - )} - {profileSettings[profile as ProfileWithSettings].boolean.map( - (option, key) => ( - { - handleUpdateSettings({ - name: option.param, - value: checked, - }); - }} - /> - ) - )} - {profileSettings[profile as ProfileWithSettings].enum.map( - (option, key) => ( - { - handleUpdateSettings({ - name: option.param, - value, - }); - }} - /> - ) - )} -
-
- )} +
+ - + {activeTab === 'directions' && ( + + + + )} -
-
-

- General Settings -

-
+ {hasProfileSettings && ( +
- {generalSettings[profile as ProfileWithSettings].numeric.map( + {profileSettings[profile as ProfileWithSettings].numeric.map( (option, key) => ( { /> ) )} - {generalSettings[profile as ProfileWithSettings].boolean.map( + {profileSettings[profile as ProfileWithSettings].boolean.map( (option, key) => ( { /> ) )} - {generalSettings.all.boolean.map((option, key) => ( - { - handleUpdateSettings({ - name: option.param, - value: checked, - }); - }} - /> - ))} - {generalSettings.all.numeric.map((option, key) => ( + {profileSettings[profile as ProfileWithSettings].enum.map( + (option, key) => ( + { + handleUpdateSettings({ + name: option.param, + value, + }); + }} + /> + ) + )} +
+
+ )} + + +
+ {generalSettings[profile as ProfileWithSettings].numeric.map( + (option, key) => ( { }); }} /> - ))} -
-
- - - -
- - + ) + )} + {generalSettings[profile as ProfileWithSettings].boolean.map( + (option, key) => ( + { + handleUpdateSettings({ + name: option.param, + value: checked, + }); + }} + /> + ) + )} + {generalSettings.all.boolean.map((option, key) => ( + { + handleUpdateSettings({ + name: option.param, + value: checked, + }); + }} + /> + ))} + {generalSettings.all.numeric.map((option, key) => ( + { + updateSettings(option.param, values[0] ?? 0); + }} + onValueCommit={handleMakeRequest} + onInputChange={(values) => { + let value = values[0] ?? 0; + if (isNaN(value)) value = option.settings.min; + value = Math.max( + option.settings.min, + Math.min(value, option.settings.max) + ); + handleUpdateSettings({ + name: option.param, + value, + }); + }} + /> + ))}
+ + +
+ +
diff --git a/src/components/ui/collapsible-section.spec.tsx b/src/components/ui/collapsible-section.spec.tsx new file mode 100644 index 00000000..3f3022a3 --- /dev/null +++ b/src/components/ui/collapsible-section.spec.tsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CollapsibleSection } from './collapsible-section'; +import { Settings } from 'lucide-react'; + +describe('CollapsibleSection', () => { + it('should render title', () => { + render( + {}} + > +
Content
+
+ ); + expect(screen.getByText('Test Section')).toBeInTheDocument(); + }); + + it('should render icon if provided', () => { + render( + {}} + > +
Content
+
+ ); + // Lucide icons render as SVGs, we can check for the class or presence + const icon = document.querySelector('.lucide-settings'); + expect(icon).toBeInTheDocument(); + }); + + it('should render subtitle if provided', () => { + render( + {}} + > +
Content
+
+ ); + expect(screen.getByText('(Details)')).toBeInTheDocument(); + }); + + it('should not render content when closed', () => { + render( + {}} + > +
Hidden Content
+
+ ); + // Collapsible content is usually hidden with attributes or CSS + // Radix Collapsible adds 'hidden' attribute when closed + const content = screen.queryByText('Hidden Content'); + // Note: implementation detail of Radix UI Collapsible might keep it in DOM but hidden + // or unmounted. Let's check visibility if it's in the document. + if (content) { + expect(content).not.toBeVisible(); + } else { + expect(content).not.toBeInTheDocument(); + } + }); + + it('should render content when open', () => { + render( + {}} + > +
Visible Content
+
+ ); + expect(screen.getByText('Visible Content')).toBeVisible(); + }); + + it('should call onOpenChange when trigger is clicked', async () => { + const handleOpenChange = vi.fn(); + const user = userEvent.setup(); + + render( + +
Content
+
+ ); + + await user.click(screen.getByText('Test Section')); + expect(handleOpenChange).toHaveBeenCalled(); + }); + + it('should apply custom className', () => { + const { container } = render( + {}} + className="custom-class" + > +
Content
+
+ ); + // The className is applied to the root element (Collapsible) + // We need to find the element with that class + const element = container.querySelector('.custom-class'); + expect(element).toBeInTheDocument(); + }); + + it('should rotate chevron when open', () => { + const { container } = render( + {}} + > +
Content
+
+ ); + + // We look for the chevron icon which should have rotate-180 class + const chevron = container.querySelector('.lucide-chevron-down'); + expect(chevron).toHaveClass('rotate-180'); + }); + + it('should not rotate chevron when closed', () => { + const { container } = render( + {}} + > +
Content
+
+ ); + + const chevron = container.querySelector('.lucide-chevron-down'); + expect(chevron).not.toHaveClass('rotate-180'); + }); +}); diff --git a/src/components/ui/collapsible-section.tsx b/src/components/ui/collapsible-section.tsx new file mode 100644 index 00000000..0069e719 --- /dev/null +++ b/src/components/ui/collapsible-section.tsx @@ -0,0 +1,55 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { ChevronDown, type LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface CollapsibleSectionProps { + title: string; + icon?: LucideIcon; + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + subtitle?: string; + className?: string; +} + +export function CollapsibleSection({ + title, + icon: Icon, + open, + onOpenChange, + children, + subtitle, + className, +}: CollapsibleSectionProps) { + return ( + + +
+ {Icon && } +

+ {title} +

+ {subtitle && ( + + {subtitle} + + )} +
+ +
+ {children} +
+ ); +} diff --git a/src/hooks/use-directions-queries.ts b/src/hooks/use-directions-queries.ts index 6a57978a..f2c50d3e 100644 --- a/src/hooks/use-directions-queries.ts +++ b/src/hooks/use-directions-queries.ts @@ -8,7 +8,7 @@ import type { ValhallaRouteResponse, } from '@/components/types'; import { - VALHALLA_OSM_URL, + getValhallaUrl, buildDirectionsRequest, parseDirectionsGeometry, } from '@/utils/valhalla'; @@ -23,10 +23,6 @@ import { useCommonStore } from '@/stores/common-store'; import { useDirectionsStore, type Waypoint } from '@/stores/directions-store'; import { router } from '@/routes'; -const serverMapping: Record = { - [VALHALLA_OSM_URL!]: 'OSM', -}; - const getActiveWaypoints = (waypoints: Waypoint[]): ActiveWaypoint[] => waypoints.flatMap((wp) => wp.geocodeResults.filter((r) => r.selected)); @@ -53,7 +49,7 @@ async function fetchDirections() { }); const { data } = await axios.get( - VALHALLA_OSM_URL + '/route', + getValhallaUrl() + '/route', { params: { json: JSON.stringify(valhallaRequest.json) }, headers: { 'Content-Type': 'application/json' }, @@ -103,7 +99,7 @@ export function useDirectionsQuery() { error_msg += ` for route.`; } toast.warning(`${response.data.status}`, { - description: `${serverMapping[VALHALLA_OSM_URL!]}: ${error_msg}`, + description: `${error_msg}`, position: 'bottom-center', duration: 5000, closeButton: true, diff --git a/src/hooks/use-isochrones-queries.ts b/src/hooks/use-isochrones-queries.ts index 08f3abfd..def536fb 100644 --- a/src/hooks/use-isochrones-queries.ts +++ b/src/hooks/use-isochrones-queries.ts @@ -7,7 +7,7 @@ import type { Center, ValhallaIsochroneResponse, } from '@/components/types'; -import { VALHALLA_OSM_URL, buildIsochronesRequest } from '@/utils/valhalla'; +import { getValhallaUrl, buildIsochronesRequest } from '@/utils/valhalla'; import { reverse_geocode, forward_geocode, @@ -19,10 +19,6 @@ import { useCommonStore } from '@/stores/common-store'; import { useIsochronesStore } from '@/stores/isochrones-store'; import { router } from '@/routes'; -const serverMapping: Record = { - [VALHALLA_OSM_URL!]: 'OSM', -}; - async function fetchIsochrones() { const { geocodeResults, maxRange, interval, denoise, generalize } = useIsochronesStore.getState(); @@ -48,7 +44,7 @@ async function fetchIsochrones() { }); const { data } = await axios.get( - VALHALLA_OSM_URL + '/isochrone', + getValhallaUrl() + '/isochrone', { params: { json: JSON.stringify(valhallaRequest.json) }, headers: { 'Content-Type': 'application/json' }, @@ -90,7 +86,7 @@ export function useIsochronesQuery() { if (axios.isAxiosError(error) && error.response) { const response = error.response; toast.warning(`${response.data.status}`, { - description: `${serverMapping[VALHALLA_OSM_URL!]}: ${response.data.error}`, + description: `${response.data.error}`, position: 'bottom-center', duration: 5000, closeButton: true, diff --git a/src/hooks/use-optimized-route-query.ts b/src/hooks/use-optimized-route-query.ts index 7a0bab7f..e3a7ef00 100644 --- a/src/hooks/use-optimized-route-query.ts +++ b/src/hooks/use-optimized-route-query.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import { toast } from 'sonner'; import { useDirectionsStore } from '@/stores/directions-store'; import { - VALHALLA_OSM_URL, + getValhallaUrl, buildOptimizedRouteRequest, parseDirectionsGeometry, } from '@/utils/valhalla'; @@ -51,7 +51,7 @@ export function useOptimizedRouteQuery() { language, }); const { data } = await axios.get( - `${VALHALLA_OSM_URL}/optimized_route`, + `${getValhallaUrl()}/optimized_route`, { params: { json: JSON.stringify(request.json) } } ); diff --git a/src/utils/base-url.spec.ts b/src/utils/base-url.spec.ts new file mode 100644 index 00000000..d5dab5fe --- /dev/null +++ b/src/utils/base-url.spec.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { + getBaseUrl, + setBaseUrl, + getDefaultBaseUrl, + validateBaseUrl, + normalizeBaseUrl, + testConnection, +} from './base-url'; + +const STORAGE_KEY = 'valhalla_base_url'; +const TEST_CUSTOM_URL = 'https://custom.valhalla.com'; + +describe('base-url', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getDefaultBaseUrl', () => { + it('should return the environment variable value', () => { + const defaultUrl = getDefaultBaseUrl(); + expect(defaultUrl).toBeTruthy(); + expect(typeof defaultUrl).toBe('string'); + }); + }); + + describe('getBaseUrl', () => { + it('should return default URL when localStorage is empty', () => { + const defaultUrl = getDefaultBaseUrl(); + expect(getBaseUrl()).toBe(defaultUrl); + }); + + it('should return stored URL when present in localStorage', () => { + localStorage.setItem(STORAGE_KEY, TEST_CUSTOM_URL); + expect(getBaseUrl()).toBe(TEST_CUSTOM_URL); + }); + }); + + describe('setBaseUrl', () => { + it('should store URL in localStorage', () => { + setBaseUrl(TEST_CUSTOM_URL); + expect(localStorage.getItem(STORAGE_KEY)).toBe(TEST_CUSTOM_URL); + }); + + it('should remove from localStorage when URL matches default', () => { + const defaultUrl = getDefaultBaseUrl(); + localStorage.setItem(STORAGE_KEY, TEST_CUSTOM_URL); + setBaseUrl(defaultUrl); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('should remove from localStorage when URL is empty', () => { + localStorage.setItem(STORAGE_KEY, TEST_CUSTOM_URL); + setBaseUrl(''); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('should trim whitespace from URL', () => { + setBaseUrl(' ' + TEST_CUSTOM_URL + ' '); + expect(localStorage.getItem(STORAGE_KEY)).toBe(TEST_CUSTOM_URL); + }); + }); + + describe('validateBaseUrl', () => { + it('should return invalid for empty string', () => { + const result = validateBaseUrl(''); + expect(result.valid).toBe(false); + expect(result.error).toBe('URL cannot be empty'); + }); + + it('should return invalid for whitespace only', () => { + const result = validateBaseUrl(' '); + expect(result.valid).toBe(false); + expect(result.error).toBe('URL cannot be empty'); + }); + + it('should return invalid for malformed URL', () => { + const result = validateBaseUrl('not-a-url'); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid URL format'); + }); + + it('should return invalid for non-http protocols', () => { + const result = validateBaseUrl('ftp://example.com'); + expect(result.valid).toBe(false); + expect(result.error).toBe('URL must use HTTP or HTTPS protocol'); + }); + + it('should return invalid for javascript protocol', () => { + const result = validateBaseUrl('javascript:alert(1)'); + expect(result.valid).toBe(false); + expect(result.error).toBe('URL must use HTTP or HTTPS protocol'); + }); + + it('should return valid for http URL', () => { + const result = validateBaseUrl('http://example.com'); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should return valid for https URL', () => { + const result = validateBaseUrl('https://example.com'); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should return valid for URL with port', () => { + const result = validateBaseUrl('https://example.com:8080'); + expect(result.valid).toBe(true); + }); + + it('should return valid for URL with path', () => { + const result = validateBaseUrl('https://example.com/api/v1'); + expect(result.valid).toBe(true); + }); + }); + + describe('normalizeBaseUrl', () => { + it('should remove trailing slash', () => { + expect(normalizeBaseUrl('https://example.com/')).toBe( + 'https://example.com' + ); + }); + + it('should not modify URL without trailing slash', () => { + expect(normalizeBaseUrl('https://example.com')).toBe( + 'https://example.com' + ); + }); + + it('should trim whitespace', () => { + expect(normalizeBaseUrl(' https://example.com ')).toBe( + 'https://example.com' + ); + }); + + it('should handle URL with path and trailing slash', () => { + expect(normalizeBaseUrl('https://example.com/api/')).toBe( + 'https://example.com/api' + ); + }); + }); + + describe('testConnection', () => { + it('should return invalid for malformed URL', async () => { + const result = await testConnection('not-a-url'); + expect(result.reachable).toBe(false); + expect(result.error).toBe('Invalid URL format'); + }); + + it('should return unreachable when fetch fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error')); + + const result = await testConnection('https://unreachable.example.com'); + expect(result.reachable).toBe(false); + expect(result.error).toBe('Server unreachable'); + }); + + it('should return unreachable on timeout', async () => { + const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; + vi.spyOn(global, 'fetch').mockRejectedValue(abortError); + + const result = await testConnection('https://slow.example.com'); + expect(result.reachable).toBe(false); + expect(result.error).toBe('Connection timeout'); + }); + + it('should return unreachable for non-ok status', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const result = await testConnection('https://error.example.com'); + expect(result.reachable).toBe(false); + expect(result.error).toBe('Server returned status 500'); + }); + + it('should return error for invalid JSON response', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.reject(new Error('Invalid JSON')), + } as unknown as Response); + + const result = await testConnection('https://invalid-json.example.com'); + expect(result.reachable).toBe(false); + expect(result.error).toBe('Invalid response format'); + }); + + it('should return error when available_actions is missing', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ version: '3.6.1' }), + } as unknown as Response); + + const result = await testConnection('https://not-valhalla.example.com'); + expect(result.reachable).toBe(false); + expect(result.error).toBe('Not a valid Valhalla server'); + }); + + it('should return error when available_actions is not an array', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ available_actions: 'route' }), + } as unknown as Response); + + const result = await testConnection( + 'https://invalid-actions.example.com' + ); + expect(result.reachable).toBe(false); + expect(result.error).toBe('Not a valid Valhalla server'); + }); + + it('should return error when required actions are missing', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ available_actions: ['status', 'locate'] }), + } as unknown as Response); + + const result = await testConnection( + 'https://missing-actions.example.com' + ); + expect(result.reachable).toBe(false); + expect(result.error).toBe( + 'Server missing required actions (route, isochrone)' + ); + }); + + it('should return reachable for valid Valhalla server', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: '3.6.1', + available_actions: ['route', 'isochrone', 'status', 'locate'], + }), + } as unknown as Response); + + const result = await testConnection('https://valid.example.com'); + expect(result.reachable).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should normalize URL before testing', async () => { + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: '3.6.1', + available_actions: ['route', 'isochrone'], + }), + } as unknown as Response); + + await testConnection('https://example.com/'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://example.com/status', + expect.any(Object) + ); + }); + }); +}); diff --git a/src/utils/base-url.ts b/src/utils/base-url.ts new file mode 100644 index 00000000..48385a79 --- /dev/null +++ b/src/utils/base-url.ts @@ -0,0 +1,153 @@ +import { z } from 'zod'; + +const BASE_URL_STORAGE_KEY = 'valhalla_base_url'; + +const DEFAULT_BASE_URL = + import.meta.env.VITE_VALHALLA_URL || 'https://valhalla1.openstreetmap.de'; + +const baseUrlSchema = z + .string() + .trim() + .min(1, 'URL cannot be empty') + .url('Invalid URL format') + .refine( + (url) => { + try { + const parsed = new URL(url); + return ['http:', 'https:'].includes(parsed.protocol); + } catch { + return false; + } + }, + { message: 'URL must use HTTP or HTTPS protocol' } + ); + +export function getBaseUrl(): string { + if (typeof window === 'undefined') { + return DEFAULT_BASE_URL; + } + + const stored = localStorage.getItem(BASE_URL_STORAGE_KEY); + + if (stored) { + return stored; + } + + return DEFAULT_BASE_URL; +} + +export function setBaseUrl(url: string): void { + if (typeof window === 'undefined') { + return; + } + + const trimmedUrl = url.trim(); + + if (trimmedUrl === '' || trimmedUrl === DEFAULT_BASE_URL) { + localStorage.removeItem(BASE_URL_STORAGE_KEY); + } else { + localStorage.setItem(BASE_URL_STORAGE_KEY, trimmedUrl); + } +} + +export function getDefaultBaseUrl(): string { + return DEFAULT_BASE_URL; +} + +export interface UrlValidationResult { + valid: boolean; + error?: string; +} + +export function validateBaseUrl(url: string): UrlValidationResult { + const result = baseUrlSchema.safeParse(url); + + if (result.success) { + return { valid: true }; + } + + return { valid: false, error: result.error.errors[0]?.message }; +} + +export function normalizeBaseUrl(url: string): string { + const trimmedUrl = url.trim(); + + if (trimmedUrl.endsWith('/')) { + return trimmedUrl.slice(0, -1); + } + + return trimmedUrl; +} + +export interface ConnectionTestResult { + reachable: boolean; + error?: string; +} + +interface ValhallaStatusResponse { + version?: string; + available_actions?: string[]; +} + +export async function testConnection( + url: string +): Promise { + const validation = validateBaseUrl(url); + if (!validation.valid) { + return { reachable: false, error: validation.error }; + } + + const normalizedUrl = normalizeBaseUrl(url); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(`${normalizedUrl}/status`, { + method: 'GET', + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { + reachable: false, + error: `Server returned status ${response.status}`, + }; + } + + let data: ValhallaStatusResponse; + try { + data = await response.json(); + } catch { + return { reachable: false, error: 'Invalid response format' }; + } + + if (!data.available_actions || !Array.isArray(data.available_actions)) { + return { reachable: false, error: 'Not a valid Valhalla server' }; + } + + const requiredActions = ['route', 'isochrone']; + const hasRequiredActions = requiredActions.every((action) => + data.available_actions!.includes(action) + ); + + if (!hasRequiredActions) { + return { + reachable: false, + error: 'Server missing required actions (route, isochrone)', + }; + } + + return { reachable: true }; + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return { reachable: false, error: 'Connection timeout' }; + } + return { reachable: false, error: 'Server unreachable' }; + } + return { reachable: false, error: 'Connection failed' }; + } +} diff --git a/src/utils/valhalla.ts b/src/utils/valhalla.ts index becb90a2..96c8100c 100644 --- a/src/utils/valhalla.ts +++ b/src/utils/valhalla.ts @@ -6,8 +6,9 @@ import type { IsochronesRequestParams, Settings, } from '@/components/types'; +import { getBaseUrl } from './base-url'; -export const VALHALLA_OSM_URL = import.meta.env.VITE_VALHALLA_URL; +export const getValhallaUrl = () => getBaseUrl(); export const buildLocateRequest = ( latLng: { lat: number; lng: number },