Compare commits

..

6 Commits

Author SHA1 Message Date
Evan Rusackas
ecbe96c006 address review: widen reactify return type to ComponentType
Preserves backward compatibility for TypeScript consumers that
previously saw ComponentClass<Props & ReactifyProps>. The new
ReactifiedComponent<Props> type is now exported so callers that want
the ref-aware surface can narrow to it explicitly.
2026-04-22 13:59:35 -07:00
Evan Rusackas
3c06394c0a address review: clear container before componentWillUnmount 2026-04-22 12:37:02 -07:00
Evan Rusackas
780342e7fa address review: drop stray async from willUnmount test 2026-04-22 12:35:31 -07:00
Evan Rusackas
12136b3881 fix(reactify): bypass forwardRef static field type mismatch
forwardRef's inherent propTypes/defaultProps types include RefAttributes,
which the renderFn's validator types don't satisfy. Cast to any for the
static field copy (matches the pre-existing pattern from the class
version of reactify).
2026-04-18 15:49:57 -07:00
Evan Rusackas
b7b40f09bd fix(reactify): preserve props + drop any cast
- Pass `props` into the componentWillUnmount callback context via a
  propsRef, so consumers that read `this.props` (e.g. ReactNVD3's
  unmount handler reading `this.props.id`) continue to work.
- Replace the `any` cast around propTypes/defaultProps assignment with
  the concrete ReactifiedComponent<Props> type.
2026-04-17 17:22:34 -07:00
Evan Rusackas
c5b4ab5822 chore(lint): convert reactify.tsx to function component
Converts the reactify HOC in superset-ui-core from a class-based
wrapper to a function component. Updates the associated unit test.

⚠️ reactify wraps legacy D3/NVD3 render functions — class→function
conversion changes componentDidMount/Update to useEffect, which has
different timing semantics. This PR isolates that change for focused
review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:35:16 -07:00
434 changed files with 4369 additions and 19308 deletions

View File

@@ -115,10 +115,6 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
GITHUB_HEAD_REF: ${GITHUB_HEAD_REF:-}
GITHUB_SHA: ${GITHUB_SHA:-}
@@ -141,10 +137,6 @@ services:
DATABASE_HOST: db-light
DATABASE_DB: superset_light
POSTGRES_DB: superset_light
EXAMPLES_HOST: db-light
EXAMPLES_DB: superset_light
EXAMPLES_USER: superset
EXAMPLES_PASSWORD: superset
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
healthcheck:
disable: true
@@ -165,7 +157,6 @@ services:
BUILD_SUPERSET_FRONTEND_IN_DOCKER: true
NPM_RUN_PRUNE: false
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
DISABLE_TS_CHECKER: "${DISABLE_TS_CHECKER:-true}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container

View File

