Compare commits

..

5 Commits

Author SHA1 Message Date
Evan Rusackas
922ab36b8b test(deckgl): add backward compatibility tests for point radius
Add tests to verify that existing charts without the new point_radius
and point_radius_units fields get deck.gl defaults (preserving their
rendering), while new charts get the control panel defaults.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-10 18:48:06 -07:00
Evan Rusackas
e39d1403ea fix(deckgl): use deck.gl defaults for backward compatibility
Use deck.gl's built-in defaults (getPointRadius=1, units='meters') as
runtime fallbacks instead of the control panel defaults. This preserves
rendering for existing charts that don't have the new point_radius and
point_radius_units fields saved.

New charts will still get the improved UX defaults (point_radius=10,
units='pixels') from the control panel.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-10 18:46:55 -07:00
Evan Rusackas
bb8e1f0cfb feat(deckgl): add point radius controls for GeoJSON layer
Adds new controls to configure point radius for GeoJSON features,
addressing issue #31176 where users couldn't set circle sizes below
100 meters or use pixel-based sizing.

New controls:
- Point Radius: Set the base radius value (default: 10)
- Point Radius Units: Choose between pixels, meters, or common units
  (default: pixels for intuitive screen-space sizing)
- Point Radius Scale: Multiplier for uniform scaling (default: 1)

The controls map directly to deck.gl's GeoJsonLayer API:
- getPointRadius, pointRadiusUnits, pointRadiusScale

Fixes #31176

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-10 18:46:55 -07:00
Richard Fogaca Nienkotter
046b1b61b3 fix(maps): preserve OSM styles and configurable renderer defaults (#40804) 2026-06-10 22:26:00 -03:00
Sam Firke
da9756ef14 chore(issue template): bump version numbers to reflect 6.1.0 released (#40479) 2026-06-10 20:37:45 -04:00
36 changed files with 2264 additions and 442 deletions

View File

@@ -41,8 +41,8 @@ body:
label: Superset version
options:
- master / latest-dev
- "6.1.0"
- "6.0.0"
- "5.0.0"
validations:
required: true
- type: dropdown

View File

@@ -24,6 +24,27 @@ assists people when migrating to a new version.
## Next
### Map chart renderer and OpenStreetMap migration behavior
The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on
the MapLibre-compatible path. Saved styles such as OpenStreetMap, `tile://`
tile templates, generic HTTPS style URLs, and charts without a saved style are
not reclassified as Mapbox during migration and do not require
`MAPBOX_API_KEY` only because of the migration.
Saved true Mapbox styles whose value starts with `mapbox://` remain
Mapbox-backed. If a Superset deployment does not configure `MAPBOX_API_KEY`,
those saved Mapbox charts keep the existing missing-key message instead of
silently falling back to MapLibre or another provider. In Explore, deck.gl and
point-cluster renderer controls preserve saved Mapbox state, but the Mapbox
choice is not available as a new working renderer without a configured key.
The MapLibre style choices include `Streets (OSM)`, backed by
`https://tile.openstreetmap.org/{z}/{x}/{y}.png`. This OpenStreetMap tile
service requires visible `© OpenStreetMap contributors` attribution and should
be used through normal browser map tile requests and caching; it is not intended
for bulk prefetch or offline tile downloads.
### Duration formatter precision
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.

View File

@@ -35,3 +35,4 @@ export * from './typedMemo';
export * from './html';
export * from './tooltip';
export * from './merge';
export * from './mapStyles';

View File

@@ -0,0 +1,260 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
getBootstrapDataFromDocument,
getDefaultMapRenderer,
getMapProviderMapStyle,
getMapboxApiKeyFromBootstrap,
getMapRendererOptions,
hasMapboxApiKey,
isRasterTileTemplate,
OSM_TILE_ATTRIBUTION,
OSM_TILE_STYLE_URL,
resolveMapStyle,
} from './mapStyles';
test('OSM style metadata uses the approved URL and attribution', () => {
expect(OSM_TILE_STYLE_URL).toBe(
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
);
expect(OSM_TILE_ATTRIBUTION).toBe('© OpenStreetMap contributors');
});
test('Mapbox key helpers report absence and presence from bootstrap data', () => {
expect(getMapboxApiKeyFromBootstrap({ common: { conf: {} } })).toBe('');
expect(hasMapboxApiKey({ common: { conf: {} } })).toBe(false);
expect(
getMapboxApiKeyFromBootstrap({
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
}),
).toBe('pk.test');
expect(
getMapboxApiKeyFromBootstrap({
common: { conf: { MAPBOX_API_KEY: ' pk.test ' } },
}),
).toBe('pk.test');
expect(hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: ' ' } } })).toBe(
false,
);
expect(
hasMapboxApiKey({ common: { conf: { MAPBOX_API_KEY: 'pk.test' } } }),
).toBe(true);
});
test('bootstrap data helper parses document data safely', () => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
})}'></div>`;
expect(getBootstrapDataFromDocument()).toEqual({
common: { conf: { MAPBOX_API_KEY: 'pk.document' } },
});
document.body.innerHTML = `<div id="app" data-bootstrap='not-json'></div>`;
expect(getBootstrapDataFromDocument()).toBeUndefined();
document.body.innerHTML = '';
expect(getBootstrapDataFromDocument()).toBeUndefined();
});
test('renderer options enable Mapbox only when a key is available', () => {
expect(getMapRendererOptions({ hasMapboxKey: true })).toEqual([
{ value: 'maplibre' },
{ value: 'mapbox' },
]);
expect(getMapRendererOptions({ hasMapboxKey: false })).toEqual([
{ value: 'maplibre' },
]);
});
test('renderer options preserve saved Mapbox without API-key labels', () => {
expect(
getMapRendererOptions({ hasMapboxKey: false, currentValue: 'mapbox' }),
).toEqual([{ value: 'maplibre' }, { value: 'mapbox', disabled: true }]);
});
test('map provider style helper preserves legacy non-Mapbox styles for MapLibre', () => {
expect(
getMapProviderMapStyle({
mapProvider: 'maplibre',
maplibreStyle: undefined,
mapboxStyle: OSM_TILE_STYLE_URL,
legacyMapStyle: 'https://example.com/fallback-style.json',
}),
).toEqual({
mapProvider: 'maplibre',
mapStyle: OSM_TILE_STYLE_URL,
});
});
test('map provider style helper does not send Mapbox URLs to MapLibre', () => {
expect(
getMapProviderMapStyle({
mapProvider: 'maplibre',
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
legacyMapStyle: 'https://example.com/fallback-style.json',
}),
).toEqual({
mapProvider: 'maplibre',
mapStyle: 'https://example.com/fallback-style.json',
});
});
test('map provider style helper uses Mapbox style when Mapbox is selected', () => {
expect(
getMapProviderMapStyle({
mapProvider: 'mapbox',
mapboxStyle: 'mapbox://styles/mapbox/dark-v11',
legacyMapStyle: 'https://example.com/fallback-style.json',
}),
).toEqual({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/dark-v11',
});
});
test('default renderer uses configured Mapbox only when a key is available', () => {
expect(
getDefaultMapRenderer({
common: {
conf: {
DEFAULT_MAP_RENDERER: 'mapbox',
MAPBOX_API_KEY: 'pk.test',
},
},
}),
).toBe('mapbox');
expect(
getDefaultMapRenderer({
common: { conf: { DEFAULT_MAP_RENDERER: 'mapbox' } },
}),
).toBe('maplibre');
expect(
getDefaultMapRenderer({
common: {
conf: {
DEFAULT_MAP_RENDERER: 'invalid',
MAPBOX_API_KEY: 'pk.test',
},
},
}),
).toBe('maplibre');
});
test('raster tile templates resolve to MapLibre raster style objects with attribution', () => {
const style = resolveMapStyle(OSM_TILE_STYLE_URL, 'default-style.json');
expect(style).toEqual({
version: 8,
sources: {
'osm-raster-tiles': {
type: 'raster',
tiles: [OSM_TILE_STYLE_URL],
tileSize: 256,
attribution: OSM_TILE_ATTRIBUTION,
},
},
layers: [
{
id: 'osm-raster-layer',
type: 'raster',
source: 'osm-raster-tiles',
minzoom: 0,
maxzoom: 22,
},
],
});
});
test('tile protocol raster templates are unwrapped before style resolution', () => {
const style = resolveMapStyle(
`tile://${OSM_TILE_STYLE_URL}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
OSM_TILE_STYLE_URL,
]);
expect(style.sources['osm-raster-tiles'].attribution).toBe(
OSM_TILE_ATTRIBUTION,
);
}
});
test('OpenStreetMap subdomain raster templates receive OSM attribution', () => {
const osmSubdomainTileUrl =
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png';
const style = resolveMapStyle(
`tile://${osmSubdomainTileUrl}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([
osmSubdomainTileUrl,
]);
expect(style.sources['osm-raster-tiles'].attribution).toBe(
OSM_TILE_ATTRIBUTION,
);
}
});
test('custom raster tile templates do not receive OSM attribution', () => {
const customTileUrl = 'https://tiles.example.com/{z}/{x}/{y}.png';
const style = resolveMapStyle(
`tile://${customTileUrl}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([customTileUrl]);
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
}
});
test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
const lookalikeTileUrl =
'https://openstreetmap.org.example.com/{z}/{x}/{y}.png';
const style = resolveMapStyle(
`tile://${lookalikeTileUrl}`,
'default-style.json',
);
expect(typeof style).toBe('object');
if (typeof style !== 'string') {
expect(style.sources['osm-raster-tiles'].tiles).toEqual([lookalikeTileUrl]);
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
}
});
test('style JSON URLs pass through without raster wrapping', () => {
const styleUrl = 'https://example.com/styles/custom-style.json';
expect(isRasterTileTemplate(undefined)).toBe(false);
expect(isRasterTileTemplate(styleUrl)).toBe(false);
expect(resolveMapStyle(styleUrl, 'default-style.json')).toBe(styleUrl);
expect(resolveMapStyle(undefined, 'default-style.json')).toBe(
'default-style.json',
);
});

View File

