mirror of
https://github.com/apache/superset.git
synced 2026-04-28 12:34:23 +00:00
Compare commits
95 Commits
fix-docker
...
semantic-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11ac4dd90 | ||
|
|
e182520bb3 | ||
|
|
bfa4d5bd92 | ||
|
|
0e9c71e283 | ||
|
|
5c1e250b77 | ||
|
|
87d15d32c4 | ||
|
|
7d9a8a0c5a | ||
|
|
ddba88ffad | ||
|
|
1e50422a66 | ||
|
|
246dbd7f5c | ||
|
|
9b861b2848 | ||
|
|
1c35c3f6d0 | ||
|
|
b71654877f | ||
|
|
cd447ca1fd | ||
|
|
01ac966b83 | ||
|
|
97e5f0631d | ||
|
|
b7acb7984f | ||
|
|
d3919cf24f | ||
|
|
27889651b3 | ||
|
|
361fe6fe89 | ||
|
|
8506d70242 | ||
|
|
00a53eec2d | ||
|
|
5040db859c | ||
|
|
ef4f7afa90 | ||
|
|
47db185e3b | ||
|
|
2e463078a2 | ||
|
|
4f42928b34 | ||
|
|
75fa474fce | ||
|
|
fd8c21591a | ||
|
|
4147d877fc | ||
|
|
a9dca529c1 | ||
|
|
20f1918dd6 | ||
|
|
c09a4f6f47 | ||
|
|
4e4fa53c8d | ||
|
|
07ff82f189 | ||
|
|
b7b9bfd3fe | ||
|
|
b968d1095c | ||
|
|
e10237fcc1 | ||
|
|
92438322c0 | ||
|
|
f96e90b979 | ||
|
|
b464979db1 | ||
|
|
45f883c9cd | ||
|
|
8fd3401077 | ||
|
|
89a98ab9a4 | ||
|
|
2dfc770b0f | ||
|
|
6b7b23ed78 | ||
|
|
5ac5480f35 | ||
|
|
76889c1a69 | ||
|
|
569606635b | ||
|
|
66264856a7 | ||
|
|
3eb860a663 | ||
|
|
a44980da65 | ||
|
|
7112bce961 | ||
|
|
568486a304 | ||
|
|
fea135b46c | ||
|
|
601fcb3382 | ||
|
|
0d7cc88b2b | ||
|
|
32ee160c75 | ||
|
|
5914e83436 | ||
|
|
0b5e4dd5de | ||
|
|
3a565a6c16 | ||
|
|
f60c82e4a6 | ||
|
|
91131d5996 | ||
|
|
4b0d497513 | ||
|
|
86f690d17f | ||
|
|
e9b494163b | ||
|
|
be404f9b84 | ||
|
|
11257c0536 | ||
|
|
f2b6c395cd | ||
|
|
2d35ed2391 | ||
|
|
bd65469091 | ||
|
|
a6a66ca483 | ||
|
|
4a7cdccdad | ||
|
|
61bd8f0cf2 | ||
|
|
ae10e105c2 | ||
|
|
901dca58f7 | ||
|
|
d95a3d8426 | ||
|
|
70b95ca1b9 | ||
|
|
004f02746f | ||
|
|
5d20dc57d7 | ||
|
|
05c2354997 | ||
|
|
6043e7e7e3 | ||
|
|
1ee14c5993 | ||
|
|
9764a84402 | ||
|
|
570cc3e5f8 | ||
|
|
66519c3a85 | ||
|
|
1f43138888 | ||
|
|
652d029a2d | ||
|
|
e67b1f5326 | ||
|
|
fa79a467e4 | ||
|
|
2cce0308d4 | ||
|
|
c7fd1a2f65 | ||
|
|
ab4f646ef6 | ||
|
|
d6029f5c8a | ||
|
|
c16e8f747c |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -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
|
||||
/.github/ @villebro @geido @eschutho @rusackas @betodealmeida @nytai @mistercrunch @craig-rueda @kgabryje @dpgaspar @sadpandajoe @hainenber
|
||||
|
||||
# Notify PMC members of changes to required GitHub Actions
|
||||
|
||||
|
||||
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
4
.github/workflows/ephemeral-env.yml
vendored
4
.github/workflows/ephemeral-env.yml
vendored
@@ -189,7 +189,7 @@ jobs:
|
||||
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
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@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
4
.github/workflows/superset-docs-deploy.yml
vendored
4
.github/workflows/superset-docs-deploy.yml
vendored
@@ -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@v12
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
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@v12
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
|
||||
4
.github/workflows/superset-docs-verify.yml
vendored
4
.github/workflows/superset-docs-verify.yml
vendored
@@ -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@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
|
||||
- uses: JustinBeckwith/linkinator-action@f62ba0c110a76effb2ee6022cc6ce4ab161085e3 # v2.4
|
||||
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@v12
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -52,6 +52,7 @@ 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:
|
||||
|
||||
@@ -27,6 +27,7 @@ repos:
|
||||
args: [--check-untyped-defs]
|
||||
exclude: ^superset-extensions-cli/
|
||||
additional_dependencies: [
|
||||
types-cachetools,
|
||||
types-simplejson,
|
||||
types-python-dateutil,
|
||||
types-requests,
|
||||
|
||||
@@ -430,6 +430,11 @@ 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"]
|
||||
|
||||
15
UPDATING.md
15
UPDATING.md
@@ -24,6 +24,21 @@ 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
|
||||
|
||||
@@ -159,8 +159,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 configuration
|
||||
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-127.0.0.1}"
|
||||
# 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_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
|
||||
ports:
|
||||
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces
|
||||
|
||||
@@ -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"
|
||||
# Bind to all interfaces so Docker port mapping works
|
||||
# Webpack dev server must bind to 0.0.0.0 to be accessible from outside the container
|
||||
WEBPACK_DEVSERVER_HOST: "0.0.0.0"
|
||||
ports:
|
||||
- "127.0.0.1:${NODE_PORT:-9000}:9000" # exposing the dynamic webpack dev server
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -105,7 +105,12 @@ class CeleryConfig:
|
||||
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True}
|
||||
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.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -171,9 +171,11 @@ 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',
|
||||
@@ -186,14 +188,6 @@ const config: Config = {
|
||||
],
|
||||
plugins: [
|
||||
require.resolve('./src/webpack.extend.ts'),
|
||||
[
|
||||
'docusaurus-plugin-less',
|
||||
{
|
||||
lessOptions: {
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
...dynamicPlugins,
|
||||
[
|
||||
'docusaurus-plugin-openapi-docs',
|
||||
|
||||
@@ -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=4096"
|
||||
NODE_OPTIONS = "--max-old-space-size=8192"
|
||||
|
||||
# Deploy preview settings
|
||||
[context.deploy-preview]
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
|
||||
"start": "yarn run _init && yarn run generate:all && NODE_ENV=development docusaurus start",
|
||||
"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",
|
||||
"stop": "pkill -f 'docusaurus start' || pkill -f 'docusaurus serve' || echo 'No docusaurus server running'",
|
||||
"build": "yarn run _init && yarn run generate:all && DEBUG=docusaurus:* docusaurus build",
|
||||
"build": "yarn run _init && yarn run generate:all && NODE_OPTIONS='--max-old-space-size=8192' 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",
|
||||
@@ -22,7 +23,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 && yarn run generate:api-docs",
|
||||
"generate:all": "yarn run generate:extension-components & yarn run generate:superset-components & yarn run generate:database-docs & wait && 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",
|
||||
@@ -38,15 +39,11 @@
|
||||
},
|
||||
"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",
|
||||
@@ -55,41 +52,39 @@
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^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/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/csf": "^0.1.13",
|
||||
"@storybook/docs-tools": "^8.6.11",
|
||||
"@storybook/preview-api": "^8.6.11",
|
||||
"@storybook/theming": "^8.6.11",
|
||||
"@storybook/docs-tools": "^8.6.15",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"antd": "^6.2.2",
|
||||
"babel-loader": "^9.2.1",
|
||||
"caniuse-lite": "^1.0.30001766",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"@swc/core": "^1.15.11",
|
||||
"antd": "^6.2.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001769",
|
||||
"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": "7.1.2",
|
||||
"react-resize-detector": "^9.1.1",
|
||||
"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": {
|
||||
@@ -104,11 +99,11 @@
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.2.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"webpack": "^5.104.1"
|
||||
"webpack": "^5.105.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -124,7 +119,8 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"react-redux": "^9.2.0",
|
||||
"@reduxjs/toolkit": "^2.5.0"
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"baseline-browser-mapping": "^2.9.19"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"generated": "2026-01-27T23:17:43.310Z",
|
||||
"generated": "2026-01-31T10:47:01.730Z",
|
||||
"statistics": {
|
||||
"totalDatabases": 68,
|
||||
"withDocumentation": 68,
|
||||
"withConnectionString": 68,
|
||||
"withDrivers": 35,
|
||||
"totalDatabases": 70,
|
||||
"withDocumentation": 70,
|
||||
"withConnectionString": 70,
|
||||
"withDrivers": 36,
|
||||
"withAuthMethods": 4,
|
||||
"supportsJoins": 64,
|
||||
"supportsSubqueries": 65,
|
||||
"supportsJoins": 66,
|
||||
"supportsSubqueries": 67,
|
||||
"supportsDynamicSchema": 15,
|
||||
"supportsCatalog": 9,
|
||||
"averageScore": 33,
|
||||
"averageScore": 32,
|
||||
"maxScore": 201,
|
||||
"byCategory": {
|
||||
"Other Databases": [
|
||||
@@ -109,6 +109,8 @@
|
||||
"Traditional RDBMS": [
|
||||
"Aurora MySQL (Data API)",
|
||||
"Aurora PostgreSQL (Data API)",
|
||||
"Aurora MySQL",
|
||||
"Aurora PostgreSQL",
|
||||
"CockroachDB",
|
||||
"Cloudflare D1",
|
||||
"IBM Db2",
|
||||
@@ -133,6 +135,8 @@
|
||||
"Open Source": [
|
||||
"Aurora MySQL (Data API)",
|
||||
"Aurora PostgreSQL (Data API)",
|
||||
"Aurora MySQL",
|
||||
"Aurora PostgreSQL",
|
||||
"ClickHouse",
|
||||
"CockroachDB",
|
||||
"Couchbase",
|
||||
@@ -490,6 +494,132 @@
|
||||
"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",
|
||||
|
||||
@@ -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.less';
|
||||
import '../styles/main.css';
|
||||
|
||||
// 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 > 10 && (
|
||||
{companiesWithLogos.length > 7 && (
|
||||
<BlurredSection>
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<SectionHeader
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
@import 'antd-theme.less';
|
||||
|
||||
body {
|
||||
font-family: var(--ifm-font-family-base);
|
||||
@@ -81,26 +80,29 @@ a > span > svg {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
&::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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
@@ -109,32 +111,32 @@ a > span > svg {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.get-started-button {
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
width: 142px;
|
||||
padding: 7px 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.navbar .get-started-button {
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
width: 142px;
|
||||
padding: 7px 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.github-button {
|
||||
background-image: url('/img/github.png');
|
||||
background-size: contain;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.navbar .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);
|
||||
}
|
||||
|
||||
.github-button {
|
||||
background-image: url('/img/github-dark.png');
|
||||
}
|
||||
.navbar--dark .github-button {
|
||||
background-image: url('/img/github-dark.png');
|
||||
}
|
||||
|
||||
.navbar__logo {
|
||||
@@ -153,11 +155,11 @@ a > span > svg {
|
||||
.navbar {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.get-started-button,
|
||||
.github-button {
|
||||
display: none;
|
||||
}
|
||||
.navbar .get-started-button,
|
||||
.navbar .github-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar__items {
|
||||
@@ -186,20 +188,20 @@ a > span > svg {
|
||||
--docsearch-searchbox-background: var(--ifm-navbar-background-color);
|
||||
border: 1px solid #187384;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&.DocSearch-Button {
|
||||
width: 225px;
|
||||
}
|
||||
.navbar .DocSearch.DocSearch-Button {
|
||||
width: 225px;
|
||||
}
|
||||
|
||||
.DocSearch-Search-Icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.navbar .DocSearch .DocSearch-Search-Icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.DocSearch-Button-Key,
|
||||
.DocSearch-Button-Placeholder {
|
||||
display: none;
|
||||
}
|
||||
.navbar .DocSearch .DocSearch-Button-Key,
|
||||
.navbar .DocSearch .DocSearch-Button-Placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar--dark .DocSearch {
|
||||
@@ -232,25 +234,25 @@ a > span > svg {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.footer__ci-services span {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s;
|
||||
.footer__ci-services a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
.footer__ci-services a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 28px;
|
||||
}
|
||||
.footer__ci-services img {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.footer__divider {
|
||||
@@ -268,13 +270,13 @@ a > span > svg {
|
||||
.footer__ci-services {
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer__ci-services span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 22px;
|
||||
}
|
||||
.footer__ci-services img {
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,8 @@ export default function webpackExtendPlugin(): Plugin<void> {
|
||||
use: 'js-yaml-loader',
|
||||
});
|
||||
|
||||
// Add babel-loader rule for superset-frontend files
|
||||
// This ensures Emotion CSS-in-JS is processed correctly for SSG
|
||||
// Add swc-loader rule for superset-frontend files
|
||||
// SWC is a Rust-based transpiler that's significantly faster than babel
|
||||
const supersetFrontendPath = path.resolve(
|
||||
__dirname,
|
||||
'../../superset-frontend',
|
||||
@@ -76,26 +76,37 @@ export default function webpackExtendPlugin(): Plugin<void> {
|
||||
config.module?.rules?.push({
|
||||
test: /\.(tsx?|jsx?)$/,
|
||||
include: supersetFrontendPath,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
loader: 'swc-loader',
|
||||
options: {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-react',
|
||||
{
|
||||
// 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: {
|
||||
runtime: 'automatic',
|
||||
importSource: '@emotion/react',
|
||||
},
|
||||
],
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
plugins: ['@emotion/babel-plugin'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
devtool: isDev ? 'eval-source-map' : config.devtool,
|
||||
devtool: isDev ? false : config.devtool,
|
||||
cache: {
|
||||
type: 'filesystem',
|
||||
buildDependencies: {
|
||||
config: [__filename],
|
||||
},
|
||||
},
|
||||
...(isDev && {
|
||||
optimization: {
|
||||
...config.optimization,
|
||||
@@ -208,8 +219,6 @@ 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
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
13
docs/static/feature-flags.json
vendored
13
docs/static/feature-flags.json
vendored
@@ -114,6 +114,12 @@
|
||||
"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,
|
||||
@@ -241,6 +247,13 @@
|
||||
"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,
|
||||
|
||||
BIN
docs/static/img/databases/alloydb.png
vendored
Normal file
BIN
docs/static/img/databases/alloydb.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
57
docs/static/img/databases/apache-iotdb.svg
vendored
Normal file
57
docs/static/img/databases/apache-iotdb.svg
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
BIN
docs/static/img/databases/apache-phoenix.png
vendored
Normal file
BIN
docs/static/img/databases/apache-phoenix.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/static/img/databases/neon.png
vendored
Normal file
BIN
docs/static/img/databases/neon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
1
docs/static/img/databases/supabase.svg
vendored
Normal file
1
docs/static/img/databases/supabase.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/static/img/logos/xnet.png
vendored
Normal file
BIN
docs/static/img/logos/xnet.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
4445
docs/yarn.lock
4445
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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.6"]
|
||||
postgres = ["psycopg2-binary==2.9.9"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.328.0"]
|
||||
prophet = ["prophet>=1.1.6, <2"]
|
||||
@@ -204,6 +204,7 @@ 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",
|
||||
@@ -437,6 +438,7 @@ authorized_licenses = [
|
||||
"apache software",
|
||||
"apache software, bsd",
|
||||
"bsd",
|
||||
"bsd-2-clause",
|
||||
"bsd-3-clause",
|
||||
"isc license (iscl)",
|
||||
"isc license",
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
urllib3>=2.6.0,<3.0.0
|
||||
werkzeug>=3.0.1
|
||||
# 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
|
||||
numexpr>=2.9.0
|
||||
|
||||
# 5.0.0 has a sensitive deprecation used in other libs
|
||||
|
||||
@@ -36,8 +36,10 @@ blinker==1.9.0
|
||||
# via flask
|
||||
bottleneck==1.5.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
brotli==1.1.0
|
||||
# via flask-compress
|
||||
brotli==1.2.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask-compress
|
||||
cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
@@ -101,6 +103,8 @@ 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)
|
||||
@@ -289,7 +293,7 @@ prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==16.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
pyasn1==0.6.1
|
||||
pyasn1==0.6.2
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# rsa
|
||||
@@ -436,7 +440,7 @@ tzdata==2025.2
|
||||
# pandas
|
||||
url-normalize==2.2.1
|
||||
# via requests-cache
|
||||
urllib3==2.6.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
@@ -453,7 +457,7 @@ wcwidth==0.2.13
|
||||
# via prompt-toolkit
|
||||
websocket-client==1.8.0
|
||||
# via selenium
|
||||
werkzeug==3.1.3
|
||||
werkzeug==3.1.5
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask
|
||||
|
||||
@@ -76,11 +76,17 @@ 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.1.0
|
||||
brotli==1.2.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-compress
|
||||
@@ -235,8 +241,10 @@ fakeredis==2.32.1
|
||||
# via pydocket
|
||||
fastmcp==2.14.3
|
||||
# via apache-superset
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
filelock==3.20.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# virtualenv
|
||||
flask==2.3.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -458,6 +466,10 @@ 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
|
||||
@@ -700,7 +712,7 @@ protobuf==4.25.5
|
||||
# proto-plus
|
||||
psutil==6.1.0
|
||||
# via apache-superset
|
||||
psycopg2-binary==2.9.6
|
||||
psycopg2-binary==2.9.9
|
||||
# via apache-superset
|
||||
py-key-value-aio==0.3.0
|
||||
# via
|
||||
@@ -714,7 +726,7 @@ pyarrow==16.1.0
|
||||
# apache-superset
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
pyasn1==0.6.1
|
||||
pyasn1==0.6.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pyasn1-modules
|
||||
@@ -810,6 +822,7 @@ python-dateutil==2.9.0.post0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# botocore
|
||||
# celery
|
||||
# croniter
|
||||
# flask-appbuilder
|
||||
@@ -913,6 +926,8 @@ 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
|
||||
@@ -1061,9 +1076,10 @@ url-normalize==2.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# requests-cache
|
||||
urllib3==2.6.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# botocore
|
||||
# docker
|
||||
# requests
|
||||
# requests-cache
|
||||
@@ -1095,7 +1111,7 @@ websocket-client==1.8.0
|
||||
# selenium
|
||||
websockets==15.0.1
|
||||
# via fastmcp
|
||||
werkzeug==3.1.3
|
||||
werkzeug==3.1.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
"""
|
||||
105
superset-core/src/superset_core/semantic_layers/semantic_view.py
Normal file
105
superset-core/src/superset_core/semantic_layers/semantic_view.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# 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.
|
||||
"""
|
||||
328
superset-core/src/superset_core/semantic_layers/types.py
Normal file
328
superset-core/src/superset_core/semantic_layers/types.py
Normal file
@@ -0,0 +1,328 @@
|
||||
# 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 dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from functools import total_ordering
|
||||
from typing import Type as TypeOf
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
__all__ = [
|
||||
"BINARY",
|
||||
"BOOLEAN",
|
||||
"DATE",
|
||||
"DATETIME",
|
||||
"DECIMAL",
|
||||
"Day",
|
||||
"Dimension",
|
||||
"Hour",
|
||||
"INTEGER",
|
||||
"INTERVAL",
|
||||
"Minute",
|
||||
"Month",
|
||||
"NUMBER",
|
||||
"OBJECT",
|
||||
"Quarter",
|
||||
"Second",
|
||||
"STRING",
|
||||
"TIME",
|
||||
"Week",
|
||||
"Year",
|
||||
]
|
||||
|
||||
|
||||
class Type:
|
||||
"""
|
||||
Base class for types.
|
||||
"""
|
||||
|
||||
|
||||
class INTEGER(Type):
|
||||
"""
|
||||
Represents an integer type.
|
||||
"""
|
||||
|
||||
|
||||
class NUMBER(Type):
|
||||
"""
|
||||
Represents a number type.
|
||||
"""
|
||||
|
||||
|
||||
class DECIMAL(Type):
|
||||
"""
|
||||
Represents a decimal type.
|
||||
"""
|
||||
|
||||
|
||||
class STRING(Type):
|
||||
"""
|
||||
Represents a string type.
|
||||
"""
|
||||
|
||||
|
||||
class BOOLEAN(Type):
|
||||
"""
|
||||
Represents a boolean type.
|
||||
"""
|
||||
|
||||
|
||||
class DATE(Type):
|
||||
"""
|
||||
Represents a date type.
|
||||
"""
|
||||
|
||||
|
||||
class TIME(Type):
|
||||
"""
|
||||
Represents a time type.
|
||||
"""
|
||||
|
||||
|
||||
class DATETIME(DATE, TIME):
|
||||
"""
|
||||
Represents a datetime type.
|
||||
"""
|
||||
|
||||
|
||||
class INTERVAL(Type):
|
||||
"""
|
||||
Represents an interval type.
|
||||
"""
|
||||
|
||||
|
||||
class OBJECT(Type):
|
||||
"""
|
||||
Represents an object type.
|
||||
"""
|
||||
|
||||
|
||||
class BINARY(Type):
|
||||
"""
|
||||
Represents a binary type.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@total_ordering
|
||||
class Grain:
|
||||
"""
|
||||
Base class for time and date grains with comparison support.
|
||||
|
||||
Attributes:
|
||||
name: Human-readable name of the grain (e.g., "Second")
|
||||
representation: ISO 8601 representation (e.g., "PT1S")
|
||||
value: Time period as a timedelta
|
||||
"""
|
||||
|
||||
name: str
|
||||
representation: str
|
||||
value: timedelta
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, Grain):
|
||||
return self.value == other.value
|
||||
return NotImplemented
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
if isinstance(other, Grain):
|
||||
return self.value < other.value
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.name, self.representation, self.value))
|
||||
|
||||
|
||||
class Second(Grain):
|
||||
name = "Second"
|
||||
representation = "PT1S"
|
||||
value = timedelta(seconds=1)
|
||||
|
||||
|
||||
class Minute(Grain):
|
||||
name = "Minute"
|
||||
representation = "PT1M"
|
||||
value = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Hour(Grain):
|
||||
name = "Hour"
|
||||
representation = "PT1H"
|
||||
value = timedelta(hours=1)
|
||||
|
||||
|
||||
class Day(Grain):
|
||||
name = "Day"
|
||||
representation = "P1D"
|
||||
value = timedelta(days=1)
|
||||
|
||||
|
||||
class Week(Grain):
|
||||
name = "Week"
|
||||
representation = "P1W"
|
||||
value = timedelta(weeks=1)
|
||||
|
||||
|
||||
class Month(Grain):
|
||||
name = "Month"
|
||||
representation = "P1M"
|
||||
value = timedelta(days=30)
|
||||
|
||||
|
||||
class Quarter(Grain):
|
||||
name = "Quarter"
|
||||
representation = "P3M"
|
||||
value = timedelta(days=90)
|
||||
|
||||
|
||||
class Year(Grain):
|
||||
name = "Year"
|
||||
representation = "P1Y"
|
||||
value = timedelta(days=365)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dimension:
|
||||
id: str
|
||||
name: str
|
||||
type: TypeOf[Type]
|
||||
|
||||
definition: str | None = None
|
||||
description: str | None = None
|
||||
grain: TypeOf[Grain] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metric:
|
||||
id: str
|
||||
name: str
|
||||
type: TypeOf[Type]
|
||||
|
||||
definition: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdhocExpression:
|
||||
id: str
|
||||
definition: str
|
||||
|
||||
|
||||
class Operator(str, enum.Enum):
|
||||
EQUALS = "="
|
||||
NOT_EQUALS = "!="
|
||||
GREATER_THAN = ">"
|
||||
LESS_THAN = "<"
|
||||
GREATER_THAN_OR_EQUAL = ">="
|
||||
LESS_THAN_OR_EQUAL = "<="
|
||||
IN = "IN"
|
||||
NOT_IN = "NOT IN"
|
||||
LIKE = "LIKE"
|
||||
NOT_LIKE = "NOT LIKE"
|
||||
IS_NULL = "IS NULL"
|
||||
IS_NOT_NULL = "IS NOT NULL"
|
||||
ADHOC = "ADHOC"
|
||||
|
||||
|
||||
FilterValues = str | int | float | bool | datetime | date | time | timedelta | None
|
||||
|
||||
|
||||
class PredicateType(enum.Enum):
|
||||
WHERE = "WHERE"
|
||||
HAVING = "HAVING"
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Filter:
|
||||
type: PredicateType
|
||||
column: Dimension | Metric | None
|
||||
operator: Operator
|
||||
value: FilterValues | frozenset[FilterValues]
|
||||
|
||||
|
||||
class OrderDirection(enum.Enum):
|
||||
ASC = "ASC"
|
||||
DESC = "DESC"
|
||||
|
||||
|
||||
OrderTuple = tuple[Metric | Dimension | AdhocExpression, OrderDirection]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupLimit:
|
||||
"""
|
||||
Limit query to top/bottom N combinations of specified dimensions.
|
||||
|
||||
The `filters` parameter allows specifying separate filter constraints for the
|
||||
group limit subquery. This is useful when you want to determine the top N groups
|
||||
using different criteria (e.g., a different time range) than the main query.
|
||||
|
||||
For example, you might want to find the top 10 products by sales over the last
|
||||
30 days, but then show daily sales for those products over the last 7 days.
|
||||
"""
|
||||
|
||||
dimensions: list[Dimension]
|
||||
top: int
|
||||
metric: Metric | None
|
||||
direction: OrderDirection = OrderDirection.DESC
|
||||
group_others: bool = False
|
||||
filters: set[Filter] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticRequest:
|
||||
"""
|
||||
Represents a request made to obtain semantic results.
|
||||
|
||||
This could be a SQL query, an HTTP request, etc.
|
||||
"""
|
||||
|
||||
type: str
|
||||
definition: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticResult:
|
||||
"""
|
||||
Represents the results of a semantic query.
|
||||
|
||||
This includes any requests (SQL queries, HTTP requests) that were performed in order
|
||||
to obtain the results, in order to help troubleshooting.
|
||||
"""
|
||||
|
||||
requests: list[SemanticRequest]
|
||||
# TODO (betodealmeida): convert to PyArrow Table
|
||||
results: DataFrame
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticQuery:
|
||||
"""
|
||||
Represents a semantic query.
|
||||
"""
|
||||
|
||||
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
|
||||
1114
superset-embedded-sdk/package-lock.json
generated
1114
superset-embedded-sdk/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,6 @@ 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',
|
||||
{
|
||||
|
||||
@@ -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|@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|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)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
1106
superset-frontend/package-lock.json
generated
1106
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -161,7 +161,7 @@
|
||||
"geostyler-openlayers-parser": "^4.3.0",
|
||||
"geostyler-style": "7.5.0",
|
||||
"geostyler-wfs-parser": "^2.0.3",
|
||||
"googleapis": "^170.1.0",
|
||||
"googleapis": "^171.4.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.6.1",
|
||||
"markdown-to-jsx": "^9.7.3",
|
||||
"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": "6.14.1",
|
||||
"query-string": "9.3.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": "^7.1.2",
|
||||
"react-resize-detector": "^9.1.1",
|
||||
"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": "^1.1.9",
|
||||
"use-query-params": "^2.2.2",
|
||||
"uuid": "^13.0.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@applitools/eyes-storybook": "^3.63.9",
|
||||
"@applitools/eyes-storybook": "^3.63.10",
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/eslint-parser": "^7.28.6",
|
||||
"@babel/node": "^7.28.6",
|
||||
"@babel/node": "^7.29.0",
|
||||
"@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.28.6",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@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.28.6",
|
||||
"@babel/runtime-corejs3": "^7.29.0",
|
||||
"@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.0",
|
||||
"@playwright/test": "^1.58.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@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/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/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.0.10",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -286,6 +286,7 @@
|
||||
"@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",
|
||||
@@ -294,12 +295,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.18",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-loader": "^7.1.3",
|
||||
"css-minimizer-webpack-plugin": "^7.0.4",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
@@ -322,7 +323,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": "^11.1.5",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
"history": "^5.3.0",
|
||||
"html-webpack-plugin": "^5.6.6",
|
||||
@@ -359,9 +360,10 @@
|
||||
"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.104.1",
|
||||
"webpack": "^5.105.0",
|
||||
"webpack-bundle-analyzer": "^5.2.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.3",
|
||||
@@ -386,7 +388,7 @@
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"underscore": "^1.13.7",
|
||||
"jspdf": "^3.0.2",
|
||||
"jspdf": "^4.0.0",
|
||||
"nwsapi": "^2.2.13",
|
||||
"@deck.gl/aggregation-layers": "~9.2.2",
|
||||
"@deck.gl/core": "~9.2.2",
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.6",
|
||||
"@babel/core": "^7.28.6",
|
||||
"@babel/preset-env": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"install": "^0.13.0",
|
||||
|
||||
@@ -18,12 +18,19 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Editors API for Superset extension editor contributions.
|
||||
* @fileoverview Editors API for Superset text editor integration.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { ForwardRefExoticComponent, RefAttributes } from 'react';
|
||||
@@ -36,69 +43,111 @@ 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 */
|
||||
/** Zero-based line number (first line is 0) */
|
||||
line: number;
|
||||
/** Zero-based column number */
|
||||
/** Zero-based column number (first column is 0) */
|
||||
column: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a range in the editor with start and end positions.
|
||||
* 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.
|
||||
*/
|
||||
export interface Range {
|
||||
/** Start position of the range */
|
||||
/** Start position of the range (inclusive) */
|
||||
start: Position;
|
||||
/** End position of the range */
|
||||
/** End position of the range (exclusive) */
|
||||
end: Position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a selection in the editor.
|
||||
* Represents a selection in the editor, extending Range with direction information.
|
||||
* A selection is a highlighted range of text that can be manipulated.
|
||||
*/
|
||||
export interface Selection extends Range {
|
||||
/** Direction of the selection */
|
||||
/**
|
||||
* 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?: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotation severity levels for editor markers.
|
||||
* Severity levels for editor annotations.
|
||||
* Determines the visual style and icon used to display the annotation.
|
||||
*/
|
||||
export type AnnotationSeverity = 'error' | 'warning' | 'info';
|
||||
|
||||
/**
|
||||
* Represents an annotation (marker/diagnostic) in the editor.
|
||||
* 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',
|
||||
* };
|
||||
*/
|
||||
export interface EditorAnnotation {
|
||||
/** Zero-based line number */
|
||||
/** Zero-based line number where the annotation appears */
|
||||
line: number;
|
||||
/** Zero-based column number (optional) */
|
||||
/** Zero-based column number for precise positioning (optional) */
|
||||
column?: number;
|
||||
/** Annotation message to display */
|
||||
/** Human-readable message describing the issue or information */
|
||||
message: string;
|
||||
/** Severity level of the annotation */
|
||||
/** Severity determines visual styling (red for error, yellow for warning, blue for info) */
|
||||
severity: AnnotationSeverity;
|
||||
/** Optional source of the annotation (e.g., "linter", "typescript") */
|
||||
/** Identifies what produced this annotation (e.g., "linter", "sql-validator") */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a keyboard shortcut binding.
|
||||
* 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);
|
||||
* },
|
||||
* };
|
||||
*/
|
||||
export interface EditorHotkey {
|
||||
/** Unique name for the hotkey command */
|
||||
/** Unique identifier for this hotkey command */
|
||||
name: string;
|
||||
/** Key binding string (e.g., "Ctrl+Enter", "Alt+Enter") */
|
||||
/**
|
||||
* 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: string;
|
||||
/** Description of what the hotkey does */
|
||||
/** Human-readable description shown in keyboard shortcut help */
|
||||
description?: string;
|
||||
/** Function to execute when the hotkey is triggered */
|
||||
/** Callback invoked when the hotkey is pressed, receives the editor handle */
|
||||
exec: (handle: EditorHandle) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion item kinds for autocompletion.
|
||||
* Categories for completion items, determining the icon displayed.
|
||||
* Includes standard programming concepts plus SQL-specific types
|
||||
* (table, column, schema, catalog, database).
|
||||
*/
|
||||
export type CompletionItemKind =
|
||||
| 'text'
|
||||
@@ -132,53 +181,87 @@ export type CompletionItemKind =
|
||||
| 'database';
|
||||
|
||||
/**
|
||||
* Represents a completion item for autocompletion.
|
||||
* 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',
|
||||
* };
|
||||
*/
|
||||
export interface CompletionItem {
|
||||
/** Display label for the completion item */
|
||||
/** Text displayed in the completion dropdown */
|
||||
label: string;
|
||||
/** Text to insert when the item is selected */
|
||||
/** Text inserted into the editor when this item is selected */
|
||||
insertText: string;
|
||||
/** Kind of completion item for icon display */
|
||||
/** Category of completion, determines the icon shown (e.g., table, column, function) */
|
||||
kind: CompletionItemKind;
|
||||
/** Optional documentation to show in the completion popup */
|
||||
/** Extended description shown in a details pane or tooltip */
|
||||
documentation?: string;
|
||||
/** Optional detail text to show alongside the label */
|
||||
/** Short additional info displayed next to the label (e.g., type, schema) */
|
||||
detail?: string;
|
||||
/** Sorting priority (higher numbers appear first) */
|
||||
/** String used for sorting; items are sorted lexicographically by this value */
|
||||
sortText?: string;
|
||||
/** Text used for filtering completions */
|
||||
/** String used for filtering; if omitted, label is used for matching user input */
|
||||
filterText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provided to completion providers.
|
||||
* Context information passed to completion providers when requesting suggestions.
|
||||
* Contains details about how completion was triggered and the current environment.
|
||||
*/
|
||||
export interface CompletionContext {
|
||||
/** Character that triggered the completion (if any) */
|
||||
/** The character that triggered automatic completion (e.g., '.', ' '), if applicable */
|
||||
triggerCharacter?: string;
|
||||
/** How the completion was triggered */
|
||||
/**
|
||||
* How the completion was triggered:
|
||||
* - 'invoke': User explicitly requested completion (e.g., Ctrl+Space)
|
||||
* - 'automatic': Triggered automatically by typing a trigger character
|
||||
*/
|
||||
triggerKind: 'invoke' | 'automatic';
|
||||
/** Language of the editor */
|
||||
/** The language mode of the editor (e.g., 'sql', 'json') */
|
||||
language: EditorLanguage;
|
||||
/** Generic metadata passed from the host (e.g., SQL Lab can pass database context) */
|
||||
/** Host-provided context (e.g., database ID, schema name for SQL completions) */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider interface for dynamic completions.
|
||||
* 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',
|
||||
* }));
|
||||
* },
|
||||
* };
|
||||
*/
|
||||
export interface CompletionProvider {
|
||||
/** Unique identifier for this provider */
|
||||
/** Unique identifier for this provider, used for debugging and deduplication */
|
||||
id: string;
|
||||
/** Trigger characters that invoke this provider (e.g., '.', ' ') */
|
||||
/** Characters that trigger this provider automatically when typed (e.g., '.', ' ') */
|
||||
triggerCharacters?: string[];
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
*/
|
||||
provideCompletions(
|
||||
content: string,
|
||||
@@ -188,98 +271,186 @@ export interface CompletionProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyword for editor autocomplete.
|
||||
* This is a generic format that editor implementations convert to their native format.
|
||||
* 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 },
|
||||
* ];
|
||||
*/
|
||||
export interface EditorKeyword {
|
||||
/** Display name of the keyword */
|
||||
/** Display name shown in the completion dropdown */
|
||||
name: string;
|
||||
/** Value to insert when selected (defaults to name if not provided) */
|
||||
/** Text to insert when selected; defaults to name if not provided */
|
||||
value?: string;
|
||||
/** Category/type of the keyword (e.g., "column", "table", "function") */
|
||||
/** Category label shown alongside the name (e.g., "column", "table", "function") */
|
||||
meta?: string;
|
||||
/** Optional score for sorting (higher = more relevant) */
|
||||
/** Sorting priority; higher scores appear first in the completion list */
|
||||
score?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props that all editor implementations must accept.
|
||||
* 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.
|
||||
*/
|
||||
export interface EditorProps {
|
||||
/** Instance identifier */
|
||||
/** Unique identifier for this editor instance */
|
||||
id: string;
|
||||
/** Controlled value */
|
||||
/** Current editor content (controlled component pattern) */
|
||||
value: string;
|
||||
/** Content change handler */
|
||||
/** Called when the editor content changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Blur handler */
|
||||
/** Called when the editor loses focus, with the current value */
|
||||
onBlur?: (value: string) => void;
|
||||
/** Cursor position change handler */
|
||||
/** Called when the cursor position changes */
|
||||
onCursorPositionChange?: (pos: Position) => void;
|
||||
/** Selection change handler */
|
||||
/** Called when the selection(s) change */
|
||||
onSelectionChange?: (sel: Selection[]) => void;
|
||||
/** Language mode for syntax highlighting */
|
||||
/** Language mode for syntax highlighting and language features */
|
||||
language: EditorLanguage;
|
||||
/** Whether the editor is read-only */
|
||||
/** When true, prevents editing (view-only mode) */
|
||||
readOnly?: boolean;
|
||||
/** Tab size in spaces */
|
||||
/** Number of spaces per tab character */
|
||||
tabSize?: number;
|
||||
/** Whether to show line numbers */
|
||||
/** Whether to display line numbers in the gutter */
|
||||
lineNumbers?: boolean;
|
||||
/** Whether to enable word wrap */
|
||||
/** Whether long lines should wrap to the next visual line */
|
||||
wordWrap?: boolean;
|
||||
/** Linting/error annotations */
|
||||
/** Diagnostic annotations to display (errors, warnings, info) */
|
||||
annotations?: EditorAnnotation[];
|
||||
/** Keyboard shortcuts */
|
||||
/** Custom keyboard shortcuts */
|
||||
hotkeys?: EditorHotkey[];
|
||||
/** Static keywords for autocomplete */
|
||||
/** Static keywords for basic autocomplete */
|
||||
keywords?: EditorKeyword[];
|
||||
/** CSS height (e.g., "100%", "500px") */
|
||||
/** CSS height value (e.g., "100%", "500px", "calc(100vh - 200px)") */
|
||||
height?: string;
|
||||
/** CSS width (e.g., "100%", "800px") */
|
||||
/** CSS width value (e.g., "100%", "800px") */
|
||||
width?: string;
|
||||
/** Callback when editor is ready with imperative handle */
|
||||
/** Called when the editor is fully initialized, providing the imperative handle */
|
||||
onReady?: (handle: EditorHandle) => void;
|
||||
/** Host-specific context (e.g., database info from SQL Lab) */
|
||||
/** Contextual data passed to completion providers (e.g., database ID, schema) */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Theme object for styling the editor */
|
||||
/** Theme object for styling the editor to match Superset's appearance */
|
||||
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 {
|
||||
/** Focus the editor */
|
||||
focus(): void;
|
||||
/** Get the current editor content */
|
||||
getValue(): string;
|
||||
/** Set the editor content */
|
||||
setValue(value: string): void;
|
||||
/** Get the current cursor position */
|
||||
getCursorPosition(): Position;
|
||||
/** Move the cursor to a specific position */
|
||||
moveCursorToPosition(position: Position): void;
|
||||
/** Get all selections in the editor */
|
||||
getSelections(): Selection[];
|
||||
/** Set the selection range */
|
||||
setSelection(selection: Range): void;
|
||||
/** Get the selected text */
|
||||
getSelectedText(): string;
|
||||
/** Insert text at the current cursor position */
|
||||
insertText(text: string): void;
|
||||
/** Execute a named editor command */
|
||||
executeCommand(commandName: string): void;
|
||||
/** Scroll to a specific line */
|
||||
scrollToLine(line: number): void;
|
||||
/** Set annotations (replaces existing) */
|
||||
setAnnotations(annotations: EditorAnnotation[]): void;
|
||||
/** Clear all annotations */
|
||||
clearAnnotations(): void;
|
||||
/**
|
||||
* Register a completion provider for dynamic suggestions.
|
||||
* Moves keyboard focus to the editor.
|
||||
* Useful after programmatic operations to return user focus to the editing area.
|
||||
*/
|
||||
focus(): void;
|
||||
|
||||
/**
|
||||
* Returns the complete text content of the editor.
|
||||
* @returns The full editor content as a string
|
||||
*/
|
||||
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
|
||||
*/
|
||||
setValue(value: string): void;
|
||||
|
||||
/**
|
||||
* Returns the current cursor position in the editor.
|
||||
* @returns Position object with zero-based line and column numbers
|
||||
*/
|
||||
getCursorPosition(): Position;
|
||||
|
||||
/**
|
||||
* Moves the cursor to the specified position.
|
||||
* @param position Target position with zero-based line and column numbers
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
setAnnotations(annotations: EditorAnnotation[]): void;
|
||||
|
||||
/**
|
||||
* Removes all annotations from the editor.
|
||||
* Equivalent to calling setAnnotations([]).
|
||||
*/
|
||||
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.
|
||||
* @param provider The completion provider to register
|
||||
* @returns A Disposable to unregister the provider
|
||||
* @returns A Disposable that removes the provider when disposed
|
||||
*/
|
||||
registerCompletionProvider(provider: CompletionProvider): Disposable;
|
||||
}
|
||||
|
||||
@@ -30,44 +30,14 @@
|
||||
*/
|
||||
|
||||
import { Event, Database, SupersetError, Column } from './core';
|
||||
import { EditorHandle } from './editors';
|
||||
|
||||
/**
|
||||
* Represents an SQL editor instance within a SQL Lab tab.
|
||||
* Contains the editor content and associated database connection information.
|
||||
* Provides imperative control over the code editor component.
|
||||
* Allows extensions to manipulate text content, cursor position,
|
||||
* selections, annotations, and register completion providers.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
export interface Editor extends EditorHandle {}
|
||||
|
||||
/**
|
||||
* Represents a panel within a SQL Lab tab.
|
||||
@@ -99,10 +69,40 @@ export interface Tab {
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The SQL editor instance associated with this tab.
|
||||
* Contains the editor content and database connection settings.
|
||||
* The database identifier for this tab's query context.
|
||||
* This determines which database the queries will be executed against.
|
||||
*/
|
||||
editor: Editor;
|
||||
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>;
|
||||
|
||||
/**
|
||||
* The panels associated with the tab.
|
||||
@@ -262,7 +262,12 @@ export declare const getActivePanel: () => Panel;
|
||||
* const tab = getCurrentTab();
|
||||
* if (tab) {
|
||||
* console.log(`Active tab: ${tab.title}`);
|
||||
* console.log(`Database ID: ${tab.editor.databaseId}`);
|
||||
* 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();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@@ -326,9 +331,10 @@ export declare const onDidChangeTabTitle: Event<string>;
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* onDidQueryRun.event((query) => {
|
||||
* console.log('Query started on database:', query.tab.editor.databaseId);
|
||||
* console.log('Query content:', query.tab.editor.content);
|
||||
* 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());
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -341,7 +347,7 @@ export declare const onDidQueryRun: Event<QueryContext>;
|
||||
* @example
|
||||
* ```typescript
|
||||
* onDidQueryStop.event((query) => {
|
||||
* console.log('Query stopped for database:', query.tab.editor.databaseId);
|
||||
* console.log('Query stopped for database:', query.tab.databaseId);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@@ -444,3 +450,253 @@ 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>;
|
||||
|
||||
@@ -29,8 +29,9 @@ import {
|
||||
FieldStringOutlined,
|
||||
NumberOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Icons } from '@superset-ui/core/components';
|
||||
|
||||
export type ColumnLabelExtendedType = 'expression' | '';
|
||||
export type ColumnLabelExtendedType = 'expression' | 'metric' | '';
|
||||
|
||||
export type ColumnTypeLabelProps = {
|
||||
type?: ColumnLabelExtendedType | GenericDataType;
|
||||
@@ -59,7 +60,9 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
|
||||
<QuestionOutlined aria-label={t('unknown type icon')} />
|
||||
);
|
||||
|
||||
if (type === '' || type === 'expression') {
|
||||
if (type === 'metric') {
|
||||
typeIcon = <Icons.Sigma aria-label={t('metric type icon')} />;
|
||||
} else 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')} />;
|
||||
|
||||
@@ -95,7 +95,7 @@ export function MetricOption({
|
||||
|
||||
return (
|
||||
<FlexRowContainer className="metric-option">
|
||||
{showType && <ColumnTypeLabel type="expression" />}
|
||||
{showType && <ColumnTypeLabel type="metric" />}
|
||||
{shouldShowTooltip ? (
|
||||
<Tooltip id="metric-name-tooltip" title={tooltipText}>
|
||||
{label}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ControlPanelSectionConfig } from '../types';
|
||||
import { formatSelectOptions } from '../utils';
|
||||
|
||||
export const TITLE_MARGIN_OPTIONS: number[] = [
|
||||
15, 30, 50, 75, 100, 125, 150, 200,
|
||||
0, 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[1],
|
||||
default: TITLE_MARGIN_OPTIONS[0],
|
||||
choices: formatSelectOptions(TITLE_MARGIN_OPTIONS),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -52,6 +52,10 @@ 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();
|
||||
|
||||
@@ -78,11 +78,11 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"fetch-mock": "^11.1.4",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"jest-mock-console": "^2.0.0",
|
||||
"resize-observer-polyfill": "1.5.1",
|
||||
"timezone-mock": "1.3.6"
|
||||
|
||||
@@ -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': {
|
||||
'& + .superset-button:not(.ant-btn-compact-item)': {
|
||||
marginLeft: theme.sizeUnit * 2,
|
||||
},
|
||||
'& > span > :first-of-type': {
|
||||
|
||||
@@ -76,6 +76,10 @@ import {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FolderAddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
FolderViewOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
@@ -94,15 +98,18 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MinusCircleOutlined,
|
||||
MinusSquareOutlined,
|
||||
MoonOutlined,
|
||||
LoadingOutlined,
|
||||
LoginOutlined,
|
||||
MonitorOutlined,
|
||||
MoreOutlined,
|
||||
OrderedListOutlined,
|
||||
PartitionOutlined,
|
||||
PieChartOutlined,
|
||||
PicCenterOutlined,
|
||||
PlusCircleOutlined,
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
QuestionCircleOutlined,
|
||||
@@ -217,6 +224,10 @@ const AntdIcons = {
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
FireOutlined,
|
||||
FolderAddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
FolderViewOutlined,
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
@@ -240,13 +251,16 @@ const AntdIcons = {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MinusCircleOutlined,
|
||||
MinusSquareOutlined,
|
||||
MonitorOutlined,
|
||||
MoonOutlined,
|
||||
MoreOutlined,
|
||||
OrderedListOutlined,
|
||||
PartitionOutlined,
|
||||
PieChartOutlined,
|
||||
PicCenterOutlined,
|
||||
PlusCircleOutlined,
|
||||
PlusSquareOutlined,
|
||||
PlusOutlined,
|
||||
ProfileOutlined,
|
||||
ReloadOutlined,
|
||||
|
||||
@@ -42,10 +42,12 @@ const customIcons = [
|
||||
'Error',
|
||||
'Full',
|
||||
'Layers',
|
||||
'Move',
|
||||
'Multiple',
|
||||
'Queued',
|
||||
'Redo',
|
||||
'Running',
|
||||
'Sigma',
|
||||
'Slack',
|
||||
'Square',
|
||||
'SortAsc',
|
||||
|
||||
@@ -24,12 +24,18 @@ 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(
|
||||
'/thumbnail',
|
||||
'glob:*/thumbnail',
|
||||
{ body: blob, headers: { 'Content-Type': 'image/png' } },
|
||||
{
|
||||
sendAsJson: false,
|
||||
},
|
||||
{ name: 'thumbnail' },
|
||||
);
|
||||
|
||||
describe('ImageLoader', () => {
|
||||
@@ -44,7 +50,7 @@ describe('ImageLoader', () => {
|
||||
return render(<ImageLoader {...props} />);
|
||||
};
|
||||
|
||||
afterEach(() => fetchMock.resetHistory());
|
||||
afterEach(() => fetchMock.clearHistory());
|
||||
|
||||
it('is a valid element', async () => {
|
||||
setup();
|
||||
@@ -57,7 +63,7 @@ describe('ImageLoader', () => {
|
||||
'src',
|
||||
'/fallback',
|
||||
);
|
||||
expect(fetchMock.calls(/thumbnail/)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1);
|
||||
expect(global.URL.createObjectURL).toHaveBeenCalled();
|
||||
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
|
||||
'src',
|
||||
@@ -66,13 +72,14 @@ describe('ImageLoader', () => {
|
||||
});
|
||||
|
||||
it('displays fallback image when response is not an image', async () => {
|
||||
fetchMock.once('/thumbnail2', {});
|
||||
setup({ src: '/thumbnail2' });
|
||||
fetchMock.once('glob:*/thumbnail2', {}, { name: 'thumbnail2' });
|
||||
|
||||
setup({ src: 'glob:*/thumbnail2' });
|
||||
expect(screen.getByTestId('image-loader')).toHaveAttribute(
|
||||
'src',
|
||||
'/fallback',
|
||||
);
|
||||
expect(fetchMock.calls(/thumbnail2/)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(/thumbnail2/)).toHaveLength(1);
|
||||
expect(await screen.findByTestId('image-loader')).toHaveAttribute(
|
||||
'src',
|
||||
'/fallback',
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -27,8 +36,7 @@ export default class DatasourceKey {
|
||||
constructor(key: string) {
|
||||
const [idStr, typeStr] = key.split('__');
|
||||
this.id = parseInt(idStr, 10);
|
||||
this.type = DatasourceType.Table; // default to SqlaTable model
|
||||
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
|
||||
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
|
||||
}
|
||||
|
||||
public toString() {
|
||||
|
||||
@@ -26,6 +26,7 @@ export enum DatasourceType {
|
||||
Dataset = 'dataset',
|
||||
SlTable = 'sl_table',
|
||||
SavedQuery = 'saved_query',
|
||||
SemanticView = 'semantic_view',
|
||||
}
|
||||
|
||||
export interface Currency {
|
||||
|
||||
@@ -34,8 +34,10 @@ 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',
|
||||
|
||||
@@ -25,6 +25,7 @@ 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';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export { default as legacyValidateInteger } from './legacyValidateInteger';
|
||||
export { default as legacyValidateNumber } from './legacyValidateNumber';
|
||||
export { default as validateInteger } from './validateInteger';
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
|
||||
* formerly called integer()
|
||||
* @param v
|
||||
*/
|
||||
export default function legacyValidateInteger(v: unknown) {
|
||||
export default function legacyValidateInteger(v: unknown): string | false {
|
||||
if (
|
||||
v &&
|
||||
(Number.isNaN(Number(v)) || parseInt(v as string, 10) !== Number(v))
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core';
|
||||
* formerly called numeric()
|
||||
* @param v
|
||||
*/
|
||||
export default function numeric(v: unknown) {
|
||||
export default function numeric(v: unknown): string | false {
|
||||
if (v && Number.isNaN(Number(v))) {
|
||||
return t('is expected to be a number');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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;
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateInteger(v: unknown) {
|
||||
export default function validateInteger(v: unknown): string | false {
|
||||
if (
|
||||
(typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
|
||||
@@ -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) {
|
||||
export default function validateMapboxStylesUrl(v: unknown): string | false {
|
||||
if (typeof v === 'string') {
|
||||
const trimmed_v = v.trim();
|
||||
if (
|
||||
|
||||
@@ -18,7 +18,10 @@
|
||||
*/
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateMaxValue(v: unknown, max: number) {
|
||||
export default function validateMaxValue(
|
||||
v: unknown,
|
||||
max: number,
|
||||
): string | false {
|
||||
if (Number(v) > +max) {
|
||||
return t('Value cannot exceed %s', max);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateNonEmpty(v: unknown) {
|
||||
export default function validateNonEmpty(v: unknown): string | false {
|
||||
if (
|
||||
v === null ||
|
||||
typeof v === 'undefined' ||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { t } from '@apache-superset/core';
|
||||
|
||||
export default function validateInteger(v: any) {
|
||||
export default function validateNumber(v: unknown): string | false {
|
||||
if (
|
||||
(typeof v === 'string' &&
|
||||
v.trim().length > 0 &&
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function validateServerPagination(
|
||||
serverPagination: boolean,
|
||||
maxValueWithoutServerPagination: number,
|
||||
maxServer: number,
|
||||
) {
|
||||
): string | false {
|
||||
if (
|
||||
Number(v) > +maxValueWithoutServerPagination &&
|
||||
Number(v) <= maxServer &&
|
||||
|
||||
@@ -22,13 +22,13 @@ import { t } from '@apache-superset/core';
|
||||
import { ensureIsArray } from '../utils';
|
||||
|
||||
export const validateTimeComparisonRangeValues = (
|
||||
timeRangeValue?: any,
|
||||
controlValue?: any,
|
||||
) => {
|
||||
timeRangeValue?: unknown,
|
||||
controlValue?: unknown,
|
||||
): string[] => {
|
||||
const isCustomTimeRange = timeRangeValue === ComparisonTimeRangeType.Custom;
|
||||
const isCustomControlEmpty = controlValue?.every(
|
||||
(val: any) => ensureIsArray(val).length === 0,
|
||||
);
|
||||
const isCustomControlEmpty =
|
||||
Array.isArray(controlValue) &&
|
||||
controlValue.every((val: unknown) => ensureIsArray(val).length === 0);
|
||||
return isCustomTimeRange && isCustomControlEmpty
|
||||
? [t('Filters for comparison must have a value')]
|
||||
: [];
|
||||
|
||||
@@ -37,6 +37,9 @@ import { SliceIdAndOrFormData } from '../../../src/chart/clients/ChartClient';
|
||||
|
||||
configureTranslation();
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('ChartClient', () => {
|
||||
let chartClient: ChartClient;
|
||||
|
||||
@@ -50,7 +53,7 @@ describe('ChartClient', () => {
|
||||
chartClient = new ChartClient();
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
describe('new ChartClient(config)', () => {
|
||||
it('creates a client without argument', () => {
|
||||
|
||||
@@ -21,10 +21,13 @@ import fetchMock from 'fetch-mock';
|
||||
import { SupersetClient, SupersetClientClass } from '@superset-ui/core';
|
||||
import { LOGIN_GLOB } from './fixtures/constants';
|
||||
|
||||
describe('SupersetClient', () => {
|
||||
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '' }));
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
afterAll(() => fetchMock.restore());
|
||||
describe('SupersetClient', () => {
|
||||
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
|
||||
|
||||
afterAll(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
afterEach(() => SupersetClient.reset());
|
||||
|
||||
@@ -108,9 +111,11 @@ describe('SupersetClient', () => {
|
||||
mockDeleteUrl,
|
||||
];
|
||||
networkCalls.map((url: string) =>
|
||||
expect(fetchMock.calls(url)[0][1]?.headers).toStrictEqual({
|
||||
Accept: 'application/json',
|
||||
'X-CSRFToken': '1234',
|
||||
expect(
|
||||
fetchMock.callHistory.calls(url)[0].options?.headers,
|
||||
).toStrictEqual({
|
||||
accept: 'application/json',
|
||||
'x-csrftoken': '1234',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -137,6 +142,6 @@ describe('SupersetClient', () => {
|
||||
authenticatedSpy.mockRestore();
|
||||
csrfSpy.mockRestore();
|
||||
|
||||
fetchMock.reset();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,14 +20,15 @@ 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.reset();
|
||||
fetchMock.get(LOGIN_GLOB, { result: '' });
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.get(LOGIN_GLOB, { result: '' }, { name: LOGIN_GLOB });
|
||||
});
|
||||
|
||||
afterAll(() => fetchMock.restore());
|
||||
|
||||
describe('new SupersetClientClass()', () => {
|
||||
it('fallback protocol to https when setting only host', () => {
|
||||
const client = new SupersetClientClass({ host: 'TEST-HOST' });
|
||||
@@ -89,21 +90,22 @@ describe('SupersetClientClass', () => {
|
||||
});
|
||||
|
||||
describe('.init()', () => {
|
||||
beforeEach(() =>
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true }),
|
||||
);
|
||||
afterEach(() => fetchMock.reset());
|
||||
beforeEach(() => {
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
|
||||
});
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
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.calls(LOGIN_GLOB)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.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.calls(LOGIN_GLOB)).toHaveLength(0);
|
||||
expect(fetchMock.callHistory.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 () => {
|
||||
@@ -112,20 +114,19 @@ describe('SupersetClientClass', () => {
|
||||
const client = new SupersetClientClass({ csrfToken: initialToken });
|
||||
|
||||
await client.init();
|
||||
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0);
|
||||
expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0);
|
||||
expect(client.csrfToken).toBe(initialToken);
|
||||
|
||||
await client.init(true);
|
||||
expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.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.get(LOGIN_GLOB, () => Promise.reject(rejectError), {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { throws: rejectError }, { name: LOGIN_GLOB });
|
||||
|
||||
let error;
|
||||
try {
|
||||
@@ -141,7 +142,7 @@ describe('SupersetClientClass', () => {
|
||||
|
||||
it('throws if api/v1/security/csrf_token/ does not return a token', async () => {
|
||||
expect.assertions(1);
|
||||
fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true });
|
||||
fetchMock.modifyRoute(LOGIN_GLOB, { response: {} });
|
||||
|
||||
let error;
|
||||
try {
|
||||
@@ -157,9 +158,8 @@ describe('SupersetClientClass', () => {
|
||||
|
||||
it('does not set csrfToken if response is not json', async () => {
|
||||
expect.assertions(1);
|
||||
fetchMock.get(LOGIN_GLOB, '123', {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { response: '123' }, { name: LOGIN_GLOB });
|
||||
|
||||
let error;
|
||||
try {
|
||||
@@ -175,7 +175,7 @@ describe('SupersetClientClass', () => {
|
||||
});
|
||||
|
||||
describe('.isAuthenticated()', () => {
|
||||
afterEach(() => fetchMock.reset());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('returns true if there is a token and false if not', async () => {
|
||||
expect.assertions(2);
|
||||
@@ -227,9 +227,8 @@ describe('SupersetClientClass', () => {
|
||||
expect.assertions(4);
|
||||
|
||||
const rejectValue = { status: 403 };
|
||||
fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get(LOGIN_GLOB, { throws: rejectValue }, { name: LOGIN_GLOB });
|
||||
|
||||
const client = new SupersetClientClass({});
|
||||
let error;
|
||||
@@ -253,18 +252,19 @@ describe('SupersetClientClass', () => {
|
||||
}
|
||||
|
||||
// reset
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get(
|
||||
LOGIN_GLOB,
|
||||
{ result: 1234 },
|
||||
{
|
||||
overwriteRoutes: true,
|
||||
name: LOGIN_GLOB,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requests', () => {
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
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.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(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(authSpy).toHaveBeenCalledTimes(5);
|
||||
authSpy.mockRestore();
|
||||
@@ -331,7 +331,8 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.get({ url: mockGetUrl });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
|
||||
.options as CallApi;
|
||||
expect(fetchRequest.mode).toBe(clientConfig.mode);
|
||||
expect(fetchRequest.credentials).toBe(clientConfig.credentials);
|
||||
expect(fetchRequest.headers).toEqual(
|
||||
@@ -354,10 +355,11 @@ describe('SupersetClientClass', () => {
|
||||
|
||||
await client.init();
|
||||
await client.get({ url: mockGetUrl });
|
||||
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
|
||||
.options as CallApi;
|
||||
expect(fetchRequest.headers).toEqual(
|
||||
expect.objectContaining({
|
||||
guestTokenHeader: 'abc123',
|
||||
guesttokenheader: 'abc123',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -370,10 +372,10 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
|
||||
await client.get({ url: mockGetUrl });
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
|
||||
|
||||
await client.get({ endpoint: mockGetEndpoint });
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('supports parsing a response as text', async () => {
|
||||
@@ -384,7 +386,7 @@ describe('SupersetClientClass', () => {
|
||||
url: mockTextUrl,
|
||||
parseMethod: 'text',
|
||||
});
|
||||
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(text).toBe(mockTextJsonResponse);
|
||||
});
|
||||
|
||||
@@ -409,7 +411,8 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.get({ url: mockGetUrl, ...overrideConfig });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0]
|
||||
.options as CallApi;
|
||||
expect(fetchRequest.mode).toBe(overrideConfig.mode);
|
||||
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
|
||||
expect(fetchRequest.headers).toEqual(
|
||||
@@ -428,10 +431,10 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
|
||||
await client.post({ url: mockPostUrl });
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
|
||||
await client.post({ endpoint: mockPostEndpoint });
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('allows overriding host, headers, mode, and credentials per-request', async () => {
|
||||
@@ -454,7 +457,8 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.post({ url: mockPostUrl, ...overrideConfig });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
|
||||
.options as CallApi;
|
||||
|
||||
expect(fetchRequest.mode).toBe(overrideConfig.mode);
|
||||
expect(fetchRequest.credentials).toBe(overrideConfig.credentials);
|
||||
@@ -473,7 +477,7 @@ describe('SupersetClientClass', () => {
|
||||
url: mockTextUrl,
|
||||
parseMethod: 'text',
|
||||
});
|
||||
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(text).toBe(mockTextJsonResponse);
|
||||
});
|
||||
|
||||
@@ -485,10 +489,11 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.post({ url: mockPostUrl, postPayload });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
|
||||
.options as CallApi;
|
||||
const formData = fetchRequest.body as FormData;
|
||||
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(formData.get(key)).toBe(JSON.stringify(value));
|
||||
});
|
||||
@@ -502,10 +507,11 @@ describe('SupersetClientClass', () => {
|
||||
await client.init();
|
||||
await client.post({ url: mockPostUrl, postPayload, stringify: false });
|
||||
|
||||
const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi;
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0]
|
||||
.options as CallApi;
|
||||
const formData = fetchRequest.body as FormData;
|
||||
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1);
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(formData.get(key)).toBe(String(value));
|
||||
});
|
||||
@@ -528,6 +534,7 @@ describe('SupersetClientClass', () => {
|
||||
// @ts-ignore
|
||||
window.location = {
|
||||
pathname: mockRequestPath,
|
||||
// @ts-ignore
|
||||
search: mockRequestSearch,
|
||||
href: mockHref,
|
||||
};
|
||||
@@ -535,9 +542,7 @@ describe('SupersetClientClass', () => {
|
||||
.spyOn(SupersetClientClass.prototype, 'ensureAuth')
|
||||
.mockImplementation();
|
||||
const rejectValue = { status: 401 };
|
||||
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue), {
|
||||
overwriteRoutes: true,
|
||||
});
|
||||
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -563,10 +568,11 @@ describe('SupersetClientClass', () => {
|
||||
it('should not redirect again if already on login page', async () => {
|
||||
const client = new SupersetClientClass({});
|
||||
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
window.location = {
|
||||
href: '/login?next=something',
|
||||
pathname: '/login',
|
||||
// @ts-ignore
|
||||
search: '?next=something',
|
||||
};
|
||||
|
||||
@@ -636,7 +642,8 @@ describe('SupersetClientClass', () => {
|
||||
let createElement: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true });
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
|
||||
|
||||
client = new SupersetClientClass({ protocol, host });
|
||||
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
|
||||
|
||||
@@ -29,14 +29,17 @@ const corruptObject = new BadObject();
|
||||
/* @ts-expect-error */
|
||||
BadObject.prototype.toString = undefined;
|
||||
|
||||
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';
|
||||
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 mockGetPayload = { get: 'payload' };
|
||||
const mockPostPayload = { post: 'payload' };
|
||||
@@ -50,20 +53,23 @@ const mockCachePayload = {
|
||||
const mockErrorPayload = { status: 500, statusText: 'Internal error' };
|
||||
|
||||
describe('callApi()', () => {
|
||||
beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' }));
|
||||
beforeAll(() => {
|
||||
fetchMock.mockGlobal();
|
||||
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);
|
||||
fetchMock.get(mockCacheUrl, mockCachePayload, { name: mockCacheUrl });
|
||||
fetchMock.get(mockNotFound, { status: 404 });
|
||||
fetchMock.get(mock503, { status: 503 });
|
||||
fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload));
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.reset());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
describe('request config', () => {
|
||||
it('calls the right url with the specified method', async () => {
|
||||
@@ -74,10 +80,10 @@ describe('callApi()', () => {
|
||||
callApi({ url: mockPutUrl, method: 'PUT' }),
|
||||
callApi({ url: mockPatchUrl, method: 'PATCH' }),
|
||||
]);
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPostUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPutUrl)).toHaveLength(1);
|
||||
expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1);
|
||||
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);
|
||||
});
|
||||
|
||||
it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', async () => {
|
||||
@@ -92,12 +98,11 @@ describe('callApi()', () => {
|
||||
},
|
||||
redirect: 'follow',
|
||||
signal: undefined,
|
||||
body: 'BODY',
|
||||
};
|
||||
|
||||
await callApi(mockRequest);
|
||||
const calls = fetchMock.calls(mockGetUrl);
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const calls = fetchMock.callHistory.calls(mockGetUrl);
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(fetchParams.mode).toBe(mockRequest.mode);
|
||||
expect(fetchParams.cache).toBe(mockRequest.cache);
|
||||
@@ -119,10 +124,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', anotherKey: 1237 };
|
||||
|
||||
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -136,10 +141,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', noValue: undefined };
|
||||
|
||||
await callApi({ url: mockPostUrl, method: 'POST', postPayload });
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
|
||||
expect(body.get('noValue')).toBeNull();
|
||||
@@ -167,13 +172,13 @@ describe('callApi()', () => {
|
||||
}),
|
||||
callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }),
|
||||
]);
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(3);
|
||||
|
||||
const stringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1][1] as RequestInit).body as FormData;
|
||||
const stringified = (calls[0].options as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1].options as RequestInit).body as FormData;
|
||||
const jsonRequestBody = JSON.parse(
|
||||
(calls[2][1] as RequestInit).body as string,
|
||||
(calls[2].options as RequestInit).body as string,
|
||||
) as JsonObject;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -211,9 +216,9 @@ describe('callApi()', () => {
|
||||
stringify: false,
|
||||
});
|
||||
|
||||
const calls = fetchMock.calls(mockPostUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPostUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
const unstringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[0].options 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
|
||||
@@ -228,10 +233,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', anotherKey: 1237 };
|
||||
|
||||
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
|
||||
const calls = fetchMock.calls(mockPutUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPutUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -245,10 +250,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', noValue: undefined };
|
||||
|
||||
await callApi({ url: mockPutUrl, method: 'PUT', postPayload });
|
||||
const calls = fetchMock.calls(mockPutUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPutUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
|
||||
expect(body.get('noValue')).toBeNull();
|
||||
@@ -275,11 +280,11 @@ describe('callApi()', () => {
|
||||
stringify: false,
|
||||
}),
|
||||
]);
|
||||
const calls = fetchMock.calls(mockPutUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPutUrl);
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
const stringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1][1] as RequestInit).body as FormData;
|
||||
const stringified = (calls[0].options as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1].options as RequestInit).body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(stringified.get(key)).toBe(JSON.stringify(value));
|
||||
@@ -294,10 +299,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', anotherKey: 1237 };
|
||||
|
||||
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
|
||||
const calls = fetchMock.calls(mockPatchUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPatchUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
@@ -311,10 +316,10 @@ describe('callApi()', () => {
|
||||
const postPayload = { key: 'value', noValue: undefined };
|
||||
|
||||
await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload });
|
||||
const calls = fetchMock.calls(mockPatchUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPatchUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const fetchParams = calls[0][1] as RequestInit;
|
||||
const fetchParams = calls[0].options as RequestInit;
|
||||
const body = fetchParams.body as FormData;
|
||||
expect(body.get('key')).toBe(JSON.stringify(postPayload.key));
|
||||
expect(body.get('noValue')).toBeNull();
|
||||
@@ -341,11 +346,11 @@ describe('callApi()', () => {
|
||||
stringify: false,
|
||||
}),
|
||||
]);
|
||||
const calls = fetchMock.calls(mockPatchUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockPatchUrl);
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
const stringified = (calls[0][1] as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1][1] as RequestInit).body as FormData;
|
||||
const stringified = (calls[0].options as RequestInit).body as FormData;
|
||||
const unstringified = (calls[1].options as RequestInit).body as FormData;
|
||||
|
||||
Object.entries(postPayload).forEach(([key, value]) => {
|
||||
expect(stringified.get(key)).toBe(JSON.stringify(value));
|
||||
@@ -373,7 +378,7 @@ describe('callApi()', () => {
|
||||
it('caches requests with ETags', async () => {
|
||||
expect.assertions(2);
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
const calls = fetchMock.calls(mockCacheUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
const supersetCache = await caches.open(constants.CACHE_KEY);
|
||||
const cachedResponse = await supersetCache.match(mockCacheUrl);
|
||||
@@ -385,7 +390,7 @@ describe('callApi()', () => {
|
||||
window.location.protocol = 'http:';
|
||||
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
const calls = fetchMock.calls(mockCacheUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const supersetCache = await caches.open(constants.CACHE_KEY);
|
||||
@@ -399,7 +404,7 @@ describe('callApi()', () => {
|
||||
Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false });
|
||||
|
||||
const firstResponse = await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
let calls = fetchMock.calls(mockCacheUrl);
|
||||
let calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
const firstBody = await firstResponse.text();
|
||||
expect(firstBody).toEqual('BODY');
|
||||
@@ -408,8 +413,8 @@ describe('callApi()', () => {
|
||||
url: mockCacheUrl,
|
||||
method: 'GET',
|
||||
});
|
||||
calls = fetchMock.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1][1] as RequestInit;
|
||||
calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1].options as RequestInit;
|
||||
expect(calls).toHaveLength(2);
|
||||
// second call should not have If-None-Match header
|
||||
expect(fetchParams.headers).toBeUndefined();
|
||||
@@ -424,14 +429,14 @@ describe('callApi()', () => {
|
||||
expect.assertions(3);
|
||||
// first call sets the cache
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
let calls = fetchMock.calls(mockCacheUrl);
|
||||
let calls = fetchMock.callHistory.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.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1][1] as RequestInit;
|
||||
const headers = { 'If-None-Match': 'etag' };
|
||||
calls = fetchMock.callHistory.calls(mockCacheUrl);
|
||||
const fetchParams = calls[1].options as RequestInit;
|
||||
const headers = { 'if-none-match': 'etag' };
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(fetchParams.headers).toEqual(
|
||||
expect.objectContaining(headers) as typeof fetchParams.headers,
|
||||
@@ -442,16 +447,16 @@ describe('callApi()', () => {
|
||||
expect.assertions(3);
|
||||
// first call sets the cache
|
||||
await callApi({ url: mockCacheUrl, method: 'GET' });
|
||||
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(1);
|
||||
// second call reuses the cached payload on a 304
|
||||
const mockCachedPayload = { status: 304 };
|
||||
fetchMock.get(mockCacheUrl, mockCachedPayload, { overwriteRoutes: true });
|
||||
fetchMock.modifyRoute(mockCacheUrl, { response: mockCachedPayload });
|
||||
|
||||
const secondResponse = await callApi({
|
||||
url: mockCacheUrl,
|
||||
method: 'GET',
|
||||
});
|
||||
expect(fetchMock.calls(mockCacheUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(2);
|
||||
const secondBody = await secondResponse.text();
|
||||
expect(secondBody).toEqual('BODY');
|
||||
});
|
||||
@@ -461,7 +466,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 = '/mock/uncached/url';
|
||||
const mockUncachedUrl = 'glob:*/mock/uncached/url';
|
||||
const mockCachedPayload = { status: 304 };
|
||||
let error;
|
||||
fetchMock.get(mockUncachedUrl, mockCachedPayload);
|
||||
@@ -471,7 +476,7 @@ describe('callApi()', () => {
|
||||
} catch (err) {
|
||||
error = err;
|
||||
} finally {
|
||||
const calls = fetchMock.calls(mockUncachedUrl);
|
||||
const calls = fetchMock.callHistory.calls(mockUncachedUrl);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect((error as { message: string }).message).toEqual(
|
||||
'Received 304 but no content is cached!',
|
||||
@@ -483,7 +488,7 @@ describe('callApi()', () => {
|
||||
expect.assertions(3);
|
||||
const url = mockGetUrl;
|
||||
const response = await callApi({ url, method: 'GET' });
|
||||
const calls = fetchMock.calls(url);
|
||||
const calls = fetchMock.callHistory.calls(url);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(response.status).toEqual(200);
|
||||
const body = await response.json();
|
||||
@@ -494,7 +499,7 @@ describe('callApi()', () => {
|
||||
expect.assertions(2);
|
||||
const url = mockNotFound;
|
||||
const response = await callApi({ url, method: 'GET' });
|
||||
const calls = fetchMock.calls(url);
|
||||
const calls = fetchMock.callHistory.calls(url);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(response.status).toEqual(404);
|
||||
});
|
||||
@@ -513,7 +518,7 @@ describe('callApi()', () => {
|
||||
error = err;
|
||||
} finally {
|
||||
const err = error as { status: number; statusText: string };
|
||||
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4);
|
||||
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(4);
|
||||
expect(err.status).toBe(mockErrorPayload.status);
|
||||
expect(err.statusText).toBe(mockErrorPayload.statusText);
|
||||
}
|
||||
@@ -531,7 +536,7 @@ describe('callApi()', () => {
|
||||
} catch (err) {
|
||||
error = err as { status: number; statusText: string };
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(1);
|
||||
expect(error?.status).toBe(mockErrorPayload.status);
|
||||
expect(error?.statusText).toBe(mockErrorPayload.statusText);
|
||||
}
|
||||
@@ -545,7 +550,7 @@ describe('callApi()', () => {
|
||||
url,
|
||||
method: 'GET',
|
||||
});
|
||||
const calls = fetchMock.calls(url);
|
||||
const calls = fetchMock.callHistory.calls(url);
|
||||
expect(calls).toHaveLength(4);
|
||||
expect(response.status).toEqual(503);
|
||||
});
|
||||
@@ -581,7 +586,9 @@ describe('callApi()', () => {
|
||||
const result = await response.json();
|
||||
expect(response.status).toEqual(200);
|
||||
expect(result).toEqual({ yes: 'ok' });
|
||||
expect(fetchMock.lastUrl()).toEqual(`http://localhost/get-search?abc=1`);
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
|
||||
`http://localhost/get-search?abc=1`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept URLSearchParams', async () => {
|
||||
@@ -596,8 +603,10 @@ describe('callApi()', () => {
|
||||
method: 'POST',
|
||||
jsonPayload: { request: 'ok' },
|
||||
});
|
||||
expect(fetchMock.lastUrl()).toEqual(`http://localhost/post-search?abc=1`);
|
||||
expect(fetchMock.lastOptions()).toEqual(
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toEqual(
|
||||
`http://localhost/post-search?abc=1`,
|
||||
);
|
||||
expect(fetchMock.callHistory.lastCall()?.options).toEqual(
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ request: 'ok' }),
|
||||
}),
|
||||
@@ -634,7 +643,7 @@ describe('callApi()', () => {
|
||||
method: 'POST',
|
||||
postPayload: payload,
|
||||
});
|
||||
expect(fetchMock.lastOptions()?.body).toBe(payload);
|
||||
expect(fetchMock.callHistory.lastCall()?.options.body).toBe(payload);
|
||||
});
|
||||
|
||||
it('should ignore "null" postPayload string', async () => {
|
||||
@@ -646,6 +655,6 @@ describe('callApi()', () => {
|
||||
method: 'POST',
|
||||
postPayload: 'null',
|
||||
});
|
||||
expect(fetchMock.lastOptions()?.body).toBeUndefined();
|
||||
expect(fetchMock.callHistory.lastCall()?.options.body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,15 +30,16 @@ 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.reset();
|
||||
fetchMock.removeRoutes().clearHistory();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -108,7 +109,7 @@ describe('callApiAndParseWithTimeout()', () => {
|
||||
} catch (err) {
|
||||
error = err;
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTimeoutUrl)).toHaveLength(1);
|
||||
expect(error).toEqual({
|
||||
error: 'Request timed out',
|
||||
statusText: 'timeout',
|
||||
|
||||
@@ -22,12 +22,15 @@ 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.restore());
|
||||
afterAll(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
const mockGetUrl = '/mock/get/url';
|
||||
const mockPostUrl = '/mock/post/url';
|
||||
@@ -45,7 +48,7 @@ describe('parseResponse()', () => {
|
||||
fetchMock.get(mockNoParseUrl, new Response('test response'));
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.reset());
|
||||
afterEach(() => fetchMock.removeRoutes().clearHistory());
|
||||
|
||||
it('returns a Promise', () => {
|
||||
const apiPromise = callApi({ url: mockGetUrl, method: 'GET' });
|
||||
@@ -58,7 +61,7 @@ describe('parseResponse()', () => {
|
||||
const args = await parseResponse(
|
||||
callApi({ url: mockGetUrl, method: 'GET' }),
|
||||
);
|
||||
expect(fetchMock.calls(mockGetUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1);
|
||||
const keys = Object.keys(args);
|
||||
expect(keys).toContain('response');
|
||||
expect(keys).toContain('json');
|
||||
@@ -81,7 +84,7 @@ describe('parseResponse()', () => {
|
||||
} catch (err) {
|
||||
error = err as Error;
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1);
|
||||
expect(error?.stack).toBeDefined();
|
||||
expect(error?.message).toContain('Unexpected token');
|
||||
}
|
||||
@@ -99,7 +102,7 @@ describe('parseResponse()', () => {
|
||||
callApi({ url: mockTextParseUrl, method: 'GET' }),
|
||||
'text',
|
||||
);
|
||||
expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockTextParseUrl)).toHaveLength(1);
|
||||
const keys = Object.keys(args);
|
||||
expect(keys).toContain('response');
|
||||
expect(keys).toContain('text');
|
||||
@@ -134,7 +137,7 @@ describe('parseResponse()', () => {
|
||||
callApi({ url: mockNoParseUrl, method: 'GET' }),
|
||||
'raw',
|
||||
);
|
||||
expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(2);
|
||||
expect(fetchMock.callHistory.calls(mockNoParseUrl)).toHaveLength(2);
|
||||
expect(responseNull.bodyUsed).toBe(false);
|
||||
expect(responseRaw.bodyUsed).toBe(false);
|
||||
});
|
||||
@@ -193,7 +196,7 @@ describe('parseResponse()', () => {
|
||||
} catch (err) {
|
||||
error = err as { ok: boolean; status: number };
|
||||
} finally {
|
||||
expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1);
|
||||
expect(fetchMock.callHistory.calls(mockNotOkayUrl)).toHaveLength(1);
|
||||
expect(error?.ok).toBe(false);
|
||||
expect(error?.status).toBe(404);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,13 @@ import { getDatasourceMetadata } from '../../../../src/query/api/legacy';
|
||||
|
||||
import setupClientForTest from '../setupClientForTest';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('getFormData()', () => {
|
||||
beforeAll(() => setupClientForTest());
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('returns datasource metadata for given datasource key', () => {
|
||||
const mockData = {
|
||||
|
||||
@@ -22,10 +22,13 @@ import { getFormData } from '../../../../src/query/api/legacy';
|
||||
|
||||
import setupClientForTest from '../setupClientForTest';
|
||||
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
describe('getFormData()', () => {
|
||||
beforeAll(() => setupClientForTest());
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
const mockData = {
|
||||
datasource: '1__table',
|
||||
|
||||
@@ -20,9 +20,13 @@ 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.restore());
|
||||
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('returns a promise of ChartDataResponse', async () => {
|
||||
const response = {
|
||||
|
||||
@@ -21,9 +21,13 @@ 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.restore());
|
||||
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
it('should expose method and endpoint', () => {
|
||||
const api = makeApi({
|
||||
@@ -95,7 +99,7 @@ describe('makeApi()', () => {
|
||||
|
||||
const expected = new FormData();
|
||||
expected.append('request', JSON.stringify('test'));
|
||||
const received = fetchMock.lastOptions()?.body as FormData;
|
||||
const received = fetchMock.callHistory.lastCall()?.options.body as FormData;
|
||||
|
||||
expect(received).toBeInstanceOf(FormData);
|
||||
expect(received.get('request')).toEqual(expected.get('request'));
|
||||
@@ -109,7 +113,7 @@ describe('makeApi()', () => {
|
||||
});
|
||||
fetchMock.get('glob:*/test-get-search*', { search: 'get' });
|
||||
await api({ p1: 1, p2: 2, p3: [1, 2] });
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-get-search?p1=1&p2=2&p3=1%2C2',
|
||||
);
|
||||
});
|
||||
@@ -123,7 +127,7 @@ describe('makeApi()', () => {
|
||||
});
|
||||
fetchMock.get('glob:*/test-post-search*', { rison: 'get' });
|
||||
await api({ p1: 1, p3: [1, 2] });
|
||||
expect(fetchMock.lastUrl()).toContain(
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-post-search?q=(p1:1,p3:!(1,2))',
|
||||
);
|
||||
});
|
||||
@@ -137,7 +141,9 @@ describe('makeApi()', () => {
|
||||
});
|
||||
fetchMock.post('glob:*/test-post-search*', { search: 'post' });
|
||||
await api({ p1: 1, p3: [1, 2] });
|
||||
expect(fetchMock.lastUrl()).toContain('/test-post-search?p1=1&p3=1%2C2');
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-post-search?p1=1&p3=1%2C2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when requestType is invalid', () => {
|
||||
@@ -215,6 +221,8 @@ describe('makeApi()', () => {
|
||||
fetchMock.delete('glob:*/test-raw-response?*', 'ok');
|
||||
const result = await api({ field1: 11 }, {});
|
||||
expect(result).toEqual(200);
|
||||
expect(fetchMock.lastUrl()).toContain('/test-raw-response?field1=11');
|
||||
expect(fetchMock.callHistory.lastCall()?.url).toContain(
|
||||
'/test-raw-response?field1=11',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
formatTimeRangeComparison,
|
||||
} from '../../src/time-comparison/fetchTimeRange';
|
||||
|
||||
afterEach(() => fetchMock.restore());
|
||||
beforeAll(() => fetchMock.mockGlobal());
|
||||
afterAll(() => fetchMock.hardReset());
|
||||
|
||||
afterEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
test('generates proper time range string', () => {
|
||||
expect(
|
||||
@@ -84,34 +87,41 @@ test('returns a formatted time range from empty response', async () => {
|
||||
});
|
||||
|
||||
test('returns a formatted error message from response', async () => {
|
||||
fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', {
|
||||
throws: new Response(JSON.stringify({ message: 'Network error' })),
|
||||
});
|
||||
const getTimeRangeUrl = 'glob:*/api/v1/time_range/?q=%27Last+day%27';
|
||||
fetchMock.get(
|
||||
getTimeRangeUrl,
|
||||
{
|
||||
throws: new Response(JSON.stringify({ message: 'Network error' })),
|
||||
},
|
||||
{ name: getTimeRangeUrl },
|
||||
);
|
||||
let timeRange = await fetchTimeRange('Last day');
|
||||
expect(timeRange).toEqual({
|
||||
error: 'Network error',
|
||||
});
|
||||
|
||||
fetchMock.removeRoute(getTimeRangeUrl);
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/time_range/?q=%27Last+day%27',
|
||||
getTimeRangeUrl,
|
||||
{
|
||||
throws: new Error('Internal Server Error'),
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
{ name: getTimeRangeUrl },
|
||||
);
|
||||
timeRange = await fetchTimeRange('Last day');
|
||||
expect(timeRange).toEqual({
|
||||
error: 'Internal Server Error',
|
||||
});
|
||||
|
||||
fetchMock.removeRoute(getTimeRangeUrl);
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/time_range/?q=%27Last+day%27',
|
||||
getTimeRangeUrl,
|
||||
{
|
||||
throws: new Response(JSON.stringify({ statusText: 'Network error' }), {
|
||||
statusText: 'Network error',
|
||||
}),
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
{ name: getTimeRangeUrl },
|
||||
);
|
||||
timeRange = await fetchTimeRange('Last day');
|
||||
expect(timeRange).toEqual({
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
import { validateMaxValue } from '@superset-ui/core';
|
||||
import './setup';
|
||||
|
||||
test('validateInteger returns the warning message if invalid', () => {
|
||||
test('validateMaxValue returns the warning message if invalid', () => {
|
||||
expect(validateMaxValue(10.1, 10)).toBeTruthy();
|
||||
expect(validateMaxValue(1, 0)).toBeTruthy();
|
||||
expect(validateMaxValue('2', 1)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('validateInteger returns false if the input is valid', () => {
|
||||
test('validateMaxValue returns false if the input is valid', () => {
|
||||
expect(validateMaxValue(0, 1)).toBeFalsy();
|
||||
expect(validateMaxValue(10, 10)).toBeFalsy();
|
||||
expect(validateMaxValue(undefined, 1)).toBeFalsy();
|
||||
|
||||
@@ -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.14",
|
||||
"@storybook/addon-controls": "8.6.14",
|
||||
"@storybook/addon-links": "8.6.14",
|
||||
"@storybook/react": "8.6.14",
|
||||
"@storybook/types": "8.6.14",
|
||||
"@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",
|
||||
"@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.28.6",
|
||||
"@babel/preset-env": "^7.28.6",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@babel/preset-env": "^7.29.0",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@storybook/react-webpack5": "8.6.14",
|
||||
"@storybook/react-webpack5": "^8.6.15",
|
||||
"babel-loader": "^10.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
"ts-loader": "^9.5.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@superset-ui/legacy-plugin-chart-calendar": "*",
|
||||
"@superset-ui/legacy-plugin-chart-chord": "*",
|
||||
"@superset-ui/legacy-plugin-chart-country-map": "*",
|
||||
|
||||
@@ -74,6 +74,9 @@ 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',
|
||||
@@ -117,10 +120,19 @@ export default defineConfig({
|
||||
// Web server setup - disabled in CI (Flask started separately in workflow)
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: {
|
||||
command: 'curl -f http://localhost:8088/health',
|
||||
url: 'http://localhost:8088/health',
|
||||
reuseExistingServer: true,
|
||||
timeout: 5000,
|
||||
},
|
||||
: (() => {
|
||||
// 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,
|
||||
};
|
||||
})(),
|
||||
});
|
||||
|
||||
116
superset-frontend/playwright/components/ListView/BulkSelect.ts
Normal file
116
superset-frontend/playwright/components/ListView/BulkSelect.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
@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;
|
||||
|
||||
// ListView-specific Playwright Components for Superset
|
||||
export { BulkSelect } from './BulkSelect';
|
||||
207
superset-frontend/playwright/components/core/AceEditor.ts
Normal file
207
superset-frontend/playwright/components/core/AceEditor.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
95
superset-frontend/playwright/components/core/Checkbox.ts
Normal file
95
superset-frontend/playwright/components/core/Checkbox.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
217
superset-frontend/playwright/components/core/Menu.ts
Normal file
217
superset-frontend/playwright/components/core/Menu.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
187
superset-frontend/playwright/components/core/Select.ts
Normal file
187
superset-frontend/playwright/components/core/Select.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
75
superset-frontend/playwright/components/core/Tabs.ts
Normal file
75
superset-frontend/playwright/components/core/Tabs.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
109
superset-frontend/playwright/components/core/Textarea.ts
Normal file
109
superset-frontend/playwright/components/core/Textarea.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,15 @@
|
||||
*/
|
||||
|
||||
// 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';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,10 @@ export class DuplicateDatasetModal extends Modal {
|
||||
datasetName: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.nameInput.fill(datasetName, options);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,4 @@
|
||||
// Specific modal implementations
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
|
||||
export { ImportDatasetModal } from './ImportDatasetModal';
|
||||
|
||||
BIN
superset-frontend/playwright/fixtures/dataset_export.zip
Normal file
BIN
superset-frontend/playwright/fixtures/dataset_export.zip
Normal file
Binary file not shown.
61
superset-frontend/playwright/helpers/api/assertions.ts
Normal file
61
superset-frontend/playwright/helpers/api/assertions.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -18,12 +18,33 @@
|
||||
*/
|
||||
|
||||
import { Page, APIResponse } from '@playwright/test';
|
||||
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
|
||||
import rison from 'rison';
|
||||
import { apiGet, 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
|
||||
@@ -31,6 +52,7 @@ const ENDPOINTS = {
|
||||
export interface DatabaseCreatePayload {
|
||||
database_name: string;
|
||||
engine: string;
|
||||
sqlalchemy_uri?: string;
|
||||
configuration_method?: string;
|
||||
engine_information?: {
|
||||
disable_ssh_tunneling?: boolean;
|
||||
@@ -77,3 +99,53 @@ 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
Reference in New Issue
Block a user