@@ -80,7 +80,7 @@ case "${1}" in
;;
app)
echo "Starting web app (using development server)..."
flask run -p $PORT --reload --debugger --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*:*/superset-frontend/*"
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -412,7 +412,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security roles](/developer-docs/api/get-security-roles) | `/api/v1/security/roles/` |
| `POST` | [Create security roles](/developer-docs/api/create-security-roles) | `/api/v1/security/roles/` |
| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` |
| `DELETE` | [Delete security roles by pk](/developer-docs/api/delete-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `GET` | [Get security roles by pk](/developer-docs/api/get-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
| `PUT` | [Update security roles by pk](/developer-docs/api/update-security-roles-by-pk) | `/api/v1/security/roles/{pk}` |
@@ -430,7 +430,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security users](/developer-docs/api/get-security-users) | `/api/v1/security/users/` |
| `POST` | [Create security users](/developer-docs/api/create-security-users) | `/api/v1/security/users/` |
| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` |
| `DELETE` | [Delete security users by pk](/developer-docs/api/delete-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `GET` | [Get security users by pk](/developer-docs/api/get-security-users-by-pk) | `/api/v1/security/users/{pk}` |
| `PUT` | [Update security users by pk](/developer-docs/api/update-security-users-by-pk) | `/api/v1/security/users/{pk}` |
@@ -443,7 +443,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | [Get security permissions](/developer-docs/api/get-security-permissions) | `/api/v1/security/permissions/` |
| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` |
| `GET` | [Get security permissions by pk](/developer-docs/api/get-security-permissions-by-pk) | `/api/v1/security/permissions/{pk}` |
</details>
@@ -455,7 +455,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security resources](/developer-docs/api/get-security-resources) | `/api/v1/security/resources/` |
| `POST` | [Create security resources](/developer-docs/api/create-security-resources) | `/api/v1/security/resources/` |
| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` |
| `DELETE` | [Delete security resources by pk](/developer-docs/api/delete-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `GET` | [Get security resources by pk](/developer-docs/api/get-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
| `PUT` | [Update security resources by pk](/developer-docs/api/update-security-resources-by-pk) | `/api/v1/security/resources/{pk}` |
@@ -469,7 +469,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|--------|----------|-------------|
| `GET` | [Get security permissions resources](/developer-docs/api/get-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `POST` | [Create security permissions resources](/developer-docs/api/create-security-permissions-resources) | `/api/v1/security/permissions-resources/` |
| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` |
| `DELETE` | [Delete security permissions resources by pk](/developer-docs/api/delete-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `GET` | [Get security permissions resources by pk](/developer-docs/api/get-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
| `PUT` | [Update security permissions resources by pk](/developer-docs/api/update-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` |
@@ -578,29 +578,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
</details>
<details>
<summary><strong>Themes</strong> (14 endpoints) — Manage UI themes for customizing Superset's appearance.</summary>
| Method | Endpoint | Description |
|--------|----------|-------------|
| `DELETE` | [Bulk delete themes](/developer-docs/api/bulk-delete-themes) | `/api/v1/theme/` |
| `GET` | [Get a list of themes](/developer-docs/api/get-a-list-of-themes) | `/api/v1/theme/` |
| `POST` | [Create a theme](/developer-docs/api/create-a-theme) | `/api/v1/theme/` |
| `GET` | [Get metadata information about this API resource (theme-info)](/developer-docs/api/get-metadata-information-about-this-api-resource-theme-info) | `/api/v1/theme/_info` |
| `DELETE` | [Delete a theme](/developer-docs/api/delete-a-theme) | `/api/v1/theme/{pk}` |
| `GET` | [Get a theme](/developer-docs/api/get-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Update a theme](/developer-docs/api/update-a-theme) | `/api/v1/theme/{pk}` |
| `PUT` | [Set a theme as the system dark theme](/developer-docs/api/set-a-theme-as-the-system-dark-theme) | `/api/v1/theme/{pk}/set_system_dark` |
| `PUT` | [Set a theme as the system default theme](/developer-docs/api/set-a-theme-as-the-system-default-theme) | `/api/v1/theme/{pk}/set_system_default` |
| `GET` | [Download multiple themes as YAML files](/developer-docs/api/download-multiple-themes-as-yaml-files) | `/api/v1/theme/export/` |
| `POST` | [Import themes from a ZIP file](/developer-docs/api/import-themes-from-a-zip-file) | `/api/v1/theme/import/` |
| `GET` | [Get related fields data (theme-related-column-name)](/developer-docs/api/get-related-fields-data-theme-related-column-name) | `/api/v1/theme/related/{column_name}` |
| `DELETE` | [Clear the system dark theme](/developer-docs/api/clear-the-system-dark-theme) | `/api/v1/theme/unset_system_dark` |
| `DELETE` | [Clear the system default theme](/developer-docs/api/clear-the-system-default-theme) | `/api/v1/theme/unset_system_default` |
| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` |
</details>

View File

@@ -164,13 +164,8 @@ Extensions configure Webpack to expose their entry points:
```javascript
externalsType: 'window',
externals: ({ request }, callback) => {
// Map @apache-superset/core and subpaths to window.superset
if (request?.startsWith('@apache-superset/core')) {
const parts = request.replace('@apache-superset/core', 'superset').split('/');
return callback(null, parts);
}
callback();
externals: {
'@apache-superset/core': 'superset',
},
plugins: [
new ModuleFederationPlugin({
@@ -192,7 +187,7 @@ This configuration does several important things:
**`exposes`** - Declares which modules are available to the host application. Superset always loads extensions by requesting the `./index` module from the remote container — this is a fixed convention, not a configurable value. Extensions must expose exactly `'./index': './src/index.tsx'` and place all API registrations (views, commands, menus, editors, event listeners) in that file. The module is executed as a side effect when the extension loads, so any call to `views.registerView`, `commands.registerCommand`, etc. made at the top level of `index.tsx` will run automatically.
**`externals` and `externalsType`** - Tell Webpack that when the extension imports from `@apache-superset/core` or its subpaths (like `@apache-superset/core/storage`), it should resolve to `window.superset` or `window.superset.storage` at runtime. The function-based externals returns an array of path segments, which Webpack uses for nested property access.
**`externals` and `externalsType`** - Tell Webpack that when the extension imports `@apache-superset/core`, it should use `window.superset` at runtime instead of bundling its own copy. This ensures extensions use the host's implementation of shared packages.
**`shared`** - Prevents duplication of common libraries like React and Ant Design. The `singleton: true` setting ensures only one instance of each library exists, avoiding version conflicts and reducing bundle size.

View File

@@ -55,6 +55,5 @@ Extension developers have access to pre-built UI components via `@apache-superse
- **[Deployment](./deployment)** - Packaging and deploying extensions
- **[MCP Integration](./mcp)** - Adding AI agent capabilities using extensions
- **[Security](./security)** - Security considerations and best practices
- **[Storage](./storage)** - Managed storage API for persisting extension data
- **[Tasks](./tasks)** - Framework for creating and managing long running tasks
- **[Community Extensions](./registry)** - Browse extensions shared by the community

View File

@@ -223,7 +223,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
**`frontend/webpack.config.js`**
The webpack configuration requires specific settings for Module Federation. Key settings include `externalsType: "window"` and a function-based `externals` to map `@apache-superset/core` and its subpaths (like `@apache-superset/core/storage`) to `window.superset` at runtime, `import: false` for shared modules to use the host's React instead of bundling a separate copy, and `remoteEntry.[contenthash].js` for cache busting.
The webpack configuration requires specific settings for Module Federation. Key settings include `externalsType: "window"` and `externals` to map `@apache-superset/core` to `window.superset` at runtime, `import: false` for shared modules to use the host's React instead of bundling a separate copy, and `remoteEntry.[contenthash].js` for cache busting.
**Convention**: Superset always loads extensions by requesting the `./index` module from the Module Federation container. The `exposes` entry must be exactly `'./index': './src/index.tsx'` — do not rename or add additional entries. All API registrations must be reachable from that file. See [Architecture](./architecture#module-federation) for a full explanation.
@@ -255,14 +255,10 @@ module.exports = (env, argv) => {
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
},
// Map @apache-superset/core and subpaths to window.superset at runtime
// Map @apache-superset/core imports to window.superset at runtime
externalsType: 'window',
externals: ({ request }, callback) => {
if (request?.startsWith('@apache-superset/core')) {
const parts = request.replace('@apache-superset/core', 'superset').split('/');
return callback(null, parts);
}
callback();
externals: {
'@apache-superset/core': 'superset',
},
module: {
rules: [

View File

@@ -1,316 +0,0 @@
---
title: Storage
sidebar_position: 8
---
<!--
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.
-->
# Storage
Superset Extensions have access to a managed storage API for persisting data. The storage system provides multiple tiers with different persistence characteristics, allowing extensions to choose the right storage for their needs.
Each extension receives its own isolated storage namespace. When Superset loads your extension, it binds storage to your extension's unique identifier, ensuring data privacy—two extensions using the same key will never collide, and extensions cannot access each other's data.
## Storage Tiers
| Tier | Storage Type | Context Property | Use Case |
| ---- | ----------------- | ------------------------------------------ | -------------------------------------- |
| 1 | Browser storage | `ctx.storage.local`, `ctx.storage.session` | UI state, wizard progress, draft forms |
| 2 | Server-side cache | `ctx.storage.ephemeral` | Job progress, temporary results |
| 3 | Database | `ctx.storage.persistent` | User preferences, durable config |
## Tier 1: Local State
Browser-based storage that persists on the user's device. Use this for UI state and settings that don't need to sync across devices.
### Why Use the API Instead of localStorage Directly?
You might wonder why extensions should use `ctx.storage.local` instead of directly accessing `window.localStorage`. The managed API provides several benefits:
- **Automatic namespacing**: Each extension's data is isolated. Two extensions using the same key name won't collide.
- **User isolation**: By default, data is scoped to the current user, preventing data leakage between users on shared devices.
- **Clean uninstall**: When an extension is uninstalled, all its data can be cleanly removed using prefix-based deletion.
- **Future sandboxing**: The async API is designed for a future sandboxed execution model where extensions run in isolated contexts without direct DOM access.
- **Consistent patterns**: The same API shape works across all storage tiers, making it easy to switch between them.
### localState
Data persists across browser sessions until explicitly deleted or the user clears browser storage.
```typescript
import { getContext } from '@apache-superset/core/extensions';
const ctx = getContext();
// Save sidebar state
await ctx.storage.local.set('sidebar_collapsed', true);
// Retrieve it later
const isCollapsed = await ctx.storage.local.get('sidebar_collapsed');
// Remove it
await ctx.storage.local.remove('sidebar_collapsed');
```
### sessionState
Data is cleared when the browser tab is closed. Use for transient state within a single session.
```typescript
import { getContext } from '@apache-superset/core/extensions';
const ctx = getContext();
// Save wizard progress (lost when tab closes)
await ctx.storage.session.set('wizard_step', 3);
await ctx.storage.session.set('unsaved_form', { name: 'Draft' });
// Retrieve on page reload within same tab
const step = await ctx.storage.session.get('wizard_step');
```
### Shared State
By default, data is scoped to the current user. Use `shared` for data that should be accessible to all users on the same device.
```typescript
import { getContext } from '@apache-superset/core/extensions';
const ctx = getContext();
// Shared across all users on this device
await ctx.storage.local.shared.set('device_id', 'abc-123');
const deviceId = await ctx.storage.local.shared.get('device_id');
```
### When to Use Tier 1
- UI state (sidebar collapsed, panel sizes)
- Recently used items
- Draft form values
- Any data acceptable to lose if user clears browser
### Limitations
- Per-browser, per-device (not shared across devices)
- Subject to browser storage quotas (~5-10 MB)
- Not accessible from backend code
## Tier 2: Ephemeral State
Server-side cache storage with automatic TTL expiration. Use for temporary data that needs to be shared between frontend and backend, or persist across page reloads.
### Frontend Usage
```typescript
import { getContext } from '@apache-superset/core/extensions';
const ctx = getContext();
// Store with server default TTL (CACHE_DEFAULT_TIMEOUT)
await ctx.storage.ephemeral.set('job_progress', { pct: 42, status: 'running' });
// Store with custom TTL (5 minutes)
await ctx.storage.ephemeral.set(
'quick_cache',
{ results: [1, 2, 3] },
{ ttl: 300 },
);
// Retrieve
const progress = await ctx.storage.ephemeral.get('job_progress');
// Remove
await ctx.storage.ephemeral.remove('job_progress');
```
### Backend Usage
```python
from superset_core.extensions.context import get_context
ctx = get_context()
# Store job progress (uses CACHE_DEFAULT_TIMEOUT when ttl is omitted)
ctx.storage.ephemeral.set('job_progress', {'pct': 42, 'status': 'running'})
# Retrieve
progress = ctx.storage.ephemeral.get('job_progress')
# Remove
ctx.storage.ephemeral.remove('job_progress')
```
### Shared State
For data that needs to be visible to all users:
```typescript
import { getContext } from '@apache-superset/core/extensions';
const ctx = getContext();
await ctx.storage.ephemeral.shared.set('shared_result', { data: [1, 2, 3] });
const result = await ctx.storage.ephemeral.shared.get('shared_result');
```
```python
from superset_core.extensions.context import get_context
ctx = get_context()
ctx.storage.ephemeral.shared.set('shared_result', {'data': [1, 2, 3]})
result = ctx.storage.ephemeral.shared.get('shared_result')
```
### When to Use Tier 2
- Background job progress indicators
- Cross-request intermediate state
- Query result previews
- Temporary computation results
- Any data that can be recomputed if lost
### Limitations
- Not guaranteed to survive server restarts
- Subject to cache eviction under memory pressure
- TTL-based expiration (data disappears after timeout)
## Tier 3: Persistent State
Database-backed storage that survives server restarts, cache evictions, and browser clears. Use for any data that must not be lost.
### Frontend Usage
```typescript
import { getContext } from '@apache-superset/core/extensions';
const ctx = getContext();
// Store user preferences
await ctx.storage.persistent.set('preferences', { theme: 'dark', locale: 'en' });
// Retrieve
const prefs = await ctx.storage.persistent.get('preferences');
// Remove
await ctx.storage.persistent.remove('preferences');
```
### Backend Usage
```python
from superset_core.extensions.context import get_context
ctx = get_context()
# Store user preferences
ctx.storage.persistent.set('preferences', {'theme': 'dark', 'locale': 'en'})
# Retrieve
prefs = ctx.storage.persistent.get('preferences')
# Remove
ctx.storage.persistent.remove('preferences')
```
### Shared State
For data that should be visible to all users of the extension:
```typescript
import { getContext } from '@apache-superset/core/extensions';
const ctx = getContext();
await ctx.storage.persistent.shared.set('global_config', { version: 2 });
const config = await ctx.storage.persistent.shared.get('global_config');
```
```python
from superset_core.extensions.context import get_context
ctx = get_context()
ctx.storage.persistent.shared.set('global_config', {'version': 2})
config = ctx.storage.persistent.shared.get('global_config')
```
### When to Use Tier 3
- User preferences and settings
- Extension configuration that must survive restarts
- Saved state that needs to roam across devices and browsers
- Any data where loss is unacceptable
### Limitations
- Higher latency than Tiers 12 (database round-trip per operation)
- Subject to the 16 MB value size limit
- Requires a database migration when first deployed
## Key Patterns
All storage keys are automatically namespaced:
| Scope | Key Pattern |
| ----------- | -------------------------------------------------- |
| User-scoped | `superset-ext:{extension_id}:user:{user_id}:{key}` |
| Shared | `superset-ext:{extension_id}:shared:{key}` |
This ensures:
- Extensions cannot accidentally access each other's data
- Users cannot see other users' data (by default)
- Clean prefix-based deletion on uninstall
## Configuration
### Tier 2: Ephemeral Storage
Administrators can configure the server-side cache backend in `superset_config.py`:
```python
EXTENSIONS_STORAGE = {
"EPHEMERAL": {
# Use Redis for better performance in production
"CACHE_TYPE": "RedisCache",
"CACHE_REDIS_URL": "redis://localhost:6379/2",
"CACHE_DEFAULT_TIMEOUT": 3600, # 1 hour default TTL
},
}
```
For development, the default `SupersetMetastoreCache` stores data in the metadata database.
### Tier 3: Persistent Storage
Tier 3 values are stored in the `extension_storage` database table. The encryption infrastructure is in place (Fernet-based, keyed from `EXTENSION_STORAGE_ENCRYPTION_KEYS`), but values written through the standard storage API are stored unencrypted by default. Encryption is available at the DAO layer for backend extensions that call `ExtensionStorageDAO.set(..., is_encrypted=True)` directly.
```python
# Optional: override the encryption key(s) used for Tier 3 persistent storage.
# Falls back to SECRET_KEY when not set.
# Rotate keys by prepending the new key — all keys are tried on decryption.
EXTENSION_STORAGE_ENCRYPTION_KEYS = [
"my-new-key-base64url-encoded", # used for new writes
"my-old-key-base64url-encoded", # kept for reading old values
]
```

View File

@@ -129,30 +129,6 @@ def add_missing_schemas(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]
}
fixed.append("DashboardColorsConfigUpdateSchema")
# DashboardChartCustomizationsConfigUpdateSchema (dashboards/schemas.py)
if "DashboardChartCustomizationsConfigUpdateSchema" not in schemas:
schemas["DashboardChartCustomizationsConfigUpdateSchema"] = {
"type": "object",
"properties": {
"deleted": {
"type": "array",
"items": {"type": "string"},
"description": "List of deleted chart customization IDs.",
},
"modified": {
"type": "array",
"items": {"type": "object"},
"description": "List of modified chart customizations.",
},
"reordered": {
"type": "array",
"items": {"type": "string"},
"description": "List of chart customization IDs in new order.",
},
},
}
fixed.append("DashboardChartCustomizationsConfigUpdateSchema")
# FormatQueryPayloadSchema - based on superset/sqllab/schemas.py
if "FormatQueryPayloadSchema" not in schemas:
schemas["FormatQueryPayloadSchema"] = {
@@ -319,7 +295,6 @@ TAG_DESCRIPTIONS = {
"Security Roles": "Manage security roles and their permissions.",
"Security Users": "Manage user accounts.",
"Tags": "Organize assets with tags.",
"Themes": "Manage UI themes for customizing Superset's appearance.",
"User": "User profile and preferences.",
}

View File

@@ -88,7 +88,6 @@ const sidebars = {
'extensions/deployment',
'extensions/mcp',
'extensions/security',
'extensions/storage',
'extensions/tasks',
'extensions/registry',
],

File diff suppressed because it is too large Load Diff

View File

@@ -5743,13 +5743,13 @@ available-typed-arrays@^1.0.7:
possible-typed-array-names "^1.0.0"
axios@^1.12.2:
version "1.15.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f"
integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==
version "1.13.5"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43"
integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==
dependencies:
follow-redirects "^1.15.11"
form-data "^4.0.5"
proxy-from-env "^2.1.0"
proxy-from-env "^1.1.0"
babel-loader@^9.2.1:
version "9.2.1"
@@ -12643,10 +12643,10 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
punycode@^1.4.1:
version "1.4.1"

View File

@@ -48,7 +48,7 @@ dependencies = [
"cryptography>=42.0.4, <47.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.1, <6.0.0",
"flask-appbuilder>=5.0.2,<6",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",

View File

@@ -120,7 +120,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.2.1
flask-appbuilder==5.2.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core

View File

@@ -259,7 +259,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.2.1
flask-appbuilder==5.2.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -1,137 +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.
"""
Extension Context API for superset-core extensions.
Provides access to the current extension's context, including metadata
and scoped resources like storage. Extensions call `get_context()` to
access their context during execution.
The context is set by the host (Superset) during extension loading and
is only available within extension code.
Usage:
from superset_core.extensions.context import get_context
def setup():
ctx = get_context()
# Access extension metadata
print(f"Running {ctx.extension.displayName} v{ctx.extension.version}")
# Access extension-scoped storage
ctx.storage.ephemeral.set("lastRun", time.time())
data = ctx.storage.ephemeral.get("cachedData")
"""
from __future__ import annotations
from typing import Any, Protocol, TYPE_CHECKING
if TYPE_CHECKING:
from superset_core.extensions.types import Manifest
class StorageAccessor(Protocol):
"""Protocol for storage access with user-scoped and shared modes."""
def get(self, key: str) -> Any:
"""Get a value from storage."""
...
def set(self, key: str, value: Any, ttl: int = 3600) -> None:
"""Set a value in storage with optional TTL."""
...
def remove(self, key: str) -> None:
"""Remove a value from storage."""
...
@property
def shared(self) -> "StorageAccessor":
"""Shared (cross-user) storage accessor."""
...
class ExtensionStorage(Protocol):
"""Extension-scoped storage accessor for all available tiers."""
@property
def ephemeral(self) -> StorageAccessor:
"""Server-side cache (Redis/Memcached) with TTL."""
...
# Future tiers:
# @property
# def persistent(self) -> StorageAccessor:
# """Database-backed persistent storage."""
# ...
class ExtensionContext(Protocol):
"""
Context object providing extension-specific resources.
This context is only available during extension execution.
Calling `get_context()` outside of an extension will raise an error.
"""
@property
def extension(self) -> "Manifest":
"""Metadata about the current extension."""
...
@property
def storage(self) -> ExtensionStorage:
"""Extension-scoped storage across all available tiers."""
...
def get_context() -> ExtensionContext:
"""
Get the current extension's context.
This function returns the context for the currently executing extension,
providing access to extension metadata and scoped resources like storage.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:returns: The current extension's context.
:raises RuntimeError: If called outside of an extension context.
Example:
from superset_core.extensions.context import get_context
ctx = get_context()
# Access extension metadata
print(f"Extension: {ctx.extension.id}")
print(f"Version: {ctx.extension.version}")
# Access extension-scoped storage
ctx.storage.ephemeral.set("tempData", data, ttl=3600)
value = ctx.storage.ephemeral.get("tempData")
# Access shared (cross-user) storage
ctx.storage.ephemeral.shared.set("globalCounter", count)
"""
raise NotImplementedError(
"get_context() must be called within an extension context. "
"This function is replaced by the host during extension loading."
)

View File

@@ -1,62 +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.
"""
Storage API for superset-core extensions.
Provides storage tiers for extensions with different persistence characteristics:
Tier 1 - Local State (Frontend Only):
- localState: Browser localStorage - persists across sessions
- sessionState: Browser sessionStorage - cleared on tab close
These are frontend-only and cannot be imported in backend code.
Tier 2 - Ephemeral State (Server Cache):
- ephemeral_state: Short-lived KV storage backed by server-side cache
- Supports TTL, not guaranteed to survive server restarts
- Use for temporary state like job progress or intermediate results
Tier 3 - Persistent State (Database) [Future]:
- persistent_state: Durable KV storage backed by database table
- Survives server restarts, supports encryption and resource linking
- Use for user preferences, extension config, per-resource settings
All tiers follow the same API pattern:
- User-scoped by default (private to current user)
- `shared` accessor for data visible to all users
Usage:
from superset_core.extensions.storage import ephemeral_state
# User-scoped state (default - private to current user)
ephemeral_state.get('preference')
ephemeral_state.set('preference', 'compact', ttl=3600)
# Shared state (explicit opt-in - visible to all users)
ephemeral_state.shared.get('job_progress')
ephemeral_state.shared.set('job_progress', {'pct': 42}, ttl=3600)
# Tier 3: Persistent state
from superset_core.extensions.storage import persistent_state
persistent_state.get('config')
persistent_state.set('config', {'version': 2})
"""
from superset_core.extensions.storage import (
ephemeral_state, # noqa: F401
persistent_state, # noqa: F401
)

View File

@@ -1,129 +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.
"""
Ephemeral State API for superset-core extensions (Tier 2 Storage).
Provides short-lived KV storage backed by the configured server-side cache
backend (Redis, Memcached, or filesystem). Automatically expires based on TTL.
Not guaranteed to survive server restarts.
Host implementations will replace these functions during initialization
with concrete implementations providing actual functionality.
Cache keys are namespaced automatically:
- User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
- Shared (global): superset-ext:{extension_id}:{key}
Usage:
from superset_core.extensions.storage import ephemeral_state
# User-scoped state (default - private to current user)
ephemeral_state.get('preference')
ephemeral_state.set('preference', 'compact')
ephemeral_state.remove('preference')
# Shared state (explicit opt-in - visible to all users)
ephemeral_state.shared.get('job_progress')
ephemeral_state.shared.set('job_progress', {'pct': 42})
ephemeral_state.shared.remove('job_progress')
"""
from typing import Any, Protocol
class EphemeralStateAccessor(Protocol):
"""Protocol for scoped ephemeral state access."""
def get(self, key: str) -> Any:
"""Get a value from ephemeral state."""
...
def set(self, key: str, value: Any, ttl: int | None = None) -> None:
"""Set a value in ephemeral state with TTL."""
...
def remove(self, key: str) -> None:
"""Remove a value from ephemeral state."""
...
def get(key: str) -> Any:
"""
Get a value from user-scoped ephemeral state.
Data is automatically scoped to the current authenticated user.
Other users cannot see or modify this data.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to retrieve.
:returns: The stored value, or None if not found or expired.
"""
raise NotImplementedError("Function will be replaced during initialization")
def set(key: str, value: Any, ttl: int | None = None) -> None:
"""
Set a value in user-scoped ephemeral state with TTL.
Data is automatically scoped to the current authenticated user.
Other users cannot see or modify this data.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to store.
:param value: The value to store (must be JSON-serializable).
:param ttl: Time-to-live in seconds. Defaults to CACHE_DEFAULT_TIMEOUT.
"""
raise NotImplementedError("Function will be replaced during initialization")
def remove(key: str) -> None:
"""
Remove a value from user-scoped ephemeral state.
Data is automatically scoped to the current authenticated user.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to remove.
"""
raise NotImplementedError("Function will be replaced during initialization")
class _SharedStub:
"""Stub for shared accessor that raises NotImplementedError on any operation."""
def get(self, key: str) -> Any:
raise NotImplementedError("Accessor will be replaced during initialization")
def set(self, key: str, value: Any, ttl: int | None = None) -> None:
raise NotImplementedError("Accessor will be replaced during initialization")
def remove(self, key: str) -> None:
raise NotImplementedError("Accessor will be replaced during initialization")
#: Shared (global) ephemeral state accessor.
#: Data stored via this accessor is visible to all users of the extension.
#: WARNING: Do not store user-specific or sensitive data here.
#: Host implementations will replace this during initialization.
shared: EphemeralStateAccessor = _SharedStub()

View File

@@ -1,129 +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.
"""
Persistent State API for superset-core extensions (Tier 3 Storage).
Provides durable KV storage backed by a dedicated database table.
Data survives server restarts, cache evictions, and browser clears.
Suitable for user preferences, saved state, and any data that must not be lost.
Host implementations will replace these functions during initialization
with concrete implementations providing actual functionality.
Database keys are namespaced automatically:
- User-scoped (default): (extension_id, user_id, key)
- Shared (global): (extension_id, null, key)
Usage:
from superset_core.extensions.storage import persistent_state
# User-scoped state (default - private to current user)
persistent_state.get('preferences')
persistent_state.set('preferences', {'theme': 'dark'})
persistent_state.remove('preferences')
# Shared state (explicit opt-in - visible to all users)
persistent_state.shared.get('global_config')
persistent_state.shared.set('global_config', {'version': 2})
persistent_state.shared.remove('global_config')
"""
from typing import Any, Protocol
class PersistentStateAccessor(Protocol):
"""Protocol for scoped persistent state access."""
def get(self, key: str) -> Any:
"""Get a value from persistent state."""
...
def set(self, key: str, value: Any) -> None:
"""Set a value in persistent state."""
...
def remove(self, key: str) -> None:
"""Remove a value from persistent state."""
...
def get(key: str) -> Any:
"""
Get a value from user-scoped persistent state.
Data is automatically scoped to the current authenticated user.
Other users cannot see or modify this data.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to retrieve.
:returns: The stored value, or None if not found.
"""
raise NotImplementedError("Function will be replaced during initialization")
def set(key: str, value: Any) -> None:
"""
Set a value in user-scoped persistent state.
Data is automatically scoped to the current authenticated user.
Other users cannot see or modify this data.
Data persists indefinitely until explicitly removed.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to store.
:param value: The value to store (must be JSON-serializable).
"""
raise NotImplementedError("Function will be replaced during initialization")
def remove(key: str) -> None:
"""
Remove a value from user-scoped persistent state.
Data is automatically scoped to the current authenticated user.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param key: The key to remove.
"""
raise NotImplementedError("Function will be replaced during initialization")
class _SharedStub:
"""Stub for shared accessor that raises NotImplementedError on any operation."""
def get(self, key: str) -> Any:
raise NotImplementedError("Accessor will be replaced during initialization")
def set(self, key: str, value: Any) -> None:
raise NotImplementedError("Accessor will be replaced during initialization")
def remove(self, key: str) -> None:
raise NotImplementedError("Accessor will be replaced during initialization")
#: Shared (global) persistent state accessor.
#: Data stored via this accessor is visible to all users of the extension.
#: WARNING: Do not store user-specific or sensitive data here.
#: Host implementations will replace this during initialization.
shared: PersistentStateAccessor = _SharedStub()

View File

@@ -26,13 +26,8 @@ module.exports = (env, argv) => {
extensions: [".ts", ".tsx", ".js", ".jsx"],
},
externalsType: "window",
externals: ({ request }, callback) => {
// Map @apache-superset/core and subpaths to window.superset
if (request?.startsWith("@apache-superset/core")) {
const parts = request.replace("@apache-superset/core", "superset").split("/");
return callback(null, parts);
}
callback();
externals: {
"@apache-superset/core": "superset",
},
module: {
rules: [

File diff suppressed because it is too large Load Diff

View File

@@ -128,17 +128,17 @@
"@superset-ui/legacy-plugin-chart-chord": "file:./plugins/legacy-plugin-chart-chord",
"@superset-ui/legacy-plugin-chart-country-map": "file:./plugins/legacy-plugin-chart-country-map",
"@superset-ui/legacy-plugin-chart-horizon": "file:./plugins/legacy-plugin-chart-horizon",
"@superset-ui/legacy-plugin-chart-map-box": "file:./plugins/legacy-plugin-chart-map-box",
"@superset-ui/legacy-plugin-chart-paired-t-test": "file:./plugins/legacy-plugin-chart-paired-t-test",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "file:./plugins/legacy-plugin-chart-parallel-coordinates",
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
@@ -347,7 +347,7 @@
"open-cli": "^9.0.0",
"oxlint": "^1.56.0",
"po2json": "^0.4.5",
"prettier": "3.8.2",
"prettier": "3.8.1",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
@@ -368,7 +368,7 @@
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.4",
"webpack": "^5.106.0",
"webpack": "^5.105.4",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",

View File

@@ -45,7 +45,6 @@ src/
├── extensions/
├── menus/
├── sqlLab/
├── storage/
├── theme/
├── translation/
├── utils/

View File

@@ -65,10 +65,6 @@
"./testing": {
"types": "./lib/testing.d.ts",
"default": "./lib/testing.js"
},
"./storage": {
"types": "./lib/storage/index.d.ts",
"default": "./lib/storage/index.js"
}
},
"files": [

View File

@@ -24,114 +24,9 @@
* including querying extension metadata and monitoring extension lifecycle events.
* Extensions can use this API to discover other extensions and react to changes
* in the extension ecosystem.
*
* Extensions can access their own context via `getContext()`, which provides:
* - Extension metadata (id, name, version, etc.)
* - Extension-scoped storage (localStorage, sessionStorage, ephemeral cache)
*
* @example
* ```typescript
* import { extensions } from '@apache-superset/core';
*
* // Get the current extension's context
* const ctx = extensions.getContext();
*
* // Access extension metadata
* console.log(`Running ${ctx.extension.name} v${ctx.extension.version}`);
*
* // Access extension-scoped storage
* await ctx.storage.local.set('preference', { theme: 'dark' });
* await ctx.storage.ephemeral.set('cache', data, { ttl: 300 });
* ```
*/
import { Extension } from '../common';
import { StorageTier } from '../storage/types';
/**
* Extension-scoped storage accessor.
*
* All storage tiers are automatically namespaced to the current extension,
* preventing key collisions between extensions.
*/
export interface ExtensionStorage {
/**
* Browser localStorage - persists across browser sessions.
* Data is scoped to the current extension and user.
*/
local: StorageTier;
/**
* Browser sessionStorage - cleared when the tab closes.
* Data is scoped to the current extension and user.
*/
session: StorageTier;
/**
* Server-side cache (Redis/Memcached) with TTL.
* Data is scoped to the current extension and user.
* Use `.shared` for data visible to all users.
*/
ephemeral: StorageTier;
/**
* Durable database-backed storage (Tier 3).
* Data survives server restarts and cache evictions.
* Use `.shared` for data visible to all users.
*/
persistent: StorageTier;
}
/**
* Context object providing extension-specific resources.
*
* This context is only available during extension execution.
* Calling `getContext()` outside of an extension will throw an error.
*/
export interface ExtensionContext {
/**
* Metadata about the current extension.
*/
extension: Extension;
/**
* Extension-scoped storage across all tiers.
* All keys are automatically namespaced to prevent collisions.
*/
storage: ExtensionStorage;
}
/**
* Get the current extension's context.
*
* This function returns the context for the currently executing extension,
* providing access to extension metadata and scoped resources like storage.
*
* @returns The current extension's context.
* @throws Error if called outside of an extension context.
*
* @example
* ```typescript
* import { extensions } from '@apache-superset/core';
*
* const ctx = extensions.getContext();
*
* // Access extension metadata
* console.log(`Extension: ${ctx.extension.id}`);
* console.log(`Version: ${ctx.extension.version}`);
*
* // Access extension-scoped storage
* await ctx.storage.local.set('userPref', { sidebar: 'collapsed' });
* const pref = await ctx.storage.local.get('userPref');
*
* // Use ephemeral storage with TTL
* await ctx.storage.ephemeral.set('tempData', data, { ttl: 3600 });
*
* // Access shared (cross-user) storage
* await ctx.storage.ephemeral.shared.set('globalCounter', count);
* ```
*/
export declare function getContext(): ExtensionContext;
/**
* Get an extension by its full identifier in the form of: `publisher.name`.

View File

@@ -23,7 +23,6 @@ export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as sqlLab from './sqlLab';
export * as storage from './storage';
export * as views from './views';
export * as contributions from './contributions';
export * as theme from './theme';

View File

@@ -62,13 +62,6 @@ export interface Tab {
*/
id: string;
/**
* The stable backend-assigned ID for this tab (the tabstateview integer ID).
* Set once the tab has been persisted to the backend. Undefined for new tabs
* before the first backend sync.
*/
backendId?: string;
/**
* The display title of the tab.
* This is what users see in the tab header.

View File

@@ -1,155 +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 type { JsonValue, StorageAccessor } from './types';
/**
* @fileoverview Ephemeral State API for Superset extensions (Tier 2 Storage).
*
* Provides short-lived KV storage backed by the configured server-side cache
* backend (Redis, Memcached, or filesystem). Automatically expires based on TTL.
* Not guaranteed to survive server restarts.
*
* By default, all operations are user-scoped (private to the current user).
* Use `shared` to access state that is visible to all users.
*
* Cache keys are namespaced automatically:
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
* - Shared (global): superset-ext:{extension_id}:{key}
*
* @example
* ```typescript
* import { ephemeralState } from '@apache-superset/core/storage';
*
* // User-scoped state (default - private to current user)
* const progress = await ephemeralState.get('job_progress');
* await ephemeralState.set('job_progress', { pct: 42 }, { ttl: 300 });
* await ephemeralState.remove('job_progress');
*
* // Shared state (explicit opt-in - visible to all users)
* const result = await ephemeralState.shared.get('shared_result');
* await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] });
* await ephemeralState.shared.remove('shared_result');
* ```
*/
/**
* Options for setting ephemeral state values.
*/
export interface SetOptions {
/**
* Time-to-live in seconds. When omitted, the server uses CACHE_DEFAULT_TIMEOUT.
*/
ttl?: number;
}
/**
* Interface for scoped ephemeral state access.
* Extends StorageAccessor with TTL-specific options for set().
*/
export interface EphemeralStateAccessor extends StorageAccessor {
/**
* Set a value in scoped ephemeral state with TTL.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
* @param options Optional settings including TTL.
*/
set(key: string, value: JsonValue, options?: SetOptions): Promise<void>;
}
/**
* Get a value from user-scoped ephemeral state.
*
* Data is automatically scoped to the current authenticated user.
* Other users cannot see or modify this data.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found or expired.
*
* @example
* ```typescript
* const progress = await ephemeralState.get('job_progress');
* if (progress !== null) {
* updateProgressBar(progress.pct);
* }
* ```
*/
export declare function get(key: string): Promise<JsonValue | null>;
/**
* Set a value in user-scoped ephemeral state with TTL.
*
* Data is automatically scoped to the current authenticated user.
* Other users cannot see or modify this data.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
* @param options Optional settings including TTL (defaults to server CACHE_DEFAULT_TIMEOUT).
*
* @example
* ```typescript
* // Store with server default TTL (CACHE_DEFAULT_TIMEOUT)
* await ephemeralState.set('recent_items', ['item1', 'item2']);
*
* // Store with custom TTL (5 minutes)
* await ephemeralState.set('temp_selection', data, { ttl: 300 });
* ```
*/
export declare function set(
key: string,
value: JsonValue,
options?: SetOptions,
): Promise<void>;
/**
* Remove a value from user-scoped ephemeral state.
*
* @param key The key to remove.
*
* @example
* ```typescript
* await ephemeralState.remove('recent_items');
* ```
*/
export declare function remove(key: string): Promise<void>;
/**
* Shared (global) ephemeral state accessor.
*
* Accessor for state that is shared across all users.
* Use this for data that needs to be visible to everyone, such as
* job progress indicators or shared computation results.
*
* WARNING: Data stored via shared is visible to all users of the extension.
* Do not store user-specific or sensitive data here.
*
* @example
* ```typescript
* // Get shared job progress
* const progress = await ephemeralState.shared.get('computation_progress');
*
* // Update shared job progress
* await ephemeralState.shared.set('computation_progress', { pct: 75 });
*
* // Clear shared state
* await ephemeralState.shared.remove('computation_progress');
* ```
*/
export declare const shared: EphemeralStateAccessor;

