Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { cleanup, render } from '@testing-library/react';
import { cleanup, render, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TileInspectDialog } from '@/components/dialogs/tile-inspect';
import type { TileSource } from '@/lib/types';
import { buildMartinUrl } from '@/lib/api';

interface MockComponentProps {
children?: ReactNode;
className?: string;
[key: string]: unknown;
}

// Mock the buildMartinUrl function
vi.mock('@/lib/api', () => ({
buildMartinUrl: vi.fn((path: string) => `http://localhost:3000${path}`),
}));

// Mock fetch globally
global.fetch = vi.fn();

// Mock the UI dialog components
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({
Expand Down Expand Up @@ -64,18 +73,28 @@ describe('TileInspectDialog', () => {

beforeEach(() => {
vi.clearAllMocks();
// Default mock for fetch - return empty TileJSON
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
tiles: [],
}),
} as Response);
});

afterEach(() => {
cleanup();
});

it('renders dialog with correct title and source information', () => {
it('renders dialog with correct title and source information', async () => {
const { container } = render(
<TileInspectDialog name="test-tiles" onCloseAction={mockOnClose} source={mockTileSource} />,
);

expect(container.textContent).toContain('Inspect Tile Source:');
await waitFor(() => {
expect(container.textContent).toContain('Inspect Tile Source:');
});

expect(container.textContent).toContain('test-tiles');
expect(container.textContent).toContain('Source Information');
expect(container.textContent).toContain('image/png');
Expand Down Expand Up @@ -165,6 +184,31 @@ describe('TileInspectDialog', () => {
expect(container.textContent).toContain('Encoding:');
});

it('fetches TileJSON when dialog opens', async () => {
const mockTileJSON = {
tiles: ['http://localhost:3000/test-tiles/{z}/{x}/{y}'],
minzoom: 5,
maxzoom: 15,
bounds: [-180, -85, 180, 85],
center: [0, 0, 10],
};

vi.mocked(global.fetch).mockResolvedValueOnce({
ok: true,
json: async () => mockTileJSON,
} as Response);

render(
<TileInspectDialog name="test-tiles" onCloseAction={mockOnClose} source={mockTileSource} />,
);

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
buildMartinUrl('/test-tiles'),
);
});
});

it('conditionally renders optional fields', () => {
const sourceWithoutOptionals: TileSource = {
content_type: 'image/png',
Expand Down
150 changes: 147 additions & 3 deletions martin/martin-ui/src/components/dialogs/tile-inspect.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useCallback, useId, useRef } from 'react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
Expand All @@ -17,6 +17,23 @@ import { Database } from 'lucide-react';
import { Popup } from 'maplibre-gl';
import { buildMartinUrl } from '@/lib/api';

interface TileJSON {
tilejson?: string;
name?: string;
description?: string;
version?: string;
attribution?: string;
scheme?: string;
tiles: string[];
grids?: string[];
data?: string[];
minzoom?: number;
maxzoom?: number;
bounds?: [number, number, number, number]; // [west, south, east, north]
center?: [number, number, number]; // [longitude, latitude, zoom]
vector_layers?: unknown[];
}

interface TileInspectDialogProps {
name: string;
source: TileSource;
Expand All @@ -27,6 +44,86 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
const id = useId();
const mapRef = useRef<MapRef>(null);
const inspectControlRef = useRef<MaplibreInspect>(null);
const [tileJSON, setTileJSON] = useState<TileJSON | null>(null);

const configureMapBounds = useCallback(() => {
if (!mapRef.current || !tileJSON) {
return;
}

const map = mapRef.current.getMap();

// Set minzoom and maxzoom restrictions
if (tileJSON.minzoom !== undefined) {
map.setMinZoom(tileJSON.minzoom);
}
if (tileJSON.maxzoom !== undefined) {
map.setMaxZoom(tileJSON.maxzoom);
}

// Set bounds restrictions if available
if (tileJSON.bounds) {
const [west, south, east, north] = tileJSON.bounds;
map.setMaxBounds([
[west, south],
[east, north],
]);
}

// Fit to bounds or center if available
if (tileJSON.bounds) {
const [west, south, east, north] = tileJSON.bounds;
map.fitBounds(
[
[west, south],
[east, north],
],
{
maxZoom: tileJSON.maxzoom,
padding: 50,
},
);
} else if (tileJSON.center) {
const [lng, lat, zoom] = tileJSON.center;
map.setCenter([lng, lat]);
if (zoom !== undefined) {
map.setZoom(zoom);
}
}
}, [tileJSON]);

// Fetch TileJSON when dialog opens
useEffect(() => {
let cancelled = false;

fetch(buildMartinUrl(`/${name}`))
.then((response) => {
Comment on lines +99 to +100
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this is very react-y.

I think the current recomendation is to use <Suspense + asyncData and just wrap the components where we need the data.

At least this is explained like this here:

https://stackoverflow.com/questions/53332321/react-hook-warnings-for-async-function-in-useeffect-useeffect-function-must-ret

What do you think? 🤔

if (!response.ok) {
throw new Error(`Failed to fetch TileJSON: ${response.statusText}`);
}
return response.json();
})
.then((data: TileJSON) => {
if (!cancelled) {
setTileJSON(data);
}
})
.catch((error) => {
console.error('Error fetching TileJSON:', error);
// Continue without TileJSON restrictions if fetch fails
});

return () => {
cancelled = true;
};
}, [name]);

// Reconfigure bounds when TileJSON loads after map is already initialized
useEffect(() => {
if (tileJSON && mapRef.current) {
configureMapBounds();
}
}, [tileJSON, configureMapBounds]);

const addInspectorToMap = useCallback(() => {
if (!mapRef.current) {
Expand Down Expand Up @@ -54,12 +151,15 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
});

map.addControl(inspectControlRef.current);
}, [name]);

// Configure bounds after adding inspector
configureMapBounds();
}, [name, configureMapBounds]);
const isImageSource = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'].includes(
source.content_type,
);
return (
<Dialog onOpenChange={(v) => !v && onCloseAction()} open={true}>
<Dialog onOpenChange={(v: boolean) => !v && onCloseAction()} open={true}>
<DialogContent className="max-w-6xl w-full p-6 max-h-[90vh] overflow-auto">
<DialogHeader className="mb-6">
<DialogTitle className="text-2xl flex items-center justify-between">
Expand All @@ -76,6 +176,31 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
<section className="border rounded-lg overflow-hidden">
{isImageSource ? (
<MapLibreMap
initialViewState={
tileJSON?.center
? {
latitude: tileJSON.center[1],
longitude: tileJSON.center[0],
zoom: tileJSON.center[2] ?? 0,
}
: undefined
}
maxBounds={
tileJSON?.bounds
? [
[tileJSON.bounds[0], tileJSON.bounds[1]],
[tileJSON.bounds[2], tileJSON.bounds[3]],
]
: undefined
}
maxZoom={tileJSON?.maxzoom}
minZoom={tileJSON?.minzoom}
onLoad={() => {
// Configure bounds for raster sources after map loads
if (tileJSON) {
configureMapBounds();
}
}}
Comment on lines +198 to +203
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You always seem to configure this twice:

  • once in the react component
  • once in the onLoad callback

Could you explain this? 🤔
Seems a bit strange

ref={mapRef}
reuseMaps={false}
style={{
Expand All @@ -88,6 +213,25 @@ export function TileInspectDialog({ name, source, onCloseAction }: TileInspectDi
</MapLibreMap>
) : (
<MapLibreMap
initialViewState={
tileJSON?.center
? {
latitude: tileJSON.center[1],
longitude: tileJSON.center[0],
zoom: tileJSON.center[2] ?? 0,
}
: undefined
}
maxBounds={
tileJSON?.bounds
? [
[tileJSON.bounds[0], tileJSON.bounds[1]],
[tileJSON.bounds[2], tileJSON.bounds[3]],
]
: undefined
}
maxZoom={tileJSON?.maxzoom}
minZoom={tileJSON?.minzoom}
onLoad={addInspectorToMap}
ref={mapRef}
reuseMaps={false}
Expand Down
Loading