From 5d88b2cbff70542a03351f563a723530376d86ad Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:33:08 +0300 Subject: [PATCH 01/12] feat: add geometry viewer tile provider preferences --- .../sqleditor/utils/query_tool_preferences.py | 63 ++++++++++++++++++- web/pgadmin/utils/constants.py | 1 + 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index a18edb13843..4eb0d75c4e2 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -12,7 +12,7 @@ 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_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,64 @@ 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' + }, + 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') From e3aa0ca1065df410658d1dc594a701a377c1d4d2 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:33:20 +0300 Subject: [PATCH 02/12] feat: support custom geometry viewer tile layers --- .../js/components/sections/GeometryViewer.jsx | 102 ++++++--------- .../sections/GeometryViewerUtils.js | 120 ++++++++++++++++++ 2 files changed, 158 insertions(+), 64 deletions(-) create mode 100644 web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js 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..41301bd6281 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..66c25357d7c --- /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: (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; +} From ab2db7ea5111a5a61793cc3e7956a7344395701b Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:33:34 +0300 Subject: [PATCH 03/12] test: cover geometry viewer tile provider helpers --- .../sqleditor/GeometryViewerUtils.spec.js | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js diff --git a/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js new file mode 100644 index 00000000000..536aa170bb9 --- /dev/null +++ b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js @@ -0,0 +1,133 @@ +///////////////////////////////////////////////////////////// +// +// 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'); + }); + }); + + 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']); + }); + }); +}); From 3983559d9d244f5a735a768cc5732b144e92fd85 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:33:48 +0300 Subject: [PATCH 04/12] docs: document geometry viewer tile provider settings --- docs/en_US/editgrid.rst | 5 +++++ docs/en_US/preferences.rst | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) 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 From feb92226a0e4db8de291723b5c0a275697dd2323 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:34:59 +0300 Subject: [PATCH 05/12] fix: sanitize custom tile provider name --- .../static/js/components/sections/GeometryViewerUtils.js | 2 +- .../javascript/sqleditor/GeometryViewerUtils.spec.js | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js index 66c25357d7c..bf84e7ec846 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewerUtils.js @@ -31,7 +31,7 @@ export function getCustomTileProvider(prefs) { const crs = prefs.custom_tile_crs || 'EPSG:3857'; return { url: url, - name: (prefs.custom_tile_name ?? '').trim() || gettext('Custom'), + 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, diff --git a/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js index 536aa170bb9..9868d8790e6 100644 --- a/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js +++ b/web/regression/javascript/sqleditor/GeometryViewerUtils.spec.js @@ -69,6 +69,14 @@ describe('GeometryViewerUtils', ()=>{ }); 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', ()=>{ From 8380aebf277ac33fb695da4d7d670b3ce01df523 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:02:19 +0300 Subject: [PATCH 06/12] fix: show custom tile warning inline --- .../js/components/sections/GeometryViewer.jsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) 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 41301bd6281..ce07ece088b 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx @@ -19,6 +19,7 @@ import Theme from 'sources/Theme'; import PropTypes from 'prop-types'; import { Box } from '@mui/material'; import EmptyPanelMessage from '../../../../../../static/js/components/EmptyPanelMessage'; +import { NotifierMessage, MESSAGE_TYPE } from '../../../../../../static/js/components/FormComponents'; import { PANELS } from '../QueryToolConstants'; import { QueryToolContext } from '../QueryToolComponent'; import usePreferences from '../../../../../../preferences/static/js/store'; @@ -289,6 +290,10 @@ GeoJsonLayer.propTypes = { }; function TheMap({data, customTileProvider}) { + // customTileProvider is passed separately (not via data.infoList) so an + // invalid tile URL only shows a small inline warning near the layers + // control instead of the full-panel EmptyPanelMessage overlay, which must + // stay reserved for genuinely empty results. const mapObj = useMap(); const resetLayersKey = useRef(0); const zoomControlWithHome = useRef(null); @@ -377,6 +382,20 @@ function TheMap({data, customTileProvider}) { ))} } + {data.selectedSRID === 4326 && customTileProvider?.invalid && + + } ); @@ -420,12 +439,8 @@ export function GeometryViewer({rows, columns, column}) { : [gettext('No spatial data found. At least one geometry or geography column is required for visualization.')], }; } - 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]); + return parseData(rows, columns, column); + }, [rows, columns, column, currentColumnKey]); useEffect(()=>{ let timeoutId; From 974d9eee00c83766255a9ffe3db32781f658cef2 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:20:05 +0300 Subject: [PATCH 07/12] fix: allow longer tile provider URLs --- .../static/js/components/PreferencesHelper.jsx | 2 +- .../tools/sqleditor/utils/query_tool_preferences.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx index 122fdf9d2bb..e600f404654 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 ?? {}; 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.'); @@ -102,7 +103,6 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) { addBinaryPathNote = true; } } else if (type === 'select') { - element.controlProps = element.control_props ?? {}; fieldValues[element.id] = element.value; if (element.name === 'theme') { diff --git a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py index 4eb0d75c4e2..0f828999bb2 100644 --- a/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py +++ b/web/pgadmin/tools/sqleditor/utils/query_tool_preferences.py @@ -9,9 +9,9 @@ """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,\ +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 @@ -829,7 +829,8 @@ def register_query_tool_preferences(self): gettext("Custom tile provider URL"), 'text', '', category_label=PREF_LABEL_GEOMETRY_VIEWER, control_props={ - 'placeholder': 'https://{s}.tile.example.com/{z}/{x}/{y}.png' + '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. ' From 5cc2441f808def749234ee1f47f3e806ccdd3617 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:20:18 +0300 Subject: [PATCH 08/12] fix: stabilize geometry base layer keys --- .../js/components/sections/GeometryViewer.jsx | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) 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 ce07ece088b..642446c3380 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GeometryViewer.jsx @@ -19,7 +19,6 @@ import Theme from 'sources/Theme'; import PropTypes from 'prop-types'; import { Box } from '@mui/material'; import EmptyPanelMessage from '../../../../../../static/js/components/EmptyPanelMessage'; -import { NotifierMessage, MESSAGE_TYPE } from '../../../../../../static/js/components/FormComponents'; import { PANELS } from '../QueryToolConstants'; import { QueryToolContext } from '../QueryToolComponent'; import usePreferences from '../../../../../../preferences/static/js/store'; @@ -290,10 +289,6 @@ GeoJsonLayer.propTypes = { }; function TheMap({data, customTileProvider}) { - // customTileProvider is passed separately (not via data.infoList) so an - // invalid tile URL only shows a small inline warning near the layers - // control instead of the full-panel EmptyPanelMessage overlay, which must - // stay reserved for genuinely empty results. const mapObj = useMap(); const resetLayersKey = useRef(0); const zoomControlWithHome = useRef(null); @@ -372,7 +367,7 @@ function TheMap({data, customTileProvider}) { {data.selectedSRID === 4326 && {getBaseLayers(customTileProvider).map((layer)=>( - + ))} } - {data.selectedSRID === 4326 && customTileProvider?.invalid && - - } ); @@ -439,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; From 69726fb460af8ff0cf10bd1d75b3fb9a09058dcb Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:38:10 +0300 Subject: [PATCH 09/12] fix: preserve preference control handlers --- .../preferences/static/js/components/PreferencesHelper.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx index e600f404654..f4b26d7d2ed 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx @@ -86,7 +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 = {...(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.'); From 641a06704dd7d2120f0ebc16b1e876b981c0f409 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:40:24 +0300 Subject: [PATCH 10/12] fix: handle malformed binary path preferences --- .../preferences/static/js/components/PreferencesHelper.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx index f4b26d7d2ed..797b15aaa50 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx @@ -97,7 +97,11 @@ 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; From 9dae1afc912f814324849cef29b924f21b0bf58e Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:41:52 +0300 Subject: [PATCH 11/12] fix: preserve cleared keyboard shortcuts --- .../preferences/static/js/components/PreferencesHelper.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx index 797b15aaa50..36ff5645d23 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx @@ -232,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('|'); From 15cece548c7403c779b79f47d30b55c117b73a82 Mon Sep 17 00:00:00 2001 From: Niv Greenstein <88280771+NivGreenstein@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:44:16 +0300 Subject: [PATCH 12/12] fix: await preference reset follow-up actions --- .../static/js/components/PreferencesHelper.jsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx index 36ff5645d23..170cb6ec7af 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx @@ -334,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.'));