View File

@@ -1,60 +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.
*/
/**
* @fileoverview Storage API for Superset extensions.
*
* This module provides storage tiers for extensions:
*
* - **localState** (Tier 1): Browser localStorage - persists across sessions
* - **sessionState** (Tier 1): Browser sessionStorage - cleared on tab close
* - **ephemeralState** (Tier 2): Server-side cache with TTL - short-lived
* - **persistentState** (Tier 3): Database storage - durable [future]
*
* All tiers follow the same API pattern:
* - User-scoped by default (private to current user)
* - `shared` accessor for data visible to all users
*
* @example
* ```typescript
* import { localState, sessionState, ephemeralState } from '@apache-superset/core/storage';
*
* // Tier 1 - localStorage (persists across browser sessions)
* await localState.set('sidebar_collapsed', true);
* const isCollapsed = await localState.get('sidebar_collapsed');
*
* // Tier 1 - sessionStorage (cleared on tab close)
* await sessionState.set('wizard_step', 3);
* const step = await sessionState.get('wizard_step');
*
* // Tier 2 - Server cache (short-lived, with TTL)
* await ephemeralState.set('job_progress', { pct: 42 }, { ttl: 300 });
* const progress = await ephemeralState.get('job_progress');
*
* // Shared state (visible to all users)
* await localState.shared.set('device_id', 'abc-123');
* await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] });
* ```
*/
export * as localState from './localState';
export * as sessionState from './sessionState';
export * as ephemeralState from './ephemeralState';
export * as persistentState from './persistentState';
export * from './types';

View File

@@ -1,121 +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.
*/
/**
* @fileoverview Local State API for Superset extensions (Tier 1 Storage).
*
* Provides client-side KV storage backed by the browser's localStorage.
* Data persists across browser sessions but is per-device (not shared across
* devices or synced to the server).
*
* By default, all operations are user-scoped (private to the current user).
* Use `shared` to access state visible to all users on the same browser.
*
* Key patterns:
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
* - Shared: superset-ext:{extension_id}:{key}
*
* The API is async to maintain compatibility with a future sandboxed execution
* model where storage calls would go through a postMessage bridge.
*
* @example
* ```typescript
* import { localState } from '@apache-superset/core/storage';
*
* // User-scoped state (default - private to current user)
* const isCollapsed = await localState.get('sidebar_collapsed');
* await localState.set('sidebar_collapsed', true);
* await localState.remove('sidebar_collapsed');
*
* // Shared state (visible to all users on same browser)
* const deviceId = await localState.shared.get('device_id');
* await localState.shared.set('device_id', 'abc-123');
* ```
*/
import type { JsonValue, StorageAccessor } from './types';
/**
* Get a value from user-scoped local state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser cannot see or modify this data.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*
* @example
* ```typescript
* const isCollapsed = await localState.get('sidebar_collapsed');
* if (isCollapsed) {
* collapseSidebar();
* }
* ```
*/
export declare function get(key: string): Promise<JsonValue | null>;
/**
* Set a value in user-scoped local state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser cannot see or modify this data.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
*
* @example
* ```typescript
* await localState.set('sidebar_collapsed', true);
* await localState.set('panel_width', 300);
* ```
*/
export declare function set(key: string, value: JsonValue): Promise<void>;
/**
* Remove a value from user-scoped local state.
*
* @param key The key to remove.
*
* @example
* ```typescript
* await localState.remove('sidebar_collapsed');
* ```
*/
export declare function remove(key: string): Promise<void>;
/**
* Shared local state accessor.
*
* Accessor for state that is shared across all users on the
* same browser/device. Use this for device-specific settings that should
* persist regardless of which user is logged in.
*
* WARNING: Data stored via shared is visible to all users on this browser.
* Do not store user-specific or sensitive data here.
*
* @example
* ```typescript
* // Get device-specific setting
* const deviceId = await localState.shared.get('device_id');
*
* // Set device-specific setting
* await localState.shared.set('last_used_printer', 'HP-1234');
* ```
*/
export declare const shared: StorageAccessor;

View File

@@ -1,123 +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 type { JsonValue, StorageAccessor } from './types';
/**
* @fileoverview Persistent State API for Superset extensions (Tier 3 Storage).
*
* Provides durable KV storage backed by a dedicated database table.
* Data survives server restarts, cache evictions, and browser clears.
* Suitable for user preferences, saved state, and any data that must
* not be lost.
*
* By default, all operations are user-scoped (private to the current user).
* Use `shared` to access state that is visible to all users of the extension.
*
* Database keys are namespaced automatically:
* - User-scoped (default): (extension_id, user_id, key)
* - Shared (global): (extension_id, null, key)
*
* @example
* ```typescript
* import { persistentState } from '@apache-superset/core/storage';
*
* // User-scoped state (default - private to current user)
* const prefs = await persistentState.get('preferences');
* await persistentState.set('preferences', { theme: 'dark', locale: 'en' });
* await persistentState.remove('preferences');
*
* // Shared state (explicit opt-in - visible to all users)
* const config = await persistentState.shared.get('global_config');
* await persistentState.shared.set('global_config', { version: 2 });
* await persistentState.shared.remove('global_config');
* ```
*/
/**
* Get a value from user-scoped persistent state.
*
* Data is automatically scoped to the current authenticated user.
* Other users cannot see or modify this data.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*
* @example
* ```typescript
* const prefs = await persistentState.get('preferences');
* if (prefs !== null) {
* applyPreferences(prefs);
* }
* ```
*/
export declare function get(key: string): Promise<JsonValue | null>;
/**
* Set a value in user-scoped persistent state.
*
* Data is automatically scoped to the current authenticated user.
* Other users cannot see or modify this data.
* Data persists indefinitely until explicitly removed.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
*
* @example
* ```typescript
* await persistentState.set('preferences', { theme: 'dark', locale: 'en' });
* ```
*/
export declare function set(key: string, value: JsonValue): Promise<void>;
/**
* Remove a value from user-scoped persistent state.
*
* @param key The key to remove.
*
* @example
* ```typescript
* await persistentState.remove('preferences');
* ```
*/
export declare function remove(key: string): Promise<void>;
/**
* Shared (global) persistent state accessor.
*
* Accessor for state that is shared across all users of the extension.
* Use this for extension-wide configuration, shared datasets, or any
* data that should be accessible to all users regardless of identity.
*
* WARNING: Data stored via shared is visible to all users of the extension.
* Do not store user-specific or sensitive data here.
*
* @example
* ```typescript
* // Read shared extension config
* const config = await persistentState.shared.get('global_config');
*
* // Update shared config (typically admin-only)
* await persistentState.shared.set('global_config', { version: 2 });
*
* // Remove shared config entry
* await persistentState.shared.remove('global_config');
* ```
*/
export declare const shared: StorageAccessor;

View File

@@ -1,122 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Session State API for Superset extensions (Tier 1 Storage).
*
* Provides client-side KV storage backed by the browser's sessionStorage.
* Data is cleared when the browser tab/window is closed. Use this for
* truly transient UI state that should not persist across sessions.
*
* By default, all operations are user-scoped (private to the current user).
* Use `shared` to access state visible to all users on the same browser tab.
*
* Key patterns:
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
* - Shared: superset-ext:{extension_id}:{key}
*
* The API is async to maintain compatibility with a future sandboxed execution
* model where storage calls would go through a postMessage bridge.
*
* @example
* ```typescript
* import { sessionState } from '@apache-superset/core/storage';
*
* // User-scoped state (default - private to current user, cleared on tab close)
* const wizardStep = await sessionState.get('wizard_step');
* await sessionState.set('wizard_step', 3);
* await sessionState.remove('wizard_step');
*
* // Shared state (visible to all users on same tab)
* const tempData = await sessionState.shared.get('temp_data');
* await sessionState.shared.set('temp_data', { draft: true });
* ```
*/
import type { JsonValue, StorageAccessor } from './types';
/**
* Get a value from user-scoped session state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser tab cannot see or modify this data.
* Data is cleared when the tab/window is closed.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*
* @example
* ```typescript
* const wizardStep = await sessionState.get('wizard_step');
* if (wizardStep !== null) {
* resumeWizard(wizardStep);
* }
* ```
*/
export declare function get(key: string): Promise<JsonValue | null>;
/**
* Set a value in user-scoped session state.
*
* Data is automatically scoped to the current authenticated user.
* Other users on the same browser tab cannot see or modify this data.
* Data is cleared when the tab/window is closed.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
*
* @example
* ```typescript
* await sessionState.set('wizard_step', 3);
* await sessionState.set('unsaved_form', formData);
* ```
*/
export declare function set(key: string, value: JsonValue): Promise<void>;
/**
* Remove a value from user-scoped session state.
*
* @param key The key to remove.
*
* @example
* ```typescript
* await sessionState.remove('wizard_step');
* ```
*/
export declare function remove(key: string): Promise<void>;
/**
* Shared session state accessor.
*
* Accessor for state that is shared across all users on the
* same browser tab. Data is cleared when the tab/window is closed.
*
* WARNING: Data stored via shared is visible to all users on this tab.
* Do not store user-specific or sensitive data here.
*
* @example
* ```typescript
* // Store temporary shared data
* await sessionState.shared.set('temp_computation', result);
*
* // Retrieve temporary shared data
* const result = await sessionState.shared.get('temp_computation');
* ```
*/
export declare const shared: StorageAccessor;

View File

@@ -1,83 +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.
*/
/**
* @fileoverview Shared types for extension storage APIs.
*
* These types are shared across all storage tiers (local, session, ephemeral,
* persistent) to ensure a consistent API pattern.
*/
/**
* JSON-compatible value type.
* These are the only values that can be safely serialized/deserialized via JSON.
*/
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Base interface for a storage accessor.
* All storage tiers implement this interface for both user-scoped and shared access.
*/
export interface StorageAccessor {
/**
* Get a value from storage.
*
* @param key The key to retrieve.
* @returns The stored value, or null if not found.
*/
get(key: string): Promise<JsonValue | null>;
/**
* Set a value in storage.
*
* @param key The key to store.
* @param value The value to store (must be JSON-serializable).
* @param options Optional settings (varies by tier).
*/
set(
key: string,
value: JsonValue,
options?: Record<string, JsonValue>,
): Promise<void>;
/**
* Remove a value from storage.
*
* @param key The key to remove.
*/
remove(key: string): Promise<void>;
}
/**
* Base interface for a storage tier.
* All storage tiers implement this interface with user-scoped default and shared accessor.
*/
export interface StorageTier extends StorageAccessor {
/**
* Shared storage accessor.
* Data stored via shared is visible to all users.
*/
shared: StorageAccessor;
}

View File

@@ -21,11 +21,7 @@ import { styled, css } from '@apache-superset/core/theme';
export const ControlSubSectionHeader = styled.div`
${({ theme }) => css`
font-weight: ${theme.fontWeightStrong};
margin-top: ${theme.sizeUnit * 3}px;
margin-bottom: ${theme.sizeUnit}px;
font-size: ${theme.fontSizeSM}px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${theme.colorTextSecondary};
`}
`;

View File

