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
108 changes: 101 additions & 7 deletions src/components/directions/route-card.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { ParsedDirectionsGeometry } from '@/components/types';

const mockExportDataAsJson = vi.fn();
const mockDownloadFile = vi.fn();
const mockFetchHeight = vi.fn();
const mockToastError = vi.fn();

vi.mock('@/utils/export', () => ({
exportDataAsJson: (...args: unknown[]) => mockExportDataAsJson(...args),
Expand All @@ -15,6 +17,16 @@ vi.mock('@/utils/download-file', () => ({
downloadFile: (...args: unknown[]) => mockDownloadFile(...args),
}));

vi.mock('@/utils/height', () => ({
fetchHeight: (...args: unknown[]) => mockFetchHeight(...args),
}));

vi.mock('sonner', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}));

vi.mock('@/utils/date-time', () => ({
getDateTimeString: () => '2024-01-01_12-00-00',
formatDuration: (seconds: number) => `${Math.floor(seconds / 60)} min`,
Expand Down Expand Up @@ -92,6 +104,7 @@ const createMockData = (
describe('RouteCard', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchHeight.mockResolvedValue({ height: [100, 101, 102] });
});

it('should render without crashing', () => {
Expand Down Expand Up @@ -194,37 +207,46 @@ describe('RouteCard', () => {

await user.click(screen.getByRole('button', { name: /export/i }));

expect(screen.getByRole('menuitem', { name: 'JSON' })).toBeInTheDocument();
expect(screen.getByText('Format')).toBeInTheDocument();
expect(screen.getByText('Options')).toBeInTheDocument();
expect(
screen.getByRole('menuitemradio', { name: 'JSON' })
).toBeInTheDocument();
expect(
screen.getByRole('menuitemradio', { name: 'GeoJSON' })
).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'GeoJSON' })
screen.getByRole('menuitemcheckbox', { name: 'Include elevation' })
).toBeInTheDocument();
});

it('should call exportDataAsJson when JSON is clicked', async () => {
it('should call exportDataAsJson when JSON format is selected and Export is clicked', async () => {
const user = userEvent.setup();
const data = createMockData();
render(
<RouteCard data={data} index={0} isActive={true} onSelect={vi.fn()} />
);

await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(screen.getByRole('menuitem', { name: 'JSON' }));
await user.click(screen.getByRole('menuitemradio', { name: 'JSON' }));
await user.click(screen.getByTestId('export-action-button'));

expect(mockExportDataAsJson).toHaveBeenCalledWith(
data,
'valhalla-directions'
);
});

it('should call downloadFile with GeoJSON when GeoJSON is clicked', async () => {
it('should call downloadFile with GeoJSON when GeoJSON format is selected and Export is clicked', async () => {
const user = userEvent.setup();
const data = createMockData();
render(
<RouteCard data={data} index={0} isActive={true} onSelect={vi.fn()} />
);

await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(screen.getByRole('menuitem', { name: 'GeoJSON' }));
await user.click(screen.getByRole('menuitemradio', { name: 'GeoJSON' }));
await user.click(screen.getByTestId('export-action-button'));

expect(mockDownloadFile).toHaveBeenCalledWith({
data: expect.stringContaining('"type": "Feature"'),
Expand All @@ -233,6 +255,77 @@ describe('RouteCard', () => {
});
});

it('should fetch elevation and export JSON with elevation when option is enabled', async () => {
const user = userEvent.setup();
const baseData = createMockData();
const firstLeg = baseData.trip.legs[0]!;
const data = createMockData({
trip: {
...baseData.trip,
legs: [
...baseData.trip.legs,
{
...firstLeg,
shape: 'encoded-2',
},
],
},
});

render(
<RouteCard data={data} index={0} isActive={true} onSelect={vi.fn()} />
);

await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(screen.getByRole('menuitemradio', { name: 'JSON' }));
await user.click(
screen.getByRole('menuitemcheckbox', { name: 'Include elevation' })
);
await user.click(screen.getByTestId('export-action-button'));

expect(mockFetchHeight).toHaveBeenCalledWith({
coordinates: data.decodedGeometry,
});

const callArg = mockDownloadFile.mock.calls[0]?.[0] as {
data: string;
fileName: string;
fileType: string;
};
const exportedJson = JSON.parse(callArg.data);

expect(callArg.fileName).toBe(
'valhalla-directions_2024-01-01_12-00-00_with_elevation.json'
);
expect(exportedJson.trip.legs).toHaveLength(2);
expect(exportedJson.trip.legs[0].elevation_interval).toBe(30);
expect(exportedJson.trip.legs[0].elevation).toEqual([100, 101, 102]);
expect(exportedJson.trip.legs[1].elevation_interval).toBe(30);
expect(exportedJson.trip.legs[1].elevation).toEqual([100, 101, 102]);
});

it('should show error toast and skip download when elevation fetch fails', async () => {
const user = userEvent.setup();
const data = createMockData();
mockFetchHeight.mockRejectedValueOnce(new Error('network failed'));

render(
<RouteCard data={data} index={0} isActive={true} onSelect={vi.fn()} />
);

await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(
screen.getByRole('menuitemcheckbox', { name: 'Include elevation' })
);
await user.click(screen.getByTestId('export-action-button'));

expect(mockToastError).toHaveBeenCalledWith(
'Failed to fetch elevation data.',
expect.any(Object)
);
expect(mockDownloadFile).not.toHaveBeenCalled();
});

it('should convert coordinates to GeoJSON format (lng, lat)', async () => {
const user = userEvent.setup();
const data = createMockData({
Expand All @@ -243,7 +336,8 @@ describe('RouteCard', () => {
);

await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(screen.getByRole('menuitem', { name: 'GeoJSON' }));
await user.click(screen.getByRole('menuitemradio', { name: 'GeoJSON' }));
await user.click(screen.getByTestId('export-action-button'));

const callArg = mockDownloadFile.mock.calls[0]?.[0] as {
data: string;
Expand Down
177 changes: 166 additions & 11 deletions src/components/directions/route-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@ import {
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Download } from 'lucide-react';
import { ChevronDown, Download } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
import { exportDataAsJson } from '@/utils/export';
import { getDateTimeString } from '@/utils/date-time';
import { fetchHeight } from '@/utils/height';
import { toast } from 'sonner';

interface RouteCardProps {
data: ParsedDirectionsGeometry;
Expand All @@ -36,6 +42,11 @@ export const RouteCard = ({
onSelect,
}: RouteCardProps) => {
const [showManeuvers, setShowManeuvers] = useState(false);
const [includeElevation, setIncludeElevation] = useState(false);
const [exportFormat, setExportFormat] = useState<'geojson' | 'json'>(
'geojson'
);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);

const exportToGeoJson = useCallback(() => {
const coordinates = data?.decodedGeometry;
Expand All @@ -60,6 +71,109 @@ export const RouteCard = ({
});
}, [data]);

const exportWithElevation = useCallback(
async (isGeoJson: boolean = false) => {
const coordinates = data?.decodedGeometry;
if (!coordinates) return;

let elevationResults: Awaited<ReturnType<typeof fetchHeight>>;
try {
elevationResults = await fetchHeight({
coordinates: coordinates as [number, number][],
});
} catch {
toast.error('Failed to fetch elevation data.', {
position: 'bottom-center',
duration: 5000,
closeButton: true,
});
return;
}

if (!elevationResults.height) {
toast.error('Failed to fetch elevation data.', {
position: 'bottom-center',
duration: 5000,
closeButton: true,
});
return;
}

if (!isGeoJson) {
const dataWithElevation = {
...data,
trip: {
...data.trip,
legs: (data.trip.legs ?? []).map((leg) => ({
...leg,
elevation_interval: 30,
elevation: elevationResults.height,
})),
},
};
const formattedData = JSON.stringify(dataWithElevation, null, 2);
downloadFile({
data: formattedData,
fileName:
'valhalla-directions_' +
getDateTimeString() +
'_with_elevation.json',
fileType: 'text/json',
});
return;
}

const geoJsonCoordinates = coordinates.map(([lat, lng]) => [lng, lat]);

const geoJson = {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: geoJsonCoordinates,
},
properties: {
elevation_interval: 30,
elevation: elevationResults.height,
},
};
const formattedData = JSON.stringify(geoJson, null, 2);
downloadFile({
data: formattedData,
fileName:
'valhalla-directions_' +
getDateTimeString() +
'_with_elevation.geojson',
fileType: 'text/json',
});
},
[data]
);

const handleExport = useCallback(async () => {
if (exportFormat === 'json') {
if (includeElevation) {
await exportWithElevation();
} else {
exportDataAsJson(data, 'valhalla-directions');
}
setIsExportMenuOpen(false);
return;
}

if (includeElevation) {
await exportWithElevation(true);
} else {
exportToGeoJson();
}
setIsExportMenuOpen(false);
}, [
data,
exportFormat,
includeElevation,
exportToGeoJson,
exportWithElevation,
]);

if (!data.trip) {
return null;
}
Expand Down Expand Up @@ -95,22 +209,63 @@ export const RouteCard = ({
{showManeuvers ? 'Hide Maneuvers' : 'Show Maneuvers'}
</Button>
</CollapsibleTrigger>
<DropdownMenu>
<DropdownMenu
open={isExportMenuOpen}
onOpenChange={setIsExportMenuOpen}
>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="size-4" />
Export
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => exportDataAsJson(data, 'valhalla-directions')}
<DropdownMenuContent className="w-56 p-2" align="end">
<DropdownMenuLabel className="text-xs text-muted-foreground font-semibold uppercase tracking-wide px-2 py-1">
Format
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={exportFormat}
onValueChange={(value) =>
setExportFormat(value as 'geojson' | 'json')
}
>
<DropdownMenuRadioItem
value="geojson"
onSelect={(e) => e.preventDefault()}
>
GeoJSON
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="json"
onSelect={(e) => e.preventDefault()}
>
JSON
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground font-semibold uppercase tracking-wide px-2 py-1">
Options
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={includeElevation}
onCheckedChange={(checked) => setIncludeElevation(!!checked)}
onSelect={(e) => e.preventDefault()}
>
JSON
</DropdownMenuItem>
<DropdownMenuItem onClick={exportToGeoJson}>
GeoJSON
</DropdownMenuItem>
Include elevation
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<div className="px-1 pt-1">
<Button
data-testid="export-action-button"
size="sm"
className="w-full"
onClick={handleExport}
>
<Download className="size-4" />
Export
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down
Loading
Loading