@@ -0,0 +1,251 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export type MapProvider = 'maplibre' | 'mapbox';
export type MapRendererOption = {
value: MapProvider;
disabled?: boolean;
};
export type MapProviderMapStyle = {
mapProvider?: unknown;
maplibreStyle?: unknown;
mapboxStyle?: unknown;
legacyMapStyle?: unknown;
};
export type SelectedMapProviderMapStyle = {
mapProvider: MapProvider;
mapStyle?: string;
};
export type RasterTileMapStyle = {
version: 8;
sources: {
[sourceId: string]: {
type: 'raster';
tiles: string[];
tileSize: 256;
attribution?: string;
};
};
layers: [
{
id: string;
type: 'raster';
source: string;
minzoom: 0;
maxzoom: 22;
},
];
};
export type ResolvedMapStyle = string | RasterTileMapStyle;
export const OSM_TILE_STYLE_URL =
'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
export const OSM_TILE_ATTRIBUTION = '© OpenStreetMap contributors';
export const MAPLIBRE_RENDERER_OPTION: MapRendererOption = {
value: 'maplibre',
};
export const MAPBOX_RENDERER_OPTION: MapRendererOption = {
value: 'mapbox',
};
export const DISABLED_MAPBOX_RENDERER_OPTION: MapRendererOption = {
...MAPBOX_RENDERER_OPTION,
disabled: true,
};
const TILE_PROTOCOL = 'tile://';
const RASTER_SOURCE_ID = 'osm-raster-tiles';
const RASTER_LAYER_ID = 'osm-raster-layer';
type BootstrapData = {
common?: {
conf?: {
DEFAULT_MAP_RENDERER?: unknown;
MAPBOX_API_KEY?: unknown;
};
};
};
export function getBootstrapDataFromDocument(): unknown {
if (typeof document === 'undefined') {
return undefined;
}
try {
const appContainer = document.getElementById('app');
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
return dataBootstrap ? JSON.parse(dataBootstrap) : undefined;
} catch {
return undefined;
}
}
export function getMapboxApiKeyFromBootstrap(
bootstrapData: unknown = getBootstrapDataFromDocument(),
): string {
const mapboxApiKey = (bootstrapData as BootstrapData | undefined)?.common
?.conf?.MAPBOX_API_KEY;
return typeof mapboxApiKey === 'string' ? mapboxApiKey.trim() : '';
}
export function hasMapboxApiKey(
bootstrapData: unknown = getBootstrapDataFromDocument(),
): boolean {
return getMapboxApiKeyFromBootstrap(bootstrapData).trim().length > 0;
}
export function getDefaultMapRenderer(
bootstrapData: unknown = getBootstrapDataFromDocument(),
): MapProvider {
const conf = (bootstrapData as BootstrapData | undefined)?.common?.conf;
const defaultRenderer = conf?.DEFAULT_MAP_RENDERER;
if (defaultRenderer === 'mapbox' && hasMapboxApiKey(bootstrapData)) {
return 'mapbox';
}
return 'maplibre';
}
export function getMapRendererOptions({
hasMapboxKey,
currentValue,
}: {
hasMapboxKey: boolean;
currentValue?: MapProvider;
}): MapRendererOption[] {
if (!hasMapboxKey && currentValue !== 'mapbox') {
return [MAPLIBRE_RENDERER_OPTION];
}
return [
MAPLIBRE_RENDERER_OPTION,
hasMapboxKey ? MAPBOX_RENDERER_OPTION : DISABLED_MAPBOX_RENDERER_OPTION,
];
}
function getNonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
function isMapboxStyle(value: unknown): boolean {
return getNonEmptyString(value)?.startsWith('mapbox://') ?? false;
}
export function getMapProviderMapStyle({
mapProvider,
maplibreStyle,
mapboxStyle,
legacyMapStyle,
}: MapProviderMapStyle): SelectedMapProviderMapStyle {
const selectedMapProvider: MapProvider =
mapProvider === 'mapbox' ? 'mapbox' : 'maplibre';
const maplibreStyleValue = getNonEmptyString(maplibreStyle);
const mapboxStyleValue = getNonEmptyString(mapboxStyle);
const legacyMapStyleValue = getNonEmptyString(legacyMapStyle);
if (selectedMapProvider === 'mapbox') {
return {
mapProvider: selectedMapProvider,
mapStyle: mapboxStyleValue ?? legacyMapStyleValue,
};
}
return {
mapProvider: selectedMapProvider,
mapStyle:
maplibreStyleValue ??
(isMapboxStyle(mapboxStyleValue) ? undefined : mapboxStyleValue) ??
legacyMapStyleValue,
};
}
function unwrapTileProtocol(value: string): string {
return value.startsWith(TILE_PROTOCOL)
? value.slice(TILE_PROTOCOL.length)
: value;
}
export function isRasterTileTemplate(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
const tileUrl = unwrapTileProtocol(value);
return ['{z}', '{x}', '{y}'].every(templateParam =>
tileUrl.includes(templateParam),
);
}
function isOpenStreetMapTileUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
return (
hostname === 'openstreetmap.org' ||
hostname.endsWith('.openstreetmap.org')
);
} catch {
return false;
}
}
export function buildRasterTileMapStyle(value: string): RasterTileMapStyle {
const tileUrl = unwrapTileProtocol(value);
const attribution = isOpenStreetMapTileUrl(tileUrl)
? { attribution: OSM_TILE_ATTRIBUTION }
: {};
return {
version: 8,
sources: {
[RASTER_SOURCE_ID]: {
type: 'raster',
tiles: [tileUrl],
tileSize: 256,
...attribution,
},
},
layers: [
{
id: RASTER_LAYER_ID,
type: 'raster',
source: RASTER_SOURCE_ID,
minzoom: 0,
maxzoom: 22,
},
],
};
}
export function resolveMapStyle(
value: string | undefined,
defaultStyle: string,
): ResolvedMapStyle {
if (!value) {
return defaultStyle;
}
return isRasterTileTemplate(value) ? buildRasterTileMapStyle(value) : value;
}

View File

