mirror of
https://github.com/apache/superset.git
synced 2026-06-11 10:39:15 +00:00
Compare commits
3 Commits
mcp-get-da
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e800329f | ||
|
|
046b1b61b3 | ||
|
|
da9756ef14 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -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}}"
|
||||
|
||||
21
UPDATING.md
21
UPDATING.md
@@ -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`.
|
||||
|
||||
@@ -35,3 +35,4 @@ export * from './typedMemo';
|
||||
export * from './html';
|
||||
export * from './tooltip';
|
||||
export * from './merge';
|
||||
export * from './mapStyles';
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -82,6 +82,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
{
|
||||
label: t('Map'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[mapProvider],
|
||||
[mapboxStyle],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user