Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot]
a3e800329f chore(deps): bump github/codeql-action from 4.36.1 to 4.36.2
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.1 to 4.36.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](87557b9c84...8aad20d150)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 07:15:25 +00: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
39 changed files with 2163 additions and 1022 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

@@ -63,7 +63,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -74,6 +74,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: "/language:${{matrix.language}}"

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,297 @@
/**
* 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,
} 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',
}),
);
});

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;
@@ -357,9 +359,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 +394,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 +424,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

@@ -82,6 +82,7 @@ const config: ControlPanelConfig = {
},
{
label: t('Map'),
expanded: true,
controlSetRows: [
[mapProvider],
[mapboxStyle],

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

@@ -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

@@ -128,7 +128,6 @@ Dashboard Management:
- list_dashboards: List dashboards with advanced filters (1-based pagination)
- get_dashboard_info: Get detailed dashboard information by ID
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
- get_dashboard_datasets: List the datasets used by a dashboard's charts, with columns and metrics (context for configuring native filters)
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
@@ -681,7 +680,6 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
add_chart_to_existing_dashboard,
generate_dashboard,
get_dashboard_datasets,
get_dashboard_info,
get_dashboard_layout,
list_dashboards,

View File

@@ -374,17 +374,6 @@ class GetDashboardLayoutRequest(BaseModel):
]
class GetDashboardDatasetsRequest(BaseModel):
"""Request schema for get_dashboard_datasets."""
identifier: Annotated[
int | str,
Field(
description="Dashboard identifier - can be numeric ID, UUID string, or slug"
),
]
logger = logging.getLogger(__name__)
@@ -1309,225 +1298,3 @@ def dashboard_layout_serializer(dashboard: "Dashboard") -> DashboardLayout:
has_layout=bool(position_json_str),
)
)
# Per-dataset caps keep responses small enough for LLM context: wide
# datasets can have hundreds of columns, which would dwarf the fields an
# agent actually needs to configure native filters.
MAX_DASHBOARD_DATASET_COLUMNS = 100
MAX_DASHBOARD_DATASET_METRICS = 50
class DashboardDatasetColumn(BaseModel):
"""Lean column representation for dashboard dataset context."""
column_name: str = Field(..., description="Column name")
verbose_name: str | None = Field(None, description="Verbose (display) name")
type: str | None = Field(None, description="Column data type")
is_dttm: bool | None = Field(None, description="Is datetime column")
class DashboardDatasetMetric(BaseModel):
"""Lean metric representation for dashboard dataset context."""
metric_name: str = Field(..., description="Saved metric name")
verbose_name: str | None = Field(None, description="Verbose (display) name")
expression: str | None = Field(None, description="SQL expression")
class DashboardDatasetDatabaseInfo(BaseModel):
"""Database connection summary for a dashboard dataset."""
id: int | None = Field(None, description="Database ID")
name: str | None = Field(None, description="Database name")
backend: str | None = Field(None, description="Database backend (engine)")
class DashboardDatasetSummary(BaseModel):
"""A dataset used by a dashboard's charts, with columns and metrics."""
id: int | None = Field(None, description="Dataset ID")
uuid: str | None = Field(None, description="Dataset UUID")
table_name: str | None = Field(None, description="Table name")
schema_name: str | None = Field(None, description="Schema name")
database: DashboardDatasetDatabaseInfo | None = Field(
None, description="Database the dataset belongs to"
)
chart_count: int = Field(
0, description="Number of charts on the dashboard using this dataset"
)
columns: List[DashboardDatasetColumn] = Field(
default_factory=list, description="Dataset columns"
)
metrics: List[DashboardDatasetMetric] = Field(
default_factory=list, description="Dataset metrics"
)
total_column_count: int = Field(
0, description="Total number of columns on the dataset"
)
total_metric_count: int = Field(
0, description="Total number of metrics on the dataset"
)
columns_truncated: bool = Field(
False,
description=(
"True when the columns list was truncated to keep the response small"
),
)
metrics_truncated: bool = Field(
False,
description=(
"True when the metrics list was truncated to keep the response small"
),
)
@model_serializer(mode="wrap")
def _rename_schema_field(self, serializer: Any, info: Any) -> Dict[str, Any]:
"""Serialize 'schema_name' as 'schema' to match API conventions."""
data = serializer(self)
if "schema_name" in data:
data["schema"] = data.pop("schema_name")
return data
class DashboardDatasets(BaseModel):
"""Response schema for get_dashboard_datasets."""
id: int | None = Field(None, description="Dashboard ID")
dashboard_title: str | None = Field(None, description="Dashboard title")
uuid: str | None = Field(None, description="Dashboard UUID")
dataset_count: int = Field(
0, description="Number of accessible datasets used by the dashboard"
)
inaccessible_dataset_count: int = Field(
0,
description=(
"Number of datasets used by the dashboard that the current user "
"cannot access (excluded from 'datasets')"
),
)
datasets: List[DashboardDatasetSummary] = Field(
default_factory=list,
description="Datasets used by the dashboard's charts",
)
def _serialize_dashboard_dataset(
datasource: Any, chart_count: int
) -> DashboardDatasetSummary:
"""Serialize a datasource to a lean, LLM-safe dataset summary."""
all_columns = list(getattr(datasource, "columns", None) or [])
all_metrics = list(getattr(datasource, "metrics", None) or [])
columns = [
DashboardDatasetColumn(
column_name=escape_llm_context_delimiters(
getattr(column, "column_name", None) or ""
),
verbose_name=sanitize_for_llm_context(
getattr(column, "verbose_name", None),
field_path=("columns", str(index), "verbose_name"),
),
type=getattr(column, "type", None),
is_dttm=getattr(column, "is_dttm", None),
)
for index, column in enumerate(all_columns[:MAX_DASHBOARD_DATASET_COLUMNS])
]
metrics = [
DashboardDatasetMetric(
metric_name=escape_llm_context_delimiters(
getattr(metric, "metric_name", None) or ""
),
verbose_name=sanitize_for_llm_context(
getattr(metric, "verbose_name", None),
field_path=("metrics", str(index), "verbose_name"),
),
expression=sanitize_for_llm_context(
getattr(metric, "expression", None),
field_path=("metrics", str(index), "expression"),
),
)
for index, metric in enumerate(all_metrics[:MAX_DASHBOARD_DATASET_METRICS])
]
database = getattr(datasource, "database", None)
database_info = (
DashboardDatasetDatabaseInfo(
id=getattr(database, "id", None),
name=escape_llm_context_delimiters(
getattr(database, "database_name", None)
),
backend=getattr(database, "backend", None),
)
if database is not None
else None
)
dataset_uuid = getattr(datasource, "uuid", None)
return DashboardDatasetSummary(
id=getattr(datasource, "id", None),
uuid=str(dataset_uuid) if dataset_uuid else None,
table_name=escape_llm_context_delimiters(
getattr(datasource, "table_name", None)
),
schema_name=escape_llm_context_delimiters(getattr(datasource, "schema", None)),
database=database_info,
chart_count=chart_count,
columns=columns,
metrics=metrics,
total_column_count=len(all_columns),
total_metric_count=len(all_metrics),
columns_truncated=len(all_columns) > MAX_DASHBOARD_DATASET_COLUMNS,
metrics_truncated=len(all_metrics) > MAX_DASHBOARD_DATASET_METRICS,
)
def dashboard_datasets_serializer(dashboard: "Dashboard") -> DashboardDatasets:
"""Serialize a Dashboard model to the datasets used by its charts.
Groups the dashboard's charts by datasource (mirroring
``Dashboard.datasets_trimmed_for_slices``) but keeps the full column and
metric lists (capped) since native-filter configuration regularly needs
columns that no chart references. Datasets the current user cannot
access are excluded and only counted.
"""
from superset.mcp_service.auth import has_dataset_access
slices_by_datasource: Dict[int, List[Any]] = {}
for slc in getattr(dashboard, "slices", None) or []:
datasource_id = getattr(slc, "datasource_id", None)
if datasource_id is None:
continue
slices_by_datasource.setdefault(datasource_id, []).append(slc)
datasets: List[DashboardDatasetSummary] = []
inaccessible_count = 0
for slices in slices_by_datasource.values():
datasource = next(
(
getattr(slc, "datasource", None)
for slc in slices
if getattr(slc, "datasource", None) is not None
),
None,
)
if datasource is None:
continue
if not has_dataset_access(datasource):
inaccessible_count += 1
continue
datasets.append(_serialize_dashboard_dataset(datasource, len(slices)))
datasets.sort(key=lambda dataset: dataset.id or 0)
return DashboardDatasets(
id=dashboard.id,
dashboard_title=sanitize_for_llm_context(
dashboard.dashboard_title or "Untitled",
field_path=("dashboard_title",),
),
uuid=str(dashboard.uuid) if dashboard.uuid else None,
dataset_count=len(datasets),
inaccessible_dataset_count=inaccessible_count,
datasets=datasets,
)

View File

@@ -17,14 +17,12 @@
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
from .generate_dashboard import generate_dashboard
from .get_dashboard_datasets import get_dashboard_datasets
from .get_dashboard_info import get_dashboard_info
from .get_dashboard_layout import get_dashboard_layout
from .list_dashboards import list_dashboards
__all__ = [
"list_dashboards",
"get_dashboard_datasets",
"get_dashboard_info",
"get_dashboard_layout",
"generate_dashboard",

View File

@@ -1,128 +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.
"""
Get dashboard datasets FastMCP tool
Returns the datasets used by a dashboard's charts, including columns and
metrics. This is the prerequisite context an agent needs before configuring
native filters on a dashboard (e.g. picking filter target columns).
"""
import logging
from datetime import datetime, timezone
from fastmcp import Context
from sqlalchemy.orm import subqueryload
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.dashboard.schemas import (
dashboard_datasets_serializer,
DashboardDatasets,
DashboardError,
GetDashboardDatasetsRequest,
)
from superset.mcp_service.mcp_core import ModelGetInfoCore
logger = logging.getLogger(__name__)
@tool(
tags=["core"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="Get dashboard datasets",
readOnlyHint=True,
destructiveHint=False,
),
)
async def get_dashboard_datasets(
request: GetDashboardDatasetsRequest, ctx: Context
) -> DashboardDatasets | DashboardError:
"""
List the datasets used by a dashboard's charts, by ID, UUID, or slug.
Each dataset includes its table name, schema, database connection
(id, name, backend), columns (name, type, is_dttm, verbose_name) and
metrics (name, expression, verbose_name). Use this to understand which
columns and metrics are available before configuring native filters or
analyzing a dashboard's data model.
Datasets the current user cannot access are excluded from the response
and reported via inaccessible_dataset_count. Column and metric lists are
capped per dataset; when truncated, columns_truncated/metrics_truncated
are set and total counts are reported.
Example usage:
```json
{
"identifier": 123
}
```
"""
await ctx.info(
"Retrieving dashboard datasets: identifier=%s" % (request.identifier,)
)
try:
from superset.daos.dashboard import DashboardDAO
from superset.models.dashboard import Dashboard
# Eager load slices to avoid N+1 queries when grouping by datasource.
eager_options = [subqueryload(Dashboard.slices)]
with event_logger.log_context(action="mcp.get_dashboard_datasets.lookup"):
core = ModelGetInfoCore(
dao_class=DashboardDAO,
output_schema=DashboardDatasets,
error_schema=DashboardError,
serializer=dashboard_datasets_serializer,
supports_slug=True,
logger=logger,
query_options=eager_options,
)
result = core.run_tool(request.identifier)
if isinstance(result, DashboardDatasets):
await ctx.info(
"Dashboard datasets retrieved: id=%s, dataset_count=%s, "
"inaccessible_dataset_count=%s"
% (
result.id,
result.dataset_count,
result.inaccessible_dataset_count,
)
)
else:
await ctx.warning(
"Dashboard datasets retrieval failed: error_type=%s, error=%s"
% (result.error_type, result.error)
)
return result
except Exception as e:
await ctx.error(
"Dashboard datasets retrieval failed: identifier=%s, error=%s, "
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
)
return DashboardError(
error=f"Failed to get dashboard datasets: {str(e)}",
error_type="InternalError",
timestamp=datetime.now(timezone.utc),
)

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

@@ -1,355 +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.
"""Unit tests for the MCP get_dashboard_datasets tool."""
from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from superset.mcp_service.app import mcp
from superset.mcp_service.utils.sanitization import (
LLM_CONTEXT_CLOSE_DELIMITER,
LLM_CONTEXT_OPEN_DELIMITER,
)
from superset.utils import json
def _wrapped(value: str) -> str:
return f"{LLM_CONTEXT_OPEN_DELIMITER}\n{value}\n{LLM_CONTEXT_CLOSE_DELIMITER}"
def _build_column_mock(
name: str,
*,
verbose_name: str | None = None,
type_: str | None = "VARCHAR",
is_dttm: bool = False,
) -> Mock:
column = Mock()
column.column_name = name
column.verbose_name = verbose_name
column.type = type_
column.is_dttm = is_dttm
return column
def _build_metric_mock(
name: str,
*,
verbose_name: str | None = None,
expression: str | None = None,
) -> Mock:
metric = Mock()
metric.metric_name = name
metric.verbose_name = verbose_name
metric.expression = expression
return metric
def _build_database_mock(
*, database_id: int = 7, name: str = "examples", backend: str = "postgresql"
) -> Mock:
database = Mock()
database.id = database_id
database.database_name = name
database.backend = backend
return database
def _build_datasource_mock(
*,
dataset_id: int,
uuid: str | None = None,
table_name: str = "my_table",
schema: str | None = "public",
database: Mock | None = None,
columns: list[Mock] | None = None,
metrics: list[Mock] | None = None,
) -> Mock:
datasource = Mock()
datasource.id = dataset_id
datasource.uuid = uuid
datasource.table_name = table_name
datasource.schema = schema
datasource.database = database
datasource.columns = columns or []
datasource.metrics = metrics or []
return datasource
def _build_slice_mock(datasource: Mock) -> Mock:
slc = Mock()
slc.datasource_id = datasource.id
slc.datasource = datasource
return slc
def _build_dashboard_mock(
*,
dashboard_id: int = 1,
title: str = "Test Dashboard",
uuid: str | None = "dashboard-uuid-1",
slices: list[Mock] | None = None,
) -> Mock:
dashboard = Mock()
dashboard.id = dashboard_id
dashboard.dashboard_title = title
dashboard.uuid = uuid
dashboard.slices = slices or []
return dashboard
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
mock_user = Mock()
mock_user.id = 1
mock_user.username = "admin"
mock_get_user.return_value = mock_user
yield mock_get_user
@pytest.fixture(autouse=True)
def mock_dataset_access():
with patch(
"superset.mcp_service.auth.has_dataset_access", return_value=True
) as mock_access:
yield mock_access
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_datasets_multiple_datasets(mock_find, mcp_server):
sales = _build_datasource_mock(
dataset_id=10,
uuid="dataset-uuid-10",
table_name="sales",
schema="public",
database=_build_database_mock(),
columns=[
_build_column_mock("region", verbose_name="Region"),
_build_column_mock("order_date", type_="TIMESTAMP", is_dttm=True),
],
metrics=[
_build_metric_mock(
"total_revenue",
verbose_name="Total Revenue",
expression="SUM(revenue)",
)
],
)
customers = _build_datasource_mock(
dataset_id=20,
uuid="dataset-uuid-20",
table_name="customers",
schema="crm",
database=_build_database_mock(database_id=8, name="crm_db", backend="mysql"),
columns=[_build_column_mock("customer_name")],
metrics=[],
)
mock_find.return_value = _build_dashboard_mock(
slices=[
_build_slice_mock(sales),
_build_slice_mock(sales),
_build_slice_mock(customers),
]
)
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_datasets", {"request": {"identifier": 1}}
)
data = json.loads(result.content[0].text)
assert data["id"] == 1
assert data["dashboard_title"] == _wrapped("Test Dashboard")
assert data["uuid"] == "dashboard-uuid-1"
assert data["dataset_count"] == 2
assert data["inaccessible_dataset_count"] == 0
assert len(data["datasets"]) == 2
datasets_by_id = {d["id"]: d for d in data["datasets"]}
sales_data = datasets_by_id[10]
assert sales_data["uuid"] == "dataset-uuid-10"
assert sales_data["table_name"] == "sales"
assert sales_data["schema"] == "public"
assert sales_data["database"] == {
"id": 7,
"name": "examples",
"backend": "postgresql",
}
assert sales_data["chart_count"] == 2
assert sales_data["columns"] == [
{
"column_name": "region",
"verbose_name": _wrapped("Region"),
"type": "VARCHAR",
"is_dttm": False,
},
{
"column_name": "order_date",
"verbose_name": None,
"type": "TIMESTAMP",
"is_dttm": True,
},
]
assert sales_data["metrics"] == [
{
"metric_name": "total_revenue",
"verbose_name": _wrapped("Total Revenue"),
"expression": _wrapped("SUM(revenue)"),
}
]
assert sales_data["total_column_count"] == 2
assert sales_data["total_metric_count"] == 1
assert sales_data["columns_truncated"] is False
assert sales_data["metrics_truncated"] is False
customers_data = datasets_by_id[20]
assert customers_data["table_name"] == "customers"
assert customers_data["schema"] == "crm"
assert customers_data["chart_count"] == 1
assert customers_data["metrics"] == []
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_datasets_by_slug(mock_find, mcp_server):
datasource = _build_datasource_mock(
dataset_id=10,
table_name="sales",
database=_build_database_mock(),
columns=[_build_column_mock("region")],
)
dashboard = _build_dashboard_mock(slices=[_build_slice_mock(datasource)])
def find_by_id(identifier, id_column=None, query_options=None):
if id_column == "slug" and identifier == "sales-dash":
return dashboard
return None
mock_find.side_effect = find_by_id
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_datasets", {"request": {"identifier": "sales-dash"}}
)
data = json.loads(result.content[0].text)
assert data["id"] == 1
assert data["dataset_count"] == 1
assert data["datasets"][0]["table_name"] == "sales"
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_datasets_not_found(mock_find, mcp_server):
mock_find.return_value = None
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_datasets", {"request": {"identifier": 999}}
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "not_found"
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_datasets_empty_dashboard(mock_find, mcp_server):
mock_find.return_value = _build_dashboard_mock(slices=[])
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_datasets", {"request": {"identifier": 1}}
)
data = json.loads(result.content[0].text)
assert data["id"] == 1
assert data["dataset_count"] == 0
assert data["inaccessible_dataset_count"] == 0
assert data["datasets"] == []
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_datasets_excludes_inaccessible(
mock_find, mcp_server, mock_dataset_access
):
allowed = _build_datasource_mock(dataset_id=10, table_name="sales")
denied = _build_datasource_mock(dataset_id=20, table_name="secrets")
mock_find.return_value = _build_dashboard_mock(
slices=[_build_slice_mock(allowed), _build_slice_mock(denied)]
)
mock_dataset_access.side_effect = lambda datasource: datasource.id != 20
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_datasets", {"request": {"identifier": 1}}
)
data = json.loads(result.content[0].text)
assert data["dataset_count"] == 1
assert data["inaccessible_dataset_count"] == 1
assert [d["id"] for d in data["datasets"]] == [10]
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
@pytest.mark.asyncio
async def test_get_dashboard_datasets_truncates_wide_datasets(mock_find, mcp_server):
from superset.mcp_service.dashboard.schemas import (
MAX_DASHBOARD_DATASET_COLUMNS,
MAX_DASHBOARD_DATASET_METRICS,
)
datasource = _build_datasource_mock(
dataset_id=10,
table_name="wide_table",
columns=[
_build_column_mock(f"col_{i}")
for i in range(MAX_DASHBOARD_DATASET_COLUMNS + 5)
],
metrics=[
_build_metric_mock(f"metric_{i}")
for i in range(MAX_DASHBOARD_DATASET_METRICS + 3)
],
)
mock_find.return_value = _build_dashboard_mock(
slices=[_build_slice_mock(datasource)]
)
async with Client(mcp_server) as client:
result = await client.call_tool(
"get_dashboard_datasets", {"request": {"identifier": 1}}
)
data = json.loads(result.content[0].text)
dataset = data["datasets"][0]
assert len(dataset["columns"]) == MAX_DASHBOARD_DATASET_COLUMNS
assert len(dataset["metrics"]) == MAX_DASHBOARD_DATASET_METRICS
assert dataset["columns_truncated"] is True
assert dataset["metrics_truncated"] is True
assert dataset["total_column_count"] == MAX_DASHBOARD_DATASET_COLUMNS + 5
assert dataset["total_metric_count"] == MAX_DASHBOARD_DATASET_METRICS + 3

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,