feat: implement location management with interactive maps

- Introduced a new `LocationMap` component for handling latitude and longitude inputs in forms and detail views.
- Updated form and detail components to utilize the `LocationMap`, enhancing user experience by allowing map-based location selection.
- Added internationalization support for map-related labels in English and Indonesian.
- Developed unit tests for the `LocationMap` to ensure functionality and reliability.

This commit enhances the application by providing a user-friendly interface for managing geographical locations, improving data accuracy and user interaction.
This commit is contained in:
shancheas
2026-08-26 07:56:04 +07:00
parent 5287da918c
commit 27dfc167d2
15 changed files with 578 additions and 62 deletions
+3
View File
@@ -1,4 +1,7 @@
export { RouteMap } from './route-map';
export type { RouteMapProps } from './route-map';
export { LocationMap } from './location-map';
export type { LocationMapProps } from './location-map';
export { toLeafletLatLngs } from './route-geometry';
export type { RouteGeometry } from './route-geometry';
export { toLocationLatLng, locationFromLatLng } from './location-point';
@@ -0,0 +1,115 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import { LocationMap } from './location-map';
import { SELECTED_MAP_ZOOM } from './location-point';
type LeafletClick = { latlng: { lat: number; lng: number } };
let mapClick: ((event: LeafletClick) => void) | undefined;
const { setView, panTo, invalidateSize } = vi.hoisted(() => ({
setView: vi.fn(),
panTo: vi.fn(),
invalidateSize: vi.fn(),
}));
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div data-testid="osm-map">{children}</div>,
TileLayer: () => <div data-testid="osm-tiles" />,
CircleMarker: ({ center }: { center: [number, number] }) => <div data-testid="location-marker">{`${center[0]},${center[1]}`}</div>,
useMap: () => ({ setView, panTo, invalidateSize }),
useMapEvents: (handlers: { click?: (event: LeafletClick) => void }) => {
mapClick = handlers.click;
return null;
},
}));
function renderMap(ui: React.ReactElement) {
return render(<MantineProvider>{ui}</MantineProvider>);
}
describe('LocationMap', () => {
beforeEach(() => {
mapClick = undefined;
setView.mockClear();
panTo.mockClear();
});
it('shows an empty state when coordinates are missing and the map is read-only', () => {
renderMap(<LocationMap emptyLabel="No location" />);
expect(screen.getByText('No location')).toBeInTheDocument();
expect(screen.queryByTestId('osm-map')).not.toBeInTheDocument();
});
it('renders OSM tiles and a marker when coordinates are present', () => {
renderMap(<LocationMap latitude={-6.2} longitude={106.8} />);
expect(screen.getByTestId('osm-map')).toBeInTheDocument();
expect(screen.getByTestId('osm-tiles')).toBeInTheDocument();
expect(screen.getByTestId('location-marker')).toHaveTextContent('-6.2,106.8');
});
it('always shows the map when picking is enabled, even without coordinates', () => {
renderMap(<LocationMap onChange={vi.fn()} helperLabel="Click the map" />);
expect(screen.getByTestId('osm-map')).toBeInTheDocument();
expect(screen.queryByTestId('location-marker')).not.toBeInTheDocument();
expect(screen.getByText('Click the map')).toBeInTheDocument();
});
it('reports rounded latitude and longitude when the map is clicked', () => {
const onChange = vi.fn();
renderMap(<LocationMap onChange={onChange} />);
expect(mapClick).toBeTypeOf('function');
mapClick?.({ latlng: { lat: -6.208812345, lng: 106.8456789 } });
expect(onChange).toHaveBeenCalledWith({ latitude: -6.208812, longitude: 106.845679 });
});
it('wraps unwrapped longitudes from world-copy clicks into range', () => {
const onChange = vi.fn();
renderMap(<LocationMap onChange={onChange} />);
mapClick?.({ latlng: { lat: -6.2, lng: 466.8 } });
expect(onChange).toHaveBeenCalledWith({ latitude: -6.2, longitude: 106.8 });
});
it('does not register a click handler on a read-only map', () => {
renderMap(<LocationMap latitude={-6.2} longitude={106.8} />);
expect(mapClick).toBeUndefined();
});
it('centers once when coordinates first become valid', () => {
const onChange = vi.fn();
const { rerender } = renderMap(<LocationMap onChange={onChange} />);
expect(setView).not.toHaveBeenCalled();
rerender(
<MantineProvider>
<LocationMap latitude={-6.2} longitude={106.8} onChange={onChange} />
</MantineProvider>,
);
expect(setView).toHaveBeenCalledWith([-6.2, 106.8], SELECTED_MAP_ZOOM);
expect(setView).toHaveBeenCalledTimes(1);
rerender(
<MantineProvider>
<LocationMap latitude={-6.3} longitude={106.9} onChange={onChange} />
</MantineProvider>,
);
expect(panTo).toHaveBeenCalledWith([-6.3, 106.9]);
expect(setView).toHaveBeenCalledTimes(1);
});
it('does not move the view for a point that was just picked', () => {
const onChange = vi.fn();
const { rerender } = renderMap(<LocationMap onChange={onChange} />);
mapClick?.({ latlng: { lat: -6.2, lng: 106.8 } });
const point = onChange.mock.calls[0][0] as { latitude: number; longitude: number };
rerender(
<MantineProvider>
<LocationMap latitude={point.latitude} longitude={point.longitude} onChange={onChange} />
</MantineProvider>,
);
expect(setView).not.toHaveBeenCalled();
expect(panTo).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
import { CircleMarker, MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import { Box, Text } from '@mantine/core';
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
import {
DEFAULT_MAP_CENTER,
DEFAULT_MAP_ZOOM,
locationFromLatLng,
SELECTED_MAP_ZOOM,
toLocationLatLng,
} from './location-point';
import 'leaflet/dist/leaflet.css';
export interface LocationMapProps {
latitude?: unknown;
longitude?: unknown;
onChange?: (point: { latitude: number; longitude: number }) => void;
height?: number;
emptyLabel?: string;
helperLabel?: string;
defaultCenter?: [number, number];
defaultZoom?: number;
tileUrl?: string;
attribution?: string;
}
function SyncMapView({
latitude,
longitude,
lastPickedRef,
}: {
latitude: unknown;
longitude: unknown;
lastPickedRef: RefObject<{ latitude: number; longitude: number } | null>;
}) {
const map = useMap();
const point = toLocationLatLng(latitude, longitude);
const lat = point?.[0] ?? null;
const lng = point?.[1] ?? null;
const hasCentered = useRef(false);
useEffect(() => {
const id = window.setTimeout(() => map.invalidateSize(), 0);
return () => window.clearTimeout(id);
}, [map]);
useEffect(() => {
if (lat === null || lng === null) return;
const picked = lastPickedRef.current;
if (picked && picked.latitude === lat && picked.longitude === lng) {
return;
}
if (!hasCentered.current) {
hasCentered.current = true;
map.setView([lat, lng], SELECTED_MAP_ZOOM);
return;
}
map.panTo([lat, lng]);
}, [map, lat, lng, lastPickedRef]);
return null;
}
function MapClickPicker({ onPick }: { onPick: (lat: number, lng: number) => void }) {
const onPickRef = useRef(onPick);
onPickRef.current = onPick;
const handlers = useMemo(
() => ({
click(event: { latlng: { lat: number; lng: number } }) {
onPickRef.current(event.latlng.lat, event.latlng.lng);
},
}),
[],
);
useMapEvents(handlers);
return null;
}
export function LocationMap({
latitude,
longitude,
onChange,
height = 280,
emptyLabel = 'No location',
helperLabel,
defaultCenter = DEFAULT_MAP_CENTER,
defaultZoom = DEFAULT_MAP_ZOOM,
tileUrl = OSM_TILE_URL,
attribution = OSM_ATTRIBUTION,
}: LocationMapProps) {
const interactive = Boolean(onChange);
const position = toLocationLatLng(latitude, longitude);
const lastPickedRef = useRef<{ latitude: number; longitude: number } | null>(null);
const center = position ?? defaultCenter;
const zoom = position ? SELECTED_MAP_ZOOM : defaultZoom;
const handlePick = useCallback(
(lat: number, lng: number) => {
const point = locationFromLatLng(lat, lng);
lastPickedRef.current = point;
onChange?.(point);
},
[onChange],
);
if (!interactive && !position) {
return (
<Box h={height} bdrs="md" bd="1px solid var(--mantine-color-default-border)" p="md">
<Text size="sm" c="dimmed">
{emptyLabel}
</Text>
</Box>
);
}
return (
<Box>
<Box
h={height}
bdrs="md"
bd="1px solid var(--mantine-color-default-border)"
style={{ overflow: 'hidden', cursor: interactive ? 'crosshair' : undefined }}
>
<MapContainer center={center} zoom={zoom} style={{ height: '100%', width: '100%' }} scrollWheelZoom={interactive}>
<TileLayer attribution={attribution} url={tileUrl} />
{position ? (
<CircleMarker center={position} radius={10} pathOptions={{ color: 'var(--mantine-color-blue-8)', fillOpacity: 0.9 }} />
) : null}
<SyncMapView latitude={latitude} longitude={longitude} lastPickedRef={lastPickedRef} />
{onChange ? <MapClickPicker onPick={handlePick} /> : null}
</MapContainer>
</Box>
{interactive && helperLabel ? (
<Text size="xs" c="dimmed" mt="xs">
{helperLabel}
</Text>
) : null}
</Box>
);
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_MAP_CENTER,
DEFAULT_MAP_ZOOM,
SELECTED_MAP_ZOOM,
locationFromLatLng,
mapViewForLocation,
roundCoordinate,
toFiniteNumber,
toLocationLatLng,
} from './location-point';
describe('toFiniteNumber', () => {
it('returns finite numbers and parses numeric strings', () => {
expect(toFiniteNumber(-6.2)).toBe(-6.2);
expect(toFiniteNumber('106.8')).toBe(106.8);
expect(toFiniteNumber(' 1.5 ')).toBe(1.5);
});
it('returns null for empty or non-numeric values', () => {
expect(toFiniteNumber(null)).toBeNull();
expect(toFiniteNumber(undefined)).toBeNull();
expect(toFiniteNumber('')).toBeNull();
expect(toFiniteNumber(' ')).toBeNull();
expect(toFiniteNumber('abc')).toBeNull();
expect(toFiniteNumber(Number.NaN)).toBeNull();
expect(toFiniteNumber(Number.POSITIVE_INFINITY)).toBeNull();
});
});
describe('toLocationLatLng', () => {
it('returns Leaflet [lat, lng] when both coordinates are valid', () => {
expect(toLocationLatLng(-6.2, 106.8)).toEqual([-6.2, 106.8]);
expect(toLocationLatLng('-6.2', '106.8')).toEqual([-6.2, 106.8]);
});
it('returns null when a coordinate is missing or out of range', () => {
expect(toLocationLatLng(undefined, 106.8)).toBeNull();
expect(toLocationLatLng(-6.2, undefined)).toBeNull();
expect(toLocationLatLng(-91, 106.8)).toBeNull();
expect(toLocationLatLng(-6.2, 181)).toBeNull();
});
});
describe('roundCoordinate', () => {
it('rounds to 6 decimal places by default', () => {
expect(roundCoordinate(-6.208812345)).toBe(-6.208812);
expect(roundCoordinate(106.8456789)).toBe(106.845679);
});
});
describe('locationFromLatLng', () => {
it('returns rounded latitude and longitude', () => {
expect(locationFromLatLng(-6.208812345, 106.8456789)).toEqual({
latitude: -6.208812,
longitude: 106.845679,
});
});
it('wraps longitude from repeated world copies back into [-180, 180]', () => {
expect(locationFromLatLng(-6.2, 466.8)).toEqual({ latitude: -6.2, longitude: 106.8 });
expect(locationFromLatLng(-6.2, -253.2)).toEqual({ latitude: -6.2, longitude: 106.8 });
expect(locationFromLatLng(-6.2, 180)).toEqual({ latitude: -6.2, longitude: 180 });
expect(locationFromLatLng(-6.2, -180)).toEqual({ latitude: -6.2, longitude: -180 });
});
it('clamps latitude to the valid range', () => {
expect(locationFromLatLng(120, 106.8).latitude).toBe(90);
expect(locationFromLatLng(-120, 106.8).latitude).toBe(-90);
});
});
describe('mapViewForLocation', () => {
it('uses the selected point at close zoom when coordinates exist', () => {
expect(mapViewForLocation(-6.2, 106.8)).toEqual({
center: [-6.2, 106.8],
zoom: SELECTED_MAP_ZOOM,
});
});
it('falls back to the default Indonesia view when empty', () => {
expect(mapViewForLocation(null, null)).toEqual({
center: DEFAULT_MAP_CENTER,
zoom: DEFAULT_MAP_ZOOM,
});
});
});
@@ -0,0 +1,66 @@
export const DEFAULT_MAP_CENTER: [number, number] = [-6.2088, 106.8456];
export const DEFAULT_MAP_ZOOM = 6;
export const SELECTED_MAP_ZOOM = 15;
export const COORDINATE_DECIMALS = 6;
export function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
export function isValidLatitude(value: number): boolean {
return value >= -90 && value <= 90;
}
export function isValidLongitude(value: number): boolean {
return value >= -180 && value <= 180;
}
export function toLocationLatLng(latitude: unknown, longitude: unknown): [number, number] | null {
const lat = toFiniteNumber(latitude);
const lng = toFiniteNumber(longitude);
if (lat === null || lng === null) return null;
if (!isValidLatitude(lat) || !isValidLongitude(lng)) return null;
return [lat, lng];
}
export function roundCoordinate(value: number, decimals = COORDINATE_DECIMALS): number {
const factor = 10 ** decimals;
return Math.round(value * factor) / factor;
}
export function clampLatitude(value: number): number {
return Math.min(90, Math.max(-90, value));
}
// Leaflet reports clicks on repeated world copies with an unwrapped longitude (e.g. 466.8),
// which the form schemas reject. Keep 180 and -180 as authored instead of collapsing them.
export function wrapLongitude(value: number): number {
if (value >= -180 && value <= 180) return value;
const wrapped = ((value + 180) % 360 + 360) % 360 - 180;
return wrapped;
}
export function locationFromLatLng(lat: number, lng: number): { latitude: number; longitude: number } {
return {
latitude: roundCoordinate(clampLatitude(lat)),
longitude: roundCoordinate(wrapLongitude(lng)),
};
}
export function mapViewForLocation(
latitude: unknown,
longitude: unknown,
): { center: [number, number]; zoom: number } {
const point = toLocationLatLng(latitude, longitude);
if (!point) {
return { center: DEFAULT_MAP_CENTER, zoom: DEFAULT_MAP_ZOOM };
}
return { center: point, zoom: SELECTED_MAP_ZOOM };
}
+3
View File
@@ -0,0 +1,3 @@
export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
+2 -1
View File
@@ -3,6 +3,7 @@ import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip, useMap } from
import { Box, Text } from '@mantine/core';
import type { RouteGeometry } from './route-geometry';
import { toLeafletLatLngs } from './route-geometry';
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
import 'leaflet/dist/leaflet.css';
function FitRouteBounds({ positions }: { positions: Array<[number, number]> }) {
@@ -39,7 +40,7 @@ export function RouteMap({ geometry, height = 280 }: RouteMapProps) {
return (
<Box h={height} bdrs="md" style={{ overflow: 'hidden' }}>
<MapContainer center={positions[0]} zoom={12} style={{ height: '100%', width: '100%' }} scrollWheelZoom>
<TileLayer attribution="&copy; OpenStreetMap contributors" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<TileLayer attribution={OSM_ATTRIBUTION} url={OSM_TILE_URL} />
<Polyline positions={positions} pathOptions={{ color: 'var(--mantine-color-blue-6)', weight: 4 }} />
{positions.map((position, index) => (
<CircleMarker key={`${position[0]}-${position[1]}-${index}`} center={position} radius={8} pathOptions={{ color: 'var(--mantine-color-blue-8)' }}>