diff --git a/docs/en_US/editgrid.rst b/docs/en_US/editgrid.rst index b72e3aed073..65ddfc39f43 100644 --- a/docs/en_US/editgrid.rst +++ b/docs/en_US/editgrid.rst @@ -101,6 +101,11 @@ properties of the geometries directly in map, just click the specific geometry: viewer will render geometries with the same SRID in the map. If SRID=4326 the OSM tile layer will be added into the map. + - *Custom tile provider:* A custom map tile provider ({z}/{x}/{y} URL template, + in EPSG:3857, EPSG:4326 or EPSG:3395) can be configured as a base layer via + *Preferences > Query Tool > Geometry Viewer*. When configured, it is + selected as the default base layer. + - *Data size:* For performance reasons, the viewer will render no more than 100000 geometries, totaling up to 20MB. diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index 0fcf6413bd5..1d93a17c6b8 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -635,6 +635,33 @@ a graphical EXPLAIN. * When the *Verbose output?* switch is set to *True*, graphical explain details will include extended information about the query execution plan. +Use the fields on the *Geometry Viewer* panel to configure a custom map tile +provider used as a base layer when viewing geometry data. + +* Use the *Custom tile provider URL* field to specify the URL template of a + custom XYZ tile provider, e.g. + ``https://myserver.example.com/tiles/{z}/{x}/{y}.png``. The template must + contain the ``{x}``, ``{y}`` and ``{z}`` placeholders, and may contain + ``{s}`` for subdomains (a, b, c). Leave the field empty to disable the + custom tile provider. + +* Use the *Custom tile provider name* field to specify the display name of + the custom tile provider in the layer selector of the Geometry Viewer. + +* Use the *Custom tile provider CRS* field to specify the coordinate + reference system of the custom tile provider. If it is not EPSG:3857 + (Web Mercator), the built-in tile layers will be hidden as they cannot be + mixed with other coordinate systems. + +* Use the *Custom tile provider attribution* field to specify the + attribution text shown on the map. It may contain HTML links. + +* Use the *Custom tile provider max zoom* field to specify the maximum zoom + level of the custom tile provider. + +When a custom tile provider is configured, it is selected as the default +base layer of the Geometry Viewer. + .. image:: images/preferences_graph_visualiser.png :alt: Preferences sqleditor graph visualiser section :align: center diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx index 122fdf9d2bb..170cb6ec7af 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx @@ -86,6 +86,7 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) { // Ensure type is set after specific handling element.type = type; + element.controlProps = {...(element.control_props ?? {}), ...element.controlProps}; if (type === 'selectFile') { // Binary Path specific handling note = gettext('Enter the directory in which the psql, pg_dump, pg_dumpall, and pg_restore utilities can be found for the corresponding database server version. The default path will be used for server versions that do not have a path specified.'); @@ -96,13 +97,16 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) { element.canEdit = false; element.editable = false; element.disabled = true; // Binary paths are managed in a collection, not directly editable here - fieldValues[element.id] = JSON.parse(element.value); + try { + fieldValues[element.id] = JSON.parse(element.value); + } catch { + fieldValues[element.id] = []; + } if (!addBinaryPathNote) { // Add note only once for binary path section fieldItems.push(...getNoteField(node, subNode, nodeData, note)); addBinaryPathNote = true; } } else if (type === 'select') { - element.controlProps = element.control_props ?? {}; fieldValues[element.id] = element.value; if (element.name === 'theme') { @@ -228,7 +232,7 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) { element.editable = false; const storedValue = preferencesStore.getPreferences(node.label.toLowerCase(), element.name)?.value; - fieldValues[element.id] = storedValue || element.value; + fieldValues[element.id] = storedValue ?? element.value; } else if (type === 'threshold') { element.type = 'threshold'; const _val = element.value.split('|'); @@ -330,12 +334,11 @@ export function showResetPrefModal(api, pgAdmin, preferencesStore, onReset) { preferencesStore.cache(); // Refresh preferences cache onReset(); if (reloadNow) { - reloadPgAdmin(); + await reloadPgAdmin(); } else { - pgAdmin.Browser.tree.destroy().then(() => { - pgAdmin.Browser.Events.trigger('pgadmin-browser:tree:destroyed', undefined, undefined); - modalClose(); // Close modal after tree destruction if no full reload - }); + await pgAdmin.Browser.tree.destroy(); + pgAdmin.Browser.Events.trigger('pgadmin-browser:tree:destroyed', undefined, undefined); + modalClose(); // Close modal after tree destruction if no full reload } } catch (err) { pgAdmin.Browser.notifier.alert(err.response?.data || err.message || gettext('Failed to reset preferences.')); diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx index 6476aaa47a4..642446c3380 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx @@ -11,7 +11,7 @@ import { styled } from '@mui/material/styles'; import ReactDOMServer from 'react-dom/server'; import _ from 'lodash'; import { MapContainer, TileLayer, LayersControl, GeoJSON, useMap } from 'react-leaflet'; -import Leaflet, { CRS } from 'leaflet'; +import Leaflet from 'leaflet'; import {Geometry as WkxGeometry} from 'wkx'; import {Buffer} from 'buffer'; import gettext from 'sources/gettext'; @@ -21,6 +21,8 @@ import { Box } from '@mui/material'; import EmptyPanelMessage from '../../../../../../static/js/components/EmptyPanelMessage'; import { PANELS } from '../QueryToolConstants'; import { QueryToolContext } from '../QueryToolComponent'; +import usePreferences from '../../../../../../preferences/static/js/store'; +import { getCustomTileProvider, getMapCrs, getBaseLayers } from './GeometryViewerUtils'; const StyledBox = styled(Box)(({theme}) => ({ position: 'relative', @@ -286,7 +288,7 @@ GeoJsonLayer.propTypes = { setHomeCoordinates: PropTypes.func, }; -function TheMap({data}) { +function TheMap({data, customTileProvider}) { const mapObj = useMap(); const resetLayersKey = useRef(0); const zoomControlWithHome = useRef(null); @@ -364,62 +366,16 @@ function TheMap({data}) { )} {data.selectedSRID === 4326 && - - - - - - - - OpenStreetMap,' - + ' © SRTM,' - + ' © OpenTopoMap' - } - /> - - - OpenStreetMap,' - + ' © CartoDB' - } - subdomains='abcd' - /> - - - OpenStreetMap,' - + ' © CartoDB' - } - subdomains='abcd' - /> - - - OpenStreetMap,' - + ' © CartoDB' - } - subdomains='abcd' - /> - + {getBaseLayers(customTileProvider).map((layer)=>( + + + + ))} } @@ -432,6 +388,7 @@ TheMap.propTypes = { getPopupContent: PropTypes.func, infoList: PropTypes.array, }), + customTileProvider: PropTypes.object, }; @@ -440,6 +397,14 @@ export function GeometryViewer({rows, columns, column}) { const mapRef = React.useRef(); const contentRef = React.useRef(); const queryToolCtx = React.useContext(QueryToolContext); + // Subscribed to the preferences store so a preference change while the + // viewer tab is open takes effect immediately (the props are frozen at + // tab-open time, hence not passed from the parent). + const prefStore = usePreferences(); + + const customTileProvider = useMemo(() => { + return getCustomTileProvider(prefStore.getPreferencesForModule('sqleditor')); + }, [prefStore.version]); const currentColumnKey = useMemo(() => column?.key, [column]); @@ -455,8 +420,12 @@ export function GeometryViewer({rows, columns, column}) { : [gettext('No spatial data found. At least one geometry or geography column is required for visualization.')], }; } - return parseData(rows, columns, column); - }, [rows, columns, column, currentColumnKey]); + const parsed = parseData(rows, columns, column); + if (customTileProvider?.invalid) { + parsed.infoList.push(gettext('Custom tile provider URL is invalid and was ignored. It must start with http(s):// and contain the {x}, {y} and {z} placeholders.')); + } + return parsed; + }, [rows, columns, column, currentColumnKey, customTileProvider]); useEffect(()=>{ let timeoutId; @@ -477,18 +446,23 @@ export function GeometryViewer({rows, columns, column}) { }; }, [queryToolCtx]); - // Dynamic CRS is not supported. Use srid and column key as key and recreate the map on change + // Dynamic CRS and TileLayer options are not supported. Use srid, column + // key and the custom tile provider config as key and recreate the map on + // change. + const providerKey = customTileProvider && !customTileProvider.invalid + ? `${customTileProvider.crs}|${customTileProvider.url}|${customTileProvider.name}|${customTileProvider.maxZoom}|${customTileProvider.attribution}` + : 'default'; return ( - + - + ); diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js new file mode 100644 index 00000000000..bf84e7ec846 --- /dev/null +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js @@ -0,0 +1,120 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// +import { CRS } from 'leaflet'; +import DOMPurify from 'dompurify'; +import gettext from 'sources/gettext'; + +const DEFAULT_MAX_ZOOM = 18; +const OSM_ATTRIBUTION = '© OpenStreetMap'; +const CARTODB_ATTRIBUTION = OSM_ATTRIBUTION + + ', © CartoDB'; + +// Builds the custom tile provider config from the sqleditor module +// preferences, or null when the URL preference is blank (feature disabled). +// A non-blank but unusable URL yields {invalid: true} so the caller can +// surface a message and fall back to the default layers. +export function getCustomTileProvider(prefs) { + const url = (prefs?.custom_tile_url ?? '').trim(); + if (!url) { + return null; + } + if (!/^https?:\/\//.test(url) || !url.includes('{x}') + || !url.includes('{y}') || !url.includes('{z}')) { + return { invalid: true }; + } + const crs = prefs.custom_tile_crs || 'EPSG:3857'; + return { + url: url, + name: DOMPurify.sanitize((prefs.custom_tile_name ?? '').trim() || gettext('Custom')), + crs: crs, + attribution: DOMPurify.sanitize(prefs.custom_tile_attribution || ''), + maxZoom: prefs.custom_tile_max_zoom ?? DEFAULT_MAX_ZOOM, + isDefaultCrs: crs === 'EPSG:3857', + }; +} + +// Returns the Leaflet CRS the map must be created with. Tiles (and thus the +// custom provider) only apply to SRID 4326 data; anything else renders on a +// blank Cartesian plane as before. The provider CRS string is resolved +// dynamically against Leaflet's CRS namespace ('EPSG:4326' -> CRS.EPSG4326), +// falling back to Web Mercator for unknown codes. +export function getMapCrs(selectedSRID, provider) { + if (selectedSRID !== 4326) { + return CRS.Simple; + } + if (!provider || provider.invalid) { + return CRS.EPSG3857; + } + const crs = CRS[provider.crs.replace(':', '')]; + return crs?.code ? crs : CRS.EPSG3857; +} + +// Returns the base layer configs for the LayersControl. Built-in layers are +// all Web Mercator tiled, so they are only offered when no custom provider +// is set or the custom provider is Web Mercator too; a custom provider in +// any other CRS can only be combined with the empty layer. +export function getBaseLayers(provider) { + const hasProvider = Boolean(provider) && !provider.invalid; + const layers = [{ + name: gettext('Empty'), + url: '', + checked: false, + }]; + + if (!hasProvider || provider.isDefaultCrs) { + layers.push({ + name: gettext('Street'), + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + maxZoom: 19, + attribution: OSM_ATTRIBUTION, + checked: !hasProvider, + }, { + name: gettext('Topography'), + url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', + maxZoom: 17, + attribution: OSM_ATTRIBUTION + + ', © SRTM' + + ', © OpenTopoMap', + checked: false, + }, { + name: gettext('Gray Style'), + url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}{r}.png', + maxZoom: 19, + attribution: CARTODB_ATTRIBUTION, + subdomains: 'abcd', + checked: false, + }, { + name: gettext('Light Color'), + url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/rastertiles/voyager/{z}/{x}/{y}{r}.png', + maxZoom: 19, + attribution: CARTODB_ATTRIBUTION, + subdomains: 'abcd', + checked: false, + }, { + name: gettext('Dark Matter'), + url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}{r}.png', + maxZoom: 19, + attribution: CARTODB_ATTRIBUTION, + subdomains: 'abcd', + checked: false, + }); + } + + if (hasProvider) { + layers.push({ + name: provider.name, + url: provider.url, + maxZoom: provider.maxZoom, + attribution: provider.attribution, + checked: true, + }); + } + + return layers; +} diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index a18edb13843..0f828999bb2 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -9,10 +9,10 @@ """Register preferences for query tool""" from flask_babel import gettext -from pgadmin.utils.constants import PREF_LABEL_DISPLAY,\ - PREF_LABEL_KEYBOARD_SHORTCUTS, PREF_LABEL_EXPLAIN, PREF_LABEL_OPTIONS,\ - PREF_LABEL_CSV_TXT, PREF_LABEL_RESULTS_GRID,\ - PREF_LABEL_GRAPH_VISUALISER +from pgadmin.utils.constants import PREF_LABEL_DISPLAY, \ + PREF_LABEL_KEYBOARD_SHORTCUTS, PREF_LABEL_EXPLAIN, PREF_LABEL_OPTIONS, \ + PREF_LABEL_CSV_TXT, PREF_LABEL_RESULTS_GRID, \ + PREF_LABEL_GRAPH_VISUALISER, PREF_LABEL_GEOMETRY_VIEWER from pgadmin.utils import SHORTCUT_FIELDS as shortcut_fields from config import DATA_RESULT_ROWS_PER_PAGE @@ -823,3 +823,65 @@ def register_query_tool_preferences(self): 'limit may impact performance if charts are plotted ' 'with very high numbers of rows.') ) + + self.custom_tile_url = self.preference.register( + 'geometry_viewer', 'custom_tile_url', + gettext("Custom tile provider URL"), 'text', '', + category_label=PREF_LABEL_GEOMETRY_VIEWER, + control_props={ + 'placeholder': 'https://{s}.tile.example.com/{z}/{x}/{y}.png', + 'maxLength': 1024 + }, + help_str=gettext('URL template of a custom XYZ tile provider used ' + 'as a base layer in the Geometry Viewer, e.g. ' + 'https://myserver.example.com/tiles/{z}/{x}/{y}' + '.png. The template must contain the {x}, {y} and ' + '{z} placeholders, and may contain {s} for ' + 'subdomains (a, b, c). Leave empty to disable the ' + 'custom tile provider.'), + allow_blanks=True + ) + + self.custom_tile_name = self.preference.register( + 'geometry_viewer', 'custom_tile_name', + gettext("Custom tile provider name"), 'text', 'Custom', + category_label=PREF_LABEL_GEOMETRY_VIEWER, + help_str=gettext('Display name of the custom tile provider in the ' + 'layer selector of the Geometry Viewer.'), + allow_blanks=True + ) + + self.custom_tile_crs = self.preference.register( + 'geometry_viewer', 'custom_tile_crs', + gettext("Custom tile provider CRS"), 'options', 'EPSG:3857', + category_label=PREF_LABEL_GEOMETRY_VIEWER, + options=[{'label': gettext('EPSG:3857 (Web Mercator)'), + 'value': 'EPSG:3857'}, + {'label': gettext('EPSG:4326'), 'value': 'EPSG:4326'}, + {'label': gettext('EPSG:3395'), 'value': 'EPSG:3395'}], + control_props={ + 'allowClear': False, + 'tags': False + }, + help_str=gettext('Coordinate reference system of the custom tile ' + 'provider. If it is not EPSG:3857 (Web Mercator), ' + 'the built-in tile layers will be hidden as they ' + 'cannot be mixed with other coordinate systems.') + ) + + self.custom_tile_attribution = self.preference.register( + 'geometry_viewer', 'custom_tile_attribution', + gettext("Custom tile provider attribution"), 'text', '', + category_label=PREF_LABEL_GEOMETRY_VIEWER, + help_str=gettext('Attribution text shown on the map for the custom ' + 'tile provider. May contain HTML links.'), + allow_blanks=True + ) + + self.custom_tile_max_zoom = self.preference.register( + 'geometry_viewer', 'custom_tile_max_zoom', + gettext("Custom tile provider max zoom"), 'integer', 18, + min_val=0, max_val=25, + category_label=PREF_LABEL_GEOMETRY_VIEWER, + help_str=gettext('Maximum zoom level of the custom tile provider.') + ) diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py index d0567b39c2f..6b86c10480a 100644 --- a/web/pgadmin/utils/constants.py +++ b/web/pgadmin/utils/constants.py @@ -30,6 +30,7 @@ PREF_LABEL_TABS_SETTINGS = gettext('Tab settings') PREF_LABEL_REFRESH_RATES = gettext('Refresh rates') PREF_LABEL_GRAPH_VISUALISER = gettext('Graph Visualiser') +PREF_LABEL_GEOMETRY_VIEWER = gettext('Geometry Viewer') PREF_LABEL_USER_INTERFACE = gettext('User Interface') PREF_LABEL_FILE_DOWNLOADS = gettext('File Downloads') PREF_LABEL_AI = gettext('AI') diff --git a/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js new file mode 100644 index 00000000000..9868d8790e6 --- /dev/null +++ b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js @@ -0,0 +1,141 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { CRS } from 'leaflet'; +import { + getCustomTileProvider, getMapCrs, getBaseLayers, +} from '../../../pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils'; + +const VALID_URL = 'https://tiles.example.com/{z}/{x}/{y}.png'; + +describe('GeometryViewerUtils', ()=>{ + + describe('getCustomTileProvider', ()=>{ + it('returns null when no preferences or blank URL', ()=>{ + expect(getCustomTileProvider(undefined)).toBeNull(); + expect(getCustomTileProvider({})).toBeNull(); + expect(getCustomTileProvider({custom_tile_url: ''})).toBeNull(); + expect(getCustomTileProvider({custom_tile_url: ' '})).toBeNull(); + }); + + it('flags URLs that are not usable tile templates as invalid', ()=>{ + expect(getCustomTileProvider({custom_tile_url: 'ftp://x/{z}/{x}/{y}'})) + .toEqual({invalid: true}); + expect(getCustomTileProvider({custom_tile_url: 'tiles/{z}/{x}/{y}'})) + .toEqual({invalid: true}); + expect(getCustomTileProvider({custom_tile_url: 'https://x.com/{x}/{y}'})) + .toEqual({invalid: true}); + expect(getCustomTileProvider({custom_tile_url: 'https://x.com/{z}/{x}'})) + .toEqual({invalid: true}); + }); + + it('applies defaults for missing optional preferences', ()=>{ + const provider = getCustomTileProvider({custom_tile_url: VALID_URL}); + expect(provider).toEqual({ + url: VALID_URL, + name: 'Custom', + crs: 'EPSG:3857', + attribution: '', + maxZoom: 18, + isDefaultCrs: true, + }); + }); + + it('uses the configured values when present', ()=>{ + const provider = getCustomTileProvider({ + custom_tile_url: VALID_URL, + custom_tile_name: 'My Tiles', + custom_tile_crs: 'EPSG:4326', + custom_tile_attribution: '© Example', + custom_tile_max_zoom: 12, + }); + expect(provider.name).toBe('My Tiles'); + expect(provider.crs).toBe('EPSG:4326'); + expect(provider.maxZoom).toBe(12); + expect(provider.isDefaultCrs).toBe(false); + expect(provider.attribution).toContain('Example'); + }); + + it('sanitizes script content out of the attribution', ()=>{ + const provider = getCustomTileProvider({ + custom_tile_url: VALID_URL, + custom_tile_attribution: 'safe', + }); + expect(provider.attribution).toBe('safe'); + }); + + it('sanitizes script content out of the custom tile name', ()=>{ + const provider = getCustomTileProvider({ + custom_tile_url: VALID_URL, + custom_tile_name: 'safe', + }); + expect(provider.name).toBe('safe'); + }); + }); + + describe('getMapCrs', ()=>{ + it('always uses CRS.Simple for non-4326 data', ()=>{ + expect(getMapCrs(0, null)).toBe(CRS.Simple); + expect(getMapCrs(3857, getCustomTileProvider({custom_tile_url: VALID_URL}))) + .toBe(CRS.Simple); + }); + + it('defaults to Web Mercator without a usable provider', ()=>{ + expect(getMapCrs(4326, null)).toBe(CRS.EPSG3857); + expect(getMapCrs(4326, {invalid: true})).toBe(CRS.EPSG3857); + }); + + it('resolves the provider CRS dynamically from leaflet', ()=>{ + expect(getMapCrs(4326, {crs: 'EPSG:3857'})).toBe(CRS.EPSG3857); + expect(getMapCrs(4326, {crs: 'EPSG:4326'})).toBe(CRS.EPSG4326); + expect(getMapCrs(4326, {crs: 'EPSG:3395'})).toBe(CRS.EPSG3395); + }); + + it('falls back to Web Mercator for unknown CRS codes', ()=>{ + expect(getMapCrs(4326, {crs: 'EPSG:9999'})).toBe(CRS.EPSG3857); + // CRS.Simple exists in leaflet but has no code - not a tile CRS + expect(getMapCrs(4326, {crs: 'Simple'})).toBe(CRS.EPSG3857); + }); + }); + + describe('getBaseLayers', ()=>{ + it('returns the built-in layers with Street checked when no provider', ()=>{ + const layers = getBaseLayers(null); + expect(layers.map((l)=>l.name)).toEqual([ + 'Empty', 'Street', 'Topography', 'Gray Style', 'Light Color', + 'Dark Matter', + ]); + expect(layers.filter((l)=>l.checked).map((l)=>l.name)).toEqual(['Street']); + }); + + it('treats an invalid provider like no provider', ()=>{ + expect(getBaseLayers({invalid: true})).toEqual(getBaseLayers(null)); + }); + + it('appends a Web Mercator provider to the built-in layers, checked', ()=>{ + const layers = getBaseLayers(getCustomTileProvider({ + custom_tile_url: VALID_URL, + custom_tile_name: 'My Tiles', + })); + expect(layers.length).toBe(7); + expect(layers[6].name).toBe('My Tiles'); + expect(layers[6].url).toBe(VALID_URL); + expect(layers.filter((l)=>l.checked).map((l)=>l.name)).toEqual(['My Tiles']); + }); + + it('offers only Empty and the provider for non-mercator CRS', ()=>{ + const layers = getBaseLayers(getCustomTileProvider({ + custom_tile_url: VALID_URL, + custom_tile_crs: 'EPSG:4326', + })); + expect(layers.map((l)=>l.name)).toEqual(['Empty', 'Custom']); + expect(layers.filter((l)=>l.checked).map((l)=>l.name)).toEqual(['Custom']); + }); + }); +});