@@ -20,6 +20,10 @@ import { memo, useCallback, useEffect, useState } from 'react';
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
import { Map as MapboxMap } from 'react-map-gl/mapbox';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import {
resolveMapStyle,
type ResolvedMapStyle,
} from '@superset-ui/core/utils/mapStyles';
import { useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import ScatterPlotOverlay from './components/ScatterPlotOverlay';
@@ -160,7 +164,10 @@ function MapLibre({
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
const theme = useTheme();
const resolvedMapStyle = mapStyle || DEFAULT_MAP_STYLE;
const resolvedMapStyle: ResolvedMapStyle =
mapProvider === 'mapbox'
? mapStyle || DEFAULT_MAP_STYLE
: resolveMapStyle(mapStyle, DEFAULT_MAP_STYLE);
const mapboxApiKey = mapProvider === 'mapbox' ? getMapboxApiKey() : '';
if (mapProvider === 'mapbox' && !mapboxApiKey) {

View File

@@ -19,11 +19,19 @@
import { t } from '@apache-superset/core/translation';
import {
columnChoices,
ControlPanelState,
ControlPanelConfig,
formatSelectOptions,
sharedControls,
getStandardizedControls,
} from '@superset-ui/chart-controls';
import type { QueryFormData } from '@superset-ui/core';
import type { MapProvider } from '@superset-ui/core/utils/mapStyles';
import { getDefaultMapRenderer } from '@superset-ui/core/utils/mapStyles';
import {
getPointClusterMapRendererProps,
POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES,
} from './utils/mapControls';
const columnsConfig = sharedControls.entity;
@@ -35,6 +43,11 @@ const colorChoices = [
['#dc143c', t('Crimson')],
['#228b22', t('Forest Green')],
];
type MapStyleVisibilityProps = {
controls?: {
map_renderer?: { value?: unknown };
};
};
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -109,7 +122,7 @@ const config: ControlPanelConfig = {
'Either a numerical column or `Auto`, which scales the point based ' +
'on the largest cluster',
),
mapStateToProps: (state: any) => {
mapStateToProps: (state: ControlPanelState) => {
const datasourceChoices = columnChoices(state.datasource);
const choices: [string, string][] = [['Auto', t('Auto')]];
return {
@@ -156,7 +169,7 @@ const config: ControlPanelConfig = {
'Non-numerical columns will be used to label points. ' +
'Leave empty to get a count of points in each cluster.',
),
mapStateToProps: (state: any) => ({
mapStateToProps: (state: ControlPanelState) => ({
choices: columnChoices(state.datasource),
}),
},
@@ -200,14 +213,17 @@ const config: ControlPanelConfig = {
label: t('Map Renderer'),
clearable: false,
renderTrigger: true,
choices: [
['maplibre', t('MapLibre (open-source)')],
['mapbox', t('Mapbox (API key required)')],
],
options: getPointClusterMapRendererProps().options,
default: 'maplibre',
description: t(
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
),
mapStateToProps: (state: ControlPanelState) => ({
...getPointClusterMapRendererProps(
state.form_data?.map_renderer as MapProvider | undefined,
),
default: getDefaultMapRenderer(),
}),
},
},
],
@@ -220,30 +236,13 @@ const config: ControlPanelConfig = {
clearable: false,
renderTrigger: true,
freeForm: true,
choices: [
[
'https://tiles.openfreemap.org/styles/liberty',
t('Liberty (OpenFreeMap)'),
],
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
t('Light (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
t('Dark (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
t('Streets (Carto)'),
],
],
choices: POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES,
default: 'https://tiles.openfreemap.org/styles/liberty',
description: t(
'Base layer map style. See MapLibre documentation: %s',
'https://maplibre.org/maplibre-style-spec/',
),
visibility: ({ controls }: any) =>
visibility: ({ controls }: MapStyleVisibilityProps) =>
controls?.map_renderer?.value !== 'mapbox',
},
},
@@ -272,7 +271,7 @@ const config: ControlPanelConfig = {
description: t(
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
),
visibility: ({ controls }: any) =>
visibility: ({ controls }: MapStyleVisibilityProps) =>
controls?.map_renderer?.value === 'mapbox',
},
},
@@ -387,7 +386,7 @@ const config: ControlPanelConfig = {
),
},
},
formDataOverrides: (formData: any) => ({
formDataOverrides: (formData: QueryFormData) => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
}),

View File

@@ -19,7 +19,7 @@
import Supercluster, {
type Options as SuperclusterOptions,
} from 'supercluster';
import { ChartProps } from '@superset-ui/core';
import { ChartProps, getMapProviderMapStyle } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapLibre';
import roundDecimal from './utils/roundDecimal';
@@ -152,6 +152,7 @@ export default function transformProps(chartProps: ChartProps) {
map_renderer: mapProvider,
maplibre_style: maplibreStyle,
mapbox_style: mapboxStyle = '',
map_style: legacyMapStyle,
pandas_aggfunc: pandasAggfunc,
point_radius: pointRadius,
point_radius_unit: pointRadiusUnit,
@@ -242,6 +243,12 @@ export default function transformProps(chartProps: ChartProps) {
const clusterer = new Supercluster<PointProperties, ClusterProperties>(opts);
// Disable strict typecheck on load since Supercluster typings have namespace issues with esModuleInterop
clusterer.load(geoJSON.features as any);
const selectedMap = getMapProviderMapStyle({
mapProvider,
maplibreStyle,
mapboxStyle,
legacyMapStyle,
});
return {
width,
@@ -251,11 +258,8 @@ export default function transformProps(chartProps: ChartProps) {
clusterer,
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
hasCustomMetric,
mapProvider,
mapStyle:
mapProvider === 'mapbox'
? (mapboxStyle as string)
: (maplibreStyle as string),
mapProvider: selectedMap.mapProvider,
mapStyle: selectedMap.mapStyle,
onViewportChange({
latitude,
longitude,

View File

@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import {
getMapRendererOptions,
OSM_TILE_STYLE_URL,
type MapRendererOption,
type MapProvider,
} from '@superset-ui/core/utils/mapStyles';
import { hasMapboxApiKey } from './mapbox';
export const POINT_CLUSTER_MAPLIBRE_STYLE_CHOICES = [
['https://tiles.openfreemap.org/styles/liberty', t('Liberty (OpenFreeMap)')],
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
t('Light (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
t('Dark (Carto)'),
],
[
'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
t('Streets (Carto)'),
],
[OSM_TILE_STYLE_URL, t('Streets (OSM)')],
];
export function getPointClusterMapRendererProps(currentValue?: MapProvider) {
const hasKey = hasMapboxApiKey();
return {
options: getMapRendererOptions({
hasMapboxKey: hasKey,
currentValue,
}).map((option: MapRendererOption) => ({
...option,
label:
option.value === 'maplibre'
? t('MapLibre (open-source)')
: t('Mapbox (API key required)'),
})),
};
}

View File

@@ -17,19 +17,15 @@
* under the License.
*/
import {
getMapboxApiKeyFromBootstrap,
hasMapboxApiKey as hasBootstrapMapboxApiKey,
} from '@superset-ui/core/utils/mapStyles';
export function getMapboxApiKey(): string {
if (typeof document === 'undefined') {
return '';
}
try {
const appContainer = document.getElementById('app');
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
if (dataBootstrap) {
const bootstrapData = JSON.parse(dataBootstrap);
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
}
} catch {
// If bootstrap data is unavailable or malformed, return empty string
}
return '';
return getMapboxApiKeyFromBootstrap();
}
export function hasMapboxApiKey(): boolean {
return hasBootstrapMapboxApiKey();
}

View File

@@ -18,7 +18,12 @@
*/
import { type ReactNode } from 'react';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import {
OSM_TILE_ATTRIBUTION,
OSM_TILE_STYLE_URL,
} from '@superset-ui/core/utils/mapStyles';
// Capture the most recent viewport props passed to the Map component
let lastMapProps: Record<string, unknown> = {};
@@ -91,6 +96,7 @@ const defaultProps = {
beforeEach(() => {
lastMapProps = {};
document.body.innerHTML = '';
jest.clearAllMocks();
mockFitBounds.mockImplementation(
(
@@ -183,6 +189,65 @@ test('passes globalOpacity to ScatterPlotOverlay', () => {
expect(overlay!.getAttribute('data-opacity')).toBe('0.5');
});
test('converts OSM raster tile templates into MapLibre style objects', () => {
render(<MapLibre {...defaultProps} mapStyle={OSM_TILE_STYLE_URL} />);
expect(lastMapProps.mapStyle).toEqual({
version: 8,
sources: {
'osm-raster-tiles': {
type: 'raster',
tiles: [OSM_TILE_STYLE_URL],
tileSize: 256,
attribution: OSM_TILE_ATTRIBUTION,
},
},
layers: [
{
id: 'osm-raster-layer',
type: 'raster',
source: 'osm-raster-tiles',
minzoom: 0,
maxzoom: 22,
},
],
});
});
test('keeps the missing Mapbox key signal for saved Mapbox charts', () => {
render(
<MapLibre
{...defaultProps}
mapProvider="mapbox"
mapStyle="mapbox://styles/mapbox/dark-v11"
/>,
);
expect(
screen.getByText(
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
),
).toBeInTheDocument();
expect(lastMapProps.mapStyle).toBeUndefined();
});
test('passes Mapbox styles through when a key exists', () => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf: { MAPBOX_API_KEY: 'pk.test' } },
})}'></div>`;
render(
<MapLibre
{...defaultProps}
mapProvider="mapbox"
mapStyle="mapbox://styles/mapbox/dark-v11"
/>,
);
expect(lastMapProps.mapStyle).toBe('mapbox://styles/mapbox/dark-v11');
expect(lastMapProps.mapboxAccessToken).toBe('pk.test');
});
test('handles undefined bounds gracefully', () => {
render(<MapLibre {...defaultProps} bounds={undefined} />);
expect(lastMapProps.longitude).toBe(0);

View File

@@ -17,9 +17,11 @@
* under the License.
*/
import type {
ControlPanelState,
ControlPanelConfig,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
import controlPanel from '../src/controlPanel';
type ControlConfig = Required<CustomControlItem['config']>;
@@ -54,6 +56,27 @@ function getControl(
return item;
}
type RendererControlConfig = ControlConfig & {
mapStateToProps: (state: ControlPanelState) => {
options?: unknown;
warning?: string;
default?: unknown;
};
};
const setBootstrap = (conf: Record<string, unknown>) => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf },
})}'></div>`;
};
const getMapRendererProps = (value?: string) =>
(
getControl(controlPanel, 'map_renderer').config as RendererControlConfig
).mapStateToProps({
form_data: { map_renderer: value },
} as unknown as ControlPanelState);
test('viewport controls default to empty values and rerender without query refresh', () => {
const longitudeControl = getControl(controlPanel, 'viewport_longitude');
const latitudeControl = getControl(controlPanel, 'viewport_latitude');
@@ -79,3 +102,63 @@ test('opacity control rerenders immediately when changed', () => {
expect(opacityControl.config.renderTrigger).toBe(true);
expect(opacityControl.config.isFloat).toBe(true);
});
test('MapLibre style choices expose Streets (OSM)', () => {
expect(
getControl(controlPanel, 'maplibre_style').config.choices,
).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
});
test('map renderer hides Mapbox when no key exists for new selections', () => {
setBootstrap({});
const props = getMapRendererProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
]);
});
test('map renderer keeps saved Mapbox visible while disabled without a key', () => {
setBootstrap({});
const props = getMapRendererProps('mapbox');
expect(props.options).toContainEqual({
value: 'mapbox',
label: 'Mapbox (API key required)',
disabled: true,
});
});
test('map renderer enables Mapbox when a key exists', () => {
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
const props = getMapRendererProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
{ value: 'mapbox', label: 'Mapbox (API key required)' },
]);
});
test('map renderer keeps the original explanatory description', () => {
expect(getControl(controlPanel, 'map_renderer').config.description).toBe(
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
);
});
test('map renderer defaults to configured Mapbox when a key exists', () => {
setBootstrap({
DEFAULT_MAP_RENDERER: 'mapbox',
MAPBOX_API_KEY: 'pk.test',
});
expect(getMapRendererProps('maplibre').default).toBe('mapbox');
});
test('map renderer falls back from configured Mapbox default without a key', () => {
setBootstrap({ DEFAULT_MAP_RENDERER: 'mapbox' });
expect(getMapRendererProps('maplibre').default).toBe('maplibre');
});

View File

@@ -34,6 +34,8 @@ import transformProps from '../src/transformProps';
type TransformPropsResult = {
globalOpacity?: number;
mapProvider?: string;
mapStyle?: string;
onViewportChange?: (viewport: {
latitude: number;
longitude: number;
@@ -215,6 +217,41 @@ test('passes through numeric values unchanged', () => {
expect(result.globalOpacity).toBe(0.8);
});
test('uses the MapLibre style when maplibre renderer is selected', () => {
const result = getTransformPropsResult({
map_renderer: 'maplibre',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
});
expect(result.mapProvider).toBe('maplibre');
expect(result.mapStyle).toBe('https://example.com/maplibre-style.json');
});
test('uses legacy non-Mapbox style for MapLibre when provider style is absent', () => {
const result = getTransformPropsResult({
map_renderer: 'maplibre',
maplibre_style: undefined,
mapbox_style: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
});
expect(result.mapProvider).toBe('maplibre');
expect(result.mapStyle).toBe(
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
);
});
test('uses the Mapbox style when mapbox renderer is selected', () => {
const result = getTransformPropsResult({
map_renderer: 'mapbox',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v11',
});
expect(result.mapProvider).toBe('mapbox');
expect(result.mapStyle).toBe('mapbox://styles/mapbox/dark-v11');
});
test('calls onError and falls back to black for invalid color', () => {
const onError = jest.fn();
const chartProps = new ChartProps({

View File

@@ -34,6 +34,7 @@ import {
JsonValue,
QueryFormData,
SetDataMaskHook,
getMapProviderMapStyle,
} from '@superset-ui/core';
import type { Layer } from '@deck.gl/core';
import Legend from './components/Legend';
@@ -318,6 +319,12 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
},
[categories],
);
const selectedMap = getMapProviderMapStyle({
mapProvider: props.formData.map_renderer,
maplibreStyle: props.formData.maplibre_style,
mapboxStyle: props.formData.mapbox_style,
legacyMapStyle: props.formData.map_style,
});
return (
<div style={{ position: 'relative' }}>
@@ -326,14 +333,8 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
viewport={viewport}
layers={getLayers()}
setControlValue={props.setControlValue}
mapStyle={
props.formData.map_renderer === 'mapbox'
? props.formData.mapbox_style
: props.formData.maplibre_style
}
mapProvider={
props.formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
}
mapStyle={selectedMap.mapStyle}
mapProvider={selectedMap.mapProvider}
mapboxApiKey={getMapboxApiKey()}
width={props.width}
height={props.height}

View File

@@ -0,0 +1,240 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ComponentProps, createRef, ReactNode } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { Layer } from '@deck.gl/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import {
OSM_TILE_ATTRIBUTION,
OSM_TILE_STYLE_URL,
} from '@superset-ui/core/utils/mapStyles';
import mapboxgl from 'mapbox-gl';
import { DeckGLContainer, DeckGLContainerHandle } from './DeckGLContainer';
jest.mock('react-map-gl/maplibre', () => ({
Map: ({
children,
mapStyle,
onMove,
}: {
children: ReactNode;
mapStyle: unknown;
onMove: (evt: { viewState: Record<string, number> }) => void;
}) => (
<div data-test="maplibre-map" data-map-style={JSON.stringify(mapStyle)}>
<button
type="button"
data-test="maplibre-move"
onClick={() =>
onMove({ viewState: { longitude: 1, latitude: 2, zoom: 3 } })
}
/>
{children}
</div>
),
}));
jest.mock('react-map-gl/mapbox', () => ({
Map: ({ children, mapStyle }: { children: ReactNode; mapStyle: unknown }) => (
<div data-test="mapbox-map" data-map-style={JSON.stringify(mapStyle)}>
{children}
</div>
),
}));
jest.mock('mapbox-gl', () => ({ accessToken: '' }));
jest.mock(
'./components/DeckGLOverlayMapLibre',
() =>
({ layers }: { layers: unknown[] }) => (
<div data-test="maplibre-overlay" data-layers-count={layers.length} />
),
);
jest.mock(
'./components/DeckGLOverlayMapbox',
() =>
({ layers }: { layers: unknown[] }) => (
<div data-test="mapbox-overlay" data-layers-count={layers.length} />
),
);
jest.mock('./components/Tooltip', () => ({
__esModule: true,
default: ({ variant = 'default' }: { variant?: 'default' | 'custom' }) => (
<div data-test={`tooltip-${variant}`} />
),
}));
const baseProps = {
viewport: { longitude: 0, latitude: 0, zoom: 1, bearing: 0, pitch: 0 },
width: 800,
height: 600,
layers: [],
};
const renderContainer = (
props: Partial<ComponentProps<typeof DeckGLContainer>>,
) =>
render(
<ThemeProvider theme={supersetTheme}>
<DeckGLContainer {...baseProps} {...props} />
</ThemeProvider>,
);
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
test('DeckGLContainer converts OSM raster tile templates into MapLibre style objects', () => {
renderContainer({ mapProvider: 'maplibre', mapStyle: OSM_TILE_STYLE_URL });
const style = JSON.parse(
screen.getByTestId('maplibre-map').getAttribute('data-map-style') || '{}',
);
expect(style.sources['osm-raster-tiles']).toEqual({
type: 'raster',
tiles: [OSM_TILE_STYLE_URL],
tileSize: 256,
attribution: OSM_TILE_ATTRIBUTION,
});
expect(style.layers[0]).toMatchObject({
id: 'osm-raster-layer',
type: 'raster',
source: 'osm-raster-tiles',
});
});
test('DeckGLContainer passes style JSON URLs through to MapLibre', () => {
const styleUrl = 'https://example.com/styles/custom-style.json';
renderContainer({ mapProvider: 'maplibre', mapStyle: styleUrl });
expect(screen.getByTestId('maplibre-map')).toHaveAttribute(
'data-map-style',
JSON.stringify(styleUrl),
);
});
test('DeckGLContainer keeps the missing Mapbox key signal for saved Mapbox charts', () => {
renderContainer({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/dark-v9',
mapboxApiKey: '',
});
expect(
screen.getByText(
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
),
).toBeInTheDocument();
expect(screen.queryByTestId('maplibre-map')).not.toBeInTheDocument();
expect(screen.queryByTestId('mapbox-map')).not.toBeInTheDocument();
});
test('DeckGLContainer passes Mapbox styles through when a key exists', () => {
renderContainer({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/dark-v9',
mapboxApiKey: 'pk.test',
});
expect(mapboxgl.accessToken).toBe('pk.test');
expect(screen.getByTestId('mapbox-map')).toHaveAttribute(
'data-map-style',
JSON.stringify('mapbox://styles/mapbox/dark-v9'),
);
});
test('DeckGLContainer supports layer factories for MapLibre overlays', () => {
const layer = { id: 'layer-1' } as unknown as Layer;
const layerFactory = () => layer;
renderContainer({ mapProvider: 'maplibre', layers: [layerFactory] });
expect(screen.getByTestId('maplibre-overlay')).toHaveAttribute(
'data-layers-count',
'1',
);
});
test('DeckGLContainer updates viewport controls after map movement is throttled', () => {
jest.useFakeTimers();
jest.setSystemTime(1000);
const setControlValue = jest.fn();
renderContainer({ mapProvider: 'maplibre', setControlValue });
fireEvent.click(screen.getByTestId('maplibre-move'));
jest.setSystemTime(1301);
act(() => {
jest.advanceTimersByTime(250);
});
expect(setControlValue).toHaveBeenCalledWith('viewport', {
longitude: 1,
latitude: 2,
zoom: 3,
});
});
test('DeckGLContainer suppresses the native context menu', () => {
renderContainer({ mapProvider: 'maplibre' });
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
const stopPropagationSpy = jest.spyOn(event, 'stopPropagation');
screen.getByTestId('maplibre-map').parentElement?.dispatchEvent(event);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
});
test('DeckGLContainer renders default and custom tooltip variants through its ref', () => {
const ref = createRef<DeckGLContainerHandle>();
render(
<ThemeProvider theme={supersetTheme}>
<DeckGLContainer {...baseProps} mapProvider="maplibre" ref={ref} />
</ThemeProvider>,
);
act(() => {
ref.current?.setTooltip({ x: 0, y: 0, content: 'Default tooltip' });
});
expect(screen.getByTestId('tooltip-default')).toBeInTheDocument();
act(() => {
ref.current?.setTooltip({
x: 0,
y: 0,
content: <span data-tooltip-type="custom">Custom tooltip</span>,
});
});
expect(screen.getByTestId('tooltip-custom')).toBeInTheDocument();
});

View File

@@ -33,6 +33,11 @@ import { Map as MapboxMap } from 'react-map-gl/mapbox';
import mapboxgl from 'mapbox-gl';
import type { Layer } from '@deck.gl/core';
import { JsonObject, JsonValue, usePrevious } from '@superset-ui/core';
import {
resolveMapStyle,
type MapProvider,
type ResolvedMapStyle,
} from '@superset-ui/core/utils/mapStyles';
import { styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import DeckGLOverlayMapLibre from './components/DeckGLOverlayMapLibre';
@@ -50,7 +55,7 @@ export type DeckGLContainerProps = {
viewport: Viewport;
setControlValue?: (control: string, value: JsonValue) => void;
mapStyle?: string;
mapProvider?: 'maplibre' | 'mapbox';
mapProvider?: MapProvider;
mapboxApiKey?: string;
children?: ReactNode;
width: number;
@@ -123,7 +128,9 @@ export const DeckGLContainer = memo(
const theme = useTheme();
const { children = null, height, width } = props;
const isMapbox = props.mapProvider === 'mapbox';
const mapStyle = props.mapStyle || DEFAULT_MAP_STYLE;
const mapStyle: ResolvedMapStyle = isMapbox
? props.mapStyle || DEFAULT_MAP_STYLE
: resolveMapStyle(props.mapStyle, DEFAULT_MAP_STYLE);
if (isMapbox && !props.mapboxApiKey) {
return (

View File

@@ -38,6 +38,7 @@ import {
QueryFormData,
QueryObjectFilterClause,
SupersetClient,
getMapProviderMapStyle,
usePrevious,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
@@ -397,6 +398,12 @@ const DeckMulti = (props: DeckMultiProps) => {
.filter(layer => layer !== undefined),
[layerOrder, subSlicesLayers],
);
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<MultiWrapper height={height} width={width}>
@@ -404,12 +411,8 @@ const DeckMulti = (props: DeckMultiProps) => {
ref={containerRef}
viewport={viewport}
layers={layers}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'}
mapStyle={selectedMap.mapStyle}
mapProvider={selectedMap.mapProvider}
mapboxApiKey={getMapboxApiKey()}
setControlValue={setControlValue}
onViewportChange={setViewport}

View File

@@ -31,6 +31,7 @@ import {
FilterState,
JsonValue,
ContextMenuFilters,
getMapProviderMapStyle,
} from '@superset-ui/core';
import {
@@ -184,6 +185,12 @@ export function createDeckGLComponent(
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
const { formData, setControlValue, height, width } = props;
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<div style={{ position: 'relative' }}>
@@ -191,14 +198,8 @@ export function createDeckGLComponent(
ref={containerRef}
viewport={viewport}
layers={layers}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={
formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
}
mapStyle={selectedMap.mapStyle}
mapProvider={selectedMap.mapProvider}
mapboxApiKey={getMapboxApiKey()}
setControlValue={setControlValue}
width={width}

View File

@@ -1,122 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SqlaFormData } from '@superset-ui/core';
import {
computeGeoJsonTextOptionsFromJsOutput,
computeGeoJsonTextOptionsFromFormData,
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
} from './Geojson';
jest.mock('react-map-gl/maplibre', () => ({
__esModule: true,
Map: () => null,
useControl: () => null,
}));
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
const input = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
invalidOption: true,
};
const expectedOutput = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
};
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
const formData: SqlaFormData = {
label_property_name: 'name',
label_color: { r: 1, g: 2, b: 3, a: 1 },
label_size: 123,
label_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getText: expect.any(Function),
getTextColor: [1, 2, 3, 255],
getTextSize: 123,
textSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
const sampleFeature = { properties: { name: 'Test' } };
expect(actualOutput.getText(sampleFeature)).toBe('Test');
});
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
const input = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
invalidOption: false,
};
const expectedOutput = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
};
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
const formData: SqlaFormData = {
icon_url: 'https://example.com/icon.png',
icon_size: 123,
icon_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getIcon: expect.any(Function),
getIconSize: 123,
iconSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
expect(actualOutput.getIcon()).toEqual({
url: 'https://example.com/icon.png',
height: 128,
width: 128,
});
});

View File

@@ -0,0 +1,348 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactElement } from 'react';
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render } from '@testing-library/react';
import { SqlaFormData } from '@superset-ui/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import DeckGLGeoJson, {
computeGeoJsonTextOptionsFromJsOutput,
computeGeoJsonTextOptionsFromFormData,
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
getPoints,
getLayer,
} from './Geojson';
import controlPanel from './controlPanel';
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
jest.mock('../../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: (props: Record<string, unknown>) => {
mockDeckGLContainerProps.push(props);
const React = jest.requireActual('react');
return React.createElement(
'div',
{ 'data-testid': 'deckgl-container' },
props.children,
);
},
}));
jest.mock('../../utils/mapbox', () => ({
getMapboxApiKey: () => 'bootstrap-mapbox-key',
hasMapboxApiKey: () => true,
}));
jest.mock('react-map-gl/maplibre', () => ({
__esModule: true,
Map: () => null,
useControl: () => null,
}));
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
const input = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
invalidOption: true,
};
const expectedOutput = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
};
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
const formData: SqlaFormData = {
label_property_name: 'name',
label_color: { r: 1, g: 2, b: 3, a: 1 },
label_size: 123,
label_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getText: expect.any(Function),
getTextColor: [1, 2, 3, 255],
getTextSize: 123,
textSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
const sampleFeature = { properties: { name: 'Test' } };
expect(actualOutput.getText(sampleFeature)).toBe('Test');
});
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
const input = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
invalidOption: false,
};
const expectedOutput = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
};
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
const formData: SqlaFormData = {
icon_url: 'https://example.com/icon.png',
icon_size: 123,
icon_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getIcon: expect.any(Function),
getIconSize: 123,
iconSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
expect(actualOutput.getIcon()).toEqual({
url: 'https://example.com/icon.png',
height: 128,
width: 128,
});
});
test('controlPanel expands Map section so renderer controls are visible', () => {
const mapSection = controlPanel.controlPanelSections.find(
(
section: ControlPanelSectionConfig | null,
): section is ControlPanelSectionConfig =>
section !== null && section.label === 'Map',
);
expect(mapSection).toBeDefined();
expect(mapSection?.expanded).toBe(true);
});
test('getPoints skips malformed GeoJSON entries instead of throwing', () => {
const features = [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [1, 2] },
properties: {},
},
[[0, 0]],
null,
] as unknown as Parameters<typeof getPoints>[0];
expect(getPoints(features)).toEqual([
[1, 2],
[1, 2],
]);
expect(getPoints()).toEqual([]);
});
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
const geoJsonProps = {
formData: {
datasource: 'test_datasource',
viz_type: 'deck_geojson',
slice_id: 1,
autozoom: false,
map_style: 'legacy-map-style',
extruded: false,
filled: true,
stroked: true,
line_width: 1,
line_width_unit: 'pixels',
point_radius_scale: 1,
enable_labels: false,
enable_icons: false,
},
payload: {
data: {
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [0, 0] },
properties: { name: 'Test point' },
},
],
},
},
setControlValue: jest.fn(),
viewport: { longitude: 0, latitude: 0, zoom: 1 },
onAddFilter: jest.fn(),
height: 600,
width: 800,
filterState: {},
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
emitCrossFilters: false,
};
const lastDeckGLContainerProps = () =>
mockDeckGLContainerProps
.slice()
.reverse()
.find(props => props?.viewport !== undefined);
test('DeckGLGeoJson passes selected MapLibre renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLGeoJson
{...geoJsonProps}
formData={{
...geoJsonProps.formData,
map_renderer: 'maplibre',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'https://example.com/maplibre-style.json',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('DeckGLGeoJson passes selected Mapbox renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLGeoJson
{...geoJsonProps}
formData={{
...geoJsonProps.formData,
map_renderer: 'mapbox',
maplibre_style: 'https://example.com/maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/satellite-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/satellite-v9',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('DeckGLGeoJson falls back to legacy map_style when provider-specific style is absent', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLGeoJson
{...geoJsonProps}
formData={{
...geoJsonProps.formData,
map_renderer: 'maplibre',
maplibre_style: undefined,
map_style: 'legacy-map-style',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'legacy-map-style',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
const baseFormData: SqlaFormData = {
datasource: 'test_datasource',
viz_type: 'deck_geojson',
slice_id: 1,
fill_color_picker: { r: 0, g: 0, b: 255, a: 1 },
stroke_color_picker: { r: 0, g: 0, b: 0, a: 1 },
};
const baseLayerArgs = {
onContextMenu: jest.fn(),
filterState: undefined,
setDataMask: jest.fn(),
payload: { data: { type: 'FeatureCollection', features: [] } },
setTooltip: jest.fn(),
emitCrossFilters: false,
};
test('getLayer preserves rendering for existing charts without new point radius fields', () => {
// Simulate form data from an existing chart that only has point_radius_scale
const legacyFormData = {
...baseFormData,
point_radius_scale: 200,
// point_radius and point_radius_units intentionally absent
};
const layer = getLayer({ formData: legacyFormData, ...baseLayerArgs });
const { props } = layer;
// Should match deck.gl defaults, NOT the new control panel defaults
expect(props.getPointRadius).toBe(1); // deck.gl default, not 10
expect(props.pointRadiusUnits).toBe('meters'); // deck.gl default, not 'pixels'
expect(props.pointRadiusScale).toBe(200); // user's saved value preserved
});
test('getLayer uses control panel defaults for new charts', () => {
const newChartFormData = {
...baseFormData,
point_radius: 10,
point_radius_units: 'pixels',
point_radius_scale: 1,
};
const layer = getLayer({ formData: newChartFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(10);
expect(props.pointRadiusUnits).toBe('pixels');
expect(props.pointRadiusScale).toBe(1);
});

View File

@@ -30,6 +30,7 @@ import {
QueryFormData,
SetDataMaskHook,
SqlaFormData,
getMapProviderMapStyle,
} from '@superset-ui/core';
import {
@@ -46,6 +47,7 @@ import { Point } from '../../types';
import { GetLayerType } from '../../factory';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
import { BLACK_COLOR, PRIMARY_COLOR } from '../../utilities/controls';
import { getMapboxApiKey } from '../../utils/mapbox';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject;
@@ -326,7 +328,11 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
getFillColor(feature, filterState?.value),
getLineColor,
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
// Use deck.gl defaults as fallbacks for backward compatibility with existing charts.
// New charts will get control panel defaults (point_radius=10, units='pixels', scale=1).
getPointRadius: fd.point_radius ?? 1,
pointRadiusUnits: fd.point_radius_units ?? 'meters',
pointRadiusScale: fd.point_radius_scale ?? 1,
lineWidthUnits: fd.line_width_unit,
pointType,
...labelOpts,
@@ -357,9 +363,19 @@ export type DeckGLGeoJsonProps = {
emitCrossFilters?: boolean;
};
export function getPoints(data: Point[]) {
export function getPoints(data?: Point[]) {
if (!Array.isArray(data)) {
return [];
}
return data.reduce((acc: Array<any>, feature: any) => {
const bounds = geojsonExtent(feature);
let bounds;
try {
bounds = geojsonExtent(feature);
} catch {
return acc;
}
if (bounds) {
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
}
@@ -382,13 +398,13 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
const viewport: Viewport = useMemo(() => {
if (formData.autozoom) {
const points = getPoints(payload.data.features) || [];
const points = getPoints(payload?.data?.features);
if (points.length) {
return fitViewport(props.viewport, {
width,
height,
points: getPoints(payload.data.features) || [],
points,
});
}
}
@@ -412,12 +428,21 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
emitCrossFilters: props.emitCrossFilters,
});
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<DeckGLContainerStyledWrapper
ref={containerRef}
viewport={viewport}
layers={[layer]}
mapStyle={formData.map_style}
mapProvider={selectedMap.mapProvider}
mapStyle={selectedMap.mapStyle}
mapboxApiKey={getMapboxApiKey()}
setControlValue={setControlValue}
height={height}
width={width}

View File

@@ -22,6 +22,8 @@ import {
legacyValidateInteger,
isFeatureEnabled,
FeatureFlag,
validateNumber,
validateInteger,
} from '@superset-ui/core';
import { formatSelectOptions } from '../../utilities/utils';
import {
@@ -82,6 +84,7 @@ const config: ControlPanelConfig = {
},
{
label: t('Map'),
expanded: true,
controlSetRows: [
[mapProvider],
[mapboxStyle],
@@ -351,15 +354,56 @@ const config: ControlPanelConfig = {
},
],
[
{
name: 'point_radius',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius'),
description: t(
'The radius of point features, in the units specified below. ' +
'The final rendered size is this value multiplied by Point Radius Scale.',
),
validators: [validateInteger],
default: 10,
choices: formatSelectOptions([1, 5, 10, 20, 50, 100]),
renderTrigger: true,
},
},
{
name: 'point_radius_scale',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius Scale'),
validators: [legacyValidateInteger],
default: null,
choices: formatSelectOptions([0, 100, 200, 300, 500]),
description: t(
'A multiplier applied to the point radius. ' +
'Use this to uniformly scale all points.',
),
validators: [validateNumber],
default: 1,
choices: formatSelectOptions([0.1, 0.5, 1, 2, 5, 10]),
renderTrigger: true,
},
},
],
[
{
name: 'point_radius_units',
config: {
type: 'SelectControl',
label: t('Point Radius Units'),
description: t(
'The unit for point radius. Use "pixels" for consistent ' +
'screen-space sizing regardless of zoom level.',
),
default: 'pixels',
choices: [
['pixels', t('Pixels')],
['meters', t('Meters')],
['common', t('Common (unit per pixel at zoom 0)')],
],
renderTrigger: true,
},
},
],

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactElement } from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render, screen } from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
@@ -33,10 +34,23 @@ const mockGetColorBreakpointsBuckets = jest.spyOn(
);
// Mock DeckGL container and Legend
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
jest.mock('../../DeckGLContainer', () => ({
DeckGLContainerStyledWrapper: ({ children }: any) => (
<div data-testid="deckgl-container">{children}</div>
),
DeckGLContainerStyledWrapper: (props: Record<string, unknown>) => {
mockDeckGLContainerProps.push(props);
const React = jest.requireActual('react');
return React.createElement(
'div',
{ 'data-testid': 'deckgl-container' },
props.children,
);
},
}));
jest.mock('../../utils/mapbox', () => ({
getMapboxApiKey: () => 'bootstrap-mapbox-key',
hasMapboxApiKey: () => true,
}));
jest.mock('../../components/Legend', () => ({ categories, position }: any) => (
@@ -109,6 +123,95 @@ const mockProps = {
emitCrossFilters: false,
};
describe('DeckGLPolygon renderer propagation', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuckets.mockReturnValue({});
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
const lastDeckGLContainerProps = () =>
mockDeckGLContainerProps
.slice()
.reverse()
.find(props => props?.viewport !== undefined);
test('passes selected MapLibre renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLPolygon
{...mockProps}
formData={{
...mockProps.formData,
map_renderer: 'maplibre',
maplibre_style: 'https://example.com/polygon-maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/dark-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'https://example.com/polygon-maplibre-style.json',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('passes selected Mapbox renderer props to the container', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLPolygon
{...mockProps}
formData={{
...mockProps.formData,
map_renderer: 'mapbox',
maplibre_style: 'https://example.com/polygon-maplibre-style.json',
mapbox_style: 'mapbox://styles/mapbox/satellite-v9',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'mapbox',
mapStyle: 'mapbox://styles/mapbox/satellite-v9',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
test('falls back to legacy map_style when provider-specific style is absent', () => {
mockDeckGLContainerProps.length = 0;
renderWithTheme(
<DeckGLPolygon
{...mockProps}
formData={{
...mockProps.formData,
map_renderer: 'maplibre',
maplibre_style: undefined,
map_style: 'legacy-map-style',
}}
/>,
);
expect(lastDeckGLContainerProps()).toEqual(
expect.objectContaining({
mapProvider: 'maplibre',
mapStyle: 'legacy-map-style',
mapboxApiKey: 'bootstrap-mapbox-key',
}),
);
});
});
describe('DeckGLPolygon bucket generation logic', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -119,7 +222,7 @@ describe('DeckGLPolygon bucket generation logic', () => {
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('should use getBuckets for linear_palette color scheme', () => {
@@ -227,7 +330,7 @@ describe('DeckGLPolygon Error Handling and Edge Cases', () => {
mockGetColorBreakpointsBuckets.mockReturnValue({});
});
const renderWithTheme = (component: React.ReactElement) =>
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('handles empty features data gracefully', () => {
@@ -291,7 +394,7 @@ describe('DeckGLPolygon Legend Integration', () => {
});
});
const renderWithTheme = (component: React.ReactElement) =>
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('renders legend with non-empty categories when metric and linear_palette are defined', () => {

View File

@@ -31,6 +31,7 @@ import {
JsonValue,
QueryFormData,
SetDataMaskHook,
getMapProviderMapStyle,
} from '@superset-ui/core';
import { PolygonLayer } from '@deck.gl/layers';
@@ -57,6 +58,7 @@ import { TooltipProps } from '../../components/Tooltip';
import { GetLayerType } from '../../factory';
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
import { getMapboxApiKey } from '../../utils/mapbox';
import {
createTooltipContent,
CommonTooltipRows,
@@ -339,6 +341,12 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
colorSchemeType === COLOR_SCHEME_TYPES.color_breakpoints
? getColorBreakpointsBuckets(formData.color_breakpoints)
: getBuckets(formData, payload.data.features, accessor);
const selectedMap = getMapProviderMapStyle({
mapProvider: formData.map_renderer,
maplibreStyle: formData.maplibre_style,
mapboxStyle: formData.mapbox_style,
legacyMapStyle: formData.map_style,
});
return (
<div style={{ position: 'relative' }}>
@@ -347,7 +355,9 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
viewport={viewport}
layers={getLayers()}
setControlValue={setControlValue}
mapStyle={formData.map_style}
mapProvider={selectedMap.mapProvider}
mapStyle={selectedMap.mapStyle}
mapboxApiKey={getMapboxApiKey()}
width={props.width}
height={props.height}
/>

View File

@@ -0,0 +1,161 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ControlPanelState } from '@superset-ui/chart-controls';
import { OSM_TILE_STYLE_URL } from '@superset-ui/core/utils/mapStyles';
import { mapProvider, maplibreStyle } from './Shared_DeckGL';
const setBootstrap = ({
conf = {},
deckglTiles,
}: {
conf?: Record<string, unknown>;
deckglTiles?: unknown;
}) => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: {
conf,
...(deckglTiles === undefined ? {} : { deckgl_tiles: deckglTiles }),
},
})}'></div>`;
};
type MapProviderControlConfig = typeof mapProvider.config & {
mapStateToProps: (state: ControlPanelState) => {
options?: unknown;
warning?: string;
default?: unknown;
};
};
const getMapProviderProps = (value?: string) =>
(mapProvider.config as MapProviderControlConfig).mapStateToProps({
form_data: { map_renderer: value },
} as unknown as ControlPanelState);
type MapLibreStyleControlConfig = typeof maplibreStyle.config & {
mapStateToProps: () => {
choices: unknown;
default: unknown;
};
};
const getMapLibreStyleProps = () =>
(maplibreStyle.config as MapLibreStyleControlConfig).mapStateToProps();
test('deck.gl MapLibre style choices expose Streets (OSM)', () => {
expect(maplibreStyle.config.choices).toContainEqual([
OSM_TILE_STYLE_URL,
'Streets (OSM)',
]);
});
test('deck.gl map renderer hides Mapbox when no key exists for new selections', () => {
setBootstrap({ conf: {} });
const props = getMapProviderProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
]);
});
test('deck.gl map renderer keeps saved Mapbox visible while disabled without a key', () => {
setBootstrap({ conf: {} });
const props = getMapProviderProps('mapbox');
expect(props.options).toContainEqual({
value: 'mapbox',
label: 'Mapbox (API key required)',
disabled: true,
});
});
test('deck.gl map renderer enables Mapbox when a key exists', () => {
setBootstrap({ conf: { MAPBOX_API_KEY: 'pk.test' } });
const props = getMapProviderProps('maplibre');
expect(props.options).toEqual([
{ value: 'maplibre', label: 'MapLibre (open-source)' },
{ value: 'mapbox', label: 'Mapbox (API key required)' },
]);
});
test('deck.gl map renderer keeps the original explanatory description', () => {
expect(mapProvider.config.description).toBe(
'Select the map tile provider. MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured in Superset.',
);
});
test('deck.gl map renderer defaults to configured Mapbox when a key exists', () => {
setBootstrap({
conf: { DEFAULT_MAP_RENDERER: 'mapbox', MAPBOX_API_KEY: 'pk.test' },
});
expect(getMapProviderProps('maplibre').default).toBe('mapbox');
});
test('deck.gl map renderer falls back from configured Mapbox default without a key', () => {
setBootstrap({ conf: { DEFAULT_MAP_RENDERER: 'mapbox' } });
expect(getMapProviderProps('maplibre').default).toBe('maplibre');
});
test('deck.gl map style falls back to default tiles for empty overrides', () => {
setBootstrap({ deckglTiles: [] });
const props = getMapLibreStyleProps();
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
expect(props.default).toBe(
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
);
});
test('deck.gl map style falls back to default tiles for malformed overrides', () => {
setBootstrap({
deckglTiles: [
['https://tiles.example.com/{z}/{x}/{y}.png'],
['https://tiles.example.com/{z}/{x}/{y}.png', 'Custom', 'Extra'],
['', 'Empty URL'],
],
});
const props = getMapLibreStyleProps();
expect(props.choices).toContainEqual([OSM_TILE_STYLE_URL, 'Streets (OSM)']);
expect(props.default).toBe(
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
);
});
test('deck.gl map style accepts well-formed tile overrides', () => {
setBootstrap({
deckglTiles: [['https://tiles.example.com/style.json', 'Custom']],
});
const props = getMapLibreStyleProps();
expect(props.choices).toEqual([
['https://tiles.example.com/style.json', 'Custom'],
]);
expect(props.default).toBe('https://tiles.example.com/style.json');
});

View File

@@ -25,9 +25,20 @@ import {
getCategoricalSchemeRegistry,
getSequentialSchemeRegistry,
SequentialScheme,
type QueryFormData,
} from '@superset-ui/core';
import {
getDefaultMapRenderer,
getBootstrapDataFromDocument,
getMapRendererOptions,
OSM_TILE_STYLE_URL,
type MapRendererOption,
type MapProvider,
} from '@superset-ui/core/utils/mapStyles';
import {
ControlPanelState,
ControlStateMapping,
ControlState,
CustomControlItem,
D3_FORMAT_OPTIONS,
getColorControlsProps,
@@ -40,15 +51,23 @@ import {
isColorSchemeTypeVisible,
} from './utils';
import { TooltipTemplateControl } from './TooltipTemplateControl';
import { hasMapboxApiKey } from '../utils/mapbox';
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
export const DEFAULT_DECKGL_COLOR = { r: 158, g: 158, b: 158, a: 1 };
let deckglTiles: string[][];
type DeckGLTileChoice = [string, string];
type MapStyleVisibilityProps = {
controls?: ControlStateMapping;
};
type MetricControlValue = {
type?: unknown;
value?: unknown;
};
export const DEFAULT_DECKGL_TILES = [
export const DEFAULT_DECKGL_TILES: DeckGLTileChoice[] = [
[
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
'Light (Carto)',
@@ -62,9 +81,10 @@ export const DEFAULT_DECKGL_TILES = [
'Streets (Carto)',
],
['https://tiles.openfreemap.org/styles/liberty', 'Liberty (OpenFreeMap)'],
[OSM_TILE_STYLE_URL, 'Streets (OSM)'],
];
export const DEFAULT_MAPBOX_TILES = [
export const DEFAULT_MAPBOX_TILES: DeckGLTileChoice[] = [
['mapbox://styles/mapbox/streets-v9', 'Streets (Mapbox)'],
['mapbox://styles/mapbox/dark-v9', 'Dark (Mapbox)'],
['mapbox://styles/mapbox/light-v9', 'Light (Mapbox)'],
@@ -73,17 +93,56 @@ export const DEFAULT_MAPBOX_TILES = [
['mapbox://styles/mapbox/outdoors-v9', 'Outdoors (Mapbox)'],
];
const isDeckGLTileChoices = (value: unknown): value is DeckGLTileChoice[] =>
Array.isArray(value) &&
value.length > 0 &&
value.every(
choice =>
Array.isArray(choice) &&
choice.length === 2 &&
typeof choice[0] === 'string' &&
choice[0].trim().length > 0 &&
typeof choice[1] === 'string' &&
choice[1].trim().length > 0,
);
const getDeckGLTiles = () => {
if (!deckglTiles) {
const appContainer = document.getElementById('app');
const { common } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
deckglTiles = common?.deckgl_tiles ?? DEFAULT_DECKGL_TILES;
}
return deckglTiles;
const bootstrapData = getBootstrapDataFromDocument();
const deckglTilesOverride = (
bootstrapData as {
common?: { deckgl_tiles?: unknown };
} | null
)?.common?.deckgl_tiles;
return isDeckGLTileChoices(deckglTilesOverride)
? deckglTilesOverride
: DEFAULT_DECKGL_TILES;
};
const getMapLibreStyleProps = () => {
const choices = getDeckGLTiles();
return {
choices,
default: choices[0][0],
};
};
const getLabeledMapRendererOptions = ({
hasMapboxKey,
currentValue,
}: {
hasMapboxKey: boolean;
currentValue?: MapProvider;
}) =>
getMapRendererOptions({ hasMapboxKey, currentValue }).map(
(option: MapRendererOption) => ({
...option,
label:
option.value === 'maplibre'
? t('MapLibre (open-source)')
: t('Mapbox (API key required)'),
}),
);
const DEFAULT_VIEWPORT = {
longitude: 6.85236157047845,
latitude: 31.222656842808707,
@@ -456,15 +515,26 @@ export const mapProvider = {
label: t('Map Renderer'),
clearable: false,
renderTrigger: true,
choices: [
['maplibre', t('MapLibre (open-source)')],
['mapbox', t('Mapbox (API key required)')],
],
options: getLabeledMapRendererOptions({
hasMapboxKey: hasMapboxApiKey(),
}),
default: 'maplibre',
description: t(
'Select the map tile provider. MapLibre is open-source and requires no API key. ' +
'Mapbox requires MAPBOX_API_KEY to be configured in Superset.',
),
mapStateToProps: (state: ControlPanelState) => {
const hasKey = hasMapboxApiKey();
return {
options: getLabeledMapRendererOptions({
hasMapboxKey: hasKey,
currentValue: state.form_data?.map_renderer as
| MapProvider
| undefined,
}),
default: getDefaultMapRenderer(),
};
},
},
};
@@ -476,13 +546,14 @@ export const maplibreStyle = {
clearable: false,
renderTrigger: true,
freeForm: true,
choices: getDeckGLTiles(),
default: getDeckGLTiles()[0][0],
choices: DEFAULT_DECKGL_TILES,
default: DEFAULT_DECKGL_TILES[0][0],
description: t(
'Base layer map style. Accepts a MapLibre-compatible style URL.',
),
visibility: ({ controls }: ControlPanelState) =>
visibility: ({ controls }: MapStyleVisibilityProps) =>
controls?.map_renderer?.value !== 'mapbox',
mapStateToProps: getMapLibreStyleProps,
},
};
@@ -499,7 +570,7 @@ export const mapboxStyle = {
description: t(
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
),
visibility: ({ controls }: ControlPanelState) =>
visibility: ({ controls }: MapStyleVisibilityProps) =>
controls?.map_renderer?.value === 'mapbox',
},
};
@@ -517,14 +588,14 @@ export const geojsonColumn = {
},
};
const extractMetricsFromFormData = (formData: any) => {
const metrics = new Set<string>();
const extractMetricsFromFormData = (formData: QueryFormData) => {
const metrics = new Set<unknown>();
if (formData.metrics) {
(Array.isArray(formData.metrics)
? formData.metrics
: [formData.metrics]
).forEach((metric: any) => metrics.add(metric));
).forEach((metric: unknown) => metrics.add(metric));
}
if (formData.point_radius_fixed?.value) {
@@ -533,8 +604,9 @@ const extractMetricsFromFormData = (formData: any) => {
Object.entries(formData).forEach(([, value]) => {
if (!value || typeof value !== 'object') return;
if ((value as any).type === 'metric' && (value as any).value) {
metrics.add((value as any).value);
const controlValue = value as MetricControlValue;
if (controlValue.type === 'metric' && controlValue.value) {
metrics.add(controlValue.value);
}
});
@@ -555,7 +627,7 @@ export const tooltipContents = {
),
ghostButtonText: t('Drop columns/metrics here or click'),
disabledTabs: new Set(['saved', 'sqlExpression']),
mapStateToProps: (state: any) => {
mapStateToProps: (state: ControlPanelState) => {
const { datasource, form_data: formData } = state;
const selectedMetrics = formData
@@ -564,7 +636,8 @@ export const tooltipContents = {
return {
columns: datasource?.columns || [],
savedMetrics: datasource?.metrics || [],
savedMetrics:
datasource && 'metrics' in datasource ? datasource.metrics || [] : [],
datasource,
selectedMetrics,
disabledTabs: new Set(['saved', 'sqlExpression']),
@@ -584,7 +657,7 @@ export const tooltipTemplate = {
default: '',
description: '',
placeholder: '',
mapStateToProps: (_state: any, control: any) => ({
mapStateToProps: (_state: ControlPanelState, control: ControlState) => ({
value: control.value,
}),
},
@@ -702,8 +775,13 @@ export const deckGLBreakpointMetric: CustomControlItem = {
// mapStateToProps: (state: ControlPanelState) => ({
// datasource: state.datasource,
// }),
visibility: ({ controls }: { controls: any }) =>
isColorSchemeTypeVisible(controls, COLOR_SCHEME_TYPES.color_breakpoints),
visibility: ({ controls }: MapStyleVisibilityProps) =>
controls
? isColorSchemeTypeVisible(
controls,
COLOR_SCHEME_TYPES.color_breakpoints,
)
: false,
},
};

View File

@@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getMapboxApiKey, hasMapboxApiKey } from './mapbox';
const setBootstrap = (conf: Record<string, unknown>) => {
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify({
common: { conf },
})}'></div>`;
};
test('deck.gl Mapbox helpers read key presence from bootstrap data', () => {
setBootstrap({ MAPBOX_API_KEY: 'pk.test' });
expect(getMapboxApiKey()).toBe('pk.test');
expect(hasMapboxApiKey()).toBe(true);
setBootstrap({});
expect(getMapboxApiKey()).toBe('');
expect(hasMapboxApiKey()).toBe(false);
});

View File

@@ -17,19 +17,15 @@
* under the License.
*/
import {
getMapboxApiKeyFromBootstrap,
hasMapboxApiKey as hasBootstrapMapboxApiKey,
} from '@superset-ui/core/utils/mapStyles';
export function getMapboxApiKey(): string {
if (typeof document === 'undefined') {
return '';
}
try {
const appContainer = document.getElementById('app');
const dataBootstrap = appContainer?.getAttribute('data-bootstrap');
if (dataBootstrap) {
const bootstrapData = JSON.parse(dataBootstrap);
return bootstrapData?.common?.conf?.MAPBOX_API_KEY || '';
}
} catch {
// If bootstrap data is unavailable or malformed, return empty string
}
return '';
return getMapboxApiKeyFromBootstrap();
}
export function hasMapboxApiKey(): boolean {
return hasBootstrapMapboxApiKey();
}

View File

@@ -103,19 +103,6 @@ class DatasourceTypeUpdateRequiredValidationError(ValidationError):
)
class ChartQueryContextDatasourceMismatchValidationError(ValidationError):
"""
Raised when a query-context-only update carries a datasource that does not
match the chart's own datasource.
"""
def __init__(self) -> None:
super().__init__(
_("The query context datasource does not match the chart datasource"),
field_name="query_context",
)
class ChartNotFoundError(CommandException):
message = "Chart not found."

View File

@@ -29,7 +29,6 @@ from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
ChartNotFoundError,
ChartQueryContextDatasourceMismatchValidationError,
ChartUpdateFailedError,
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
@@ -42,7 +41,6 @@ from superset.exceptions import SupersetSecurityException
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType
from superset.utils import json
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -103,51 +101,6 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
if not security_manager.is_owner(dash):
raise DashboardsForbiddenError()
def _validate_query_context_datasource(
self, exceptions: list[ValidationError]
) -> None:
"""
Ensure a query-context-only update keeps the chart's own datasource.
The submitted query context is only verified when it carries a parseable
``datasource`` object; a payload that references a different datasource than
the chart's persisted one is rejected. Payloads without a datasource fall
back to the chart's datasource at execution time and need no check.
"""
if not self._model:
return
raw_query_context = self._properties.get("query_context")
if not raw_query_context:
return
try:
query_context = json.loads(raw_query_context)
except (TypeError, ValueError):
# An unparseable payload cannot be verified or replayed; leave it for
# downstream handling rather than guessing at its intent.
return
datasource = (
query_context.get("datasource") if isinstance(query_context, dict) else None
)
if not isinstance(datasource, dict):
return
try:
ids_match = int(datasource["id"]) == self._model.datasource_id
except (KeyError, TypeError, ValueError):
ids_match = False
datasource_type = datasource.get("type")
types_match = (
datasource_type is None
or str(datasource_type) == self._model.datasource_type
)
if not ids_match or not types_match:
exceptions.append(ChartQueryContextDatasourceMismatchValidationError())
def validate(self) -> None: # noqa: C901
exceptions: list[ValidationError] = []
dashboard_ids = self._properties.get("dashboards")
@@ -181,12 +134,6 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
raise ChartForbiddenError() from ex
except ValidationError as ex:
exceptions.append(ex)
else:
# The query-context-only path skips the ownership check so report and
# alert workers can refresh a chart's cached payload. Keep that payload
# bound to the chart's own datasource so it cannot be repointed at an
# unrelated one.
self._validate_query_context_datasource(exceptions)
# validate tags
try:

View File

@@ -506,6 +506,10 @@ D3_FORMAT: D3Format = {}
# Add also map url in connect-src of TALISMAN_CONFIG variable
DECKGL_BASE_MAP: list[list[str, str]] = None
# Default map renderer for map visualizations that support multiple providers.
# Set to "mapbox" only in deployments that also configure MAPBOX_API_KEY.
DEFAULT_MAP_RENDERER = os.environ.get("DEFAULT_MAP_RENDERER", "maplibre")
# Override the default d3 locale for time format
# Default values are equivalent to

View File

@@ -17,7 +17,7 @@
"""migrate mapbox and deckgl charts to point_cluster_map
Revision ID: ce6bd21901ab
Revises: 4b2a8c9d3e1f
Revises: a1b2c3d4e5f6
Create Date: 2026-03-02 00:00:00.000000
@@ -59,6 +59,31 @@ DECKGL_VIZ_TYPES = [
"deck_scatter",
"deck_screengrid",
]
DECKGL_MIGRATION_ADDED_FIELDS = "__deckgl_maplibre_migration_added_fields"
def _is_mapbox_style(style: Any) -> bool:
return isinstance(style, str) and style.startswith("mapbox://")
def _copy_legacy_maplibre_style(
data: dict[str, Any], added_fields: list[str] | None = None
) -> bool:
mapbox_style = data.get("mapbox_style")
if (
isinstance(mapbox_style, str)
and not _is_mapbox_style(mapbox_style)
and "maplibre_style" not in data
):
data["maplibre_style"] = mapbox_style
if added_fields is not None:
added_fields.append("maplibre_style")
if "map_renderer" not in data:
data["map_renderer"] = "maplibre"
if added_fields is not None:
added_fields.append("map_renderer")
return True
return False
class MigrateMapBox(MigrateViz):
@@ -78,8 +103,10 @@ class MigrateMapBox(MigrateViz):
# Set map_renderer so the new chart continues to use the Mapbox renderer,
# which will pick up MAPBOX_API_KEY from the server config.
mapbox_style = self.data.get("mapbox_style", "")
if isinstance(mapbox_style, str) and mapbox_style.startswith("mapbox://"):
if _is_mapbox_style(mapbox_style):
self.data["map_renderer"] = "mapbox"
else:
_copy_legacy_maplibre_style(self.data)
@classmethod
def upgrade_slice(cls, slc: Slice) -> None:
@@ -116,22 +143,33 @@ class MigrateMapBox(MigrateViz):
def _migrate_deckgl_slice(slc: Slice) -> bool:
"""Set map_renderer='mapbox' for all existing deck.gl slices.
"""Preserve deck.gl renderer/style state after the MapLibre migration.
This ensures full backwards compatibility: existing charts keep using the
Mapbox renderer. Users can later switch to MapLibre in the chart controls.
Only new charts will default to MapLibre.
True Mapbox styles get map_renderer='mapbox'. Non-Mapbox legacy
mapbox_style values are copied to maplibre_style so the MapLibre path keeps
rendering the saved style value.
Returns True if the slice was modified.
"""
params = try_load_json(slc.params)
if not params:
if not isinstance(params, dict) or not params:
return False
if "map_renderer" in params:
return False
modified = False
added_fields: list[str] = []
params["map_renderer"] = "mapbox"
mapbox_style = params.get("mapbox_style", "")
if _is_mapbox_style(mapbox_style):
if "map_renderer" not in params:
params["map_renderer"] = "mapbox"
added_fields.append("map_renderer")
modified = True
else:
modified = _copy_legacy_maplibre_style(params, added_fields)
if not modified:
return False
params[DECKGL_MIGRATION_ADDED_FIELDS] = added_fields
slc.params = json.dumps(params)
return True
@@ -139,10 +177,18 @@ def _migrate_deckgl_slice(slc: Slice) -> bool:
def _downgrade_deckgl_slice(slc: Slice) -> bool:
"""Reverse _migrate_deckgl_slice. Returns True if the slice was modified."""
params = try_load_json(slc.params)
if not params or "map_renderer" not in params:
if not isinstance(params, dict) or not params:
return False
params.pop("map_renderer", None)
added_fields = params.get(DECKGL_MIGRATION_ADDED_FIELDS)
if not isinstance(added_fields, list):
return False
for field in added_fields:
if field in {"map_renderer", "maplibre_style"} and field in params:
params.pop(field, None)
params.pop(DECKGL_MIGRATION_ADDED_FIELDS, None)
slc.params = json.dumps(params)
return True

View File

@@ -122,6 +122,7 @@ FRONTEND_CONF_KEYS = (
"SYNC_DB_PERMISSIONS_IN_ASYNC_MODE",
"TABLE_VIZ_MAX_ROW_SERVER",
"MAPBOX_API_KEY",
"DEFAULT_MAP_RENDERER",
"CSV_STREAMING_ROW_THRESHOLD",
)

View File

@@ -27,6 +27,7 @@ migrate_deckgl_and_mapbox = import_module(
Slice = migrate_deckgl_and_mapbox.Slice
MigrateMapBox = migrate_deckgl_and_mapbox.MigrateMapBox
DECKGL_MIGRATION_ADDED_FIELDS = migrate_deckgl_and_mapbox.DECKGL_MIGRATION_ADDED_FIELDS
_migrate_deckgl_slice = migrate_deckgl_and_mapbox._migrate_deckgl_slice
_downgrade_deckgl_slice = migrate_deckgl_and_mapbox._downgrade_deckgl_slice
@@ -79,7 +80,7 @@ def test_upgrade_mapbox():
@pytest.mark.usefixtures("app_context")
def test_upgrade_mapbox_with_non_mapbox_style():
"""Charts with non-mapbox:// style URLs should not get map_provider=mapbox."""
"""Charts with non-mapbox:// style URLs should stay on the MapLibre path."""
slc = Slice(
slice_name="Test Mapbox Open Style",
viz_type="mapbox",
@@ -98,17 +99,135 @@ def test_upgrade_mapbox_with_non_mapbox_style():
assert slc.viz_type == "point_cluster_map"
params = json.loads(slc.params)
assert params["mapbox_style"] == "https://tiles.openfreemap.org/styles/liberty"
assert "map_renderer" not in params
assert params["maplibre_style"] == "https://tiles.openfreemap.org/styles/liberty"
assert params["map_renderer"] == "maplibre"
def test_migrate_deckgl_slice_mapbox_style():
@pytest.mark.parametrize(
(
"mapbox_style",
"expected_map_renderer",
"expected_maplibre_style",
"expected_modified",
),
[
("mapbox://styles/mapbox/dark-v9", "mapbox", None, True),
(
"tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"maplibre",
"tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png",
True,
),
(
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"maplibre",
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
True,
),
(None, None, None, False),
(
"https://example.com/styles/custom-style.json",
"maplibre",
"https://example.com/styles/custom-style.json",
True,
),
],
)
def test_migrate_deckgl_slice_map_renderer_classification(
mapbox_style, expected_map_renderer, expected_maplibre_style, expected_modified
):
params = {
"viz_type": "deck_arc",
"other_param": "value",
}
if mapbox_style is not None:
params["mapbox_style"] = mapbox_style
slc = Slice(
slice_name="Test Arc",
viz_type="deck_arc",
params=json.dumps(params),
)
modified = _migrate_deckgl_slice(slc)
assert modified is expected_modified
migrated_params = json.loads(slc.params)
if mapbox_style is not None:
assert migrated_params["mapbox_style"] == mapbox_style
else:
assert "mapbox_style" not in migrated_params
if expected_map_renderer is None:
assert "map_renderer" not in migrated_params
else:
assert migrated_params["map_renderer"] == expected_map_renderer
if expected_maplibre_style is None:
assert "maplibre_style" not in migrated_params
else:
assert migrated_params["maplibre_style"] == expected_maplibre_style
if expected_modified:
assert DECKGL_MIGRATION_ADDED_FIELDS in migrated_params
else:
assert DECKGL_MIGRATION_ADDED_FIELDS not in migrated_params
assert migrated_params["viz_type"] == "deck_arc" # viz_type unchanged
assert migrated_params["other_param"] == "value"
def test_migrate_deckgl_slice_preserves_existing_maplibre_style():
slc = Slice(
slice_name="Test Arc Existing MapLibre Style",
viz_type="deck_arc",
params=json.dumps(
{
"viz_type": "deck_arc",
"mapbox_style": "https://legacy.example.com/style.json",
"maplibre_style": "https://saved.example.com/style.json",
"other_param": "value",
}
),
)
modified = _migrate_deckgl_slice(slc)
assert modified is False
params = json.loads(slc.params)
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
assert params["maplibre_style"] == "https://saved.example.com/style.json"
assert params["other_param"] == "value"
def test_migrate_deckgl_slice_preserves_existing_map_renderer():
slc = Slice(
slice_name="Test Arc Existing Renderer",
viz_type="deck_arc",
params=json.dumps(
{
"viz_type": "deck_arc",
"mapbox_style": "mapbox://styles/mapbox/dark-v9",
"map_renderer": "maplibre",
"other_param": "value",
}
),
)
modified = _migrate_deckgl_slice(slc)
assert modified is False
params = json.loads(slc.params)
assert params["map_renderer"] == "maplibre"
assert params["mapbox_style"] == "mapbox://styles/mapbox/dark-v9"
assert params["other_param"] == "value"
def test_migrate_deckgl_slice_copies_style_without_overwriting_renderer():
slc = Slice(
slice_name="Test Arc Existing Renderer Open Style",
viz_type="deck_arc",
params=json.dumps(
{
"viz_type": "deck_arc",
"mapbox_style": "https://legacy.example.com/style.json",
"map_renderer": "mapbox",
"other_param": "value",
}
),
@@ -118,50 +237,25 @@ def test_migrate_deckgl_slice_mapbox_style():
assert modified is True
params = json.loads(slc.params)
assert params["mapbox_style"] == "mapbox://styles/mapbox/dark-v9"
assert params["map_renderer"] == "mapbox"
assert params["viz_type"] == "deck_arc" # viz_type unchanged
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
assert params["maplibre_style"] == "https://legacy.example.com/style.json"
assert params[DECKGL_MIGRATION_ADDED_FIELDS] == ["maplibre_style"]
assert params["other_param"] == "value"
def test_migrate_deckgl_slice_open_style():
"""All existing deck_* charts get map_renderer='mapbox' for backwards compat."""
@pytest.mark.parametrize("params", [[], "legacy", 1])
def test_migrate_deckgl_slice_ignores_non_object_params(params):
slc = Slice(
slice_name="Test Scatter",
viz_type="deck_scatter",
params=json.dumps(
{
"viz_type": "deck_scatter",
"mapbox_style": "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
}
),
)
modified = _migrate_deckgl_slice(slc)
assert modified is True
params = json.loads(slc.params)
assert (
params["mapbox_style"]
== "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
)
assert params["map_renderer"] == "mapbox"
def test_migrate_deckgl_slice_no_mapbox_style():
"""Slices without mapbox_style still get map_renderer='mapbox'."""
slc = Slice(
slice_name="Test Arc No Style",
slice_name="Test Arc Non-object Params",
viz_type="deck_arc",
params=json.dumps({"viz_type": "deck_arc", "other_param": "value"}),
params=json.dumps(params),
)
modified = _migrate_deckgl_slice(slc)
assert modified is True
params = json.loads(slc.params)
assert params["map_renderer"] == "mapbox"
assert params["other_param"] == "value"
assert modified is False
assert json.loads(slc.params) == params
def test_downgrade_deckgl_slice():
@@ -173,6 +267,7 @@ def test_downgrade_deckgl_slice():
"viz_type": "deck_arc",
"mapbox_style": "mapbox://styles/mapbox/dark-v9",
"map_renderer": "mapbox",
DECKGL_MIGRATION_ADDED_FIELDS: ["map_renderer"],
"other_param": "value",
}
),
@@ -184,4 +279,94 @@ def test_downgrade_deckgl_slice():
params = json.loads(slc.params)
assert params["mapbox_style"] == "mapbox://styles/mapbox/dark-v9"
assert "map_renderer" not in params
assert DECKGL_MIGRATION_ADDED_FIELDS not in params
assert params["other_param"] == "value"
def test_downgrade_deckgl_slice_removes_copied_maplibre_style():
slc = Slice(
slice_name="Test Arc Open Style",
viz_type="deck_arc",
params=json.dumps(
{
"viz_type": "deck_arc",
"mapbox_style": "https://legacy.example.com/style.json",
"maplibre_style": "https://legacy.example.com/style.json",
"map_renderer": "maplibre",
DECKGL_MIGRATION_ADDED_FIELDS: ["maplibre_style", "map_renderer"],
"other_param": "value",
}
),
)
modified = _downgrade_deckgl_slice(slc)
assert modified is True
params = json.loads(slc.params)
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
assert "maplibre_style" not in params
assert "map_renderer" not in params
assert DECKGL_MIGRATION_ADDED_FIELDS not in params
assert params["other_param"] == "value"
def test_downgrade_deckgl_slice_preserves_distinct_maplibre_style():
slc = Slice(
slice_name="Test Arc Existing MapLibre Style",
viz_type="deck_arc",
params=json.dumps(
{
"viz_type": "deck_arc",
"mapbox_style": "https://legacy.example.com/style.json",
"maplibre_style": "https://saved.example.com/style.json",
"other_param": "value",
}
),
)
modified = _downgrade_deckgl_slice(slc)
assert modified is False
params = json.loads(slc.params)
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
assert params["maplibre_style"] == "https://saved.example.com/style.json"
assert params["other_param"] == "value"
def test_downgrade_deckgl_slice_preserves_unmarked_renderer_and_maplibre_style():
slc = Slice(
slice_name="Test Arc Existing Fields",
viz_type="deck_arc",
params=json.dumps(
{
"viz_type": "deck_arc",
"mapbox_style": "https://legacy.example.com/style.json",
"maplibre_style": "https://legacy.example.com/style.json",
"map_renderer": "maplibre",
"other_param": "value",
}
),
)
modified = _downgrade_deckgl_slice(slc)
assert modified is False
params = json.loads(slc.params)
assert params["mapbox_style"] == "https://legacy.example.com/style.json"
assert params["maplibre_style"] == "https://legacy.example.com/style.json"
assert params["map_renderer"] == "maplibre"
assert params["other_param"] == "value"
@pytest.mark.parametrize("params", [[], "legacy", 1])
def test_downgrade_deckgl_slice_ignores_non_object_params(params):
slc = Slice(
slice_name="Test Arc Non-object Params",
viz_type="deck_arc",
params=json.dumps(params),
)
modified = _downgrade_deckgl_slice(slc)
assert modified is False
assert json.loads(slc.params) == params

View File

@@ -17,11 +17,10 @@
import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.chart.exceptions import ChartForbiddenError
from superset.commands.chart.update import UpdateChartCommand
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.utils import json
def _ownership_exc() -> SupersetSecurityException:
@@ -92,73 +91,3 @@ def test_update_chart_owner_can_perform_regular_update(
find_by_id.assert_called_once_with(1)
raise_for_ownership.assert_called_once()
def _query_context_payload(datasource: object) -> dict[str, object]:
return {
"query_context": json.dumps({"datasource": datasource, "queries": []}),
"query_context_generation": True,
}
def test_update_chart_query_context_matching_datasource_is_allowed(
mocker: MockerFixture,
) -> None:
"""A query context that targets the chart's own datasource is accepted."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
UpdateChartCommand(
1, _query_context_payload({"id": 42, "type": "table"})
).validate()
@pytest.mark.parametrize(
"datasource",
[
{"id": 99, "type": "table"}, # different id
{"id": 42, "type": "query"}, # different type
{"id": "99", "type": "table"}, # different id as string
],
)
def test_update_chart_query_context_mismatched_datasource_is_rejected(
mocker: MockerFixture,
datasource: dict[str, object],
) -> None:
"""A query context pointing at a different datasource is rejected with a 4xx."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
with pytest.raises(ChartInvalidError):
UpdateChartCommand(1, _query_context_payload(datasource)).validate()
@pytest.mark.parametrize(
"query_context",
[
"{}", # no datasource key
'{"datasource": null}', # null datasource
"not-json", # unparseable payload
],
)
def test_update_chart_query_context_without_datasource_is_allowed(
mocker: MockerFixture,
query_context: str,
) -> None:
"""Payloads with no verifiable datasource fall back to the chart's own."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(
id=1, tags=[], dashboards=[], datasource_id=42, datasource_type="table"
)
mocker.patch("superset.commands.chart.update.security_manager.raise_for_ownership")
UpdateChartCommand(
1,
{"query_context": query_context, "query_context_generation": True},
).validate()

View File

@@ -69,6 +69,12 @@ def test_common_bootstrap_payload_handles_none_locale(
mock_cached.assert_called_once_with(1, None)
def test_default_map_renderer_is_exposed_to_frontend_config() -> None:
from superset.views.base import FRONTEND_CONF_KEYS
assert "DEFAULT_MAP_RENDERER" in FRONTEND_CONF_KEYS
def _extract_language(
locale_str: str | None,
languages: dict[str, dict[str, object]] | None = None,