diff --git a/docs/screenshots/map-redesign/01-default-map.png b/docs/screenshots/map-redesign/01-default-map.png new file mode 100644 index 00000000..47189020 Binary files /dev/null and b/docs/screenshots/map-redesign/01-default-map.png differ diff --git a/docs/screenshots/map-redesign/02-layers-panel-open.png b/docs/screenshots/map-redesign/02-layers-panel-open.png new file mode 100644 index 00000000..111f0f7a Binary files /dev/null and b/docs/screenshots/map-redesign/02-layers-panel-open.png differ diff --git a/docs/screenshots/map-redesign/03-basemaps-panel-open.png b/docs/screenshots/map-redesign/03-basemaps-panel-open.png new file mode 100644 index 00000000..3e81e7fc Binary files /dev/null and b/docs/screenshots/map-redesign/03-basemaps-panel-open.png differ diff --git a/docs/screenshots/map-redesign/04-basemap-switched.png b/docs/screenshots/map-redesign/04-basemap-switched.png new file mode 100644 index 00000000..c3a35314 Binary files /dev/null and b/docs/screenshots/map-redesign/04-basemap-switched.png differ diff --git a/docs/screenshots/map-redesign/05-layer-toggled-off.png b/docs/screenshots/map-redesign/05-layer-toggled-off.png new file mode 100644 index 00000000..7cbf98f8 Binary files /dev/null and b/docs/screenshots/map-redesign/05-layer-toggled-off.png differ diff --git a/docs/screenshots/map-redesign/06-layer-removed.png b/docs/screenshots/map-redesign/06-layer-removed.png new file mode 100644 index 00000000..b6aaa1c5 Binary files /dev/null and b/docs/screenshots/map-redesign/06-layer-removed.png differ diff --git a/docs/screenshots/map-redesign/07-catalog-page.png b/docs/screenshots/map-redesign/07-catalog-page.png new file mode 100644 index 00000000..60c0a6c8 Binary files /dev/null and b/docs/screenshots/map-redesign/07-catalog-page.png differ diff --git a/framework/SimpleModule.Hosting/CspOptions.cs b/framework/SimpleModule.Hosting/CspOptions.cs new file mode 100644 index 00000000..e187ebe4 --- /dev/null +++ b/framework/SimpleModule.Hosting/CspOptions.cs @@ -0,0 +1,24 @@ +namespace SimpleModule.Hosting; + +/// +/// Configurable Content Security Policy directives. Modules that need external +/// resources (tile servers, CDNs) can append origins at startup via +/// builder.AddSimpleModule(o => o.Csp.ConnectSources.Add("https://tiles.example.com")). +/// +public class CspOptions +{ + /// Extra origins appended to connect-src. + public List ConnectSources { get; } = []; + + /// Extra origins appended to img-src. + public List ImgSources { get; } = []; + + /// Extra origins appended to worker-src. + public List WorkerSources { get; } = []; + + /// Extra origins appended to font-src. + public List FontSources { get; } = []; + + /// Extra origins appended to style-src. + public List StyleSources { get; } = []; +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 12bae494..3d469556 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -169,6 +169,24 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.UseHttpsRedirection(); var isDevelopment = app.Environment.IsDevelopment(); + var cspOptions = options.Csp; + + // Directives never change after startup, so build everything except the + // per-request nonce once. Per request we only do a single concat. + var connectSrc = isDevelopment + ? $"'self' ws: wss: https: {string.Join(' ', cspOptions.ConnectSources)}" + : $"'self' https: {string.Join(' ', cspOptions.ConnectSources)}"; + + var cspPrefix = "default-src 'none'; script-src 'self' 'nonce-"; + var cspSuffix = + $"'; style-src 'self' 'unsafe-inline' fonts.googleapis.com rsms.me {string.Join(' ', cspOptions.StyleSources)}; " + + $"font-src 'self' fonts.gstatic.com rsms.me {string.Join(' ', cspOptions.FontSources)}; " + + $"worker-src 'self' blob: {string.Join(' ', cspOptions.WorkerSources)}; " + + $"connect-src {connectSrc}; " + + $"img-src 'self' data: https: {string.Join(' ', cspOptions.ImgSources)}; " + + "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';"; + var cspSuffixHttps = cspSuffix + " upgrade-insecure-requests;"; + app.Use( async (context, next) => { @@ -181,25 +199,11 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) headers["X-Frame-Options"] = "SAMEORIGIN"; headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; headers["X-Permitted-Cross-Domain-Policies"] = "none"; - // In development, allow WebSocket connections for live reload - var connectSrc = isDevelopment ? "'self' ws: wss:" : "'self'"; - var csp = - $"default-src 'none'; " - + $"script-src 'self' 'nonce-{nonce}'; " - + $"style-src 'self' 'unsafe-inline' fonts.googleapis.com rsms.me; " - + $"font-src 'self' fonts.gstatic.com rsms.me; " - + $"connect-src {connectSrc}; " - + $"img-src 'self' data:; " - + $"object-src 'none'; " - + $"base-uri 'self'; " - + $"form-action 'self'; " - + $"frame-ancestors 'none';"; - if (isHttps) - { - csp += " upgrade-insecure-requests;"; - } - - headers["Content-Security-Policy"] = csp; + headers["Content-Security-Policy"] = string.Concat( + cspPrefix, + nonce, + isHttps ? cspSuffixHttps : cspSuffix + ); return Task.CompletedTask; }); await next(); diff --git a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs index bad16b77..5cdadce9 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs @@ -14,6 +14,12 @@ public class SimpleModuleOptions public bool EnableDevTools { get; set; } = true; + /// + /// Content Security Policy overrides. Modules can append extra origins for + /// directives like connect-src, img-src, etc. + /// + public CspOptions Csp { get; } = new(); + /// /// The detected database provider, set during startup validation. /// diff --git a/modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs b/modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs index f65f9de4..f012b6ef 100644 --- a/modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs +++ b/modules/Map/src/SimpleModule.Map/EntityConfigurations/BasemapConfiguration.cs @@ -6,6 +6,38 @@ namespace SimpleModule.Map.EntityConfigurations; public class BasemapConfiguration : IEntityTypeConfiguration { + /// + /// Fixed ids for the basemaps seeded via . + /// Reference these from other seeders that link back to this catalog. + /// + public static class SeedIds + { + public static readonly BasemapId MapLibreDemotiles = BasemapId.From( + new Guid("22222222-2222-2222-2222-000000000001") + ); + public static readonly BasemapId OpenFreeMapLiberty = BasemapId.From( + new Guid("22222222-2222-2222-2222-000000000002") + ); + public static readonly BasemapId OpenFreeMapPositron = BasemapId.From( + new Guid("22222222-2222-2222-2222-000000000003") + ); + public static readonly BasemapId OpenFreeMapBright = BasemapId.From( + new Guid("22222222-2222-2222-2222-000000000004") + ); + public static readonly BasemapId VersatilesColorful = BasemapId.From( + new Guid("22222222-2222-2222-2222-000000000005") + ); + + public static IReadOnlyList All { get; } = + [ + MapLibreDemotiles, + OpenFreeMapLiberty, + OpenFreeMapPositron, + OpenFreeMapBright, + VersatilesColorful, + ]; + } + public void Configure(EntityTypeBuilder builder) { builder.HasKey(b => b.Id); @@ -30,7 +62,7 @@ private static Basemap[] GenerateSeedBasemaps() [ new Basemap { - Id = BasemapId.From(new Guid("22222222-2222-2222-2222-000000000001")), + Id = SeedIds.MapLibreDemotiles, Name = "MapLibre Demotiles", Description = "Official MapLibre demo vector style. Free for development.", StyleUrl = "https://demotiles.maplibre.org/style.json", @@ -41,7 +73,7 @@ private static Basemap[] GenerateSeedBasemaps() }, new Basemap { - Id = BasemapId.From(new Guid("22222222-2222-2222-2222-000000000002")), + Id = SeedIds.OpenFreeMapLiberty, Name = "OpenFreeMap Liberty", Description = "OpenFreeMap free vector basemap, Liberty style.", StyleUrl = "https://tiles.openfreemap.org/styles/liberty", @@ -52,7 +84,7 @@ private static Basemap[] GenerateSeedBasemaps() }, new Basemap { - Id = BasemapId.From(new Guid("22222222-2222-2222-2222-000000000003")), + Id = SeedIds.OpenFreeMapPositron, Name = "OpenFreeMap Positron", Description = "OpenFreeMap free vector basemap, light Positron style.", StyleUrl = "https://tiles.openfreemap.org/styles/positron", @@ -63,7 +95,7 @@ private static Basemap[] GenerateSeedBasemaps() }, new Basemap { - Id = BasemapId.From(new Guid("22222222-2222-2222-2222-000000000004")), + Id = SeedIds.OpenFreeMapBright, Name = "OpenFreeMap Bright", Description = "OpenFreeMap free vector basemap, Bright style.", StyleUrl = "https://tiles.openfreemap.org/styles/bright", @@ -74,7 +106,7 @@ private static Basemap[] GenerateSeedBasemaps() }, new Basemap { - Id = BasemapId.From(new Guid("22222222-2222-2222-2222-000000000005")), + Id = SeedIds.VersatilesColorful, Name = "Versatiles Colorful", Description = "VersaTiles free OSM-based vector basemap, Colorful style.", StyleUrl = "https://tiles.versatiles.org/assets/styles/colorful/style.json", diff --git a/modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs b/modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs index 86ed76b6..41cc86bd 100644 --- a/modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs +++ b/modules/Map/src/SimpleModule.Map/EntityConfigurations/LayerSourceConfiguration.cs @@ -8,6 +8,35 @@ namespace SimpleModule.Map.EntityConfigurations; public class LayerSourceConfiguration : IEntityTypeConfiguration { + /// + /// Fixed ids for layer sources seeded via . + /// Reference these from other seeders that link back to this catalog. + /// + public static class SeedIds + { + public static readonly LayerSourceId OpenStreetMapXyz = LayerSourceId.From( + new Guid("11111111-1111-1111-1111-000000000001") + ); + public static readonly LayerSourceId TerrestrisOsmWms = LayerSourceId.From( + new Guid("11111111-1111-1111-1111-000000000002") + ); + public static readonly LayerSourceId TerrestrisTopoWms = LayerSourceId.From( + new Guid("11111111-1111-1111-1111-000000000003") + ); + public static readonly LayerSourceId MapLibreDemotilesVector = LayerSourceId.From( + new Guid("11111111-1111-1111-1111-000000000004") + ); + public static readonly LayerSourceId ProtomapsFirenzePmTiles = LayerSourceId.From( + new Guid("11111111-1111-1111-1111-000000000005") + ); + public static readonly LayerSourceId GeomaticoKrigingCog = LayerSourceId.From( + new Guid("11111111-1111-1111-1111-000000000006") + ); + public static readonly LayerSourceId MapLibreEarthquakesGeoJson = LayerSourceId.From( + new Guid("11111111-1111-1111-1111-000000000007") + ); + } + /// /// Toggles mapping of the spatial column. /// Defaults to false; flipped on by MapModule.ConfigureServices @@ -84,7 +113,7 @@ private static LayerSource[] GenerateSeedSources() // ── Raster basemaps (XYZ) ──────────────────────────────────────────── new LayerSource { - Id = LayerSourceId.From(new Guid("11111111-1111-1111-1111-000000000001")), + Id = SeedIds.OpenStreetMapXyz, Name = "OpenStreetMap (raster tiles)", Description = "Standard OSM raster tiles. Free for low-volume use; respect the OSMF tile usage policy.", @@ -102,7 +131,7 @@ private static LayerSource[] GenerateSeedSources() // ── WMS (terrestris demo, used in the official MapLibre WMS example) ─ new LayerSource { - Id = LayerSourceId.From(new Guid("11111111-1111-1111-1111-000000000002")), + Id = SeedIds.TerrestrisOsmWms, Name = "terrestris OSM-WMS", Description = "Public WMS by terrestris. Used in the official MapLibre 'Add a WMS source' example.", @@ -124,7 +153,7 @@ private static LayerSource[] GenerateSeedSources() }, new LayerSource { - Id = LayerSourceId.From(new Guid("11111111-1111-1111-1111-000000000003")), + Id = SeedIds.TerrestrisTopoWms, Name = "terrestris TOPO-WMS", Description = "terrestris topographic WMS overlay layer (transparent).", Type = LayerSourceType.Wms, @@ -146,7 +175,7 @@ private static LayerSource[] GenerateSeedSources() // ── Vector tiles (MapLibre demotiles) ──────────────────────────────── new LayerSource { - Id = LayerSourceId.From(new Guid("11111111-1111-1111-1111-000000000004")), + Id = SeedIds.MapLibreDemotilesVector, Name = "MapLibre demotiles (vector)", Description = "Official MapLibre demo MVT vector tileset. Free for development.", Type = LayerSourceType.VectorTile, @@ -163,7 +192,7 @@ private static LayerSource[] GenerateSeedSources() // ── PMTiles (Protomaps demo archive used in MapLibre PMTiles example) ─ new LayerSource { - Id = LayerSourceId.From(new Guid("11111111-1111-1111-1111-000000000005")), + Id = SeedIds.ProtomapsFirenzePmTiles, Name = "Protomaps Firenze (PMTiles)", Description = "Public PMTiles vector archive of Florence (ODbL). Used in the MapLibre PMTiles example.", @@ -183,7 +212,7 @@ private static LayerSource[] GenerateSeedSources() // ── COG (geomatico demo Cloud-Optimized GeoTIFF) ───────────────────── new LayerSource { - Id = LayerSourceId.From(new Guid("11111111-1111-1111-1111-000000000006")), + Id = SeedIds.GeomaticoKrigingCog, Name = "Geomatico kriging COG (demo)", Description = "Public Cloud-Optimized GeoTIFF demo from the maplibre-cog-protocol sample viewer.", @@ -199,7 +228,7 @@ private static LayerSource[] GenerateSeedSources() // ── GeoJSON (raw OSM Overpass-style demo: world airports subset) ───── new LayerSource { - Id = LayerSourceId.From(new Guid("11111111-1111-1111-1111-000000000007")), + Id = SeedIds.MapLibreEarthquakesGeoJson, Name = "MapLibre demotiles point sample (GeoJSON)", Description = "Small public GeoJSON FeatureCollection from the MapLibre demo assets.", diff --git a/modules/Map/src/SimpleModule.Map/MapService.cs b/modules/Map/src/SimpleModule.Map/MapService.cs index e2dd0f67..ad153f90 100644 --- a/modules/Map/src/SimpleModule.Map/MapService.cs +++ b/modules/Map/src/SimpleModule.Map/MapService.cs @@ -5,6 +5,7 @@ using SimpleModule.Core.Exceptions; using SimpleModule.Datasets.Contracts; using SimpleModule.Map.Contracts; +using SimpleModule.Map.EntityConfigurations; namespace SimpleModule.Map; @@ -193,6 +194,33 @@ public async Task GetDefaultMapAsync(CancellationToken ct = default) Pitch = Options.DefaultPitch, Bearing = Options.DefaultBearing, BaseStyleUrl = Options.BaseStyleUrl, + Basemaps = BasemapConfiguration + .SeedIds.All.Select((id, i) => new MapBasemap { BasemapId = id, Order = i }) + .ToList(), + Layers = + [ + new MapLayer + { + LayerSourceId = LayerSourceConfiguration.SeedIds.OpenStreetMapXyz, + Order = 0, + Visible = true, + Opacity = 1, + }, + new MapLayer + { + LayerSourceId = LayerSourceConfiguration.SeedIds.MapLibreEarthquakesGeoJson, + Order = 1, + Visible = true, + Opacity = 1, + }, + new MapLayer + { + LayerSourceId = LayerSourceConfiguration.SeedIds.TerrestrisOsmWms, + Order = 2, + Visible = false, + Opacity = 1, + }, + ], }; db.SavedMaps.Add(seed); diff --git a/modules/Map/src/SimpleModule.Map/Pages/Browse.tsx b/modules/Map/src/SimpleModule.Map/Pages/Browse.tsx index 5ad57c89..8219a5c6 100644 --- a/modules/Map/src/SimpleModule.Map/Pages/Browse.tsx +++ b/modules/Map/src/SimpleModule.Map/Pages/Browse.tsx @@ -1,14 +1,6 @@ import { router } from '@inertiajs/react'; import { Button, - Card, - CardContent, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, - Container, - Field, - FieldGroup, Input, Label, Select, @@ -19,7 +11,7 @@ import { Switch, } from '@simplemodule/ui'; import type { Map as MapLibreMap } from 'maplibre-gl'; -import { useMemo, useRef, useState } from 'react'; +import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'; import type { Basemap, LayerSource, @@ -30,9 +22,26 @@ import type { } from '@/types'; import MapCanvas from './components/MapCanvas'; -// Moves the item at `idx` by `delta` positions, rewriting `.order` on every -// element so it matches its new array index. Returns a new array; returns the -// original reference when the move would go out of bounds. +// Inline styles for positioning — deliberately NOT Tailwind utility classes. +// The Tailwind utilities top-3/bottom-3/right-3/etc. aren't always generated +// by the host's CSS pipeline when only module .tsx sources change, so the map +// layout regressed in CI. Inline styles make the positioning survive that. +const controlOverlayStyle: CSSProperties = { + position: 'absolute', + inset: 0, + zIndex: 1000, + pointerEvents: 'none', +}; + +const floatingControlStyle: CSSProperties = { + position: 'absolute', + pointerEvents: 'auto', +}; + +const floatingPanelStyle: CSSProperties = { + ...floatingControlStyle, +}; + function reorder(items: T[], idx: number, delta: number): T[] { const target = idx + delta; if (target < 0 || target >= items.length) return items; @@ -75,11 +84,43 @@ export default function Browse({ const [pickerDatasetId, setPickerDatasetId] = useState(''); const [datasetsLoaded, setDatasetsLoaded] = useState(false); const [saving, setSaving] = useState(false); - const [panelOpen, setPanelOpen] = useState(true); - const [advancedOpen, setAdvancedOpen] = useState(false); + + // Single-value state enforces mutual exclusion — two panels cannot be open at once. + const [openPanel, setOpenPanel] = useState<'layers' | 'basemaps' | null>(null); + const layersPanelOpen = openPanel === 'layers'; + const basemapsPanelOpen = openPanel === 'basemaps'; const mapInstanceRef = useRef(null); + // The map fills the viewport minus whatever the current layout puts around it: + // the public layout has only a top nav; the authenticated layout adds a left + // sidebar that can be collapsed. Re-measure on resize and sidebar transitions. + const [insets, setInsets] = useState({ top: 0, left: 0 }); + useEffect(() => { + const measure = () => { + const nav = document.querySelector('nav.sticky') as HTMLElement | null; + const mobileHeader = document.querySelector('.app-mobile-header') as HTMLElement | null; + const sidebar = document.querySelector('.app-sidebar') as HTMLElement | null; + const sidebarRect = sidebar?.getBoundingClientRect(); + setInsets((prev) => { + const next = { + top: nav?.offsetHeight ?? mobileHeader?.offsetHeight ?? 0, + left: sidebarRect && sidebarRect.right > 0 ? sidebarRect.right : 0, + }; + return prev.top === next.top && prev.left === next.left ? prev : next; + }); + }; + measure(); + window.addEventListener('resize', measure); + const sidebar = document.querySelector('.app-sidebar'); + const observer = sidebar ? new ResizeObserver(measure) : null; + if (sidebar && observer) observer.observe(sidebar); + return () => { + window.removeEventListener('resize', measure); + observer?.disconnect(); + }; + }, []); + const basemapById = useMemo(() => new Map(basemaps.map((b) => [b.id, b])), [basemaps]); const sourceById = useMemo( () => new Map(availableSources.map((s) => [s.id, s])), @@ -93,8 +134,6 @@ export default function Browse({ .filter((b): b is Basemap => Boolean(b)); }, [mapBasemaps, basemapById]); - // Local selection for the floating basemap chip switcher; does not persist. - // The persisted default is the first entry in mapBasemaps. const [activeBasemapId, setActiveBasemapId] = useState( () => availableBasemaps[0]?.id, ); @@ -175,7 +214,6 @@ export default function Browse({ async function handleSave() { setSaving(true); try { - // Read the current viewport from MapLibre so panning/zooming is persisted. const live = mapInstanceRef.current; const center = live?.getCenter(); const body: UpdateDefaultMapRequest = { @@ -218,313 +256,298 @@ export default function Browse({ const visibleLayerCount = layers.filter((l) => l.visible).length; return ( - -
-
-

