Compare commits

..

3 Commits

Author SHA1 Message Date
Beto Dealmeida
37094dc404 feat(semantic-layer): integrate semantic views with explore API
Update views and APIs to support semantic layer explorables:
- Add UUID support for datasource IDs in views and utils
- Update explore command to handle semantic views
- Extend datasource DAO to resolve semantic view UUIDs
- Update explore API parameters for semantic layer support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:59:18 -05:00
Beto Dealmeida
e00dab2cbc feat(semantic-layer): add core semantic layer infrastructure
Add the foundational semantic layer implementation:
- SemanticLayer and SemanticView SQLAlchemy models
- Semantic layer registry for plugin-based implementations
- Query mapper for translating Superset queries to semantic layer format
- Type definitions for metrics, dimensions, entities, and grains
- DAO layer for semantic layer CRUD operations
- Database migration for semantic_layers and semantic_views tables
- Updated Explorable base class with ColumnMetadata protocol
- TypedDict updates for API response compatibility
- Update sql_lab and sqla models for new TypedDict fields

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:59:12 -05:00
Beto Dealmeida
91d018c52d feat(extensions): improve extension loading for backend modules
- Add load_extension_backend() to install in-memory modules and import entry points
- Entry points are module names that self-register on import
- Add volume mounts in docker-compose-light.yml for extensions development:
  - superset-core for local Pydantic models
  - extensions directory for .supx bundles
- Add EXTENSIONS_PATH config support
- Simplify init_extensions() to delegate to get_extensions()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 14:59:23 -05:00
435 changed files with 7645 additions and 35576 deletions

2
.github/CODEOWNERS vendored
View File

@@ -20,7 +20,7 @@
# Notify PMC members of changes to GitHub Actions
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe
# Notify PMC members of changes to required GitHub Actions

View File

@@ -33,7 +33,7 @@ jobs:
pull-requests: write
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

@@ -189,7 +189,7 @@ jobs:
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -225,7 +225,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
uses: aws-actions/configure-aws-credentials@v5
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

View File

@@ -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@v12
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@v12
continue-on-error: true
with:
workflow: superset-python-integrationtest.yml

View File

@@ -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@v12
with:
workflow: superset-python-integrationtest.yml
run_id: ${{ github.event.workflow_run.id }}

View File

@@ -52,7 +52,6 @@ jobs:
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@v5
with:

View File