@@ -17,8 +17,14 @@
* under the License.
*/
// eslint-disable-next-line no-restricted-syntax -- whole React import is required for `reactify.test.tsx` Jest test passing.
import { Component, ComponentClass, WeakValidationMap } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import type {
ComponentType,
WeakValidationMap,
ForwardRefExoticComponent,
PropsWithoutRef,
RefAttributes,
} from 'react';
// TODO: Note that id and className can collide between Props and ReactifyProps
// leading to (likely) unexpected behaviors. We should either require Props to not
@@ -49,66 +55,90 @@ export interface RenderFuncType<Props> {
propTypes?: WeakValidationMap<Props & ReactifyProps>;
}
export interface ReactifiedComponentRef {
container?: HTMLDivElement;
}
export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
>;
// Return the widest public type that covers "use it as a React component" so
// TypeScript consumers who previously relied on `ComponentClass<...>` still
// compile. Callers that want the new ref-aware surface can narrow to
// `ReactifiedComponent<Props>` explicitly.
export default function reactify<Props extends object>(
renderFn: RenderFuncType<Props>,
callbacks?: LifeCycleCallbacks,
): ComponentClass<Props & ReactifyProps> {
class ReactifiedComponent extends Component<Props & ReactifyProps> {
container?: HTMLDivElement;
): ComponentType<Props & ReactifyProps> {
const ReactifiedComponent = forwardRef<
ReactifiedComponentRef,
Props & ReactifyProps
>(function ReactifiedComponent(props, ref) {
const containerRef = useRef<HTMLDivElement>(null);
// Keep the latest props available to the unmount callback — legacy
// consumers read values off `this.props` (e.g. ReactNVD3 uses id).
const propsRef = useRef(props);
propsRef.current = props;
constructor(props: Props & ReactifyProps) {
super(props);
this.setContainerRef = this.setContainerRef.bind(this);
}
// Expose container via ref for external access
useImperativeHandle(
ref,
() => ({
get container() {
return containerRef.current ?? undefined;
},
}),
[],
);
componentDidMount() {
this.execute();
}
componentDidUpdate() {
this.execute();
}
componentWillUnmount() {
this.container = undefined;
if (callbacks?.componentWillUnmount) {
callbacks.componentWillUnmount.bind(this)();
// Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
useEffect(() => {
if (containerRef.current) {
renderFn(containerRef.current, props);
}
}
});
setContainerRef(ref: HTMLDivElement) {
this.container = ref;
}
// Cleanup on unmount
useEffect(
() => () => {
if (callbacks?.componentWillUnmount) {
// Preserve legacy behavior where `this` was a component instance
// exposing `props`. The class version cleared `this.container`
// before invoking componentWillUnmount, so mirror that here to
// prevent callbacks from touching a DOM node that's being torn
// down.
callbacks.componentWillUnmount.call({
container: undefined,
props: propsRef.current,
});
}
},
[],
);
execute() {
if (this.container) {
renderFn(this.container, this.props);
}
}
const { id, className } = props;
render() {
const { id, className } = this.props;
return <div ref={this.setContainerRef} id={id} className={className} />;
}
}
const ReactifiedClass: ComponentClass<Props & ReactifyProps> =
ReactifiedComponent;
return <div ref={containerRef} id={id} className={className} />;
});
if (renderFn.displayName) {
ReactifiedClass.displayName = renderFn.displayName;
ReactifiedComponent.displayName = renderFn.displayName;
}
// eslint-disable-next-line react/forbid-foreign-prop-types
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- forwardRef static field types don't line up with renderFn's validator types
const result = ReactifiedComponent as any;
if (renderFn.propTypes) {
ReactifiedClass.propTypes = {
...ReactifiedClass.propTypes,
result.propTypes = {
...result.propTypes,
...renderFn.propTypes,
};
}
if (renderFn.defaultProps) {
ReactifiedClass.defaultProps = renderFn.defaultProps;
result.defaultProps = renderFn.defaultProps;
}
return ReactifiedComponent;
return result as unknown as ComponentType<Props & ReactifyProps>;
}

View File

@@ -41,7 +41,6 @@ export enum VizType {
LegacyBubble = 'bubble',
Line = 'echarts_timeseries_line',
MapBox = 'mapbox',
PointClusterMap = 'point_cluster_map',
MixedTimeseries = 'mixed_timeseries',
PairedTTest = 'paired_ttest',
ParallelCoordinates = 'para',

View File

@@ -319,11 +319,6 @@ export function AsyncAceEditor(
opacity: 0.5;
}
/* Style bracket matching to blend with theme */
.ace_editor .ace_bracket {
border-color: ${token.colorPrimaryBorderHover} !important;
}
/* Adjust cursor color */
.ace_editor .ace_cursor {
color: ${token.colorPrimaryText} !important;

View File

@@ -33,22 +33,20 @@ import type { PlaceholderProps } from './types';
function DefaultPlaceholder({
width,
height,
showLoadingForImport = true,
showLoadingForImport = false,
placeholderStyle: style,
}: PlaceholderProps) {
if (showLoadingForImport) {
return (
return (
// since `width` defaults to 100%, we can display the placeholder once
// height is specified.
(height && (
<div key="async-asm-placeholder" style={{ width, height, ...style }}>
<Loading position="floating" size="s" />
{showLoadingForImport && <Loading position="floating" />}
</div>
);
}
if (height) {
return (
<div key="async-asm-placeholder" style={{ width, height, ...style }} />
);
}
return null;
)) ||
// `|| null` is for in case of height=0.
null
);
}
/**

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from '../../spec';
import { render, screen } from '../../spec';
import CodeSyntaxHighlighter from './index';
// Simple mock that just returns the content
@@ -153,44 +153,4 @@ describe('CodeSyntaxHighlighter', () => {
expect(screen.getByText('SELECT * FROM users;')).toBeInTheDocument();
});
test('shows copy button by default', () => {
render(
<CodeSyntaxHighlighter language="sql">SELECT 1;</CodeSyntaxHighlighter>,
);
expect(screen.getByTitle('Copy to clipboard')).toBeInTheDocument();
});
test('hides copy button when showCopyButton is false', () => {
render(
<CodeSyntaxHighlighter language="sql" showCopyButton={false}>
SELECT 1;
</CodeSyntaxHighlighter>,
);
expect(screen.queryByTitle('Copy to clipboard')).not.toBeInTheDocument();
});
test('copy button does not throw when clipboard API is unavailable', () => {
const originalClipboard = navigator.clipboard;
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
});
document.execCommand = jest.fn().mockReturnValue(true);
render(
<CodeSyntaxHighlighter language="sql">SELECT 1;</CodeSyntaxHighlighter>,
);
expect(() =>
fireEvent.click(screen.getByTitle('Copy to clipboard')),
).not.toThrow();
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
configurable: true,
});
});
});

View File

@@ -16,14 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import SyntaxHighlighterBase from 'react-syntax-highlighter/dist/cjs/light';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import tomorrow from 'react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow-night';
import { css, isThemeDark, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import copyTextToClipboard from '../../utils/copy';
import { Icons } from '../Icons';
import { isThemeDark, useTheme } from '@apache-superset/core/theme';
export type SupportedLanguage = 'sql' | 'htmlbars' | 'markdown' | 'json';
@@ -34,7 +31,6 @@ export interface CodeSyntaxHighlighterProps {
showLineNumbers?: boolean;
wrapLines?: boolean;
style?: any; // Override theme style if needed
showCopyButton?: boolean;
}
// Track which languages have been registered to avoid duplicate registrations
@@ -80,14 +76,11 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
showLineNumbers = false,
wrapLines = true,
style: overrideStyle,
showCopyButton = true,
}) => {
const theme = useTheme();
const [isLanguageReady, setIsLanguageReady] = useState(
registeredLanguages.has(language),
);
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const loadLanguage = async () => {
@@ -100,21 +93,6 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
loadLanguage();
}, [language]);
useEffect(
() => () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
},
[],
);
const handleCopy = useCallback(() => {
copyTextToClipboard(() => Promise.resolve(children)).then(() => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
setCopied(true);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500);
});
}, [children]);
const isDark = isThemeDark(theme);
const themeStyle = overrideStyle || (isDark ? tomorrow : github);
@@ -126,79 +104,32 @@ export const CodeSyntaxHighlighter: React.FC<CodeSyntaxHighlighterProps> = ({
...customStyle,
};
const copyButton = showCopyButton && (
<button
type="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleCopy();
}}
title={copied ? t('Copied!') : t('Copy to clipboard')}
css={css`
position: absolute;
top: ${theme.sizeUnit}px;
right: ${theme.sizeUnit}px;
background: none;
border: none;
cursor: pointer;
padding: ${theme.sizeUnit}px;
color: ${copied ? theme.colorSuccess : theme.colorTextSecondary};
line-height: 1;
border-radius: ${theme.borderRadius}px;
&:hover {
color: ${copied ? theme.colorSuccess : theme.colorText};
background: ${theme.colorBgTextHover};
}
`}
>
{copied ? (
<Icons.CheckOutlined style={{ fontSize: theme.fontSizeSM }} />
) : (
<Icons.CopyOutlined style={{ fontSize: theme.fontSizeSM }} />
)}
</button>
);
// Show a simple pre-formatted text while language is loading
if (!isLanguageReady) {
return (
<div
css={css`
position: relative;
`}
<pre
style={{
...defaultCustomStyle,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
margin: 0,
}}
>
{copyButton}
<pre
style={{
...defaultCustomStyle,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
margin: 0,
}}
>
{children}
</pre>
</div>
{children}
</pre>
);
}
return (
<div
css={css`
position: relative;
`}
<SyntaxHighlighterBase
language={language}
style={themeStyle}
customStyle={defaultCustomStyle}
showLineNumbers={showLineNumbers}
wrapLines={wrapLines}
>
{copyButton}
<SyntaxHighlighterBase
language={language}
style={themeStyle}
customStyle={defaultCustomStyle}
showLineNumbers={showLineNumbers}
wrapLines={wrapLines}
>
{children}
</SyntaxHighlighterBase>
</div>
{children}
</SyntaxHighlighterBase>
);
};

View File

@@ -115,7 +115,6 @@ import {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
QuestionCircleOutlined,
ReloadOutlined,
@@ -271,7 +270,6 @@ const AntdIcons = {
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
PushpinFilled,
PushpinOutlined,
ReloadOutlined,
QuestionCircleOutlined,

View File

@@ -158,13 +158,11 @@ test('passes all props through to AgGridReact', () => {
/>,
);
// onGridReady and onFirstDataRendered are intercepted by the component to expose
// the grid API on the container element; the wrapped function is passed instead.
expect(AgGridReact).toHaveBeenCalledWith(
expect.objectContaining({
rowData: mockRowData,
columnDefs: mockColumnDefs,
onGridReady: expect.any(Function),
onGridReady,
onCellClicked,
pagination: true,
paginationPageSize: 10,
@@ -173,47 +171,6 @@ test('passes all props through to AgGridReact', () => {
);
});
test('onGridReady wrapper calls user callback and exposes api on container', () => {
const onGridReady = jest.fn();
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
onGridReady={onGridReady}
/>,
);
// Retrieve the wrapped handler that was passed to AgGridReact
const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0];
const wrappedOnGridReady = lastCall.onGridReady as Function;
const mockApi = { setGridOption: jest.fn() };
wrappedOnGridReady({ api: mockApi });
// The user-provided callback must be forwarded
expect(onGridReady).toHaveBeenCalledWith({ api: mockApi });
});
test('onFirstDataRendered wrapper calls user callback', () => {
const onFirstDataRendered = jest.fn();
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
onFirstDataRendered={onFirstDataRendered}
/>,
);
const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0];
const wrappedOnFirstDataRendered = lastCall.onFirstDataRendered as Function;
wrappedOnFirstDataRendered({ firstRow: 0 });
expect(onFirstDataRendered).toHaveBeenCalledWith({ firstRow: 0 });
});
test('applies custom theme colors from Superset theme', () => {
const customTheme = {
...supersetTheme,

View File

@@ -16,28 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo, useRef, useCallback, forwardRef } from 'react';
import { useMemo, forwardRef } from 'react';
import { css } from '@emotion/react';
import { AgGridReact, type AgGridReactProps } from 'ag-grid-react';
import {
themeQuartz,
colorSchemeDark,
colorSchemeLight,
type GridApi,
type GridReadyEvent,
type FirstDataRenderedEvent,
} from 'ag-grid-community';
import { useTheme, useThemeMode } from '@apache-superset/core/theme';
// Note: With ag-grid v34's new theming API, CSS files are injected automatically
// Do NOT import 'ag-grid-community/styles/ag-grid.css' or theme CSS files
// Extends HTMLDivElement with ag-grid state attached to the container for downloadAsImage.
export interface AgGridContainerElement extends HTMLDivElement {
_agGridApi?: GridApi;
_agGridFirstDataRendered?: boolean;
}
export interface ThemedAgGridReactProps extends AgGridReactProps {
/**
* Optional theme parameter overrides to customize specific ag-grid theme values.
@@ -80,13 +71,9 @@ export interface ThemedAgGridReactProps extends AgGridReactProps {
export const ThemedAgGridReact = forwardRef<
AgGridReact,
ThemedAgGridReactProps
>(function ThemedAgGridReact(
{ themeOverrides, onGridReady, onFirstDataRendered, ...props },
ref,
) {
>(function ThemedAgGridReact({ themeOverrides, ...props }, ref) {
const theme = useTheme();
const isDarkMode = useThemeMode();
const containerRef = useRef<AgGridContainerElement>(null);
// Get the appropriate ag-grid theme based on dark/light mode
const agGridTheme = useMemo(() => {
@@ -153,32 +140,8 @@ export const ThemedAgGridReact = forwardRef<
return baseTheme.withParams(finalParams);
}, [theme, isDarkMode, themeOverrides]);
// Expose gridApi and first-data-rendered flag on the container for downloadAsImage.
const handleGridReady = useCallback(
(event: GridReadyEvent) => {
if (containerRef.current) {
containerRef.current._agGridFirstDataRendered = false;
containerRef.current._agGridApi = event.api;
}
onGridReady?.(event);
},
[onGridReady],
);
// Mark the container once rows are painted so downloadAsImage can gate on readiness.
const handleFirstDataRendered = useCallback(
(event: FirstDataRenderedEvent) => {
if (containerRef.current) {
containerRef.current._agGridFirstDataRendered = true;
}
onFirstDataRendered?.(event);
},
[onFirstDataRendered],
);
return (
<div
ref={containerRef}
css={css`
width: 100%;
height: 100%;
@@ -188,13 +151,7 @@ export const ThemedAgGridReact = forwardRef<
`}
data-themed-ag-grid="true"
>
<AgGridReact
ref={ref}
theme={agGridTheme}
onGridReady={handleGridReady}
onFirstDataRendered={handleFirstDataRendered}
{...props}
/>
<AgGridReact ref={ref} theme={agGridTheme} {...props} />
</div>
);
});

View File

@@ -201,7 +201,6 @@ export * from './Result';
export {
ThemedAgGridReact,
type ThemedAgGridReactProps,
type AgGridContainerElement,
setupAGGridModules,
defaultModules,
} from './ThemedAgGridReact';

View File

@@ -1,98 +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.
*/
const isSafari = (): boolean => {
const { userAgent } = navigator;
return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
};
// Use the new Clipboard API if the browser supports it
const copyTextWithClipboardApi = async (getText: () => Promise<string>) => {
// Safari (WebKit) does not support delayed generation of clipboard.
// This means that writing to the clipboard, from the moment the user
// interacts with the app, must be instantaneous.
// However, neither writeText nor write accepts a Promise, so
// we need to create a ClipboardItem that accepts said Promise to
// delay the text generation, as needed.
// Source: https://bugs.webkit.org/show_bug.cgi?id=222262P
if (isSafari()) {
try {
const clipboardItem = new ClipboardItem({
'text/plain': getText(),
});
await navigator.clipboard.write([clipboardItem]);
} catch {
// Fallback to default clipboard API implementation
const text = await getText();
await navigator.clipboard.writeText(text);
}
} else {
// For Blink, the above method won't work, but we can use the
// default (intended) API, since the delayed generation of the
// clipboard is now supported.
// Source: https://bugs.chromium.org/p/chromium/issues/detail?id=1014310
const text = await getText();
await navigator.clipboard.writeText(text);
}
};
const copyTextToClipboard = (getText: () => Promise<string>) =>
copyTextWithClipboardApi(getText)
// If the Clipboard API is not supported, fallback to the older method.
.catch(() =>
getText().then(
text =>
new Promise<void>((resolve, reject) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = text;
span.style.position = 'fixed';
span.style.top = '0';
span.style.clip = 'rect(0, 0, 0, 0)';
span.style.whiteSpace = 'pre';
document.body.appendChild(span);
range.selectNode(span);
selection.addRange(range);
try {
if (!document.execCommand('copy')) {
reject();
}
} catch (err) {
reject();
}
document.body.removeChild(span);
if (selection.removeRange) {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
}
resolve();
}),
),
);
export default copyTextToClipboard;

View File

@@ -17,7 +17,6 @@
* under the License.
*/
export { default as convertKeysToCamelCase } from './convertKeysToCamelCase';
export { default as copyTextToClipboard } from './copy';
export { default as ensureIsArray } from './ensureIsArray';
export { default as ensureIsInt } from './ensureIsInt';
export { default as isDefined } from './isDefined';

View File

@@ -19,9 +19,9 @@
import '@testing-library/jest-dom';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useEffect, useState } from 'react';
import { reactify } from '@superset-ui/core';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { RenderFuncType } from '../../../src/chart/components/reactify';
describe('reactify(renderFn)', () => {
@@ -52,48 +52,36 @@ describe('reactify(renderFn)', () => {
componentWillUnmount: willUnmountCb,
});
class TestComponent extends PureComponent<{}, { content: string }> {
constructor(props = {}) {
super(props);
this.state = { content: 'abc' };
}
function TestComponent() {
const [content, setContent] = useState('abc');
componentDidMount() {
setTimeout(() => {
this.setState({ content: 'def' });
useEffect(() => {
const timer = setTimeout(() => {
setContent('def');
}, 10);
}
return () => clearTimeout(timer);
}, []);
render() {
const { content } = this.state;
return <TheChart id="test" content={content} />;
}
return <TheChart id="test" content={content} />;
}
class AnotherTestComponent extends PureComponent<{}, {}> {
render() {
return <TheChartWithWillUnmountHook id="another_test" />;
}
function AnotherTestComponent() {
return <TheChartWithWillUnmountHook id="another_test" />;
}
test('returns a React component class', () =>
new Promise(done => {
render(<TestComponent />);
test('returns a React component and re-renders on prop changes', async () => {
render(<TestComponent />);
expect(renderFn).toHaveBeenCalledTimes(1);
expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
setTimeout(() => {
expect(renderFn).toHaveBeenCalledTimes(2);
expect(screen.getByText('def')).toBeInTheDocument();
expect(screen.getByText('def').parentNode).toHaveAttribute(
'id',
'test',
);
done(undefined);
}, 20);
}));
expect(renderFn).toHaveBeenCalledTimes(1);
expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
await waitFor(() => {
expect(screen.getByText('def')).toBeInTheDocument();
});
expect(screen.getByText('def').parentNode).toHaveAttribute('id', 'test');
expect(renderFn).toHaveBeenCalledTimes(2);
});
describe('displayName', () => {
test('has displayName if renderFn.displayName is defined', () => {
expect(TheChart.displayName).toEqual('BoldText');
@@ -126,20 +114,16 @@ describe('reactify(renderFn)', () => {
expect(AnotherChart.defaultProps).toBeUndefined();
});
});
test('does not try to render if not mounted', () => {
test('calls renderFn when container is set', () => {
const anotherRenderFn = jest.fn();
const AnotherChart = reactify(anotherRenderFn); // enables valid new AnotherChart() call
// @ts-expect-error
new AnotherChart({ id: 'test' }).execute();
expect(anotherRenderFn).not.toHaveBeenCalled();
const AnotherChart = reactify(anotherRenderFn);
const { unmount } = render(<AnotherChart id="test" />);
expect(anotherRenderFn).toHaveBeenCalled();
unmount();
});
test('calls willUnmount hook when it is provided', () => {
const { unmount } = render(<AnotherTestComponent />);
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
});
test('calls willUnmount hook when it is provided', () =>
new Promise(done => {
const { unmount } = render(<AnotherTestComponent />);
setTimeout(() => {
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
done(undefined);
}, 20);
}));
});

View File

@@ -0,0 +1,55 @@
<!--
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.
-->
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
### Bug Fixes
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
### Features
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
### Bug Fixes
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
### Features
- apply standardized form data to tier 2 charts ([#20530](https://github.com/apache/superset/issues/20530)) ([de524bc](https://github.com/apache/superset/commit/de524bc59f011fd361dcdb7d35c2cb51f7eba442))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
# [0.18.0](https://github.com/apache-superset/superset-ui/compare/v0.17.87...v0.18.0) (2021-08-30)
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box
## [0.17.61](https://github.com/apache-superset/superset-ui/compare/v0.17.60...v0.17.61) (2021-07-02)
**Note:** Version bump only for package @superset-ui/legacy-plugin-chart-map-box

View File

@@ -0,0 +1,52 @@
<!--
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.
-->
## @superset-ui/legacy-plugin-chart-map-box
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-map-box.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/legacy-plugin-chart-map-box)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Flegacy-plugin-chart-map-box?style=flat)](https://libraries.io/npm/@superset-ui%2Flegacy-plugin-chart-map-box)
This plugin provides MapBox for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to
lookup this chart throughout the app.
```js
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
```
Then use it via `SuperChart`. See
[storybook](https://apache-superset.github.io/superset-ui-plugins/?selectedKind=plugin-chart-map-box)
for more details.
```js
<SuperChart
chartType="map-box"
width={600}
height={600}
formData={...}
queriesData={[{
data: {...},
}]}
/>
```

View File

@@ -1,7 +1,7 @@
{
"name": "@superset-ui/plugin-chart-point-cluster-map",
"version": "1.0.0",
"description": "Superset Chart Plugin - Point Cluster Map",
"name": "@superset-ui/legacy-plugin-chart-map-box",
"version": "0.20.3",
"description": "Superset Legacy Chart - MapBox",
"keywords": [
"superset"
],
@@ -12,7 +12,7 @@
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/plugins/plugin-chart-point-cluster-map"
"directory": "superset-frontend/plugins/legacy-plugin-chart-map-box"
},
"license": "Apache-2.0",
"author": "Superset",
@@ -27,17 +27,16 @@
],
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.0.0",
"maplibre-gl": "^5.0.0",
"react-map-gl": "^8.0.0",
"prop-types": "^15.8.1",
"react-map-gl": "^6.1.19",
"supercluster": "^8.0.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^17.0.2 || ^19.0.0",
"react-dom": "^17.0.2 || ^19.0.0"
"@apache-superset/core": "*",
"mapbox-gl": "*",
"react": "^17.0.2"
},
"publishConfig": {
"access": "public"

View File

@@ -16,6 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
.maplibre .slice_container div {
.mapbox .slice_container div {
padding-top: 0px;
}

View File

@@ -0,0 +1,243 @@
/**
* 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.
*/
/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
/* eslint-disable react/forbid-prop-types, react/require-default-props */
import { Component } from 'react';
import MapGL from 'react-map-gl';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
import './MapBox.css';
const NOOP = () => {};
export const DEFAULT_MAX_ZOOM = 16;
export const DEFAULT_POINT_RADIUS = 60;
interface Viewport {
longitude: number;
latitude: number;
zoom: number;
isDragging?: boolean;
}
interface Clusterer {
getClusters(bbox: number[], zoom: number): GeoJSONLocation[];
}
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface MapBoxProps {
width?: number;
height?: number;
aggregatorName?: string;
clusterer: Clusterer; // Required - used for getClusters()
globalOpacity?: number;
hasCustomMetric?: boolean;
mapStyle?: string;
mapboxApiKey: string;
onViewportChange?: (viewport: Viewport) => void;
pointRadius?: number;
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
viewportLongitude?: number;
viewportLatitude?: number;
viewportZoom?: number;
}
interface MapBoxState {
viewport: Viewport;
}
const defaultProps: Partial<MapBoxProps> = {
width: 400,
height: 400,
globalOpacity: 1,
onViewportChange: NOOP,
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit: 'Pixels',
};
class MapBox extends Component<MapBoxProps, MapBoxState> {
static defaultProps = defaultProps;
constructor(props: MapBoxProps) {
super(props);
const fitBounds = this.computeFitBoundsViewport();
this.state = {
viewport: this.mergeViewportWithProps(fitBounds),
};
this.handleViewportChange = this.handleViewportChange.bind(this);
}
handleViewportChange(viewport: Viewport) {
this.setState({ viewport });
const { onViewportChange } = this.props;
onViewportChange!(viewport);
}
mergeViewportWithProps(
fitBounds: Viewport,
viewport: Viewport = fitBounds,
props: MapBoxProps = this.props,
useFitBoundsForUnset = true,
): Viewport {
const { viewportLongitude, viewportLatitude, viewportZoom } = props;
return {
...viewport,
longitude:
viewportLongitude ??
(useFitBoundsForUnset ? fitBounds.longitude : viewport.longitude),
latitude:
viewportLatitude ??
(useFitBoundsForUnset ? fitBounds.latitude : viewport.latitude),
zoom:
viewportZoom ?? (useFitBoundsForUnset ? fitBounds.zoom : viewport.zoom),
};
}
computeFitBoundsViewport(): Viewport {
const { width = 400, height = 400, bounds } = this.props;
if (bounds && bounds[0] && bounds[1]) {
const mercator = new WebMercatorViewport({ width, height }).fitBounds(
bounds,
);
return {
latitude: mercator.latitude,
longitude: mercator.longitude,
zoom: mercator.zoom,
};
}
return { latitude: 0, longitude: 0, zoom: 1 };
}
componentDidUpdate(prevProps: MapBoxProps) {
const { viewport } = this.state;
const fitBoundsInputsChanged =
prevProps.width !== this.props.width ||
prevProps.height !== this.props.height ||
prevProps.bounds !== this.props.bounds;
const viewportPropsChanged =
prevProps.viewportLongitude !== this.props.viewportLongitude ||
prevProps.viewportLatitude !== this.props.viewportLatitude ||
prevProps.viewportZoom !== this.props.viewportZoom;
if (!fitBoundsInputsChanged && !viewportPropsChanged) {
return;
}
const fitBounds = this.computeFitBoundsViewport();
const nextViewport = this.mergeViewportWithProps(
fitBounds,
viewport,
this.props,
fitBoundsInputsChanged || viewportPropsChanged,
);
const viewportChanged =
nextViewport.longitude !== viewport.longitude ||
nextViewport.latitude !== viewport.latitude ||
nextViewport.zoom !== viewport.zoom;
if (viewportChanged) {
this.setState({ viewport: nextViewport });
}
}
render() {
const {
width,
height,
aggregatorName,
clusterer,
globalOpacity,
mapStyle,
mapboxApiKey,
pointRadius,
pointRadiusUnit,
renderWhileDragging,
rgb,
hasCustomMetric,
bounds,
} = this.props;
const { viewport } = this.state;
const isDragging =
viewport.isDragging === undefined ? false : viewport.isDragging;
// Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
// to an area outside of the original bounds, no additional queries are made to the backend to
// retrieve additional data.
// add this variable to widen the visible area
const offsetHorizontal = ((width ?? 400) * 0.5) / 100;
const offsetVertical = ((height ?? 400) * 0.5) / 100;
// Guard against empty datasets where bounds may be undefined
const bbox =
bounds && bounds[0] && bounds[1]
? [
bounds[0][0] - offsetHorizontal,
bounds[0][1] - offsetVertical,
bounds[1][0] + offsetHorizontal,
bounds[1][1] + offsetVertical,
]
: [-180, -90, 180, 90]; // Default to world bounds
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
return (
<MapGL
{...viewport}
mapStyle={mapStyle}
width={width}
height={height}
mapboxApiAccessToken={mapboxApiKey}
onViewportChange={this.handleViewportChange}
preserveDrawingBuffer
>
<ScatterPlotGlowOverlay
{...viewport}
isDragging={isDragging}
locations={clusters}
dotRadius={pointRadius}
pointRadiusUnit={pointRadiusUnit}
rgb={rgb}
globalOpacity={globalOpacity}
compositeOperation="screen"
renderWhileDragging={renderWhileDragging}
aggregation={hasCustomMetric ? aggregatorName : undefined}
lngLatAccessor={(location: GeoJSONLocation) => {
const { coordinates } = location.geometry;
return [coordinates[0], coordinates[1]];
}}
/>
</MapGL>
);
}
}
export default MapBox;

View File

@@ -0,0 +1,425 @@
/**
* 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.
*/
/* eslint-disable react/require-default-props */
import { PureComponent } from 'react';
import { CanvasOverlay } from 'react-map-gl';
import { kmToPixels, MILES_PER_KM } from './utils/geo';
import roundDecimal from './utils/roundDecimal';
import luminanceFromRGB from './utils/luminanceFromRGB';
import 'mapbox-gl/dist/mapbox-gl.css';
// Shared radius bounds keep cluster and point sizing in sync.
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface RedrawParams {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}
interface DrawTextOptions {
fontHeight?: number;
label?: string | number;
radius?: number;
rgb?: (string | number)[];
shadow?: boolean;
}
interface ScatterPlotGlowOverlayProps {
aggregation?: string;
compositeOperation?: string;
dotRadius?: number;
globalOpacity?: number;
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
locations: GeoJSONLocation[];
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
zoom?: number;
isDragging?: boolean;
}
const defaultProps: Partial<ScatterPlotGlowOverlayProps> = {
// Same as browser default.
compositeOperation: 'source-over',
dotRadius: 4,
lngLatAccessor: (location: GeoJSONLocation) => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
],
renderWhileDragging: true,
};
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
aggregation: string | undefined,
): number | string => {
const count = properties.point_count as number;
if (!aggregation) {
return count;
}
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
return properties[aggregation] as number;
}
const { sum } = properties as { sum: number };
const mean = sum / count;
if (aggregation === 'mean') {
return Math.round(100 * mean) / 100;
}
const { squaredSum } = properties as { squaredSum: number };
const variance = squaredSum / count - (sum / count) ** 2;
if (aggregation === 'var') {
return Math.round(100 * variance) / 100;
}
if (aggregation === 'stdev') {
return Math.round(100 * Math.sqrt(variance)) / 100;
}
// fallback to point_count, this really shouldn't happen
return count;
};
class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps> {
static defaultProps = defaultProps;
constructor(props: ScatterPlotGlowOverlayProps) {
super(props);
this.redraw = this.redraw.bind(this);
}
drawText(
ctx: CanvasRenderingContext2D,
pixel: [number, number],
options: DrawTextOptions = {},
) {
const IS_DARK_THRESHOLD = 110;
const {
fontHeight = 0,
label = '',
radius = 0,
rgb = [0, 0, 0],
shadow = false,
} = options;
const maxWidth = radius * 1.8;
const luminance = luminanceFromRGB(
rgb[1] as number,
rgb[2] as number,
rgb[3] as number,
);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
ctx.font = `${fontHeight}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (shadow) {
ctx.shadowBlur = 15;
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
}
const textWidth = ctx.measureText(String(label)).width;
if (textWidth > maxWidth) {
const scale = fontHeight / textWidth;
ctx.font = `${scale * maxWidth}px sans-serif`;
}
const { compositeOperation } = this.props;
ctx.fillText(String(label), pixel[0], pixel[1]);
ctx.globalCompositeOperation = (compositeOperation ??
'source-over') as GlobalCompositeOperation;
ctx.shadowBlur = 0;
ctx.shadowColor = '';
}
// Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
redraw({ width, height, ctx, isDragging, project }: RedrawParams) {
const {
aggregation,
compositeOperation,
dotRadius,
globalOpacity,
lngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging,
rgb,
zoom,
} = this.props;
const radius = dotRadius ?? 4;
const clusterLabelMap: (number | string)[] = [];
locations.forEach((location, i) => {
if (location.properties.cluster) {
clusterLabelMap[i] = computeClusterLabel(
location.properties,
aggregation,
);
}
});
const finiteClusterLabels = clusterLabelMap
.map(value => Number(value))
.filter(value => Number.isFinite(value));
const safeMaxAbsLabel =
finiteClusterLabels.length > 0
? Math.max(
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
1,
)
: 1;
// Calculate min/max radius values for Pixels mode scaling
let minRadiusValue = Infinity;
let maxRadiusValue = -Infinity;
if (pointRadiusUnit === 'Pixels') {
locations.forEach(location => {
// Accept both null and undefined as "no value" and coerce potential numeric strings
if (
!location.properties.cluster &&
location.properties.radius != null
) {
const radiusValueRaw = location.properties.radius;
const radiusValue =
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
? null
: Number(radiusValueRaw);
if (radiusValue != null && Number.isFinite(radiusValue)) {
minRadiusValue = Math.min(minRadiusValue, radiusValue);
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
}
});
}
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation = (compositeOperation ??
'source-over') as GlobalCompositeOperation;
if ((renderWhileDragging || !isDragging) && locations) {
locations.forEach(function _forEach(
this: ScatterPlotGlowOverlay,
location: GeoJSONLocation,
i: number,
) {
const pixel = project(lngLatAccessor!(location)) as [number, number];
const pixelRounded: [number, number] = [
roundDecimal(pixel[0], 1),
roundDecimal(pixel[1], 1),
];
if (
pixelRounded[0] + radius >= 0 &&
pixelRounded[0] - radius < width &&
pixelRounded[1] + radius >= 0 &&
pixelRounded[1] - radius < height
) {
ctx.beginPath();
if (location.properties.cluster) {
const clusterLabel = clusterLabelMap[i];
// Validate clusterLabel is a finite number before using it for radius calculation
const numericLabel = Number(clusterLabel);
const safeNumericLabel = Number.isFinite(numericLabel)
? numericLabel
: 0;
const minClusterRadius =
pointRadiusUnit === 'Pixels'
? radius * MAX_POINT_RADIUS_RATIO
: radius * MIN_CLUSTER_RADIUS_RATIO;
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
const scaledRadius = roundDecimal(
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
1,
);
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
const [x, y] = pixelRounded;
const gradient = ctx.createRadialGradient(
x,
y,
scaledRadius,
x,
y,
0,
);
gradient.addColorStop(
1,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
);
gradient.addColorStop(
0,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
);
ctx.arc(
pixelRounded[0],
pixelRounded[1],
scaledRadius,
0,
Math.PI * 2,
);
ctx.fillStyle = gradient;
ctx.fill();
if (Number.isFinite(safeNumericLabel)) {
let label: string | number = clusterLabel;
const absLabel = Math.abs(safeNumericLabel);
const sign = safeNumericLabel < 0 ? '-' : '';
if (absLabel >= 10000) {
label = `${sign}${Math.round(absLabel / 1000)}k`;
} else if (absLabel >= 1000) {
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
}
this.drawText(ctx, pixelRounded, {
fontHeight,
label,
radius: scaledRadius,
rgb,
shadow: true,
});
}
} else {
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
const rawRadius = location.properties.radius;
const numericRadiusProperty =
rawRadius != null &&
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
? Number(rawRadius)
: null;
const radiusProperty =
numericRadiusProperty != null &&
Number.isFinite(numericRadiusProperty)
? numericRadiusProperty
: null;
const pointMetric = location.properties.metric ?? null;
let pointRadius: number = radiusProperty ?? defaultRadius;
let pointLabel: string | number | undefined;
if (radiusProperty != null) {
const pointLatitude = lngLatAccessor!(location)[1];
if (pointRadiusUnit === 'Kilometers') {
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
pointRadius = kmToPixels(pointRadius, pointLatitude, zoom ?? 0);
} else if (pointRadiusUnit === 'Miles') {
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
pointRadius = kmToPixels(
pointRadius * MILES_PER_KM,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Pixels') {
// Scale pixel values to a reasonable range (radius/6 to radius/3)
// This ensures points are visible and proportional to their values
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
if (
Number.isFinite(minRadiusValue) &&
Number.isFinite(maxRadiusValue) &&
maxRadiusValue > minRadiusValue
) {
// Normalize the value to 0-1 range, then scale to pixel range
const numericPointRadius = Number(pointRadius);
if (!Number.isFinite(numericPointRadius)) {
// fallback to minimum visible size when the value is not a finite number
pointRadius = MIN_POINT_RADIUS;
} else {
const normalizedValueRaw =
(numericPointRadius - minRadiusValue) /
(maxRadiusValue - minRadiusValue);
const normalizedValue = Math.max(
0,
Math.min(1, normalizedValueRaw),
);
pointRadius =
MIN_POINT_RADIUS +
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
}
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else if (
Number.isFinite(minRadiusValue) &&
minRadiusValue === maxRadiusValue
) {
// All values are the same, use a fixed medium size
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else {
// Use raw pixel values if they're already in a reasonable range
pointRadius = Math.max(
MIN_POINT_RADIUS,
Math.min(pointRadius, MAX_POINT_RADIUS),
);
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
}
}
if (pointMetric !== null) {
const numericMetric = parseFloat(String(pointMetric));
pointLabel = Number.isFinite(numericMetric)
? roundDecimal(numericMetric, 2)
: String(pointMetric);
}
// Fall back to default points if pointRadius wasn't a numerical column
if (!pointRadius) {
pointRadius = defaultRadius;
}
ctx.arc(
pixelRounded[0],
pixelRounded[1],
roundDecimal(pointRadius, 1),
0,
Math.PI * 2,
);
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
ctx.fill();
if (pointLabel !== undefined) {
this.drawText(ctx, pixelRounded, {
fontHeight: roundDecimal(pointRadius, 1),
label: pointLabel,
radius: pointRadius,
rgb,
shadow: false,
});
}
}
}
}, this);
}
}
render() {
return <CanvasOverlay redraw={this.redraw} />;
}
}
export default ScatterPlotGlowOverlay;

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { validateMapboxStylesUrl } from '@superset-ui/core';
import {
columnChoices,
ControlPanelConfig,
@@ -28,12 +29,12 @@ import {
const columnsConfig = sharedControls.entity;
const colorChoices = [
['#008b8b', t('Dark Cyan')],
['#800080', t('Purple')],
['#ffd700', t('Gold')],
['#454545', t('Dim Gray')],
['#dc143c', t('Crimson')],
['#228b22', t('Forest Green')],
['rgb(0, 139, 139)', t('Dark Cyan')],
['rgb(128, 0, 128)', t('Purple')],
['rgb(255, 215, 0)', t('Gold')],
['rgb(69, 69, 69)', t('Dim Gray')],
['rgb(220, 20, 60)', t('Crimson')],
['rgb(34, 139, 34)', t('Forest Green')],
];
const config: ControlPanelConfig = {
@@ -109,7 +110,7 @@ const config: ControlPanelConfig = {
'Either a numerical column or `Auto`, which scales the point based ' +
'on the largest cluster',
),
mapStateToProps: (state: any) => {
mapStateToProps: state => {
const datasourceChoices = columnChoices(state.datasource);
const choices: [string, string][] = [['Auto', t('Auto')]];
return {
@@ -144,7 +145,7 @@ const config: ControlPanelConfig = {
controlSetRows: [
[
{
name: 'map_label',
name: 'mapbox_label',
config: {
type: 'SelectControl',
multi: true,
@@ -156,7 +157,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 => ({
choices: columnChoices(state.datasource),
}),
},
@@ -188,66 +189,21 @@ const config: ControlPanelConfig = {
],
},
{
label: t('Map'),
tabOverride: 'customize',
expanded: true,
label: t('Visual Tweaks'),
controlSetRows: [
[
{
name: 'map_renderer',
name: 'render_while_dragging',
config: {
type: 'SelectControl',
label: t('Map Renderer'),
clearable: false,
renderTrigger: true,
choices: [
['maplibre', t('MapLibre (open-source)')],
['mapbox', t('Mapbox (API key required)')],
],
default: 'maplibre',
type: 'CheckboxControl',
label: t('Live render'),
default: true,
description: t(
'MapLibre is open-source and requires no API key. Mapbox requires MAPBOX_API_KEY to be configured on the server.',
'Points and clusters will update as the viewport is being changed',
),
},
},
],
[
{
name: 'maplibre_style',
config: {
type: 'SelectControl',
label: t('Map Style'),
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)'),
],
],
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) =>
controls?.map_renderer?.value !== 'mapbox',
},
},
],
[
{
name: 'mapbox_style',
@@ -257,42 +213,22 @@ const config: ControlPanelConfig = {
clearable: false,
renderTrigger: true,
freeForm: true,
validators: [validateMapboxStylesUrl],
choices: [
['mapbox://styles/mapbox/streets-v12', t('Streets')],
['mapbox://styles/mapbox/outdoors-v12', t('Outdoors')],
['mapbox://styles/mapbox/light-v11', t('Light')],
['mapbox://styles/mapbox/dark-v11', t('Dark')],
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
['mapbox://styles/mapbox/streets-v9', t('Streets')],
['mapbox://styles/mapbox/dark-v9', t('Dark')],
['mapbox://styles/mapbox/light-v9', t('Light')],
[
'mapbox://styles/mapbox/satellite-streets-v12',
'mapbox://styles/mapbox/satellite-streets-v9',
t('Satellite Streets'),
],
['mapbox://styles/mapbox/satellite-v9', t('Satellite')],
['mapbox://styles/mapbox/outdoors-v9', t('Outdoors')],
],
default: 'mapbox://styles/mapbox/light-v11',
default: 'mapbox://styles/mapbox/light-v9',
description: t(
'Base layer map style. Accepts a Mapbox style URL (mapbox://styles/...).',
),
visibility: ({ controls }: any) =>
controls?.map_renderer?.value === 'mapbox',
},
},
],
],
},
{
label: t('Visual Tweaks'),
tabOverride: 'customize',
controlSetRows: [
[
{
name: 'render_while_dragging',
config: {
type: 'CheckboxControl',
label: t('Live render'),
renderTrigger: true,
default: true,
description: t(
'Points and clusters will update as the viewport is being changed',
'Base layer map style. See Mapbox documentation: %s',
'https://docs.mapbox.com/help/glossary/style-url/',
),
},
},
@@ -303,9 +239,9 @@ const config: ControlPanelConfig = {
config: {
type: 'TextControl',
label: t('Opacity'),
renderTrigger: true,
default: 1,
isFloat: true,
renderTrigger: true,
description: t(
'Opacity of all clusters, points, and labels. Between 0 and 1.',
),
@@ -314,11 +250,10 @@ const config: ControlPanelConfig = {
],
[
{
name: 'map_color',
name: 'mapbox_color',
config: {
type: 'SelectControl',
freeForm: true,
renderTrigger: true,
label: t('RGB Color'),
default: colorChoices[0][0],
choices: colorChoices,
@@ -343,6 +278,7 @@ const config: ControlPanelConfig = {
isFloat: true,
description: t('Longitude of default viewport'),
places: 8,
// Viewport longitude changes shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -356,6 +292,7 @@ const config: ControlPanelConfig = {
isFloat: true,
description: t('Latitude of default viewport'),
places: 8,
// Viewport latitude changes shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -371,6 +308,7 @@ const config: ControlPanelConfig = {
default: '',
description: t('Zoom level of the map'),
places: 8,
// Viewport zoom shouldn't prompt user to re-run query
dontRefreshOnChange: true,
},
},
@@ -387,7 +325,7 @@ const config: ControlPanelConfig = {
),
},
},
formDataOverrides: (formData: any) => ({
formDataOverrides: formData => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
}),

View File

@@ -28,30 +28,31 @@ import controlPanel from './controlPanel';
const metadata = new ChartMetadata({
category: t('Map'),
credits: ['https://maplibre.org/'],
credits: ['https://www.mapbox.com/mapbox-gl-js/api/'],
description: '',
exampleGallery: [
{ url: example1, urlDark: example1Dark, caption: t('Light mode') },
{ url: example2, urlDark: example2Dark, caption: t('Dark mode') },
],
name: t('Point Cluster Map'),
name: t('MapBox'),
tags: [
t('Business'),
t('Intensity'),
t('Legacy'),
t('Density'),
t('Scatter'),
t('Transformable'),
],
thumbnail,
thumbnailDark,
useLegacyApi: true,
});
export default class ScatterMapChartPlugin extends ChartPlugin {
export default class MapBoxChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./MapLibre'),
loadChart: () => import('./MapBox'),
loadTransformProps: () => import('./transformProps'),
loadBuildQuery: () => import('./buildQuery'),
metadata,
controlPanel,
});

View File

@@ -0,0 +1,107 @@
/*
* 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.
*/
/* eslint-disable no-magic-numbers */
import { SuperChart } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import MapBoxChartPlugin from '@superset-ui/legacy-plugin-chart-map-box';
import { withResizableChartDemo } from '@storybook-shared';
import { generateData } from './data';
new MapBoxChartPlugin().configure({ key: 'map-box' }).register();
export default {
title: 'Legacy Chart Plugins/legacy-plugin-chart-map-box',
decorators: [withResizableChartDemo],
args: {
clusteringRadius: 60,
globalOpacity: 1,
pointRadius: 'Auto',
renderWhileDragging: true,
},
argTypes: {
clusteringRadius: {
control: { type: 'range', min: 0, max: 200, step: 10 },
description: 'Radius in pixels for clustering points',
},
globalOpacity: {
control: { type: 'range', min: 0, max: 1, step: 0.1 },
description: 'Opacity of map markers',
},
pointRadius: {
control: 'select',
options: ['Auto', 1, 2, 5, 10, 20, 50],
description: 'Size of point markers',
},
renderWhileDragging: {
control: 'boolean',
description: 'Render markers while dragging the map',
},
},
parameters: {
docs: {
description: {
component:
'Note: This chart requires a Mapbox API key to display. Without a valid key, the map background will not render.',
},
},
},
};
export const MapBoxViz = ({
clusteringRadius,
globalOpacity,
pointRadius,
renderWhileDragging,
width,
height,
}: {
clusteringRadius: number;
globalOpacity: number;
pointRadius: string | number;
renderWhileDragging: boolean;
width: number;
height: number;
}) => {
const theme = useTheme();
return (
<SuperChart
chartType="map-box"
width={width}
height={height}
queriesData={[{ data: generateData(theme) }]}
formData={{
all_columns_x: 'LON',
all_columns_y: 'LAT',
clustering_radius: String(clusteringRadius),
global_opacity: globalOpacity,
mapbox_color: 'rgb(244, 176, 42)',
mapbox_label: [],
mapbox_style: 'mapbox://styles/mapbox/light-v9',
pandas_aggfunc: 'sum',
point_radius: pointRadius,
point_radius_unit: 'Pixels',
render_while_dragging: renderWhileDragging,
viewport_latitude: 37.78711146014447,
viewport_longitude: -122.37633433151713,
viewport_zoom: 10.026425338292782,
}}
/>
);
};

View File

@@ -0,0 +1,162 @@
/**
* 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 Supercluster, {
type Options as SuperclusterOptions,
} from 'supercluster';
import { ChartProps } from '@superset-ui/core';
import { DEFAULT_POINT_RADIUS, DEFAULT_MAX_ZOOM } from './MapBox';
const NOOP = () => {};
const MIN_LONGITUDE = -180;
const MAX_LONGITUDE = 180;
const MIN_LATITUDE = -90;
const MAX_LATITUDE = 90;
const MIN_ZOOM = 0;
function toFiniteNumber(
value: string | number | null | undefined,
): number | undefined {
if (value === null || value === undefined) return undefined;
const normalizedValue = typeof value === 'string' ? value.trim() : value;
if (normalizedValue === '') return undefined;
const num = Number(normalizedValue);
return Number.isFinite(num) ? num : undefined;
}
function clampNumber(
value: number | undefined,
min: number,
max: number,
): number | undefined {
if (value === undefined) return undefined;
return Math.min(max, Math.max(min, value));
}
interface ClusterProperties {
metric: number;
sum: number;
squaredSum: number;
min: number;
max: number;
}
export default function transformProps(chartProps: ChartProps) {
const { width, height, formData, hooks, queriesData } = chartProps;
const { onError = NOOP, setControlValue = NOOP } = hooks;
const { bounds, geoJSON, hasCustomMetric, mapboxApiKey } =
queriesData[0].data;
const {
clusteringRadius,
globalOpacity,
mapboxColor,
mapboxStyle,
pandasAggfunc,
pointRadiusUnit,
renderWhileDragging,
viewportLongitude,
viewportLatitude,
viewportZoom,
} = formData;
// Validate mapbox color
const rgb = /^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$/.exec(mapboxColor);
if (rgb === null) {
onError("Color field must be of form 'rgb(%d, %d, %d)'");
return {};
}
const opts: SuperclusterOptions<ClusterProperties, ClusterProperties> = {
maxZoom: DEFAULT_MAX_ZOOM,
radius: clusteringRadius,
};
if (hasCustomMetric) {
opts.initial = () => ({
metric: 0,
sum: 0,
squaredSum: 0,
min: Infinity,
max: -Infinity,
});
opts.map = (prop: ClusterProperties) => ({
metric: prop.metric,
sum: prop.metric,
squaredSum: prop.metric ** 2,
min: prop.metric,
max: prop.metric,
});
opts.reduce = (accu: ClusterProperties, prop: ClusterProperties) => {
// Temporarily disable param-reassignment linting to work with supercluster's api
/* eslint-disable no-param-reassign */
accu.sum += prop.sum;
accu.squaredSum += prop.squaredSum;
accu.min = Math.min(accu.min, prop.min);
accu.max = Math.max(accu.max, prop.max);
/* eslint-enable no-param-reassign */
};
}
const clusterer = new Supercluster(opts);
clusterer.load(geoJSON.features);
return {
width,
height,
aggregatorName: pandasAggfunc,
bounds,
clusterer,
hasCustomMetric,
mapboxApiKey,
mapStyle: mapboxStyle,
onViewportChange({
latitude,
longitude,
zoom,
}: {
latitude: number;
longitude: number;
zoom: number;
}) {
setControlValue('viewport_longitude', longitude);
setControlValue('viewport_latitude', latitude);
setControlValue('viewport_zoom', zoom);
},
// Always use DEFAULT_POINT_RADIUS as the base radius for cluster sizing
// Individual point radii come from geoJSON properties.radius
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit,
renderWhileDragging,
rgb,
viewportLongitude: clampNumber(
toFiniteNumber(viewportLongitude),
MIN_LONGITUDE,
MAX_LONGITUDE,
),
viewportLatitude: clampNumber(
toFiniteNumber(viewportLatitude),
MIN_LATITUDE,
MAX_LATITUDE,
),
viewportZoom: clampNumber(
toFiniteNumber(viewportZoom),
MIN_ZOOM,
DEFAULT_MAX_ZOOM,
),
globalOpacity: Math.min(1, Math.max(0, toFiniteNumber(globalOpacity) ?? 1)),
};
}

View File

@@ -0,0 +1,381 @@
/*
* 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 ReactNode } from 'react';
import { render } from '@testing-library/react';
import MapBox from '../src/MapBox';
// Capture the most recent viewport props passed to MapGL
let lastMapGLProps: Record<string, unknown> = {};
const mockFitBounds = jest.fn();
jest.mock('react-map-gl', () => {
const MockMapGL = (props: Record<string, unknown>) => {
lastMapGLProps = props;
return <div data-test="map-gl">{props.children as ReactNode}</div>;
};
return { __esModule: true, default: MockMapGL };
});
jest.mock('@math.gl/web-mercator', () => ({
WebMercatorViewport: jest
.fn()
.mockImplementation(
({ width, height }: { width: number; height: number }) => ({
fitBounds: (bounds: [[number, number], [number, number]]) =>
mockFitBounds(bounds, width, height),
}),
),
}));
jest.mock('../src/ScatterPlotGlowOverlay', () => {
const MockOverlay = (props: Record<string, unknown>) => (
<div data-test="scatter-overlay" data-opacity={props.globalOpacity} />
);
return { __esModule: true, default: MockOverlay };
});
const defaultProps = {
width: 800,
height: 600,
clusterer: {
getClusters: jest.fn().mockReturnValue([]),
},
globalOpacity: 1,
mapboxApiKey: 'test-key',
mapStyle: 'mapbox://styles/mapbox/light-v9',
pointRadius: 60,
pointRadiusUnit: 'Pixels',
renderWhileDragging: true,
rgb: ['', 255, 0, 0] as (string | number)[],
hasCustomMetric: false,
bounds: [
[-74.0, 40.7],
[-73.9, 40.8],
] as [[number, number], [number, number]],
onViewportChange: jest.fn(),
};
beforeEach(() => {
lastMapGLProps = {};
jest.clearAllMocks();
mockFitBounds.mockImplementation(
(
bounds: [[number, number], [number, number]],
width: number,
height: number,
) => ({
latitude: Number(((bounds[0][1] + bounds[1][1]) / 2).toFixed(2)),
longitude: Number(((bounds[0][0] + bounds[1][0]) / 2).toFixed(2)),
zoom: Number((10 + width / 1000 + height / 10000).toFixed(2)),
}),
);
});
test('initializes viewport from bounds', () => {
render(<MapBox {...defaultProps} />);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('updates viewport when viewport props change', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('does not loop when viewport state matches new props', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
// Re-render with same props that match the initial viewport state
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-73.95}
viewportLatitude={40.75}
viewportZoom={10}
/>,
);
// Viewport should still be the fitBounds-computed values since props didn't change
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10);
});
test('passes globalOpacity to ScatterPlotGlowOverlay', () => {
const { getByTestId } = render(
<MapBox {...defaultProps} globalOpacity={0.5} />,
);
const overlay = getByTestId('scatter-overlay');
expect(overlay.dataset.opacity).toBe('0.5');
});
test('initializes viewport from props when provided', () => {
render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('handles undefined bounds gracefully', () => {
render(<MapBox {...defaultProps} bounds={undefined} />);
expect(lastMapGLProps.longitude).toBe(0);
expect(lastMapGLProps.latitude).toBe(0);
expect(lastMapGLProps.zoom).toBe(1);
});
test('applies partial viewport props on update', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(<MapBox {...defaultProps} viewportLongitude={-122.4} />);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('restores fitBounds when viewport props are cleared', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear all viewport props (simulates user clearing the controls)
rerender(<MapBox {...defaultProps} />);
// Should revert to fitBounds values
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('restores only cleared viewport props, keeps the rest', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear only longitude, keep lat/zoom
rerender(
<MapBox {...defaultProps} viewportLatitude={37.8} viewportZoom={5} />,
);
// Longitude reverts to fitBounds, lat/zoom stay
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});
test('applies changed viewport props even when another is cleared simultaneously', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear longitude, change latitude simultaneously
rerender(
<MapBox {...defaultProps} viewportLatitude={40.0} viewportZoom={5} />,
);
// Longitude reverts to fitBounds, latitude should be the NEW value
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.0);
expect(lastMapGLProps.zoom).toBe(5);
});
test('falls back to default viewport when cleared with undefined bounds', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
bounds={undefined}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
// Clear viewport props — no bounds to fitBounds to
rerender(<MapBox {...defaultProps} bounds={undefined} />);
// Should fall back to {0, 0, 1}
expect(lastMapGLProps.longitude).toBe(0);
expect(lastMapGLProps.latitude).toBe(0);
expect(lastMapGLProps.zoom).toBe(1);
});
test('recomputes fitBounds when bounds change and no explicit viewport is set', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(
<MapBox
{...defaultProps}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.5);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('recomputes fitBounds when chart size changes and no explicit viewport is set', () => {
const { rerender } = render(<MapBox {...defaultProps} />);
rerender(<MapBox {...defaultProps} width={1200} height={900} />);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(40.75);
expect(lastMapGLProps.zoom).toBe(11.29);
});
test('recomputes only implicit viewport fields when bounds change', () => {
const { rerender } = render(
<MapBox {...defaultProps} viewportLongitude={-122.4} />,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(10.86);
});
test('recomputes only implicit viewport fields when chart size changes', () => {
const { rerender } = render(
<MapBox {...defaultProps} viewportLatitude={37.8} />,
);
rerender(
<MapBox
{...defaultProps}
viewportLatitude={37.8}
width={1200}
height={900}
/>,
);
expect(lastMapGLProps.longitude).toBe(-73.95);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(11.29);
});
test('recomputes implicit position when zoom stays explicit across bounds changes', () => {
const { rerender } = render(<MapBox {...defaultProps} viewportZoom={5} />);
rerender(
<MapBox
{...defaultProps}
viewportZoom={5}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.5);
expect(lastMapGLProps.latitude).toBe(37.3);
expect(lastMapGLProps.zoom).toBe(5);
});
test('does not recompute fitBounds on bounds change when an explicit viewport is set', () => {
const { rerender } = render(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
/>,
);
rerender(
<MapBox
{...defaultProps}
viewportLongitude={-122.4}
viewportLatitude={37.8}
viewportZoom={5}
bounds={[
[-123.2, 36.5],
[-121.8, 38.1],
]}
/>,
);
expect(lastMapGLProps.longitude).toBe(-122.4);
expect(lastMapGLProps.latitude).toBe(37.8);
expect(lastMapGLProps.zoom).toBe(5);
});

View File

@@ -18,11 +18,7 @@
*/
import { render } from '@testing-library/react';
import ScatterPlotOverlay from '../src/components/ScatterPlotOverlay';
import {
MIN_CLUSTER_RADIUS_RATIO,
MAX_POINT_RADIUS_RATIO,
} from '../src/components/ScatterPlotOverlay';
import ScatterPlotGlowOverlay from '../src/ScatterPlotGlowOverlay';
type MockGradient = {
addColorStop: jest.Mock<void, [number, string]>;
@@ -71,20 +67,22 @@ declare global {
var mockRedraw: unknown;
}
// Mock the CanvasOverlay component to capture the redraw function
jest.mock('../src/components/CanvasOverlay', () => ({
__esModule: true,
default: ({ redraw }: { redraw: unknown }) => {
// Mock react-map-gl's CanvasOverlay
jest.mock('react-map-gl', () => ({
CanvasOverlay: ({ redraw }: { redraw: unknown }) => {
// Store the redraw function so tests can call it
global.mockRedraw = redraw;
return <div data-testid="canvas-overlay" />;
},
}));
// Mock utility functions
jest.mock('../src/utils/luminanceFromRGB', () => ({
__esModule: true,
default: jest.fn(() => 150),
default: jest.fn(() => 150), // Return a value above the dark threshold
}));
// Test helpers
const createMockCanvas = () => {
const ctx: MockCanvasContext = {
clearRect: jest.fn(),
@@ -153,10 +151,8 @@ const defaultProps = {
rgb: ['', 255, 0, 0] as [string, number, number, number],
globalOpacity: 1,
};
const MIN_VISIBLE_POINT_RADIUS =
defaultProps.dotRadius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_VISIBLE_POINT_RADIUS =
defaultProps.dotRadius * MAX_POINT_RADIUS_RATIO;
const MIN_VISIBLE_POINT_RADIUS = 10;
const MAX_VISIBLE_POINT_RADIUS = 20;
test('renders map with varying radius values in Pixels mode', () => {
const locations = [
@@ -166,7 +162,7 @@ test('renders map with varying radius values in Pixels mode', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -176,11 +172,13 @@ test('renders map with varying radius values in Pixels mode', () => {
const arcCalls = redrawParams.ctx.arc.mock.calls;
// With dotRadius=60, pixel-sized points should map to the visible 10-20 range.
arcCalls.forEach(call => {
expect(call[2]).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
expect(call[2]).toBeLessThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
});
// Ordering should be preserved: radius 10 < 50 < 100
expect(arcCalls[0][2]).toBeLessThan(arcCalls[1][2]);
expect(arcCalls[1][2]).toBeLessThan(arcCalls[2][2]);
});
@@ -194,7 +192,7 @@ test('handles dataset with uniform radius values', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -213,7 +211,7 @@ test('renders successfully when data contains non-finite values', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -231,7 +229,7 @@ test('handles radius values provided as strings', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -258,7 +256,7 @@ test('treats blank radius strings as missing values', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -282,7 +280,7 @@ test('renders points when radius values are missing', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -306,7 +304,7 @@ test('renders both cluster and non-cluster points correctly', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -325,7 +323,7 @@ test('renders map with multiple points with different radius values', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -343,7 +341,7 @@ test('renders map with Kilometers mode', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Kilometers"
@@ -362,7 +360,7 @@ test('renders map with Miles mode', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Miles"
@@ -380,7 +378,7 @@ test('displays metric property labels on points', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -393,7 +391,7 @@ test('displays metric property labels on points', () => {
test('handles empty dataset without errors', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={[]}
pointRadiusUnit="Pixels"
@@ -412,7 +410,7 @@ test('handles extreme outlier radius values without breaking', () => {
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -433,7 +431,7 @@ test('renders successfully with mixed extreme and negative radius values', () =>
expect(() => {
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
pointRadiusUnit="Pixels"
@@ -458,7 +456,7 @@ test('cluster radius is always >= max individual point radius in Pixels mode', (
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -469,7 +467,9 @@ test('cluster radius is always >= max individual point radius in Pixels mode', (
const arcCalls = redrawParams.ctx.arc.mock.calls;
// cluster with label=1 (index 0) should not be smaller than the largest point bubble
expect(arcCalls[0][2]).toBeGreaterThanOrEqual(MAX_VISIBLE_POINT_RADIUS);
// point radii span the configured pixel range
expect(arcCalls[1][2]).toBe(MIN_VISIBLE_POINT_RADIUS);
expect(arcCalls[2][2]).toBe(MAX_VISIBLE_POINT_RADIUS);
expect(arcCalls[0][2]).toBeGreaterThanOrEqual(arcCalls[2][2]);
@@ -490,7 +490,7 @@ test('largest cluster gets full dotRadius', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -500,6 +500,7 @@ test('largest cluster gets full dotRadius', () => {
const redrawParams = triggerRedraw();
const arcCalls = redrawParams.ctx.arc.mock.calls;
// The largest cluster (label=100, maxLabel=100) should get full radius
expect(arcCalls[1][2]).toBe(defaultProps.dotRadius);
});
@@ -523,7 +524,7 @@ test('cluster radii preserve proportional ordering', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -551,7 +552,7 @@ test('negative cluster label produces valid finite radius', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -580,7 +581,7 @@ test('ignores non-finite cluster labels when computing cluster scaling bounds',
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -605,7 +606,7 @@ test('single cluster with small maxLabel gets full dotRadius', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -614,6 +615,7 @@ test('single cluster with small maxLabel gets full dotRadius', () => {
const redrawParams = triggerRedraw();
const arcCalls = redrawParams.ctx.arc.mock.calls;
// When there's only one cluster, label=maxLabel, so it gets full radius
expect(arcCalls[0][2]).toBe(defaultProps.dotRadius);
});
@@ -637,7 +639,7 @@ test('all-negative cluster labels produce differentiated radii by magnitude', ()
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -650,6 +652,7 @@ test('all-negative cluster labels produce differentiated radii by magnitude', ()
const rNeg10 = arcCalls[1][2];
const rNeg1 = arcCalls[2][2];
// Higher magnitude = bigger circle: |-100| > |-10| > |-1|
expect(rNeg1).toBeLessThan(rNeg10);
expect(rNeg10).toBeLessThan(rNeg100);
expect(Number.isFinite(rNeg100)).toBe(true);
@@ -679,7 +682,7 @@ test('mixed positive-and-negative cluster labels size by magnitude', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -692,6 +695,7 @@ test('mixed positive-and-negative cluster labels size by magnitude', () => {
const rZero = arcCalls[1][2];
const r100 = arcCalls[2][2];
// Magnitude ordering: |0| < |-50| < |100|
expect(rZero).toBeLessThan(rNeg50);
expect(rNeg50).toBeLessThan(r100);
expect(rZero).toBeGreaterThanOrEqual(MIN_VISIBLE_POINT_RADIUS);
@@ -718,7 +722,7 @@ test('all-identical negative labels get equal full radii', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -746,7 +750,7 @@ test('single negative cluster gets full radius', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -768,7 +772,7 @@ test('large negative cluster labels are abbreviated', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -808,7 +812,7 @@ test.each([
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation={aggregation}
@@ -842,7 +846,7 @@ test('zero-value cluster is visible with minimum radius', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"
@@ -873,7 +877,7 @@ test('all-zero clusters use a finite radius', () => {
];
render(
<ScatterPlotOverlay
<ScatterPlotGlowOverlay
{...defaultProps}
locations={locations}
aggregation="sum"

View File

@@ -42,23 +42,19 @@ type TransformPropsResult = {
viewportLongitude?: number;
viewportLatitude?: number;
viewportZoom?: number;
rgb?: string[] | null;
};
const baseFormData = {
all_columns_x: 'lon',
all_columns_y: 'lat',
clustering_radius: 60,
global_opacity: 0.8,
map_color: 'rgb(0, 139, 139)',
map_renderer: 'maplibre',
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
pandas_aggfunc: 'sum',
point_radius_unit: 'Pixels',
render_while_dragging: true,
viewport_longitude: -73.935242,
viewport_latitude: 40.73061,
viewport_zoom: 9,
clusteringRadius: 60,
globalOpacity: 0.8,
mapboxColor: 'rgb(0, 139, 139)',
mapboxStyle: 'mapbox://styles/mapbox/light-v9',
pandasAggfunc: 'sum',
pointRadiusUnit: 'Pixels',
renderWhileDragging: true,
viewportLongitude: -73.935242,
viewportLatitude: 40.73061,
viewportZoom: 9,
};
const baseQueriesData = [
@@ -70,6 +66,7 @@ const baseQueriesData = [
] as [[number, number], [number, number]],
geoJSON: { features: [] },
hasCustomMetric: false,
mapboxApiKey: 'test-api-key',
},
},
];
@@ -91,15 +88,15 @@ function getTransformPropsResult(
}
test('extracts globalOpacity from formData', () => {
const result = getTransformPropsResult({ global_opacity: 0.5 });
const result = getTransformPropsResult({ globalOpacity: 0.5 });
expect(result.globalOpacity).toBe(0.5);
});
test('extracts viewport values from formData', () => {
const result = getTransformPropsResult({
viewport_longitude: -122.4,
viewport_latitude: 37.8,
viewport_zoom: 12,
viewportLongitude: -122.4,
viewportLatitude: 37.8,
viewportZoom: 12,
});
expect(result).toEqual(
expect.objectContaining({
@@ -112,9 +109,9 @@ test('extracts viewport values from formData', () => {
test('clamps viewport values to safe map ranges', () => {
const result = getTransformPropsResult({
viewport_longitude: 190,
viewport_latitude: -100,
viewport_zoom: 99,
viewportLongitude: 190,
viewportLatitude: -100,
viewportZoom: 99,
});
expect(result).toEqual(
expect.objectContaining({
@@ -151,9 +148,9 @@ test('provides onViewportChange callback that updates control values', () => {
test('normalizes string viewport values to numbers', () => {
const result = getTransformPropsResult({
viewport_longitude: '-122.4',
viewport_latitude: '37.8',
viewport_zoom: '12',
viewportLongitude: '-122.4',
viewportLatitude: '37.8',
viewportZoom: '12',
});
expect(result.viewportLongitude).toBe(-122.4);
expect(result.viewportLatitude).toBe(37.8);
@@ -162,9 +159,9 @@ test('normalizes string viewport values to numbers', () => {
test('normalizes empty viewport values to undefined', () => {
const result = getTransformPropsResult({
viewport_longitude: '',
viewport_latitude: '',
viewport_zoom: '',
viewportLongitude: '',
viewportLatitude: '',
viewportZoom: '',
});
expect(result.viewportLongitude).toBeUndefined();
expect(result.viewportLatitude).toBeUndefined();
@@ -173,9 +170,9 @@ test('normalizes empty viewport values to undefined', () => {
test('normalizes whitespace-only viewport values to undefined', () => {
const result = getTransformPropsResult({
viewport_longitude: ' ',
viewport_latitude: '\t',
viewport_zoom: ' \n ',
viewportLongitude: ' ',
viewportLatitude: '\t',
viewportZoom: ' \n ',
});
expect(result.viewportLongitude).toBeUndefined();
expect(result.viewportLatitude).toBeUndefined();
@@ -183,31 +180,31 @@ test('normalizes whitespace-only viewport values to undefined', () => {
});
test('normalizes string opacity to number', () => {
const result = getTransformPropsResult({ global_opacity: '0.5' });
const result = getTransformPropsResult({ globalOpacity: '0.5' });
expect(result.globalOpacity).toBe(0.5);
});
test('defaults empty opacity to 1', () => {
const result = getTransformPropsResult({ global_opacity: '' });
const result = getTransformPropsResult({ globalOpacity: '' });
expect(result.globalOpacity).toBe(1);
});
test('defaults whitespace-only opacity to 1', () => {
const result = getTransformPropsResult({ global_opacity: ' ' });
const result = getTransformPropsResult({ globalOpacity: ' ' });
expect(result.globalOpacity).toBe(1);
});
test('clamps opacity to [0, 1] range', () => {
expect(getTransformPropsResult({ global_opacity: 5 }).globalOpacity).toBe(1);
expect(getTransformPropsResult({ global_opacity: -1 }).globalOpacity).toBe(0);
expect(getTransformPropsResult({ globalOpacity: 5 }).globalOpacity).toBe(1);
expect(getTransformPropsResult({ globalOpacity: -1 }).globalOpacity).toBe(0);
});
test('passes through numeric values unchanged', () => {
const result = getTransformPropsResult({
viewport_longitude: -122.4,
viewport_latitude: 37.8,
viewport_zoom: 12,
global_opacity: 0.8,
viewportLongitude: -122.4,
viewportLatitude: 37.8,
viewportZoom: 12,
globalOpacity: 0.8,
});
expect(result.viewportLongitude).toBe(-122.4);
expect(result.viewportLatitude).toBe(37.8);
@@ -215,18 +212,19 @@ test('passes through numeric values unchanged', () => {
expect(result.globalOpacity).toBe(0.8);
});
test('calls onError and falls back to black for invalid color', () => {
test('calls onError and returns empty object for invalid color', () => {
const onError = jest.fn();
const chartProps = new ChartProps({
formData: { ...baseFormData, map_color: 'invalid-color' },
formData: { ...baseFormData, mapboxColor: 'invalid-color' },
width: 800,
height: 600,
queriesData: baseQueriesData,
hooks: { onError },
theme: supersetTheme,
});
const result = transformProps(chartProps) as TransformPropsResult;
expect(onError).toHaveBeenCalled();
// Falls back to black instead of returning empty object
expect(result.rgb).toEqual(['', '0', '0', '0']);
const result = transformProps(chartProps);
expect(onError).toHaveBeenCalledWith(
"Color field must be of form 'rgb(%d, %d, %d)'",
);
expect(result).toEqual({});
});

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
}

View File

@@ -1,4 +1,4 @@
/**
/*
* 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
@@ -17,10 +17,12 @@
* under the License.
*/
import { extensions as extensionsImpl } from './index';
import roundDecimal from '../../src/utils/roundDecimal';
test('extensions.getContext throws when not in extension context', () => {
expect(() => extensionsImpl.getContext()).toThrow(
'getContext() must be called within an extension context',
);
describe('roundDecimal', () => {
test('rounding method to limit the number of decimal digits', () => {
expect(roundDecimal(1.139, 2)).toBe(1.14);
expect(roundDecimal(1.13929, 3)).toBe(1.139);
expect(roundDecimal(1.13929)).toBe(1);
});
});

View File

@@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
// Path Resolution: Override baseUrl to maintain correct path mappings from parent config
// (e.g., "@apache-superset/core" -> "./packages/superset-core/src")
"baseUrl": "../..",
// Directory Overrides: Parent config paths are relative to frontend root,
// but packages need paths relative to their own directory
"outDir": "lib",
"rootDir": "src",
"declarationDir": "lib"
},
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
]
}

View File

@@ -0,0 +1,101 @@
/**
* 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.
*/
declare module '*.png' {
const value: string;
export default value;
}
declare module '*.jpg' {
const value: string;
export default value;
}
declare module 'supercluster' {
interface Options<P = Record<string, unknown>, C = Record<string, unknown>> {
minZoom?: number;
maxZoom?: number;
minPoints?: number;
radius?: number;
extent?: number;
nodeSize?: number;
log?: boolean;
initial?: () => C;
map?: (props: P) => C;
reduce?: (accumulated: C, props: C) => void;
}
interface GeoJSONFeature {
type: string;
geometry: {
type: string;
coordinates: [number, number];
};
properties: Record<string, unknown>;
}
class Supercluster<P = Record<string, unknown>, C = Record<string, unknown>> {
constructor(options?: Options<P, C>);
load(points: GeoJSONFeature[]): Supercluster<P, C>;
getClusters(bbox: number[], zoom: number): GeoJSONFeature[];
getTile(z: number, x: number, y: number): GeoJSONFeature[] | null;
getChildren(clusterId: number): GeoJSONFeature[];
getLeaves(
clusterId: number,
limit?: number,
offset?: number,
): GeoJSONFeature[];
getClusterExpansionZoom(clusterId: number): number;
}
export default Supercluster;
export { Options, GeoJSONFeature };
}
declare module 'react-map-gl' {
import { Component, ReactNode } from 'react';
interface MapGLProps {
width?: number;
height?: number;
latitude?: number;
longitude?: number;
zoom?: number;
mapStyle?: string;
mapboxApiAccessToken?: string;
onViewportChange?: Function;
preserveDrawingBuffer?: boolean;
children?: ReactNode;
[key: string]: unknown;
}
export default class MapGL extends Component<MapGLProps> {}
interface CanvasOverlayProps {
redraw: (params: {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}) => void;
}
export class CanvasOverlay extends Component<CanvasOverlayProps> {}
}

View File

@@ -0,0 +1,89 @@
<!--
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.
-->
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.20.4](https://github.com/apache/superset/compare/v0.20.3...v0.20.4) (2024-12-10)
**Note:** Version bump only for package @superset-ui/legacy-preset-chart-deckgl
# [0.20.0](https://github.com/apache/superset/compare/v2021.41.0...v0.20.0) (2024-09-09)
### Bug Fixes
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
### Features
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))
# [0.19.0](https://github.com/apache/superset/compare/v2021.41.0...v0.19.0) (2024-09-07)
### Bug Fixes
- **Dashboard:** Color inconsistency on refreshes and conflicts ([#27439](https://github.com/apache/superset/issues/27439)) ([313ee59](https://github.com/apache/superset/commit/313ee596f5435894f857d72be7269d5070c8c964))
- deck.gl Geojson path not visible ([#24428](https://github.com/apache/superset/issues/24428)) ([6bb930e](https://github.com/apache/superset/commit/6bb930ef4ed26ea381e7f8e889851aa7867ba0eb))
- deck.gl GeoJsonLayer Autozoom & fill/stroke options ([#19778](https://github.com/apache/superset/issues/19778)) ([d65b77e](https://github.com/apache/superset/commit/d65b77ec7dac4c2368fcaa1fe6e98db102966198))
- **deck.gl Multiple Layer Chart:** Add Contour and Heatmap Layer as options ([#25923](https://github.com/apache/superset/issues/25923)) ([64ba579](https://github.com/apache/superset/commit/64ba5797df92d0f8067ccd2b30ba6ff58e0bd791))
- deck.gl Scatterplot min/max radius ([#24363](https://github.com/apache/superset/issues/24363)) ([c728cdf](https://github.com/apache/superset/commit/c728cdf501ec292beb14a0982265052bf2274bec))
- **deck.gl:** multiple layers map size is shrunk ([#18939](https://github.com/apache/superset/issues/18939)) ([2cb3635](https://github.com/apache/superset/commit/2cb3635256ee8e91f0bac2f3091684673c04ff2b))
- **deck.gl:** update view state on property changes ([#17720](https://github.com/apache/superset/issues/17720)) ([#17826](https://github.com/apache/superset/issues/17826)) ([97d918b](https://github.com/apache/superset/commit/97d918b6927f572dca3b33c61b89c8b3ebdc4376))
- DeckGL legend layout ([#30140](https://github.com/apache/superset/issues/30140)) ([af066a4](https://github.com/apache/superset/commit/af066a46306f2f476aa2944b14df3de1faf1e96d))
- **deckgl:** deckgl unable to load map ([#17851](https://github.com/apache/superset/issues/17851)) ([52f5dcb](https://github.com/apache/superset/commit/52f5dcb58eec7b188f4387b8781dcda4252a5680))
- **explore:** Fix chart standalone URL for report/thumbnail generation ([#20673](https://github.com/apache/superset/issues/20673)) ([84d4302](https://github.com/apache/superset/commit/84d4302628d18aa19c13cc5322e68abbc690ea4d))
- **explore:** Prevent shared controls from checking feature flags outside React render ([#21315](https://github.com/apache/superset/issues/21315)) ([2285ebe](https://github.com/apache/superset/commit/2285ebe72ec4edded6d195052740b7f9f13d1f1b))
- weight tooltip issue ([#19397](https://github.com/apache/superset/issues/19397)) ([f6d550b](https://github.com/apache/superset/commit/f6d550b7fc3643350483850064e65dbd3d026dc4))
### Features
- Add Deck.gl Contour Layer ([#24154](https://github.com/apache/superset/issues/24154)) ([512fb9a](https://github.com/apache/superset/commit/512fb9a0bdd428b94b0c121158b8b15b7631e0fb))
- Add deck.gl Heatmap Visualization ([#23551](https://github.com/apache/superset/issues/23551)) ([fc8c537](https://github.com/apache/superset/commit/fc8c537118ce6c7b3a4624f88a31e2e7fb287327))
- Add line width unit control in deckgl Polygon and Path ([#24755](https://github.com/apache/superset/issues/24755)) ([d26ea98](https://github.com/apache/superset/commit/d26ea980acc7d2a20757efc360d810afe83d5c65))
- apply standardized form data to deckgl ([#20579](https://github.com/apache/superset/issues/20579)) ([290b89c](https://github.com/apache/superset/commit/290b89c7b4ae702c55f611bfac9cedb245ea8bd8))
- **deck.gl:** add color range for deck.gl 3D ([#19520](https://github.com/apache/superset/issues/19520)) ([c0a00fd](https://github.com/apache/superset/commit/c0a00fd302ec66fbe0ca766cf73978c99ba00d82))
- **deckgl-map:** use an arbitraty Mabpox style URL ([#26027](https://github.com/apache/superset/issues/26027)) ([#26031](https://github.com/apache/superset/issues/26031)) ([af58784](https://github.com/apache/superset/commit/af587840403d83a7da7fb0f57bc10ad2335d4eeb))
- **explore:** Frontend implementation of dataset creation from infobox ([#19855](https://github.com/apache/superset/issues/19855)) ([ba0c37d](https://github.com/apache/superset/commit/ba0c37d3df85b1af39404af1d578daeb0ff2d278))
- improve color consistency (save all labels) ([#19038](https://github.com/apache/superset/issues/19038)) ([dc57508](https://github.com/apache/superset/commit/dc575080d7e43d40b1734bb8f44fdc291cb95b11))
- **legacy-preset-chart-deckgl:** Add ,.1f and ,.2f value formats to deckgl charts ([#18945](https://github.com/apache/superset/issues/18945)) ([c56dc8e](https://github.com/apache/superset/commit/c56dc8eace6a71b45240d1bb6768d75661052a2e))
- make data tables support html ([#24368](https://github.com/apache/superset/issues/24368)) ([d2b0b8e](https://github.com/apache/superset/commit/d2b0b8eac52ad8b68639c6581a1ed174a593f564))
- **viz picker:** Remove some tags, refactor Recommended section ([#27708](https://github.com/apache/superset/issues/27708)) ([c314999](https://github.com/apache/superset/commit/c3149994ac0d4392e0462421b62cd0c034142082))

View File

@@ -0,0 +1,57 @@
<!--
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.
-->
## @superset-ui/legacy-preset-chart-deckgl
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-preset-chart-deckgl.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/legacy-preset-chart-deckgl.svg?style=flat-square)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Flegacy-preset-chart-deckgl?style=flat)](https://libraries.io/npm/@superset-ui%2Flegacy-preset-chart-deckgl)
This plugin provides `deck.gl` for Superset.
### Usage
Import the preset and register. This will register all the chart plugins under `deck.gl`.
```js
import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl';
new DeckGLChartPreset().register();
```
or register charts one by one. Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import { ArcChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
new ArcChartPlugin().configure({ key: 'deck_arc' }).register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-plugins-deckgl) for more details.
```js
<SuperChart
chartType="deck_arc"
width={600}
height={600}
formData={...}
queriesData={[{
data: {...},
}]}
/>
```

View File

@@ -1,7 +1,7 @@
{
"name": "@superset-ui/preset-chart-deckgl",
"version": "1.0.0",
"description": "Superset Chart Plugin - deck.gl (MapLibre)",
"name": "@superset-ui/legacy-preset-chart-deckgl",
"version": "0.20.4",
"description": "Superset Legacy Chart - deck.gl",
"keywords": [
"superset"
],
@@ -12,7 +12,7 @@
"repository": {
"type": "git",
"url": "https://github.com/apache/superset.git",
"directory": "superset-frontend/plugins/preset-chart-deckgl"
"directory": "superset-frontend/packages/legacy-preset-chart-deckgl"
},
"license": "Apache-2.0",
"author": "Superset",
@@ -29,13 +29,14 @@
"@deck.gl/extensions": "~9.2.9",
"@deck.gl/geo-layers": "~9.2.5",
"@deck.gl/layers": "~9.2.5",
"@deck.gl/mapbox": "~9.2.5",
"@deck.gl/mesh-layers": "~9.2.5",
"@deck.gl/react": "~9.2.11",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.6",
"@luma.gl/shadertools": "~9.2.6",
"@luma.gl/webgl": "~9.2.6",
"@mapbox/tiny-sdf": "^2.0.7",
"@mapbox/geojson-extent": "^1.0.1",
"@math.gl/web-mercator": "^4.1.0",
"@types/d3-array": "^3.2.2",
@@ -46,12 +47,10 @@
"d3-scale": "^4.0.2",
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"maplibre-gl": "^5.0.0",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
"prop-types": "^15.8.1",
"react-map-gl": "^8.0.0",
"underscore": "^1.13.7",
"underscore": "^1.13.8",
"urijs": "^1.19.11",
"xss": "^1.0.15"
},
@@ -66,14 +65,10 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"mapbox-gl": ">=1.0.0",
"react": "^17.0.2 || ^19.0.0",
"react-dom": "^17.0.2 || ^19.0.0"
},
"peerDependenciesMeta": {
"mapbox-gl": {
"optional": true
}
"mapbox-gl": "*",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-map-gl": "^6.1.19"
},
"publishConfig": {
"access": "public"

View File

@@ -38,7 +38,6 @@ import {
import type { Layer } from '@deck.gl/core';
import Legend from './components/Legend';
import { hexToRGB } from './utils/colors';
import { getMapboxApiKey } from './utils/mapbox';
import sandboxedEval from './utils/sandbox';
import fitViewport, { Viewport } from './utils/fitViewport';
import {
@@ -84,6 +83,7 @@ function getCategories(fd: QueryFormData, data: JsonObject[]) {
export type CategoricalDeckGLContainerProps = {
datasource: Datasource;
formData: QueryFormData;
mapboxApiKey: string;
getPoints: (data: JsonObject[]) => Point[];
height: number;
width: number;
@@ -155,7 +155,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
switch (selectedColorScheme) {
case COLOR_SCHEME_TYPES.fixed_color: {
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 1 };
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 100 };
const colorArray = [color.r, color.g, color.b, color.a * 255];
return data.map(d => ({ ...d, color: colorArray }));
@@ -166,7 +166,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
r: 0,
g: 0,
b: 0,
a: 1,
a: 100,
};
const colorArray = [
fallbackColor.r,
@@ -325,15 +325,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'
}
mapboxApiKey={getMapboxApiKey()}
mapStyle={props.formData.mapbox_style}
mapboxApiAccessToken={props.mapboxApiKey}
width={props.width}
height={props.height}
/>

View File

@@ -1,3 +1,7 @@
/* eslint-disable react/jsx-sort-default-props */
/* eslint-disable react/sort-prop-types */
/* eslint-disable react/jsx-handler-names */
/* eslint-disable react/forbid-prop-types */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -26,32 +30,32 @@ import {
useImperativeHandle,
useState,
isValidElement,
useRef,
} from 'react';
import { isEqual } from 'lodash';
import { Map as MapLibreMap } from 'react-map-gl/maplibre';
import { Map as MapboxMap } from 'react-map-gl/mapbox';
import mapboxgl from 'mapbox-gl';
import { StaticMap } from 'react-map-gl';
import DeckGL from '@deck.gl/react';
import type { Layer } from '@deck.gl/core';
import { JsonObject, JsonValue, usePrevious } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import DeckGLOverlayMapLibre from './components/DeckGLOverlayMapLibre';
import DeckGLOverlayMapbox from './components/DeckGLOverlayMapbox';
import { styled } from '@apache-superset/core/theme';
import { Device } from '@luma.gl/core';
import Tooltip, { TooltipProps } from './components/Tooltip';
import 'maplibre-gl/dist/maplibre-gl.css';
import 'mapbox-gl/dist/mapbox-gl.css';
import { Viewport } from './utils/fitViewport';
import {
MAPBOX_LAYER_PREFIX,
OSM_LAYER_KEYWORDS,
TILE_LAYER_PREFIX,
buildTileLayer,
} from './utils';
const TICK = 250; // milliseconds
const DEFAULT_MAP_STYLE =
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
export type DeckGLContainerProps = {
viewport: Viewport;
setControlValue?: (control: string, value: JsonValue) => void;
mapStyle?: string;
mapProvider?: 'maplibre' | 'mapbox';
mapboxApiKey?: string;
mapboxApiAccessToken: string;
children?: ReactNode;
width: number;
height: number;
@@ -65,6 +69,14 @@ export const DeckGLContainer = memo(
const [lastUpdate, setLastUpdate] = useState<number | null>(null);
const [viewState, setViewState] = useState(props.viewport);
const prevViewport = usePrevious(props.viewport);
const glContextRef = useRef<WebGL2RenderingContext | null>(null);
useEffect(
() => () => {
glContextRef.current?.getExtension('WEBGL_lose_context')?.loseContext();
},
[],
);
useImperativeHandle(ref, () => ({ setTooltip }), []);
@@ -81,7 +93,7 @@ export const DeckGLContainer = memo(
useEffect(() => {
const timer = setInterval(tick, TICK);
return () => clearInterval(timer);
return clearInterval(timer);
}, [tick]);
useEffect(() => {
@@ -90,12 +102,31 @@ export const DeckGLContainer = memo(
}
}, [prevViewport, props.viewport]);
const onMove = useCallback((evt: { viewState: JsonObject }) => {
setViewState(evt.viewState as Viewport);
setLastUpdate(Date.now());
}, []);
const onViewStateChange = useCallback(
({ viewState }: { viewState: JsonObject }) => {
setViewState(viewState as Viewport);
setLastUpdate(Date.now());
},
[],
);
const layers = useCallback(() => {
if (
(props.mapStyle?.startsWith(TILE_LAYER_PREFIX) ||
OSM_LAYER_KEYWORDS.some((tilek: string) =>
props.mapStyle?.includes(tilek),
)) &&
props.layers.some(
l => typeof l !== 'function' && l?.id === 'tile-layer',
) === false
) {
props.layers.unshift(
buildTileLayer(
(props.mapStyle ?? '').replace(TILE_LAYER_PREFIX, ''),
'tile-layer',
),
);
}
// Support for layer factory
if (props.layers.some(l => typeof l === 'function')) {
return props.layers.map(l =>
@@ -104,7 +135,7 @@ export const DeckGLContainer = memo(
}
return props.layers as Layer[];
}, [props.layers]);
}, [props.layers, props.mapStyle]);
const isCustomTooltip = (content: ReactNode): boolean =>
isValidElement(content) &&
@@ -120,35 +151,7 @@ export const DeckGLContainer = memo(
return <Tooltip tooltip={tooltipState} />;
};
const theme = useTheme();
const { children = null, height, width } = props;
const isMapbox = props.mapProvider === 'mapbox';
const mapStyle = props.mapStyle || DEFAULT_MAP_STYLE;
if (isMapbox && !props.mapboxApiKey) {
return (
<div
style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
textAlign: 'center',
color: theme.colorTextSecondary,
}}
>
{t(
'Mapbox requires a MAPBOX_API_KEY to be configured on the server.',
)}
</div>
);
}
if (isMapbox && props.mapboxApiKey) {
mapboxgl.accessToken = props.mapboxApiKey;
}
return (
<>
@@ -159,25 +162,28 @@ export const DeckGLContainer = memo(
e.stopPropagation();
}}
>
{isMapbox ? (
<MapboxMap
{...viewState}
onMove={onMove}
mapStyle={mapStyle}
style={{ width, height }}
>
<DeckGLOverlayMapbox layers={layers()} />
</MapboxMap>
) : (
<MapLibreMap
{...viewState}
onMove={onMove}
mapStyle={mapStyle}
style={{ width, height }}
>
<DeckGLOverlayMapLibre layers={layers()} />
</MapLibreMap>
)}
<DeckGL
controller
width={width}
height={height}
layers={layers()}
viewState={viewState}
onViewStateChange={onViewStateChange}
onAfterRender={(context: {
device: Device;
gl: WebGL2RenderingContext;
}) => {
glContextRef.current = context.gl;
}}
>
{props.mapStyle?.startsWith(MAPBOX_LAYER_PREFIX) && (
<StaticMap
preserveDrawingBuffer
mapStyle={props.mapStyle || 'light'}
mapboxApiAccessToken={props.mapboxApiAccessToken}
/>
)}
</DeckGL>
{children}
</div>
{renderTooltip(tooltip)}

View File

@@ -58,7 +58,7 @@ const baseMockProps = {
viz_type: 'deck_multi',
deck_slices: [1, 2],
autozoom: false,
map_style: 'mapbox://styles/mapbox/light-v9',
mapbox_style: 'mapbox://styles/mapbox/light-v9',
},
payload: {
data: {

View File

@@ -50,7 +50,6 @@ import {
import { getExploreLongUrl } from '../utils/explore';
import layerGenerators from '../layers';
import fitViewport, { Viewport } from '../utils/fitViewport';
import { getMapboxApiKey } from '../utils/mapbox';
import { TooltipProps } from '../components/Tooltip';
import { getPoints as getPointsArc } from '../layers/Arc/Arc';
@@ -378,7 +377,7 @@ const DeckMulti = (props: DeckMultiProps) => {
);
if (deckSlicesChanged || visibilityFilterChanged) {
loadLayers(formData, payload, visibleDeckLayersFromRedux);
loadLayers(formData, payload, undefined);
}
}, [
loadLayers,
@@ -388,7 +387,7 @@ const DeckMulti = (props: DeckMultiProps) => {
props,
]);
const { formData, setControlValue, height, width } = props;
const { payload, formData, setControlValue, height, width } = props;
const layers = useMemo(
() =>
@@ -402,15 +401,10 @@ const DeckMulti = (props: DeckMultiProps) => {
<MultiWrapper height={height} width={width}>
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={layers}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'}
mapboxApiKey={getMapboxApiKey()}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
onViewportChange={setViewport}
height={height}

View File

@@ -18,13 +18,7 @@
*/
import { t } from '@apache-superset/core/translation';
import { validateNonEmpty } from '@superset-ui/core';
import {
viewport,
mapboxStyle,
maplibreStyle,
mapProvider,
autozoom,
} from '../utilities/Shared_DeckGL';
import { viewport, mapboxStyle, autozoom } from '../utilities/Shared_DeckGL';
export default {
controlPanelSections: [
@@ -32,9 +26,7 @@ export default {
label: t('Map'),
expanded: true,
controlSetRows: [
[mapProvider],
[mapboxStyle],
[maplibreStyle],
[viewport],
[autozoom],
[

View File

@@ -113,14 +113,8 @@ const Legend = ({
<a
href="#"
role="button"
onClick={e => {
e.preventDefault();
toggleCategory(k);
}}
onDoubleClick={e => {
e.preventDefault();
showSingleCategory(k);
}}
onClick={() => toggleCategory(k)}
onDoubleClick={() => showSingleCategory(k)}
>
<span style={style}>{icon}</span> {formatCategoryLabel(k)}
</a>

View File

@@ -42,8 +42,8 @@ const StyledDiv = styled.div<{
position: absolute;
top: ${top}px;
left: ${left}px;
z-index: 9;
pointer-events: none;
zIndex: 9;
pointerEvents: none;
${
variant === 'default'
? `
@@ -51,8 +51,8 @@ const StyledDiv = styled.div<{
margin: ${theme.sizeUnit * 2}px;
background: ${theme.colorBgElevated};
color: ${theme.colorText};
max-width: 300px;
font-size: ${theme.fontSizeSM}px;
maxWidth: 300px;
fontSize: ${theme.fontSizeSM}px;
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
box-shadow: ${theme.boxShadowSecondary};

View File

@@ -19,7 +19,6 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { isEqual } from 'lodash';
import type { Layer } from '@deck.gl/core';
import { getMapboxApiKey } from './utils/mapbox';
import {
Datasource,
QueryFormData,
@@ -183,23 +182,16 @@ export function createDeckGLComponent(
}
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
const { formData, setControlValue, height, width } = props;
const { formData, payload, setControlValue, height, width } = props;
return (
<div style={{ position: 'relative' }}>
<DeckGLContainerStyledWrapper
ref={containerRef}
mapboxApiAccessToken={payload.data.mapboxApiKey}
viewport={viewport}
layers={layers}
mapStyle={
formData.map_renderer === 'mapbox'
? formData.mapbox_style
: formData.maplibre_style
}
mapProvider={
formData.map_renderer === 'mapbox' ? 'mapbox' : 'maplibre'
}
mapboxApiKey={getMapboxApiKey()}
mapStyle={formData.mapbox_style}
setControlValue={setControlValue}
width={width}
height={height}
@@ -240,6 +232,7 @@ export function createCategoricalDeckGLComponent(
<CategoricalDeckGLContainer
datasource={datasource}
formData={formData}
mapboxApiKey={payload.data.mapboxApiKey}
setControlValue={setControlValue}
viewport={viewport}
getLayer={getLayer}

View File

@@ -39,8 +39,6 @@ import {
legendPosition,
viewport,
mapboxStyle,
maplibreStyle,
mapProvider,
tooltipContents,
tooltipTemplate,
deckGLCategoricalColor,
@@ -88,12 +86,7 @@ const config: ControlPanelConfig = {
},
{
label: t('Map'),
controlSetRows: [
[mapProvider],
[mapboxStyle],
[maplibreStyle],
[autozoom, viewport],
],
controlSetRows: [[mapboxStyle], [autozoom, viewport]],
},
{
label: t('Arc'),

Some files were not shown because too many files have changed in this diff Show More