mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423e5280d0 | ||
|
|
c5dce675a0 | ||
|
|
c54b21ef98 | ||
|
|
438a927420 | ||
|
|
37a4637018 | ||
|
|
79b2647481 | ||
|
|
1b605c4dda | ||
|
|
b543358d2f | ||
|
|
a1312a86e8 | ||
|
|
3084907931 | ||
|
|
1cd35bb102 | ||
|
|
5eb35a4795 | ||
|
|
01c1b2eb8f | ||
|
|
9e4a88dfa2 | ||
|
|
4809903bb8 | ||
|
|
76a2559b2b | ||
|
|
e4a7cd30c3 | ||
|
|
aa475734ef | ||
|
|
97b8585fe5 | ||
|
|
0d66accc37 | ||
|
|
35c135852e | ||
|
|
7b04d251d6 | ||
|
|
974bee14c3 | ||
|
|
fca8a49561 | ||
|
|
f60432e34c | ||
|
|
b8459c15b8 | ||
|
|
8eb3046888 | ||
|
|
615f13419c | ||
|
|
8a74424545 | ||
|
|
8f070169a5 | ||
|
|
e06427d1ef | ||
|
|
c4eb7de6de | ||
|
|
228b598409 | ||
|
|
40f609fdce | ||
|
|
6e94a6c21a | ||
|
|
50cc1b93d2 | ||
|
|
131a97b657 | ||
|
|
6f3a200c19 | ||
|
|
cbb80f0462 | ||
|
|
2a3567d2f1 | ||
|
|
3f64ad3da5 | ||
|
|
672a380587 | ||
|
|
a87a006aae | ||
|
|
159fb5d6f4 | ||
|
|
6424194c87 | ||
|
|
5bee32ea93 | ||
|
|
82fce8d7de | ||
|
|
5e6524954c | ||
|
|
987b6a6f04 | ||
|
|
3d6644864d | ||
|
|
577b965a60 | ||
|
|
b565128fe7 | ||
|
|
b290f71245 | ||
|
|
cff854b06e | ||
|
|
44d6b6a513 | ||
|
|
2d44f52ad1 | ||
|
|
6f34ba7d4a | ||
|
|
1a77e17179 | ||
|
|
6fdaa8e9b3 | ||
|
|
e30a9caba5 | ||
|
|
7937246575 | ||
|
|
9f8b212ccc | ||
|
|
1ecff6fe5c | ||
|
|
69653dfd08 | ||
|
|
58d8aa01f8 | ||
|
|
88f0e322e3 | ||
|
|
f4acce5727 | ||
|
|
5278deaf63 | ||
|
|
3868821dc8 | ||
|
|
6a61baf5be | ||
|
|
5cc8ae5427 | ||
|
|
1f76944c2b | ||
|
|
f049d3e34a | ||
|
|
86c8fa5cd7 | ||
|
|
e12140beb6 | ||
|
|
b7a3224f04 | ||
|
|
f5a5a804e2 | ||
|
|
0b77ace110 | ||
|
|
c175346808 | ||
|
|
6b80135aa2 | ||
|
|
de079a7b19 | ||
|
|
f54bbdc06b | ||
|
|
33441ccf3d | ||
|
|
9ec56f5f02 | ||
|
|
11a36ff488 | ||
|
|
af3e088233 | ||
|
|
29f499528f | ||
|
|
21481eef4f | ||
|
|
0d2c8fd373 | ||
|
|
7b56fc1714 | ||
|
|
9131739f98 | ||
|
|
a30492f55e | ||
|
|
090eab099c | ||
|
|
cd4cd53726 | ||
|
|
65c460c9d2 | ||
|
|
868e719c60 | ||
|
|
5efc7ea5a5 | ||
|
|
b0f9a73f63 | ||
|
|
746e266e90 | ||
|
|
5a777c0f45 | ||
|
|
aec1f6edce | ||
|
|
f7218e7a19 | ||
|
|
5cd829f13c | ||
|
|
9566e8a9c6 | ||
|
|
604d49f557 | ||
|
|
84f1ee4409 | ||
|
|
3e3c9686de | ||
|
|
7b21979fa3 | ||
|
|
8853ff19d4 | ||
|
|
22ac5e02b6 | ||
|
|
2c9f0c1c2a | ||
|
|
d47a7105df | ||
|
|
c873225308 | ||
|
|
982e2c1ef7 | ||
|
|
eee3af5775 | ||
|
|
232b34d944 | ||
|
|
d748ed19ce | ||
|
|
5300f65a74 | ||
|
|
440602ef34 | ||
|
|
cbf153845e | ||
|
|
097f474f24 | ||
|
|
73adff55ee | ||
|
|
a65f73a532 | ||
|
|
475615e118 | ||
|
|
79f51e2ae7 | ||
|
|
75d6a95ac3 | ||
|
|
ffd7f10320 | ||
|
|
e3e2bece6b | ||
|
|
0c0d915391 | ||
|
|
080f629ea2 | ||
|
|
142b2cc425 | ||
|
|
6328e51620 | ||
|
|
0d5ddb3674 | ||
|
|
58d245c6b0 | ||
|
|
dbf5e1f131 | ||
|
|
88ce1425e2 | ||
|
|
4dfece9ee5 | ||
|
|
3f64c25712 | ||
|
|
afacca350f | ||
|
|
30ccbb2e05 | ||
|
|
19ec7b48a0 |
@@ -16,9 +16,12 @@ updates:
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "eslint-plugin-storybook"
|
||||
- dependency-name: "react-error-boundary"
|
||||
- dependency-name: "@rjsf/*"
|
||||
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
|
||||
- dependency-name: "remark-gfm"
|
||||
- dependency-name: "react-markdown"
|
||||
# TODO: remove below entries until React >= 19.0.0
|
||||
- dependency-name: "react-icons"
|
||||
# JSDOM v30 doesn't play well with Jest v30
|
||||
# Source: https://jestjs.io/blog#known-issues
|
||||
# GH thread: https://github.com/jsdom/jsdom/issues/3492
|
||||
@@ -65,6 +68,22 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
ignore:
|
||||
# TODO: remove below entries until React >= 18.0.0 in superset-frontend
|
||||
- dependency-name: "storybook"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "@storybook*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "eslint-plugin-storybook"
|
||||
- dependency-name: "react-error-boundary"
|
||||
groups:
|
||||
storybook:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "@storybook*"
|
||||
- "storybook"
|
||||
update-types:
|
||||
- "patch"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
@@ -123,6 +142,9 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
|
||||
ignore:
|
||||
# TODO: remove below entries until React >= 19.0.0
|
||||
- dependency-name: "react-icons"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
labels:
|
||||
@@ -173,6 +195,9 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-table/"
|
||||
ignore:
|
||||
# TODO: remove below entries until React >= 19.0.0
|
||||
- dependency-name: "react-icons"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
labels:
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
# Scan for vulnerabilities in built container image after pushes to mainline branch.
|
||||
- name: Run Trivy container image vulnerabity scan
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'lean'
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # v0.34.1
|
||||
with:
|
||||
image-ref: ${{ env.IMAGE_TAG }}
|
||||
format: 'sarif'
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics (if triggered by integration tests)
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
path: docs/src/data/
|
||||
- name: Try to download latest diagnostics (for push/dispatch triggers)
|
||||
if: github.event_name != 'workflow_run'
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
# Do not bump this linkinator-action version without opening
|
||||
# an ASF Infra ticket to allow the new version first!
|
||||
- uses: JustinBeckwith/linkinator-action@f62ba0c110a76effb2ee6022cc6ce4ab161085e3 # v2.4
|
||||
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
|
||||
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
|
||||
with:
|
||||
paths: "**/*.md, **/*.mdx"
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
run: |
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics from integration tests
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
UPLOAD_FOLDER: /tmp/.superset/uploads/
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
- master
|
||||
- "[0-9].[0-9]*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
+36
@@ -24,6 +24,42 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### MCP Tool Observability
|
||||
|
||||
MCP (Model Context Protocol) tools now include enhanced observability instrumentation for monitoring and debugging:
|
||||
|
||||
**Two-layer instrumentation:**
|
||||
1. **Middleware layer** (`LoggingMiddleware`): Automatically logs all MCP tool calls with `duration_ms` and `success` status in the audit log (Action Log UI, logs table)
|
||||
2. **Sub-operation tracking**: All 19 MCP tools include granular `event_logger.log_context()` blocks for tracking individual operations like validation, database writes, and query execution
|
||||
|
||||
**Action naming convention:**
|
||||
- Tool-level logs: `mcp_tool_call` (via middleware)
|
||||
- Sub-operation logs: `mcp.{tool_name}.{operation}` (e.g., `mcp.generate_chart.validation`, `mcp.execute_sql.query_execution`)
|
||||
|
||||
**Querying MCP logs:**
|
||||
```sql
|
||||
-- Top slowest MCP operations
|
||||
SELECT action, COUNT(*) as calls, AVG(duration_ms) as avg_ms
|
||||
FROM logs
|
||||
WHERE action LIKE 'mcp.%'
|
||||
GROUP BY action
|
||||
ORDER BY avg_ms DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- MCP tool success rate
|
||||
SELECT
|
||||
json_extract(curated_payload, '$.tool') as tool,
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) as successful,
|
||||
ROUND(100.0 * SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) / COUNT(*), 2) as success_rate
|
||||
FROM logs
|
||||
WHERE action = 'mcp_tool_call'
|
||||
GROUP BY tool
|
||||
ORDER BY total_calls DESC;
|
||||
```
|
||||
|
||||
**Security note:** Sensitive parameters (passwords, API keys, tokens) are automatically redacted in logs as `[REDACTED]`.
|
||||
|
||||
### Signal Cache Backend
|
||||
|
||||
A new `SIGNAL_CACHE_CONFIG` configuration provides a unified Redis-based backend for real-time coordination features in Superset. This backend enables:
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
container_name: superset_db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- db_home_light:/var/lib/postgresql/data
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
container_name: superset_db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${DATABASE_PORT:-5432}:5432"
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v20.18.3
|
||||
v20.20.0
|
||||
|
||||
@@ -24,132 +24,180 @@ under the License.
|
||||
|
||||
# Contribution Types
|
||||
|
||||
To facilitate the development of extensions, we define a set of well-defined contribution types that extensions can implement. These contribution types serve as the building blocks for extensions, allowing them to interact with the host application and provide new functionality.
|
||||
Extensions provide functionality through **contributions** - well-defined extension points that integrate with the host application.
|
||||
|
||||
## Frontend
|
||||
## Why Contributions?
|
||||
|
||||
Frontend contribution types allow extensions to extend Superset's user interface with new views, commands, and menu items.
|
||||
The contribution system provides several key benefits:
|
||||
|
||||
- **Transparency**: Administrators can review exactly what functionality an extension provides before installation. The `manifest.json` documents all REST APIs, MCP tools, views, and other contributions in a single, readable location.
|
||||
|
||||
- **Security**: Only contributions explicitly declared in the manifest are registered during startup. Extensions cannot expose functionality they haven't declared, preventing hidden or undocumented code from executing.
|
||||
|
||||
- **Discoverability**: The manifest serves as a contract between extensions and the host application, making it easy to understand what an extension does without reading its source code.
|
||||
|
||||
## How Contributions Work
|
||||
|
||||
Contributions are automatically discovered from source code at build time. Simply use the `@extension_api`, `@tool`, `@prompt` decorators in Python or `define*()` functions in TypeScript - the build system finds them and generates a `manifest.json` with all discovered contributions.
|
||||
|
||||
No manual configuration needed!
|
||||
|
||||
## Backend Contributions
|
||||
|
||||
### REST API Endpoints
|
||||
|
||||
Register REST APIs under `/api/v1/extensions/`:
|
||||
|
||||
```python
|
||||
from superset_core.api import RestApi, extension_api
|
||||
from flask_appbuilder import expose
|
||||
|
||||
@extension_api(id="my_api", name="My Extension API")
|
||||
class MyExtensionAPI(RestApi):
|
||||
@expose("/endpoint", methods=["GET"])
|
||||
def get_data(self):
|
||||
return self.response(200, result={"message": "Hello"})
|
||||
```
|
||||
|
||||
### MCP Tools
|
||||
|
||||
Register MCP tools for AI agents:
|
||||
|
||||
```python
|
||||
from superset_core.mcp import tool
|
||||
|
||||
@tool(tags=["database"])
|
||||
def query_database(sql: str, database_id: int) -> dict:
|
||||
"""Execute a SQL query against a database."""
|
||||
return execute_query(sql, database_id)
|
||||
```
|
||||
|
||||
### MCP Prompts
|
||||
|
||||
Register MCP prompts:
|
||||
|
||||
```python
|
||||
from superset_core.mcp import prompt
|
||||
|
||||
@prompt(tags={"analysis"})
|
||||
async def analyze_data(ctx, dataset: str) -> str:
|
||||
"""Generate analysis for a dataset."""
|
||||
return f"Analyze the {dataset} dataset..."
|
||||
```
|
||||
|
||||
See [MCP Integration](./mcp) for more details.
|
||||
|
||||
## Frontend Contributions
|
||||
|
||||
### Views
|
||||
|
||||
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Each view is registered with a unique ID and can be activated or deactivated as needed. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
|
||||
Add panels or views to the UI using `defineView()`:
|
||||
|
||||
```json
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
"views": {
|
||||
"sqllab": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "my_extension.main",
|
||||
"name": "My Panel Name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { defineView } from '@apache-superset/core';
|
||||
import MyPanel from './MyPanel';
|
||||
|
||||
export const myView = defineView({
|
||||
id: 'main',
|
||||
title: 'My Panel',
|
||||
location: 'sqllab.panels', // or dashboard.tabs, explore.panels, etc.
|
||||
component: () => <MyPanel />,
|
||||
});
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
Extensions can define custom commands that can be executed within the host application, such as context-aware actions or menu options. Each command can specify properties like a unique command identifier, an icon, a title, and a description. These commands can be invoked by users through menus, keyboard shortcuts, or other UI elements, enabling extensions to add rich, interactive functionality to Superset.
|
||||
Define executable commands using `defineCommand()`:
|
||||
|
||||
```json
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "my_extension.copy_query",
|
||||
"icon": "CopyOutlined",
|
||||
"title": "Copy Query",
|
||||
"description": "Copy the current query to clipboard"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```tsx
|
||||
import { defineCommand } from '@apache-superset/core';
|
||||
|
||||
export const copyQuery = defineCommand({
|
||||
id: 'copy_query',
|
||||
title: 'Copy Query',
|
||||
icon: 'CopyOutlined',
|
||||
execute: async () => {
|
||||
// Copy the current query
|
||||
navigator.clipboard.writeText(getCurrentQuery());
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Menus
|
||||
|
||||
Extensions can contribute new menu items or context menus to the host application, providing users with additional actions and options. Each menu item can specify properties such as the target view, the command to execute, its placement (primary, secondary, or context), and conditions for when it should be displayed. Menu contribution areas are uniquely identified (e.g., `sqllab.editor` for the SQL Lab editor), allowing extensions to seamlessly integrate their functionality into specific menus and workflows within Superset.
|
||||
Add items to menus using `defineMenu()`:
|
||||
|
||||
```tsx
|
||||
import { defineMenu } from '@apache-superset/core';
|
||||
|
||||
export const contextMenu = defineMenu({
|
||||
id: 'clear_editor',
|
||||
title: 'Clear Editor',
|
||||
location: 'sqllab.editor.context',
|
||||
action: () => {
|
||||
clearEditor();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Editors
|
||||
|
||||
Replace the default text editor using `defineEditor()`:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { defineEditor } from '@apache-superset/core';
|
||||
import MonacoEditor from './MonacoEditor';
|
||||
|
||||
export const monacoSqlEditor = defineEditor({
|
||||
id: 'monaco_sql',
|
||||
name: 'Monaco SQL Editor',
|
||||
mimeTypes: ['text/x-sql'],
|
||||
component: MonacoEditor,
|
||||
});
|
||||
```
|
||||
|
||||
All contributions are automatically discovered at build time and registered at runtime - no manual configuration needed!
|
||||
|
||||
## Configuration
|
||||
|
||||
### extension.json
|
||||
|
||||
Specify which files to scan for contributions:
|
||||
|
||||
```json
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
"menus": {
|
||||
"sqllab": {
|
||||
"editor": {
|
||||
"primary": [
|
||||
{
|
||||
"view": "builtin.editor",
|
||||
"command": "my_extension.copy_query"
|
||||
}
|
||||
],
|
||||
"secondary": [
|
||||
{
|
||||
"view": "builtin.editor",
|
||||
"command": "my_extension.prettify"
|
||||
}
|
||||
],
|
||||
"context": [
|
||||
{
|
||||
"view": "builtin.editor",
|
||||
"command": "my_extension.clear"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
{
|
||||
"id": "my_extension",
|
||||
"name": "My Extension",
|
||||
"version": "1.0.0",
|
||||
"backend": {
|
||||
"entryPoints": ["my_extension.entrypoint"],
|
||||
"files": ["backend/src/**/*.py"]
|
||||
},
|
||||
"frontend": {
|
||||
"moduleFederation": {
|
||||
"exposes": ["./index"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Editors
|
||||
### Manual Contributions (Advanced)
|
||||
|
||||
Extensions can replace Superset's default text editors with custom implementations. This enables enhanced editing experiences using alternative editor frameworks like Monaco, CodeMirror, or custom solutions. When an extension registers an editor for a language, it replaces the default Ace editor in all locations that use that language (SQL Lab, Dashboard Properties, CSS editors, etc.).
|
||||
Override auto-discovery by specifying contributions directly:
|
||||
|
||||
```json
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
"editors": [
|
||||
{
|
||||
"id": "my_extension.monaco_sql",
|
||||
"name": "Monaco SQL Editor",
|
||||
"languages": ["sql"],
|
||||
"description": "Monaco-based SQL editor with IntelliSense"
|
||||
}
|
||||
]
|
||||
{
|
||||
"backend": {
|
||||
"contributions": {
|
||||
"mcpTools": [
|
||||
{ "id": "query_db", "name": "query_db", "module": "my_ext.tools.query_db" }
|
||||
],
|
||||
"restApis": [
|
||||
{ "id": "my_api", "name": "My API", "module": "my_ext.api.MyAPI", "basePath": "/my_api" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Editors Extension Point](./extension-points/editors) for implementation details.
|
||||
|
||||
## Backend
|
||||
|
||||
Backend contribution types allow extensions to extend Superset's server-side capabilities with new API endpoints, MCP tools, and MCP prompts.
|
||||
|
||||
### REST API Endpoints
|
||||
|
||||
Extensions can register custom REST API endpoints under the `/api/v1/extensions/` namespace. This dedicated namespace prevents conflicts with built-in endpoints and provides a clear separation between core and extension functionality.
|
||||
|
||||
```json
|
||||
"backend": {
|
||||
"entryPoints": ["my_extension.entrypoint"],
|
||||
"files": ["backend/src/my_extension/**/*.py"]
|
||||
}
|
||||
```
|
||||
|
||||
The entry point module registers the API with Superset:
|
||||
|
||||
```python
|
||||
from superset_core.api.rest_api import add_extension_api
|
||||
from .api import MyExtensionAPI
|
||||
|
||||
add_extension_api(MyExtensionAPI)
|
||||
```
|
||||
|
||||
### MCP Tools and Prompts
|
||||
|
||||
Extensions can contribute Model Context Protocol (MCP) tools and prompts that AI agents can discover and use. See [MCP Integration](./mcp) for detailed documentation.
|
||||
|
||||
@@ -40,10 +40,10 @@ superset-extensions bundle: Packages the extension into a .supx file.
|
||||
superset-extensions dev: Automatically rebuilds the extension as files change.
|
||||
```
|
||||
|
||||
When creating a new extension with `superset-extensions init <extension-name>`, the CLI generates a standardized folder structure:
|
||||
When creating a new extension with `superset-extensions init`, the CLI generates a standardized folder structure:
|
||||
|
||||
```
|
||||
dataset_references/
|
||||
dataset-references/
|
||||
├── extension.json
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
@@ -52,25 +52,33 @@ dataset_references/
|
||||
│ └── package.json
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ └── dataset_references/
|
||||
│ │ └── superset_extensions/
|
||||
│ │ └── dataset_references/
|
||||
│ ├── tests/
|
||||
│ ├── pyproject.toml
|
||||
│ └── requirements.txt
|
||||
├── dist/
|
||||
│ ├── manifest.json
|
||||
│ ├── frontend
|
||||
│ └── dist/
|
||||
│ ├── remoteEntry.d7a9225d042e4ccb6354.js
|
||||
│ └── 900.038b20cdff6d49cfa8d9.js
|
||||
│ │ └── dist/
|
||||
│ │ ├── remoteEntry.d7a9225d042e4ccb6354.js
|
||||
│ │ └── 900.038b20cdff6d49cfa8d9.js
|
||||
│ └── backend
|
||||
│ └── dataset_references/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py
|
||||
│ └── entrypoint.py
|
||||
├── dataset_references-1.0.0.supx
|
||||
│ └── superset_extensions/
|
||||
│ └── dataset_references/
|
||||
│ ├── __init__.py
|
||||
│ ├── api.py
|
||||
│ └── entrypoint.py
|
||||
├── dataset-references-1.0.0.supx
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**Note**: The extension ID (`dataset-references`) serves as the basis for all technical names:
|
||||
- Directory name: `dataset-references` (kebab-case)
|
||||
- Backend Python package: `dataset_references` (snake_case)
|
||||
- Frontend package name: `dataset-references` (kebab-case)
|
||||
- Module Federation name: `datasetReferences` (camelCase)
|
||||
|
||||
The `extension.json` file serves as the declared metadata for the extension, containing the extension's name, version, author, description, and a list of capabilities. This file is essential for the host application to understand how to load and manage the extension.
|
||||
|
||||
The `frontend` directory contains the source code for the frontend components of the extension, including React components, styles, and assets. The `webpack.config.js` file is used to configure Webpack for building the frontend code, while the `tsconfig.json` file defines the TypeScript configuration for the project. The `package.json` file specifies the dependencies and scripts for building and testing the frontend code.
|
||||
@@ -87,7 +95,8 @@ The `extension.json` file contains all metadata necessary for the host applicati
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "dataset_references",
|
||||
"id": "dataset-references",
|
||||
"name": "Dataset References",
|
||||
"version": "1.0.0",
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
@@ -95,20 +104,21 @@ The `extension.json` file contains all metadata necessary for the host applicati
|
||||
"sqllab": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "dataset_references.main",
|
||||
"name": "Dataset references"
|
||||
"id": "dataset-references.main",
|
||||
"name": "Dataset References"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"moduleFederation": {
|
||||
"exposes": ["./index"]
|
||||
"exposes": ["./index"],
|
||||
"name": "datasetReferences"
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entryPoints": ["dataset_references.entrypoint"],
|
||||
"files": ["backend/src/dataset_references/**/*.py"]
|
||||
"entryPoints": ["superset_extensions.dataset_references.entrypoint"],
|
||||
"files": ["backend/src/superset_extensions/dataset_references/**/*.py"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -172,13 +172,9 @@ import { editors } from '@apache-superset/core';
|
||||
import MonacoSQLEditor from './MonacoSQLEditor';
|
||||
|
||||
export function activate(context) {
|
||||
// Register the Monaco editor for SQL
|
||||
// Register the Monaco editor for SQL using the contribution ID from extension.json
|
||||
const disposable = editors.registerEditorProvider(
|
||||
{
|
||||
id: 'monaco-sql-editor.sql',
|
||||
name: 'Monaco SQL Editor',
|
||||
languages: ['sql'],
|
||||
},
|
||||
'monaco-sql-editor.sql',
|
||||
MonacoSQLEditor,
|
||||
);
|
||||
|
||||
|
||||
@@ -51,27 +51,39 @@ Use the CLI to scaffold a new extension project. Extensions can include frontend
|
||||
superset-extensions init
|
||||
```
|
||||
|
||||
The CLI will prompt you for information:
|
||||
The CLI will prompt you for information using a three-step publisher workflow:
|
||||
|
||||
```
|
||||
Extension ID (unique identifier, alphanumeric only): hello_world
|
||||
Extension name (human-readable display name): Hello World
|
||||
Extension display name: Hello World
|
||||
Extension name (hello-world): hello-world
|
||||
Publisher (e.g., my-org): my-org
|
||||
Initial version [0.1.0]: 0.1.0
|
||||
License [Apache-2.0]: Apache-2.0
|
||||
Include frontend? [Y/n]: Y
|
||||
Include backend? [Y/n]: Y
|
||||
```
|
||||
|
||||
**Publisher Namespaces**: Extensions use organizational namespaces similar to VS Code extensions, providing collision-safe naming across organizations:
|
||||
- **NPM package**: `@my-org/hello-world` (scoped package for frontend distribution)
|
||||
- **Module Federation name**: `myOrg_helloWorld` (collision-safe JavaScript identifier)
|
||||
- **Backend package**: `my_org-hello_world` (collision-safe Python distribution name)
|
||||
- **Python namespace**: `superset_extensions.my_org.hello_world`
|
||||
|
||||
This approach ensures that extensions from different organizations cannot conflict, even if they use the same technical name (e.g., both `acme.dashboard-widgets` and `corp.dashboard-widgets` can coexist).
|
||||
|
||||
This creates a complete project structure:
|
||||
|
||||
```
|
||||
hello_world/
|
||||
my-org.hello-world/
|
||||
├── extension.json # Extension metadata and configuration
|
||||
├── backend/ # Backend Python code
|
||||
│ ├── src/
|
||||
│ │ └── hello_world/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── entrypoint.py # Backend registration
|
||||
│ │ └── superset_extensions/
|
||||
│ │ └── my_org/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── hello_world/
|
||||
│ │ ├── __init__.py
|
||||
│ │ └── entrypoint.py # Backend registration
|
||||
│ └── pyproject.toml
|
||||
└── frontend/ # Frontend TypeScript/React code
|
||||
├── src/
|
||||
@@ -87,8 +99,9 @@ The generated `extension.json` contains basic metadata. Update it to register yo
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hello_world",
|
||||
"name": "Hello World",
|
||||
"publisher": "my-org",
|
||||
"name": "hello-world",
|
||||
"displayName": "Hello World",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"frontend": {
|
||||
@@ -97,7 +110,7 @@ The generated `extension.json` contains basic metadata. Update it to register yo
|
||||
"sqllab": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "hello_world.main",
|
||||
"id": "my-org.hello-world.main",
|
||||
"name": "Hello World"
|
||||
}
|
||||
]
|
||||
@@ -105,27 +118,33 @@ The generated `extension.json` contains basic metadata. Update it to register yo
|
||||
}
|
||||
},
|
||||
"moduleFederation": {
|
||||
"exposes": ["./index"]
|
||||
"exposes": ["./index"],
|
||||
"name": "myOrg_helloWorld"
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"entryPoints": ["hello_world.entrypoint"],
|
||||
"files": ["backend/src/hello_world/**/*.py"]
|
||||
"entryPoints": ["superset_extensions.my_org.hello_world.entrypoint"],
|
||||
"files": ["backend/src/superset_extensions/my_org/hello_world/**/*.py"]
|
||||
},
|
||||
"permissions": ["can_read"]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `moduleFederation.name` uses collision-safe naming (`myOrg_helloWorld`), and backend entry points use the full nested Python namespace (`superset_extensions.my_org.hello_world`).
|
||||
|
||||
**Key fields:**
|
||||
|
||||
- `publisher`: Organizational namespace for the extension
|
||||
- `name`: Technical identifier (kebab-case)
|
||||
- `displayName`: Human-readable name shown to users
|
||||
- `frontend.contributions.views.sqllab.panels`: Registers your panel in SQL Lab
|
||||
- `backend.entryPoints`: Python modules to load eagerly when extension starts
|
||||
|
||||
## Step 4: Create Backend API
|
||||
|
||||
The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create an API endpoint.
|
||||
The CLI generated a basic `backend/src/superset_extensions/my_org/hello_world/entrypoint.py`. We'll create an API endpoint.
|
||||
|
||||
**Create `backend/src/hello_world/api.py`**
|
||||
**Create `backend/src/superset_extensions/my_org/hello_world/api.py`**
|
||||
|
||||
```python
|
||||
from flask import Response
|
||||
@@ -174,10 +193,10 @@ class HelloWorldAPI(RestApi):
|
||||
- Extends `RestApi` from `superset_core.api.types.rest_api`
|
||||
- Uses Flask-AppBuilder decorators (`@expose`, `@protect`, `@safe`)
|
||||
- Returns responses using `self.response(status_code, result=data)`
|
||||
- The endpoint will be accessible at `/extensions/hello_world/message`
|
||||
- The endpoint will be accessible at `/extensions/my-org/hello-world/message`
|
||||
- OpenAPI docstrings are crucial - Flask-AppBuilder uses them to automatically generate interactive API documentation at `/swagger/v1`, allowing developers to explore endpoints, understand schemas, and test the API directly from the browser
|
||||
|
||||
**Update `backend/src/hello_world/entrypoint.py`**
|
||||
**Update `backend/src/superset_extensions/my_org/hello_world/entrypoint.py`**
|
||||
|
||||
Replace the generated print statement with API registration:
|
||||
|
||||
@@ -201,7 +220,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "hello_world",
|
||||
"name": "@my-org/hello-world",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -252,7 +271,7 @@ module.exports = (env, argv) => {
|
||||
chunkFilename: "[name].[contenthash].js",
|
||||
clean: true,
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
publicPath: `/api/v1/extensions/${packageConfig.name}/`,
|
||||
publicPath: `/api/v1/extensions/my-org/hello-world/`,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
||||
@@ -273,7 +292,7 @@ module.exports = (env, argv) => {
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
name: packageConfig.name,
|
||||
name: "myOrg_helloWorld",
|
||||
filename: "remoteEntry.[contenthash].js",
|
||||
exposes: {
|
||||
"./index": "./src/index.tsx",
|
||||
@@ -330,7 +349,7 @@ const HelloWorldPanel: React.FC = () => {
|
||||
const fetchMessage = async () => {
|
||||
try {
|
||||
const csrfToken = await authentication.getCSRFToken();
|
||||
const response = await fetch('/extensions/hello_world/message', {
|
||||
const response = await fetch('/extensions/my-org/hello-world/message', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -398,25 +417,48 @@ Replace the generated code with the extension entry point:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { core } from '@apache-superset/core';
|
||||
import { defineView } from '@apache-superset/core';
|
||||
import HelloWorldPanel from './HelloWorldPanel';
|
||||
|
||||
export const activate = (context: core.ExtensionContext) => {
|
||||
context.disposables.push(
|
||||
core.registerViewProvider('hello_world.main', () => <HelloWorldPanel />),
|
||||
);
|
||||
};
|
||||
// Define the view - automatically registered when extension loads
|
||||
export const helloWorldView = defineView({
|
||||
id: 'main',
|
||||
title: 'Hello World',
|
||||
location: 'sqllab.panels',
|
||||
component: () => <HelloWorldPanel />,
|
||||
});
|
||||
```
|
||||
|
||||
export const deactivate = () => {};
|
||||
That's it! For most extensions, this is all you need.
|
||||
|
||||
**Optional lifecycle callbacks:**
|
||||
|
||||
If you need to run code when your contribution activates or deactivates, add optional callbacks:
|
||||
|
||||
```tsx
|
||||
export const helloWorldView = defineView({
|
||||
id: 'main',
|
||||
title: 'Hello World',
|
||||
location: 'sqllab.panels',
|
||||
component: () => <HelloWorldPanel />,
|
||||
onActivate: () => {
|
||||
// Optional: runs when panel is registered
|
||||
console.log('Hello World panel activated');
|
||||
},
|
||||
onDeactivate: () => {
|
||||
// Optional: runs when panel is unregistered
|
||||
console.log('Hello World panel deactivated');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Key patterns:**
|
||||
|
||||
- `activate` function is called when the extension loads
|
||||
- `core.registerViewProvider` registers the component with ID `hello_world.main` (matching `extension.json`)
|
||||
- `defineView()` automatically handles discovery, registration, and cleanup
|
||||
- `onActivate` and `onDeactivate` are completely optional
|
||||
- `authentication.getCSRFToken()` retrieves the CSRF token for API calls
|
||||
- Fetch calls to `/extensions/{extension_id}/{endpoint}` reach your backend API
|
||||
- `context.disposables.push()` ensures proper cleanup
|
||||
- Everything happens automatically - no manual setup required
|
||||
|
||||
## Step 6: Install Dependencies
|
||||
|
||||
@@ -444,7 +486,7 @@ This command automatically:
|
||||
- `manifest.json` - Build metadata and asset references
|
||||
- `frontend/dist/` - Built frontend assets (remoteEntry.js, chunks)
|
||||
- `backend/` - Python source files
|
||||
- Packages everything into `hello_world-0.1.0.supx` - a zip archive with the specific structure required by Superset
|
||||
- Packages everything into `my-org.hello-world-0.1.0.supx` - a zip archive with the specific structure required by Superset
|
||||
|
||||
## Step 8: Deploy to Superset
|
||||
|
||||
@@ -469,7 +511,7 @@ EXTENSIONS_PATH = "/path/to/extensions/folder"
|
||||
Copy your `.supx` file to the configured extensions path:
|
||||
|
||||
```bash
|
||||
cp hello_world-0.1.0.supx /path/to/extensions/folder/
|
||||
cp my-org.hello-world-0.1.0.supx /path/to/extensions/folder/
|
||||
```
|
||||
|
||||
**Restart Superset**
|
||||
@@ -500,7 +542,7 @@ Here's what happens when your extension loads:
|
||||
4. **Module Federation**: Webpack loads your extension code and resolves `@apache-superset/core` to `window.superset`
|
||||
5. **Activation**: `activate()` is called, registering your view provider
|
||||
6. **Rendering**: When the user opens your panel, React renders `<HelloWorldPanel />`
|
||||
7. **API call**: Component fetches data from `/extensions/hello_world/message`
|
||||
7. **API call**: Component fetches data from `/extensions/my-org/hello-world/message`
|
||||
8. **Backend response**: Your Flask API returns the hello world message
|
||||
9. **Display**: Component shows the message to the user
|
||||
|
||||
|
||||
@@ -141,10 +141,10 @@ database engine on a separate host or container.
|
||||
|
||||
Superset supports the following database engines/versions:
|
||||
|
||||
| Database Engine | Supported Versions |
|
||||
| ----------------------------------------- | ---------------------------------------- |
|
||||
| [PostgreSQL](https://www.postgresql.org/) | 10.X, 11.X, 12.X, 13.X, 14.X, 15.X, 16.X |
|
||||
| [MySQL](https://www.mysql.com/) | 5.7, 8.X |
|
||||
| Database Engine | Supported Versions |
|
||||
| ----------------------------------------- | ---------------------------------------------- |
|
||||
| [PostgreSQL](https://www.postgresql.org/) | 10.X, 11.X, 12.X, 13.X, 14.X, 15.X, 16.X, 17.X |
|
||||
| [MySQL](https://www.mysql.com/) | 5.7, 8.X |
|
||||
|
||||
Use the following database drivers and connection strings:
|
||||
|
||||
|
||||
@@ -96,6 +96,24 @@ To enable this entry, add the following line to the `.env` file:
|
||||
SUPERSET_FEATURE_EMBEDDED_SUPERSET=true
|
||||
```
|
||||
|
||||
### Hiding the Logout Button in Embedded Contexts
|
||||
|
||||
When Superset is embedded in an application that manages authentication via SSO (OAuth2, SAML, or JWT), the logout button should be hidden since session management is handled by the parent application.
|
||||
|
||||
To hide the logout button in embedded contexts, add to `superset_config.py`:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"DISABLE_EMBEDDED_SUPERSET_LOGOUT": True,
|
||||
}
|
||||
```
|
||||
|
||||
This flag only hides the logout button when Superset detects it is running inside an iframe. Users accessing Superset directly (not embedded) will still see the logout button regardless of this setting.
|
||||
|
||||
:::note
|
||||
When embedding with SSO, also set `SESSION_COOKIE_SAMESITE = 'None'` and `SESSION_COOKIE_SECURE = True`. See [Security documentation](/docs/security/securing_superset) for details.
|
||||
:::
|
||||
|
||||
## CSRF settings
|
||||
|
||||
Similarly, [flask-wtf](https://flask-wtf.readthedocs.io/en/0.15.x/config/) is used to manage
|
||||
|
||||
+18
-17
@@ -48,25 +48,26 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^8.6.15",
|
||||
"@storybook/addon-docs": "^8.6.17",
|
||||
"@storybook/blocks": "^8.6.15",
|
||||
"@storybook/channels": "^8.6.15",
|
||||
"@storybook/client-logger": "^8.6.15",
|
||||
"@storybook/components": "^8.6.15",
|
||||
"@storybook/core": "^8.6.15",
|
||||
"@storybook/core-events": "^8.6.15",
|
||||
"@storybook/channels": "^8.6.17",
|
||||
"@storybook/client-logger": "^8.6.17",
|
||||
"@storybook/components": "^8.6.17",
|
||||
"@storybook/core": "^8.6.17",
|
||||
"@storybook/core-events": "^8.6.17",
|
||||
"@storybook/csf": "^0.1.13",
|
||||
"@storybook/docs-tools": "^8.6.15",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/docs-tools": "^8.6.17",
|
||||
"@storybook/preview-api": "^8.6.17",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.11",
|
||||
"antd": "^6.3.0",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001769",
|
||||
"@swc/core": "^1.15.13",
|
||||
"antd": "^6.3.1",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"caniuse-lite": "^1.0.30001770",
|
||||
"docusaurus-plugin-openapi-docs": "^4.6.0",
|
||||
"docusaurus-theme-openapi-docs": "^4.6.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -81,8 +82,8 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.1.1",
|
||||
"storybook": "^8.6.15",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"storybook": "^8.6.17",
|
||||
"swagger-ui-react": "^5.31.2",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -94,7 +95,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.52.0",
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
@@ -102,8 +103,8 @@
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"webpack": "^5.105.1"
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"webpack": "^5.105.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -104,6 +104,10 @@ const DatabasePage: React.FC<DatabasePageProps> = ({ database, name }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Ensure db filename can be obtained regardless of how db doc gets generated
|
||||
// by either Flask app (superset.db_engine_specs.postgres) or fallback mode (postgres)
|
||||
const databaseModuleFilename = `${database.module?.split('.').pop()}.py`;
|
||||
|
||||
// Render driver information
|
||||
const renderDrivers = () => {
|
||||
if (!docs?.drivers?.length) return null;
|
||||
@@ -770,11 +774,11 @@ const DatabasePage: React.FC<DatabasePageProps> = ({ database, name }) => {
|
||||
Help improve this documentation by editing the engine spec:
|
||||
</Text>
|
||||
<a
|
||||
href={`https://github.com/apache/superset/edit/master/superset/db_engine_specs/${database.module}.py`}
|
||||
href={`https://github.com/apache/superset/edit/master/superset/db_engine_specs/${databaseModuleFilename}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<EditOutlined /> Edit {database.module}.py
|
||||
<EditOutlined /> Edit {databaseModuleFilename}
|
||||
</a>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"generated": "2026-01-31T10:47:01.730Z",
|
||||
"generated": "2026-02-16T04:47:37.257Z",
|
||||
"statistics": {
|
||||
"totalDatabases": 70,
|
||||
"withDocumentation": 70,
|
||||
"withConnectionString": 70,
|
||||
"totalDatabases": 72,
|
||||
"withDocumentation": 72,
|
||||
"withConnectionString": 72,
|
||||
"withDrivers": 36,
|
||||
"withAuthMethods": 4,
|
||||
"supportsJoins": 66,
|
||||
"supportsSubqueries": 67,
|
||||
"supportsJoins": 68,
|
||||
"supportsSubqueries": 69,
|
||||
"supportsDynamicSchema": 15,
|
||||
"supportsCatalog": 9,
|
||||
"averageScore": 32,
|
||||
"averageScore": 31,
|
||||
"maxScore": 201,
|
||||
"byCategory": {
|
||||
"Other Databases": [
|
||||
@@ -74,6 +74,7 @@
|
||||
"Apache Kylin",
|
||||
"Azure Synapse",
|
||||
"Ocient",
|
||||
"Apache Phoenix",
|
||||
"Amazon Redshift",
|
||||
"RisingWave",
|
||||
"SingleStore",
|
||||
@@ -151,12 +152,14 @@
|
||||
"Greenplum",
|
||||
"Apache Hive",
|
||||
"Apache Impala",
|
||||
"Apache IoTDB",
|
||||
"Apache Kylin",
|
||||
"MariaDB",
|
||||
"MonetDB",
|
||||
"MySQL",
|
||||
"OceanBase",
|
||||
"Parseable",
|
||||
"Apache Phoenix",
|
||||
"Apache Pinot",
|
||||
"PostgreSQL",
|
||||
"Presto",
|
||||
@@ -187,6 +190,7 @@
|
||||
"Time Series Databases": [
|
||||
"CrateDB",
|
||||
"Apache Druid",
|
||||
"Apache IoTDB",
|
||||
"Apache Pinot",
|
||||
"TDengine"
|
||||
],
|
||||
@@ -197,7 +201,9 @@
|
||||
"Apache Druid",
|
||||
"Apache Hive",
|
||||
"Apache Impala",
|
||||
"Apache IoTDB",
|
||||
"Apache Kylin",
|
||||
"Apache Phoenix",
|
||||
"Apache Pinot",
|
||||
"Apache Solr",
|
||||
"Apache Spark SQL"
|
||||
@@ -2890,6 +2896,47 @@
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Apache IoTDB": {
|
||||
"engine": "apache_iotdb",
|
||||
"engine_name": "Apache IoTDB",
|
||||
"module": "iotdb",
|
||||
"documentation": {
|
||||
"description": "Apache IoTDB is a time series database designed for IoT data, with efficient storage and query capabilities for massive time series data.",
|
||||
"logo": "apache-iotdb.svg",
|
||||
"homepage_url": "https://iotdb.apache.org/",
|
||||
"categories": [
|
||||
"APACHE_PROJECTS",
|
||||
"TIME_SERIES",
|
||||
"OPEN_SOURCE"
|
||||
],
|
||||
"pypi_packages": [
|
||||
"apache-iotdb"
|
||||
],
|
||||
"connection_string": "iotdb://{username}:{password}@{hostname}:{port}",
|
||||
"default_port": 6667,
|
||||
"parameters": {
|
||||
"username": "Database username (default: root)",
|
||||
"password": "Database password (default: root)",
|
||||
"hostname": "IP address or hostname",
|
||||
"port": "Default 6667"
|
||||
},
|
||||
"notes": "The IoTDB SQLAlchemy dialect was written to integrate with Apache Superset. IoTDB uses a hierarchical data model, which is reorganized into a relational model for SQL queries."
|
||||
},
|
||||
"time_grains": {},
|
||||
"score": 0,
|
||||
"max_score": 0,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false,
|
||||
"ssh_tunneling": false,
|
||||
"query_cancelation": false,
|
||||
"supports_file_upload": false,
|
||||
"user_impersonation": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Azure Data Explorer": {
|
||||
"engine": "azure_data_explorer",
|
||||
"engine_name": "Azure Data Explorer",
|
||||
@@ -4039,6 +4086,41 @@
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Apache Phoenix": {
|
||||
"engine": "apache_phoenix",
|
||||
"engine_name": "Apache Phoenix",
|
||||
"module": "phoenix",
|
||||
"documentation": {
|
||||
"description": "Apache Phoenix is a relational database layer over Apache HBase, providing low-latency SQL queries over HBase data.",
|
||||
"logo": "apache-phoenix.png",
|
||||
"homepage_url": "https://phoenix.apache.org/",
|
||||
"categories": [
|
||||
"APACHE_PROJECTS",
|
||||
"ANALYTICAL_DATABASES",
|
||||
"OPEN_SOURCE"
|
||||
],
|
||||
"pypi_packages": [
|
||||
"phoenixdb"
|
||||
],
|
||||
"connection_string": "phoenix://{hostname}:{port}/",
|
||||
"default_port": 8765,
|
||||
"notes": "Phoenix provides a SQL interface to Apache HBase. The phoenixdb driver connects via the Phoenix Query Server and supports a subset of SQLAlchemy."
|
||||
},
|
||||
"time_grains": {},
|
||||
"score": 0,
|
||||
"max_score": 0,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false,
|
||||
"ssh_tunneling": false,
|
||||
"query_cancelation": false,
|
||||
"supports_file_upload": false,
|
||||
"user_impersonation": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Apache Pinot": {
|
||||
"engine": "apache_pinot",
|
||||
"engine_name": "Apache Pinot",
|
||||
@@ -4207,6 +4289,80 @@
|
||||
"OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Supabase",
|
||||
"description": "Open-source Firebase alternative built on top of PostgreSQL, providing a full backend-as-a-service with a hosted Postgres database.",
|
||||
"logo": "supabase.svg",
|
||||
"homepage_url": "https://supabase.com/",
|
||||
"pypi_packages": [
|
||||
"psycopg2"
|
||||
],
|
||||
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
|
||||
"connection_examples": [
|
||||
{
|
||||
"description": "Supabase project (connection pooler)",
|
||||
"connection_string": "postgresql://{username}.{project_ref}:{password}@aws-0-{region}.pooler.supabase.com:6543/{database}"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"username": "Database user (default: postgres)",
|
||||
"password": "Database password",
|
||||
"host": "Supabase project host (from project settings)",
|
||||
"port": "Default 5432 (direct) or 6543 (pooler)",
|
||||
"database": "Database name (default: postgres)",
|
||||
"project_ref": "Supabase project reference (from project settings)",
|
||||
"region": "Supabase project region (e.g., us-east-1)"
|
||||
},
|
||||
"notes": "Find connection details in your Supabase project dashboard under Settings > Database. Use the connection pooler (port 6543) for better connection management.",
|
||||
"docs_url": "https://supabase.com/docs/guides/database/connecting-to-postgres",
|
||||
"categories": [
|
||||
"HOSTED_OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Google AlloyDB",
|
||||
"description": "Google Cloud's PostgreSQL-compatible database service for demanding transactional and analytical workloads.",
|
||||
"logo": "alloydb.png",
|
||||
"homepage_url": "https://cloud.google.com/alloydb",
|
||||
"pypi_packages": [
|
||||
"psycopg2"
|
||||
],
|
||||
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
|
||||
"parameters": {
|
||||
"username": "Database user (default: postgres)",
|
||||
"password": "Database password",
|
||||
"host": "AlloyDB instance IP or Auth Proxy address",
|
||||
"port": "Default 5432",
|
||||
"database": "Database name"
|
||||
},
|
||||
"notes": "For public IP connections, use the AlloyDB Auth Proxy for secure access. Private IP connections can connect directly.",
|
||||
"docs_url": "https://cloud.google.com/alloydb/docs",
|
||||
"categories": [
|
||||
"CLOUD_GCP",
|
||||
"HOSTED_OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Neon",
|
||||
"description": "Serverless PostgreSQL with branching, scale-to-zero, and bottomless storage.",
|
||||
"logo": "neon.png",
|
||||
"homepage_url": "https://neon.tech/",
|
||||
"pypi_packages": [
|
||||
"psycopg2"
|
||||
],
|
||||
"connection_string": "postgresql://{username}:{password}@{host}/{database}?sslmode=require",
|
||||
"parameters": {
|
||||
"username": "Neon role name",
|
||||
"password": "Neon role password",
|
||||
"host": "Neon hostname (e.g., ep-cool-name-123456.us-east-2.aws.neon.tech)",
|
||||
"database": "Database name (default: neondb)"
|
||||
},
|
||||
"notes": "SSL is required for all connections. Find connection details in the Neon console under Connection Details.",
|
||||
"docs_url": "https://neon.tech/docs/connect/connect-from-any-app",
|
||||
"categories": [
|
||||
"HOSTED_OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amazon Aurora PostgreSQL",
|
||||
"description": "Amazon Aurora PostgreSQL is a fully managed, PostgreSQL-compatible relational database with up to 5x the throughput of standard PostgreSQL.",
|
||||
|
||||
Vendored
+8
@@ -261,6 +261,14 @@
|
||||
"description": "Data panel closed by default in chart builder",
|
||||
"category": "runtime_config"
|
||||
},
|
||||
{
|
||||
"name": "DISABLE_EMBEDDED_SUPERSET_LOGOUT",
|
||||
"default": false,
|
||||
"lifecycle": "stable",
|
||||
"description": "Hide the logout button in embedded contexts (e.g., when using SSO in iframes)",
|
||||
"docs": "https://superset.apache.org/docs/configuration/networking-settings#hiding-the-logout-button-in-embedded-contexts",
|
||||
"category": "runtime_config"
|
||||
},
|
||||
{
|
||||
"name": "DRILL_BY",
|
||||
"default": true,
|
||||
|
||||
+520
-470
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 13.4.4
|
||||
version: 16.7.27
|
||||
- name: redis
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 17.9.4
|
||||
digest: sha256:c6290bb7e8ce9c694c06b3f5e9b9d01401943b0943c515d3a7a3a8dc1e6492ea
|
||||
generated: "2025-03-16T00:52:41.47139769+09:00"
|
||||
digest: sha256:fcae507ca24a20b9cc08b8bf0fcb0eba8ffa33126ab6f71cc3a6e1d5e997e9e3
|
||||
generated: "2026-02-08T14:11:58.8058368+01:00"
|
||||
|
||||
@@ -29,10 +29,10 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.15.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.15.4 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 13.4.4
|
||||
version: 16.7.27
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
condition: postgresql.enabled
|
||||
- name: redis
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -50,7 +50,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
|
||||
| Repository | Name | Version |
|
||||
|------------|------|---------|
|
||||
| oci://registry-1.docker.io/bitnamicharts | postgresql | 13.4.4 |
|
||||
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.7.27 |
|
||||
| oci://registry-1.docker.io/bitnamicharts | redis | 17.9.4 |
|
||||
|
||||
## Values
|
||||
|
||||
+2
-2
@@ -82,7 +82,7 @@ dependencies = [
|
||||
"parsedatetime",
|
||||
"paramiko>=3.4.0",
|
||||
"pgsanity",
|
||||
"Pillow>=11.0.0, <12",
|
||||
"Pillow>=11.0.0, <13",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pyparsing>=3.0.6, <4",
|
||||
@@ -99,7 +99,7 @@ dependencies = [
|
||||
"simplejson>=3.15.0",
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-utils>=0.38.3, <0.39",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.9.0, <1.0",
|
||||
|
||||
@@ -399,7 +399,7 @@ sqlalchemy==1.4.54
|
||||
# marshmallow-sqlalchemy
|
||||
# shillelagh
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-utils==0.38.3
|
||||
sqlalchemy-utils==0.42.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
|
||||
@@ -990,7 +990,7 @@ sqlalchemy==1.4.54
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.15.0
|
||||
# via apache-superset
|
||||
sqlalchemy-utils==0.38.3
|
||||
sqlalchemy-utils==0.42.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"flask-appbuilder>=5.0.2,<6",
|
||||
"pydantic>=2.8.0",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy-utils>=0.38.0",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
"typing-extensions>=4.0.0",
|
||||
]
|
||||
|
||||
@@ -16,20 +16,69 @@
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
REST API functions for superset-core.
|
||||
REST API decorators and base classes for superset-core.
|
||||
|
||||
Provides dependency-injected REST API utility functions that will be replaced by
|
||||
host implementations during initialization.
|
||||
Provides decorator stubs that will be replaced by host implementations
|
||||
during initialization.
|
||||
|
||||
Usage:
|
||||
from superset_core.api.rest_api import add_api, add_extension_api
|
||||
from superset_core.api import RestApi, api, extension_api
|
||||
|
||||
add_api(MyCustomAPI)
|
||||
add_extension_api(MyExtensionAPI)
|
||||
# For host application APIs
|
||||
@api
|
||||
class MyAPI(RestApi):
|
||||
@expose("/endpoint", methods=["GET"])
|
||||
def get_data(self):
|
||||
return self.response(200, result={})
|
||||
|
||||
# For extension APIs (auto-discovered, registered under /extensions/)
|
||||
@extension_api(id="my_api", name="My Extension API")
|
||||
class MyExtensionAPI(RestApi):
|
||||
@expose("/endpoint", methods=["GET"])
|
||||
def get_data(self):
|
||||
return self.response(200, result={})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from flask_appbuilder.api import BaseApi
|
||||
|
||||
T = TypeVar("T", bound=type)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Metadata dataclass - attached to decorated classes for discovery
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class RestApiMetadata:
|
||||
"""
|
||||
Metadata stored on classes decorated with @extension_api.
|
||||
|
||||
Attached to classes as __rest_api_metadata__ for build-time discovery.
|
||||
Includes auto-inferred Flask-AppBuilder configuration fields.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
base_path: str = "" # Defaults to /{id}
|
||||
module: str = "" # Format: "package.module.ClassName"
|
||||
|
||||
# Auto-inferred Flask-AppBuilder fields
|
||||
resource_name: str = "" # Used for URL generation and permissions
|
||||
openapi_spec_tag: str = "" # Used for OpenAPI documentation grouping
|
||||
class_permission_name: str = "" # Used for RBAC permissions
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Base class
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RestApi(BaseApi):
|
||||
"""
|
||||
@@ -42,31 +91,150 @@ class RestApi(BaseApi):
|
||||
allow_browser_login = True
|
||||
|
||||
|
||||
def add_api(api: type[RestApi]) -> None:
|
||||
# =============================================================================
|
||||
# Decorator stubs - replaced by host application during initialization
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def api(cls: T) -> T:
|
||||
"""
|
||||
Add a REST API to the Superset API.
|
||||
Decorator to register a REST API with the host application.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
This is a stub that raises NotImplementedError until the host application
|
||||
initializes the concrete implementation via dependency injection.
|
||||
|
||||
:param api: A REST API instance.
|
||||
:returns: None.
|
||||
Usage:
|
||||
@api
|
||||
class MyAPI(RestApi):
|
||||
@expose("/endpoint", methods=["GET"])
|
||||
def get_data(self):
|
||||
return self.response(200, result={})
|
||||
|
||||
Args:
|
||||
cls: The API class to register
|
||||
|
||||
Returns:
|
||||
The decorated class
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Before host implementation is initialized
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
raise NotImplementedError(
|
||||
"REST API decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
def add_extension_api(api: type[RestApi]) -> None:
|
||||
def extension_api(
|
||||
id: str, # noqa: A002
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
base_path: str | None = None,
|
||||
resource_name: str | None = None,
|
||||
openapi_spec_tag: str | None = None,
|
||||
class_permission_name: str | None = None,
|
||||
) -> Callable[[T], T]:
|
||||
"""
|
||||
Add an extension REST API to the Superset API.
|
||||
Decorator to mark a class as an extension REST API.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
This is a stub that raises NotImplementedError until the host application
|
||||
initializes the concrete implementation via dependency injection.
|
||||
|
||||
:param api: An extension REST API instance. These are placed under
|
||||
the /extensions resource.
|
||||
:returns: None.
|
||||
In BUILD mode, stores metadata for discovery without registration.
|
||||
|
||||
Auto-infers Flask-AppBuilder fields from decorator parameters:
|
||||
- resource_name: defaults to id (lowercase)
|
||||
- openapi_spec_tag: defaults to name
|
||||
- class_permission_name: defaults to resource_name
|
||||
- base_path: defaults to /{id}
|
||||
|
||||
Extension APIs are:
|
||||
- Auto-discovered at build time
|
||||
- Registered under /api/v1/extensions/{id}/
|
||||
- Subject to manifest validation for security
|
||||
|
||||
Usage:
|
||||
@extension_api(id="my_api", name="My Extension API")
|
||||
class MyExtensionAPI(RestApi):
|
||||
# These are auto-set by the decorator:
|
||||
# resource_name = "my_api"
|
||||
# openapi_spec_tag = "My Extension API"
|
||||
# class_permission_name = "my_api"
|
||||
|
||||
@expose("/endpoint", methods=["GET"])
|
||||
def get_data(self):
|
||||
return self.response(200, result={})
|
||||
|
||||
Args:
|
||||
id: Unique identifier for this API (used in URL path and resource_name)
|
||||
name: Human-readable name for the API (used for openapi_spec_tag)
|
||||
description: Description of the API (defaults to class docstring)
|
||||
base_path: Base URL path (defaults to /{id})
|
||||
resource_name: Override resource_name (defaults to id)
|
||||
openapi_spec_tag: Override OpenAPI tag (defaults to name)
|
||||
class_permission_name: Override permission name (defaults to resource_name)
|
||||
|
||||
Returns:
|
||||
Decorator that attaches __rest_api_metadata__ and auto-configures
|
||||
Flask-AppBuilder fields
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Before host implementation is initialized (except in
|
||||
BUILD mode)
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
def decorator(cls: T) -> T:
|
||||
# Auto-infer Flask-AppBuilder fields
|
||||
inferred_resource_name = resource_name or id.lower()
|
||||
inferred_openapi_spec_tag = openapi_spec_tag or name
|
||||
inferred_class_permission_name = class_permission_name or inferred_resource_name
|
||||
inferred_base_path = base_path or f"/{id}"
|
||||
|
||||
# Set Flask-AppBuilder attributes on the class
|
||||
cls.resource_name = inferred_resource_name # type: ignore[attr-defined]
|
||||
cls.openapi_spec_tag = inferred_openapi_spec_tag # type: ignore[attr-defined]
|
||||
cls.class_permission_name = inferred_class_permission_name # type: ignore[attr-defined]
|
||||
|
||||
# Try to get context for BUILD mode detection
|
||||
try:
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# In BUILD mode, store metadata for discovery
|
||||
if ctx.is_build_mode:
|
||||
api_description = description
|
||||
if api_description is None and cls.__doc__:
|
||||
api_description = cls.__doc__.strip().split("\n")[0]
|
||||
|
||||
metadata = RestApiMetadata(
|
||||
id=id,
|
||||
name=name,
|
||||
description=api_description,
|
||||
base_path=inferred_base_path,
|
||||
module=f"{cls.__module__}.{cls.__name__}",
|
||||
resource_name=inferred_resource_name,
|
||||
openapi_spec_tag=inferred_openapi_spec_tag,
|
||||
class_permission_name=inferred_class_permission_name,
|
||||
)
|
||||
cls.__rest_api_metadata__ = metadata # type: ignore[attr-defined]
|
||||
return cls
|
||||
except ImportError:
|
||||
# Context not available - fall through to error
|
||||
pass
|
||||
|
||||
# Default behavior: raise error for host to replace
|
||||
raise NotImplementedError(
|
||||
"Extension REST API decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
__all__ = ["RestApi", "add_api", "add_extension_api"]
|
||||
__all__ = [
|
||||
"RestApi",
|
||||
"RestApiMetadata",
|
||||
"api",
|
||||
"extension_api",
|
||||
]
|
||||
|
||||
@@ -14,3 +14,51 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Extension framework types and context."""
|
||||
|
||||
from superset_core.extensions.context import (
|
||||
ContributionType,
|
||||
get_context,
|
||||
PendingContribution,
|
||||
RegistrationContext,
|
||||
RegistrationMode,
|
||||
)
|
||||
from superset_core.extensions.types import (
|
||||
BackendContributions,
|
||||
ExtensionConfig,
|
||||
ExtensionConfigBackend,
|
||||
ExtensionConfigFrontend,
|
||||
FrontendContributions,
|
||||
Manifest,
|
||||
ManifestBackend,
|
||||
ManifestFrontend,
|
||||
McpPromptContribution,
|
||||
McpToolContribution,
|
||||
ModuleFederationConfig,
|
||||
RestApiContribution,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Context
|
||||
"ContributionType",
|
||||
"get_context",
|
||||
"PendingContribution",
|
||||
"RegistrationContext",
|
||||
"RegistrationMode",
|
||||
# Types - Config
|
||||
"ExtensionConfig",
|
||||
"ExtensionConfigBackend",
|
||||
"ExtensionConfigFrontend",
|
||||
# Types - Manifest
|
||||
"Manifest",
|
||||
"ManifestBackend",
|
||||
"ManifestFrontend",
|
||||
# Types - Contributions
|
||||
"BackendContributions",
|
||||
"FrontendContributions",
|
||||
"McpToolContribution",
|
||||
"McpPromptContribution",
|
||||
"RestApiContribution",
|
||||
"ModuleFederationConfig",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Constants for extension validation and naming.
|
||||
"""
|
||||
|
||||
# Publisher validation pattern: lowercase letters, numbers, hyphens; must start with
|
||||
# letter; no consecutive hyphens or trailing hyphens
|
||||
PUBLISHER_PATTERN = r"^[a-z]([a-z0-9]*(-[a-z0-9]+)*)?$"
|
||||
|
||||
# Technical name validation pattern: lowercase letters, numbers, hyphens; must start
|
||||
# with letter; no consecutive hyphens or trailing hyphens
|
||||
TECHNICAL_NAME_PATTERN = r"^[a-z]([a-z0-9]*(-[a-z0-9]+)*)?$"
|
||||
|
||||
# Display name validation pattern: must start with letter, can contain letters,
|
||||
# numbers, spaces, hyphens, underscores, dots
|
||||
DISPLAY_NAME_PATTERN = r"^[a-zA-Z][a-zA-Z0-9\s\-_\.]*$"
|
||||
|
||||
# Version pattern for semantic versioning
|
||||
VERSION_PATTERN = r"^\d+\.\d+\.\d+$"
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Registration context for contribution discovery and security.
|
||||
|
||||
Controls how decorators behave based on the current execution context:
|
||||
- host: Register immediately (for host application components)
|
||||
- extension: Store metadata only, defer to ExtensionManager (security boundary)
|
||||
- build: Store metadata only, for CLI discovery
|
||||
|
||||
The manifest.json serves as the security allowlist for extensions.
|
||||
Only contributions declared in the manifest will be registered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Iterator, Literal, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.api.rest_api import RestApiMetadata
|
||||
from superset_core.mcp import PromptMetadata, ToolMetadata
|
||||
|
||||
|
||||
# Type alias for contribution types
|
||||
ContributionType = Literal["tool", "prompt", "restApi"]
|
||||
|
||||
|
||||
class RegistrationMode(Enum):
|
||||
"""Registration modes for decorator behavior."""
|
||||
|
||||
HOST = "host" # Register immediately (host application)
|
||||
EXTENSION = "extension" # Defer registration (manifest validation)
|
||||
BUILD = "build" # Metadata only (CLI discovery)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingContribution:
|
||||
"""A contribution waiting for registration after manifest validation."""
|
||||
|
||||
func: Callable[..., Any]
|
||||
metadata: ToolMetadata | PromptMetadata | RestApiMetadata
|
||||
contrib_type: ContributionType
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistrationContext:
|
||||
"""
|
||||
Global context controlling decorator registration behavior.
|
||||
|
||||
In host mode, decorators register immediately with MCP.
|
||||
In extension mode, decorators store metadata and the ExtensionManager
|
||||
validates against the manifest before completing registration.
|
||||
In build mode, decorators only store metadata for discovery.
|
||||
"""
|
||||
|
||||
_mode: RegistrationMode = RegistrationMode.HOST
|
||||
_current_extension_id: str | None = None
|
||||
_pending_contributions: dict[str, list[PendingContribution]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
def set_mode(self, mode: RegistrationMode) -> None:
|
||||
"""Set the global registration mode."""
|
||||
self._mode = mode
|
||||
|
||||
@property
|
||||
def mode(self) -> RegistrationMode:
|
||||
"""Get the current registration mode."""
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def is_host_mode(self) -> bool:
|
||||
"""True if in host mode (immediate registration)."""
|
||||
return self._mode == RegistrationMode.HOST
|
||||
|
||||
@property
|
||||
def is_extension_mode(self) -> bool:
|
||||
"""True if in extension mode (deferred registration)."""
|
||||
return self._mode == RegistrationMode.EXTENSION
|
||||
|
||||
@property
|
||||
def is_build_mode(self) -> bool:
|
||||
"""True if in build mode (metadata only)."""
|
||||
return self._mode == RegistrationMode.BUILD
|
||||
|
||||
@property
|
||||
def current_extension_id(self) -> str | None:
|
||||
"""Get the current extension ID being loaded."""
|
||||
return self._current_extension_id
|
||||
|
||||
@contextmanager
|
||||
def extension_context(self, extension_id: str) -> Iterator[None]:
|
||||
"""
|
||||
Context manager for loading an extension.
|
||||
|
||||
While in this context, decorators defer registration and store
|
||||
contributions for manifest validation.
|
||||
|
||||
Args:
|
||||
extension_id: The extension being loaded
|
||||
|
||||
Yields:
|
||||
None
|
||||
"""
|
||||
old_mode = self._mode
|
||||
old_ext = self._current_extension_id
|
||||
|
||||
self._mode = RegistrationMode.EXTENSION
|
||||
self._current_extension_id = extension_id
|
||||
self._pending_contributions[extension_id] = []
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._mode = old_mode
|
||||
self._current_extension_id = old_ext
|
||||
|
||||
def add_pending_contribution(
|
||||
self,
|
||||
func: Callable[..., Any],
|
||||
metadata: ToolMetadata | PromptMetadata | RestApiMetadata,
|
||||
contrib_type: ContributionType,
|
||||
) -> None:
|
||||
"""
|
||||
Add a contribution pending manifest validation.
|
||||
|
||||
Called by decorators in extension mode.
|
||||
|
||||
Args:
|
||||
func: The decorated function
|
||||
metadata: The contribution metadata
|
||||
contrib_type: Type of contribution ("tool", "prompt", "restApi")
|
||||
"""
|
||||
if self._current_extension_id is None:
|
||||
raise RuntimeError(
|
||||
"Cannot add pending contribution outside extension context"
|
||||
)
|
||||
|
||||
self._pending_contributions[self._current_extension_id].append(
|
||||
PendingContribution(
|
||||
func=func,
|
||||
metadata=metadata,
|
||||
contrib_type=contrib_type,
|
||||
)
|
||||
)
|
||||
|
||||
def get_pending_contributions(self, extension_id: str) -> list[PendingContribution]:
|
||||
"""
|
||||
Get pending contributions for an extension.
|
||||
|
||||
Called by ExtensionManager during manifest validation.
|
||||
|
||||
Args:
|
||||
extension_id: The extension to get contributions for
|
||||
|
||||
Returns:
|
||||
List of pending contributions
|
||||
"""
|
||||
return self._pending_contributions.get(extension_id, [])
|
||||
|
||||
def clear_pending_contributions(self, extension_id: str) -> None:
|
||||
"""
|
||||
Clear pending contributions after registration.
|
||||
|
||||
Called by ExtensionManager after successful registration.
|
||||
|
||||
Args:
|
||||
extension_id: The extension to clear contributions for
|
||||
"""
|
||||
self._pending_contributions.pop(extension_id, None)
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_context = RegistrationContext()
|
||||
|
||||
|
||||
def get_context() -> RegistrationContext:
|
||||
"""Get the global registration context."""
|
||||
return _context
|
||||
@@ -29,6 +29,13 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field # noqa: I001
|
||||
|
||||
from superset_core.extensions.constants import (
|
||||
DISPLAY_NAME_PATTERN,
|
||||
PUBLISHER_PATTERN,
|
||||
TECHNICAL_NAME_PATTERN,
|
||||
VERSION_PATTERN,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# Shared components
|
||||
# =============================================================================
|
||||
@@ -37,6 +44,11 @@ from pydantic import BaseModel, Field # noqa: I001
|
||||
class ModuleFederationConfig(BaseModel):
|
||||
"""Configuration for Webpack Module Federation."""
|
||||
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="Module Federation container name "
|
||||
"(must be valid JavaScript identifier)",
|
||||
)
|
||||
exposes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Modules exposed by this extension",
|
||||
@@ -75,6 +87,8 @@ class ContributionConfig(BaseModel):
|
||||
}
|
||||
}
|
||||
"""
|
||||
class FrontendContributions(BaseModel):
|
||||
"""Frontend UI contributions."""
|
||||
|
||||
commands: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
@@ -88,25 +102,99 @@ class ContributionConfig(BaseModel):
|
||||
default_factory=dict,
|
||||
description="Menu contributions by scope and location",
|
||||
)
|
||||
editors: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Editor contributions",
|
||||
)
|
||||
editors: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Editor contributions",
|
||||
)
|
||||
|
||||
|
||||
class McpToolContribution(BaseModel):
|
||||
"""MCP tool contribution."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
module: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
protect: bool = True
|
||||
|
||||
|
||||
class McpPromptContribution(BaseModel):
|
||||
"""MCP prompt contribution."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
module: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
protect: bool = True
|
||||
|
||||
|
||||
class RestApiContribution(BaseModel):
|
||||
"""REST API contribution."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
module: str
|
||||
basePath: str # noqa: N815
|
||||
|
||||
# Auto-inferred Flask-AppBuilder fields
|
||||
resourceName: str = "" # noqa: N815
|
||||
openapiSpecTag: str = "" # noqa: N815
|
||||
classPermissionName: str = "" # noqa: N815
|
||||
|
||||
|
||||
class BackendContributions(BaseModel):
|
||||
"""Backend contributions."""
|
||||
|
||||
mcp_tools: list[McpToolContribution] = Field(
|
||||
default_factory=list,
|
||||
description="MCP tools",
|
||||
alias="mcp.tools",
|
||||
)
|
||||
mcp_prompts: list[McpPromptContribution] = Field(
|
||||
default_factory=list,
|
||||
description="MCP prompts",
|
||||
alias="mcp.prompts",
|
||||
)
|
||||
rest_apis: list[RestApiContribution] = Field(
|
||||
default_factory=list,
|
||||
description="REST APIs",
|
||||
alias="rest.apis",
|
||||
)
|
||||
|
||||
|
||||
class BaseExtension(BaseModel):
|
||||
"""Base fields shared by ExtensionConfig and Manifest."""
|
||||
|
||||
id: str = Field(
|
||||
publisher: str = Field(
|
||||
...,
|
||||
description="Unique extension identifier",
|
||||
description="Publisher/organization namespace",
|
||||
min_length=1,
|
||||
pattern=PUBLISHER_PATTERN,
|
||||
)
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Technical extension identifier",
|
||||
min_length=1,
|
||||
pattern=TECHNICAL_NAME_PATTERN,
|
||||
)
|
||||
displayName: str = Field( # noqa: N815
|
||||
...,
|
||||
description="Human-readable extension name",
|
||||
min_length=1,
|
||||
pattern=DISPLAY_NAME_PATTERN,
|
||||
)
|
||||
version: str = Field(
|
||||
default="0.0.0",
|
||||
description="Semantic version string",
|
||||
pattern=r"^\d+\.\d+\.\d+$",
|
||||
pattern=VERSION_PATTERN,
|
||||
)
|
||||
license: str | None = Field(
|
||||
default=None,
|
||||
@@ -134,10 +222,6 @@ class BaseExtension(BaseModel):
|
||||
class ExtensionConfigFrontend(BaseModel):
|
||||
"""Frontend section in extension.json."""
|
||||
|
||||
contributions: ContributionConfig = Field(
|
||||
default_factory=ContributionConfig,
|
||||
description="UI contribution points",
|
||||
)
|
||||
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
|
||||
default_factory=ModuleFederationConfig,
|
||||
description="Module Federation configuration",
|
||||
@@ -153,7 +237,8 @@ class ExtensionConfigBackend(BaseModel):
|
||||
)
|
||||
files: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Glob patterns for backend Python files",
|
||||
description="Glob patterns for backend Python files (defaults to"
|
||||
"backend/src/**/*.py)",
|
||||
)
|
||||
|
||||
|
||||
@@ -161,7 +246,9 @@ class ExtensionConfig(BaseExtension):
|
||||
"""
|
||||
Schema for extension.json (source configuration).
|
||||
|
||||
This file is authored by developers to define extension metadata.
|
||||
Authored by developers. Contributions can be:
|
||||
- Auto-discovered from decorated code (default)
|
||||
- Manually specified in contributions field (overrides discovery)
|
||||
"""
|
||||
|
||||
frontend: ExtensionConfigFrontend | None = Field(
|
||||
@@ -182,9 +269,9 @@ class ExtensionConfig(BaseExtension):
|
||||
class ManifestFrontend(BaseModel):
|
||||
"""Frontend section in manifest.json."""
|
||||
|
||||
contributions: ContributionConfig = Field(
|
||||
default_factory=ContributionConfig,
|
||||
description="UI contribution points",
|
||||
contributions: FrontendContributions = Field(
|
||||
default_factory=FrontendContributions,
|
||||
description="Frontend contributions",
|
||||
)
|
||||
moduleFederation: ModuleFederationConfig = Field( # noqa: N815
|
||||
default_factory=ModuleFederationConfig,
|
||||
@@ -203,15 +290,25 @@ class ManifestBackend(BaseModel):
|
||||
default_factory=list,
|
||||
description="Python module entry points to load",
|
||||
)
|
||||
contributions: BackendContributions = Field(
|
||||
default_factory=BackendContributions,
|
||||
description="Backend contributions",
|
||||
)
|
||||
|
||||
|
||||
class Manifest(BaseExtension):
|
||||
"""
|
||||
Schema for manifest.json (built output).
|
||||
|
||||
This file is generated by the build tool from extension.json.
|
||||
Generated by the build tool. Contains all contributions
|
||||
(discovered or manually specified) that will be registered at runtime.
|
||||
"""
|
||||
|
||||
id: str = Field(
|
||||
...,
|
||||
description="Composite extension ID (publisher.name)",
|
||||
min_length=1,
|
||||
)
|
||||
frontend: ManifestFrontend | None = Field(
|
||||
default=None,
|
||||
description="Frontend manifest",
|
||||
|
||||
@@ -16,31 +16,78 @@
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
MCP (Model Context Protocol) tool registration for Superset MCP server.
|
||||
MCP (Model Context Protocol) tool and prompt registration for Superset.
|
||||
|
||||
This module provides a decorator interface to register MCP tools with the
|
||||
host application.
|
||||
This module provides decorator stubs that are replaced by the host application
|
||||
during initialization. Each decorator defines metadata dataclasses that are
|
||||
used for build-time discovery.
|
||||
|
||||
Usage:
|
||||
from superset_core.mcp import tool
|
||||
from superset_core.mcp import tool, prompt
|
||||
|
||||
@tool(name="my_tool", description="Custom business logic", tags=["extension"])
|
||||
def my_extension_tool(param: str) -> dict:
|
||||
return {"message": f"Hello {param}!"}
|
||||
@tool(tags=["database"])
|
||||
def query_database(sql: str) -> dict:
|
||||
'''Execute a SQL query against a database.'''
|
||||
return execute_query(sql)
|
||||
|
||||
# Or use function name and docstring:
|
||||
@tool
|
||||
def another_tool(value: int) -> str:
|
||||
'''Tool description from docstring'''
|
||||
return str(value * 2)
|
||||
@prompt(tags={"analysis"})
|
||||
async def analyze_data(ctx, dataset: str) -> str:
|
||||
'''Generate analysis for a dataset.'''
|
||||
return f"Analyze {dataset}..."
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
# Type variable for decorated functions
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Metadata dataclasses - attached to decorated functions for discovery
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolMetadata:
|
||||
"""
|
||||
Metadata stored on functions decorated with @tool.
|
||||
|
||||
Attached to functions as __tool_metadata__ for build-time discovery.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
tags: list[str] = field(default_factory=list)
|
||||
protect: bool = True
|
||||
module: str = "" # Format: "package.module.function_name"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptMetadata:
|
||||
"""
|
||||
Metadata stored on functions decorated with @prompt.
|
||||
|
||||
Attached to functions as __prompt_metadata__ for build-time discovery.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
tags: set[str] = field(default_factory=set)
|
||||
protect: bool = True
|
||||
module: str = "" # Format: "package.module.function_name"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decorator stubs - replaced by host application during initialization
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def tool(
|
||||
func_or_name: str | Callable[..., Any] | None = None,
|
||||
*,
|
||||
@@ -48,53 +95,90 @@ def tool(
|
||||
description: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
protect: bool = True,
|
||||
) -> Any: # Use Any to avoid mypy issues with dependency injection
|
||||
) -> Any:
|
||||
"""
|
||||
Decorator to register an MCP tool with optional authentication.
|
||||
|
||||
This decorator combines FastMCP tool registration with optional authentication.
|
||||
This is a stub that raises NotImplementedError until the host application
|
||||
initializes the concrete implementation via dependency injection.
|
||||
|
||||
In BUILD mode, stores metadata for discovery without registration.
|
||||
|
||||
Can be used as:
|
||||
@tool
|
||||
def my_tool(): ...
|
||||
|
||||
Or:
|
||||
@tool(tags=["database"])
|
||||
def query(): ...
|
||||
|
||||
@tool(name="custom_name", protect=False)
|
||||
def my_tool(): ...
|
||||
|
||||
Args:
|
||||
func_or_name: When used as @tool, this will be the function.
|
||||
When used as @tool("name"), this will be the name.
|
||||
name: Tool name (defaults to function name, prefixed with extension ID)
|
||||
description: Tool description (defaults to function docstring)
|
||||
tags: List of tags for categorizing the tool (defaults to empty list)
|
||||
name: Tool name (defaults to function name)
|
||||
description: Tool description (defaults to first line of docstring)
|
||||
tags: List of tags for categorizing the tool
|
||||
protect: Whether to require Superset authentication (defaults to True)
|
||||
|
||||
Returns:
|
||||
Decorator function that registers and wraps the tool, or the wrapped function
|
||||
Decorated function with __tool_metadata__ attribute
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If called before host implementation is initialized
|
||||
|
||||
Example:
|
||||
@tool(name="my_tool", description="Does something useful", tags=["utility"])
|
||||
def my_custom_tool(param: str) -> dict:
|
||||
return {"result": param}
|
||||
|
||||
@tool # Uses function name and docstring with auth
|
||||
def simple_tool(value: int) -> str:
|
||||
'''Doubles the input value'''
|
||||
return str(value * 2)
|
||||
|
||||
@tool(protect=False) # No authentication required
|
||||
def public_tool() -> str:
|
||||
'''Public tool accessible without auth'''
|
||||
return "Hello world"
|
||||
NotImplementedError: Before host implementation is initialized (except in
|
||||
BUILD mode)
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"MCP tool decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
# Try to get context for BUILD mode detection
|
||||
try:
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# In BUILD mode, store metadata for discovery
|
||||
if ctx.is_build_mode:
|
||||
tool_name = name or func.__name__
|
||||
tool_description = description
|
||||
if tool_description is None and func.__doc__:
|
||||
tool_description = func.__doc__.strip().split("\n")[0]
|
||||
tool_tags = tags or []
|
||||
|
||||
metadata = ToolMetadata(
|
||||
id=func.__name__,
|
||||
name=tool_name,
|
||||
description=tool_description,
|
||||
tags=tool_tags,
|
||||
protect=protect,
|
||||
module=f"{func.__module__}.{func.__name__}",
|
||||
)
|
||||
func.__tool_metadata__ = metadata # type: ignore[attr-defined]
|
||||
return func
|
||||
except ImportError:
|
||||
# Context not available - fall through to error
|
||||
pass
|
||||
|
||||
# Default behavior: raise error for host to replace
|
||||
raise NotImplementedError(
|
||||
"MCP tool decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
# Handle decorator usage patterns
|
||||
if callable(func_or_name):
|
||||
return decorator(func_or_name)
|
||||
|
||||
# Return parameterized decorator
|
||||
actual_name = func_or_name if isinstance(func_or_name, str) else name
|
||||
|
||||
def parameterized_decorator(func: F) -> F:
|
||||
nonlocal name
|
||||
if actual_name is not None:
|
||||
name = actual_name
|
||||
return decorator(func)
|
||||
|
||||
return parameterized_decorator
|
||||
|
||||
|
||||
def prompt(
|
||||
@@ -105,57 +189,98 @@ def prompt(
|
||||
description: str | None = None,
|
||||
tags: set[str] | None = None,
|
||||
protect: bool = True,
|
||||
) -> Any: # Use Any to avoid mypy issues with dependency injection
|
||||
) -> Any:
|
||||
"""
|
||||
Decorator to register an MCP prompt with optional authentication.
|
||||
|
||||
This decorator combines FastMCP prompt registration with optional authentication.
|
||||
This is a stub that raises NotImplementedError until the host application
|
||||
initializes the concrete implementation via dependency injection.
|
||||
|
||||
In BUILD mode, stores metadata for discovery without registration.
|
||||
|
||||
Can be used as:
|
||||
@prompt
|
||||
async def my_prompt_handler(): ...
|
||||
async def my_prompt(ctx): ...
|
||||
|
||||
Or:
|
||||
@prompt("my_prompt")
|
||||
async def my_prompt_handler(): ...
|
||||
@prompt(tags={"analysis"})
|
||||
async def analyze(ctx): ...
|
||||
|
||||
Or:
|
||||
@prompt("my_prompt", protected=False, title="Custom Title")
|
||||
async def my_prompt_handler(): ...
|
||||
@prompt("custom_name", title="Custom Title")
|
||||
async def my_prompt(ctx): ...
|
||||
|
||||
Args:
|
||||
func_or_name: When used as @prompt, this will be the function.
|
||||
When used as @prompt("name"), this will be the name.
|
||||
name: Prompt name (defaults to function name if not provided)
|
||||
name: Prompt name (defaults to function name)
|
||||
title: Prompt title (defaults to function name)
|
||||
description: Prompt description (defaults to function docstring)
|
||||
description: Prompt description (defaults to first line of docstring)
|
||||
tags: Set of tags for categorizing the prompt
|
||||
protect: Whether to require Superset authentication (defaults to True)
|
||||
|
||||
Returns:
|
||||
Decorator function that registers and wraps the prompt, or the wrapped function
|
||||
Decorated function with __prompt_metadata__ attribute
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If called before host implementation is initialized
|
||||
|
||||
Example:
|
||||
@prompt
|
||||
async def my_prompt_handler(ctx: Context) -> str:
|
||||
'''Interactive prompt for doing something.'''
|
||||
return "Prompt instructions here..."
|
||||
|
||||
@prompt("custom_prompt", protect=False, title="Custom Title")
|
||||
async def public_prompt_handler(ctx: Context) -> str:
|
||||
'''Public prompt accessible without auth'''
|
||||
return "Public prompt accessible without auth"
|
||||
NotImplementedError: Before host implementation is initialized (except in
|
||||
BUILD mode)
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"MCP prompt decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
def decorator(func: F) -> F:
|
||||
# Try to get context for BUILD mode detection
|
||||
try:
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# In BUILD mode, store metadata for discovery
|
||||
if ctx.is_build_mode:
|
||||
prompt_name = name or func.__name__
|
||||
prompt_title = title or func.__name__
|
||||
prompt_description = description
|
||||
if prompt_description is None and func.__doc__:
|
||||
prompt_description = func.__doc__.strip().split("\n")[0]
|
||||
prompt_tags = tags or set()
|
||||
|
||||
metadata = PromptMetadata(
|
||||
id=func.__name__,
|
||||
name=prompt_name,
|
||||
title=prompt_title,
|
||||
description=prompt_description,
|
||||
tags=prompt_tags,
|
||||
protect=protect,
|
||||
module=f"{func.__module__}.{func.__name__}",
|
||||
)
|
||||
func.__prompt_metadata__ = metadata # type: ignore[attr-defined]
|
||||
return func
|
||||
except ImportError:
|
||||
# Context not available - fall through to error
|
||||
pass
|
||||
|
||||
# Default behavior: raise error for host to replace
|
||||
raise NotImplementedError(
|
||||
"MCP prompt decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
# Handle decorator usage patterns
|
||||
if callable(func_or_name):
|
||||
return decorator(func_or_name)
|
||||
|
||||
# Return parameterized decorator
|
||||
actual_name = func_or_name if isinstance(func_or_name, str) else name
|
||||
|
||||
def parameterized_decorator(func: F) -> F:
|
||||
nonlocal name
|
||||
if actual_name is not None:
|
||||
name = actual_name
|
||||
return decorator(func)
|
||||
|
||||
return parameterized_decorator
|
||||
|
||||
|
||||
__all__ = [
|
||||
"tool",
|
||||
"prompt",
|
||||
"ToolMetadata",
|
||||
"PromptMetadata",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for REST API decorators in BUILD mode."""
|
||||
|
||||
from superset_core.api.rest_api import extension_api, RestApi
|
||||
from superset_core.extensions.context import get_context, RegistrationMode
|
||||
|
||||
|
||||
def test_extension_api_decorator_stores_metadata():
|
||||
"""Test that @extension_api decorator stores metadata."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@extension_api(id="test_api", name="Test API")
|
||||
class TestAPI(RestApi):
|
||||
"""Test API class."""
|
||||
|
||||
pass
|
||||
|
||||
# Should have metadata attached
|
||||
assert hasattr(TestAPI, "__rest_api_metadata__")
|
||||
meta = TestAPI.__rest_api_metadata__
|
||||
assert meta.id == "test_api"
|
||||
assert meta.name == "Test API"
|
||||
assert meta.description == "Test API class."
|
||||
assert meta.base_path == "/test_api"
|
||||
assert "TestAPI" in meta.module
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_extension_api_decorator_with_custom_metadata():
|
||||
"""Test @extension_api decorator with custom metadata."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@extension_api(
|
||||
id="custom_api",
|
||||
name="Custom API",
|
||||
description="Custom description",
|
||||
base_path="/custom/path",
|
||||
)
|
||||
class CustomAPI(RestApi):
|
||||
"""Original docstring."""
|
||||
|
||||
pass
|
||||
|
||||
meta = CustomAPI.__rest_api_metadata__
|
||||
assert meta.id == "custom_api"
|
||||
assert meta.name == "Custom API"
|
||||
assert meta.description == "Custom description"
|
||||
assert meta.base_path == "/custom/path"
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_extension_api_decorator_auto_infers_flask_fields():
|
||||
"""Test that @extension_api auto-infers Flask-AppBuilder fields."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@extension_api(id="infer_api", name="Infer API")
|
||||
class InferAPI(RestApi):
|
||||
"""Test auto-inference."""
|
||||
|
||||
pass
|
||||
|
||||
meta = InferAPI.__rest_api_metadata__
|
||||
# Check auto-inferred fields
|
||||
assert meta.resource_name == "infer_api"
|
||||
assert meta.openapi_spec_tag == "Infer API"
|
||||
assert meta.class_permission_name == "infer_api"
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_extension_api_decorator_custom_flask_fields():
|
||||
"""Test @extension_api with custom Flask-AppBuilder fields."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@extension_api(
|
||||
id="custom_flask_api",
|
||||
name="Custom Flask API",
|
||||
resource_name="custom_resource",
|
||||
openapi_spec_tag="Custom Tag",
|
||||
class_permission_name="custom_permission",
|
||||
)
|
||||
class CustomFlaskAPI(RestApi):
|
||||
"""Custom Flask fields."""
|
||||
|
||||
pass
|
||||
|
||||
meta = CustomFlaskAPI.__rest_api_metadata__
|
||||
assert meta.resource_name == "custom_resource"
|
||||
assert meta.openapi_spec_tag == "Custom Tag"
|
||||
assert meta.class_permission_name == "custom_permission"
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_extension_api_decorator_does_not_register_in_build_mode():
|
||||
"""Test that @extension_api doesn't attempt registration in BUILD mode."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
# This should not raise an exception even though no registration mechanism
|
||||
# is available
|
||||
@extension_api(id="build_api", name="Build API")
|
||||
class BuildAPI(RestApi):
|
||||
"""Build mode API."""
|
||||
|
||||
pass
|
||||
|
||||
# Should have metadata
|
||||
assert hasattr(BuildAPI, "__rest_api_metadata__")
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for MCP decorators in BUILD mode."""
|
||||
|
||||
from superset_core.extensions.context import get_context, RegistrationMode
|
||||
from superset_core.mcp import prompt, tool
|
||||
|
||||
|
||||
def test_tool_decorator_stores_metadata_in_build_mode():
|
||||
"""Test that @tool decorator stores metadata when in BUILD mode."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@tool(tags=["database"])
|
||||
def test_query_tool(sql: str) -> dict:
|
||||
"""Execute a SQL query."""
|
||||
return {"result": "test"}
|
||||
|
||||
# Should have metadata attached
|
||||
assert hasattr(test_query_tool, "__tool_metadata__")
|
||||
meta = test_query_tool.__tool_metadata__
|
||||
assert meta.id == "test_query_tool"
|
||||
assert meta.name == "test_query_tool"
|
||||
assert meta.description == "Execute a SQL query."
|
||||
assert meta.tags == ["database"]
|
||||
assert meta.protect is True
|
||||
assert "test_query_tool" in meta.module
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_tool_decorator_with_custom_metadata():
|
||||
"""Test @tool decorator with custom name and description."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@tool(
|
||||
name="Custom Tool",
|
||||
description="Custom description",
|
||||
tags=["test"],
|
||||
protect=False,
|
||||
)
|
||||
def my_tool() -> str:
|
||||
"""Original docstring."""
|
||||
return "test"
|
||||
|
||||
meta = my_tool.__tool_metadata__
|
||||
assert meta.id == "my_tool"
|
||||
assert meta.name == "Custom Tool"
|
||||
assert meta.description == "Custom description"
|
||||
assert meta.tags == ["test"]
|
||||
assert meta.protect is False
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_prompt_decorator_stores_metadata_in_build_mode():
|
||||
"""Test that @prompt decorator stores metadata when in BUILD mode."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@prompt(tags=["analysis"])
|
||||
async def test_prompt(ctx, dataset: str) -> str:
|
||||
"""Generate analysis for a dataset."""
|
||||
return f"Analyze {dataset}"
|
||||
|
||||
# Should have metadata attached
|
||||
assert hasattr(test_prompt, "__prompt_metadata__")
|
||||
meta = test_prompt.__prompt_metadata__
|
||||
assert meta.id == "test_prompt"
|
||||
assert meta.name == "test_prompt"
|
||||
assert meta.description == "Generate analysis for a dataset."
|
||||
assert meta.tags == ["analysis"]
|
||||
assert meta.protect is True
|
||||
assert "test_prompt" in meta.module
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_prompt_decorator_with_custom_metadata():
|
||||
"""Test @prompt decorator with custom metadata."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
|
||||
@prompt(
|
||||
name="Custom Prompt",
|
||||
title="Custom Title",
|
||||
description="Custom description",
|
||||
tags=["custom"],
|
||||
protect=False,
|
||||
)
|
||||
async def my_prompt(ctx) -> str:
|
||||
"""Original docstring."""
|
||||
return "test"
|
||||
|
||||
meta = my_prompt.__prompt_metadata__
|
||||
assert meta.id == "my_prompt"
|
||||
assert meta.name == "Custom Prompt"
|
||||
assert meta.title == "Custom Title"
|
||||
assert meta.description == "Custom description"
|
||||
assert meta.tags == ["custom"]
|
||||
assert meta.protect is False
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
|
||||
def test_decorators_do_not_register_in_build_mode():
|
||||
"""Test that decorators don't attempt registration in BUILD mode."""
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
# This should not raise an exception even though no MCP server is available
|
||||
@tool(tags=["test"])
|
||||
def build_mode_tool() -> str:
|
||||
return "test"
|
||||
|
||||
@prompt(tags=["test"])
|
||||
async def build_mode_prompt(ctx) -> str:
|
||||
return "test"
|
||||
|
||||
# Both should have metadata
|
||||
assert hasattr(build_mode_tool, "__tool_metadata__")
|
||||
assert hasattr(build_mode_prompt, "__prompt_metadata__")
|
||||
|
||||
finally:
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
@@ -15,6 +15,8 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json # noqa: TID251
|
||||
import re
|
||||
import shutil
|
||||
@@ -28,22 +30,169 @@ from typing import Any, Callable
|
||||
import click
|
||||
import semver
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from superset_core.extensions.types import (
|
||||
from superset_core.extensions import (
|
||||
BackendContributions,
|
||||
ExtensionConfig,
|
||||
FrontendContributions,
|
||||
Manifest,
|
||||
ManifestBackend,
|
||||
ManifestFrontend,
|
||||
McpPromptContribution,
|
||||
McpToolContribution,
|
||||
RegistrationMode,
|
||||
RestApiContribution,
|
||||
get_context,
|
||||
)
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from superset_extensions_cli.constants import MIN_NPM_VERSION
|
||||
from superset_extensions_cli.utils import read_json, read_toml
|
||||
from superset_extensions_cli.exceptions import ExtensionNameError
|
||||
from superset_extensions_cli.types import ExtensionNames
|
||||
from superset_extensions_cli.utils import (
|
||||
generate_extension_names,
|
||||
kebab_to_snake_case,
|
||||
read_json,
|
||||
read_toml,
|
||||
suggest_technical_name,
|
||||
validate_display_name,
|
||||
validate_publisher,
|
||||
validate_technical_name,
|
||||
)
|
||||
|
||||
REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$")
|
||||
FRONTEND_DIST_REGEX = re.compile(r"/frontend/dist")
|
||||
|
||||
|
||||
def discover_backend_contributions(
|
||||
cwd: Path, files_patterns: list[str]
|
||||
) -> BackendContributions:
|
||||
"""
|
||||
Discover backend contributions by importing modules and inspecting decorated objects.
|
||||
|
||||
Sets context to BUILD mode so decorators only store metadata, no registration.
|
||||
"""
|
||||
contributions = BackendContributions()
|
||||
|
||||
# Set build mode so decorators don't try to register
|
||||
ctx = get_context()
|
||||
ctx.set_mode(RegistrationMode.BUILD)
|
||||
|
||||
try:
|
||||
# Collect all Python files matching patterns
|
||||
py_files: list[Path] = []
|
||||
for pattern in files_patterns:
|
||||
py_files.extend(cwd.glob(pattern))
|
||||
|
||||
# Filter to only process Python files
|
||||
python_files = [f for f in py_files if f.is_file() and f.suffix == ".py"]
|
||||
|
||||
for py_file in python_files:
|
||||
try:
|
||||
# Import module dynamically
|
||||
module = _import_module_from_path(py_file)
|
||||
if module is None:
|
||||
continue
|
||||
|
||||
# Inspect all members for decorated objects
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
|
||||
# Check for @tool metadata
|
||||
if hasattr(obj, "__tool_metadata__"):
|
||||
meta = obj.__tool_metadata__
|
||||
contributions.mcp_tools.append(
|
||||
McpToolContribution(
|
||||
id=meta.id,
|
||||
name=meta.name,
|
||||
description=meta.description,
|
||||
module=meta.module,
|
||||
tags=list(meta.tags),
|
||||
protect=meta.protect,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for @prompt metadata
|
||||
if hasattr(obj, "__prompt_metadata__"):
|
||||
meta = obj.__prompt_metadata__
|
||||
contributions.mcp_prompts.append(
|
||||
McpPromptContribution(
|
||||
id=meta.id,
|
||||
name=meta.name,
|
||||
title=meta.title,
|
||||
description=meta.description,
|
||||
module=meta.module,
|
||||
tags=list(meta.tags),
|
||||
protect=meta.protect,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for @extension_api metadata
|
||||
if hasattr(obj, "__rest_api_metadata__"):
|
||||
meta = obj.__rest_api_metadata__
|
||||
contributions.rest_apis.append(
|
||||
RestApiContribution(
|
||||
id=meta.id,
|
||||
name=meta.name,
|
||||
description=meta.description,
|
||||
module=meta.module,
|
||||
basePath=meta.base_path,
|
||||
resourceName=meta.resource_name,
|
||||
openapiSpecTag=meta.openapi_spec_tag,
|
||||
classPermissionName=meta.class_permission_name,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
click.secho(f"⚠️ Failed to analyze {py_file}: {e}", fg="yellow")
|
||||
|
||||
finally:
|
||||
# Reset to host mode
|
||||
ctx.set_mode(RegistrationMode.HOST)
|
||||
|
||||
return contributions
|
||||
|
||||
|
||||
def discover_frontend_contributions(cwd: Path) -> FrontendContributions:
|
||||
"""
|
||||
Discover frontend contributions from webpack plugin output.
|
||||
|
||||
The webpack plugin outputs a contributions.json file during build.
|
||||
"""
|
||||
contributions_file = cwd / "frontend" / "dist" / "contributions.json"
|
||||
|
||||
if not contributions_file.exists():
|
||||
# No frontend contributions found - this is normal for extensions without frontend
|
||||
return FrontendContributions()
|
||||
|
||||
try:
|
||||
contributions_data = json.loads(contributions_file.read_text())
|
||||
return FrontendContributions.model_validate(contributions_data)
|
||||
except Exception as e:
|
||||
click.secho(f"⚠️ Failed to parse frontend contributions: {e}", fg="yellow")
|
||||
return FrontendContributions()
|
||||
|
||||
|
||||
def _import_module_from_path(py_file: Path) -> Any:
|
||||
"""Import a Python module from a file path."""
|
||||
module_name = py_file.stem
|
||||
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
||||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except Exception:
|
||||
# Clean up on failure
|
||||
sys.modules.pop(module_name, None)
|
||||
raise
|
||||
|
||||
|
||||
def validate_npm() -> None:
|
||||
"""Abort if `npm` is not on PATH."""
|
||||
if shutil.which("npm") is None:
|
||||
@@ -137,21 +286,59 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
|
||||
|
||||
extension = ExtensionConfig.model_validate(extension_data)
|
||||
|
||||
# Build frontend manifest with auto-discovery
|
||||
frontend: ManifestFrontend | None = None
|
||||
if extension.frontend and remote_entry:
|
||||
click.secho("🔍 Auto-discovering frontend contributions...", fg="cyan")
|
||||
frontend_contributions = discover_frontend_contributions(cwd)
|
||||
|
||||
# Count contributions for feedback
|
||||
command_count = len(frontend_contributions.commands)
|
||||
view_count = sum(len(views) for views in frontend_contributions.views.values())
|
||||
menu_count = len(frontend_contributions.menus)
|
||||
editor_count = len(frontend_contributions.editors)
|
||||
|
||||
total_count = command_count + view_count + menu_count + editor_count
|
||||
if total_count > 0:
|
||||
click.secho(
|
||||
f" Found: {command_count} commands, {view_count} views, {menu_count} menus, {editor_count} editors",
|
||||
fg="green",
|
||||
)
|
||||
else:
|
||||
click.secho(" No frontend contributions found", fg="yellow")
|
||||
|
||||
frontend = ManifestFrontend(
|
||||
contributions=extension.frontend.contributions,
|
||||
contributions=frontend_contributions,
|
||||
moduleFederation=extension.frontend.moduleFederation,
|
||||
remoteEntry=remote_entry,
|
||||
)
|
||||
|
||||
# Build backend manifest with auto-discovered contributions
|
||||
backend: ManifestBackend | None = None
|
||||
if extension.backend and extension.backend.entryPoints:
|
||||
backend = ManifestBackend(entryPoints=extension.backend.entryPoints)
|
||||
if extension.backend:
|
||||
click.secho("🔍 Auto-discovering backend contributions...", fg="cyan")
|
||||
backend_contributions = discover_backend_contributions(
|
||||
cwd, extension.backend.files
|
||||
)
|
||||
|
||||
tool_count = len(backend_contributions.mcp_tools)
|
||||
prompt_count = len(backend_contributions.mcp_prompts)
|
||||
api_count = len(backend_contributions.rest_apis)
|
||||
click.secho(
|
||||
f" Found: {tool_count} tools, {prompt_count} prompts, {api_count} APIs",
|
||||
fg="green",
|
||||
)
|
||||
|
||||
backend = ManifestBackend(
|
||||
entryPoints=extension.backend.entryPoints,
|
||||
contributions=backend_contributions,
|
||||
)
|
||||
|
||||
return Manifest(
|
||||
id=extension.id,
|
||||
id=composite_id,
|
||||
publisher=extension.publisher,
|
||||
name=extension.name,
|
||||
displayName=extension.displayName,
|
||||
version=extension.version,
|
||||
permissions=extension.permissions,
|
||||
dependencies=extension.dependencies,
|
||||
@@ -403,14 +590,111 @@ def dev(ctx: click.Context) -> None:
|
||||
click.secho("❌ No directories to watch. Exiting.", fg="red")
|
||||
|
||||
|
||||
def prompt_for_extension_info(
|
||||
display_name_opt: str | None,
|
||||
publisher_opt: str | None,
|
||||
technical_name_opt: str | None,
|
||||
) -> ExtensionNames:
|
||||
"""
|
||||
Prompt for extension info with graceful validation and re-prompting.
|
||||
|
||||
Args:
|
||||
display_name_opt: Display name provided via CLI option (if any)
|
||||
publisher_opt: Publisher provided via CLI option (if any)
|
||||
technical_name_opt: Technical name provided via CLI option (if any)
|
||||
|
||||
Returns:
|
||||
ExtensionNames: Validated extension name variants
|
||||
"""
|
||||
|
||||
# Step 1: Get display name
|
||||
if display_name_opt:
|
||||
display_name = display_name_opt
|
||||
try:
|
||||
display_name = validate_display_name(display_name)
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
sys.exit(1)
|
||||
else:
|
||||
while True:
|
||||
display_name = click.prompt("Extension display name", type=str)
|
||||
try:
|
||||
display_name = validate_display_name(display_name)
|
||||
break
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
|
||||
# Step 2: Get technical name (with suggestion from display name)
|
||||
if technical_name_opt:
|
||||
technical_name = technical_name_opt
|
||||
try:
|
||||
validate_technical_name(technical_name)
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# Suggest technical name from display name
|
||||
try:
|
||||
suggested_technical = suggest_technical_name(display_name)
|
||||
except ExtensionNameError:
|
||||
suggested_technical = "extension"
|
||||
|
||||
while True:
|
||||
technical_name = click.prompt(
|
||||
f"Extension name ({suggested_technical})",
|
||||
default=suggested_technical,
|
||||
type=str,
|
||||
)
|
||||
try:
|
||||
validate_technical_name(technical_name)
|
||||
break
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
|
||||
# Step 3: Get publisher
|
||||
if publisher_opt:
|
||||
publisher = publisher_opt
|
||||
try:
|
||||
validate_publisher(publisher)
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
sys.exit(1)
|
||||
else:
|
||||
while True:
|
||||
publisher = click.prompt("Publisher (e.g., my-org)", type=str)
|
||||
try:
|
||||
validate_publisher(publisher)
|
||||
break
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
|
||||
# Generate all name variants
|
||||
try:
|
||||
return generate_extension_names(display_name, publisher, technical_name)
|
||||
except ExtensionNameError as e:
|
||||
click.secho(f"❌ {e}", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@app.command()
|
||||
@click.option(
|
||||
"--id",
|
||||
"id_opt",
|
||||
"--publisher",
|
||||
"publisher_opt",
|
||||
default=None,
|
||||
help="Extension ID (alphanumeric and underscores only)",
|
||||
help="Publisher namespace (kebab-case, e.g. my-org)",
|
||||
)
|
||||
@click.option(
|
||||
"--name",
|
||||
"name_opt",
|
||||
default=None,
|
||||
help="Technical extension name (kebab-case, e.g. dashboard-widgets)",
|
||||
)
|
||||
@click.option(
|
||||
"--display-name",
|
||||
"display_name_opt",
|
||||
default=None,
|
||||
help="Extension display name (e.g. Dashboard Widgets)",
|
||||
)
|
||||
@click.option("--name", "name_opt", default=None, help="Extension display name")
|
||||
@click.option(
|
||||
"--version", "version_opt", default=None, help="Initial version (default: 0.1.0)"
|
||||
)
|
||||
@@ -424,25 +708,17 @@ def dev(ctx: click.Context) -> None:
|
||||
"--backend/--no-backend", "backend_opt", default=None, help="Include backend"
|
||||
)
|
||||
def init(
|
||||
id_opt: str | None,
|
||||
publisher_opt: str | None,
|
||||
name_opt: str | None,
|
||||
display_name_opt: str | None,
|
||||
version_opt: str | None,
|
||||
license_opt: str | None,
|
||||
frontend_opt: bool | None,
|
||||
backend_opt: bool | None,
|
||||
) -> None:
|
||||
id_ = id_opt or click.prompt(
|
||||
"Extension ID (unique identifier, alphanumeric only)", type=str
|
||||
)
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", id_):
|
||||
click.secho(
|
||||
"❌ ID must be alphanumeric (letters, digits, underscore).", fg="red"
|
||||
)
|
||||
sys.exit(1)
|
||||
# Get extension names with graceful validation
|
||||
names = prompt_for_extension_info(display_name_opt, publisher_opt, name_opt)
|
||||
|
||||
name = name_opt or click.prompt(
|
||||
"Extension name (human-readable display name)", type=str
|
||||
)
|
||||
version = version_opt or click.prompt("Initial version", default="0.1.0")
|
||||
license_ = license_opt or click.prompt("License", default="Apache-2.0")
|
||||
include_frontend = (
|
||||
@@ -456,7 +732,7 @@ def init(
|
||||
else click.confirm("Include backend?", default=True)
|
||||
)
|
||||
|
||||
target_dir = Path.cwd() / id_
|
||||
target_dir = Path.cwd() / names["id"]
|
||||
if target_dir.exists():
|
||||
click.secho(f"❌ Directory {target_dir} already exists.", fg="red")
|
||||
sys.exit(1)
|
||||
@@ -465,8 +741,7 @@ def init(
|
||||
templates_dir = Path(__file__).parent / "templates"
|
||||
env = Environment(loader=FileSystemLoader(templates_dir)) # noqa: S701
|
||||
ctx = {
|
||||
"id": id_,
|
||||
"name": name,
|
||||
**names, # Include all name variants
|
||||
"include_frontend": include_frontend,
|
||||
"include_backend": include_backend,
|
||||
"license": license_,
|
||||
@@ -502,29 +777,48 @@ def init(
|
||||
(frontend_src_dir / "index.tsx").write_text(index_tsx)
|
||||
click.secho("✅ Created frontend folder structure", fg="green")
|
||||
|
||||
# Initialize backend files
|
||||
# Initialize backend files with superset_extensions.publisher.name structure
|
||||
if include_backend:
|
||||
backend_dir = target_dir / "backend"
|
||||
backend_dir.mkdir()
|
||||
backend_src_dir = backend_dir / "src"
|
||||
backend_src_dir.mkdir()
|
||||
backend_src_package_dir = backend_src_dir / id_
|
||||
backend_src_package_dir.mkdir()
|
||||
|
||||
# Create superset_extensions namespace directory
|
||||
namespace_dir = backend_src_dir / "superset_extensions"
|
||||
namespace_dir.mkdir()
|
||||
|
||||
# Create publisher directory (e.g., superset_extensions/my_org)
|
||||
publisher_snake = kebab_to_snake_case(names["publisher"])
|
||||
publisher_dir = namespace_dir / publisher_snake
|
||||
publisher_dir.mkdir()
|
||||
|
||||
# Create extension package directory (e.g., superset_extensions/my_org/dashboard_widgets)
|
||||
name_snake = kebab_to_snake_case(names["name"])
|
||||
extension_package_dir = publisher_dir / name_snake
|
||||
extension_package_dir.mkdir()
|
||||
|
||||
# backend files
|
||||
pyproject_toml = env.get_template("backend/pyproject.toml.j2").render(ctx)
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_toml)
|
||||
|
||||
# Namespace package __init__.py (empty for namespace)
|
||||
(namespace_dir / "__init__.py").write_text("")
|
||||
(publisher_dir / "__init__.py").write_text("")
|
||||
|
||||
# Extension package files
|
||||
init_py = env.get_template("backend/src/package/__init__.py.j2").render(ctx)
|
||||
(backend_src_package_dir / "__init__.py").write_text(init_py)
|
||||
(extension_package_dir / "__init__.py").write_text(init_py)
|
||||
entrypoint_py = env.get_template("backend/src/package/entrypoint.py.j2").render(
|
||||
ctx
|
||||
)
|
||||
(backend_src_package_dir / "entrypoint.py").write_text(entrypoint_py)
|
||||
(extension_package_dir / "entrypoint.py").write_text(entrypoint_py)
|
||||
|
||||
click.secho("✅ Created backend folder structure", fg="green")
|
||||
|
||||
click.secho(
|
||||
f"🎉 Extension {name} (ID: {id_}) initialized at {target_dir}", fg="cyan"
|
||||
f"🎉 Extension {names['display_name']} (ID: {names['id']}) initialized at {target_dir}",
|
||||
fg="cyan",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
|
||||
|
||||
class ExtensionNameError(Exception):
|
||||
"""Raised when extension name validation fails."""
|
||||
|
||||
pass
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[project]
|
||||
name = "{{ id }}"
|
||||
name = "{{ backend_package }}"
|
||||
version = "{{ version }}"
|
||||
license = "{{ license }}"
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
print("{{ name }} extension registered")
|
||||
print("{{ display_name }} extension registered")
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
{
|
||||
"id": "{{ id }}",
|
||||
"publisher": "{{ publisher }}",
|
||||
"name": "{{ name }}",
|
||||
"displayName": "{{ display_name }}",
|
||||
"version": "{{ version }}",
|
||||
"license": "{{ license }}",
|
||||
{% if include_frontend -%}
|
||||
"frontend": {
|
||||
"contributions": {
|
||||
"commands": [],
|
||||
"views": {},
|
||||
"menus": {}
|
||||
},
|
||||
"moduleFederation": {
|
||||
"name": "{{ mf_name }}",
|
||||
"exposes": ["./index"]
|
||||
}
|
||||
},
|
||||
{% endif -%}
|
||||
{% if include_backend -%}
|
||||
"backend": {
|
||||
"entryPoints": ["{{ id }}.entrypoint"],
|
||||
"files": ["backend/src/{{ id }}/**/*.py"]
|
||||
"entryPoints": ["{{ backend_entry }}"],
|
||||
"files": ["backend/src/{{ backend_path|replace('.', '/') }}/**/*.py"]
|
||||
},
|
||||
{% endif -%}
|
||||
"permissions": []
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "{{ id }}",
|
||||
"name": "{{ npm_name }}",
|
||||
"version": "{{ version }}",
|
||||
"main": "dist/main.js",
|
||||
"types": "dist/publicAPI.d.ts",
|
||||
@@ -19,6 +19,7 @@
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apache-superset/webpack-extension-plugin": "^0.0.1",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@types/react": "^19.0.10",
|
||||
|
||||
+29
-10
@@ -1,13 +1,32 @@
|
||||
import React from "react";
|
||||
import { core } from "@apache-superset/core";
|
||||
import { defineCommand, defineView } from "@apache-superset/core";
|
||||
|
||||
export const activate = (context: core.ExtensionContext) => {
|
||||
context.disposables.push(
|
||||
core.registerViewProvider("{{ id }}.example", () => <p>{{ name }}</p>)
|
||||
);
|
||||
console.log("{{ name }} extension activated");
|
||||
};
|
||||
// Example command
|
||||
export const exampleCommand = defineCommand({
|
||||
id: "example",
|
||||
title: "Example Command",
|
||||
icon: "ExperimentOutlined",
|
||||
execute: async () => {
|
||||
console.log("{{ name }} command executed!");
|
||||
},
|
||||
onActivate: () => {
|
||||
console.log("Example command activated");
|
||||
},
|
||||
onDeactivate: () => {
|
||||
console.log("Example command deactivated");
|
||||
},
|
||||
});
|
||||
|
||||
export const deactivate = () => {
|
||||
console.log("{{ name }} extension deactivated");
|
||||
};
|
||||
// Example view
|
||||
export const exampleView = defineView({
|
||||
id: "example",
|
||||
title: "{{ name }} View",
|
||||
location: "explore.panels", // or dashboard.tabs, sqllab.panels, etc.
|
||||
component: () => <div>Welcome to {{ name }}!</div>,
|
||||
onActivate: () => {
|
||||
console.log("Example view activated");
|
||||
},
|
||||
onDeactivate: () => {
|
||||
console.log("Example view deactivated");
|
||||
},
|
||||
});
|
||||
|
||||
+9
-2
@@ -1,5 +1,6 @@
|
||||
const path = require("path");
|
||||
const { ModuleFederationPlugin } = require("webpack").container;
|
||||
const SupersetExtensionPlugin = require("@apache-superset/webpack-extension-plugin");
|
||||
const packageConfig = require("./package");
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
@@ -19,7 +20,7 @@ module.exports = (env, argv) => {
|
||||
filename: isProd ? undefined : "[name].[contenthash].js",
|
||||
chunkFilename: "[name].[contenthash].js",
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
publicPath: `/api/v1/extensions/${packageConfig.name}/`,
|
||||
publicPath: `/api/v1/extensions/{{ publisher }}/{{ name }}/`,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
||||
@@ -39,7 +40,7 @@ module.exports = (env, argv) => {
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
name: "{{ id }}",
|
||||
name: "{{ mf_name }}",
|
||||
filename: "remoteEntry.[contenthash].js",
|
||||
exposes: {
|
||||
"./index": "./src/index.tsx",
|
||||
@@ -62,6 +63,12 @@ module.exports = (env, argv) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Auto-discover and validate frontend contributions
|
||||
new SupersetExtensionPlugin({
|
||||
outputPath: "contributions.json",
|
||||
include: ["src/**/*.{ts,tsx,js,jsx}"],
|
||||
exclude: ["**/*.test.*", "**/node_modules/**"],
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class ExtensionNames(TypedDict):
|
||||
"""Type definition for extension name variants following platform conventions."""
|
||||
|
||||
# Publisher namespace (e.g., "my-org")
|
||||
publisher: str
|
||||
|
||||
# Technical extension name (e.g., "dashboard-widgets")
|
||||
name: str
|
||||
|
||||
# Human-readable display name (e.g., "Dashboard Widgets")
|
||||
display_name: str
|
||||
|
||||
# Composite extension ID - publisher.name (e.g., "my-org.dashboard-widgets")
|
||||
id: str
|
||||
|
||||
# NPM package name - @publisher/name (e.g., "@my-org/dashboard-widgets")
|
||||
npm_name: str
|
||||
|
||||
# Module Federation library - publisherCamel_nameCamel (e.g., "myOrg_dashboardWidgets")
|
||||
mf_name: str
|
||||
|
||||
# Backend package name with hyphens for distribution (e.g., "my_org-dashboard_widgets")
|
||||
backend_package: str
|
||||
|
||||
# Full backend import path (e.g., "superset_extensions.my_org.dashboard_widgets")
|
||||
backend_path: str
|
||||
|
||||
# Backend entry point (e.g., "superset_extensions.my_org.dashboard_widgets.entrypoint")
|
||||
backend_entry: str
|
||||
@@ -16,15 +16,82 @@
|
||||
# under the License.
|
||||
|
||||
import json # noqa: TID251
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from superset_core.extensions.constants import (
|
||||
DISPLAY_NAME_PATTERN,
|
||||
PUBLISHER_PATTERN,
|
||||
TECHNICAL_NAME_PATTERN,
|
||||
)
|
||||
from superset_extensions_cli.exceptions import ExtensionNameError
|
||||
from superset_extensions_cli.types import ExtensionNames
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
import tomli as tomllib
|
||||
|
||||
# Python reserved keywords to avoid in package names
|
||||
PYTHON_KEYWORDS = {
|
||||
"and",
|
||||
"as",
|
||||
"assert",
|
||||
"break",
|
||||
"class",
|
||||
"continue",
|
||||
"def",
|
||||
"del",
|
||||
"elif",
|
||||
"else",
|
||||
"except",
|
||||
"exec",
|
||||
"finally",
|
||||
"for",
|
||||
"from",
|
||||
"global",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"is",
|
||||
"lambda",
|
||||
"not",
|
||||
"or",
|
||||
"pass",
|
||||
"print",
|
||||
"raise",
|
||||
"return",
|
||||
"try",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
"False",
|
||||
"None",
|
||||
"True",
|
||||
}
|
||||
|
||||
# npm reserved names to avoid
|
||||
NPM_RESERVED = {
|
||||
"node_modules",
|
||||
"favicon.ico",
|
||||
"www",
|
||||
"http",
|
||||
"https",
|
||||
"ftp",
|
||||
"localhost",
|
||||
"package.json",
|
||||
"npm",
|
||||
"yarn",
|
||||
"bower_components",
|
||||
}
|
||||
|
||||
# Compiled patterns for publisher/name validation
|
||||
PUBLISHER_REGEX = re.compile(PUBLISHER_PATTERN)
|
||||
TECHNICAL_NAME_REGEX = re.compile(TECHNICAL_NAME_PATTERN)
|
||||
DISPLAY_NAME_REGEX = re.compile(DISPLAY_NAME_PATTERN)
|
||||
|
||||
|
||||
def read_toml(path: Path) -> dict[str, Any] | None:
|
||||
if not path.is_file():
|
||||
@@ -40,3 +107,276 @@ def read_json(path: Path) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def _normalize_for_identifiers(name: str) -> str:
|
||||
"""
|
||||
Normalize display name to clean lowercase words.
|
||||
|
||||
Args:
|
||||
name: Raw display name (e.g., "Hello World!")
|
||||
|
||||
Returns:
|
||||
Normalized string (e.g., "hello world")
|
||||
"""
|
||||
# Convert to lowercase
|
||||
normalized = name.lower().strip()
|
||||
|
||||
# Convert underscores and existing hyphens to spaces for consistent processing
|
||||
normalized = normalized.replace("_", " ").replace("-", " ")
|
||||
|
||||
# Remove any non-alphanumeric characters except spaces
|
||||
normalized = re.sub(r"[^a-z0-9\s]", "", normalized)
|
||||
|
||||
# Normalize whitespace (collapse multiple spaces, strip)
|
||||
normalized = " ".join(normalized.split())
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalized_to_kebab(normalized: str) -> str:
|
||||
"""Convert normalized string to kebab-case."""
|
||||
return normalized.replace(" ", "-")
|
||||
|
||||
|
||||
def _normalized_to_snake(normalized: str) -> str:
|
||||
"""Convert normalized string to snake_case."""
|
||||
return normalized.replace(" ", "_")
|
||||
|
||||
|
||||
def _normalized_to_camel(normalized: str) -> str:
|
||||
"""Convert normalized string to camelCase."""
|
||||
parts = normalized.split()
|
||||
if not parts:
|
||||
return ""
|
||||
# First part lowercase, subsequent parts capitalized
|
||||
return parts[0] + "".join(word.capitalize() for word in parts[1:])
|
||||
|
||||
|
||||
def kebab_to_camel_case(kebab_name: str) -> str:
|
||||
"""Convert kebab-case to camelCase (e.g., 'hello-world' -> 'helloWorld')."""
|
||||
parts = kebab_name.split("-")
|
||||
if not parts:
|
||||
return ""
|
||||
# First part lowercase, subsequent parts capitalized
|
||||
return parts[0] + "".join(word.capitalize() for word in parts[1:])
|
||||
|
||||
|
||||
def kebab_to_snake_case(kebab_name: str) -> str:
|
||||
"""Convert kebab-case to snake_case (e.g., 'hello-world' -> 'hello_world')."""
|
||||
return kebab_name.replace("-", "_")
|
||||
|
||||
|
||||
def name_to_kebab_case(name: str) -> str:
|
||||
"""Convert display name directly to kebab-case (e.g., 'Hello World' -> 'hello-world')."""
|
||||
normalized = _normalize_for_identifiers(name)
|
||||
return _normalized_to_kebab(normalized)
|
||||
|
||||
|
||||
def validate_python_package_name(name: str) -> None:
|
||||
"""
|
||||
Validate Python package name (snake_case format).
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If name is invalid
|
||||
"""
|
||||
# Check if it starts with a number (invalid for Python identifiers)
|
||||
if name[0].isdigit():
|
||||
raise ExtensionNameError(f"Package name '{name}' cannot start with a number")
|
||||
|
||||
# Check if the first part (before any underscore) is a Python keyword
|
||||
if (first_part := name.split("_")[0]) in PYTHON_KEYWORDS:
|
||||
raise ExtensionNameError(
|
||||
f"Package name cannot start with Python keyword '{first_part}'"
|
||||
)
|
||||
|
||||
# Check if it's a valid Python identifier
|
||||
if not name.replace("_", "a").isalnum():
|
||||
raise ExtensionNameError(f"'{name}' is not a valid Python package name")
|
||||
|
||||
|
||||
def validate_npm_package_name(name: str) -> None:
|
||||
"""
|
||||
Validate npm package name (kebab-case format).
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If name is invalid
|
||||
"""
|
||||
if name.lower() in NPM_RESERVED:
|
||||
raise ExtensionNameError(f"'{name}' is a reserved npm package name")
|
||||
|
||||
|
||||
def validate_publisher(publisher: str) -> None:
|
||||
"""
|
||||
Validate publisher namespace format.
|
||||
|
||||
Args:
|
||||
publisher: Publisher namespace (e.g., 'my-org')
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If publisher is invalid
|
||||
"""
|
||||
if not publisher:
|
||||
raise ExtensionNameError("Publisher cannot be empty")
|
||||
|
||||
if not PUBLISHER_REGEX.match(publisher):
|
||||
raise ExtensionNameError(
|
||||
"Publisher must start with a letter and contain only lowercase letters, numbers, and hyphens (e.g., 'my-org')"
|
||||
)
|
||||
|
||||
|
||||
def validate_technical_name(name: str) -> None:
|
||||
"""
|
||||
Validate technical extension name format.
|
||||
|
||||
Args:
|
||||
name: Technical extension name (e.g., 'dashboard-widgets')
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If name is invalid
|
||||
"""
|
||||
if not name:
|
||||
raise ExtensionNameError("Extension name cannot be empty")
|
||||
|
||||
if not TECHNICAL_NAME_REGEX.match(name):
|
||||
raise ExtensionNameError(
|
||||
"Extension name must start with a letter and contain only lowercase letters, numbers, and hyphens (e.g., 'dashboard-widgets')"
|
||||
)
|
||||
|
||||
|
||||
def validate_display_name(display_name: str) -> str:
|
||||
"""
|
||||
Validate and normalize display name format.
|
||||
|
||||
Args:
|
||||
display_name: Human-readable extension name
|
||||
|
||||
Returns:
|
||||
Cleaned display name
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If display name is invalid
|
||||
"""
|
||||
if not display_name or not display_name.strip():
|
||||
raise ExtensionNameError("Display name cannot be empty")
|
||||
|
||||
# Normalize whitespace: strip and collapse multiple spaces
|
||||
normalized = " ".join(display_name.strip().split())
|
||||
|
||||
if not DISPLAY_NAME_REGEX.match(normalized):
|
||||
raise ExtensionNameError(
|
||||
"Display name must start with a letter and can contain letters, numbers, spaces, hyphens, underscores, and dots (e.g., 'Dashboard Widgets')"
|
||||
)
|
||||
|
||||
# Check for only whitespace/special chars after normalization
|
||||
if not any(c.isalnum() for c in normalized):
|
||||
raise ExtensionNameError(
|
||||
"Display name must contain at least one letter or number"
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def suggest_technical_name(display_name: str) -> str:
|
||||
"""
|
||||
Suggest technical name from display name.
|
||||
|
||||
Args:
|
||||
display_name: Human-readable name (e.g., "Dashboard Widgets!")
|
||||
|
||||
Returns:
|
||||
Technical name suggestion (e.g., "dashboard-widgets")
|
||||
"""
|
||||
# Normalize for identifiers
|
||||
normalized = _normalize_for_identifiers(display_name)
|
||||
|
||||
# Convert to kebab-case
|
||||
technical_name = _normalized_to_kebab(normalized)
|
||||
|
||||
# Remove any leading/trailing hyphens that might result from edge cases
|
||||
technical_name = technical_name.strip("-")
|
||||
|
||||
# Ensure we have something left
|
||||
if not technical_name:
|
||||
raise ExtensionNameError(
|
||||
"Display name must contain at least one letter or number"
|
||||
)
|
||||
|
||||
return technical_name
|
||||
|
||||
|
||||
def get_module_federation_name(publisher: str, name: str) -> str:
|
||||
"""
|
||||
Generate Module Federation container name.
|
||||
|
||||
Args:
|
||||
publisher: Publisher namespace (e.g., 'my-org')
|
||||
name: Technical name (e.g., 'dashboard-widgets')
|
||||
|
||||
Returns:
|
||||
Module Federation name (e.g., 'myOrg_dashboardWidgets')
|
||||
"""
|
||||
publisher_camel = kebab_to_camel_case(publisher)
|
||||
name_camel = kebab_to_camel_case(name)
|
||||
return f"{publisher_camel}_{name_camel}"
|
||||
|
||||
|
||||
def generate_extension_names(
|
||||
display_name: str, publisher: str, technical_name: str | None = None
|
||||
) -> ExtensionNames:
|
||||
"""
|
||||
Generate all extension name variants from input.
|
||||
|
||||
Args:
|
||||
display_name: Human-readable name (e.g., "Dashboard Widgets")
|
||||
publisher: Publisher namespace (e.g., "my-org")
|
||||
technical_name: Technical name override, or None to auto-generate
|
||||
|
||||
Returns:
|
||||
ExtensionNames: Dictionary with all name variants
|
||||
|
||||
Raises:
|
||||
ExtensionNameError: If any name is invalid
|
||||
"""
|
||||
# Validate and normalize inputs
|
||||
display_name = validate_display_name(display_name)
|
||||
validate_publisher(publisher)
|
||||
|
||||
# Use provided technical name or generate from display name
|
||||
if technical_name is None:
|
||||
technical_name = suggest_technical_name(display_name)
|
||||
else:
|
||||
validate_technical_name(technical_name)
|
||||
|
||||
# Generate composite ID
|
||||
composite_id = f"{publisher}.{technical_name}"
|
||||
|
||||
# Generate NPM package name
|
||||
npm_name = f"@{publisher}/{technical_name}"
|
||||
|
||||
# Generate Module Federation name
|
||||
mf_name = get_module_federation_name(publisher, technical_name)
|
||||
|
||||
# Generate backend names with collision protection
|
||||
publisher_snake = kebab_to_snake_case(publisher)
|
||||
name_snake = kebab_to_snake_case(technical_name)
|
||||
backend_package = f"{publisher_snake}-{name_snake}"
|
||||
backend_path = f"superset_extensions.{publisher_snake}.{name_snake}"
|
||||
backend_entry = f"{backend_path}.entrypoint"
|
||||
|
||||
# Validate the generated names
|
||||
validate_python_package_name(publisher_snake)
|
||||
validate_python_package_name(name_snake)
|
||||
validate_npm_package_name(technical_name)
|
||||
|
||||
return ExtensionNames(
|
||||
publisher=publisher,
|
||||
name=technical_name,
|
||||
display_name=display_name,
|
||||
id=composite_id,
|
||||
npm_name=npm_name,
|
||||
mf_name=mf_name,
|
||||
backend_package=backend_package,
|
||||
backend_path=backend_path,
|
||||
backend_entry=backend_entry,
|
||||
)
|
||||
|
||||
@@ -46,8 +46,9 @@ def isolated_filesystem(tmp_path):
|
||||
def extension_params():
|
||||
"""Default parameters for extension creation."""
|
||||
return {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"include_frontend": True,
|
||||
@@ -58,25 +59,25 @@ def extension_params():
|
||||
@pytest.fixture
|
||||
def cli_input_both():
|
||||
"""CLI input for creating extension with both frontend and backend."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_input_frontend_only():
|
||||
"""CLI input for creating extension with frontend only."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\ny\nn\n"
|
||||
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\ny\nn\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_input_backend_only():
|
||||
"""CLI input for creating extension with backend only."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\ny\n"
|
||||
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\nn\ny\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_input_neither():
|
||||
"""CLI input for creating extension with neither frontend nor backend."""
|
||||
return "test_extension\nTest Extension\n0.1.0\nApache-2.0\nn\nn\n"
|
||||
return "Test Extension\n\ntest-org\n0.1.0\nApache-2.0\nn\nn\n"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -86,10 +87,11 @@ def extension_setup_for_dev():
|
||||
def _setup(base_path: Path) -> None:
|
||||
import json
|
||||
|
||||
# Create extension.json
|
||||
# Create extension.json with new structure
|
||||
extension_json = {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
@@ -113,10 +115,12 @@ def extension_setup_for_bundling():
|
||||
dist_dir = base_path / "dist"
|
||||
dist_dir.mkdir(parents=True)
|
||||
|
||||
# Create manifest.json
|
||||
# Create manifest.json with composite ID
|
||||
manifest = {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"id": "test-org.test-extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
@@ -128,8 +132,15 @@ def extension_setup_for_bundling():
|
||||
(frontend_dir / "remoteEntry.abc123.js").write_text("// remote entry")
|
||||
(frontend_dir / "main.js").write_text("// main js")
|
||||
|
||||
# Create some backend files
|
||||
backend_dir = dist_dir / "backend" / "src" / "test_extension"
|
||||
# Create some backend files - updated path structure
|
||||
backend_dir = (
|
||||
dist_dir
|
||||
/ "backend"
|
||||
/ "src"
|
||||
/ "superset_extensions"
|
||||
/ "test_org"
|
||||
/ "test_extension"
|
||||
)
|
||||
backend_dir.mkdir(parents=True)
|
||||
(backend_dir / "__init__.py").write_text("# init")
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Tests for backend contributions discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from superset_extensions_cli.cli import discover_backend_contributions
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_discover_backend_contributions_finds_tools():
|
||||
"""Test that discover_backend_contributions finds @tool decorated functions."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# Create a Python file with decorated functions
|
||||
backend_file = tmpdir_path / "backend.py"
|
||||
backend_code = '''
|
||||
from superset_core.mcp import tool, prompt
|
||||
from superset_core.api.rest_api import RestApi, extension_api
|
||||
|
||||
|
||||
@tool(tags=["database"], description="Query the database")
|
||||
def query_database(sql: str) -> dict:
|
||||
"""Execute a SQL query against the database."""
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@tool(name="custom_tool", protect=False)
|
||||
def my_custom_tool():
|
||||
"""A custom tool with specific configuration."""
|
||||
return {"custom": True}
|
||||
|
||||
|
||||
@prompt(tags={"analysis"}, title="Data Analysis")
|
||||
async def analyze_data(ctx, dataset: str) -> str:
|
||||
"""Generate analysis for a dataset."""
|
||||
return f"Analysis for {dataset}"
|
||||
|
||||
|
||||
@extension_api(id="test_api", name="Test API")
|
||||
class TestAPI(RestApi):
|
||||
"""Test API for the extension."""
|
||||
|
||||
def get_data(self):
|
||||
return self.response(200, result={})
|
||||
'''
|
||||
backend_file.write_text(backend_code)
|
||||
|
||||
# Run discovery
|
||||
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
|
||||
|
||||
# Verify tools were discovered
|
||||
assert len(contributions.mcp_tools) == 2
|
||||
|
||||
# Check first tool
|
||||
tool1 = contributions.mcp_tools[0]
|
||||
assert tool1.id == "query_database"
|
||||
assert tool1.name == "query_database"
|
||||
assert tool1.description == "Execute a SQL query against the database."
|
||||
assert tool1.tags == ["database"]
|
||||
assert tool1.protect is True
|
||||
assert tool1.module == "backend.query_database"
|
||||
|
||||
# Check second tool
|
||||
tool2 = contributions.mcp_tools[1]
|
||||
assert tool2.id == "my_custom_tool"
|
||||
assert tool2.name == "custom_tool"
|
||||
assert tool2.description == "A custom tool with specific configuration."
|
||||
assert tool2.tags == []
|
||||
assert tool2.protect is False
|
||||
assert tool2.module == "backend.my_custom_tool"
|
||||
|
||||
# Verify prompt was discovered
|
||||
assert len(contributions.mcp_prompts) == 1
|
||||
|
||||
prompt1 = contributions.mcp_prompts[0]
|
||||
assert prompt1.id == "analyze_data"
|
||||
assert prompt1.name == "analyze_data"
|
||||
assert prompt1.title == "Data Analysis"
|
||||
assert prompt1.description == "Generate analysis for a dataset."
|
||||
assert prompt1.tags == {"analysis"}
|
||||
assert prompt1.protect is True
|
||||
assert prompt1.module == "backend.analyze_data"
|
||||
|
||||
# Verify REST API was discovered
|
||||
assert len(contributions.rest_apis) == 1
|
||||
|
||||
api1 = contributions.rest_apis[0]
|
||||
assert api1.id == "test_api"
|
||||
assert api1.name == "Test API"
|
||||
assert api1.description == "Test API for the extension."
|
||||
assert api1.basePath == "/test_api"
|
||||
assert api1.module == "backend.TestAPI"
|
||||
|
||||
# Verify auto-inferred Flask-AppBuilder fields
|
||||
assert api1.resourceName == "test_api" # defaults to id.lower()
|
||||
assert api1.openapiSpecTag == "Test API" # defaults to name
|
||||
assert api1.classPermissionName == "test_api" # defaults to resource_name
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_discover_backend_contributions_handles_empty_directory():
|
||||
"""Test that discovery handles directories with no Python files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# Create a non-Python file
|
||||
(tmpdir_path / "readme.txt").write_text("This is not Python")
|
||||
|
||||
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
|
||||
|
||||
assert len(contributions.mcp_tools) == 0
|
||||
assert len(contributions.mcp_prompts) == 0
|
||||
assert len(contributions.rest_apis) == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_discover_backend_contributions_handles_syntax_errors():
|
||||
"""Test that discovery handles Python files with syntax errors gracefully."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# Create a Python file with syntax error
|
||||
bad_file = tmpdir_path / "bad.py"
|
||||
bad_file.write_text("def broken_function(\n # missing closing parenthesis")
|
||||
|
||||
# Create a good file with contributions
|
||||
good_file = tmpdir_path / "good.py"
|
||||
good_code = '''
|
||||
from superset_core.mcp import tool
|
||||
|
||||
@tool
|
||||
def working_tool():
|
||||
"""This tool should be discovered."""
|
||||
return {}
|
||||
'''
|
||||
good_file.write_text(good_code)
|
||||
|
||||
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
|
||||
|
||||
# Should discover the working tool despite the syntax error in other file
|
||||
assert len(contributions.mcp_tools) == 1
|
||||
assert contributions.mcp_tools[0].id == "working_tool"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_discover_backend_contributions_skips_private_functions():
|
||||
"""Test that discovery skips functions starting with underscore."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
backend_file = tmpdir_path / "backend.py"
|
||||
backend_code = '''
|
||||
from superset_core.mcp import tool
|
||||
|
||||
@tool
|
||||
def public_tool():
|
||||
"""This should be discovered."""
|
||||
return {}
|
||||
|
||||
@tool
|
||||
def _private_tool():
|
||||
"""This should be skipped."""
|
||||
return {}
|
||||
'''
|
||||
backend_file.write_text(backend_code)
|
||||
|
||||
contributions = discover_backend_contributions(tmpdir_path, ["*.py"])
|
||||
|
||||
# Should only find the public tool
|
||||
assert len(contributions.mcp_tools) == 1
|
||||
assert contributions.mcp_tools[0].id == "public_tool"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_discover_backend_contributions_uses_file_patterns():
|
||||
"""Test that discovery respects file patterns."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
|
||||
# Create files in different subdirectories
|
||||
src_dir = tmpdir_path / "src"
|
||||
src_dir.mkdir()
|
||||
tests_dir = tmpdir_path / "tests"
|
||||
tests_dir.mkdir()
|
||||
|
||||
src_file = src_dir / "tools.py"
|
||||
src_code = '''
|
||||
from superset_core.mcp import tool
|
||||
|
||||
@tool
|
||||
def src_tool():
|
||||
"""Tool from src directory."""
|
||||
return {}
|
||||
'''
|
||||
src_file.write_text(src_code)
|
||||
|
||||
test_file = tests_dir / "test_tools.py"
|
||||
test_code = '''
|
||||
from superset_core.mcp import tool
|
||||
|
||||
@tool
|
||||
def test_tool():
|
||||
"""Tool from tests directory."""
|
||||
return {}
|
||||
'''
|
||||
test_file.write_text(test_code)
|
||||
|
||||
# Only search in src directory
|
||||
contributions = discover_backend_contributions(tmpdir_path, ["src/**/*.py"])
|
||||
|
||||
# Should only find the src tool
|
||||
assert len(contributions.mcp_tools) == 1
|
||||
assert contributions.mcp_tools[0].id == "src_tool"
|
||||
@@ -52,20 +52,25 @@ def extension_with_build_structure():
|
||||
|
||||
# Create extension.json
|
||||
extension_json = {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
|
||||
if include_frontend:
|
||||
extension_json["frontend"] = {
|
||||
"contributions": {"commands": []},
|
||||
"moduleFederation": {"exposes": ["./index"]},
|
||||
}
|
||||
|
||||
if include_backend:
|
||||
extension_json["backend"] = {"entryPoints": ["test_extension.entrypoint"]}
|
||||
extension_json["backend"] = {
|
||||
"entryPoints": [
|
||||
"superset_extensions.test_org.test_extension.entrypoint"
|
||||
],
|
||||
"files": ["backend/src/**/*.py"],
|
||||
}
|
||||
|
||||
(base_path / "extension.json").write_text(json.dumps(extension_json))
|
||||
|
||||
@@ -230,16 +235,19 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
|
||||
"""Test build_manifest creates correct manifest from extension.json."""
|
||||
# Create extension.json
|
||||
extension_data = {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["read_data"],
|
||||
"dependencies": ["some_dep"],
|
||||
"frontend": {
|
||||
"contributions": {"commands": [{"id": "test_command", "title": "Test"}]},
|
||||
"moduleFederation": {"exposes": ["./index"]},
|
||||
},
|
||||
"backend": {"entryPoints": ["test_extension.entrypoint"]},
|
||||
"backend": {
|
||||
"entryPoints": ["superset_extensions.test_org.test_extension.entrypoint"],
|
||||
"files": ["backend/src/**/*.py"],
|
||||
},
|
||||
}
|
||||
extension_json = isolated_filesystem / "extension.json"
|
||||
extension_json.write_text(json.dumps(extension_data))
|
||||
@@ -247,31 +255,36 @@ def test_build_manifest_creates_correct_manifest_structure(isolated_filesystem):
|
||||
manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js")
|
||||
|
||||
# Verify manifest structure
|
||||
assert manifest.id == "test_extension"
|
||||
assert manifest.name == "Test Extension"
|
||||
assert manifest.id == "test-org.test-extension" # Composite ID
|
||||
assert manifest.publisher == "test-org"
|
||||
assert manifest.name == "test-extension"
|
||||
assert manifest.displayName == "Test Extension"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.permissions == ["read_data"]
|
||||
assert manifest.dependencies == ["some_dep"]
|
||||
|
||||
# Verify frontend section
|
||||
# Verify frontend section (auto-discovery, currently empty)
|
||||
assert manifest.frontend is not None
|
||||
assert manifest.frontend.contributions.commands == [
|
||||
{"id": "test_command", "title": "Test"}
|
||||
]
|
||||
assert (
|
||||
manifest.frontend.contributions.commands == []
|
||||
) # Auto-discovered (empty for now)
|
||||
assert manifest.frontend.moduleFederation.exposes == ["./index"]
|
||||
assert manifest.frontend.remoteEntry == "remoteEntry.abc123.js"
|
||||
|
||||
# Verify backend section
|
||||
# Verify backend section (auto-discovery)
|
||||
assert manifest.backend is not None
|
||||
assert manifest.backend.entryPoints == ["test_extension.entrypoint"]
|
||||
assert manifest.backend.entryPoints == [
|
||||
"superset_extensions.test_org.test_extension.entrypoint"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_build_manifest_handles_minimal_extension(isolated_filesystem):
|
||||
"""Test build_manifest with minimal extension.json (no frontend/backend)."""
|
||||
extension_data = {
|
||||
"id": "minimal_extension",
|
||||
"name": "Minimal Extension",
|
||||
"publisher": "minimal-org",
|
||||
"name": "minimal-extension",
|
||||
"displayName": "Minimal Extension",
|
||||
"version": "0.1.0",
|
||||
"permissions": [],
|
||||
}
|
||||
@@ -280,8 +293,10 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem):
|
||||
|
||||
manifest = build_manifest(isolated_filesystem, None)
|
||||
|
||||
assert manifest.id == "minimal_extension"
|
||||
assert manifest.name == "Minimal Extension"
|
||||
assert manifest.id == "minimal-org.minimal-extension" # Composite ID
|
||||
assert manifest.publisher == "minimal-org"
|
||||
assert manifest.name == "minimal-extension"
|
||||
assert manifest.displayName == "Minimal Extension"
|
||||
assert manifest.version == "0.1.0"
|
||||
assert manifest.permissions == []
|
||||
assert manifest.dependencies == [] # Default empty list
|
||||
@@ -393,8 +408,9 @@ def test_rebuild_backend_calls_copy_and_shows_message(isolated_filesystem):
|
||||
|
||||
# Create extension.json
|
||||
extension_json = {
|
||||
"id": "test",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
@@ -420,8 +436,9 @@ def test_copy_backend_files_skips_non_files(isolated_filesystem):
|
||||
|
||||
# Create extension.json with backend file patterns
|
||||
extension_data = {
|
||||
"id": "test_ext",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
"backend": {
|
||||
@@ -457,8 +474,9 @@ def test_copy_backend_files_copies_matched_files(isolated_filesystem):
|
||||
|
||||
# Create extension.json with backend file patterns
|
||||
extension_data = {
|
||||
"id": "test_ext",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
"backend": {"files": ["backend/src/test_ext/**/*.py"]},
|
||||
@@ -480,8 +498,9 @@ def test_copy_backend_files_copies_matched_files(isolated_filesystem):
|
||||
def test_copy_backend_files_handles_no_backend_config(isolated_filesystem):
|
||||
"""Test copy_backend_files handles extension.json without backend config."""
|
||||
extension_data = {
|
||||
"id": "frontend_only",
|
||||
"name": "Frontend Only Extension",
|
||||
"publisher": "frontend-org",
|
||||
"name": "frontend-only",
|
||||
"displayName": "Frontend Only Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ def test_bundle_command_creates_zip_with_default_name(
|
||||
result = cli_runner.invoke(app, ["bundle"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "✅ Bundle created: test_extension-1.0.0.supx" in result.output
|
||||
assert "✅ Bundle created: test-org.test-extension-1.0.0.supx" in result.output
|
||||
|
||||
# Verify zip file was created
|
||||
zip_path = isolated_filesystem / "test_extension-1.0.0.supx"
|
||||
zip_path = isolated_filesystem / "test-org.test-extension-1.0.0.supx"
|
||||
assert_file_exists(zip_path)
|
||||
|
||||
# Verify zip contents
|
||||
@@ -55,7 +55,10 @@ def test_bundle_command_creates_zip_with_default_name(
|
||||
assert "manifest.json" in file_list
|
||||
assert "frontend/dist/remoteEntry.abc123.js" in file_list
|
||||
assert "frontend/dist/main.js" in file_list
|
||||
assert "backend/src/test_extension/__init__.py" in file_list
|
||||
assert (
|
||||
"backend/src/superset_extensions/test_org/test_extension/__init__.py"
|
||||
in file_list
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@@ -100,7 +103,7 @@ def test_bundle_command_with_output_directory(
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify zip file was created in output directory
|
||||
expected_path = output_dir / "test_extension-1.0.0.supx"
|
||||
expected_path = output_dir / "test-org.test-extension-1.0.0.supx"
|
||||
assert_file_exists(expected_path)
|
||||
assert f"✅ Bundle created: {expected_path}" in result.output
|
||||
|
||||
@@ -159,8 +162,10 @@ def test_bundle_includes_all_files_recursively(
|
||||
|
||||
# Manifest
|
||||
manifest = {
|
||||
"id": "complex_extension",
|
||||
"name": "Complex Extension",
|
||||
"id": "complex-org.complex-extension",
|
||||
"publisher": "complex-org",
|
||||
"name": "complex-extension",
|
||||
"displayName": "Complex Extension",
|
||||
"version": "2.1.0",
|
||||
"permissions": [],
|
||||
}
|
||||
@@ -191,7 +196,7 @@ def test_bundle_includes_all_files_recursively(
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify zip file and contents
|
||||
zip_path = isolated_filesystem / "complex_extension-2.1.0.supx"
|
||||
zip_path = isolated_filesystem / "complex-org.complex-extension-2.1.0.supx"
|
||||
assert_file_exists(zip_path)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zipf:
|
||||
|
||||
@@ -49,7 +49,13 @@ def test_dev_command_starts_watchers(
|
||||
"""Test dev command starts file watchers."""
|
||||
# Setup mocks
|
||||
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
|
||||
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
|
||||
mock_build_manifest.return_value = Manifest(
|
||||
id="test-org.test-extension",
|
||||
publisher="test-org",
|
||||
name="test-extension",
|
||||
displayName="Test Extension",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
mock_observer = Mock()
|
||||
mock_observer_class.return_value = mock_observer
|
||||
@@ -101,7 +107,13 @@ def test_dev_command_initial_build(
|
||||
"""Test dev command performs initial build setup."""
|
||||
# Setup mocks
|
||||
mock_rebuild_frontend.return_value = "remoteEntry.abc123.js"
|
||||
mock_build_manifest.return_value = Manifest(id="test", name="test", version="1.0.0")
|
||||
mock_build_manifest.return_value = Manifest(
|
||||
id="test-org.test-extension",
|
||||
publisher="test-org",
|
||||
name="test-extension",
|
||||
displayName="Test Extension",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
extension_setup_for_dev(isolated_filesystem)
|
||||
|
||||
@@ -178,8 +190,9 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
|
||||
"""Test frontend watcher function for coverage."""
|
||||
# Create extension.json
|
||||
extension_json = {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
@@ -189,7 +202,13 @@ def test_frontend_watcher_function_coverage(isolated_filesystem):
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
dist_dir.mkdir()
|
||||
|
||||
mock_manifest = Manifest(id="test", name="test", version="1.0.0")
|
||||
mock_manifest = Manifest(
|
||||
id="test-org.test-extension",
|
||||
publisher="test-org",
|
||||
name="test-extension",
|
||||
displayName="Test Extension",
|
||||
version="1.0.0",
|
||||
)
|
||||
with patch("superset_extensions_cli.cli.rebuild_frontend") as mock_rebuild:
|
||||
with patch("superset_extensions_cli.cli.build_manifest") as mock_build:
|
||||
with patch("superset_extensions_cli.cli.write_manifest") as mock_write:
|
||||
|
||||
@@ -43,16 +43,17 @@ def test_init_creates_extension_with_both_frontend_and_backend(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
assert (
|
||||
"🎉 Extension Test Extension (ID: test_extension) initialized" in result.output
|
||||
"🎉 Extension Test Extension (ID: test-org.test-extension) initialized"
|
||||
in result.output
|
||||
)
|
||||
|
||||
# Verify directory structure
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
assert_directory_exists(extension_path, "main extension directory")
|
||||
|
||||
expected_structure = create_test_extension_structure(
|
||||
isolated_filesystem,
|
||||
"test_extension",
|
||||
"test-org.test-extension",
|
||||
include_frontend=True,
|
||||
include_backend=True,
|
||||
)
|
||||
@@ -73,7 +74,7 @@ def test_init_creates_extension_with_frontend_only(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
assert_directory_exists(extension_path)
|
||||
|
||||
# Should have frontend directory and package.json
|
||||
@@ -96,7 +97,7 @@ def test_init_creates_extension_with_backend_only(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
assert_directory_exists(extension_path)
|
||||
|
||||
# Should have backend directory and pyproject.toml
|
||||
@@ -119,7 +120,7 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
assert_directory_exists(extension_path)
|
||||
|
||||
# Should only have extension.json
|
||||
@@ -130,54 +131,54 @@ def test_init_creates_extension_with_neither_frontend_nor_backend(
|
||||
assert not (extension_path / "backend").exists()
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
def test_init_accepts_valid_display_name(cli_runner, isolated_filesystem):
|
||||
"""Test that init accepts valid display names and generates proper ID."""
|
||||
cli_input = "My Awesome Extension\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, f"Should accept display name: {result.output}"
|
||||
assert Path("test-org.my-awesome-extension").exists(), (
|
||||
"Directory for generated composite ID should be created"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
def test_init_accepts_mixed_alphanumeric_name(cli_runner, isolated_filesystem):
|
||||
"""Test that init accepts mixed alphanumeric display names."""
|
||||
cli_input = "Tool 123\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, (
|
||||
f"Mixed alphanumeric display name should be valid: {result.output}"
|
||||
)
|
||||
assert Path("test-org.tool-123").exists(), (
|
||||
"Directory for 'test-org.tool-123' should be created"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_name,expected_error",
|
||||
"display_name,expected_id",
|
||||
[
|
||||
("test-extension", "must be alphanumeric"),
|
||||
("test extension", "must be alphanumeric"),
|
||||
("test.extension", "must be alphanumeric"),
|
||||
("test@extension", "must be alphanumeric"),
|
||||
("", "must be alphanumeric"),
|
||||
("Test Extension", "test-org.test-extension"),
|
||||
("My Tool v2", "test-org.my-tool-v2"),
|
||||
("Dashboard Helper", "test-org.dashboard-helper"),
|
||||
("Chart Builder Pro", "test-org.chart-builder-pro"),
|
||||
],
|
||||
)
|
||||
def test_init_validates_extension_name(
|
||||
cli_runner, isolated_filesystem, invalid_name, expected_error
|
||||
):
|
||||
"""Test that init validates extension names according to regex pattern."""
|
||||
cli_input = f"{invalid_name}\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 1, (
|
||||
f"Expected command to fail for invalid name '{invalid_name}'"
|
||||
)
|
||||
assert expected_error in result.output
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
def test_init_accepts_numeric_extension_name(cli_runner, isolated_filesystem):
|
||||
"""Test that init accepts numeric extension ids like '123'."""
|
||||
cli_input = "123\n123\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, f"Numeric id '123' should be valid: {result.output}"
|
||||
assert Path("123").exists(), "Directory for '123' should be created"
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@pytest.mark.parametrize(
|
||||
"valid_id", ["test123", "TestExtension", "test_extension_123", "MyExt_1"]
|
||||
)
|
||||
def test_init_with_valid_alphanumeric_names(cli_runner, valid_id):
|
||||
"""Test that init accepts various valid alphanumeric names."""
|
||||
def test_init_with_various_display_names(cli_runner, display_name, expected_id):
|
||||
"""Test that init accepts various display names and generates proper IDs."""
|
||||
with cli_runner.isolated_filesystem():
|
||||
cli_input = f"{valid_id}\nTest Extension\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
cli_input = f"{display_name}\n\ntest-org\n0.1.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0, (
|
||||
f"Valid name '{valid_id}' was rejected: {result.output}"
|
||||
f"Valid display name '{display_name}' was rejected: {result.output}"
|
||||
)
|
||||
assert Path(expected_id).exists(), (
|
||||
f"Directory for '{expected_id}' was not created"
|
||||
)
|
||||
assert Path(valid_id).exists(), f"Directory for '{valid_id}' was not created"
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@@ -186,7 +187,7 @@ def test_init_fails_when_directory_already_exists(
|
||||
):
|
||||
"""Test that init fails gracefully when target directory already exists."""
|
||||
# Create the directory first
|
||||
existing_dir = isolated_filesystem / "test_extension"
|
||||
existing_dir = isolated_filesystem / "test-org.test-extension"
|
||||
existing_dir.mkdir()
|
||||
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
@@ -203,15 +204,16 @@ def test_extension_json_content_is_correct(
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
extension_json_path = extension_path / "extension.json"
|
||||
|
||||
# Verify the JSON structure and values
|
||||
assert_json_content(
|
||||
extension_json_path,
|
||||
{
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"displayName": "Test Extension",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"permissions": [],
|
||||
@@ -226,16 +228,28 @@ def test_extension_json_content_is_correct(
|
||||
frontend = content["frontend"]
|
||||
assert "contributions" in frontend
|
||||
assert "moduleFederation" in frontend
|
||||
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
|
||||
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
|
||||
assert frontend["contributions"] == {
|
||||
"commands": [],
|
||||
"views": {},
|
||||
"menus": {},
|
||||
"editors": [],
|
||||
}
|
||||
assert frontend["moduleFederation"] == {
|
||||
"exposes": ["./index"],
|
||||
"name": "testOrg_testExtension",
|
||||
}
|
||||
|
||||
# Verify backend section exists and has correct structure
|
||||
assert "backend" in content
|
||||
backend = content["backend"]
|
||||
assert "entryPoints" in backend
|
||||
assert "files" in backend
|
||||
assert backend["entryPoints"] == ["test_extension.entrypoint"]
|
||||
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
|
||||
assert backend["entryPoints"] == [
|
||||
"superset_extensions.test_org.test_extension.entrypoint"
|
||||
]
|
||||
assert backend["files"] == [
|
||||
"backend/src/superset_extensions/test_org/test_extension/**/*.py"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@@ -246,14 +260,14 @@ def test_frontend_package_json_content_is_correct(
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
package_json_path = extension_path / "frontend" / "package.json"
|
||||
|
||||
# Verify the package.json structure and values
|
||||
assert_json_content(
|
||||
package_json_path,
|
||||
{
|
||||
"name": "test_extension",
|
||||
"name": "@test-org/test-extension",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
},
|
||||
@@ -275,14 +289,16 @@ def test_backend_pyproject_toml_is_created(
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
pyproject_path = extension_path / "backend" / "pyproject.toml"
|
||||
|
||||
assert_file_exists(pyproject_path, "backend pyproject.toml")
|
||||
|
||||
# Basic content verification (without parsing TOML for now)
|
||||
content = pyproject_path.read_text()
|
||||
assert "test_extension" in content
|
||||
assert (
|
||||
"test_org-test_extension" in content
|
||||
) # Package name uses collision-safe naming
|
||||
assert "0.1.0" in content
|
||||
assert "Apache-2.0" in content
|
||||
|
||||
@@ -300,7 +316,9 @@ def test_init_command_output_messages(cli_runner, isolated_filesystem, cli_input
|
||||
assert "Created .gitignore" in output
|
||||
assert "Created frontend folder structure" in output
|
||||
assert "Created backend folder structure" in output
|
||||
assert "Extension Test Extension (ID: test_extension) initialized" in output
|
||||
assert (
|
||||
"Extension Test Extension (ID: test-org.test-extension) initialized" in output
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
@@ -309,7 +327,7 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input_both)
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "test_extension"
|
||||
extension_path = isolated_filesystem / "test-org.test-extension"
|
||||
gitignore_path = extension_path / ".gitignore"
|
||||
|
||||
assert_file_exists(gitignore_path, ".gitignore")
|
||||
@@ -329,19 +347,20 @@ def test_gitignore_content_is_correct(cli_runner, isolated_filesystem, cli_input
|
||||
@pytest.mark.cli
|
||||
def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
|
||||
"""Test init with custom version and license parameters."""
|
||||
cli_input = "my_extension\nMy Extension\n2.1.0\nMIT\ny\nn\n"
|
||||
cli_input = "My Extension\n\ntest-org\n2.1.0\nMIT\ny\nn\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
extension_path = isolated_filesystem / "my_extension"
|
||||
extension_path = isolated_filesystem / "test-org.my-extension"
|
||||
extension_json_path = extension_path / "extension.json"
|
||||
|
||||
assert_json_content(
|
||||
extension_json_path,
|
||||
{
|
||||
"id": "my_extension",
|
||||
"name": "My Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "my-extension",
|
||||
"displayName": "My Extension",
|
||||
"version": "2.1.0",
|
||||
"license": "MIT",
|
||||
},
|
||||
@@ -353,17 +372,17 @@ def test_init_with_custom_version_and_license(cli_runner, isolated_filesystem):
|
||||
def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
|
||||
"""Integration test for the complete init workflow."""
|
||||
# Test the complete flow with realistic user input
|
||||
cli_input = "awesome_charts\nAwesome Charts\n1.0.0\nApache-2.0\ny\ny\n"
|
||||
cli_input = "Awesome Charts\n\nawesome-org\n1.0.0\nApache-2.0\ny\ny\n"
|
||||
result = cli_runner.invoke(app, ["init"], input=cli_input)
|
||||
|
||||
# Verify success
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Verify complete directory structure
|
||||
extension_path = isolated_filesystem / "awesome_charts"
|
||||
extension_path = isolated_filesystem / "awesome-org.awesome-charts"
|
||||
expected_structure = create_test_extension_structure(
|
||||
isolated_filesystem,
|
||||
"awesome_charts",
|
||||
"awesome-org.awesome-charts",
|
||||
include_frontend=True,
|
||||
include_backend=True,
|
||||
)
|
||||
@@ -374,16 +393,19 @@ def test_full_init_workflow_integration(cli_runner, isolated_filesystem):
|
||||
|
||||
# Verify all generated files have correct content
|
||||
extension_json = load_json_file(extension_path / "extension.json")
|
||||
assert extension_json["id"] == "awesome_charts"
|
||||
assert extension_json["name"] == "Awesome Charts"
|
||||
assert extension_json["publisher"] == "awesome-org"
|
||||
assert extension_json["name"] == "awesome-charts"
|
||||
assert extension_json["displayName"] == "Awesome Charts"
|
||||
assert extension_json["version"] == "1.0.0"
|
||||
assert extension_json["license"] == "Apache-2.0"
|
||||
|
||||
package_json = load_json_file(extension_path / "frontend" / "package.json")
|
||||
assert package_json["name"] == "awesome_charts"
|
||||
assert package_json["name"] == "@awesome-org/awesome-charts"
|
||||
|
||||
pyproject_content = (extension_path / "backend" / "pyproject.toml").read_text()
|
||||
assert "awesome_charts" in pyproject_content
|
||||
assert (
|
||||
"awesome_org-awesome_charts" in pyproject_content
|
||||
) # Package name uses collision-safe naming
|
||||
|
||||
|
||||
# Non-interactive mode tests
|
||||
@@ -394,9 +416,11 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"my_ext",
|
||||
"--publisher",
|
||||
"my-org",
|
||||
"--name",
|
||||
"my-ext",
|
||||
"--display-name",
|
||||
"My Extension",
|
||||
"--version",
|
||||
"1.0.0",
|
||||
@@ -408,16 +432,17 @@ def test_init_non_interactive_with_all_options(cli_runner, isolated_filesystem):
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
assert "🎉 Extension My Extension (ID: my_ext) initialized" in result.output
|
||||
assert "🎉 Extension My Extension (ID: my-org.my-ext) initialized" in result.output
|
||||
|
||||
extension_path = isolated_filesystem / "my_ext"
|
||||
extension_path = isolated_filesystem / "my-org.my-ext"
|
||||
assert_directory_exists(extension_path)
|
||||
assert_directory_exists(extension_path / "frontend")
|
||||
assert_directory_exists(extension_path / "backend")
|
||||
|
||||
extension_json = load_json_file(extension_path / "extension.json")
|
||||
assert extension_json["id"] == "my_ext"
|
||||
assert extension_json["name"] == "My Extension"
|
||||
assert extension_json["publisher"] == "my-org"
|
||||
assert extension_json["name"] == "my-ext"
|
||||
assert extension_json["displayName"] == "My Extension"
|
||||
assert extension_json["version"] == "1.0.0"
|
||||
assert extension_json["license"] == "MIT"
|
||||
|
||||
@@ -429,9 +454,11 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"frontend_ext",
|
||||
"--publisher",
|
||||
"frontend-org",
|
||||
"--name",
|
||||
"frontend-ext",
|
||||
"--display-name",
|
||||
"Frontend Extension",
|
||||
"--version",
|
||||
"1.0.0",
|
||||
@@ -444,7 +471,7 @@ def test_init_frontend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "frontend_ext"
|
||||
extension_path = isolated_filesystem / "frontend-org.frontend-ext"
|
||||
assert_directory_exists(extension_path / "frontend")
|
||||
assert not (extension_path / "backend").exists()
|
||||
|
||||
@@ -456,9 +483,11 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"backend_ext",
|
||||
"--publisher",
|
||||
"backend-org",
|
||||
"--name",
|
||||
"backend-ext",
|
||||
"--display-name",
|
||||
"Backend Extension",
|
||||
"--version",
|
||||
"1.0.0",
|
||||
@@ -471,7 +500,7 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "backend_ext"
|
||||
extension_path = isolated_filesystem / "backend-org.backend-ext"
|
||||
assert not (extension_path / "frontend").exists()
|
||||
assert_directory_exists(extension_path / "backend")
|
||||
|
||||
@@ -479,14 +508,16 @@ def test_init_backend_only_with_cli_options(cli_runner, isolated_filesystem):
|
||||
@pytest.mark.cli
|
||||
def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
|
||||
"""Test that init prompts for options not provided via CLI and uses defaults."""
|
||||
# Provide id and name via CLI, but version/license will be prompted (accept defaults)
|
||||
# Provide publisher, name, and display-name via CLI, but version/license will be prompted (accept defaults)
|
||||
result = cli_runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"default_ext",
|
||||
"--publisher",
|
||||
"default-org",
|
||||
"--name",
|
||||
"default-ext",
|
||||
"--display-name",
|
||||
"Default Extension",
|
||||
"--frontend",
|
||||
"--backend",
|
||||
@@ -496,22 +527,24 @@ def test_init_prompts_for_missing_options(cli_runner, isolated_filesystem):
|
||||
|
||||
assert result.exit_code == 0, f"Command failed with output: {result.output}"
|
||||
|
||||
extension_path = isolated_filesystem / "default_ext"
|
||||
extension_path = isolated_filesystem / "default-org.default-ext"
|
||||
extension_json = load_json_file(extension_path / "extension.json")
|
||||
assert extension_json["version"] == "0.1.0"
|
||||
assert extension_json["license"] == "Apache-2.0"
|
||||
|
||||
|
||||
@pytest.mark.cli
|
||||
def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
|
||||
"""Test that non-interactive mode validates extension ID."""
|
||||
def test_init_non_interactive_validates_technical_name(cli_runner, isolated_filesystem):
|
||||
"""Test that non-interactive mode validates technical name."""
|
||||
result = cli_runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--id",
|
||||
"invalid-id",
|
||||
"--publisher",
|
||||
"test-org",
|
||||
"--name",
|
||||
"invalid_name",
|
||||
"--display-name",
|
||||
"Invalid Extension",
|
||||
"--frontend",
|
||||
"--backend",
|
||||
@@ -519,4 +552,4 @@ def test_init_non_interactive_validates_id(cli_runner, isolated_filesystem):
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "must be alphanumeric" in result.output
|
||||
assert "must start with a letter" in result.output.lower()
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
# 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 pytest
|
||||
|
||||
from superset_extensions_cli.exceptions import ExtensionNameError
|
||||
from superset_extensions_cli.utils import (
|
||||
generate_extension_names,
|
||||
get_module_federation_name,
|
||||
kebab_to_camel_case,
|
||||
kebab_to_snake_case,
|
||||
name_to_kebab_case,
|
||||
suggest_technical_name,
|
||||
validate_display_name,
|
||||
validate_npm_package_name,
|
||||
validate_publisher,
|
||||
validate_python_package_name,
|
||||
validate_technical_name,
|
||||
)
|
||||
|
||||
|
||||
# Name transformation tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("display_name", "expected"),
|
||||
[
|
||||
("Hello World", "hello-world"),
|
||||
("Data Explorer", "data-explorer"),
|
||||
("My Extension", "my-extension"),
|
||||
("hello-world", "hello-world"), # Already normalized
|
||||
("Hello@World!", "helloworld"), # Special chars removed
|
||||
(
|
||||
"Data_Explorer",
|
||||
"data-explorer",
|
||||
), # Underscores become spaces then hyphens
|
||||
("My Extension", "my-extension"), # Multiple spaces normalized
|
||||
(" Hello World ", "hello-world"), # Trimmed
|
||||
("API v2 Client", "api-v2-client"), # Numbers preserved
|
||||
("Simple", "simple"), # Single word
|
||||
],
|
||||
)
|
||||
def test_name_to_kebab_case(display_name, expected):
|
||||
"""Test direct kebab case conversion from display names."""
|
||||
assert name_to_kebab_case(display_name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kebab_name", "expected"),
|
||||
[
|
||||
("hello-world", "helloWorld"),
|
||||
("data-explorer", "dataExplorer"),
|
||||
("my-extension", "myExtension"),
|
||||
("api-v2-client", "apiV2Client"),
|
||||
("simple", "simple"), # Single word
|
||||
("chart-tool", "chartTool"),
|
||||
("dashboard-helper", "dashboardHelper"),
|
||||
],
|
||||
)
|
||||
def test_kebab_to_camel_case(kebab_name, expected):
|
||||
"""Test kebab-case to camelCase conversion."""
|
||||
assert kebab_to_camel_case(kebab_name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kebab_name", "expected"),
|
||||
[
|
||||
("hello-world", "hello_world"),
|
||||
("data-explorer", "data_explorer"),
|
||||
("my-extension", "my_extension"),
|
||||
("api-v2-client", "api_v2_client"),
|
||||
("simple", "simple"), # Single word
|
||||
("chart-tool", "chart_tool"),
|
||||
("dashboard-helper", "dashboard_helper"),
|
||||
],
|
||||
)
|
||||
def test_kebab_to_snake_case(kebab_name, expected):
|
||||
"""Test kebab-case to snake_case conversion."""
|
||||
assert kebab_to_snake_case(kebab_name) == expected
|
||||
|
||||
|
||||
# Display name validation tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("valid_display", "expected_normalized"),
|
||||
[
|
||||
("Hello World", "Hello World"),
|
||||
("Data Explorer", "Data Explorer"),
|
||||
("My Extension", "My Extension"),
|
||||
("Simple", "Simple"),
|
||||
(" Extra Spaces ", "Extra Spaces"), # Gets normalized
|
||||
("Dashboard Widgets", "Dashboard Widgets"),
|
||||
("Chart Builder Pro", "Chart Builder Pro"),
|
||||
("API Client v2.0", "API Client v2.0"),
|
||||
("Tool_123", "Tool_123"), # Underscores allowed
|
||||
("My-Extension", "My-Extension"), # Hyphens allowed
|
||||
],
|
||||
)
|
||||
def test_validate_display_name_valid(valid_display, expected_normalized):
|
||||
"""Test valid display names return correctly normalized output."""
|
||||
result = validate_display_name(valid_display)
|
||||
assert result == expected_normalized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_display", "error_match"),
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
(" ", "cannot be empty"),
|
||||
("@#$%", "must start with a letter"),
|
||||
("123 Tool", "must start with a letter"),
|
||||
("-My Extension", "must start with a letter"),
|
||||
],
|
||||
)
|
||||
def test_validate_display_name_invalid(invalid_display, error_match):
|
||||
"""Test invalid display names."""
|
||||
with pytest.raises(ExtensionNameError, match=error_match):
|
||||
validate_display_name(invalid_display)
|
||||
|
||||
|
||||
# Python package name validation tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("valid_package",),
|
||||
[
|
||||
("hello_world",),
|
||||
("data_explorer",),
|
||||
("myext",),
|
||||
("test123",),
|
||||
("package_with_many_parts",),
|
||||
],
|
||||
)
|
||||
def test_validate_python_package_name_valid(valid_package):
|
||||
"""Test valid Python package names."""
|
||||
# Should not raise exceptions
|
||||
validate_python_package_name(valid_package)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("keyword",),
|
||||
[
|
||||
("class",),
|
||||
("import",),
|
||||
("def",),
|
||||
("return",),
|
||||
("if",),
|
||||
("else",),
|
||||
("for",),
|
||||
("while",),
|
||||
("try",),
|
||||
("except",),
|
||||
("finally",),
|
||||
("with",),
|
||||
("as",),
|
||||
("lambda",),
|
||||
("yield",),
|
||||
("False",),
|
||||
("None",),
|
||||
("True",),
|
||||
],
|
||||
)
|
||||
def test_validate_python_package_name_keywords(keyword):
|
||||
"""Test that Python reserved keywords are rejected."""
|
||||
with pytest.raises(
|
||||
ExtensionNameError, match="Package name cannot start with Python keyword"
|
||||
):
|
||||
validate_python_package_name(keyword)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_package",),
|
||||
[
|
||||
("hello-world",), # Hyphens not allowed in Python identifiers
|
||||
],
|
||||
)
|
||||
def test_validate_python_package_name_invalid(invalid_package):
|
||||
"""Test invalid Python package names."""
|
||||
with pytest.raises(ExtensionNameError, match="not a valid Python package"):
|
||||
validate_python_package_name(invalid_package)
|
||||
|
||||
|
||||
# NPM package validation tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("valid_npm",),
|
||||
[
|
||||
("hello-world",),
|
||||
("data-explorer",),
|
||||
("myext",),
|
||||
("package-with-many-parts",),
|
||||
],
|
||||
)
|
||||
def test_validate_npm_package_name_valid(valid_npm):
|
||||
"""Test valid npm package names."""
|
||||
# Should not raise exceptions
|
||||
validate_npm_package_name(valid_npm)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reserved_name",),
|
||||
[
|
||||
("node_modules",),
|
||||
("npm",),
|
||||
("yarn",),
|
||||
("package.json",),
|
||||
("localhost",),
|
||||
("favicon.ico",),
|
||||
],
|
||||
)
|
||||
def test_validate_npm_package_name_reserved(reserved_name):
|
||||
"""Test that npm reserved names are rejected."""
|
||||
with pytest.raises(ExtensionNameError, match="reserved npm package name"):
|
||||
validate_npm_package_name(reserved_name)
|
||||
|
||||
|
||||
# Publisher validation tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("valid_publisher",),
|
||||
[
|
||||
("my-org",),
|
||||
("acme",),
|
||||
("apache-superset",),
|
||||
("test123",),
|
||||
("a",), # Single character
|
||||
("publisher-with-many-parts",),
|
||||
],
|
||||
)
|
||||
def test_validate_publisher_valid(valid_publisher):
|
||||
"""Test valid publisher namespaces."""
|
||||
# Should not raise exceptions
|
||||
validate_publisher(valid_publisher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_publisher", "error_match"),
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
("My-Org", "must start with a letter and contain only lowercase letters"),
|
||||
("-publisher", "must start with a letter and contain only lowercase letters"),
|
||||
("publisher-", "must start with a letter and contain only lowercase letters"),
|
||||
("pub--lisher", "must start with a letter and contain only lowercase letters"),
|
||||
],
|
||||
)
|
||||
def test_validate_publisher_invalid(invalid_publisher, error_match):
|
||||
"""Test invalid publisher namespaces."""
|
||||
with pytest.raises(ExtensionNameError, match=error_match):
|
||||
validate_publisher(invalid_publisher)
|
||||
|
||||
|
||||
# Technical name validation tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("valid_name",),
|
||||
[
|
||||
("dashboard-widgets",),
|
||||
("chart-builder",),
|
||||
("simple",),
|
||||
("api-client-v2",),
|
||||
("tool123",),
|
||||
],
|
||||
)
|
||||
def test_validate_technical_name_valid(valid_name):
|
||||
"""Test valid technical names."""
|
||||
# Should not raise exceptions
|
||||
validate_technical_name(valid_name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_name", "error_match"),
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
(
|
||||
"Dashboard-Widgets",
|
||||
"must start with a letter and contain only lowercase letters",
|
||||
),
|
||||
("-name", "must start with a letter and contain only lowercase letters"),
|
||||
("name-", "must start with a letter and contain only lowercase letters"),
|
||||
("na--me", "must start with a letter and contain only lowercase letters"),
|
||||
],
|
||||
)
|
||||
def test_validate_technical_name_invalid(invalid_name, error_match):
|
||||
"""Test invalid technical names."""
|
||||
with pytest.raises(ExtensionNameError, match=error_match):
|
||||
validate_technical_name(invalid_name)
|
||||
|
||||
|
||||
# Name suggestion tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("display_name", "expected_technical"),
|
||||
[
|
||||
("Dashboard Widgets", "dashboard-widgets"),
|
||||
("Chart Builder Pro!", "chart-builder-pro"),
|
||||
("My@Tool#123", "mytool123"),
|
||||
(" Spaced Out ", "spaced-out"),
|
||||
("API v2 Client", "api-v2-client"),
|
||||
],
|
||||
)
|
||||
def test_suggest_technical_name(display_name, expected_technical):
|
||||
"""Test technical name suggestion from display names."""
|
||||
result = suggest_technical_name(display_name)
|
||||
assert result == expected_technical
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("publisher", "name", "expected_mf"),
|
||||
[
|
||||
("my-org", "dashboard-widgets", "myOrg_dashboardWidgets"),
|
||||
("acme", "chart-builder", "acme_chartBuilder"),
|
||||
("test-company", "simple", "testCompany_simple"),
|
||||
],
|
||||
)
|
||||
def test_get_module_federation_name(publisher, name, expected_mf):
|
||||
"""Test Module Federation name generation."""
|
||||
result = get_module_federation_name(publisher, name)
|
||||
assert result == expected_mf
|
||||
|
||||
|
||||
# Complete name generation tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("display_name", "expected_kebab", "expected_snake", "expected_camel"),
|
||||
[
|
||||
("Hello World", "hello-world", "hello_world", "helloWorld"),
|
||||
("Data Explorer", "data-explorer", "data_explorer", "dataExplorer"),
|
||||
("My Extension v2", "my-extension-v2", "my_extension_v2", "myExtensionV2"),
|
||||
("Chart Tool", "chart-tool", "chart_tool", "chartTool"),
|
||||
("Simple", "simple", "simple", "simple"),
|
||||
("API v2 Client", "api-v2-client", "api_v2_client", "apiV2Client"),
|
||||
(
|
||||
"Dashboard Helper",
|
||||
"dashboard-helper",
|
||||
"dashboard_helper",
|
||||
"dashboardHelper",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_generate_extension_names_complete_flow(
|
||||
display_name, expected_kebab, expected_snake, expected_camel
|
||||
):
|
||||
"""Test complete name generation flow with publisher concept."""
|
||||
publisher = "test-org"
|
||||
names = generate_extension_names(display_name, publisher, expected_kebab)
|
||||
|
||||
# Test all transformations with publisher concept
|
||||
assert names["display_name"] == display_name
|
||||
assert names["publisher"] == publisher
|
||||
assert names["name"] == expected_kebab # Technical name
|
||||
assert names["id"] == f"{publisher}.{expected_kebab}" # Composite ID
|
||||
assert names["npm_name"] == f"@{publisher}/{expected_kebab}" # NPM scoped
|
||||
assert (
|
||||
names["mf_name"] == f"testOrg_{expected_camel}"
|
||||
) # Module Federation with publisher prefix
|
||||
assert (
|
||||
names["backend_package"] == f"{publisher.replace('-', '_')}-{expected_snake}"
|
||||
) # Collision-safe
|
||||
assert (
|
||||
names["backend_path"]
|
||||
== f"superset_extensions.{publisher.replace('-', '_')}.{expected_snake}"
|
||||
)
|
||||
assert (
|
||||
names["backend_entry"]
|
||||
== f"superset_extensions.{publisher.replace('-', '_')}.{expected_snake}.entrypoint"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("invalid_display",),
|
||||
[
|
||||
("Class Helper",), # Would create 'class_helper' - reserved keyword
|
||||
("Import Tool",), # Would create 'import_tool' - reserved keyword
|
||||
("@#$%",), # All special chars - becomes empty
|
||||
("123 Tool",), # Starts with number after kebab conversion
|
||||
],
|
||||
)
|
||||
def test_generate_extension_names_invalid(invalid_display):
|
||||
"""Test invalid name generation scenarios."""
|
||||
with pytest.raises(ExtensionNameError):
|
||||
generate_extension_names(invalid_display, "test-org")
|
||||
|
||||
|
||||
def test_generate_extension_names_unicode():
|
||||
"""Test handling of unicode characters."""
|
||||
# Use a simpler approach - the display name validation now requires starting with letter
|
||||
names = generate_extension_names("Cafe Extension", "test-org", "cafe-extension")
|
||||
assert names["id"] == "test-org.cafe-extension"
|
||||
assert names["display_name"] == "Cafe Extension" # Original preserved
|
||||
|
||||
|
||||
def test_generate_extension_names_special_chars():
|
||||
"""Test name generation with special characters."""
|
||||
# Use manual technical name since display validation is stricter
|
||||
names = generate_extension_names("My Extension", "test-org", "my-extension")
|
||||
|
||||
assert names["display_name"] == "My Extension"
|
||||
assert names["id"] == "test-org.my-extension"
|
||||
assert names["backend_package"] == "test_org-my_extension"
|
||||
|
||||
|
||||
def test_generate_extension_names_case_preservation():
|
||||
"""Test that display name case is preserved."""
|
||||
names = generate_extension_names("CamelCase Extension", "test-org")
|
||||
assert names["display_name"] == "CamelCase Extension"
|
||||
assert names["id"] == "test-org.camelcase-extension"
|
||||
|
||||
|
||||
# Edge case tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("edge_case",),
|
||||
[
|
||||
("",), # Empty string
|
||||
(" ",), # Only spaces
|
||||
("---",), # Only hyphens
|
||||
("___",), # Only underscores
|
||||
],
|
||||
)
|
||||
def test_empty_or_invalid_inputs(edge_case):
|
||||
"""Test inputs that become empty or invalid after processing."""
|
||||
with pytest.raises(ExtensionNameError):
|
||||
generate_extension_names(edge_case, "test-org")
|
||||
|
||||
|
||||
def test_minimal_valid_input():
|
||||
"""Test minimal valid input."""
|
||||
names = generate_extension_names("A Extension", "test-org")
|
||||
assert names["id"] == "test-org.a-extension"
|
||||
assert names["backend_package"] == "test_org-a_extension"
|
||||
|
||||
|
||||
def test_numbers_handling():
|
||||
"""Test handling of numbers in names."""
|
||||
names = generate_extension_names("Tool 123 v2", "test-org")
|
||||
assert names["id"] == "test-org.tool-123-v2"
|
||||
assert names["backend_package"] == "test_org-tool_123_v2"
|
||||
|
||||
|
||||
def test_manual_technical_name_override():
|
||||
"""Test using manual technical name instead of auto-generated."""
|
||||
display_name = "My Awesome Chart Builder Pro"
|
||||
publisher = "acme"
|
||||
technical_name = "chart-builder" # Much shorter than display name
|
||||
|
||||
# Create names using manual technical name
|
||||
names = generate_extension_names(display_name, publisher, technical_name)
|
||||
|
||||
# Verify technical names come from provided technical name, not display name
|
||||
assert (
|
||||
names["display_name"] == "My Awesome Chart Builder Pro"
|
||||
) # Display name preserved
|
||||
assert names["publisher"] == "acme"
|
||||
assert names["name"] == "chart-builder" # Technical name used
|
||||
assert names["id"] == "acme.chart-builder" # Composite ID
|
||||
assert names["mf_name"] == "acme_chartBuilder" # Module Federation format
|
||||
assert names["backend_package"] == "acme-chart_builder" # Collision-safe
|
||||
assert names["backend_path"] == "superset_extensions.acme.chart_builder"
|
||||
assert names["backend_entry"] == "superset_extensions.acme.chart_builder.entrypoint"
|
||||
|
||||
|
||||
def test_generate_names_uses_suggested_technical_names():
|
||||
"""Test that generate_extension_names can auto-suggest technical names."""
|
||||
display_name = "Hello World"
|
||||
publisher = "test-org"
|
||||
|
||||
# Generated names should use suggested technical name generation
|
||||
names = generate_extension_names(display_name, publisher)
|
||||
|
||||
# Verify the technical name was suggested from display name
|
||||
assert names["name"] == "hello-world"
|
||||
assert names["id"] == "test-org.hello-world"
|
||||
|
||||
# Verify other names were generated from the technical name and publisher
|
||||
assert names["mf_name"] == get_module_federation_name(
|
||||
"test-org", "hello-world"
|
||||
) # "testOrg_helloWorld"
|
||||
assert names["backend_package"] == "test_org-hello_world"
|
||||
|
||||
# Module Federation name should use underscore format with camelCase
|
||||
assert names["mf_name"] == "testOrg_helloWorld"
|
||||
@@ -42,8 +42,15 @@ def jinja_env(templates_dir):
|
||||
def template_context():
|
||||
"""Default template context for testing."""
|
||||
return {
|
||||
"id": "test_extension",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-org",
|
||||
"name": "test-extension",
|
||||
"display_name": "Test Extension",
|
||||
"id": "test-org.test-extension",
|
||||
"npm_name": "@test-org/test-extension",
|
||||
"mf_name": "testOrg_testExtension",
|
||||
"backend_package": "test_org-test_extension",
|
||||
"backend_path": "superset_extensions.test_org.test_extension",
|
||||
"backend_entry": "superset_extensions.test_org.test_extension.entrypoint",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"include_frontend": True,
|
||||
@@ -64,8 +71,9 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
# Verify basic fields
|
||||
assert parsed["id"] == "test_extension"
|
||||
assert parsed["name"] == "Test Extension"
|
||||
assert parsed["publisher"] == "test-org"
|
||||
assert parsed["name"] == "test-extension"
|
||||
assert parsed["displayName"] == "Test Extension"
|
||||
assert parsed["version"] == "0.1.0"
|
||||
assert parsed["license"] == "Apache-2.0"
|
||||
assert parsed["permissions"] == []
|
||||
@@ -75,14 +83,26 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
|
||||
frontend = parsed["frontend"]
|
||||
assert "contributions" in frontend
|
||||
assert "moduleFederation" in frontend
|
||||
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
|
||||
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
|
||||
assert frontend["contributions"] == {
|
||||
"commands": [],
|
||||
"views": {},
|
||||
"menus": {},
|
||||
"editors": [],
|
||||
}
|
||||
assert frontend["moduleFederation"] == {
|
||||
"exposes": ["./index"],
|
||||
"name": "testOrg_testExtension",
|
||||
}
|
||||
|
||||
# Verify backend section exists
|
||||
assert "backend" in parsed
|
||||
backend = parsed["backend"]
|
||||
assert backend["entryPoints"] == ["test_extension.entrypoint"]
|
||||
assert backend["files"] == ["backend/src/test_extension/**/*.py"]
|
||||
assert backend["entryPoints"] == [
|
||||
"superset_extensions.test_org.test_extension.entrypoint"
|
||||
]
|
||||
assert backend["files"] == [
|
||||
"backend/src/superset_extensions/test_org/test_extension/**/*.py"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -127,7 +147,7 @@ def test_frontend_package_json_template_renders_correctly(jinja_env, template_co
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
# Verify basic package info
|
||||
assert parsed["name"] == "test_extension"
|
||||
assert parsed["name"] == "@test-org/test-extension"
|
||||
assert parsed["version"] == "0.1.0"
|
||||
assert parsed["license"] == "Apache-2.0"
|
||||
assert parsed["private"] is True
|
||||
@@ -161,7 +181,7 @@ def test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
|
||||
rendered = template.render(template_context)
|
||||
|
||||
# Basic content verification (without full TOML parsing)
|
||||
assert "test_extension" in rendered
|
||||
assert "test_org-test_extension" in rendered
|
||||
assert "0.1.0" in rendered
|
||||
assert "Apache-2.0" in rendered
|
||||
|
||||
@@ -169,19 +189,36 @@ def test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_c
|
||||
# Template Rendering with Different Parameters Tests
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"id_,name",
|
||||
"publisher,technical_name,display_name",
|
||||
[
|
||||
("simple_extension", "Simple Extension"),
|
||||
("MyExtension123", "My Extension 123"),
|
||||
("complex_extension_name_123", "Complex Extension Name 123"),
|
||||
("ext", "Ext"),
|
||||
("test-org", "simple-extension", "Simple Extension"),
|
||||
("acme", "my-extension-123", "My Extension 123"),
|
||||
("company", "complex-extension-name-123", "Complex Extension Name 123"),
|
||||
("pub", "ext", "Ext"),
|
||||
],
|
||||
)
|
||||
def test_template_rendering_with_different_ids(jinja_env, id_, name):
|
||||
"""Test templates render correctly with various extension ids/names."""
|
||||
def test_template_rendering_with_different_ids(
|
||||
jinja_env, publisher, technical_name, display_name
|
||||
):
|
||||
"""Test templates render correctly with various publisher/name combinations."""
|
||||
from superset_extensions_cli.utils import (
|
||||
get_module_federation_name,
|
||||
kebab_to_snake_case,
|
||||
)
|
||||
|
||||
publisher_snake = kebab_to_snake_case(publisher)
|
||||
name_snake = kebab_to_snake_case(technical_name)
|
||||
|
||||
context = {
|
||||
"id": id_,
|
||||
"name": name,
|
||||
"publisher": publisher,
|
||||
"name": technical_name,
|
||||
"display_name": display_name,
|
||||
"id": f"{publisher}.{technical_name}",
|
||||
"npm_name": f"@{publisher}/{technical_name}",
|
||||
"mf_name": get_module_federation_name(publisher, technical_name),
|
||||
"backend_package": f"{publisher_snake}-{name_snake}",
|
||||
"backend_path": f"superset_extensions.{publisher_snake}.{name_snake}",
|
||||
"backend_entry": f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"include_frontend": True,
|
||||
@@ -193,23 +230,28 @@ def test_template_rendering_with_different_ids(jinja_env, id_, name):
|
||||
rendered = template.render(context)
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
assert parsed["id"] == id_
|
||||
assert parsed["name"] == name
|
||||
assert parsed["backend"]["entryPoints"] == [f"{id_}.entrypoint"]
|
||||
assert parsed["backend"]["files"] == [f"backend/src/{id_}/**/*.py"]
|
||||
assert parsed["publisher"] == publisher
|
||||
assert parsed["name"] == technical_name
|
||||
assert parsed["displayName"] == display_name
|
||||
assert parsed["backend"]["entryPoints"] == [
|
||||
f"superset_extensions.{publisher_snake}.{name_snake}.entrypoint"
|
||||
]
|
||||
assert parsed["backend"]["files"] == [
|
||||
f"backend/src/superset_extensions/{publisher_snake}/{name_snake}/**/*.py"
|
||||
]
|
||||
|
||||
# Test package.json template
|
||||
template = jinja_env.get_template("frontend/package.json.j2")
|
||||
rendered = template.render(context)
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
assert parsed["name"] == id_
|
||||
assert parsed["name"] == f"@{publisher}/{technical_name}"
|
||||
|
||||
# Test pyproject.toml template
|
||||
template = jinja_env.get_template("backend/pyproject.toml.j2")
|
||||
rendered = template.render(context)
|
||||
|
||||
assert id_ in rendered
|
||||
assert f"{publisher_snake}-{name_snake}" in rendered
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -217,8 +259,12 @@ def test_template_rendering_with_different_ids(jinja_env, id_, name):
|
||||
def test_template_rendering_with_different_versions(jinja_env, version):
|
||||
"""Test templates render correctly with various version formats."""
|
||||
context = {
|
||||
"id": "test_ext",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-pub",
|
||||
"name": "test-ext",
|
||||
"display_name": "Test Extension",
|
||||
"id": "test-pub.test-ext",
|
||||
"npm_name": "@test-pub/test-ext",
|
||||
"mf_name": "testPub_testExt",
|
||||
"version": version,
|
||||
"license": "Apache-2.0",
|
||||
"include_frontend": True,
|
||||
@@ -246,8 +292,15 @@ def test_template_rendering_with_different_versions(jinja_env, version):
|
||||
def test_template_rendering_with_different_licenses(jinja_env, license_type):
|
||||
"""Test templates render correctly with various license types."""
|
||||
context = {
|
||||
"id": "test_ext",
|
||||
"name": "Test Extension",
|
||||
"publisher": "test-pub",
|
||||
"name": "test-ext",
|
||||
"display_name": "Test Extension",
|
||||
"id": "test-pub.test-ext",
|
||||
"npm_name": "@test-pub/test-ext",
|
||||
"mf_name": "testPub_testExt",
|
||||
"backend_package": "test_pub-test_ext",
|
||||
"backend_path": "superset_extensions.test_pub.test_ext",
|
||||
"backend_entry": "superset_extensions.test_pub.test_ext.entrypoint",
|
||||
"version": "1.0.0",
|
||||
"license": license_type,
|
||||
"include_frontend": True,
|
||||
@@ -312,8 +365,15 @@ def test_template_context_edge_cases(jinja_env):
|
||||
"""Test template rendering with edge case contexts."""
|
||||
# Test with minimal context
|
||||
minimal_context = {
|
||||
"id": "minimal",
|
||||
"name": "Minimal",
|
||||
"publisher": "min",
|
||||
"name": "minimal",
|
||||
"display_name": "Minimal",
|
||||
"id": "min.minimal",
|
||||
"npm_name": "@min/minimal",
|
||||
"mf_name": "min_minimal",
|
||||
"backend_package": "min-minimal",
|
||||
"backend_path": "superset_extensions.min.minimal",
|
||||
"backend_entry": "superset_extensions.min.minimal.entrypoint",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"include_frontend": False,
|
||||
@@ -325,7 +385,8 @@ def test_template_context_edge_cases(jinja_env):
|
||||
parsed = json.loads(rendered)
|
||||
|
||||
# Should still be valid JSON with basic fields
|
||||
assert parsed["id"] == "minimal"
|
||||
assert parsed["name"] == "Minimal"
|
||||
assert parsed["publisher"] == "min"
|
||||
assert parsed["name"] == "minimal"
|
||||
assert parsed["displayName"] == "Minimal"
|
||||
assert "frontend" not in parsed
|
||||
assert "backend" not in parsed
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Register TypeScript require hook so ESLint can load .ts plugin files
|
||||
require('tsx/cjs');
|
||||
|
||||
const packageConfig = require('./package.json');
|
||||
|
||||
const importCoreModules = [];
|
||||
@@ -148,7 +152,7 @@ module.exports = {
|
||||
// Custom Superset rules
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': ['error', true],
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
|
||||
// Core ESLint overrides for Superset
|
||||
'no-console': 'warn',
|
||||
@@ -195,7 +199,7 @@ module.exports = {
|
||||
'**/jest.setup.js',
|
||||
'**/webpack.config.js',
|
||||
'**/webpack.config.*.js',
|
||||
'**/.eslintrc.js',
|
||||
'**/.eslintrc*.js',
|
||||
],
|
||||
optionalDependencies: false,
|
||||
},
|
||||
@@ -245,6 +249,16 @@ module.exports = {
|
||||
// Lodash
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
|
||||
// React effect best practices
|
||||
'react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change':
|
||||
'error',
|
||||
'react-you-might-not-need-an-effect/no-chain-state-updates': 'error',
|
||||
'react-you-might-not-need-an-effect/no-event-handler': 'error',
|
||||
'react-you-might-not-need-an-effect/no-derived-state': 'error',
|
||||
|
||||
// Storybook
|
||||
'storybook/prefer-pascal-case': 'error',
|
||||
|
||||
// File progress
|
||||
'file-progress/activate': 1,
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Register TypeScript require hook so ESLint can load .ts plugin files
|
||||
require('tsx/cjs');
|
||||
|
||||
/**
|
||||
* MINIMAL ESLint config - ONLY for rules OXC doesn't support
|
||||
* This config is designed to be run alongside OXC linter
|
||||
@@ -66,7 +69,7 @@ module.exports = {
|
||||
// Custom Superset plugins
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': ['error', true],
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
'file-progress/activate': 1,
|
||||
|
||||
// Explicitly turn off all other rules to avoid conflicts
|
||||
|
||||
@@ -115,7 +115,7 @@ module.exports = {
|
||||
}),
|
||||
|
||||
typescript: {
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgen: getAbsolutePath('react-docgen-typescript'),
|
||||
},
|
||||
|
||||
framework: {
|
||||
|
||||
+70
-42
@@ -2808,9 +2808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -2818,6 +2818,10 @@
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
@@ -2838,15 +2842,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4545,6 +4549,22 @@
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
|
||||
@@ -7506,15 +7526,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -7849,16 +7869,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/table/node_modules/ajv": {
|
||||
"version": "8.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz",
|
||||
"integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -8341,6 +8361,7 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
@@ -10848,9 +10869,9 @@
|
||||
}
|
||||
},
|
||||
"ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
@@ -10870,15 +10891,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"json-schema-traverse": {
|
||||
@@ -12142,6 +12163,12 @@
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"peer": true
|
||||
},
|
||||
"fastq": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
|
||||
@@ -14235,15 +14262,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "8.12.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz",
|
||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"ajv-keywords": {
|
||||
@@ -14500,16 +14527,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": {
|
||||
"version": "8.6.3",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.3.tgz",
|
||||
"integrity": "sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"uri-js": "^4.2.2"
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"json-schema-traverse": {
|
||||
@@ -14828,6 +14855,7 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"punycode": "^2.1.0"
|
||||
|
||||
@@ -42,6 +42,11 @@ module.exports = {
|
||||
'<rootDir>/packages/.*/lib',
|
||||
'<rootDir>/plugins/.*/esm',
|
||||
'<rootDir>/plugins/.*/lib',
|
||||
// Ignore build artifacts that contain duplicate package.json or mock files
|
||||
'<rootDir>/storybook-static',
|
||||
// Ignore duplicate __mocks__ at package root level (e.g., packages/superset-ui-core/__mocks__)
|
||||
// but not test __mocks__ directories (e.g., packages/superset-ui-core/test/__mocks/)
|
||||
'<rootDir>/packages/[^/]+/__mocks__',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/spec/helpers/setup.ts'],
|
||||
snapshotSerializers: ['@emotion/jest/serializer'],
|
||||
@@ -64,7 +69,7 @@ module.exports = {
|
||||
],
|
||||
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
|
||||
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import", "react", "jest", "jsx-a11y", "typescript", "unicorn"],
|
||||
"plugins": [
|
||||
"import",
|
||||
"react",
|
||||
"jest",
|
||||
"jsx-a11y",
|
||||
"typescript",
|
||||
"unicorn",
|
||||
"oxc"
|
||||
],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
@@ -34,7 +42,8 @@
|
||||
"no-unused-vars": "off",
|
||||
"no-undef": "error",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-unsafe-optional-chaining": "off",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-import-assign": "off",
|
||||
"no-promise-executor-return": "off",
|
||||
|
||||
@@ -72,6 +81,12 @@
|
||||
"as-needed",
|
||||
{ "requireReturnForObjectLiteral": false }
|
||||
],
|
||||
"no-useless-constructor": "error",
|
||||
"no-else-return": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-object-constructor": "error",
|
||||
|
||||
// === Import plugin rules ===
|
||||
"import/no-unresolved": "error",
|
||||
@@ -136,7 +151,7 @@
|
||||
"react/jsx-no-bind": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/jsx-boolean-value": ["error", "never", { "always": [] }],
|
||||
"react/jsx-no-duplicate-props": ["error", { "ignoreCase": true }],
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-pascal-case": ["error", { "allowAllCaps": true, "ignore": [] }],
|
||||
"react/jsx-uses-vars": "error",
|
||||
@@ -254,6 +269,16 @@
|
||||
// === Unicorn rules (bonus coverage) ===
|
||||
"unicorn/no-new-array": "error",
|
||||
"unicorn/no-invalid-remove-event-listener": "error",
|
||||
"unicorn/no-useless-length-check": "error",
|
||||
"unicorn/no-length-as-slice-end": "error",
|
||||
"unicorn/no-useless-spread": "error",
|
||||
"unicorn/no-thenable": "error",
|
||||
"unicorn/escape-case": "error",
|
||||
"unicorn/prefer-array-flat-map": "error",
|
||||
"unicorn/prefer-array-some": "error",
|
||||
"unicorn/throw-new-error": "error",
|
||||
"unicorn/prefer-negative-index": "error",
|
||||
"unicorn/prefer-math-trunc": "error",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"unicorn/no-null": "off",
|
||||
|
||||
Generated
+1585
-2168
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,7 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -159,12 +160,11 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^1.0.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^2.1.2",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.3.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
@@ -183,14 +183,14 @@
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
"mapbox-gl": "^3.18.1",
|
||||
"markdown-to-jsx": "^9.7.3",
|
||||
"markdown-to-jsx": "^9.7.6",
|
||||
"match-sorter": "^6.3.4",
|
||||
"memoize-one": "^5.2.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"ol": "^7.5.2",
|
||||
"ol": "^10.8.0",
|
||||
"query-string": "9.3.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^17.0.2",
|
||||
@@ -201,7 +201,7 @@
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-intersection-observer": "^10.0.2",
|
||||
"react-intersection-observer": "^10.0.3",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lines-ellipsis": "^0.16.1",
|
||||
"react-loadable": "^5.5.0",
|
||||
@@ -257,20 +257,20 @@
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-actions": "^8.6.15",
|
||||
"@storybook/addon-controls": "^8.6.15",
|
||||
"@storybook/addon-essentials": "^8.6.15",
|
||||
"@storybook/addon-links": "^8.6.15",
|
||||
"@storybook/addon-mdx-gfm": "^8.6.15",
|
||||
"@storybook/components": "^8.6.15",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
"@storybook/react-webpack5": "^8.6.15",
|
||||
"@storybook/addon-actions": "^8.6.17",
|
||||
"@storybook/addon-controls": "^8.6.17",
|
||||
"@storybook/addon-essentials": "^8.6.17",
|
||||
"@storybook/addon-links": "^8.6.17",
|
||||
"@storybook/addon-mdx-gfm": "^8.6.17",
|
||||
"@storybook/components": "^8.6.17",
|
||||
"@storybook/preview-api": "^8.6.17",
|
||||
"@storybook/react": "^8.6.17",
|
||||
"@storybook/react-webpack5": "^8.6.17",
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/plugin-emotion": "^14.5.0",
|
||||
"@swc/core": "^1.15.13",
|
||||
"@swc/plugin-emotion": "^14.6.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -283,7 +283,7 @@
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -304,12 +304,12 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.3",
|
||||
"css-loader": "^7.1.4",
|
||||
"css-minimizer-webpack-plugin": "^7.0.4",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
@@ -317,6 +317,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-cypress": "^3.6.0",
|
||||
"eslint-plugin-file-progress": "^1.5.0",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
@@ -326,9 +327,9 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.8.5",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.15.4",
|
||||
"eslint-plugin-testing-library": "^7.16.0",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
@@ -341,12 +342,12 @@
|
||||
"jest-html-reporter": "^4.3.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^28.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lerna": "^8.2.3",
|
||||
"lightningcss": "^1.31.1",
|
||||
"mini-css-extract-plugin": "^2.10.0",
|
||||
"open-cli": "^8.0.0",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint": "^1.50.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-packagejson": "^3.0.0",
|
||||
@@ -357,7 +358,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.5.0",
|
||||
"storybook": "8.6.15",
|
||||
"storybook": "8.6.17",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
@@ -368,13 +369,13 @@
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.3",
|
||||
"webpack": "^5.105.0",
|
||||
"wait-on": "^9.0.4",
|
||||
"webpack": "^5.105.2",
|
||||
"webpack-bundle-analyzer": "^5.2.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.3",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.3.3",
|
||||
"webpack-sources": "^3.3.4",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -390,7 +391,6 @@
|
||||
},
|
||||
"overrides": {
|
||||
"core-js": "^3.38.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"underscore": "^1.13.7",
|
||||
|
||||
-18
@@ -4144,15 +4144,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-icons/all-files": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz",
|
||||
"integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
@@ -4186,7 +4177,6 @@
|
||||
"integrity": "sha512-zi2DJ2cTpgR1HugPX3yBHJAaBo7XYhodgZqj0BsKNMoexrLvHyPYsN+cw5xXFE1Q1ZyeKtQBB5m41+CKKfwQYw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@superset-ui/core": "0.18.25",
|
||||
"lodash": "^4.17.15",
|
||||
"prop-types": "^15.7.2"
|
||||
@@ -21330,13 +21320,6 @@
|
||||
"fastq": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"@react-icons/all-files": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz",
|
||||
"integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==",
|
||||
"peer": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
@@ -21367,7 +21350,6 @@
|
||||
"integrity": "sha512-zi2DJ2cTpgR1HugPX3yBHJAaBo7XYhodgZqj0BsKNMoexrLvHyPYsN+cw5xXFE1Q1ZyeKtQBB5m41+CKKfwQYw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@superset-ui/core": "0.18.25",
|
||||
"lodash": "^4.17.15",
|
||||
"prop-types": "^15.7.2"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"typescript": "^5.0.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^12.1.5",
|
||||
@@ -33,7 +33,7 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/fira-code": "^5.2.6",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^17.0.2",
|
||||
|
||||
@@ -294,6 +294,10 @@ export interface EditorKeyword {
|
||||
meta?: string;
|
||||
/** Sorting priority; higher scores appear first in the completion list */
|
||||
score?: number;
|
||||
/** Short supplementary text such as a type signature or the full value when truncated */
|
||||
detail?: string;
|
||||
/** Longer documentation content as an HTML string, shown in a documentation popup */
|
||||
documentation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,25 +497,25 @@ export interface EditorProviderUnregisteredEvent {
|
||||
* Register an editor provider for specific languages.
|
||||
* When an extension registers an editor, it replaces the default for those languages.
|
||||
*
|
||||
* @param contribution The editor contribution metadata from extension.json
|
||||
* The contribution metadata (name, languages, description) is read from the
|
||||
* extension's manifest (extension.json), so only the contribution ID and
|
||||
* component are needed at registration time.
|
||||
*
|
||||
* @param id The editor contribution ID declared in extension.json
|
||||
* @param component The React component implementing EditorProps
|
||||
* @returns A Disposable to unregister the provider
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const disposable = registerEditorProvider(
|
||||
* {
|
||||
* id: 'acme.monaco-sql',
|
||||
* name: 'Monaco SQL Editor',
|
||||
* languages: ['sql'],
|
||||
* },
|
||||
* 'acme.monaco-sql',
|
||||
* MonacoSQLEditor
|
||||
* );
|
||||
* context.disposables.push(disposable);
|
||||
* ```
|
||||
*/
|
||||
export declare function registerEditorProvider(
|
||||
contribution: EditorContribution,
|
||||
id: string,
|
||||
component: EditorComponent,
|
||||
): Disposable;
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Contribution configuration interfaces
|
||||
export interface CommandConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
execute: () => void | Promise<void>;
|
||||
when?: () => boolean;
|
||||
onActivate?: () => void; // Called when command is registered
|
||||
onDeactivate?: () => void; // Called when command is unregistered
|
||||
}
|
||||
|
||||
export interface ViewConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string; // e.g., "dashboard.tabs", "explore.panels"
|
||||
component: React.ComponentType;
|
||||
when?: () => boolean;
|
||||
onActivate?: () => void; // Called when view is registered
|
||||
onDeactivate?: () => void; // Called when view is unregistered
|
||||
}
|
||||
|
||||
export interface EditorConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeTypes: string[];
|
||||
component: React.ComponentType;
|
||||
onActivate?: () => void; // Called when editor is registered
|
||||
onDeactivate?: () => void; // Called when editor is unregistered
|
||||
}
|
||||
|
||||
export interface MenuConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
location: string; // e.g., "navbar.items", "context.menus"
|
||||
action: () => void | Promise<void>;
|
||||
when?: () => boolean;
|
||||
onActivate?: () => void; // Called when menu item is registered
|
||||
onDeactivate?: () => void; // Called when menu item is unregistered
|
||||
}
|
||||
|
||||
// Extension metadata attached to defined contributions
|
||||
export interface ContributionMetadata {
|
||||
type: 'command' | 'view' | 'editor' | 'menu';
|
||||
id: string;
|
||||
config: any;
|
||||
}
|
||||
|
||||
// Handle returned by define* functions for cleanup
|
||||
export interface ContributionHandle<T = any> {
|
||||
config: T;
|
||||
dispose: () => void;
|
||||
__contributionMeta__: ContributionMetadata;
|
||||
}
|
||||
|
||||
// Extension context interface (simplified)
|
||||
export interface ExtensionContext {
|
||||
registerCommand: (config: CommandConfig) => () => void;
|
||||
registerViewProvider: (
|
||||
id: string,
|
||||
component: React.ComponentType,
|
||||
) => () => void;
|
||||
registerEditor: (config: EditorConfig) => () => void;
|
||||
registerMenu: (config: MenuConfig) => () => void;
|
||||
}
|
||||
|
||||
// Global registry for auto-registration
|
||||
let _context: ExtensionContext | null = null;
|
||||
const _pendingContributions: ContributionHandle[] = [];
|
||||
|
||||
/**
|
||||
* Set the extension context for auto-registration.
|
||||
* Called automatically by the extension loader.
|
||||
*/
|
||||
export function setExtensionContext(context: ExtensionContext): void {
|
||||
_context = context;
|
||||
|
||||
// Auto-register any pending contributions
|
||||
for (const handle of _pendingContributions) {
|
||||
_registerContribution(handle);
|
||||
}
|
||||
_pendingContributions.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Auto-register a single contribution
|
||||
*/
|
||||
function _registerContribution(handle: ContributionHandle): void {
|
||||
if (!_context) {
|
||||
_pendingContributions.push(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
const { config, __contributionMeta__ } = handle;
|
||||
let disposeFn: () => void;
|
||||
|
||||
// Call onActivate callback if provided
|
||||
const typedConfig = config as
|
||||
| CommandConfig
|
||||
| ViewConfig
|
||||
| EditorConfig
|
||||
| MenuConfig;
|
||||
if (typedConfig.onActivate) {
|
||||
typedConfig.onActivate();
|
||||
}
|
||||
|
||||
switch (__contributionMeta__.type) {
|
||||
case 'command':
|
||||
disposeFn = _context.registerCommand(config as CommandConfig);
|
||||
break;
|
||||
case 'view':
|
||||
const viewConfig = config as ViewConfig;
|
||||
disposeFn = _context.registerViewProvider(
|
||||
viewConfig.id,
|
||||
viewConfig.component,
|
||||
);
|
||||
break;
|
||||
case 'editor':
|
||||
disposeFn = _context.registerEditor(config as EditorConfig);
|
||||
break;
|
||||
case 'menu':
|
||||
disposeFn = _context.registerMenu(config as MenuConfig);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown contribution type: ${__contributionMeta__.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap dispose function to call onDeactivate
|
||||
const originalDispose = disposeFn;
|
||||
handle.dispose = () => {
|
||||
if (typedConfig.onDeactivate) {
|
||||
typedConfig.onDeactivate();
|
||||
}
|
||||
originalDispose();
|
||||
};
|
||||
}
|
||||
|
||||
// Type augmentation to add metadata to functions
|
||||
declare global {
|
||||
interface Function {
|
||||
__contributionMeta__?: ContributionMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a command contribution.
|
||||
*
|
||||
* Commands are actions that can be triggered from various UI elements
|
||||
* like menus, toolbars, or keyboard shortcuts.
|
||||
*
|
||||
* Auto-registers when extension context is available.
|
||||
*
|
||||
* @param config Command configuration
|
||||
* @returns Handle with config and dispose function
|
||||
*/
|
||||
export function defineCommand<T extends CommandConfig>(
|
||||
config: T,
|
||||
): ContributionHandle<T> {
|
||||
// Store metadata for webpack plugin discovery
|
||||
const metadata: ContributionMetadata = {
|
||||
type: 'command',
|
||||
id: config.id,
|
||||
config,
|
||||
};
|
||||
|
||||
// Attach metadata to the execute function for runtime validation
|
||||
if (config.execute) {
|
||||
config.execute.__contributionMeta__ = metadata;
|
||||
}
|
||||
|
||||
// Create handle that auto-registers
|
||||
const handle: ContributionHandle<T> = {
|
||||
config,
|
||||
dispose: () => {}, // Will be set by _registerContribution
|
||||
__contributionMeta__: metadata,
|
||||
};
|
||||
|
||||
// Auto-register immediately or queue for later
|
||||
_registerContribution(handle);
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a view contribution.
|
||||
*
|
||||
* Views are UI components that can be embedded in various locations
|
||||
* throughout the Superset interface.
|
||||
*
|
||||
* Auto-registers when extension context is available.
|
||||
*
|
||||
* @param config View configuration
|
||||
* @returns Handle with config and dispose function
|
||||
*/
|
||||
export function defineView<T extends ViewConfig>(
|
||||
config: T,
|
||||
): ContributionHandle<T> {
|
||||
// Store metadata for webpack plugin discovery
|
||||
const metadata: ContributionMetadata = {
|
||||
type: 'view',
|
||||
id: config.id,
|
||||
config,
|
||||
};
|
||||
|
||||
// Attach metadata to the component for runtime validation
|
||||
if (config.component) {
|
||||
(config.component as any).__contributionMeta__ = metadata;
|
||||
}
|
||||
|
||||
// Create handle that auto-registers
|
||||
const handle: ContributionHandle<T> = {
|
||||
config,
|
||||
dispose: () => {}, // Will be set by _registerContribution
|
||||
__contributionMeta__: metadata,
|
||||
};
|
||||
|
||||
// Auto-register immediately or queue for later
|
||||
_registerContribution(handle);
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define an editor contribution.
|
||||
*
|
||||
* Editors provide custom editing interfaces for specific MIME types
|
||||
* in SQL Lab and other contexts.
|
||||
*
|
||||
* Auto-registers when extension context is available.
|
||||
*
|
||||
* @param config Editor configuration
|
||||
* @returns Handle with config and dispose function
|
||||
*/
|
||||
export function defineEditor<T extends EditorConfig>(
|
||||
config: T,
|
||||
): ContributionHandle<T> {
|
||||
// Store metadata for webpack plugin discovery
|
||||
const metadata: ContributionMetadata = {
|
||||
type: 'editor',
|
||||
id: config.id,
|
||||
config,
|
||||
};
|
||||
|
||||
// Attach metadata to the component for runtime validation
|
||||
if (config.component) {
|
||||
(config.component as any).__contributionMeta__ = metadata;
|
||||
}
|
||||
|
||||
// Create handle that auto-registers
|
||||
const handle: ContributionHandle<T> = {
|
||||
config,
|
||||
dispose: () => {}, // Will be set by _registerContribution
|
||||
__contributionMeta__: metadata,
|
||||
};
|
||||
|
||||
// Auto-register immediately or queue for later
|
||||
_registerContribution(handle);
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a menu contribution.
|
||||
*
|
||||
* Menus add items to various menu locations throughout the interface.
|
||||
*
|
||||
* Auto-registers when extension context is available.
|
||||
*
|
||||
* @param config Menu configuration
|
||||
* @returns Handle with config and dispose function
|
||||
*/
|
||||
export function defineMenu<T extends MenuConfig>(
|
||||
config: T,
|
||||
): ContributionHandle<T> {
|
||||
// Store metadata for webpack plugin discovery
|
||||
const metadata: ContributionMetadata = {
|
||||
type: 'menu',
|
||||
id: config.id,
|
||||
config,
|
||||
};
|
||||
|
||||
// Attach metadata to the action function for runtime validation
|
||||
if (config.action) {
|
||||
config.action.__contributionMeta__ = metadata;
|
||||
}
|
||||
|
||||
// Create handle that auto-registers
|
||||
const handle: ContributionHandle<T> = {
|
||||
config,
|
||||
dispose: () => {}, // Will be set by _registerContribution
|
||||
__contributionMeta__: metadata,
|
||||
};
|
||||
|
||||
// Auto-register immediately or queue for later
|
||||
_registerContribution(handle);
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Clear the contribution registry (for testing)
|
||||
*/
|
||||
export function _clearContributionRegistry(): void {
|
||||
_pendingContributions.length = 0;
|
||||
_context = null;
|
||||
}
|
||||
@@ -17,5 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
export * from './api';
|
||||
export * from './extensions';
|
||||
export * from './ui';
|
||||
export * from './utils';
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { Alert as AntdAlert } from 'antd';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { AlertProps as AntdAlertProps } from 'antd/es/alert';
|
||||
import { t } from '../../translation';
|
||||
|
||||
/**
|
||||
* Props for the Alert component, extending Ant Design's AlertProps
|
||||
@@ -74,7 +75,7 @@ export const Alert = (props: AlertProps) => {
|
||||
type={type}
|
||||
showIcon={showIcon}
|
||||
closable={closable}
|
||||
message={children || 'Default message'}
|
||||
message={children || t('Default message')}
|
||||
description={description}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -23,9 +23,9 @@ import '@fontsource/inter/200.css';
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/fira-code/400.css';
|
||||
import '@fontsource/fira-code/500.css';
|
||||
import '@fontsource/fira-code/600.css';
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
/* eslint-enable import/extensions */
|
||||
|
||||
import { css, useTheme, Global } from '@emotion/react';
|
||||
|
||||
@@ -502,7 +502,7 @@ test('Theme base theme integration works with real-world Superset base theme con
|
||||
colorSuccess: '#5ac189',
|
||||
colorInfo: '#66bcfe',
|
||||
fontFamily: "'Inter', Helvetica, Arial",
|
||||
fontFamilyCode: "'Fira Code', 'Courier New', monospace",
|
||||
fontFamilyCode: "'IBM Plex Mono', 'Courier New', monospace",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface SupersetSpecificTokens {
|
||||
fontWeightNormal: string;
|
||||
fontWeightLight: string;
|
||||
fontWeightStrong: number;
|
||||
fontWeightBold: string;
|
||||
|
||||
// Brand-related
|
||||
brandIconMaxWidth: number;
|
||||
|
||||
@@ -47,6 +47,15 @@ export function getFontSize(theme: SupersetTheme, size?: FontSizeKey): string {
|
||||
return String(theme[key] || theme.fontSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current theme is dark mode based on background color
|
||||
* @param theme - Theme tokens from useTheme()
|
||||
* @returns true if theme is dark, false if light
|
||||
*/
|
||||
export function isThemeDark(theme: SupersetTheme): boolean {
|
||||
return tinycolor(theme.colorBgContainer).isDark();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color variants for a given color type from theme tokens
|
||||
* @param theme - Theme tokens from useTheme()
|
||||
@@ -111,15 +120,6 @@ export function getColorVariants(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current theme is dark mode based on background color
|
||||
* @param theme - Theme tokens from useTheme()
|
||||
* @returns true if theme is dark, false if light
|
||||
*/
|
||||
export function isThemeDark(theme: SupersetTheme): boolean {
|
||||
return tinycolor(theme.colorBgContainer).isDark();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a theme configuration results in a dark theme
|
||||
* @param config - The theme configuration to check
|
||||
|
||||
@@ -17,4 +17,5 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { default as isBlank } from './isBlank';
|
||||
export { default as logging } from './logging';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 isBlank from './isBlank';
|
||||
|
||||
test('returns true for null', () => {
|
||||
expect(isBlank(null)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for undefined', () => {
|
||||
expect(isBlank(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for empty string', () => {
|
||||
expect(isBlank('')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for whitespace-only strings', () => {
|
||||
expect(isBlank(' ')).toBe(true);
|
||||
expect(isBlank(' ')).toBe(true);
|
||||
expect(isBlank('\t')).toBe(true);
|
||||
expect(isBlank('\n')).toBe(true);
|
||||
expect(isBlank(' \t\n ')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for non-empty strings', () => {
|
||||
expect(isBlank('hello')).toBe(false);
|
||||
expect(isBlank(' hello ')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for NaN', () => {
|
||||
expect(isBlank(NaN)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for numbers', () => {
|
||||
expect(isBlank(0)).toBe(false);
|
||||
expect(isBlank(50)).toBe(false);
|
||||
expect(isBlank(-1)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for booleans', () => {
|
||||
expect(isBlank(true)).toBe(false);
|
||||
expect(isBlank(false)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { isEmpty, isNaN, isNil, isString, trim } from 'lodash';
|
||||
|
||||
/**
|
||||
* Checks if a value is null, undefined, NaN, or a whitespace-only string.
|
||||
*/
|
||||
export default function isBlank(value: unknown): boolean {
|
||||
return (
|
||||
isNil(value) || isNaN(value) || (isString(value) && isEmpty(trim(value)))
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
const console = window.console || {};
|
||||
const console =
|
||||
typeof window !== 'undefined' ? window.console || {} : globalThis.console;
|
||||
const log = console.log || (() => {});
|
||||
|
||||
const logger = {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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 * as React from 'react';
|
||||
import {
|
||||
defineCommand,
|
||||
defineView,
|
||||
defineEditor,
|
||||
defineMenu,
|
||||
setExtensionContext,
|
||||
_clearContributionRegistry,
|
||||
} from '../src/extensions';
|
||||
|
||||
// Mock extension context for testing
|
||||
const mockContext = {
|
||||
registerCommand: jest.fn(() => jest.fn()),
|
||||
registerViewProvider: jest.fn(() => jest.fn()),
|
||||
registerEditor: jest.fn(() => jest.fn()),
|
||||
registerMenu: jest.fn(() => jest.fn()),
|
||||
};
|
||||
|
||||
describe('Extension Contributions', () => {
|
||||
beforeEach(() => {
|
||||
_clearContributionRegistry();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('defineCommand', () => {
|
||||
test('should create command contribution with metadata', () => {
|
||||
const command = defineCommand({
|
||||
id: 'test-command',
|
||||
title: 'Test Command',
|
||||
icon: 'TestIcon',
|
||||
execute: async () => console.log('executed'),
|
||||
});
|
||||
|
||||
expect(command.config.id).toBe('test-command');
|
||||
expect(command.config.title).toBe('Test Command');
|
||||
expect(command.config.icon).toBe('TestIcon');
|
||||
expect(command.__contributionMeta__).toBeDefined();
|
||||
expect(command.__contributionMeta__.type).toBe('command');
|
||||
expect(command.__contributionMeta__.id).toBe('test-command');
|
||||
});
|
||||
|
||||
test('should auto-register when context is available', () => {
|
||||
setExtensionContext(mockContext);
|
||||
|
||||
const command = defineCommand({
|
||||
id: 'auto-command',
|
||||
title: 'Auto Command',
|
||||
execute: async () => {},
|
||||
});
|
||||
|
||||
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
|
||||
});
|
||||
|
||||
test('should call lifecycle callbacks', () => {
|
||||
const onActivate = jest.fn();
|
||||
const onDeactivate = jest.fn();
|
||||
|
||||
setExtensionContext(mockContext);
|
||||
|
||||
const command = defineCommand({
|
||||
id: 'lifecycle-command',
|
||||
title: 'Lifecycle Command',
|
||||
execute: async () => {},
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
});
|
||||
|
||||
expect(onActivate).toHaveBeenCalled();
|
||||
|
||||
// Test disposal
|
||||
command.dispose();
|
||||
expect(onDeactivate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineView', () => {
|
||||
test('should create view contribution with metadata', () => {
|
||||
const TestComponent = () => React.createElement('div', null, 'Test');
|
||||
|
||||
const view = defineView({
|
||||
id: 'test-view',
|
||||
title: 'Test View',
|
||||
location: 'sqllab.panels',
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
expect(view.config.id).toBe('test-view');
|
||||
expect(view.config.title).toBe('Test View');
|
||||
expect(view.config.location).toBe('sqllab.panels');
|
||||
expect(view.config.component).toBe(TestComponent);
|
||||
expect(view.__contributionMeta__).toBeDefined();
|
||||
expect(view.__contributionMeta__.type).toBe('view');
|
||||
});
|
||||
|
||||
test('should auto-register when context is available', () => {
|
||||
const TestComponent = () => React.createElement('div', null, 'Test');
|
||||
|
||||
setExtensionContext(mockContext);
|
||||
|
||||
defineView({
|
||||
id: 'auto-view',
|
||||
title: 'Auto View',
|
||||
location: 'dashboard.tabs',
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
|
||||
'auto-view',
|
||||
TestComponent
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineEditor', () => {
|
||||
test('should create editor contribution with metadata', () => {
|
||||
const EditorComponent = () => React.createElement('textarea');
|
||||
|
||||
const editor = defineEditor({
|
||||
id: 'test-editor',
|
||||
name: 'Test Editor',
|
||||
mimeTypes: ['text/x-sql'],
|
||||
component: EditorComponent,
|
||||
});
|
||||
|
||||
expect(editor.config.id).toBe('test-editor');
|
||||
expect(editor.config.name).toBe('Test Editor');
|
||||
expect(editor.config.mimeTypes).toEqual(['text/x-sql']);
|
||||
expect(editor.__contributionMeta__).toBeDefined();
|
||||
expect(editor.__contributionMeta__.type).toBe('editor');
|
||||
});
|
||||
|
||||
test('should auto-register when context is available', () => {
|
||||
const EditorComponent = () => React.createElement('textarea');
|
||||
|
||||
setExtensionContext(mockContext);
|
||||
|
||||
const editor = defineEditor({
|
||||
id: 'auto-editor',
|
||||
name: 'Auto Editor',
|
||||
mimeTypes: ['text/plain'],
|
||||
component: EditorComponent,
|
||||
});
|
||||
|
||||
expect(mockContext.registerEditor).toHaveBeenCalledWith(editor.config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineMenu', () => {
|
||||
test('should create menu contribution with metadata', () => {
|
||||
const menu = defineMenu({
|
||||
id: 'test-menu',
|
||||
title: 'Test Menu',
|
||||
location: 'navbar.items',
|
||||
action: () => console.log('clicked'),
|
||||
});
|
||||
|
||||
expect(menu.config.id).toBe('test-menu');
|
||||
expect(menu.config.title).toBe('Test Menu');
|
||||
expect(menu.config.location).toBe('navbar.items');
|
||||
expect(menu.__contributionMeta__).toBeDefined();
|
||||
expect(menu.__contributionMeta__.type).toBe('menu');
|
||||
});
|
||||
|
||||
test('should auto-register when context is available', () => {
|
||||
setExtensionContext(mockContext);
|
||||
|
||||
const menu = defineMenu({
|
||||
id: 'auto-menu',
|
||||
title: 'Auto Menu',
|
||||
location: 'context.menus',
|
||||
action: () => {},
|
||||
});
|
||||
|
||||
expect(mockContext.registerMenu).toHaveBeenCalledWith(menu.config);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-registration system', () => {
|
||||
test('should queue contributions when no context is set', () => {
|
||||
const command = defineCommand({
|
||||
id: 'queued-command',
|
||||
title: 'Queued Command',
|
||||
execute: async () => {},
|
||||
});
|
||||
|
||||
// Should not be registered yet
|
||||
expect(mockContext.registerCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Set context - should register queued contributions
|
||||
setExtensionContext(mockContext);
|
||||
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
|
||||
});
|
||||
|
||||
test('should handle disposal correctly', () => {
|
||||
const mockDispose = jest.fn();
|
||||
mockContext.registerCommand.mockReturnValue(mockDispose);
|
||||
|
||||
setExtensionContext(mockContext);
|
||||
|
||||
const command = defineCommand({
|
||||
id: 'dispose-command',
|
||||
title: 'Dispose Command',
|
||||
execute: async () => {},
|
||||
});
|
||||
|
||||
// Dispose should call the returned cleanup function
|
||||
command.dispose();
|
||||
expect(mockDispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle mixed contribution types', () => {
|
||||
setExtensionContext(mockContext);
|
||||
|
||||
const command = defineCommand({
|
||||
id: 'mixed-command',
|
||||
title: 'Mixed Command',
|
||||
execute: async () => {},
|
||||
});
|
||||
|
||||
const view = defineView({
|
||||
id: 'mixed-view',
|
||||
title: 'Mixed View',
|
||||
location: 'explore.panels',
|
||||
component: () => React.createElement('div', null, 'Mixed'),
|
||||
});
|
||||
|
||||
expect(mockContext.registerCommand).toHaveBeenCalledWith(command.config);
|
||||
expect(mockContext.registerViewProvider).toHaveBeenCalledWith(
|
||||
'mixed-view',
|
||||
view.config.component
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,6 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@types/react": "*",
|
||||
"lodash": "^4.17.23"
|
||||
},
|
||||
|
||||
+72
-31
@@ -19,14 +19,20 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { t } from '@apache-superset/core';
|
||||
import { JsonValue } from '@superset-ui/core';
|
||||
import { Radio } from '@superset-ui/core/components';
|
||||
import { Radio, Tooltip, TooltipPlacement } from '@superset-ui/core/components';
|
||||
import { ControlHeader } from '../../components/ControlHeader';
|
||||
|
||||
// [value, label]
|
||||
export type RadioButtonOption = [
|
||||
JsonValue,
|
||||
Exclude<ReactNode, null | undefined | boolean>,
|
||||
];
|
||||
export interface RadioButtonOptionObject {
|
||||
value: JsonValue;
|
||||
label: Exclude<ReactNode, null | undefined | boolean>;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
tooltipPlacement?: TooltipPlacement;
|
||||
}
|
||||
|
||||
export type RadioButtonOption =
|
||||
| [JsonValue, Exclude<ReactNode, null | undefined | boolean>]
|
||||
| RadioButtonOptionObject;
|
||||
|
||||
export interface RadioButtonControlProps {
|
||||
label?: ReactNode;
|
||||
@@ -34,7 +40,17 @@ export interface RadioButtonControlProps {
|
||||
options: RadioButtonOption[];
|
||||
hovered?: boolean;
|
||||
value?: JsonValue;
|
||||
onChange: (opt: RadioButtonOption[0]) => void;
|
||||
onChange: (opt: JsonValue) => void;
|
||||
}
|
||||
|
||||
function normalizeOption(option: RadioButtonOption): RadioButtonOptionObject {
|
||||
if (Array.isArray(option)) {
|
||||
return {
|
||||
value: option[0],
|
||||
label: option[1],
|
||||
};
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
export default function RadioButtonControl({
|
||||
@@ -43,7 +59,9 @@ export default function RadioButtonControl({
|
||||
onChange,
|
||||
...props
|
||||
}: RadioButtonControlProps) {
|
||||
const currentValue = initialValue || options[0][0];
|
||||
const normalizedOptions = options.map(normalizeOption);
|
||||
const currentValue = initialValue || normalizedOptions[0].value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -55,29 +73,52 @@ export default function RadioButtonControl({
|
||||
value={currentValue}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
>
|
||||
{options.map(([val, label]) => (
|
||||
<Radio.Button
|
||||
// role="tab"
|
||||
key={JSON.stringify(val)}
|
||||
value={val}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
id={`tab-${val}`}
|
||||
type="button"
|
||||
aria-selected={val === currentValue}
|
||||
className={`btn btn-default ${
|
||||
val === currentValue ? 'active' : ''
|
||||
}`}
|
||||
onClick={e => {
|
||||
e.currentTarget?.focus();
|
||||
onChange(val);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Radio.Button>
|
||||
))}
|
||||
{normalizedOptions.map(
|
||||
({
|
||||
value: val,
|
||||
label,
|
||||
disabled = false,
|
||||
tooltip,
|
||||
tooltipPlacement = 'top',
|
||||
}) => {
|
||||
const button = (
|
||||
<Radio.Button
|
||||
key={JSON.stringify(val)}
|
||||
value={val}
|
||||
disabled={disabled}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
id={`tab-${val}`}
|
||||
type="button"
|
||||
aria-selected={val === currentValue}
|
||||
className={`btn btn-default ${
|
||||
val === currentValue ? 'active' : ''
|
||||
}`}
|
||||
onClick={e => {
|
||||
e.currentTarget?.focus();
|
||||
onChange(val);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Radio.Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={JSON.stringify(val)}
|
||||
title={tooltip}
|
||||
placement={tooltipPlacement}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
},
|
||||
)}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{/* accessibility begin */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{
|
||||
@@ -90,10 +131,10 @@ export default function RadioButtonControl({
|
||||
>
|
||||
{t(
|
||||
'%s tab selected',
|
||||
options.find(([val]) => val === currentValue)?.[1],
|
||||
normalizedOptions.find(({ value: val }) => val === currentValue)
|
||||
?.label,
|
||||
)}
|
||||
</div>
|
||||
{/* accessibility end */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+28
-5
@@ -62,18 +62,41 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
||||
// Dynamically add axis-specific controls (rows and columns)
|
||||
(['columns', 'rows'] as const).forEach(axisParam => {
|
||||
const axis: 'rows' | 'columns' = axisParam;
|
||||
const otherAxis: 'rows' | 'columns' = axis === 'rows' ? 'columns' : 'rows';
|
||||
|
||||
matrixifyControls[`matrixify_mode_${axis}`] = {
|
||||
type: 'RadioButtonControl',
|
||||
label: t(`Metrics / Dimensions`),
|
||||
default: 'metrics',
|
||||
options: [
|
||||
['metrics', t('Metrics')],
|
||||
['dimensions', t('Dimension members')],
|
||||
],
|
||||
default: axis === 'columns' ? 'metrics' : 'dimensions',
|
||||
renderTrigger: true,
|
||||
tabOverride: 'matrixify',
|
||||
visibility: ({ controls }) => isMatrixifyVisible(controls, axis),
|
||||
mapStateToProps: ({ controls }) => {
|
||||
const otherAxisControlName = `matrixify_mode_${otherAxis}`;
|
||||
|
||||
const otherAxisValue =
|
||||
controls?.[otherAxisControlName]?.value ??
|
||||
(otherAxis === 'columns' ? 'metrics' : 'dimensions');
|
||||
|
||||
const isMetricsDisabled = otherAxisValue === 'metrics';
|
||||
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
value: 'metrics',
|
||||
label: t('Metrics'),
|
||||
disabled: isMetricsDisabled,
|
||||
tooltip: isMetricsDisabled
|
||||
? t(
|
||||
"Metrics can't be used for both rows and columns at the same time",
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
{ value: 'dimensions', label: t('Dimension members') },
|
||||
],
|
||||
};
|
||||
},
|
||||
rerender: [`matrixify_mode_${otherAxis}`, `matrixify_dimension_${axis}`],
|
||||
};
|
||||
|
||||
matrixifyControls[`matrixify_${axis}`] = {
|
||||
|
||||
+2
-2
@@ -80,9 +80,9 @@ export const datePickerInAdhocFilterMixin: Pick<
|
||||
// 2) there was a time filter in adhoc filters
|
||||
if (
|
||||
state?.controls?.time_range?.value ||
|
||||
ensureIsArray(control.value).findIndex(
|
||||
ensureIsArray(control.value).some(
|
||||
(flt: any) => flt?.operator === 'TEMPORAL_RANGE',
|
||||
) > -1
|
||||
)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+6
-8
@@ -443,14 +443,12 @@ const order_by_cols: SharedControlConfig<'SelectControl'> = {
|
||||
default: [],
|
||||
shouldMapStateToProps: () => true,
|
||||
mapStateToProps: ({ datasource }) => ({
|
||||
choices: (datasource?.columns || [])
|
||||
.map(col =>
|
||||
[true, false].map(asc => [
|
||||
JSON.stringify([col.column_name, asc]),
|
||||
`${getColumnLabel(col.column_name)} [${asc ? 'asc' : 'desc'}]`,
|
||||
]),
|
||||
)
|
||||
.flat(),
|
||||
choices: (datasource?.columns || []).flatMap(col =>
|
||||
[true, false].map(asc => [
|
||||
JSON.stringify([col.column_name, asc]),
|
||||
`${getColumnLabel(col.column_name)} [${asc ? 'asc' : 'desc'}]`,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
resetOnHide: false,
|
||||
};
|
||||
|
||||
@@ -491,12 +491,16 @@ export type ConditionalFormattingConfig = {
|
||||
toAllRow?: boolean;
|
||||
toTextColor?: boolean;
|
||||
useGradient?: boolean;
|
||||
columnFormatting?: string;
|
||||
objectFormatting?: ObjectFormattingEnum;
|
||||
};
|
||||
|
||||
export type ColorFormatters = {
|
||||
column: string;
|
||||
toAllRow?: boolean;
|
||||
toTextColor?: boolean;
|
||||
columnFormatting?: string;
|
||||
objectFormatting?: ObjectFormattingEnum;
|
||||
getColorFromValue: (
|
||||
value: number | string | boolean | null,
|
||||
) => string | undefined;
|
||||
@@ -614,6 +618,13 @@ export type ControlFormItemSpec<T extends ControlType = ControlType> = {
|
||||
}
|
||||
: {});
|
||||
|
||||
export enum ObjectFormattingEnum {
|
||||
BACKGROUND_COLOR = 'BACKGROUND_COLOR',
|
||||
TEXT_COLOR = 'TEXT_COLOR',
|
||||
CELL_BAR = 'CELL_BAR',
|
||||
ENTIRE_ROW = 'ENTIRE_ROW',
|
||||
}
|
||||
|
||||
export enum ColorSchemeEnum {
|
||||
Green = 'Green',
|
||||
Red = 'Red',
|
||||
|
||||
+7
-8
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { isString, isBoolean } from 'lodash';
|
||||
import { isBlank } from '@apache-superset/core';
|
||||
import { addAlpha, DataRecord } from '@superset-ui/core';
|
||||
import {
|
||||
ColorFormatters,
|
||||
@@ -254,6 +256,9 @@ export const getColorFunction = (
|
||||
}
|
||||
|
||||
return (value: number | string | boolean | null) => {
|
||||
if (isBlank(value) && operator !== Comparator.IsNull) {
|
||||
return undefined;
|
||||
}
|
||||
const compareResult = comparatorFunction(value, columnValues);
|
||||
if (compareResult === false) return undefined;
|
||||
const { cutoffValue, extremeValue } = compareResult;
|
||||
@@ -306,6 +311,8 @@ export const getColorFormatters = memoizeOne(
|
||||
column: config?.column,
|
||||
toAllRow: config?.toAllRow,
|
||||
toTextColor: config?.toTextColor,
|
||||
columnFormatting: config?.columnFormatting,
|
||||
objectFormatting: config?.objectFormatting,
|
||||
getColorFromValue: getColorFunction(
|
||||
{ ...config, colorScheme: resolvedColorScheme },
|
||||
data.map(row => row[config.column!] as number),
|
||||
@@ -318,11 +325,3 @@ export const getColorFormatters = memoizeOne(
|
||||
[],
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
function isString(value: unknown) {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown) {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RadioButtonControl, {
|
||||
RadioButtonControlProps,
|
||||
RadioButtonOption,
|
||||
} from '../../../src/shared-controls/components/RadioButtonControl';
|
||||
|
||||
const defaultProps: RadioButtonControlProps = {
|
||||
label: 'Test Radio Control',
|
||||
options: [
|
||||
['option1', 'Option 1'],
|
||||
['option2', 'Option 2'],
|
||||
['option3', 'Option 3'],
|
||||
],
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = (props: Partial<RadioButtonControlProps> = {}) =>
|
||||
render(<RadioButtonControl {...defaultProps} {...props} />);
|
||||
|
||||
test('renders with array-based options (legacy format)', () => {
|
||||
const { container } = setup();
|
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with object-based options (new format)', () => {
|
||||
const objectOptions: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Object Option 1' },
|
||||
{ value: 'opt2', label: 'Object Option 2' },
|
||||
{ value: 'opt3', label: 'Object Option 3' },
|
||||
];
|
||||
|
||||
setup({ options: objectOptions });
|
||||
|
||||
expect(screen.getByText('Object Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Object Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Object Option 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders mixed array and object options', () => {
|
||||
const mixedOptions: RadioButtonOption[] = [
|
||||
['array1', 'Array Option'],
|
||||
{ value: 'obj1', label: 'Object Option' },
|
||||
];
|
||||
|
||||
setup({ options: mixedOptions });
|
||||
|
||||
expect(screen.getByText('Array Option')).toBeInTheDocument();
|
||||
expect(screen.getByText('Object Option')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('defaults to first option when no value provided', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const firstButton = container.querySelector('#tab-option1');
|
||||
expect(firstButton).toBeInTheDocument();
|
||||
expect(firstButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('respects initial value prop', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
const secondButton = container.querySelector('#tab-option2');
|
||||
expect(secondButton).toBeInTheDocument();
|
||||
expect(secondButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('calls onChange when radio button is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
setup({ onChange });
|
||||
|
||||
const secondOption = screen.getByText('Option 2');
|
||||
fireEvent.click(secondOption);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('option2');
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles multiple clicks correctly', () => {
|
||||
const onChange = jest.fn();
|
||||
setup({ onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('Option 2'));
|
||||
fireEvent.click(screen.getByText('Option 3'));
|
||||
fireEvent.click(screen.getByText('Option 1'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('option2');
|
||||
expect(onChange).toHaveBeenCalledWith('option3');
|
||||
expect(onChange).toHaveBeenCalledWith('option1');
|
||||
expect(onChange.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('disables specific options when disabled flag is set', () => {
|
||||
const optionsWithDisabled: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Enabled Option' },
|
||||
{ value: 'opt2', label: 'Disabled Option', disabled: true },
|
||||
{ value: 'opt3', label: 'Another Enabled' },
|
||||
];
|
||||
|
||||
const { container } = setup({ options: optionsWithDisabled });
|
||||
|
||||
const disabledButton = container.querySelector('#tab-opt2');
|
||||
const enabledButton = container.querySelector('#tab-opt1');
|
||||
|
||||
expect(disabledButton).toHaveAttribute('disabled');
|
||||
expect(enabledButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
test('disabled options do not trigger onChange when clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
const optionsWithDisabled: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Enabled' },
|
||||
{ value: 'opt2', label: 'Disabled', disabled: true },
|
||||
];
|
||||
|
||||
const { container } = setup({ options: optionsWithDisabled, onChange });
|
||||
|
||||
const disabledButton = container.querySelector('#tab-opt2');
|
||||
if (disabledButton) {
|
||||
fireEvent.click(disabledButton);
|
||||
}
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders ControlHeader with label and description', () => {
|
||||
const { container } = setup({
|
||||
label: 'My Radio Control',
|
||||
description: 'This is a helpful description',
|
||||
});
|
||||
|
||||
const header = container.querySelector('.ControlHeader');
|
||||
expect(header).toBeInTheDocument();
|
||||
expect(screen.getByText('My Radio Control')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('aria-live region updates with current selection', () => {
|
||||
const { container } = setup({ value: 'option1' });
|
||||
|
||||
const ariaLiveRegion = container.querySelector('[aria-live="polite"]');
|
||||
expect(ariaLiveRegion).toBeInTheDocument();
|
||||
expect(ariaLiveRegion?.textContent).toContain('Option 1');
|
||||
});
|
||||
|
||||
test('aria-live region updates when selection changes', () => {
|
||||
const { container, rerender } = setup({ value: 'option1' });
|
||||
|
||||
let ariaLiveRegion = container.querySelector('[aria-live="polite"]');
|
||||
expect(ariaLiveRegion?.textContent).toContain('Option 1');
|
||||
|
||||
rerender(<RadioButtonControl {...defaultProps} value="option2" />);
|
||||
|
||||
ariaLiveRegion = container.querySelector('[aria-live="polite"]');
|
||||
expect(ariaLiveRegion?.textContent).toContain('Option 2');
|
||||
});
|
||||
|
||||
test('aria-live region is visually hidden but accessible', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const ariaLiveRegion = container.querySelector(
|
||||
'[aria-live="polite"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(ariaLiveRegion).toBeInTheDocument();
|
||||
expect(ariaLiveRegion?.style.position).toBe('absolute');
|
||||
expect(ariaLiveRegion?.style.left).toBe('-9999px');
|
||||
expect(ariaLiveRegion?.style.height).toBe('1px');
|
||||
expect(ariaLiveRegion?.style.width).toBe('1px');
|
||||
expect(ariaLiveRegion?.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
test('renders tablist with correct aria-label when label is string', () => {
|
||||
const { container } = setup({ label: 'String Label' });
|
||||
|
||||
const tablist = container.querySelector('[role="tablist"]');
|
||||
expect(tablist).toHaveAttribute('aria-label', 'String Label');
|
||||
});
|
||||
|
||||
test('tablist has no aria-label when label is not string', () => {
|
||||
const { container } = setup({ label: <div>JSX Label</div> });
|
||||
|
||||
const tablist = container.querySelector('[role="tablist"]');
|
||||
expect(tablist).not.toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
test('each radio button has correct aria-selected state', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
expect(container.querySelector('#tab-option1')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
expect(container.querySelector('#tab-option2')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
expect(container.querySelector('#tab-option3')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('radio buttons have correct aria-label when label is string', () => {
|
||||
setup();
|
||||
|
||||
const option1Button = screen.getByLabelText('Option 1');
|
||||
expect(option1Button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('focuses button when clicked', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const button = container.querySelector('#tab-option2') as HTMLElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(document.activeElement).toBe(button);
|
||||
});
|
||||
|
||||
test('handles numeric values in options', () => {
|
||||
const onChange = jest.fn();
|
||||
const numericOptions: RadioButtonOption[] = [
|
||||
[1, 'One'],
|
||||
[2, 'Two'],
|
||||
[3, 'Three'],
|
||||
];
|
||||
|
||||
setup({ options: numericOptions, onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('Two'));
|
||||
expect(onChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
test('handles boolean values in options', () => {
|
||||
const onChange = jest.fn();
|
||||
const booleanOptions: RadioButtonOption[] = [
|
||||
[true, 'True'],
|
||||
[false, 'False'],
|
||||
];
|
||||
|
||||
setup({ options: booleanOptions, onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('False'));
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('handles null values in options', () => {
|
||||
const onChange = jest.fn();
|
||||
const nullOptions: RadioButtonOption[] = [
|
||||
[null, 'None'],
|
||||
['value', 'Value'],
|
||||
];
|
||||
|
||||
setup({ options: nullOptions, onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('None'));
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('generates unique IDs for options', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const button1 = container.querySelector('#tab-option1');
|
||||
const button2 = container.querySelector('#tab-option2');
|
||||
const button3 = container.querySelector('#tab-option3');
|
||||
|
||||
expect(button1).toBeInTheDocument();
|
||||
expect(button2).toBeInTheDocument();
|
||||
expect(button3).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies active class to selected button', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
const activeButton = container.querySelector('#tab-option2');
|
||||
expect(activeButton).toBeInTheDocument();
|
||||
expect(activeButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('does not set aria-selected to true for unselected buttons', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
const inactiveButton1 = container.querySelector('#tab-option1');
|
||||
const inactiveButton3 = container.querySelector('#tab-option3');
|
||||
|
||||
expect(inactiveButton1).toHaveAttribute('aria-selected', 'false');
|
||||
expect(inactiveButton3).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
test('backward compatibility with legacy array format', () => {
|
||||
const onChange = jest.fn();
|
||||
const legacyOptions: RadioButtonOption[] = [
|
||||
['val1', 'Label 1'],
|
||||
['val2', 'Label 2'],
|
||||
];
|
||||
|
||||
setup({ options: legacyOptions, onChange });
|
||||
|
||||
expect(screen.getByText('Label 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Label 2')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Label 2'));
|
||||
expect(onChange).toHaveBeenCalledWith('val2');
|
||||
});
|
||||
|
||||
test('normalizeOption handles array format correctly', () => {
|
||||
const arrayOption: RadioButtonOption = ['value', 'Label'];
|
||||
const onChange = jest.fn();
|
||||
|
||||
setup({ options: [arrayOption], onChange });
|
||||
|
||||
expect(screen.getByText('Label')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Label'));
|
||||
expect(onChange).toHaveBeenCalledWith('value');
|
||||
});
|
||||
|
||||
test('normalizeOption handles object format correctly', () => {
|
||||
const objectOption: RadioButtonOption = {
|
||||
value: 'value',
|
||||
label: 'Label',
|
||||
disabled: false,
|
||||
};
|
||||
const onChange = jest.fn();
|
||||
|
||||
setup({ options: [objectOption], onChange });
|
||||
|
||||
expect(screen.getByText('Label')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Label'));
|
||||
expect(onChange).toHaveBeenCalledWith('value');
|
||||
});
|
||||
|
||||
test('handles empty options array gracefully', () => {
|
||||
const { container } = setup({ options: [], value: 'default' });
|
||||
|
||||
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with hovered prop', () => {
|
||||
const { container } = setup({
|
||||
label: 'Test',
|
||||
description: 'Test description',
|
||||
hovered: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-test="info-tooltip-icon"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders tooltips for options with tooltip property', async () => {
|
||||
const optionsWithTooltips: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Option 1', tooltip: 'Tooltip for option 1' },
|
||||
{ value: 'opt2', label: 'Option 2' },
|
||||
{ value: 'opt3', label: 'Option 3', tooltip: 'Tooltip for option 3' },
|
||||
];
|
||||
|
||||
setup({ options: optionsWithTooltips });
|
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
|
||||
const option1 = screen.getByText('Option 1');
|
||||
userEvent.hover(option1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tooltip for option 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.unhover(option1);
|
||||
|
||||
const option3 = screen.getByText('Option 3');
|
||||
userEvent.hover(option3);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tooltip for option 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('wraps disabled buttons with tooltip in span', () => {
|
||||
const optionsWithDisabledTooltip: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Enabled with tooltip', tooltip: 'Tooltip text' },
|
||||
{
|
||||
value: 'opt2',
|
||||
label: 'Disabled with tooltip',
|
||||
disabled: true,
|
||||
tooltip: 'Disabled tooltip',
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = setup({ options: optionsWithDisabledTooltip });
|
||||
|
||||
const disabledButton = container.querySelector('#tab-opt2');
|
||||
expect(disabledButton).toHaveAttribute('disabled');
|
||||
expect(disabledButton?.parentElement?.tagName).toBe('SPAN');
|
||||
});
|
||||
+111
@@ -506,6 +506,117 @@ test('getColorFunction IsNotNull', () => {
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on numeric comparators', () => {
|
||||
const operators = [
|
||||
{ operator: Comparator.LessThan, targetValue: 50 },
|
||||
{ operator: Comparator.LessOrEqual, targetValue: 50 },
|
||||
{ operator: Comparator.GreaterThan, targetValue: 50 },
|
||||
{ operator: Comparator.GreaterOrEqual, targetValue: 50 },
|
||||
{ operator: Comparator.Equal, targetValue: 50 },
|
||||
{ operator: Comparator.NotEqual, targetValue: 50 },
|
||||
];
|
||||
operators.forEach(({ operator, targetValue }) => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator,
|
||||
targetValue,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on Between comparators', () => {
|
||||
const operators = [
|
||||
Comparator.Between,
|
||||
Comparator.BetweenOrEqual,
|
||||
Comparator.BetweenOrLeftEqual,
|
||||
Comparator.BetweenOrRightEqual,
|
||||
];
|
||||
operators.forEach(operator => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator,
|
||||
targetValueLeft: -10,
|
||||
targetValueRight: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on None operator', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on string comparators', () => {
|
||||
const operators = [
|
||||
Comparator.BeginsWith,
|
||||
Comparator.EndsWith,
|
||||
Comparator.Containing,
|
||||
Comparator.NotContaining,
|
||||
];
|
||||
operators.forEach(operator => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator,
|
||||
targetValue: 'test',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for empty and whitespace string values', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction('' as unknown as number)).toBeUndefined();
|
||||
expect(colorFunction(' ' as unknown as number)).toBeUndefined();
|
||||
expect(colorFunction('\t' as unknown as number)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction IsNull still matches null values', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.IsNull,
|
||||
targetValue: '',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'isMember',
|
||||
},
|
||||
boolValues,
|
||||
);
|
||||
expect(colorFunction(null)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(true)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('correct column config', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { t } from '@apache-superset/core';
|
||||
import { styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { MatrixifyGridCell as GridCellData } from '../../types/matrixify';
|
||||
import StatefulChart from '../StatefulChart';
|
||||
@@ -84,7 +85,7 @@ interface MatrixifyGridCellProps {
|
||||
// Simple No Data component for matrix cells
|
||||
const MatrixNoDataComponent = () => {
|
||||
const theme = useTheme();
|
||||
return <NoDataMessage theme={theme}>No data</NoDataMessage>;
|
||||
return <NoDataMessage theme={theme}>{t('No data')}</NoDataMessage>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { t } from '@apache-superset/core';
|
||||
import {
|
||||
QueryFormData,
|
||||
QueryData,
|
||||
@@ -440,7 +441,7 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Error: {error.message}
|
||||
{t('Error')}: {error.message}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
+15
@@ -42,6 +42,21 @@ test('renders SQLEditor', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SQLEditor uses fontFamilyCode from theme', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
const fontFamily = editorInstance?.getOption('fontFamily');
|
||||
// Verify font family is set (not undefined) and contains a monospace font
|
||||
expect(fontFamily).toBeDefined();
|
||||
expect(fontFamily).toMatch(/mono|courier|consolas/i);
|
||||
});
|
||||
|
||||
test('renders FullSQLEditor', async () => {
|
||||
const { container } = render(<FullSQLEditor />);
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AsyncAceEditor(
|
||||
defaultMode,
|
||||
defaultTheme,
|
||||
defaultTabSize = 2,
|
||||
fontFamily = 'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
|
||||
fontFamily,
|
||||
placeholder,
|
||||
}: AsyncAceEditorOptions = {},
|
||||
) {
|
||||
@@ -171,6 +171,7 @@ export function AsyncAceEditor(
|
||||
ref,
|
||||
) {
|
||||
const token = useTheme();
|
||||
const editorFontFamily = fontFamily || token.fontFamilyCode;
|
||||
const langTools = acequire('ace/ext/language_tools');
|
||||
|
||||
const setCompleters = useCallback(
|
||||
@@ -436,7 +437,7 @@ export function AsyncAceEditor(
|
||||
theme={theme}
|
||||
tabSize={tabSize}
|
||||
defaultValue={defaultValue}
|
||||
setOptions={{ fontFamily }}
|
||||
setOptions={{ fontFamily: editorFontFamily }}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user