@@ -27,7 +27,6 @@ repos:
args: [--check-untyped-defs]
exclude: ^superset-extensions-cli/
additional_dependencies: [
types-cachetools,
types-simplejson,
types-python-dateutil,
types-requests,

View File

@@ -430,11 +430,6 @@ categories:
url: https://brandct.cn/
contributors: ["@wenbinye"]
- name: XNET
url: https://xnetmobile.com/
logo: xnet.png
contributors: ["@deuspt"]
- name: Zeta
url: https://www.zeta.tech/
contributors: ["@shaikidris"]

View File

@@ -24,21 +24,6 @@ assists people when migrating to a new version.
## Next
### WebSocket config for GAQ with Docker
[35896](https://github.com/apache/superset/pull/35896) and [37624](https://github.com/apache/superset/pull/37624) updated documentation on how to run and configure Superset with Docker. Specifically for the WebSocket configuration, a new `docker/superset-websocket/config.example.json` was added to the repo, so that users could copy it to create a `docker/superset-websocket/config.json` file. The existing `docker/superset-websocket/config.json` was removed and git-ignored, so if you're using GAQ / WebSocket make sure to:
- Stash/backup your existing `config.json` file, to re-apply it after (will get git-ignored going forward)
- Update the `volumes` configuration for the `superset-websocket` service in your `docker-compose.override.yml` file, to include the `docker/superset-websocket/config.json` file. For example:
``` yaml
services:
superset-websocket:
volumes:
- ./superset-websocket:/home/superset-websocket
- /home/superset-websocket/node_modules
- /home/superset-websocket/dist
- ./docker/superset-websocket/config.json:/home/superset-websocket/config.json:ro
```
### Example Data Loading Improvements
#### New Directory Structure

View File

@@ -64,9 +64,11 @@ x-superset-volumes: &superset-volumes
# /app/pythonpath_docker will be appended to the PYTHONPATH in the final container
- ./docker:/app/docker
- ./superset:/app/superset
- ./superset-core:/app/superset-core
- ./superset-frontend:/app/superset-frontend
- superset_home_light:/app/superset_home
- ./tests:/app/tests
- ./extensions:/app/extensions
x-common-build: &common-build
context: .
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
@@ -159,8 +161,8 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
# Webpack dev server configuration
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces

View File

@@ -175,7 +175,7 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset:8088"
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
# Bind to all interfaces so Docker port mapping works
WEBPACK_DEVSERVER_HOST: "0.0.0.0"
ports:
- "127.0.0.1:${NODE_PORT:-9000}:9000" # exposing the dynamic webpack dev server

View File

@@ -28,11 +28,11 @@ if [ "$BUILD_SUPERSET_FRONTEND_IN_DOCKER" = "true" ]; then
cd /app/superset-frontend
if [ "$NPM_RUN_PRUNE" = "true" ]; then
echo "Running \"npm run prune\""
echo "Running `npm run prune`"
npm run prune
fi
echo "Running \"npm install\""
echo "Running `npm install`"
npm install
echo "Start webpack dev server"

View File

@@ -105,12 +105,15 @@ class CeleryConfig:
CELERY_CONFIG = CeleryConfig
# Extensions configuration
# For local development, point to the extensions directory
# Note: If running in Docker, this path needs to be accessible from inside the container
EXTENSIONS_PATH = os.getenv("EXTENSIONS_PATH", "/app/extensions")
FEATURE_FLAGS = {
"ALERT_REPORTS": True,
"DATASET_FOLDERS": True,
"ENABLE_EXTENSIONS": True,
}
EXTENSIONS_PATH = "/app/docker/extensions"
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
# The base URL for the email report hyperlinks.

View File

@@ -0,0 +1,22 @@
{
"port": 8080,
"logLevel": "info",
"logToFile": false,
"logFilename": "app.log",
"statsd": {
"host": "127.0.0.1",
"port": 8125,
"globalTags": []
},
"redis": {
"port": 6379,
"host": "127.0.0.1",
"password": "",
"db": 0,
"ssl": false
},
"redisStreamPrefix": "async-events-",
"jwtAlgorithms": ["HS256"],
"jwtSecret": "CHANGE-ME-IN-PRODUCTION-GOTTA-BE-LONG-AND-SECRET",
"jwtCookieName": "async-token"
}

View File

@@ -171,11 +171,9 @@ const config: Config = {
url: 'https://superset.apache.org',
baseUrl: '/',
onBrokenLinks: 'warn',
onBrokenMarkdownLinks: 'throw',
markdown: {
mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'throw',
},
},
favicon: '/img/favicon.ico',
organizationName: 'apache',
@@ -188,6 +186,14 @@ const config: Config = {
],
plugins: [
require.resolve('./src/webpack.extend.ts'),
[
'docusaurus-plugin-less',
{
lessOptions: {
javascriptEnabled: true,
},
},
],
...dynamicPlugins,
[
'docusaurus-plugin-openapi-docs',

View File

@@ -35,7 +35,7 @@
# Yarn version
YARN_VERSION = "1.22.22"
# Increase heap size for webpack bundling of Superset UI components
NODE_OPTIONS = "--max-old-space-size=8192"
NODE_OPTIONS = "--max-old-space-size=4096"
# Deploy preview settings
[context.deploy-preview]

View File

@@ -6,10 +6,9 @@
"scripts": {
"docusaurus": "docusaurus",
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
"start": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start",
"start:quick": "yarn run _init && NODE_OPTIONS='--max-old-space-size=8192' NODE_ENV=development docusaurus start",
"start": "yarn run _init && yarn run generate:all && NODE_ENV=development docusaurus start",
"stop": "pkill -f 'docusaurus start' || pkill -f 'docusaurus serve' || echo 'No docusaurus server running'",
"build": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' DEBUG=docusaurus:* docusaurus build",
"build": "yarn run _init && yarn run generate:all && DEBUG=docusaurus:* docusaurus build",
"generate:api-docs": "python3 scripts/fix-openapi-spec.py && docusaurus gen-api-docs superset && node scripts/convert-api-sidebar.mjs && node scripts/generate-api-index.mjs && node scripts/generate-api-tag-pages.mjs",
"clean:api-docs": "docusaurus clean-api-docs superset",
"swizzle": "docusaurus swizzle",
@@ -23,7 +22,7 @@
"generate:superset-components": "node scripts/generate-superset-components.mjs",
"generate:database-docs": "node scripts/generate-database-docs.mjs",
"gen-db-docs": "node scripts/generate-database-docs.mjs",
"generate:all": "yarn run generate:extension-components & yarn run generate:superset-components & yarn run generate:database-docs & wait && yarn run generate:api-docs",
"generate:all": "yarn run generate:extension-components && yarn run generate:superset-components && yarn run generate:database-docs && yarn run generate:api-docs",
"lint:db-metadata": "python3 ../superset/db_engine_specs/lint_metadata.py",
"lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md",
"update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme",
@@ -39,11 +38,15 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@babel/core": "^7.26.0",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@docusaurus/core": "3.9.2",
"@docusaurus/plugin-client-redirects": "3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@docusaurus/theme-live-codeblock": "^3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/core": "^11.0.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.14.1",
@@ -52,39 +55,41 @@
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^8.6.15",
"@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/blocks": "^8.6.11",
"@storybook/channels": "^8.6.11",
"@storybook/client-logger": "^8.6.11",
"@storybook/components": "^8.6.11",
"@storybook/core": "^8.6.11",
"@storybook/core-events": "^8.6.11",
"@storybook/csf": "^0.1.13",
"@storybook/docs-tools": "^8.6.15",
"@storybook/preview-api": "^8.6.15",
"@storybook/theming": "^8.6.15",
"@storybook/docs-tools": "^8.6.11",
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.11",
"antd": "^6.2.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001769",
"antd": "^6.2.2",
"babel-loader": "^9.2.1",
"caniuse-lite": "^1.0.30001766",
"docusaurus-plugin-less": "^2.0.2",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
"js-yaml": "^4.1.1",
"js-yaml-loader": "^1.2.2",
"json-bigint": "^1.0.0",
"less": "^4.5.1",
"less-loader": "^12.3.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-github-btn": "^1.4.0",
"react-resize-detector": "^9.1.1",
"react-resize-detector": "7.1.2",
"react-svg-pan-zoom": "^3.13.1",
"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",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"ts-loader": "^9.5.4",
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
@@ -99,11 +104,11 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.3.0",
"globals": "^17.2.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.54.0",
"webpack": "^5.105.0"
"webpack": "^5.104.1"
},
"browserslist": {
"production": [
@@ -119,8 +124,7 @@
},
"resolutions": {
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.5.0",
"baseline-browser-mapping": "^2.9.19"
"@reduxjs/toolkit": "^2.5.0"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}

View File

@@ -1,16 +1,16 @@
{
"generated": "2026-01-31T10:47:01.730Z",
"generated": "2026-01-27T23:17:43.310Z",
"statistics": {
"totalDatabases": 70,
"withDocumentation": 70,
"withConnectionString": 70,
"withDrivers": 36,
"totalDatabases": 68,
"withDocumentation": 68,
"withConnectionString": 68,
"withDrivers": 35,
"withAuthMethods": 4,
"supportsJoins": 66,
"supportsSubqueries": 67,
"supportsJoins": 64,
"supportsSubqueries": 65,
"supportsDynamicSchema": 15,
"supportsCatalog": 9,
"averageScore": 32,
"averageScore": 33,
"maxScore": 201,
"byCategory": {
"Other Databases": [
@@ -109,8 +109,6 @@
"Traditional RDBMS": [
"Aurora MySQL (Data API)",
"Aurora PostgreSQL (Data API)",
"Aurora MySQL",
"Aurora PostgreSQL",
"CockroachDB",
"Cloudflare D1",
"IBM Db2",
@@ -135,8 +133,6 @@
"Open Source": [
"Aurora MySQL (Data API)",
"Aurora PostgreSQL (Data API)",
"Aurora MySQL",
"Aurora PostgreSQL",
"ClickHouse",
"CockroachDB",
"Couchbase",
@@ -494,132 +490,6 @@
"query_cost_estimation": false,
"sql_validation": false
},
"Aurora MySQL": {
"engine": "aurora_mysql",
"engine_name": "Aurora MySQL",
"module": "aurora",
"documentation": {
"description": "MySQL is a popular open-source relational database.",
"logo": "mysql.png",
"homepage_url": "https://www.mysql.com/",
"categories": [
"TRADITIONAL_RDBMS",
"OPEN_SOURCE"
],
"pypi_packages": [
"mysqlclient"
],
"connection_string": "mysql://{username}:{password}@{host}/{database}",
"default_port": 3306,
"parameters": {
"username": "Database username",
"password": "Database password",
"host": "localhost, 127.0.0.1, IP address, or hostname",
"database": "Database name"
},
"host_examples": [
{
"platform": "Localhost",
"host": "localhost or 127.0.0.1"
},
{
"platform": "Docker on Linux",
"host": "172.18.0.1"
},
{
"platform": "Docker on macOS",
"host": "docker.for.mac.host.internal"
},
{
"platform": "On-premise",
"host": "IP address or hostname"
}
],
"drivers": [
{
"name": "mysqlclient",
"pypi_package": "mysqlclient",
"connection_string": "mysql://{username}:{password}@{host}/{database}",
"is_recommended": true,
"notes": "Recommended driver. May fail with caching_sha2_password auth."
},
{
"name": "mysql-connector-python",
"pypi_package": "mysql-connector-python",
"connection_string": "mysql+mysqlconnector://{username}:{password}@{host}/{database}",
"is_recommended": false,
"notes": "Required for newer MySQL databases using caching_sha2_password authentication."
}
]
},
"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
},
"Aurora PostgreSQL": {
"engine": "aurora_postgresql",
"engine_name": "Aurora PostgreSQL",
"module": "aurora",
"documentation": {
"description": "PostgreSQL is an advanced open-source relational database.",
"logo": "postgresql.svg",
"homepage_url": "https://www.postgresql.org/",
"categories": [
"TRADITIONAL_RDBMS",
"OPEN_SOURCE"
],
"pypi_packages": [
"psycopg2"
],
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
"default_port": 5432,
"parameters": {
"username": "Database username",
"password": "Database password",
"host": "For localhost: localhost or 127.0.0.1. For AWS: endpoint URL",
"port": "Default 5432",
"database": "Database name"
},
"notes": "The psycopg2 library comes bundled with Superset Docker images.",
"connection_examples": [
{
"description": "Basic connection",
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}"
},
{
"description": "With SSL required",
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}?sslmode=require"
}
],
"docs_url": "https://www.postgresql.org/docs/",
"sqlalchemy_docs_url": "https://docs.sqlalchemy.org/en/13/dialects/postgresql.html"
},
"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
},
"Google BigQuery": {
"engine": "google_bigquery",
"engine_name": "Google BigQuery",

View File

@@ -28,7 +28,7 @@ import databaseData from '../data/databases.json';
import BlurredSection from '../components/BlurredSection';
import DataSet from '../../../RESOURCES/INTHEWILD.yaml';
import type { DatabaseData } from '../components/databases/types';
import '../styles/main.css';
import '../styles/main.less';
// Build database list from databases.json (databases with logos)
// Deduplicate by logo filename to avoid showing the same logo twice
@@ -795,7 +795,7 @@ export default function Home(): JSX.Element {
</StyledIntegrations>
</BlurredSection>
{/* Only show carousel when we have enough logos (>10) for a good display */}
{companiesWithLogos.length > 7 && (
{companiesWithLogos.length > 10 && (
<BlurredSection>
<div style={{ padding: '0 20px' }}>
<SectionHeader

View File

@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
export { FoldersToolbarComponent } from './FoldersToolbarComponent';
export { ResetConfirmModal } from './ResetConfirmModal';
export { DragOverlayContent } from './DragOverlayContent';
@primary-color: #20a7c9;
@info-color: #66bcfe;
@success-color: #59c189;
@processing-color: #66bcfe;
@error-color: #e04355;
@highlight-color: #e04355;
@normal-color: #d9d9d9;
@white: #FFF;
@black: #000;

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
@import 'antd-theme.less';
body {
font-family: var(--ifm-font-family-base);
@@ -80,29 +81,26 @@ a > span > svg {
text-align: center;
position: relative;
z-index: 2;
}
.default-button-theme::before {
border-radius: inherit;
background: linear-gradient(180deg, #11b0d8 0%, #116f86 100%);
content: '';
display: block;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
width: 100%;
z-index: -1;
transition: all 0.3s;
}
.default-button-theme:hover {
color: #ffffff;
}
.default-button-theme:hover::before {
opacity: 1;
&::before {
border-radius: inherit;
background: linear-gradient(180deg, #11b0d8 0%, #116f86 100%);
content: '';
display: block;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
width: 100%;
z-index: -1;
transition: all 0.3s;
}
&:hover {
color: #ffffff;
&::before {
opacity: 1;
}
}
}
/* Navbar */
@@ -111,32 +109,32 @@ a > span > svg {
font-size: 14px;
font-weight: 400;
transition: all 0.5s;
}
.navbar .get-started-button {
border-radius: 10px;
font-size: 18px;
font-weight: bold;
width: 142px;
padding: 7px 0;
margin-right: 20px;
}
.get-started-button {
border-radius: 10px;
font-size: 18px;
font-weight: bold;
width: 142px;
padding: 7px 0;
margin-right: 20px;
}
.navbar .github-button {
background-image: url('/img/github.png');
background-size: contain;
width: 30px;
height: 30px;
margin-right: 10px;
.github-button {
background-image: url('/img/github.png');
background-size: contain;
width: 30px;
height: 30px;
margin-right: 10px;
}
}
.navbar--dark {
background-color: transparent;
border-bottom: 1px solid rgba(24, 115, 132, 0.4);
}
.navbar--dark .github-button {
background-image: url('/img/github-dark.png');
.github-button {
background-image: url('/img/github-dark.png');
}
}
.navbar__logo {
@@ -155,11 +153,11 @@ a > span > svg {
.navbar {
padding-right: 8px;
padding-left: 8px;
}
.navbar .get-started-button,
.navbar .github-button {
display: none;
.get-started-button,
.github-button {
display: none;
}
}
.navbar__items {
@@ -188,20 +186,20 @@ a > span > svg {
--docsearch-searchbox-background: var(--ifm-navbar-background-color);
border: 1px solid #187384;
border-radius: 10px;
}
.navbar .DocSearch.DocSearch-Button {
width: 225px;
}
&.DocSearch-Button {
width: 225px;
}
.navbar .DocSearch .DocSearch-Search-Icon {
width: 16px;
height: 16px;
}
.DocSearch-Search-Icon {
width: 16px;
height: 16px;
}
.navbar .DocSearch .DocSearch-Button-Key,
.navbar .DocSearch .DocSearch-Button-Placeholder {
display: none;
.DocSearch-Button-Key,
.DocSearch-Button-Placeholder {
display: none;
}
}
.navbar--dark .DocSearch {
@@ -234,25 +232,25 @@ a > span > svg {
align-items: center;
justify-content: center;
gap: 16px;
}
.footer__ci-services span {
font-size: 13px;
opacity: 0.85;
}
span {
font-size: 13px;
opacity: 0.85;
}
.footer__ci-services a {
display: inline-flex;
align-items: center;
transition: opacity 0.2s;
}
a {
display: inline-flex;
align-items: center;
transition: opacity 0.2s;
.footer__ci-services a:hover {
opacity: 0.8;
}
&:hover {
opacity: 0.8;
}
}
.footer__ci-services img {
height: 28px;
img {
height: 28px;
}
}
.footer__divider {
@@ -270,13 +268,13 @@ a > span > svg {
.footer__ci-services {
gap: 12px;
padding: 10px 16px;
}
.footer__ci-services span {
font-size: 12px;
}
span {
font-size: 12px;
}
.footer__ci-services img {
height: 22px;
img {
height: 22px;
}
}
}

View File

@@ -67,8 +67,8 @@ export default function webpackExtendPlugin(): Plugin<void> {
use: 'js-yaml-loader',
});
// Add swc-loader rule for superset-frontend files
// SWC is a Rust-based transpiler that's significantly faster than babel
// Add babel-loader rule for superset-frontend files
// This ensures Emotion CSS-in-JS is processed correctly for SSG
const supersetFrontendPath = path.resolve(
__dirname,
'../../superset-frontend',
@@ -76,37 +76,26 @@ export default function webpackExtendPlugin(): Plugin<void> {
config.module?.rules?.push({
test: /\.(tsx?|jsx?)$/,
include: supersetFrontendPath,
exclude: /node_modules/,
use: {
loader: 'swc-loader',
loader: 'babel-loader',
options: {
// Ignore superset-frontend/.swcrc which references plugins not
// installed in the docs workspace (e.g. @swc/plugin-emotion)
swcrc: false,
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
},
transform: {
react: {
presets: [
[
'@babel/preset-react',
{
runtime: 'automatic',
importSource: '@emotion/react',
},
},
},
],
'@babel/preset-typescript',
],
plugins: ['@emotion/babel-plugin'],
},
},
});
return {
devtool: isDev ? false : config.devtool,
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
},
devtool: isDev ? 'eval-source-map' : config.devtool,
...(isDev && {
optimization: {
...config.optimization,
@@ -219,6 +208,8 @@ export default function webpackExtendPlugin(): Plugin<void> {
),
},
},
// We're removing the ts-loader rule that was processing superset-frontend files
// This will prevent TypeScript errors from files outside the docs directory
};
},
};

View File

@@ -114,12 +114,6 @@
"lifecycle": "testing",
"description": "Allow users to export full CSV of table viz type. Warning: Could cause server memory/compute issues with large datasets."
},
{
"name": "AWS_DATABASE_IAM_AUTH",
"default": false,
"lifecycle": "testing",
"description": "Enable AWS IAM authentication for database connections (Aurora, Redshift). Allows cross-account role assumption via STS AssumeRole. Security note: When enabled, ensure Superset's IAM role has restricted sts:AssumeRole permissions to prevent unauthorized access."
},
{
"name": "CACHE_IMPERSONATION",
"default": false,
@@ -247,13 +241,6 @@
"description": "Enables dashboard virtualization for improved performance",
"category": "path_to_deprecation"
},
{
"name": "DASHBOARD_VIRTUALIZATION_DEFER_DATA",
"default": false,
"lifecycle": "stable",
"description": "Supports simultaneous data and dashboard virtualization for backend performance",
"category": "runtime_config"
},
{
"name": "DATAPANEL_CLOSED_BY_DEFAULT",
"default": false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="Õ_xBA__x2264__x201E__1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 107.7 107.7"
style="enable-background:new 0 0 107.7 107.7;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#9E2878;}
</style>
<g>
<g id="g1133" transform="translate(-244.51235,-228.78793)">
<path id="path1119" class="st0" d="M340.8,253.8c2.6-1,5.5,0.4,6.2,3c0.7,2.6-1.1,5.7-3.7,6.4c-3.2,0.6-6.2-1.4-9.3,0.2
c-3.1,1.7-4,5.2-4.7,8.3c-1.4,5-8.5,7.3-12.1,4.2c-3.3-2.4-3.4-7.8-0.2-11c2.2-2.5,5.9-3.3,8.8-2c2.7,1.2,6,1.7,8.6-0.4
C337.5,260.1,336.7,255.1,340.8,253.8L340.8,253.8z"/>
<path id="path1121" class="st0" d="M280.5,244.7c4.2-2.2,9.5,1.5,8.2,6.1c-1.4,5.4-0.7,11.5,2.9,15.5c3.4,4,9.8,4.8,14.6,1.9
c3.7-2.1,6-5.8,7.4-9.6c1-3.1,0.6-6.2,1.1-9.3c1-3.8,5.8-6,9.1-4.2c3.2,1.4,4,5.9,1.7,8.8c-1.4,2.2-4.2,2.7-6.3,3.8
c-3.6,1.9-6.5,4.9-8.4,8.6c-1.2,2.1-1.1,4.5-1.7,6.7c-0.9,1.9-3,2.7-4.8,2.9c-4.8,0.6-9.5,3-13,6.7c-1.7,1.7-3.1,4.2-5.6,4.2
c-2.7-0.2-4.7-2.6-7.4-2.7c-4.9-0.7-10.2,0.7-14.4,4c-3.7,3.1-9.4,0.8-9.6-3.7c-0.9-5.1,6.2-9.5,10.1-6.2c4.3,4,10.8,5.6,16.9,3.7
c5.6-1.9,9.7-8.3,8.6-14c-0.9-4.9-4.2-9.3-8.6-11.4c-1.7-0.8-3.6-1.6-4.2-3.5C275.6,250.2,277.3,246.2,280.5,244.7L280.5,244.7z"
/>
<path id="path1123" class="st0" d="M277.8,235.9c2.2-0.8,4.2,1.3,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1.1
C275,238.9,276,236.4,277.8,235.9z"/>
<path id="path1125" class="st0" d="M246.9,278.8c2.2-0.8,4.2,1.2,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1C244,281.9,245,279.3,246.9,278.8
L246.9,278.8z"/>
<path id="path1127" class="st0" d="M328.2,236.2c2.2-0.7,4.2,1.3,3.3,3.5c-0.6,2-3.7,2.7-4.8,1
C325.4,239.2,326.3,236.8,328.2,236.2z"/>
<path id="path1129" class="st0" d="M253.6,257.7c0.4-3.7,5.5-5.9,8.1-3.6c1.9,1.1,1.6,3.6,2.2,5.4c0.4,2.4,2.7,4.3,5.2,4.3
c3.2,0.3,6.4-2.3,9.5-1.2c4.8,1.2,6.5,7.5,3.2,11.5c-2.9,4.1-9.3,4.5-12,0.7c-2.4-2.8-0.5-7.1-2.7-10.1c-1.7-2.9-5.4-2.7-8.3-1.9
C255.8,263.8,253,260.7,253.6,257.7L253.6,257.7z"/>
<path id="path1131" class="st0" d="M300.8,230c3.3-1.9,7.5,0.9,6.7,4.6c0,2.3-2.2,3.6-3.5,5.2c-1.9,1.9-2.3,4.9-1.2,7
c1.3,2.9,4.9,4,5.5,7.2c1.2,4.8-3.3,10.1-8.2,9.8c-4.8,0.1-8.3-5-6.3-9.5c1.2-3.8,5.8-4.9,7.2-8.5c1.7-3.2-0.4-6.1-2.4-8.1
C296.7,235.6,297.9,231.4,300.8,230z"/>
</g>
<g id="g1149" transform="translate(-244.51235,-228.78793)">
<path id="path1135" class="st0" d="M256,311.5c-2.6,1-5.5-0.4-6.2-3c-0.7-2.6,1.1-5.7,3.7-6.4c3.2-0.6,6.2,1.4,9.3-0.2
c3.1-1.6,4-5.1,4.7-8.2c1.4-5,8.5-7.3,12.1-4.2c3.3,2.4,3.4,7.8,0.2,10.9c-2.2,2.5-5.9,3.3-8.8,2c-2.7-1.2-6-1.7-8.6,0.4
C259.1,305,259.9,310.2,256,311.5L256,311.5z"/>
<path id="path1137" class="st0" d="M316.1,320.5c-4.2,2.2-9.5-1.5-8.2-6c1.4-5.4,0.7-11.6-2.9-15.6c-3.4-4-9.8-4.7-14.6-1.9
c-3.7,2-6,5.7-7.4,9.5c-1,3.1-0.6,6.2-1.1,9.3c-1,3.8-5.8,6-9.1,4.3c-3.2-1.4-4-5.9-1.7-8.9c1.4-2.2,4.2-2.7,6.3-3.8
c3.6-1.9,6.5-4.9,8.4-8.6c1.2-2,1.1-4.5,1.7-6.6c0.9-1.9,3-2.7,4.8-2.9c4.8-0.6,9.5-3,13-6.7c1.7-1.6,3.1-4.2,5.6-4.1
c2.7,0.1,4.7,2.5,7.4,2.7c4.9,0.6,10.2-0.8,14.4-4c3.7-3.2,9.4-0.9,9.6,3.6c0.9,5.1-6.2,9.5-10.1,6.2c-4.3-4-10.8-5.6-16.9-3.7
c-5.6,1.9-9.7,8.3-8.6,14c0.9,4.8,4.2,9.3,8.6,11.3c1.7,0.8,3.6,1.6,4.2,3.5C321.2,314.9,319.4,319.1,316.1,320.5L316.1,320.5z"/>
<path id="path1139" class="st0" d="M318.9,329.4c-2.2,0.8-4.2-1.3-3.3-3.4c0.6-2.1,3.7-2.7,4.8-1.1
C321.7,326.3,320.7,328.8,318.9,329.4z"/>
<path id="path1141" class="st0" d="M349.9,286.5c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2.1,3.7-2.7,4.8-1
C352.8,283.5,351.8,285.9,349.9,286.5z"/>
<path id="path1143" class="st0" d="M268.5,329c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2,3.7-2.7,4.8-1C271.3,325.9,270.3,328.5,268.5,329z"
/>
<path id="path1145" class="st0" d="M343.1,307.4c-0.4,3.7-5.5,5.9-8.1,3.6c-1.9-1.1-1.6-3.6-2.2-5.4c-0.4-2.4-2.7-4.3-5.2-4.3
c-3.2-0.3-6.4,2.3-9.5,1.2c-4.8-1.2-6.5-7.5-3.2-11.5c2.9-4.1,9.3-4.5,12-0.7c2.4,2.8,0.5,7,2.7,10c1.7,2.9,5.4,2.7,8.3,1.9
C341,301.4,343.8,304.4,343.1,307.4L343.1,307.4z"/>
<path id="path1147" class="st0" d="M295.8,335.2c-3.3,2-7.5-0.9-6.7-4.6c0-2.3,2.2-3.6,3.5-5.2c1.9-1.9,2.3-4.9,1.2-7
c-1.3-2.9-4.9-4-5.5-7.2c-1.2-4.8,3.3-10.1,8.2-9.8c4.8-0.1,8.3,5,6.3,9.6c-1.2,3.7-5.8,4.8-7.2,8.4c-1.7,3.2,0.4,6.1,2.4,8.2
C300,329.6,298.8,333.8,295.8,335.2L295.8,335.2z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -174,7 +174,7 @@ oracle = ["cx-Oracle>8.0.0, <8.1"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <6.0.0"]
playwright = ["playwright>=1.37.0, <2"]
postgres = ["psycopg2-binary==2.9.9"]
postgres = ["psycopg2-binary==2.9.6"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.328.0"]
prophet = ["prophet>=1.1.6, <2"]
@@ -204,7 +204,6 @@ ydb = ["ydb-sqlalchemy>=0.1.2"]
development = [
# no bounds for apache-superset-extensions-cli until a stable version
"apache-superset-extensions-cli",
"boto3",
"docker",
"flask-testing",
"freezegun",
@@ -438,7 +437,6 @@ authorized_licenses = [
"apache software",
"apache software, bsd",
"bsd",
"bsd-2-clause",
"bsd-3-clause",
"isc license (iscl)",
"isc license",

View File

@@ -16,14 +16,8 @@
# specific language governing permissions and limitations
# under the License.
#
# Security: CVE-2026-21441 - decompression bomb bypass on redirects
urllib3>=2.6.3,<3.0.0
# Security: GHSA-87hc-h4r5-73f7 - Windows path traversal fix
werkzeug>=3.1.5,<4.0.0
# Security: CVE-2025-68146 - TOCTOU symlink vulnerability
filelock>=3.20.3,<4.0.0
# Security: decompression bomb fix (required by aiohttp 3.13.3)
brotli>=1.2.0,<2.0.0
urllib3>=2.6.0,<3.0.0
werkzeug>=3.0.1
numexpr>=2.9.0
# 5.0.0 has a sensitive deprecation used in other libs

View File

@@ -36,10 +36,8 @@ blinker==1.9.0
# via flask
bottleneck==1.5.0
# via apache-superset (pyproject.toml)
brotli==1.2.0
# via
# -r requirements/base.in
# flask-compress
brotli==1.1.0
# via flask-compress
cachelib==0.13.0
# via
# flask-caching
@@ -103,8 +101,6 @@ email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
filelock==3.20.3
# via -r requirements/base.in
flask==2.3.3
# via
# apache-superset (pyproject.toml)
@@ -293,7 +289,7 @@ prompt-toolkit==3.0.51
# via click-repl
pyarrow==16.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.2
pyasn1==0.6.1
# via
# pyasn1-modules
# rsa
@@ -440,7 +436,7 @@ tzdata==2025.2
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.6.3
urllib3==2.6.0
# via
# -r requirements/base.in
# requests
@@ -457,7 +453,7 @@ wcwidth==0.2.13
# via prompt-toolkit
websocket-client==1.8.0
# via selenium
werkzeug==3.1.5
werkzeug==3.1.3
# via
# -r requirements/base.in
# flask

View File

@@ -76,17 +76,11 @@ blinker==1.9.0
# via
# -c requirements/base-constraint.txt
# flask
boto3==1.42.39
# via apache-superset
botocore==1.42.39
# via
# boto3
# s3transfer
bottleneck==1.5.0
# via
# -c requirements/base-constraint.txt
# apache-superset
brotli==1.2.0
brotli==1.1.0
# via
# -c requirements/base-constraint.txt
# flask-compress
@@ -241,10 +235,8 @@ fakeredis==2.32.1
# via pydocket
fastmcp==2.14.3
# via apache-superset
filelock==3.20.3
# via
# -c requirements/base-constraint.txt
# virtualenv
filelock==3.12.2
# via virtualenv
flask==2.3.3
# via
# -c requirements/base-constraint.txt
@@ -466,10 +458,6 @@ jinja2==3.1.6
# apache-superset-extensions-cli
# flask
# flask-babel
jmespath==1.1.0
# via
# boto3
# botocore
jsonpath-ng==1.7.0
# via
# -c requirements/base-constraint.txt
@@ -712,7 +700,7 @@ protobuf==4.25.5
# proto-plus
psutil==6.1.0
# via apache-superset
psycopg2-binary==2.9.9
psycopg2-binary==2.9.6
# via apache-superset
py-key-value-aio==0.3.0
# via
@@ -726,7 +714,7 @@ pyarrow==16.1.0
# apache-superset
# db-dtypes
# pandas-gbq
pyasn1==0.6.2
pyasn1==0.6.1
# via
# -c requirements/base-constraint.txt
# pyasn1-modules
@@ -822,7 +810,6 @@ python-dateutil==2.9.0.post0
# via
# -c requirements/base-constraint.txt
# apache-superset
# botocore
# celery
# croniter
# flask-appbuilder
@@ -926,8 +913,6 @@ rsa==4.9.1
# google-auth
ruff==0.9.7
# via apache-superset
s3transfer==0.16.0
# via boto3
secretstorage==3.5.0
# via keyring
selenium==4.32.0
@@ -1076,10 +1061,9 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.6.3
urllib3==2.6.0
# via
# -c requirements/base-constraint.txt
# botocore
# docker
# requests
# requests-cache
@@ -1111,7 +1095,7 @@ websocket-client==1.8.0
# selenium
websockets==15.0.1
# via fastmcp
werkzeug==3.1.5
werkzeug==3.1.3
# via
# -c requirements/base-constraint.txt
# flask

View File

@@ -1,114 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from typing import Any, Protocol, runtime_checkable, TypeVar
from pydantic import BaseModel
from superset_core.semantic_layers.semantic_view import SemanticView
ConfigT = TypeVar("ConfigT", bound=BaseModel, contravariant=True)
SemanticViewT = TypeVar("SemanticViewT", bound="SemanticView")
# TODO (betodealmeida): convert to ABC
@runtime_checkable
class SemanticLayer(Protocol[ConfigT, SemanticViewT]):
"""
A protocol for semantic layers.
"""
@classmethod
def from_configuration(
cls,
configuration: dict[str, Any],
) -> SemanticLayer[ConfigT, SemanticViewT]:
"""
Create a semantic layer from its configuration.
"""
@classmethod
def get_configuration_schema(
cls,
configuration: ConfigT | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the configuration needed to add the semantic layer.
A partial configuration `configuration` can be sent to improve the schema,
allowing for progressive validation and better UX. For example, a semantic
layer might require:
- auth information
- a database
If the user provides the auth information, a client can send the partial
configuration to this method, and the resulting JSON schema would include
the list of databases the user has access to, allowing a dropdown to be
populated.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are populated based on the provided connection info.
"""
@classmethod
def get_runtime_schema(
cls,
configuration: ConfigT,
runtime_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the runtime parameters needed to load semantic views.
This returns the schema needed to connect to a semantic view given the
configuration for the semantic layer. For example, a semantic layer might
be configured by:
- auth information
- an optional database
If the user does not provide a database when creating the semantic layer, the
runtime schema would require the database name to be provided before loading any
semantic views. This allows users to create semantic layers that connect to a
specific database (or project, account, etc.), or that allow users to select it
at query time.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are required if they were not provided in the initial
configuration.
"""
def get_semantic_views(
self,
runtime_configuration: dict[str, Any],
) -> set[SemanticViewT]:
"""
Get the semantic views available in the semantic layer.
The runtime configuration can provide information like a given project or
schema, used to restrict the semantic views returned.
"""
def get_semantic_view(
self,
name: str,
additional_configuration: dict[str, Any],
) -> SemanticViewT:
"""
Get a specific semantic view by its name and additional configuration.
"""

View File

@@ -1,105 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import enum
from typing import Protocol, runtime_checkable
from superset_core.semantic_layers.types import (
Dimension,
Filter,
GroupLimit,
Metric,
OrderTuple,
SemanticResult,
)
# TODO (betodealmeida): move to the extension JSON
class SemanticViewFeature(enum.Enum):
"""
Custom features supported by semantic layers.
"""
ADHOC_EXPRESSIONS_IN_ORDERBY = "ADHOC_EXPRESSIONS_IN_ORDERBY"
GROUP_LIMIT = "GROUP_LIMIT"
GROUP_OTHERS = "GROUP_OTHERS"
# TODO (betodealmeida): convert to ABC
@runtime_checkable
class SemanticView(Protocol):
"""
A protocol for semantic views.
"""
features: frozenset[SemanticViewFeature]
def uid(self) -> str:
"""
Returns a unique identifier for the semantic view.
"""
def get_dimensions(self) -> set[Dimension]:
"""
Get the dimensions defined in the semantic view.
"""
def get_metrics(self) -> set[Metric]:
"""
Get the metrics defined in the semantic view.
"""
def get_values(
self,
dimension: Dimension,
filters: set[Filter] | None = None,
) -> SemanticResult:
"""
Return distinct values for a dimension.
"""
def get_dataframe(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a semantic query and return the results as a DataFrame.
"""
def get_row_count(
self,
metrics: list[Metric],
dimensions: list[Dimension],
filters: set[Filter] | None = None,
order: list[OrderTuple] | None = None,
limit: int | None = None,
offset: int | None = None,
*,
group_limit: GroupLimit | None = None,
) -> SemanticResult:
"""
Execute a query and return the number of rows the result would have.
"""

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,7 @@ module.exports = {
['@babel/plugin-transform-runtime', { corejs: 3 }],
// only used in packages/superset-ui-core/src/chart/components/reactify.tsx
['babel-plugin-typescript-to-proptypes', { loose: true }],
'react-hot-loader/babel',
[
'@emotion/babel-plugin',
{

View File

@@ -59,7 +59,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-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/*.|sinon|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)|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|@rjsf/*.|sinon|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)',
],
preset: 'ts-jest',
transform: {

File diff suppressed because it is too large Load Diff

View File

@@ -161,7 +161,7 @@
"geostyler-openlayers-parser": "^4.3.0",
"geostyler-style": "7.5.0",
"geostyler-wfs-parser": "^2.0.3",
"googleapis": "^171.4.0",
"googleapis": "^170.1.0",
"immer": "^11.1.3",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -171,7 +171,7 @@
"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.6.1",
"match-sorter": "^6.3.4",
"memoize-one": "^5.2.1",
"mousetrap": "^1.6.5",
@@ -179,22 +179,22 @@
"nanoid": "^5.1.6",
"ol": "^7.5.2",
"prop-types": "^15.8.1",
"query-string": "9.3.1",
"query-string": "6.14.1",
"re-resizable": "^6.11.2",
"react": "^17.0.2",
"react-arborist": "^3.4.3",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^3.4.0",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^17.0.2",
"react-google-recaptcha": "^3.1.0",
"react-hot-loader": "^4.13.1",
"react-intersection-observer": "^10.0.2",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
"react-redux": "^7.2.9",
"react-resize-detector": "^9.1.1",
"react-resize-detector": "^7.1.2",
"react-reverse-portal": "^2.3.0",
"react-router-dom": "^5.3.4",
"react-search-input": "^0.11.3",
@@ -216,46 +216,46 @@
"urijs": "^1.19.8",
"use-event-callback": "^0.1.0",
"use-immer": "^0.11.0",
"use-query-params": "^2.2.2",
"uuid": "^13.0.0",
"use-query-params": "^1.1.9",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "^17.7.2"
},
"devDependencies": {
"@applitools/eyes-storybook": "^3.63.10",
"@applitools/eyes-storybook": "^3.63.9",
"@babel/cli": "^7.28.6",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.29.0",
"@babel/core": "^7.28.6",
"@babel/eslint-parser": "^7.28.6",
"@babel/node": "^7.29.0",
"@babel/node": "^7.28.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/preset-env": "^7.29.0",
"@babel/preset-env": "^7.28.6",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@babel/register": "^7.23.7",
"@babel/runtime": "^7.28.6",
"@babel/runtime-corejs3": "^7.29.0",
"@babel/runtime-corejs3": "^7.28.6",
"@babel/types": "^7.28.6",
"@cypress/react": "^8.0.2",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.58.1",
"@playwright/test": "^1.58.0",
"@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/test": "^8.6.15",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-controls": "8.6.14",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/components": "8.6.14",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/react-webpack5": "8.6.14",
"@storybook/test": "^8.6.14",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.14.0",
@@ -272,7 +272,7 @@
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.2.1",
"@types/node": "^25.0.10",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-loadable": "^5.5.11",
@@ -286,7 +286,6 @@
"@types/rison": "0.1.0",
"@types/sinon": "^17.0.3",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"babel-jest": "^30.0.2",
@@ -295,12 +294,12 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"baseline-browser-mapping": "^2.9.19",
"baseline-browser-mapping": "^2.9.18",
"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.2",
"css-minimizer-webpack-plugin": "^7.0.4",
"eslint": "^8.56.0",
"eslint-config-prettier": "^7.2.0",
@@ -323,7 +322,7 @@
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.15.4",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
"fetch-mock": "^11.1.5",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"history": "^5.3.0",
"html-webpack-plugin": "^5.6.6",
@@ -360,10 +359,9 @@
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.3",
"webpack": "^5.105.0",
"webpack": "^5.104.1",
"webpack-bundle-analyzer": "^5.2.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.3",
@@ -388,7 +386,7 @@
"puppeteer": "^22.4.1",
"remark-gfm": "^3.0.1",
"underscore": "^1.13.7",
"jspdf": "^4.0.0",
"jspdf": "^3.0.2",
"nwsapi": "^2.2.13",
"@deck.gl/aggregation-layers": "~9.2.2",
"@deck.gl/core": "~9.2.2",

View File

@@ -12,8 +12,8 @@
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"install": "^0.13.0",

View File

@@ -18,19 +18,12 @@
*/
/**
* @fileoverview Editors API for Superset text editor integration.
* @fileoverview Editors API for Superset extension editor contributions.
*
* This module defines the interfaces and types for working with text editors
* in Superset. It provides:
*
* - `EditorHandle`: Imperative API for programmatically controlling editors
* (get/set content, cursor position, selections, annotations, completions)
* - `EditorProps`: Props contract for editor React components
* - `CompletionProvider`: Interface for registering custom autocomplete providers
* - Registration functions for custom editor implementations
*
* The API is editor-agnostic, supporting Ace, Monaco, CodeMirror, or any
* compliant implementation.
* This module defines the interfaces and types for editor contributions to the
* Superset platform. Extensions can register custom text editor implementations
* (e.g., Monaco, CodeMirror) through the extension manifest, replacing the
* default Ace editor for specific languages.
*/
import { ForwardRefExoticComponent, RefAttributes } from 'react';
@@ -43,111 +36,69 @@ export type { EditorContribution, EditorLanguage };
/**
* Represents a position in the editor (line and column).
* Both line and column are zero-based indices.
*
* @example
* // Position at the start of line 5, column 10
* const pos: Position = { line: 4, column: 9 };
*/
export interface Position {
/** Zero-based line number (first line is 0) */
/** Zero-based line number */
line: number;
/** Zero-based column number (first column is 0) */
/** Zero-based column number */
column: number;
}
/**
* Represents a contiguous range in the editor defined by start and end positions.
* The range is inclusive of the start position and exclusive of the end position.
* Represents a range in the editor with start and end positions.
*/
export interface Range {
/** Start position of the range (inclusive) */
/** Start position of the range */
start: Position;
/** End position of the range (exclusive) */
/** End position of the range */
end: Position;
}
/**
* Represents a selection in the editor, extending Range with direction information.
* A selection is a highlighted range of text that can be manipulated.
* Represents a selection in the editor.
*/
export interface Selection extends Range {
/**
* Direction of the selection.
* - 'ltr': Selection was made left-to-right (anchor at start, cursor at end)
* - 'rtl': Selection was made right-to-left (anchor at end, cursor at start)
*/
/** Direction of the selection */
direction?: 'ltr' | 'rtl';
}
/**
* Severity levels for editor annotations.
* Determines the visual style and icon used to display the annotation.
* Annotation severity levels for editor markers.
*/
export type AnnotationSeverity = 'error' | 'warning' | 'info';
/**
* Represents a diagnostic annotation displayed in the editor.
* Annotations are used to highlight issues like syntax errors, linting warnings,
* or informational messages at specific locations in the code.
*
* @example
* const annotation: EditorAnnotation = {
* line: 5,
* column: 10,
* message: 'Unknown column "user_id"',
* severity: 'error',
* source: 'sql-validator',
* };
* Represents an annotation (marker/diagnostic) in the editor.
*/
export interface EditorAnnotation {
/** Zero-based line number where the annotation appears */
/** Zero-based line number */
line: number;
/** Zero-based column number for precise positioning (optional) */
/** Zero-based column number (optional) */
column?: number;
/** Human-readable message describing the issue or information */
/** Annotation message to display */
message: string;
/** Severity determines visual styling (red for error, yellow for warning, blue for info) */
/** Severity level of the annotation */
severity: AnnotationSeverity;
/** Identifies what produced this annotation (e.g., "linter", "sql-validator") */
/** Optional source of the annotation (e.g., "linter", "typescript") */
source?: string;
}
/**
* Defines a keyboard shortcut that triggers a custom action in the editor.
* Hotkeys allow binding key combinations to functions that manipulate
* the editor or perform other actions.
*
* @example
* const runQueryHotkey: EditorHotkey = {
* name: 'runQuery',
* key: 'Ctrl+Enter',
* description: 'Execute the current query',
* exec: (handle) => {
* const sql = handle.getValue();
* executeQuery(sql);
* },
* };
* Represents a keyboard shortcut binding.
*/
export interface EditorHotkey {
/** Unique identifier for this hotkey command */
/** Unique name for the hotkey command */
name: string;
/**
* Key combination string. Format varies by editor but typically uses:
* - Modifiers: Ctrl, Alt, Shift, Meta (Cmd on Mac)
* - Separator: + (e.g., "Ctrl+Enter", "Ctrl+Shift+F")
*/
/** Key binding string (e.g., "Ctrl+Enter", "Alt+Enter") */
key: string;
/** Human-readable description shown in keyboard shortcut help */
/** Description of what the hotkey does */
description?: string;
/** Callback invoked when the hotkey is pressed, receives the editor handle */
/** Function to execute when the hotkey is triggered */
exec: (handle: EditorHandle) => void;
}
/**
* Categories for completion items, determining the icon displayed.
* Includes standard programming concepts plus SQL-specific types
* (table, column, schema, catalog, database).
* Completion item kinds for autocompletion.
*/
export type CompletionItemKind =
| 'text'
@@ -181,87 +132,53 @@ export type CompletionItemKind =
| 'database';
/**
* Represents a single item in the autocompletion dropdown.
* Completion items are suggestions shown to users as they type,
* allowing quick insertion of code snippets, keywords, or identifiers.
*
* @example
* const tableCompletion: CompletionItem = {
* label: 'users',
* insertText: 'users',
* kind: 'table',
* detail: 'public schema',
* documentation: 'User accounts table with profile information',
* };
* Represents a completion item for autocompletion.
*/
export interface CompletionItem {
/** Text displayed in the completion dropdown */
/** Display label for the completion item */
label: string;
/** Text inserted into the editor when this item is selected */
/** Text to insert when the item is selected */
insertText: string;
/** Category of completion, determines the icon shown (e.g., table, column, function) */
/** Kind of completion item for icon display */
kind: CompletionItemKind;
/** Extended description shown in a details pane or tooltip */
/** Optional documentation to show in the completion popup */
documentation?: string;
/** Short additional info displayed next to the label (e.g., type, schema) */
/** Optional detail text to show alongside the label */
detail?: string;
/** String used for sorting; items are sorted lexicographically by this value */
/** Sorting priority (higher numbers appear first) */
sortText?: string;
/** String used for filtering; if omitted, label is used for matching user input */
/** Text used for filtering completions */
filterText?: string;
}
/**
* Context information passed to completion providers when requesting suggestions.
* Contains details about how completion was triggered and the current environment.
* Context provided to completion providers.
*/
export interface CompletionContext {
/** The character that triggered automatic completion (e.g., '.', ' '), if applicable */
/** Character that triggered the completion (if any) */
triggerCharacter?: string;
/**
* How the completion was triggered:
* - 'invoke': User explicitly requested completion (e.g., Ctrl+Space)
* - 'automatic': Triggered automatically by typing a trigger character
*/
/** How the completion was triggered */
triggerKind: 'invoke' | 'automatic';
/** The language mode of the editor (e.g., 'sql', 'json') */
/** Language of the editor */
language: EditorLanguage;
/** Host-provided context (e.g., database ID, schema name for SQL completions) */
/** Generic metadata passed from the host (e.g., SQL Lab can pass database context) */
metadata?: Record<string, unknown>;
}
/**
* Interface for providing dynamic autocompletion suggestions.
* Providers are invoked when the user triggers completion, allowing
* context-aware suggestions based on cursor position and editor content.
*
* @example
* const tableCompletionProvider: CompletionProvider = {
* id: 'sql-tables',
* triggerCharacters: [' ', '.'],
* provideCompletions: async (content, position, context) => {
* const dbId = context.metadata?.databaseId;
* const tables = await fetchTables(dbId);
* return tables.map(t => ({
* label: t.name,
* insertText: t.name,
* kind: 'table',
* }));
* },
* };
* Provider interface for dynamic completions.
*/
export interface CompletionProvider {
/** Unique identifier for this provider, used for debugging and deduplication */
/** Unique identifier for this provider */
id: string;
/** Characters that trigger this provider automatically when typed (e.g., '.', ' ') */
/** Trigger characters that invoke this provider (e.g., '.', ' ') */
triggerCharacters?: string[];
/**
* Generate completion suggestions for the current cursor position.
*
* @param content Full text content of the editor
* @param position Current cursor position where completion was triggered
* @param context Additional context about the trigger and environment
* @returns Array of completion items, or a Promise resolving to them for async providers
* Provide completions at the given position.
* @param content The editor content
* @param position The cursor position
* @param context Completion context with trigger info and metadata
* @returns Array of completion items or a promise that resolves to them
*/
provideCompletions(
content: string,
@@ -271,186 +188,98 @@ export interface CompletionProvider {
}
/**
* Represents a static keyword for basic autocomplete.
* Keywords are simpler than CompletionItems and are used for static lists
* of suggestions (e.g., SQL keywords, table names) that don't require
* dynamic computation.
*
* Editor implementations convert these to their native completion format.
*
* @example
* const sqlKeywords: EditorKeyword[] = [
* { name: 'SELECT', meta: 'keyword', score: 100 },
* { name: 'FROM', meta: 'keyword', score: 100 },
* { name: 'users', value: 'users', meta: 'table', score: 50 },
* ];
* A keyword for editor autocomplete.
* This is a generic format that editor implementations convert to their native format.
*/
export interface EditorKeyword {
/** Display name shown in the completion dropdown */
/** Display name of the keyword */
name: string;
/** Text to insert when selected; defaults to name if not provided */
/** Value to insert when selected (defaults to name if not provided) */
value?: string;
/** Category label shown alongside the name (e.g., "column", "table", "function") */
/** Category/type of the keyword (e.g., "column", "table", "function") */
meta?: string;
/** Sorting priority; higher scores appear first in the completion list */
/** Optional score for sorting (higher = more relevant) */
score?: number;
}
/**
* Props accepted by all editor component implementations.
* This interface defines the contract between Superset and editor components,
* ensuring consistent behavior regardless of the underlying editor library.
* Props that all editor implementations must accept.
*/
export interface EditorProps {
/** Unique identifier for this editor instance */
/** Instance identifier */
id: string;
/** Current editor content (controlled component pattern) */
/** Controlled value */
value: string;
/** Called when the editor content changes */
/** Content change handler */
onChange: (value: string) => void;
/** Called when the editor loses focus, with the current value */
/** Blur handler */
onBlur?: (value: string) => void;
/** Called when the cursor position changes */
/** Cursor position change handler */
onCursorPositionChange?: (pos: Position) => void;
/** Called when the selection(s) change */
/** Selection change handler */
onSelectionChange?: (sel: Selection[]) => void;
/** Language mode for syntax highlighting and language features */
/** Language mode for syntax highlighting */
language: EditorLanguage;
/** When true, prevents editing (view-only mode) */
/** Whether the editor is read-only */
readOnly?: boolean;
/** Number of spaces per tab character */
/** Tab size in spaces */
tabSize?: number;
/** Whether to display line numbers in the gutter */
/** Whether to show line numbers */
lineNumbers?: boolean;
/** Whether long lines should wrap to the next visual line */
/** Whether to enable word wrap */
wordWrap?: boolean;
/** Diagnostic annotations to display (errors, warnings, info) */
/** Linting/error annotations */
annotations?: EditorAnnotation[];
/** Custom keyboard shortcuts */
/** Keyboard shortcuts */
hotkeys?: EditorHotkey[];
/** Static keywords for basic autocomplete */
/** Static keywords for autocomplete */
keywords?: EditorKeyword[];
/** CSS height value (e.g., "100%", "500px", "calc(100vh - 200px)") */
/** CSS height (e.g., "100%", "500px") */
height?: string;
/** CSS width value (e.g., "100%", "800px") */
/** CSS width (e.g., "100%", "800px") */
width?: string;
/** Called when the editor is fully initialized, providing the imperative handle */
/** Callback when editor is ready with imperative handle */
onReady?: (handle: EditorHandle) => void;
/** Contextual data passed to completion providers (e.g., database ID, schema) */
/** Host-specific context (e.g., database info from SQL Lab) */
metadata?: Record<string, unknown>;
/** Theme object for styling the editor to match Superset's appearance */
/** Theme object for styling the editor */
theme?: SupersetTheme;
}
/**
* Imperative API for controlling the editor programmatically.
*
* This handle provides a unified interface for interacting with text editors
* regardless of the underlying implementation (Ace, Monaco, CodeMirror, etc.).
* It can be used by any part of Superset that needs to manipulate editor content,
* read selections, or register custom behaviors.
*/
export interface EditorHandle {
/**
* Moves keyboard focus to the editor.
* Useful after programmatic operations to return user focus to the editing area.
*/
/** Focus the editor */
focus(): void;
/**
* Returns the complete text content of the editor.
* @returns The full editor content as a string
*/
/** Get the current editor content */
getValue(): string;
/**
* Replaces the entire editor content with the provided value.
* This will clear any existing content and reset the undo history in most editors.
* @param value The new content to set
*/
/** Set the editor content */
setValue(value: string): void;
/**
* Returns the current cursor position in the editor.
* @returns Position object with zero-based line and column numbers
*/
/** Get the current cursor position */
getCursorPosition(): Position;
/**
* Moves the cursor to the specified position.
* @param position Target position with zero-based line and column numbers
*/
/** Move the cursor to a specific position */
moveCursorToPosition(position: Position): void;
/**
* Returns all active selections in the editor.
* Most editors support multiple selections (e.g., via Ctrl+click).
* Each selection includes start/end positions and optional direction.
* @returns Array of Selection objects, empty array if no selections
*/
/** Get all selections in the editor */
getSelections(): Selection[];
/**
* Sets the selection to the specified range.
* This replaces any existing selections with a single new selection.
* @param selection Range to select, with start and end positions
*/
/** Set the selection range */
setSelection(selection: Range): void;
/**
* Returns the text within the current selection.
* If multiple selections exist, behavior depends on the editor implementation
* (typically returns the primary/first selection's text).
* @returns The selected text, or empty string if no selection
*/
/** Get the selected text */
getSelectedText(): string;
/**
* Inserts text at the current cursor position.
* If text is selected, the selection is replaced with the inserted text.
* @param text The text to insert
*/
/** Insert text at the current cursor position */
insertText(text: string): void;
/**
* Execute a named editor command.
*
* Note: Command names are editor-specific. For example:
* - Ace: 'centerselection', 'gotoline', 'fold', 'unfold'
* - Monaco: 'editor.action.formatDocument', 'editor.action.commentLine'
*
* Callers using this method should be aware of which editor is active
* or handle cases where the command may not exist.
*
* @param commandName The editor-specific command name to execute
*/
/** Execute a named editor command */
executeCommand(commandName: string): void;
/**
* Scrolls the editor viewport to bring the specified line into view.
* The exact positioning (top, center, bottom) depends on the editor implementation.
* @param line Zero-based line number to scroll to
*/
/** Scroll to a specific line */
scrollToLine(line: number): void;
/**
* Sets diagnostic annotations (errors, warnings, info markers) in the editor.
* This replaces any previously set annotations.
* Annotations appear as markers in the gutter and/or inline decorations.
* @param annotations Array of annotations to display
*/
/** Set annotations (replaces existing) */
setAnnotations(annotations: EditorAnnotation[]): void;
/**
* Removes all annotations from the editor.
* Equivalent to calling setAnnotations([]).
*/
/** Clear all annotations */
clearAnnotations(): void;
/**
* Registers a provider for dynamic autocompletion suggestions.
* The provider will be invoked when completion is triggered (manually or automatically).
* Multiple providers can be registered; their results are merged.
* Register a completion provider for dynamic suggestions.
* @param provider The completion provider to register
* @returns A Disposable that removes the provider when disposed
* @returns A Disposable to unregister the provider
*/
registerCompletionProvider(provider: CompletionProvider): Disposable;
}

View File

@@ -30,14 +30,44 @@
*/
import { Event, Database, SupersetError, Column } from './core';
import { EditorHandle } from './editors';
/**
* Provides imperative control over the code editor component.
* Allows extensions to manipulate text content, cursor position,
* selections, annotations, and register completion providers.
* Represents an SQL editor instance within a SQL Lab tab.
* Contains the editor content and associated database connection information.
*/
export interface Editor extends EditorHandle {}
export interface Editor {
/**
* The SQL content of the editor.
* This represents the current text in the SQL editor.
*/
content: string;
/**
* The database identifier associated with the editor.
* This determines which database the queries will be executed against.
*/
databaseId: number;
/**
* The catalog name associated with the editor.
* Can be null if no specific catalog is selected.
*/
catalog: string | null;
/**
* The schema name associated with the editor.
* Defines the database schema context for the editor.
*/
schema: string;
/**
* The table name associated with the editor.
* Can be null if no specific table is selected.
*
* @todo Revisit if we actually need the table property
*/
table: string | null;
}
/**
* Represents a panel within a SQL Lab tab.
@@ -69,40 +99,10 @@ export interface Tab {
title: string;
/**
* The database identifier for this tab's query context.
* This determines which database the queries will be executed against.
* The SQL editor instance associated with this tab.
* Contains the editor content and database connection settings.
*/
databaseId: number;
/**
* The catalog name for this tab's query context.
* Can be null if no specific catalog is selected (for multi-catalog databases like Trino).
*/
catalog: string | null;
/**
* The schema name for this tab's query context.
* Can be null if no schema is selected.
*/
schema: string | null;
/**
* Gets the code editor instance for this tab.
* Returns a Promise that resolves when the editor is ready.
* The returned editor is a proxy that always delegates to the current
* editor implementation, even if the editor is swapped (e.g., Ace to Monaco).
*
* @returns Promise that resolves to the Editor instance
*
* @example
* ```typescript
* const tab = sqlLab.getCurrentTab();
* const editor = await tab.getEditor();
* editor.setValue("SELECT * FROM users");
* editor.focus();
* ```
*/
getEditor(): Promise<Editor>;
editor: Editor;
/**
* The panels associated with the tab.
@@ -262,12 +262,7 @@ export declare const getActivePanel: () => Panel;
* const tab = getCurrentTab();
* if (tab) {
* console.log(`Active tab: ${tab.title}`);
* console.log(`Database ID: ${tab.databaseId}, Schema: ${tab.schema}`);
*
* // Editor manipulation via async getEditor()
* const editor = await tab.getEditor();
* editor.setValue("SELECT * FROM users");
* editor.focus();
* console.log(`Database ID: ${tab.editor.databaseId}`);
* }
* ```
*/
@@ -331,10 +326,9 @@ export declare const onDidChangeTabTitle: Event<string>;
*
* @example
* ```typescript
* onDidQueryRun.event(async (query) => {
* console.log('Query started on database:', query.tab.databaseId);
* const editor = await query.tab.getEditor();
* console.log('Query SQL:', editor.getValue());
* onDidQueryRun.event((query) => {
* console.log('Query started on database:', query.tab.editor.databaseId);
* console.log('Query content:', query.tab.editor.content);
* });
* ```
*/
@@ -347,7 +341,7 @@ export declare const onDidQueryRun: Event<QueryContext>;
* @example
* ```typescript
* onDidQueryStop.event((query) => {
* console.log('Query stopped for database:', query.tab.databaseId);
* console.log('Query stopped for database:', query.tab.editor.databaseId);
* });
* ```
*/
@@ -450,253 +444,3 @@ export declare const onDidCloseTab: Event<Tab>;
* ```
*/
export declare const onDidChangeActiveTab: Event<Tab>;
/**
* Event fired when a new tab is created in SQL Lab.
* Provides the newly created tab object as the event payload.
*
* @example
* ```typescript
* onDidCreateTab.event((tab) => {
* console.log('New tab created:', tab.title);
* // Initialize extension state for new tab
* });
* ```
*/
export declare const onDidCreateTab: Event<Tab>;
/**
* Tab/Editor Management APIs
*
* These APIs allow extensions to create, close, and manage SQL Lab tabs.
*/
/**
* Options for creating a new SQL Lab tab.
*/
export interface CreateTabOptions {
/**
* Initial SQL content for the editor.
*/
sql?: string;
/**
* Display title for the tab.
* If not provided, defaults to "Untitled Query N".
*/
title?: string;
/**
* Database ID to connect to.
* If not provided, inherits from the active tab or uses default.
*/
databaseId?: number;
/**
* Catalog name (for multi-catalog databases like Trino).
*/
catalog?: string | null;
/**
* Schema name for the query context.
*/
schema?: string | null;
}
/**
* Creates a new query editor tab in SQL Lab.
*
* @param options Optional configuration for the new tab
* @returns The newly created tab object
*
* @example
* ```typescript
* // Create a tab with default settings
* const tab = await createTab();
*
* // Create a tab with specific SQL and database
* const tab = await createTab({
* sql: "SELECT * FROM users LIMIT 10",
* title: "User Query",
* databaseId: 1,
* schema: "public"
* });
* ```
*/
export declare function createTab(options?: CreateTabOptions): Promise<Tab>;
/**
* Closes a specific tab in SQL Lab.
*
* @param tabId The ID of the tab to close
* @returns Promise that resolves when the tab is closed
*
* @example
* ```typescript
* const tabs = getTabs();
* if (tabs.length > 1) {
* await closeTab(tabs[0].id);
* }
* ```
*/
export declare function closeTab(tabId: string): Promise<void>;
/**
* Switches to a specific tab in SQL Lab.
*
* @param tabId The ID of the tab to activate
* @returns Promise that resolves when the tab is activated
*
* @example
* ```typescript
* const tabs = getTabs();
* const targetTab = tabs.find(t => t.title === "My Query");
* if (targetTab) {
* await setActiveTab(targetTab.id);
* }
* ```
*/
export declare function setActiveTab(tabId: string): Promise<void>;
/**
* Query Execution APIs
*
* These APIs allow extensions to execute and control SQL queries.
*/
/**
* Options for executing a SQL query.
*/
export interface QueryOptions {
/**
* SQL to execute without modifying editor content.
* If not provided, uses the current editor content.
*/
sql?: string;
/**
* Run only the selected text in the editor.
* Ignored if `sql` option is provided.
*/
selectedOnly?: boolean;
/**
* Override the query row limit.
* If not provided, uses the tab's configured limit.
*/
limit?: number;
/**
* Template parameters for Jinja templating.
* Merged with existing template parameters from the editor.
*/
templateParameters?: Record<string, unknown>;
/**
* Create Table/View As Select options.
* When provided, query results are stored in a new table instead of returned directly.
*/
ctas?: {
/**
* Whether to create a TABLE or VIEW.
*/
method: 'TABLE' | 'VIEW';
/**
* Name of the table or view to create.
*/
tableName: string;
};
}
/**
* Executes a SQL query in the current tab.
*
* @param options Optional query execution options
* @returns Promise that resolves with the query ID
*
* @example
* ```typescript
* // Execute the current editor content
* const queryId = await executeQuery();
*
* // Execute custom SQL without modifying the editor
* const queryId = await executeQuery({
* sql: "SELECT * FROM users LIMIT 10"
* });
*
* // Execute only selected text
* const queryId = await executeQuery({ selectedOnly: true });
*
* // Create a table from query results
* const queryId = await executeQuery({
* ctas: { method: 'TABLE', tableName: 'my_results' }
* });
* ```
*/
export declare function executeQuery(options?: QueryOptions): Promise<string>;
/**
* Cancels a running query.
*
* @param queryId The client ID of the query to cancel
* @returns Promise that resolves when the cancellation request is sent
*
* @example
* ```typescript
* const queryId = await executeQuery();
* // Later, if needed:
* await cancelQuery(queryId);
* ```
*/
export declare function cancelQuery(queryId: string): Promise<void>;
/**
* Tab Context APIs
*
* These APIs manage tab-level query context and settings.
* Text manipulation is handled directly via Editor (e.g., tab.editor.setValue(sql)).
*/
/**
* Sets the database for the current tab.
*
* @param databaseId The ID of the database to set
* @returns Promise that resolves when the database is updated
*
* @example
* ```typescript
* const databases = getDatabases();
* const prodDb = databases.find(d => d.database_name === "production");
* if (prodDb) {
* await setDatabase(prodDb.id);
* }
* ```
*/
export declare function setDatabase(databaseId: number): Promise<void>;
/**
* Sets the catalog for the current tab.
*
* @param catalog The catalog name to set, or null to clear
* @returns Promise that resolves when the catalog is updated
*
* @example
* ```typescript
* await setCatalog("hive_metastore");
* ```
*/
export declare function setCatalog(catalog: string | null): Promise<void>;
/**
* Sets the schema for the current tab.
*
* @param schema The schema name to set, or null to clear
* @returns Promise that resolves when the schema is updated
*
* @example
* ```typescript
* await setSchema("public");
* ```
*/
export declare function setSchema(schema: string | null): Promise<void>;

View File

@@ -29,9 +29,8 @@ import {
FieldStringOutlined,
NumberOutlined,
} from '@ant-design/icons';
import { Icons } from '@superset-ui/core/components';
export type ColumnLabelExtendedType = 'expression' | 'metric' | '';
export type ColumnLabelExtendedType = 'expression' | '';
export type ColumnTypeLabelProps = {
type?: ColumnLabelExtendedType | GenericDataType;
@@ -60,9 +59,7 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
<QuestionOutlined aria-label={t('unknown type icon')} />
);
if (type === 'metric') {
typeIcon = <Icons.Sigma aria-label={t('metric type icon')} />;
} else if (type === '' || type === 'expression') {
if (type === '' || type === 'expression') {
typeIcon = <FunctionOutlined aria-label={t('function type icon')} />;
} else if (type === GenericDataType.String) {
typeIcon = <FieldStringOutlined aria-label={t('string type icon')} />;

View File

@@ -95,7 +95,7 @@ export function MetricOption({
return (
<FlexRowContainer className="metric-option">
{showType && <ColumnTypeLabel type="metric" />}
{showType && <ColumnTypeLabel type="expression" />}
{shouldShowTooltip ? (
<Tooltip id="metric-name-tooltip" title={tooltipText}>
{label}

View File

@@ -23,7 +23,7 @@ import { ControlPanelSectionConfig } from '../types';
import { formatSelectOptions } from '../utils';
export const TITLE_MARGIN_OPTIONS: number[] = [
0, 15, 30, 50, 75, 100, 125, 150, 200,
15, 30, 50, 75, 100, 125, 150, 200,
];
export const TITLE_POSITION_OPTIONS: [string, string][] = [
['Left', t('Left')],
@@ -82,7 +82,7 @@ export const titleControls: ControlPanelSectionConfig = {
clearable: true,
label: t('Y Axis Title Margin'),
renderTrigger: true,
default: TITLE_MARGIN_OPTIONS[0],
default: TITLE_MARGIN_OPTIONS[1],
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
},
},

View File

@@ -52,10 +52,6 @@ describe('ColumnOption', () => {
renderColumnTypeLabel({ type: 'expression' });
expect(screen.getByLabelText('function type icon')).toBeVisible();
});
it('metric type shows sigma icon', () => {
renderColumnTypeLabel({ type: 'metric' });
expect(screen.getByLabelText('metric type icon')).toBeVisible();
});
it('unknown type shows question mark', () => {
renderColumnTypeLabel({ type: undefined });
expect(screen.getByLabelText('unknown type icon')).toBeVisible();

View File

@@ -78,11 +78,11 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.23",
"@types/node": "^25.2.1",
"@types/node": "^25.0.10",
"@types/prop-types": "^15.7.15",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",
"fetch-mock": "^12.6.0",
"fetch-mock": "^11.1.4",
"jest-mock-console": "^2.0.0",
"resize-observer-polyfill": "1.5.1",
"timezone-mock": "1.3.6"

View File

@@ -126,7 +126,7 @@ export function Button(props: ButtonProps) {
minWidth: cta ? theme.sizeUnit * 36 : undefined,
minHeight: cta ? theme.sizeUnit * 8 : undefined,
marginLeft: 0,
'& + .superset-button:not(.ant-btn-compact-item)': {
'& + .superset-button': {
marginLeft: theme.sizeUnit * 2,
},
'& > span > :first-of-type': {

View File

@@ -76,10 +76,6 @@ import {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderAddOutlined,
FolderOpenOutlined,
FolderOutlined,
FolderViewOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -98,18 +94,15 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
MinusCircleOutlined,
MinusSquareOutlined,
MoonOutlined,
LoadingOutlined,
LoginOutlined,
MonitorOutlined,
MoreOutlined,
OrderedListOutlined,
PartitionOutlined,
PieChartOutlined,
PicCenterOutlined,
PlusCircleOutlined,
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
QuestionCircleOutlined,
@@ -224,10 +217,6 @@ const AntdIcons = {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderAddOutlined,
FolderOpenOutlined,
FolderOutlined,
FolderViewOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -251,16 +240,13 @@ const AntdIcons = {
MenuFoldOutlined,
MenuUnfoldOutlined,
MinusCircleOutlined,
MinusSquareOutlined,
MonitorOutlined,
MoonOutlined,
MoreOutlined,
OrderedListOutlined,
PartitionOutlined,
PieChartOutlined,
PicCenterOutlined,
PlusCircleOutlined,
PlusSquareOutlined,
PlusOutlined,
ProfileOutlined,
ReloadOutlined,

View File

@@ -42,12 +42,10 @@ const customIcons = [
'Error',
'Full',
'Layers',
'Move',
'Multiple',
'Queued',
'Redo',
'Running',
'Sigma',
'Slack',
'Square',
'SortAsc',

View File

@@ -24,18 +24,12 @@ import { ImageLoader, type BackgroundPosition } from './ImageLoader';
global.URL.createObjectURL = jest.fn(() => '/local_url');
const blob = new Blob([], { type: 'image/png' });
beforeAll(() => {
fetchMock.mockGlobal();
});
afterAll(() => {
fetchMock.hardReset();
});
fetchMock.get(
'glob:*/thumbnail',
'/thumbnail',
{ body: blob, headers: { 'Content-Type': 'image/png' } },
{ name: 'thumbnail' },
{
sendAsJson: false,
},
);
describe('ImageLoader', () => {
@@ -50,7 +44,7 @@ describe('ImageLoader', () => {
return render(<ImageLoader {...props} />);
};
afterEach(() => fetchMock.clearHistory());
afterEach(() => fetchMock.resetHistory());
it('is a valid element', async () => {
setup();
@@ -63,7 +57,7 @@ describe('ImageLoader', () => {
'src',
'/fallback',
);
expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1);
expect(fetchMock.calls(/thumbnail/)).toHaveLength(1);
expect(global.URL.createObjectURL).toHaveBeenCalled();
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
'src',
@@ -72,14 +66,13 @@ describe('ImageLoader', () => {
});
it('displays fallback image when response is not an image', async () => {
fetchMock.once('glob:*/thumbnail2', {}, { name: 'thumbnail2' });
setup({ src: 'glob:*/thumbnail2' });
fetchMock.once('/thumbnail2', {});
setup({ src: '/thumbnail2' });
expect(screen.getByTestId('image-loader')).toHaveAttribute(
'src',
'/fallback',
);
expect(fetchMock.callHistory.calls(/thumbnail2/)).toHaveLength(1);
expect(fetchMock.calls(/thumbnail2/)).toHaveLength(1);
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
'src',
'/fallback',

View File

@@ -19,15 +19,6 @@
import { DatasourceType } from './types/Datasource';
const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
table: DatasourceType.Table,
query: DatasourceType.Query,
dataset: DatasourceType.Dataset,
sl_table: DatasourceType.SlTable,
saved_query: DatasourceType.SavedQuery,
semantic_view: DatasourceType.SemanticView,
};
export default class DatasourceKey {
readonly id: number;
@@ -36,7 +27,8 @@ export default class DatasourceKey {
constructor(key: string) {
const [idStr, typeStr] = key.split('__');
this.id = parseInt(idStr, 10);
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
this.type = DatasourceType.Table; // default to SqlaTable model
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
}
public toString() {

View File

@@ -26,7 +26,6 @@ export enum DatasourceType {
Dataset = 'dataset',
SlTable = 'sl_table',
SavedQuery = 'saved_query',
SemanticView = 'semantic_view',
}
export interface Currency {

View File

@@ -34,10 +34,8 @@ export enum FeatureFlag {
ConfirmDashboardDiff = 'CONFIRM_DASHBOARD_DIFF',
CssTemplates = 'CSS_TEMPLATES',
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardVirtualizationDeferData = 'DASHBOARD_VIRTUALIZATION_DEFER_DATA',
DashboardRbac = 'DASHBOARD_RBAC',
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
DatasetFolders = 'DATASET_FOLDERS',
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
/** @deprecated */
DrillToDetail = 'DRILL_TO_DETAIL',

View File

@@ -25,7 +25,6 @@ export { default as isEqualArray } from './isEqualArray';
export { default as makeSingleton } from './makeSingleton';
export { default as promiseTimeout } from './promiseTimeout';
export { default as removeDuplicates } from './removeDuplicates';
export { default as withLabel } from './withLabel';
export { lruCache } from './lruCache';
export { getSelectedText } from './getSelectedText';
export * from './featureFlags';

View File

@@ -1,43 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ValidatorFunction } from '../validator';
/**
* Wraps a validator function to prepend a label to its error message.
*
* @param validator - The validator function to wrap
* @param label - The label to prepend to error messages
* @returns A new validator function that includes the label in error messages
*
* @example
* validators: [
* withLabel(validateInteger, t('Row limit')),
* ]
* // Returns: "Row limit is expected to be an integer"
*/
export default function withLabel<V = unknown, S = unknown>(
validator: ValidatorFunction<V, S>,
label: string,
): ValidatorFunction<V, S> {
return (value: V, state?: S): string | false => {
const error = validator(value, state);
return error ? `${label} ${error}` : false;
};
}

View File

@@ -17,7 +17,6 @@
* under the License.
*/
export * from './types';
export { default as legacyValidateInteger } from './legacyValidateInteger';
export { default as legacyValidateNumber } from './legacyValidateNumber';
export { default as validateInteger } from './validateInteger';

View File

@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
* formerly called integer()
* @param v
*/
export default function legacyValidateInteger(v: unknown): string | false {
export default function legacyValidateInteger(v: unknown) {
if (
v &&
(Number.isNaN(Number(v)) || parseInt(v as string, 10) !== Number(v))

View File

@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
* formerly called numeric()
* @param v
*/
export default function numeric(v: unknown): string | false {
export default function numeric(v: unknown) {
if (v && Number.isNaN(Number(v))) {
return t('is expected to be a number');
}

View File

@@ -1,27 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Type definition for a validator function.
* Returns an error message string if validation fails, or false if validation passes.
*/
export type ValidatorFunction<V = unknown, S = unknown> = (
value: V,
state?: S,
) => string | false;

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core';
export default function validateInteger(v: unknown): string | false {
export default function validateInteger(v: unknown) {
if (
(typeof v === 'string' &&
v.trim().length > 0 &&

View File

@@ -25,7 +25,7 @@ const VALIDE_OSM_URLS = ['https://tile.osm', 'https://tile.openstreetmap'];
* Validate a [Mapbox styles URL](https://docs.mapbox.com/help/glossary/style-url/)
* @param v
*/
export default function validateMapboxStylesUrl(v: unknown): string | false {
export default function validateMapboxStylesUrl(v: unknown) {
if (typeof v === 'string') {
const trimmed_v = v.trim();
if (

View File

@@ -18,10 +18,7 @@
*/
import { t } from '@apache-superset/core';
export default function validateMaxValue(
v: unknown,
max: number,
): string | false {
export default function validateMaxValue(v: unknown, max: number) {
if (Number(v) > +max) {
return t('Value cannot exceed %s', max);
}

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core';
export default function validateNonEmpty(v: unknown): string | false {
export default function validateNonEmpty(v: unknown) {
if (
v === null ||
typeof v === 'undefined' ||

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core';
export default function validateNumber(v: unknown): string | false {
export default function validateInteger(v: any) {
if (
(typeof v === 'string' &&
v.trim().length > 0 &&

View File

@@ -23,7 +23,7 @@ export default function validateServerPagination(
serverPagination: boolean,
maxValueWithoutServerPagination: number,
maxServer: number,
): string | false {
) {
if (
Number(v) > +maxValueWithoutServerPagination &&
Number(v) <= maxServer &&

View File

@@ -22,13 +22,13 @@ import { t } from '@apache-superset/core';
import { ensureIsArray } from '../utils';
export const validateTimeComparisonRangeValues = (
timeRangeValue?: unknown,
controlValue?: unknown,
): string[] => {
timeRangeValue?: any,
controlValue?: any,
) => {
const isCustomTimeRange = timeRangeValue === ComparisonTimeRangeType.Custom;
const isCustomControlEmpty =
Array.isArray(controlValue) &&
controlValue.every((val: unknown) => ensureIsArray(val).length === 0);
const isCustomControlEmpty = controlValue?.every(
(val: any) => ensureIsArray(val).length === 0,
);
return isCustomTimeRange && isCustomControlEmpty
? [t('Filters for comparison must have a value')]
: [];

View File

@@ -37,9 +37,6 @@ import { SliceIdAndOrFormData } from '../../../src/chart/clients/ChartClient';
configureTranslation();
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('ChartClient', () => {
let chartClient: ChartClient;
@@ -53,7 +50,7 @@ describe('ChartClient', () => {
chartClient = new ChartClient();
});
afterEach(() => fetchMock.removeRoutes().clearHistory());
afterEach(() => fetchMock.restore());
describe('new ChartClient(config)', () => {
it('creates a client without argument', () => {

View File

@@ -21,13 +21,10 @@ import fetchMock from 'fetch-mock';
import { SupersetClient, SupersetClientClass } from '@superset-ui/core';
import { LOGIN_GLOB } from './fixtures/constants';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('SupersetClient', () => {
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '' }));
afterAll(() => fetchMock.removeRoutes().clearHistory());
afterAll(() => fetchMock.restore());
afterEach(() => SupersetClient.reset());
@@ -111,11 +108,9 @@ describe('SupersetClient', () => {
mockDeleteUrl,
];
networkCalls.map((url: string) =>
expect(
fetchMock.callHistory.calls(url)[0].options?.headers,
).toStrictEqual({
accept: 'application/json',
'x-csrftoken': '1234',
expect(fetchMock.calls(url)[0][1]?.headers).toStrictEqual({
Accept: 'application/json',
'X-CSRFToken': '1234',
}),
);
@@ -142,6 +137,6 @@ describe('SupersetClient', () => {
authenticatedSpy.mockRestore();
csrfSpy.mockRestore();
fetchMock.clearHistory().removeRoutes();
fetchMock.reset();
});
});

View File

@@ -20,15 +20,14 @@ import fetchMock from 'fetch-mock';
import { SupersetClientClass, ClientConfig, CallApi } from '@superset-ui/core';
import { LOGIN_GLOB } from './fixtures/constants';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('SupersetClientClass', () => {
beforeEach(() => {
fetchMock.clearHistory().removeRoutes();
fetchMock.get(LOGIN_GLOB, { result: '' }, { name: LOGIN_GLOB });
fetchMock.reset();
fetchMock.get(LOGIN_GLOB, { result: '' });
});
afterAll(() => fetchMock.restore());
describe('new SupersetClientClass()', () => {
it('fallback protocol to https when setting only host', () => {
const client = new SupersetClientClass({ host: 'TEST-HOST' });
@@ -90,22 +89,21 @@ describe('SupersetClientClass', () => {
});
describe('.init()', () => {
beforeEach(() => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
});
afterEach(() => fetchMock.clearHistory().removeRoutes());
beforeEach(() =>
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true }),
);
afterEach(() => fetchMock.reset());
it('calls api/v1/security/csrf_token/ when init() is called if no CSRF token is passed', async () => {
// expect.assertions(1);
expect.assertions(1);
await new SupersetClientClass().init();
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
});
it('does NOT call api/v1/security/csrf_token/ when init() is called if a CSRF token is passed', async () => {
expect.assertions(1);
await new SupersetClientClass({ csrfToken: 'abc' }).init();
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
});
it('calls api/v1/security/csrf_token/ when init(force=true) is called even if a CSRF token is passed', async () => {
@@ -114,19 +112,20 @@ describe('SupersetClientClass', () => {
const client = new SupersetClientClass({ csrfToken: initialToken });
await client.init();
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
expect(client.csrfToken).toBe(initialToken);
await client.init(true);
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1);
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
expect(client.csrfToken).not.toBe(initialToken);
});
it('throws if api/v1/security/csrf_token/ returns an error', async () => {
expect.assertions(1);
const rejectError = { status: 403 };
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { throws: rejectError }, { name: LOGIN_GLOB });
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectError), {
overwriteRoutes: true,
});
let error;
try {
@@ -142,7 +141,7 @@ describe('SupersetClientClass', () => {
it('throws if api/v1/security/csrf_token/ does not return a token', async () => {
expect.assertions(1);
fetchMock.modifyRoute(LOGIN_GLOB, { response: {} });
fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true });
let error;
try {
@@ -158,8 +157,9 @@ describe('SupersetClientClass', () => {
it('does not set csrfToken if response is not json', async () => {
expect.assertions(1);
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { response: '123' }, { name: LOGIN_GLOB });
fetchMock.get(LOGIN_GLOB, '123', {
overwriteRoutes: true,
});
let error;
try {
@@ -175,7 +175,7 @@ describe('SupersetClientClass', () => {
});
describe('.isAuthenticated()', () => {
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.reset());
it('returns true if there is a token and false if not', async () => {
expect.assertions(2);
@@ -227,8 +227,9 @@ describe('SupersetClientClass', () => {
expect.assertions(4);
const rejectValue = { status: 403 };
fetchMock.removeRoutes();
fetchMock.get(LOGIN_GLOB, { throws: rejectValue }, { name: LOGIN_GLOB });
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), {
overwriteRoutes: true,
});
const client = new SupersetClientClass({});
let error;
@@ -252,19 +253,18 @@ describe('SupersetClientClass', () => {
}
// reset
fetchMock.removeRoutes();
fetchMock.get(
LOGIN_GLOB,
{ result: 1234 },
{
name: LOGIN_GLOB,
overwriteRoutes: true,
},
);
});
});
describe('requests', () => {
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.restore());
const protocol = 'https:';
const host = 'host';
@@ -306,11 +306,11 @@ describe('SupersetClientClass', () => {
await client.delete({ url: mockDeleteUrl });
await client.request({ url: mockRequestUrl, method: 'DELETE' });
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockDeleteUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockRequestUrl)).toHaveLength(1);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockDeleteUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockRequestUrl)).toHaveLength(1);
expect(authSpy).toHaveBeenCalledTimes(5);
authSpy.mockRestore();
@@ -331,8 +331,7 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl });
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
.options as CallApi;
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
expect(fetchRequest.mode).toBe(clientConfig.mode);
expect(fetchRequest.credentials).toBe(clientConfig.credentials);
expect(fetchRequest.headers).toEqual(
@@ -355,11 +354,10 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl });
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
.options as CallApi;
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
expect(fetchRequest.headers).toEqual(
expect.objectContaining({
guesttokenheader: 'abc123',
guestTokenHeader: 'abc123',
}),
);
});
@@ -372,10 +370,10 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl });
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
await client.get({ endpoint: mockGetEndpoint });
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(2);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
});
it('supports parsing a response as text', async () => {
@@ -386,7 +384,7 @@ describe('SupersetClientClass', () => {
url: mockTextUrl,
parseMethod: 'text',
});
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
});
@@ -411,8 +409,7 @@ describe('SupersetClientClass', () => {
await client.init();
await client.get({ url: mockGetUrl, ...overrideConfig });
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
.options as CallApi;
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
expect(fetchRequest.headers).toEqual(
@@ -431,10 +428,10 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl });
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
await client.post({ endpoint: mockPostEndpoint });
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(2);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
});
it('allows overriding host, headers, mode, and credentials per-request', async () => {
@@ -457,8 +454,7 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl, ...overrideConfig });
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
.options as CallApi;
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
expect(fetchRequest.mode).toBe(overrideConfig.mode);
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
@@ -477,7 +473,7 @@ describe('SupersetClientClass', () => {
url: mockTextUrl,
parseMethod: 'text',
});
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(text).toBe(mockTextJsonResponse);
});
@@ -489,11 +485,10 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl, postPayload });
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
.options as CallApi;
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
const formData = fetchRequest.body as FormData;
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(JSON.stringify(value));
});
@@ -507,11 +502,10 @@ describe('SupersetClientClass', () => {
await client.init();
await client.post({ url: mockPostUrl, postPayload, stringify: false });
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
.options as CallApi;
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
const formData = fetchRequest.body as FormData;
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
Object.entries(postPayload).forEach(([key, value]) => {
expect(formData.get(key)).toBe(String(value));
});
@@ -534,7 +528,6 @@ describe('SupersetClientClass', () => {
// @ts-ignore
window.location = {
pathname: mockRequestPath,
// @ts-ignore
search: mockRequestSearch,
href: mockHref,
};
@@ -542,7 +535,9 @@ describe('SupersetClientClass', () => {
.spyOn(SupersetClientClass.prototype, 'ensureAuth')
.mockImplementation();
const rejectValue = { status: 401 };
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue));
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue), {
overwriteRoutes: true,
});
});
afterEach(() => {
@@ -568,11 +563,10 @@ describe('SupersetClientClass', () => {
it('should not redirect again if already on login page', async () => {
const client = new SupersetClientClass({});
// @ts-ignore
// @ts-expect-error
window.location = {
href: '/login?next=something',
pathname: '/login',
// @ts-ignore
search: '?next=something',
};
@@ -642,8 +636,7 @@ describe('SupersetClientClass', () => {
let createElement: any;
beforeEach(async () => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true });
client = new SupersetClientClass({ protocol, host });
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');

View File

@@ -29,17 +29,14 @@ const corruptObject = new BadObject();
/* @ts-expect-error */
BadObject.prototype.toString = undefined;
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
const mockGetUrl = 'glob:*/mock/get/url';
const mockPostUrl = 'glob:*/mock/post/url';
const mockPutUrl = 'glob:*/mock/put/url';
const mockPatchUrl = 'glob:*/mock/patch/url';
const mockCacheUrl = 'glob:*/mock/cache/url';
const mockNotFound = 'glob:*/mock/notfound';
const mockErrorUrl = 'glob:*/mock/error/url';
const mock503 = 'glob:*/mock/503';
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
const mockPutUrl = '/mock/put/url';
const mockPatchUrl = '/mock/patch/url';
const mockCacheUrl = '/mock/cache/url';
const mockNotFound = '/mock/notfound';
const mockErrorUrl = '/mock/error/url';
const mock503 = '/mock/503';
const mockGetPayload = { get: 'payload' };
const mockPostPayload = { post: 'payload' };
@@ -53,23 +50,20 @@ const mockCachePayload = {
const mockErrorPayload = { status: 500, statusText: 'Internal error' };
describe('callApi()', () => {
beforeAll(() => {
fetchMock.mockGlobal();
fetchMock.get(LOGIN_GLOB, { result: '1234' });
});
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
beforeEach(() => {
fetchMock.get(mockGetUrl, mockGetPayload);
fetchMock.post(mockPostUrl, mockPostPayload);
fetchMock.put(mockPutUrl, mockPutPayload);
fetchMock.patch(mockPatchUrl, mockPatchPayload);
fetchMock.get(mockCacheUrl, mockCachePayload, { name: mockCacheUrl });
fetchMock.get(mockCacheUrl, mockCachePayload);
fetchMock.get(mockNotFound, { status: 404 });
fetchMock.get(mock503, { status: 503 });
fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload));
});
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.reset());
describe('request config', () => {
it('calls the right url with the specified method', async () => {
@@ -80,10 +74,10 @@ describe('callApi()', () => {
callApi({ url: mockPutUrl, method: 'PUT' }),
callApi({ url: mockPatchUrl, method: 'PATCH' }),
]);
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.callHistory.calls(mockPatchUrl)).toHaveLength(1);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1);
});
it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', async () => {
@@ -98,11 +92,12 @@ describe('callApi()', () => {
},
redirect: 'follow',
signal: undefined,
body: 'BODY',
};
await callApi(mockRequest);
const calls = fetchMock.callHistory.calls(mockGetUrl);
const fetchParams = calls[0].options as RequestInit;
const calls = fetchMock.calls(mockGetUrl);
const fetchParams = calls[0][1] as RequestInit;
expect(calls).toHaveLength(1);
expect(fetchParams.mode).toBe(mockRequest.mode);
expect(fetchParams.cache).toBe(mockRequest.cache);
@@ -124,10 +119,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', anotherKey: 1237 };
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.callHistory.calls(mockPostUrl);
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0].options as RequestInit;
const fetchParams = calls[0][1] as RequestInit;
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -141,10 +136,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', noValue: undefined };
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
const calls = fetchMock.callHistory.calls(mockPostUrl);
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0].options as RequestInit;
const fetchParams = calls[0][1] as RequestInit;
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
@@ -172,13 +167,13 @@ describe('callApi()', () => {
}),
callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }),
]);
const calls = fetchMock.callHistory.calls(mockPostUrl);
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(3);
const stringified = (calls[0].options as RequestInit).body as FormData;
const unstringified = (calls[1].options as RequestInit).body as FormData;
const stringified = (calls[0][1] as RequestInit).body as FormData;
const unstringified = (calls[1][1] as RequestInit).body as FormData;
const jsonRequestBody = JSON.parse(
(calls[2].options as RequestInit).body as string,
(calls[2][1] as RequestInit).body as string,
) as JsonObject;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -216,9 +211,9 @@ describe('callApi()', () => {
stringify: false,
});
const calls = fetchMock.callHistory.calls(mockPostUrl);
const calls = fetchMock.calls(mockPostUrl);
expect(calls).toHaveLength(1);
const unstringified = (calls[0].options as RequestInit).body as FormData;
const unstringified = (calls[0][1] as RequestInit).body as FormData;
const hasCorruptKey = unstringified.has('corrupt');
expect(hasCorruptKey).toBeFalsy();
// When a corrupt attribute is encountered, a console.error call is made with info about the corrupt attribute
@@ -233,10 +228,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', anotherKey: 1237 };
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.callHistory.calls(mockPutUrl);
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0].options as RequestInit;
const fetchParams = calls[0][1] as RequestInit;
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -250,10 +245,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', noValue: undefined };
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
const calls = fetchMock.callHistory.calls(mockPutUrl);
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0].options as RequestInit;
const fetchParams = calls[0][1] as RequestInit;
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
@@ -280,11 +275,11 @@ describe('callApi()', () => {
stringify: false,
}),
]);
const calls = fetchMock.callHistory.calls(mockPutUrl);
const calls = fetchMock.calls(mockPutUrl);
expect(calls).toHaveLength(2);
const stringified = (calls[0].options as RequestInit).body as FormData;
const unstringified = (calls[1].options as RequestInit).body as FormData;
const stringified = (calls[0][1] as RequestInit).body as FormData;
const unstringified = (calls[1][1] as RequestInit).body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
@@ -299,10 +294,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', anotherKey: 1237 };
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.callHistory.calls(mockPatchUrl);
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0].options as RequestInit;
const fetchParams = calls[0][1] as RequestInit;
const body = fetchParams.body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
@@ -316,10 +311,10 @@ describe('callApi()', () => {
const postPayload = { key: 'value', noValue: undefined };
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
const calls = fetchMock.callHistory.calls(mockPatchUrl);
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(1);
const fetchParams = calls[0].options as RequestInit;
const fetchParams = calls[0][1] as RequestInit;
const body = fetchParams.body as FormData;
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
expect(body.get('noValue')).toBeNull();
@@ -346,11 +341,11 @@ describe('callApi()', () => {
stringify: false,
}),
]);
const calls = fetchMock.callHistory.calls(mockPatchUrl);
const calls = fetchMock.calls(mockPatchUrl);
expect(calls).toHaveLength(2);
const stringified = (calls[0].options as RequestInit).body as FormData;
const unstringified = (calls[1].options as RequestInit).body as FormData;
const stringified = (calls[0][1] as RequestInit).body as FormData;
const unstringified = (calls[1][1] as RequestInit).body as FormData;
Object.entries(postPayload).forEach(([key, value]) => {
expect(stringified.get(key)).toBe(JSON.stringify(value));
@@ -378,7 +373,7 @@ describe('callApi()', () => {
it('caches requests with ETags', async () => {
expect.assertions(2);
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.callHistory.calls(mockCacheUrl);
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
const supersetCache = await caches.open(constants.CACHE_KEY);
const cachedResponse = await supersetCache.match(mockCacheUrl);
@@ -390,7 +385,7 @@ describe('callApi()', () => {
window.location.protocol = 'http:';
await callApi({ url: mockCacheUrl, method: 'GET' });
const calls = fetchMock.callHistory.calls(mockCacheUrl);
const calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
const supersetCache = await caches.open(constants.CACHE_KEY);
@@ -404,7 +399,7 @@ describe('callApi()', () => {
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false });
const firstResponse = await callApi({ url: mockCacheUrl, method: 'GET' });
let calls = fetchMock.callHistory.calls(mockCacheUrl);
let calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
const firstBody = await firstResponse.text();
expect(firstBody).toEqual('BODY');
@@ -413,8 +408,8 @@ describe('callApi()', () => {
url: mockCacheUrl,
method: 'GET',
});
calls = fetchMock.callHistory.calls(mockCacheUrl);
const fetchParams = calls[1].options as RequestInit;
calls = fetchMock.calls(mockCacheUrl);
const fetchParams = calls[1][1] as RequestInit;
expect(calls).toHaveLength(2);
// second call should not have If-None-Match header
expect(fetchParams.headers).toBeUndefined();
@@ -429,14 +424,14 @@ describe('callApi()', () => {
expect.assertions(3);
// first call sets the cache
await callApi({ url: mockCacheUrl, method: 'GET' });
let calls = fetchMock.callHistory.calls(mockCacheUrl);
let calls = fetchMock.calls(mockCacheUrl);
expect(calls).toHaveLength(1);
// second call sends the Etag in the If-None-Match header
await callApi({ url: mockCacheUrl, method: 'GET' });
calls = fetchMock.callHistory.calls(mockCacheUrl);
const fetchParams = calls[1].options as RequestInit;
const headers = { 'if-none-match': 'etag' };
calls = fetchMock.calls(mockCacheUrl);
const fetchParams = calls[1][1] as RequestInit;
const headers = { 'If-None-Match': 'etag' };
expect(calls).toHaveLength(2);
expect(fetchParams.headers).toEqual(
expect.objectContaining(headers) as typeof fetchParams.headers,
@@ -447,16 +442,16 @@ describe('callApi()', () => {
expect.assertions(3);
// first call sets the cache
await callApi({ url: mockCacheUrl, method: 'GET' });
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(1);
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(1);
// second call reuses the cached payload on a 304
const mockCachedPayload = { status: 304 };
fetchMock.modifyRoute(mockCacheUrl, { response: mockCachedPayload });
fetchMock.get(mockCacheUrl, mockCachedPayload, { overwriteRoutes: true });
const secondResponse = await callApi({
url: mockCacheUrl,
method: 'GET',
});
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(2);
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(2);
const secondBody = await secondResponse.text();
expect(secondBody).toEqual('BODY');
});
@@ -466,7 +461,7 @@ describe('callApi()', () => {
// this should never happen, since a 304 is only returned if we have
// the cached response and sent the If-None-Match header
const mockUncachedUrl = 'glob:*/mock/uncached/url';
const mockUncachedUrl = '/mock/uncached/url';
const mockCachedPayload = { status: 304 };
let error;
fetchMock.get(mockUncachedUrl, mockCachedPayload);
@@ -476,7 +471,7 @@ describe('callApi()', () => {
} catch (err) {
error = err;
} finally {
const calls = fetchMock.callHistory.calls(mockUncachedUrl);
const calls = fetchMock.calls(mockUncachedUrl);
expect(calls).toHaveLength(1);
expect((error as { message: string }).message).toEqual(
'Received 304 but no content is cached!',
@@ -488,7 +483,7 @@ describe('callApi()', () => {
expect.assertions(3);
const url = mockGetUrl;
const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.callHistory.calls(url);
const calls = fetchMock.calls(url);
expect(calls).toHaveLength(1);
expect(response.status).toEqual(200);
const body = await response.json();
@@ -499,7 +494,7 @@ describe('callApi()', () => {
expect.assertions(2);
const url = mockNotFound;
const response = await callApi({ url, method: 'GET' });
const calls = fetchMock.callHistory.calls(url);
const calls = fetchMock.calls(url);
expect(calls).toHaveLength(1);
expect(response.status).toEqual(404);
});
@@ -518,7 +513,7 @@ describe('callApi()', () => {
error = err;
} finally {
const err = error as { status: number; statusText: string };
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(4);
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4);
expect(err.status).toBe(mockErrorPayload.status);
expect(err.statusText).toBe(mockErrorPayload.statusText);
}
@@ -536,7 +531,7 @@ describe('callApi()', () => {
} catch (err) {
error = err as { status: number; statusText: string };
} finally {
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(1);
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
expect(error?.status).toBe(mockErrorPayload.status);
expect(error?.statusText).toBe(mockErrorPayload.statusText);
}
@@ -550,7 +545,7 @@ describe('callApi()', () => {
url,
method: 'GET',
});
const calls = fetchMock.callHistory.calls(url);
const calls = fetchMock.calls(url);
expect(calls).toHaveLength(4);
expect(response.status).toEqual(503);
});
@@ -586,9 +581,7 @@ describe('callApi()', () => {
const result = await response.json();
expect(response.status).toEqual(200);
expect(result).toEqual({ yes: 'ok' });
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
`http://localhost/get-search?abc=1`,
);
expect(fetchMock.lastUrl()).toEqual(`http://localhost/get-search?abc=1`);
});
it('should accept URLSearchParams', async () => {
@@ -603,10 +596,8 @@ describe('callApi()', () => {
method: 'POST',
jsonPayload: { request: 'ok' },
});
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
`http://localhost/post-search?abc=1`,
);
expect(fetchMock.callHistory.lastCall()?.options).toEqual(
expect(fetchMock.lastUrl()).toEqual(`http://localhost/post-search?abc=1`);
expect(fetchMock.lastOptions()).toEqual(
expect.objectContaining({
body: JSON.stringify({ request: 'ok' }),
}),
@@ -643,7 +634,7 @@ describe('callApi()', () => {
method: 'POST',
postPayload: payload,
});
expect(fetchMock.callHistory.lastCall()?.options.body).toBe(payload);
expect(fetchMock.lastOptions()?.body).toBe(payload);
});
it('should ignore "null" postPayload string', async () => {
@@ -655,6 +646,6 @@ describe('callApi()', () => {
method: 'POST',
postPayload: 'null',
});
expect(fetchMock.callHistory.lastCall()?.options.body).toBeUndefined();
expect(fetchMock.lastOptions()?.body).toBeUndefined();
});
});

View File

@@ -30,16 +30,15 @@ import { LOGIN_GLOB } from '../fixtures/constants';
const mockGetUrl = '/mock/get/url';
const mockGetPayload = { get: 'payload' };
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('callApiAndParseWithTimeout()', () => {
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
beforeEach(() => fetchMock.get(mockGetUrl, mockGetPayload));
afterAll(() => fetchMock.restore());
afterEach(() => {
fetchMock.removeRoutes().clearHistory();
fetchMock.reset();
jest.useRealTimers();
});
@@ -109,7 +108,7 @@ describe('callApiAndParseWithTimeout()', () => {
} catch (err) {
error = err;
} finally {
expect(fetchMock.callHistory.calls(mockTimeoutUrl)).toHaveLength(1);
expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1);
expect(error).toEqual({
error: 'Request timed out',
statusText: 'timeout',

View File

@@ -22,15 +22,12 @@ import parseResponse from '../../../src/connection/callApi/parseResponse';
import { LOGIN_GLOB } from '../fixtures/constants';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('parseResponse()', () => {
beforeAll(() => {
fetchMock.get(LOGIN_GLOB, { result: '1234' });
});
afterAll(() => fetchMock.removeRoutes().clearHistory());
afterAll(() => fetchMock.restore());
const mockGetUrl = '/mock/get/url';
const mockPostUrl = '/mock/post/url';
@@ -48,7 +45,7 @@ describe('parseResponse()', () => {
fetchMock.get(mockNoParseUrl, new Response('test response'));
});
afterEach(() => fetchMock.removeRoutes().clearHistory());
afterEach(() => fetchMock.reset());
it('returns a Promise', () => {
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' });
@@ -61,7 +58,7 @@ describe('parseResponse()', () => {
const args = await parseResponse(
callApi({ url: mockGetUrl, method: 'GET' }),
);
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('json');
@@ -84,7 +81,7 @@ describe('parseResponse()', () => {
} catch (err) {
error = err as Error;
} finally {
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
expect(error?.stack).toBeDefined();
expect(error?.message).toContain('Unexpected token');
}
@@ -102,7 +99,7 @@ describe('parseResponse()', () => {
callApi({ url: mockTextParseUrl, method: 'GET' }),
'text',
);
expect(fetchMock.callHistory.calls(mockTextParseUrl)).toHaveLength(1);
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1);
const keys = Object.keys(args);
expect(keys).toContain('response');
expect(keys).toContain('text');
@@ -137,7 +134,7 @@ describe('parseResponse()', () => {
callApi({ url: mockNoParseUrl, method: 'GET' }),
'raw',
);
expect(fetchMock.callHistory.calls(mockNoParseUrl)).toHaveLength(2);
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(2);
expect(responseNull.bodyUsed).toBe(false);
expect(responseRaw.bodyUsed).toBe(false);
});
@@ -196,7 +193,7 @@ describe('parseResponse()', () => {
} catch (err) {
error = err as { ok: boolean; status: number };
} finally {
expect(fetchMock.callHistory.calls(mockNotOkayUrl)).toHaveLength(1);
expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1);
expect(error?.ok).toBe(false);
expect(error?.status).toBe(404);
}

View File

@@ -21,13 +21,10 @@ import { getDatasourceMetadata } from '../../../../src/query/api/legacy';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('getFormData()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.restore());
it('returns datasource metadata for given datasource key', () => {
const mockData = {

View File

@@ -22,13 +22,10 @@ import { getFormData } from '../../../../src/query/api/legacy';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('getFormData()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.restore());
const mockData = {
datasource: '1__table',

View File

@@ -20,13 +20,9 @@ import fetchMock from 'fetch-mock';
import { buildQueryContext, ApiV1, VizType } from '@superset-ui/core';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('API v1 > getChartData()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.restore());
it('returns a promise of ChartDataResponse', async () => {
const response = {

View File

@@ -21,13 +21,9 @@ import { JsonValue, SupersetClientClass } from '@superset-ui/core';
import { makeApi, SupersetApiError } from '../../../../src/query';
import setupClientForTest from '../setupClientForTest';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
describe('makeApi()', () => {
beforeAll(() => setupClientForTest());
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.restore());
it('should expose method and endpoint', () => {
const api = makeApi({
@@ -99,7 +95,7 @@ describe('makeApi()', () => {
const expected = new FormData();
expected.append('request', JSON.stringify('test'));
const received = fetchMock.callHistory.lastCall()?.options.body as FormData;
const received = fetchMock.lastOptions()?.body as FormData;
expect(received).toBeInstanceOf(FormData);
expect(received.get('request')).toEqual(expected.get('request'));
@@ -113,7 +109,7 @@ describe('makeApi()', () => {
});
fetchMock.get('glob:*/test-get-search*', { search: 'get' });
await api({ p1: 1, p2: 2, p3: [1, 2] });
expect(fetchMock.callHistory.lastCall()?.url).toContain(
expect(fetchMock.lastUrl()).toContain(
'/test-get-search?p1=1&p2=2&p3=1%2C2',
);
});
@@ -127,7 +123,7 @@ describe('makeApi()', () => {
});
fetchMock.get('glob:*/test-post-search*', { rison: 'get' });
await api({ p1: 1, p3: [1, 2] });
expect(fetchMock.callHistory.lastCall()?.url).toContain(
expect(fetchMock.lastUrl()).toContain(
'/test-post-search?q=(p1:1,p3:!(1,2))',
);
});
@@ -141,9 +137,7 @@ describe('makeApi()', () => {
});
fetchMock.post('glob:*/test-post-search*', { search: 'post' });
await api({ p1: 1, p3: [1, 2] });
expect(fetchMock.callHistory.lastCall()?.url).toContain(
'/test-post-search?p1=1&p3=1%2C2',
);
expect(fetchMock.lastUrl()).toContain('/test-post-search?p1=1&p3=1%2C2');
});
it('should throw when requestType is invalid', () => {
@@ -221,8 +215,6 @@ describe('makeApi()', () => {
fetchMock.delete('glob:*/test-raw-response?*', 'ok');
const result = await api({ field1: 11 }, {});
expect(result).toEqual(200);
expect(fetchMock.callHistory.lastCall()?.url).toContain(
'/test-raw-response?field1=11',
);
expect(fetchMock.lastUrl()).toContain('/test-raw-response?field1=11');
});
});

View File

@@ -25,10 +25,7 @@ import {
formatTimeRangeComparison,
} from '../../src/time-comparison/fetchTimeRange';
beforeAll(() => fetchMock.mockGlobal());
afterAll(() => fetchMock.hardReset());
afterEach(() => fetchMock.clearHistory().removeRoutes());
afterEach(() => fetchMock.restore());
test('generates proper time range string', () => {
expect(
@@ -87,41 +84,34 @@ test('returns a formatted time range from empty response', async () => {
});
test('returns a formatted error message from response', async () => {
const getTimeRangeUrl = 'glob:*/api/v1/time_range/?q=%27Last+day%27';
fetchMock.get(
getTimeRangeUrl,
{
throws: new Response(JSON.stringify({ message: 'Network error' })),
},
{ name: getTimeRangeUrl },
);
fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', {
throws: new Response(JSON.stringify({ message: 'Network error' })),
});
let timeRange = await fetchTimeRange('Last day');
expect(timeRange).toEqual({
error: 'Network error',
});
fetchMock.removeRoute(getTimeRangeUrl);
fetchMock.get(
getTimeRangeUrl,
'glob:*/api/v1/time_range/?q=%27Last+day%27',
{
throws: new Error('Internal Server Error'),
},
{ name: getTimeRangeUrl },
{ overwriteRoutes: true },
);
timeRange = await fetchTimeRange('Last day');
expect(timeRange).toEqual({
error: 'Internal Server Error',
});
fetchMock.removeRoute(getTimeRangeUrl);
fetchMock.get(
getTimeRangeUrl,
'glob:*/api/v1/time_range/?q=%27Last+day%27',
{
throws: new Response(JSON.stringify({ statusText: 'Network error' }), {
statusText: 'Network error',
}),
},
{ name: getTimeRangeUrl },
{ overwriteRoutes: true },
);
timeRange = await fetchTimeRange('Last day');
expect(timeRange).toEqual({

View File

@@ -20,13 +20,13 @@
import { validateMaxValue } from '@superset-ui/core';
import './setup';
test('validateMaxValue returns the warning message if invalid', () => {
test('validateInteger returns the warning message if invalid', () => {
expect(validateMaxValue(10.1, 10)).toBeTruthy();
expect(validateMaxValue(1, 0)).toBeTruthy();
expect(validateMaxValue('2', 1)).toBeTruthy();
});
test('validateMaxValue returns false if the input is valid', () => {
test('validateInteger returns false if the input is valid', () => {
expect(validateMaxValue(0, 1)).toBeFalsy();
expect(validateMaxValue(10, 10)).toBeFalsy();
expect(validateMaxValue(undefined, 1)).toBeFalsy();

View File

@@ -36,11 +36,11 @@
"@emotion/styled": "^11.14.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@react-icons/all-files": "^4.1.0",
"@storybook/addon-actions": "^8.6.15",
"@storybook/addon-controls": "^8.6.15",
"@storybook/addon-links": "^8.6.15",
"@storybook/react": "^8.6.15",
"@storybook/types": "^8.6.15",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-controls": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/types": "8.6.14",
"@types/react-loadable": "^5.5.11",
"core-js": "3.48.0",
"gh-pages": "^6.3.0",
@@ -52,19 +52,19 @@
"react-resizable": "^3.1.3"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@babel/core": "^7.28.6",
"@babel/preset-env": "^7.28.6",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@storybook/react-webpack5": "^8.6.15",
"@storybook/react-webpack5": "8.6.14",
"babel-loader": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"@superset-ui/legacy-plugin-chart-calendar": "*",
"@superset-ui/legacy-plugin-chart-chord": "*",
"@superset-ui/legacy-plugin-chart-country-map": "*",

View File

@@ -74,9 +74,6 @@ export default defineConfig({
viewport: { width: 1280, height: 1024 },
// Accept downloads without prompts (needed for export tests)
acceptDownloads: true,
// Screenshots and videos on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
@@ -120,19 +117,10 @@ export default defineConfig({
// Web server setup - disabled in CI (Flask started separately in workflow)
webServer: process.env.CI
? undefined
: (() => {
// Support custom base URL (e.g., http://localhost:9012/app/prefix/)
const baseUrl =
process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
// Extract origin (scheme + host + port) for health check
// Health endpoint is always at /health regardless of app prefix
const healthUrl = new URL('/health', new URL(baseUrl).origin).href;
return {
// Quote URL to prevent shell injection via PLAYWRIGHT_BASE_URL
command: `curl -f '${healthUrl}'`,
url: healthUrl,
reuseExistingServer: true,
timeout: 5000,
};
})(),
: {
command: 'curl -f http://localhost:8088/health',
url: 'http://localhost:8088/health',
reuseExistingServer: true,
timeout: 5000,
},
});

View File

@@ -1,116 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { Button, Checkbox, Table } from '../core';
const BULK_SELECT_SELECTORS = {
CONTROLS: '[data-test="bulk-select-controls"]',
ACTION: '[data-test="bulk-select-action"]',
} as const;
/**
* BulkSelect component for Superset ListView bulk operations.
* Provides a reusable interface for bulk selection and actions across list pages.
*
* @example
* const bulkSelect = new BulkSelect(page, table);
* await bulkSelect.enable();
* await bulkSelect.selectRow('my-dataset');
* await bulkSelect.selectRow('another-dataset');
* await bulkSelect.clickAction('Delete');
*/
export class BulkSelect {
private readonly page: Page;
private readonly table: Table;
constructor(page: Page, table: Table) {
this.page = page;
this.table = table;
}
/**
* Gets the "Bulk select" toggle button
*/
getToggleButton(): Button {
return new Button(
this.page,
this.page.getByRole('button', { name: 'Bulk select' }),
);
}
/**
* Enables bulk selection mode by clicking the toggle button
*/
async enable(): Promise<void> {
await this.getToggleButton().click();
}
/**
* Gets the checkbox for a row by name
* @param rowName - The name/text identifying the row
*/
getRowCheckbox(rowName: string): Checkbox {
const row = this.table.getRow(rowName);
return new Checkbox(this.page, row.getByRole('checkbox'));
}
/**
* Selects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to select
*/
async selectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).check();
}
/**
* Deselects a row's checkbox in bulk select mode
* @param rowName - The name/text identifying the row to deselect
*/
async deselectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).uncheck();
}
/**
* Gets the bulk select controls container locator (for assertions)
*/
getControls(): Locator {
return this.page.locator(BULK_SELECT_SELECTORS.CONTROLS);
}
/**
* Gets a bulk action button by name
* @param actionName - The name of the bulk action (e.g., "Export", "Delete")
*/
getActionButton(actionName: string): Button {
const controls = this.getControls();
return new Button(
this.page,
controls.locator(BULK_SELECT_SELECTORS.ACTION, { hasText: actionName }),
);
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* @param actionName - The name of the bulk action to click
*/
async clickAction(actionName: string): Promise<void> {
await this.getActionButton(actionName).click();
}
}

View File

@@ -1,21 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// ListView-specific Playwright Components for Superset
export { BulkSelect } from './BulkSelect';

View File

@@ -1,207 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
const ACE_EDITOR_SELECTORS = {
TEXT_INPUT: '.ace_text-input',
TEXT_LAYER: '.ace_text-layer',
CONTENT: '.ace_content',
SCROLLER: '.ace_scroller',
} as const;
/**
* AceEditor component for interacting with Ace Editor instances in Playwright.
* Uses the ace editor API directly for reliable text manipulation.
*/
export class AceEditor {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Gets the editor element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Waits for the ace editor to be fully loaded and ready for interaction.
*/
async waitForReady(): Promise<void> {
// Wait for editor to be attached (outer .ace_editor div may be CSS-hidden)
await this.locator.waitFor({ state: 'attached' });
await this.locator
.locator(ACE_EDITOR_SELECTORS.CONTENT)
.waitFor({ state: 'attached' });
// Wait for window.ace library to be fully loaded (may load async)
await this.page.waitForFunction(
() =>
typeof (window as unknown as { ace?: { edit?: unknown } }).ace?.edit ===
'function',
{ timeout: 10000 },
);
}
/**
* Sets text in the ace editor using the ace API.
* Uses element handle to target the specific editor instance (not global ID lookup).
* @param text - The text to set
*/
async setText(text: string): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(
({ element, value }) => {
const windowWithAce = window as unknown as {
ace?: {
edit(el: Element): {
setValue(v: string, c: number): void;
session: { getUndoManager(): { reset(): void } };
};
};
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
// ace.edit() accepts either an element ID string or the DOM element itself
const editor = windowWithAce.ace.edit(element);
editor.setValue(value, 1);
editor.session.getUndoManager().reset();
},
{ element: elementHandle, value: text },
);
}
/**
* Gets the text content from the ace editor.
* Uses element handle to target the specific editor instance.
* @returns The text content
*/
async getText(): Promise<string> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
return this.page.evaluate(element => {
const windowWithAce = window as unknown as {
ace?: { edit(el: Element): { getValue(): string } };
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
return windowWithAce.ace.edit(element).getValue();
}, elementHandle);
}
/**
* Clears the text in the ace editor.
*/
async clear(): Promise<void> {
await this.setText('');
}
/**
* Appends text to the existing content in the ace editor.
* Uses element handle to target the specific editor instance.
* @param text - The text to append
*/
async appendText(text: string): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(
({ element, value }) => {
const windowWithAce = window as unknown as {
ace?: {
edit(el: Element): {
getValue(): string;
setValue(v: string, c: number): void;
};
};
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
const editor = windowWithAce.ace.edit(element);
const currentText = editor.getValue();
// Only add newline if there's existing text that doesn't already end with one
const needsNewline = currentText && !currentText.endsWith('\n');
const newText = currentText + (needsNewline ? '\n' : '') + value;
editor.setValue(newText, 1);
},
{ element: elementHandle, value: text },
);
}
/**
* Focuses the ace editor.
* Uses element handle to target the specific editor instance.
*/
async focus(): Promise<void> {
await this.waitForReady();
const elementHandle = await this.locator.elementHandle();
if (!elementHandle) {
throw new Error('Could not get element handle for ace editor');
}
await this.page.evaluate(element => {
const windowWithAce = window as unknown as {
ace?: { edit(el: Element): { focus(): void } };
};
if (!windowWithAce.ace) {
throw new Error(
'Ace editor library not loaded. Ensure the page has finished loading.',
);
}
windowWithAce.ace.edit(element).focus();
}, elementHandle);
}
/**
* Checks if the editor is visible.
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
}

View File

@@ -1,95 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
/**
* Core Checkbox component used in Playwright tests to interact with checkbox
* elements in the Superset UI.
*
* This class wraps a Playwright {@link Locator} pointing to a checkbox input
* and provides convenience methods for common interactions such as checking,
* unchecking, toggling, and asserting checkbox state and visibility.
*
* @example
* const checkbox = new Checkbox(page, page.locator('input[type="checkbox"]'));
* await checkbox.check();
* await expect(await checkbox.isChecked()).toBe(true);
*
* @param page - The Playwright {@link Page} instance associated with the test.
* @param locator - The Playwright {@link Locator} targeting the checkbox element.
*/
export class Checkbox {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, locator: Locator) {
this.page = page;
this.locator = locator;
}
/**
* Gets the checkbox element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Checks the checkbox (ensures it's checked)
*/
async check(): Promise<void> {
await this.locator.check();
}
/**
* Unchecks the checkbox (ensures it's unchecked)
*/
async uncheck(): Promise<void> {
await this.locator.uncheck();
}
/**
* Toggles the checkbox state
*/
async toggle(): Promise<void> {
await this.locator.click();
}
/**
* Checks if the checkbox is checked
*/
async isChecked(): Promise<boolean> {
return this.locator.isChecked();
}
/**
* Checks if the checkbox is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the checkbox is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -1,217 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { TIMEOUT } from '../../utils/constants';
/**
* Menu component for Ant Design dropdown menus.
* Uses hover as primary approach (most natural user interaction).
* Falls back to keyboard navigation, then dispatchEvent if hover fails.
*
* This component handles menu content only - not the trigger that opens the menu.
* The calling page object should open the menu first, then use this component.
*
* @example
* // In a page object
* async selectDownloadOption(optionText: string): Promise<void> {
* await this.openHeaderActionsMenu();
* const menu = new Menu(this.page, '[data-test="header-actions-menu"]');
* await menu.selectSubmenuItem('Download', optionText);
* }
*/
export class Menu {
private readonly page: Page;
private readonly locator: Locator;
private static readonly SELECTORS = {
SUBMENU: '.ant-dropdown-menu-submenu',
SUBMENU_POPUP: '.ant-dropdown-menu-submenu-popup',
SUBMENU_TITLE: '.ant-dropdown-menu-submenu-title',
} as const;
/**
* Ant Design animation delay - allows slide-in animation to complete.
* Without this, elements may be "not stable" and clicks can fail.
*/
private static readonly ANIMATION_DELAY = 150;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Opens a submenu and selects an item within it.
* Uses hover as primary approach, falls back to keyboard then dispatchEvent.
*
* @param submenuText - The text of the submenu to open (e.g., "Download")
* @param itemText - The text of the item to select (e.g., "Export YAML")
* @param options - Optional timeout settings
*/
async selectSubmenuItem(
submenuText: string,
itemText: string,
options?: { timeout?: number },
): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
// Try hover first (most natural user interaction)
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
// Fallback to keyboard navigation
if (!popup) {
popup = await this.openSubmenuWithKeyboard(
submenuText,
itemText,
timeout,
);
}
// Last resort: dispatchEvent
if (!popup) {
popup = await this.openSubmenuWithDispatchEvent(
submenuText,
itemText,
timeout,
);
}
if (!popup) {
throw new Error(
`Failed to open submenu "${submenuText}". Tried hover, keyboard, and dispatchEvent.`,
);
}
// Use dispatchEvent instead of click to bypass viewport and pointer interception
// issues. Ant Design renders submenu popups in a portal that can be positioned
// outside the viewport or behind chart content (e.g., large tables with z-index).
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
}
/**
* Opens a submenu using native Playwright hover.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithHover(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.hover();
// Find the popup that contains the expected item (scopes to correct popup)
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
// Allow Ant Design's slide-in animation to complete before clicking.
// Without this, the element may be "not stable" and clicks can fail.
await this.page.waitForTimeout(Menu.ANIMATION_DELAY);
return popup;
} catch {
return null;
}
}
/**
* Opens a submenu using keyboard navigation.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithKeyboard(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.focus();
await this.page.keyboard.press('ArrowRight');
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
} catch {
return null;
}
}
/**
* Opens a submenu using dispatchEvent to trigger mouseover/mouseenter.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithDispatchEvent(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.evaluate(el => {
el.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
view: window,
}),
);
el.dispatchEvent(
new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window,
}),
);
});
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
} catch {
return null;
}
}
/**
* Gets the submenu title element for a submenu containing the given text.
*/
private getSubmenuTitle(submenuText: string): Locator {
return this.locator
.locator(Menu.SELECTORS.SUBMENU)
.filter({ hasText: submenuText })
.locator(Menu.SELECTORS.SUBMENU_TITLE);
}
}

View File

@@ -1,187 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
/**
* Ant Design Select component selectors
*/
const SELECT_SELECTORS = {
DROPDOWN: '.ant-select-dropdown',
OPTION: '.ant-select-item-option',
SEARCH_INPUT: '.ant-select-selection-search-input',
CLEAR: '.ant-select-clear',
} as const;
/**
* Select component for Ant Design Select/Combobox interactions.
*/
export class Select {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Creates a Select from a combobox role with the given accessible name
* @param page - The Playwright page
* @param name - The accessible name (aria-label or placeholder text)
*/
static fromRole(page: Page, name: string): Select {
const locator = page.getByRole('combobox', { name });
return new Select(page, locator);
}
/**
* Gets the select element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Opens the dropdown, types to filter, and selects an option.
* Handles cases where the option may not be initially visible in the dropdown.
* Waits for dropdown to close after selection to avoid stale dropdowns.
* @param optionText - The text of the option to select
*/
async selectOption(optionText: string): Promise<void> {
await this.open();
await this.type(optionText);
await this.clickOption(optionText);
// Wait for dropdown to close to avoid multiple visible dropdowns
await this.waitForDropdownClose();
}
/**
* Waits for dropdown to close after selection
* This prevents strict mode violations when multiple selects are used sequentially
*/
private async waitForDropdownClose(): Promise<void> {
// Wait for dropdown to actually close (become hidden)
await this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last()
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(error => {
// Only ignore TimeoutError (dropdown may already be closed); re-throw others
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
});
}
/**
* Opens the dropdown
*/
async open(): Promise<void> {
await this.locator.click();
}
/**
* Clicks an option in an already-open dropdown by its text content.
* Uses selector-based approach matching Cypress patterns.
* Handles multiple dropdowns by targeting only visible, non-hidden ones.
* @param optionText - The text of the option to click (partial match for filtered results)
*/
async clickOption(optionText: string): Promise<void> {
// Target visible dropdown (excludes hidden ones via :not(.ant-select-dropdown-hidden))
// Use .last() in case multiple dropdowns exist - the most recent one is what we want
const dropdown = this.page
.locator(`${SELECT_SELECTORS.DROPDOWN}:not(.ant-select-dropdown-hidden)`)
.last();
await dropdown.waitFor({ state: 'visible' });
// Find option by text content - use partial match since filtered results may have prefixes
// (e.g., searching for 'main' shows 'examples.main', 'system.main')
// First try exact match, fall back to partial match
const exactOption = dropdown
.locator(SELECT_SELECTORS.OPTION)
.getByText(optionText, { exact: true });
if ((await exactOption.count()) > 0) {
await exactOption.click();
} else {
// Fall back to first option containing the text
const partialOption = dropdown
.locator(SELECT_SELECTORS.OPTION)
.filter({ hasText: optionText })
.first();
await partialOption.click();
}
}
/**
* Closes the dropdown by pressing Escape
*/
async close(): Promise<void> {
await this.page.keyboard.press('Escape');
}
/**
* Types into the select to filter options (assumes dropdown is open)
* @param text - The text to type
*/
async type(text: string): Promise<void> {
// Find the actual search input inside the select component
const searchInput = this.locator.locator(SELECT_SELECTORS.SEARCH_INPUT);
try {
// Wait for search input in case dropdown is still rendering
await searchInput.first().waitFor({ state: 'attached', timeout: 1000 });
await searchInput.first().fill(text);
} catch (error) {
// Only handle TimeoutError (search input not found); re-throw other errors
if (!(error instanceof Error) || error.name !== 'TimeoutError') {
throw error;
}
// Fallback: locator might be the input itself (e.g., from getByRole('combobox'))
await this.locator.fill(text);
}
}
/**
* Clears the current selection
*/
async clear(): Promise<void> {
await this.locator.clear();
}
/**
* Checks if the select is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the select is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -1,75 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
/**
* Tabs component for Ant Design tab navigation.
*/
export class Tabs {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, locator?: Locator) {
this.page = page;
// Default to the tablist role if no specific locator provided
this.locator = locator ?? page.getByRole('tablist');
}
/**
* Gets the tablist element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Gets a tab by name, scoped to this tablist's container
* @param tabName - The name/label of the tab
*/
getTab(tabName: string): Locator {
return this.locator.getByRole('tab', { name: tabName });
}
/**
* Clicks a tab by name
* @param tabName - The name/label of the tab to click
*/
async clickTab(tabName: string): Promise<void> {
await this.getTab(tabName).click();
}
/**
* Gets the tab panel content for a given tab
* @param tabName - The name/label of the tab
*/
getTabPanel(tabName: string): Locator {
return this.page.getByRole('tabpanel', { name: tabName });
}
/**
* Checks if a tab is selected
* @param tabName - The name/label of the tab
*/
async isSelected(tabName: string): Promise<boolean> {
const tab = this.getTab(tabName);
const ariaSelected = await tab.getAttribute('aria-selected');
return ariaSelected === 'true';
}
}

View File

@@ -1,109 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
/**
* Playwright helper for interacting with HTML {@link HTMLTextAreaElement | `<textarea>`} elements.
*
* This component wraps a Playwright {@link Locator} and provides convenience methods for
* filling, clearing, and reading the value of a textarea without having to work with
* locators directly.
*
* Typical usage:
* ```ts
* const textarea = new Textarea(page, 'textarea[name="description"]');
* await textarea.fill('Some multi-line text');
* const value = await textarea.getValue();
* ```
*
* You can also construct an instance from the `name` attribute:
* ```ts
* const textarea = Textarea.fromName(page, 'description');
* await textarea.clear();
* ```
*/
export class Textarea {
readonly page: Page;
private readonly locator: Locator;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Creates a Textarea from a name attribute
* @param page - The Playwright page
* @param name - The name attribute value
*/
static fromName(page: Page, name: string): Textarea {
const locator = page.locator(`textarea[name="${name}"]`);
return new Textarea(page, locator);
}
/**
* Gets the textarea element locator
*/
get element(): Locator {
return this.locator;
}
/**
* Fills the textarea with text (clears existing content)
* @param text - The text to fill
*/
async fill(text: string): Promise<void> {
await this.locator.fill(text);
}
/**
* Clears the textarea content
*/
async clear(): Promise<void> {
await this.locator.clear();
}
/**
* Gets the current value of the textarea
*/
async getValue(): Promise<string> {
return this.locator.inputValue();
}
/**
* Checks if the textarea is visible
*/
async isVisible(): Promise<boolean> {
return this.locator.isVisible();
}
/**
* Checks if the textarea is enabled
*/
async isEnabled(): Promise<boolean> {
return this.locator.isEnabled();
}
}

View File

@@ -18,15 +18,8 @@
*/
// Core Playwright Components for Superset
export { AceEditor } from './AceEditor';
export { Button } from './Button';
export { Checkbox } from './Checkbox';
export { Form } from './Form';
export { Input } from './Input';
export { Menu } from './Menu';
export { Modal } from './Modal';
export { Select } from './Select';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { Textarea } from './Textarea';
export { Toast } from './Toast';

View File

@@ -1,75 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page, Locator } from '@playwright/test';
import { Modal } from '../core/Modal';
/**
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
*/
export class ConfirmDialog extends Modal {
private readonly specificLocator: Locator;
constructor(page: Page, dialogName = 'Confirm save') {
super(page);
// Use getByRole with specific name to avoid strict mode violations
// when multiple dialogs are open (e.g., Edit Dataset modal + Confirm save dialog)
this.specificLocator = page.getByRole('dialog', { name: dialogName });
}
/**
* Override element getter to use specific locator
*/
override get element(): Locator {
return this.specificLocator;
}
/**
* Clicks the OK button to confirm.
* @param options.timeout - If provided, silently returns if dialog doesn't appear
* within timeout. If not provided, waits indefinitely (strict mode).
*/
async clickOk(options?: { timeout?: number }): Promise<void> {
try {
await this.element.waitFor({
state: 'visible',
timeout: options?.timeout,
});
await this.clickFooterButton('OK');
await this.waitForHidden();
} catch (error) {
// Only swallow TimeoutError when timeout was explicitly provided
if (options?.timeout !== undefined) {
if (error instanceof Error && error.name === 'TimeoutError') {
return;
}
}
throw error;
}
}
/**
* Clicks the Cancel button to dismiss
*/
async clickCancel(): Promise<void> {
await this.clickFooterButton('Cancel');
}
}

View File

@@ -55,10 +55,7 @@ export class DuplicateDatasetModal extends Modal {
datasetName: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
const input = this.nameInput.element;
// Clear existing text then fill (fill() clears first, but explicit clear is more reliable)
await input.clear();
await input.fill(datasetName, options);
await this.nameInput.fill(datasetName, options);
}
/**

View File

@@ -1,189 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { Input, Modal, Tabs, AceEditor } from '../core';
/**
* Edit Dataset Modal component (DatasourceModal).
* Used for editing dataset properties like description, metrics, columns, etc.
* Uses specific dialog name to avoid strict mode violations when multiple dialogs are open.
*/
export class EditDatasetModal extends Modal {
private static readonly SELECTORS = {
NAME_INPUT: '[data-test="inline-name"]',
LOCK_ICON: '[data-test="lock"]',
UNLOCK_ICON: '[data-test="unlock"]',
};
private readonly tabs: Tabs;
private readonly specificLocator: Locator;
constructor(page: Page) {
super(page);
// Use getByRole with specific name to target Edit Dataset dialog
// The dialog has aria-labelledby that resolves to "edit Edit Dataset"
this.specificLocator = page.getByRole('dialog', { name: /edit.*dataset/i });
// Scope tabs to modal's tablist to avoid matching tablists elsewhere on page
this.tabs = new Tabs(page, this.specificLocator.getByRole('tablist'));
}
/**
* Override element getter to use specific locator
*/
override get element(): Locator {
return this.specificLocator;
}
/**
* Click the Save button to save changes
*/
async clickSave(): Promise<void> {
await this.clickFooterButton('Save');
}
/**
* Click the Cancel button to discard changes
*/
async clickCancel(): Promise<void> {
await this.clickFooterButton('Cancel');
}
/**
* Click the lock icon to enable edit mode
* The modal starts in read-only mode and requires clicking the lock to edit
*/
async enableEditMode(): Promise<void> {
const lockButton = this.body.locator(EditDatasetModal.SELECTORS.LOCK_ICON);
await lockButton.click();
}
/**
* Gets the dataset name input component
*/
private get nameInput(): Input {
return new Input(
this.page,
this.body.locator(EditDatasetModal.SELECTORS.NAME_INPUT),
);
}
/**
* Fill in the dataset name field
* Note: Call enableEditMode() first if the modal is in read-only mode
* @param name - The new dataset name
*/
async fillName(name: string): Promise<void> {
await this.nameInput.fill(name);
}
/**
* Navigate to a specific tab in the modal
* @param tabName - The name of the tab (e.g., 'Source', 'Metrics', 'Columns')
*/
async clickTab(tabName: string): Promise<void> {
await this.tabs.clickTab(tabName);
}
/**
* Navigate to the Settings tab
*/
async clickSettingsTab(): Promise<void> {
await this.tabs.clickTab('Settings');
}
/**
* Navigate to the Columns tab.
* Uses regex to avoid matching "Calculated columns" tab, scoped to modal.
*/
async clickColumnsTab(): Promise<void> {
// Use regex starting with "Columns" to avoid matching "Calculated columns"
// Scope to modal element to avoid matching tabs elsewhere on page
await this.element.getByRole('tab', { name: /^Columns/ }).click();
}
/**
* Gets the description Ace Editor component (Settings tab).
* The Description button and ace-editor are in the same form item.
*/
private get descriptionEditor(): AceEditor {
// Use tabpanel role with name "Settings" for more reliable lookup
const settingsPanel = this.element.getByRole('tabpanel', {
name: 'Settings',
});
// Find the form item that contains the Description button
const descriptionFormItem = settingsPanel
.locator('.ant-form-item')
.filter({
has: this.page.getByRole('button', {
name: 'Description',
exact: true,
}),
})
.first();
// The ace-editor has class .ace_editor within the form item
const editorElement = descriptionFormItem.locator('.ace_editor');
return new AceEditor(this.page, editorElement);
}
/**
* Fill the dataset description field (Settings tab).
* @param description - The description text to set
*/
async fillDescription(description: string): Promise<void> {
await this.descriptionEditor.setText(description);
}
/**
* Expand a column row by column name.
* Uses exact cell match to avoid false positives with short names like "ds".
* @param columnName - The name of the column to expand
* @returns The row locator for scoped selector access
*/
async expandColumn(columnName: string): Promise<Locator> {
// Find cell with exact column name text, then derive row from that cell
const cell = this.body.getByRole('cell', { name: columnName, exact: true });
const row = cell.locator('xpath=ancestor::tr[1]');
await row.getByRole('button', { name: /expand row/i }).click();
return row;
}
/**
* Fill column datetime format for a given column.
* Expands the column row and fills the date format input.
* Note: Expanded content appears in a sibling row, so we scope to modal body.
* @param columnName - The name of the column to edit
* @param format - The python date format string (e.g., '%Y-%m-%d')
*/
async fillColumnDateFormat(
columnName: string,
format: string,
): Promise<void> {
await this.expandColumn(columnName);
// Expanded content appears in a sibling row, not nested inside the original row.
// Use modal body scope with placeholder selector to find the datetime format input.
const dateFormatInput = new Input(
this.page,
this.body.getByPlaceholder('%Y-%m-%d'),
);
await dateFormatInput.element.waitFor({ state: 'visible' });
await dateFormatInput.clear();
await dateFormatInput.fill(format);
}
}

View File

@@ -1,73 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Modal, Input } from '../core';
/**
* Import dataset modal for uploading dataset export files.
* Handles file upload, overwrite confirmation, and import submission.
*/
export class ImportDatasetModal extends Modal {
private static readonly SELECTORS = {
FILE_INPUT: '[data-test="model-file-input"]',
OVERWRITE_INPUT: '[data-test="overwrite-modal-input"]',
};
/**
* Upload a file to the import modal
* @param filePath - Absolute path to the file to upload
*/
async uploadFile(filePath: string): Promise<void> {
await this.page
.locator(ImportDatasetModal.SELECTORS.FILE_INPUT)
.setInputFiles(filePath);
}
/**
* Fill the overwrite confirmation input (only needed if dataset exists)
*/
async fillOverwriteConfirmation(): Promise<void> {
const input = new Input(
this.page,
this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT),
);
await input.fill('OVERWRITE');
}
/**
* Get the overwrite confirmation input locator
*/
getOverwriteInput() {
return this.body.locator(ImportDatasetModal.SELECTORS.OVERWRITE_INPUT);
}
/**
* Check if overwrite confirmation is visible
*/
async isOverwriteVisible(): Promise<boolean> {
return this.getOverwriteInput().isVisible();
}
/**
* Click the Import button in the footer
*/
async clickImport(): Promise<void> {
await this.clickFooterButton('Import');
}
}

View File

@@ -20,4 +20,3 @@
// Specific modal implementations
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';

View File

@@ -1,61 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { Response, APIResponse } from '@playwright/test';
import { expect } from '@playwright/test';
/**
* Common interface for response types with status() method.
* Supports both Response (network interception) and APIResponse (page.request API).
*/
type ResponseLike = Response | APIResponse;
/**
* Verify response has exact status code
* @param response - Playwright Response or APIResponse object
* @param expected - Expected status code
* @returns The response for chaining
*/
export function expectStatus<T extends ResponseLike>(
response: T,
expected: number,
): T {
expect(
response.status(),
`Expected status ${expected}, got ${response.status()}`,
).toBe(expected);
return response;
}
/**
* Verify response status code is one of the expected values
* @param response - Playwright Response or APIResponse object
* @param expected - Array of acceptable status codes
* @returns The response for chaining
*/
export function expectStatusOneOf<T extends ResponseLike>(
response: T,
expected: number[],
): T {
expect(
expected,
`Expected status to be one of ${expected.join(', ')}, got ${response.status()}`,
).toContain(response.status());
return response;
}

View File

@@ -18,33 +18,12 @@
*/
import { Page, APIResponse } from '@playwright/test';
import rison from 'rison';
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
const ENDPOINTS = {
DATABASE: 'api/v1/database/',
} as const;
/**
* TypeScript interface for database API response
*/
export interface DatabaseResult {
id: number;
database_name: string;
/** Optional - list API masks this for security, only detail API returns it */
sqlalchemy_uri?: string;
backend?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
supports_dynamic_catalog?: boolean;
supports_file_upload?: boolean;
supports_oauth2?: boolean;
};
extra?: string;
expose_in_sqllab?: boolean;
impersonate_user?: boolean;
}
/**
* TypeScript interface for database creation API payload
* Provides compile-time safety for required fields
@@ -52,7 +31,6 @@ export interface DatabaseResult {
export interface DatabaseCreatePayload {
database_name: string;
engine: string;
sqlalchemy_uri?: string;
configuration_method?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
@@ -99,53 +77,3 @@ export async function apiDeleteDatabase(
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}
/**
* GET request to fetch a database's details
* @param page - Playwright page instance (provides authentication context)
* @param databaseId - ID of the database to fetch
* @returns API response with database details
*/
export async function apiGetDatabase(
page: Page,
databaseId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}
/**
* Get a database by its name
* @param page - Playwright page instance (provides authentication context)
* @param databaseName - The database_name to search for
* @returns Database object if found, null if not found
*/
export async function getDatabaseByName(
page: Page,
databaseName: string,
): Promise<DatabaseResult | null> {
const filter = {
filters: [
{
col: 'database_name',
opr: 'eq',
value: databaseName,
},
],
};
const queryParam = rison.encode(filter);
const response = await apiGet(page, `${ENDPOINTS.DATABASE}?q=${queryParam}`, {
failOnStatusCode: false,
});
if (!response.ok()) {
return null;
}
const body = await response.json();
if (body.result && body.result.length > 0) {
return body.result[0] as DatabaseResult;
}
return null;
}

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