Default map

-

- {visibleLayerCount} of {layers.length} layer{layers.length === 1 ? '' : 's'} visible - {availableBasemaps.length > 0 && ` · ${availableBasemaps.length} basemap(s)`} -

+
+ { + mapInstanceRef.current = m; + }} + /> + +
+
+ +
-
+ +
- -
-
-
- {panelOpen && ( - - )} - -
- { - mapInstanceRef.current = m; - }} - /> - -
- {visibleLayerCount} / {layers.length} layer{layers.length === 1 ? '' : 's'} - {activeBasemap && ` · ${activeBasemap.name}`} + ); + })} +
+ + +
+
+ + +
+ )} - {layers.length === 0 && availableBasemaps.length === 0 && ( -
-
-
This map is empty
-
- Add a basemap or layer from the side panel to get started. -
+ {/* ── Basemaps floating panel ── */} + {basemapsPanelOpen && ( +
+
Basemaps ({mapBasemaps.length})
+ {mapBasemaps.length === 0 && ( +
+ No basemaps yet. The fallback style URL will be used.
+ )} + {mapBasemaps.map((mb, idx) => { + const def = basemapById.get(mb.basemapId); + const isActive = def?.id === activeBasemapId; + return ( +
+
+ {def?.name ?? 'Unknown basemap'} + {idx === 0 && (default)} +
+
+ + + +
+
+ ); + })} +
+ +
- )} - {availableBasemaps.length > 1 && ( -
- {availableBasemaps.map((b) => ( - - ))} +
+ + setStyleUrl(e.currentTarget.value)} + className="mt-1" + />
- )} +
+ )} - {enableExportPng && ( -
- -
- )} -
+ ))} +
+ )} + + {/* ── Bottom-right: export ── */} + {enableExportPng && ( +
+ +
+ )}
- + {/* end control overlay */} +
); } diff --git a/modules/Map/src/SimpleModule.Map/Pages/lib/layer-builders.ts b/modules/Map/src/SimpleModule.Map/Pages/lib/layer-builders.ts index fc18b68e..a8f6af4e 100644 --- a/modules/Map/src/SimpleModule.Map/Pages/lib/layer-builders.ts +++ b/modules/Map/src/SimpleModule.Map/Pages/lib/layer-builders.ts @@ -23,6 +23,11 @@ export type BuiltLayer = { layer: LayerSpecification; }; +/** Build a partial object, omitting keys whose values are null or undefined. */ +function defined>(obj: T): Partial { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null)) as Partial; +} + function buildWmsTileUrl(base: string, meta: Record): string { const sep = base.includes('?') ? '&' : '?'; const params = new URLSearchParams({ @@ -65,9 +70,11 @@ export function buildMapLibreLayer(source: LayerSource, composition: MapLayer): type: 'raster', tiles: [tileUrl], tileSize: 256, - attribution: source.attribution ?? undefined, - minzoom: source.minZoom ?? undefined, - maxzoom: source.maxZoom ?? undefined, + ...defined({ + attribution: source.attribution, + minzoom: source.minZoom, + maxzoom: source.maxZoom, + }), } as SourceSpecification, layer: { id: layerId, @@ -86,9 +93,11 @@ export function buildMapLibreLayer(source: LayerSource, composition: MapLayer): type: 'raster', tiles: [source.url], tileSize: 256, - attribution: source.attribution ?? undefined, - minzoom: source.minZoom ?? undefined, - maxzoom: source.maxZoom ?? undefined, + ...defined({ + attribution: source.attribution, + minzoom: source.minZoom, + maxzoom: source.maxZoom, + }), } as SourceSpecification, layer: { id: layerId, @@ -105,9 +114,11 @@ export function buildMapLibreLayer(source: LayerSource, composition: MapLayer): source: { type: 'vector', tiles: [source.url], - attribution: source.attribution ?? undefined, - minzoom: source.minZoom ?? undefined, - maxzoom: source.maxZoom ?? undefined, + ...defined({ + attribution: source.attribution, + minzoom: source.minZoom, + maxzoom: source.maxZoom, + }), } as SourceSpecification, layer: { id: layerId, @@ -127,7 +138,7 @@ export function buildMapLibreLayer(source: LayerSource, composition: MapLayer): source: { type: 'geojson', data: source.url, - attribution: source.attribution ?? undefined, + ...defined({ attribution: source.attribution }), } as SourceSpecification, layer: { id: layerId, @@ -149,7 +160,7 @@ export function buildMapLibreLayer(source: LayerSource, composition: MapLayer): source: { type: isVector ? 'vector' : 'raster', url: `pmtiles://${source.url}`, - attribution: source.attribution ?? undefined, + ...defined({ attribution: source.attribution }), } as SourceSpecification, layer: isVector ? ({ @@ -176,7 +187,7 @@ export function buildMapLibreLayer(source: LayerSource, composition: MapLayer): type: 'raster', url: `cog://${source.url}`, tileSize: 256, - attribution: source.attribution ?? undefined, + ...defined({ attribution: source.attribution }), } as SourceSpecification, layer: { id: layerId, diff --git a/tests/e2e/pages/map/browse.page.ts b/tests/e2e/pages/map/browse.page.ts index 894ea959..c20bb1cd 100644 --- a/tests/e2e/pages/map/browse.page.ts +++ b/tests/e2e/pages/map/browse.page.ts @@ -7,16 +7,24 @@ export class MapBrowsePage { await this.page.goto('/map'); } - get heading() { - return this.page.getByRole('heading', { name: /default map/i }); + get layersToggle() { + return this.page.getByTestId('layers-toggle'); } - get manageCatalogButton() { - return this.page.getByRole('button', { name: /manage catalog/i }); + get basemapsToggle() { + return this.page.getByTestId('basemaps-toggle'); + } + + get layersPanel() { + return this.page.getByTestId('layers-panel'); } - get sidePanel() { - return this.page.locator('[data-testid="map-side-panel"]'); + get basemapsPanel() { + return this.page.getByTestId('basemaps-panel'); + } + + get manageCatalogButton() { + return this.page.getByRole('button', { name: /manage catalog/i }); } get saveButton() { diff --git a/tests/e2e/tests/flows/map-crud.spec.ts b/tests/e2e/tests/flows/map-crud.spec.ts index 75626a88..c710b9cd 100644 --- a/tests/e2e/tests/flows/map-crud.spec.ts +++ b/tests/e2e/tests/flows/map-crud.spec.ts @@ -113,7 +113,7 @@ test.describe('Map CRUD flows', () => { // UI: browse page renders the default map shell const browse = new MapBrowsePage(page); await browse.goto(); - await expect(browse.heading).toBeVisible(); + await expect(browse.layersToggle).toBeVisible(); }); test('add layer source via UI dialog', async ({ page, request }) => { diff --git a/tests/e2e/tests/smoke/map.spec.ts b/tests/e2e/tests/smoke/map.spec.ts index 3ac7184e..4686ee9a 100644 --- a/tests/e2e/tests/smoke/map.spec.ts +++ b/tests/e2e/tests/smoke/map.spec.ts @@ -3,10 +3,11 @@ import { MapBrowsePage } from '../../pages/map/browse.page'; import { MapLayersPage } from '../../pages/map/layers.page'; test.describe('Map pages', () => { - test('browse page loads', async ({ page }) => { + test('browse page loads with layer and basemap toggles', async ({ page }) => { const browse = new MapBrowsePage(page); await browse.goto(); - await expect(browse.heading).toBeVisible(); + await expect(browse.layersToggle).toBeVisible(); + await expect(browse.basemapsToggle).toBeVisible(); }); test('browse page shows manage-catalog and save actions', async ({ page }) => { @@ -16,10 +17,18 @@ test.describe('Map pages', () => { await expect(browse.saveButton).toBeVisible(); }); - test('browse page shows configuration side panel', async ({ page }) => { + test('clicking layers toggle opens the layers panel', async ({ page }) => { const browse = new MapBrowsePage(page); await browse.goto(); - await expect(browse.sidePanel).toBeVisible(); + await browse.layersToggle.click(); + await expect(browse.layersPanel).toBeVisible(); + }); + + test('clicking basemaps toggle opens the basemaps panel', async ({ page }) => { + const browse = new MapBrowsePage(page); + await browse.goto(); + await browse.basemapsToggle.click(); + await expect(browse.basemapsPanel).toBeVisible(); }); test('layers page loads', async ({ page }) => {