mirror of
https://github.com/apache/superset.git
synced 2026-06-10 10:09:14 +00:00
Compare commits
57 Commits
feat/aes-g
...
enxdev/cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65c9523aa | ||
|
|
94e0071883 | ||
|
|
2f71771b56 | ||
|
|
d7ddf2023d | ||
|
|
c58408d76c | ||
|
|
1188cfef1d | ||
|
|
fb0e7fecaf | ||
|
|
3afbb48188 | ||
|
|
837f41986d | ||
|
|
8eda626466 | ||
|
|
fe9818226d | ||
|
|
1e8438a478 | ||
|
|
8fdabc44f5 | ||
|
|
e9e9245112 | ||
|
|
580be2cf32 | ||
|
|
911bb9dcda | ||
|
|
380e70060b | ||
|
|
507cf93687 | ||
|
|
ba6e9cc90f | ||
|
|
228ac0d568 | ||
|
|
c6ecaf9642 | ||
|
|
534d2191ff | ||
|
|
709fd52b0b | ||
|
|
c5d795c1f1 | ||
|
|
983f2818b0 | ||
|
|
b4eda37fbf | ||
|
|
a5fe47ee71 | ||
|
|
dc423b22b3 | ||
|
|
7c7ab88a60 | ||
|
|
21189ae130 | ||
|
|
06f95f5362 | ||
|
|
5da63d716b | ||
|
|
9bb700ff0d | ||
|
|
c0a12f4cfb | ||
|
|
138e405cb6 | ||
|
|
849f297e9d | ||
|
|
9da4536354 | ||
|
|
2463eb65b1 | ||
|
|
d3f07a7ba5 | ||
|
|
6348aa1917 | ||
|
|
ef7379c47e | ||
|
|
84aaaaa6b0 | ||
|
|
b85a2cdab1 | ||
|
|
381b99ae84 | ||
|
|
6b0d747939 | ||
|
|
151df43d9d | ||
|
|
3d7021fdf9 | ||
|
|
2babb48081 | ||
|
|
4715cfd372 | ||
|
|
5a6306983e | ||
|
|
7f452e4096 | ||
|
|
7eaaffde89 | ||
|
|
0984839788 | ||
|
|
863e93539a | ||
|
|
81bc3088e2 | ||
|
|
19d01521bf | ||
|
|
1623ceda73 |
@@ -77,11 +77,10 @@ github:
|
||||
# combination here.
|
||||
contexts:
|
||||
- lint-check
|
||||
- cypress-matrix (0, chrome)
|
||||
- cypress-matrix (1, chrome)
|
||||
- cypress-matrix-required
|
||||
- dependency-review
|
||||
- frontend-build
|
||||
- playwright-tests (chromium)
|
||||
- playwright-tests-required
|
||||
- pre-commit (current)
|
||||
- test-mysql
|
||||
- test-postgres-required
|
||||
|
||||
60
.github/workflows/superset-e2e.yml
vendored
60
.github/workflows/superset-e2e.yml
vendored
@@ -281,3 +281,63 @@ jobs:
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ github.workspace }}/superset-frontend/test-results/
|
||||
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
# Stable required-status-check anchors. cypress-matrix and playwright-tests
|
||||
# are matrix jobs gated on change detection (python || frontend). On a PR
|
||||
# that touches neither — e.g. a docs-only PR — they are skipped at the job
|
||||
# level, which happens before matrix expansion, so the per-combination
|
||||
# contexts (`cypress-matrix (0, chrome)`, `playwright-tests (chromium)`) are
|
||||
# never produced and branch protection waits on them forever. These
|
||||
# always-running jobs report a single stable context that passes when the
|
||||
# underlying matrix job succeeded or was skipped, and fails only on a real
|
||||
# failure. Require these in .asf.yaml instead of the matrix-expanded names.
|
||||
#
|
||||
# A matrix job reads as "skipped" in two distinct cases, and only the first
|
||||
# is a legitimate pass: (a) change detection succeeded and gated the job off
|
||||
# (docs-only PR); (b) the `changes` job itself failed or was cancelled, in
|
||||
# which case GHA skips its dependents too. Accepting (b) would let a broken
|
||||
# change-detector report a false green, so each anchor first requires
|
||||
# `changes` to have succeeded before honouring a skip.
|
||||
cypress-matrix-required:
|
||||
needs: [changes, cypress-matrix]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Check cypress-matrix result
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
RESULT: ${{ needs.cypress-matrix.result }}
|
||||
run: |
|
||||
if [ "$CHANGES" != "success" ]; then
|
||||
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "cypress-matrix did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "cypress-matrix result: $RESULT (changes: $CHANGES)"
|
||||
|
||||
playwright-tests-required:
|
||||
needs: [changes, playwright-tests]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Check playwright-tests result
|
||||
env:
|
||||
CHANGES: ${{ needs.changes.result }}
|
||||
RESULT: ${{ needs.playwright-tests.result }}
|
||||
run: |
|
||||
if [ "$CHANGES" != "success" ]; then
|
||||
echo "change detection did not succeed (result: $CHANGES); refusing to pass on a skipped matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
|
||||
echo "playwright-tests did not pass (result: $RESULT)"
|
||||
exit 1
|
||||
fi
|
||||
echo "playwright-tests result: $RESULT (changes: $CHANGES)"
|
||||
|
||||
2
.github/workflows/superset-translations.yml
vendored
2
.github/workflows/superset-translations.yml
vendored
@@ -41,6 +41,8 @@ jobs:
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-frontend/.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'superset-frontend/package-lock.json'
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
|
||||
@@ -55,6 +55,13 @@ WORKDIR /app/superset-frontend
|
||||
RUN mkdir -p /app/superset/static/assets \
|
||||
/app/superset/translations
|
||||
|
||||
# Harden `npm ci` against transient npm-registry network blips (e.g. ECONNRESET),
|
||||
# which otherwise fail the entire multi-platform image build with no retry.
|
||||
ENV npm_config_fetch_retries=5 \
|
||||
npm_config_fetch_retry_mintimeout=20000 \
|
||||
npm_config_fetch_retry_maxtimeout=120000 \
|
||||
npm_config_fetch_timeout=600000
|
||||
|
||||
# Mount package files and install dependencies if not in dev mode
|
||||
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
|
||||
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
|
||||
|
||||
@@ -189,6 +189,11 @@ Try out Superset's [quickstart](https://superset.apache.org/docs/quickstart/) gu
|
||||
- [Join our community's Slack](http://bit.ly/join-superset-slack)
|
||||
and please read our [Slack Community Guidelines](https://github.com/apache/superset/blob/master/CODE_OF_CONDUCT.md#slack-community-guidelines)
|
||||
- [Join our dev@superset.apache.org Mailing list](https://lists.apache.org/list.html?dev@superset.apache.org). To join, simply send an email to [dev-subscribe@superset.apache.org](mailto:dev-subscribe@superset.apache.org)
|
||||
- Follow us on social media:
|
||||
[X](https://x.com/apachesuperset) |
|
||||
[LinkedIn](https://www.linkedin.com/company/apache-superset) |
|
||||
[Bluesky](https://bsky.app/profile/apachesuperset.bsky.social) |
|
||||
[Reddit](https://reddit.com/r/apache-superset)
|
||||
- If you want to help troubleshoot GitHub Issues involving the numerous database drivers that Superset supports, please consider adding your name and the databases you have access to on the [Superset Database Familiarity Rolodex](https://docs.google.com/spreadsheets/d/1U1qxiLvOX0kBTUGME1AHHi6Ywel6ECF8xk_Qy-V9R8c/edit#gid=0)
|
||||
- Join Superset's Town Hall and [Operational Model](https://preset.io/blog/the-superset-operational-model-wants-you/) recurring meetings. Meeting info is available on the [Superset Community Calendar](https://superset.apache.org/community)
|
||||
|
||||
|
||||
41
UPDATING.md
41
UPDATING.md
@@ -30,6 +30,10 @@ The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware
|
||||
|
||||
To preserve sub-second precision in custom duration formatters, enable `formatSubMilliseconds`.
|
||||
|
||||
### Cache warmup authenticates via SUPERSET_CACHE_WARMUP_USER
|
||||
|
||||
The `cache-warmup` Celery task now drives a real WebDriver session for reliable authentication and reads the user to authenticate as from the new `SUPERSET_CACHE_WARMUP_USER` config option. It no longer consults `CACHE_WARMUP_EXECUTORS` for the warmup path. `SUPERSET_CACHE_WARMUP_USER` defaults to `None`, so the task fails fast with a clear message until you set it. Operators who previously relied on `CACHE_WARMUP_EXECUTORS` for cache warmup must set `SUPERSET_CACHE_WARMUP_USER` to a dedicated least-privilege user with access to the dashboards they want warmed up before the next warmup run.
|
||||
|
||||
### YDB now uses a native sqlglot dialect
|
||||
|
||||
YDB SQL parsing now relies on the dedicated [`ydb-sqlglot-plugin`](https://pypi.org/project/ydb-sqlglot-plugin/) dialect, which registers itself with sqlglot automatically. YDB users must install this plugin (e.g., via `pip install "apache-superset[ydb]"`) to avoid a `ValueError` when Superset parses YDB queries.
|
||||
@@ -40,6 +44,20 @@ The embedded dashboard page now validates the origin of incoming `postMessage` e
|
||||
|
||||
Enforcement only applies when the Allowed Domains list is non-empty. If the list is empty (the default), any origin is accepted, so there is no behavior change for embeds that did not configure Allowed Domains.
|
||||
|
||||
### Default guest/async JWT secrets are rejected at startup
|
||||
|
||||
Superset already refuses to start in production (non-debug, non-testing) when `SECRET_KEY` is left at its built-in default, and when `GUEST_TOKEN_JWT_SECRET` is left at its default while `EMBEDDED_SUPERSET` is enabled. This behavior is extended to `GLOBAL_ASYNC_QUERIES_JWT_SECRET`: if the `GLOBAL_ASYNC_QUERIES` feature flag is enabled and the secret is still the publicly known default (`test-secret-change-me`), Superset logs a clear error and refuses to start.
|
||||
|
||||
As with the existing `SECRET_KEY` check, this only fails in production. In debug mode, testing mode, or under the test runner, a warning is logged instead of exiting, so local development is unaffected.
|
||||
|
||||
To resolve the error, set a strong random value in `superset_config.py`:
|
||||
|
||||
```python
|
||||
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "<output of: openssl rand -base64 42>"
|
||||
```
|
||||
|
||||
The check is only active when the relevant feature is enabled, so deployments that do not use global async queries (or embedding) are not affected.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
@@ -59,29 +77,6 @@ Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENS
|
||||
|
||||
The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (A–Z), descending (Z–A), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of A–Z; open the customization and enable the toggle to restore alphabetical ordering.
|
||||
|
||||
### Selectable encryption engine for app-encrypted fields (AES-GCM)
|
||||
|
||||
App-encrypted fields (database passwords, SSH tunnel credentials, OAuth tokens, etc.) can now use authenticated **AES-GCM** encryption instead of the historical unauthenticated **AES-CBC**. A new config selects the engine for the default adapter:
|
||||
|
||||
```python
|
||||
# "aes" (AES-CBC, historical default) | "aes-gcm" (authenticated, recommended for new installs)
|
||||
SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes"
|
||||
```
|
||||
|
||||
**No action required / no behavior change:** the default remains `"aes"`, so existing installs are unaffected.
|
||||
|
||||
**Opting in on an existing install:** flipping the engine on a populated database without re-encrypting first will make stored secrets undecryptable, because the two ciphertext formats are not compatible. A migrator is provided. Recommended runbook:
|
||||
|
||||
1. Take a metadata-DB backup.
|
||||
2. Re-encrypt existing secrets into the new engine (the `SECRET_KEY` is unchanged):
|
||||
```bash
|
||||
superset re-encrypt-secrets --engine aes-gcm
|
||||
```
|
||||
3. Set `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` in your config.
|
||||
4. Restart Superset.
|
||||
|
||||
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
@@ -61,6 +61,31 @@ services:
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/templates:/etc/nginx/templates:ro
|
||||
# Wait for the webpack dev server's manifest.json to be served before
|
||||
# starting nginx. This prevents 404s on static assets at startup. The
|
||||
# probe targets host.docker.internal so it works regardless of whether
|
||||
# the dev server runs in the superset-node container
|
||||
# (BUILD_SUPERSET_FRONTEND_IN_DOCKER=true, the default) or directly on
|
||||
# the host (BUILD_SUPERSET_FRONTEND_IN_DOCKER=false).
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
url="http://host.docker.internal:9000/static/assets/manifest.json"
|
||||
max_attempts=150 # ~5 minutes at 2s intervals
|
||||
echo "Waiting for webpack dev server at $url..."
|
||||
attempt=0
|
||||
until curl -sf --max-time 5 -o /dev/null "$url"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "ERROR: webpack dev server did not serve $url after $max_attempts attempts (~5 minutes)." >&2
|
||||
echo "Is the dev server running? With BUILD_SUPERSET_FRONTEND_IN_DOCKER=false you must start it on the host (e.g. 'npm run dev' in superset-frontend)." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "Webpack dev server is ready; starting nginx."
|
||||
exec nginx -g 'daemon off;'
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
|
||||
@@ -86,6 +86,39 @@ instead requires a cachelib object.
|
||||
|
||||
See [Async Queries via Celery](/admin-docs/configuration/async-queries-celery) for details.
|
||||
|
||||
## Celery beat
|
||||
|
||||
Superset has a Celery task that will periodically warm up the cache based on different strategies.
|
||||
To use it, add the following to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
from celery.schedules import crontab
|
||||
from superset.config import CeleryConfig
|
||||
|
||||
# User that will be used to authenticate and render dashboards for cache warmup
|
||||
SUPERSET_CACHE_WARMUP_USER = "user_with_permission_to_dashboards"
|
||||
|
||||
# Extend the default CeleryConfig to add cache warmup schedule
|
||||
class CustomCeleryConfig(CeleryConfig):
|
||||
beat_schedule = {
|
||||
**CeleryConfig.beat_schedule,
|
||||
'cache-warmup-hourly': {
|
||||
'task': 'cache-warmup',
|
||||
'schedule': crontab(minute=0, hour='*'), # hourly
|
||||
'kwargs': {
|
||||
'strategy_name': 'top_n_dashboards',
|
||||
'top_n': 5,
|
||||
'since': '7 days ago',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
CELERY_CONFIG = CustomCeleryConfig
|
||||
```
|
||||
|
||||
This will cache the top 5 most popular dashboards every hour. For other
|
||||
strategies, check the `superset/tasks/cache.py` file.
|
||||
|
||||
## Caching Thumbnails
|
||||
|
||||
This is an optional feature that can be turned on by activating its [feature flag](/admin-docs/configuration/configuring-superset#feature-flags) on config:
|
||||
|
||||
@@ -917,6 +917,23 @@ const config: Config = {
|
||||
footer: {
|
||||
links: [],
|
||||
copyright: `
|
||||
<div class="footer__social-links">
|
||||
<a href="https://bit.ly/join-superset-slack" target="_blank" rel="noopener noreferrer" title="Join us on Slack" aria-label="Slack">
|
||||
<img src="/img/community/slack-symbol.svg" alt="Slack" />
|
||||
</a>
|
||||
<a href="https://x.com/apachesuperset" target="_blank" rel="noopener noreferrer" title="Follow us on X" aria-label="X">
|
||||
<img src="/img/community/x-symbol.svg" alt="X" />
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/company/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on LinkedIn" aria-label="LinkedIn">
|
||||
<img src="/img/community/linkedin-symbol.svg" alt="LinkedIn" />
|
||||
</a>
|
||||
<a href="https://bsky.app/profile/apachesuperset.bsky.social" target="_blank" rel="noopener noreferrer" title="Follow us on Bluesky" aria-label="Bluesky">
|
||||
<img src="/img/community/bluesky-symbol.svg" alt="Bluesky" />
|
||||
</a>
|
||||
<a href="https://reddit.com/r/apache-superset" target="_blank" rel="noopener noreferrer" title="Follow us on Reddit" aria-label="Reddit">
|
||||
<img src="/img/community/reddit-symbol.svg" alt="Reddit" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer__ci-services">
|
||||
<span>CI powered by</span>
|
||||
<a href="https://www.netlify.com/" target="_blank" rel="nofollow noopener noreferrer"><img src="/img/netlify.png" alt="Netlify" title="Netlify - Deploy Previews" /></a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"copyright": {
|
||||
"message": "\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a> | \n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a> | \n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a> | \n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a> | \n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a> | \n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
|
||||
"message": "\n <div class=\"footer__social-links\">\n <a href=\"https://bit.ly/join-superset-slack\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Join us on Slack\" aria-label=\"Slack\">\n <img src=\"/img/community/slack-symbol.svg\" alt=\"Slack\" />\n </a>\n <a href=\"https://x.com/apachesuperset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on X\" aria-label=\"X\">\n <img src=\"/img/community/x-symbol.svg\" alt=\"X\" />\n </a>\n <a href=\"https://www.linkedin.com/company/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on LinkedIn\" aria-label=\"LinkedIn\">\n <img src=\"/img/community/linkedin-symbol.svg\" alt=\"LinkedIn\" />\n </a>\n <a href=\"https://bsky.app/profile/apachesuperset.bsky.social\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Bluesky\" aria-label=\"Bluesky\">\n <img src=\"/img/community/bluesky-symbol.svg\" alt=\"Bluesky\" />\n </a>\n <a href=\"https://reddit.com/r/apache-superset\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"Follow us on Reddit\" aria-label=\"Reddit\">\n <img src=\"/img/community/reddit-symbol.svg\" alt=\"Reddit\" />\n </a>\n </div>\n <div class=\"footer__ci-services\">\n <span>CI powered by</span>\n <a href=\"https://www.netlify.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer\"><img src=\"/img/netlify.png\" alt=\"Netlify\" title=\"Netlify - Deploy Previews\" /></a>\n </div>\n <p>Copyright © 2026,\n The <a href=\"https://www.apache.org/\" target=\"_blank\" rel=\"noreferrer\">Apache Software Foundation</a>,\n Licensed under the Apache <a href=\"https://apache.org/licenses/LICENSE-2.0\" target=\"_blank\" rel=\"noreferrer\">License</a>.</p>\n <p><small>Apache Superset, Apache, Superset, the Superset logo, and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation. All other products or name brands are trademarks of their respective holders, including The Apache Software Foundation.\n <a href=\"https://www.apache.org/\" target=\"_blank\">Apache Software Foundation</a> resources</small></p>\n <img class=\"footer__divider\" src=\"/img/community/line.png\" alt=\"Divider\" />\n <p>\n <small>\n <a href=\"/admin-docs/security/\" target=\"_blank\" rel=\"noreferrer\">Security</a> | \n <a href=\"https://www.apache.org/foundation/sponsorship.html\" target=\"_blank\" rel=\"noreferrer\">Donate</a> | \n <a href=\"https://www.apache.org/foundation/thanks.html\" target=\"_blank\" rel=\"noreferrer\">Thanks</a> | \n <a href=\"https://apache.org/events/current-event\" target=\"_blank\" rel=\"noreferrer\">Events</a> | \n <a href=\"https://apache.org/licenses/\" target=\"_blank\" rel=\"noreferrer\">License</a> | \n <a href=\"https://privacy.apache.org/policies/privacy-policy-public.html\" target=\"_blank\" rel=\"noreferrer\">Privacy</a>\n </small>\n </p>\n <!-- telemetry/analytics pixel: -->\n <img referrerPolicy=\"no-referrer-when-downgrade\" src=\"https://static.scarf.sh/a.png?x-pxid=39ae6855-95fc-4566-86e5-360d542b0a68\" />\n ",
|
||||
"description": "The footer copyright"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"version:remove:components": "node scripts/manage-versions.mjs remove components"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
"@docusaurus/core": "^3.10.1",
|
||||
"@docusaurus/faster": "^3.10.1",
|
||||
"@docusaurus/plugin-client-redirects": "^3.10.1",
|
||||
@@ -72,11 +72,11 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.40",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -104,7 +104,7 @@
|
||||
"@typescript-eslint/parser": "^8.60.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# SIP: Authenticated encryption (AES-GCM) for app-encrypted fields
|
||||
|
||||
## [DRAFT — proposal for discussion]
|
||||
|
||||
This document is a draft proposal accompanying the code in this PR. It is
|
||||
intended to seed the formal SIP discussion. The code here ships the
|
||||
backward-compatible engine selection **and** the re-encryption migrator
|
||||
(Phases 1–2 below); both are opt-in and change nothing for existing installs by
|
||||
default. Flipping the default for fresh installs (Phase 3) remains future work.
|
||||
|
||||
## Motivation
|
||||
|
||||
Superset app-encrypts a number of sensitive fields before persisting them to
|
||||
the metadata database, including:
|
||||
|
||||
- database connection passwords and `encrypted_extra` (`superset/models/core.py`),
|
||||
- SSH tunnel credentials — password, private key, private-key password
|
||||
(`superset/databases/ssh_tunnel/models.py`),
|
||||
- OAuth2 tokens and other secrets stored via `EncryptedType`.
|
||||
|
||||
These fields are encrypted with `sqlalchemy_utils.EncryptedType`, which
|
||||
**defaults to `AesEngine` (AES-CBC)**. AES-CBC provides confidentiality but is
|
||||
**unauthenticated**: it has no integrity tag. An attacker with write access to
|
||||
the ciphertext (e.g. direct metadata-DB access, a backup, or a compromised
|
||||
replica) can perform **bit-flipping / chosen-ciphertext manipulation** to
|
||||
silently alter the decrypted plaintext of a secret without detection.
|
||||
|
||||
`AesGcmEngine` (AES-GCM) is authenticated encryption: tampering causes
|
||||
decryption to fail loudly rather than yielding attacker-influenced plaintext.
|
||||
Using authenticated encryption for secrets at rest is an ASVS L1 expectation
|
||||
(11.3.2 / cryptography best practice).
|
||||
|
||||
`config.py` already documents that operators *can* switch to GCM by writing a
|
||||
custom `AbstractEncryptedFieldAdapter`, but:
|
||||
|
||||
1. it is opt-in, undocumented as a security recommendation, and easy to miss;
|
||||
2. there is **no migration path** — flipping the engine on a populated database
|
||||
makes every existing secret undecryptable, because GCM ciphertext is not
|
||||
format-compatible with CBC.
|
||||
|
||||
## Proposed change
|
||||
|
||||
A three-part change, delivered incrementally so existing deployments are never
|
||||
broken:
|
||||
|
||||
### Phase 1 — engine selection (this PR)
|
||||
|
||||
- Add a `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE` config (`"aes"` | `"aes-gcm"`),
|
||||
**defaulting to `"aes"`** (no behavior change for existing installs).
|
||||
- Teach the default `SQLAlchemyUtilsAdapter` to honor it (an explicit `engine`
|
||||
kwarg still wins, so the migrator can pin an engine).
|
||||
- This lets **new** deployments choose AES-GCM from day one with a one-line
|
||||
config, instead of writing a custom adapter.
|
||||
|
||||
### Phase 2 — CBC→GCM re-encryption migrator (this PR)
|
||||
|
||||
The existing `SecretsMigrator` (previously only used for `SECRET_KEY` rotation)
|
||||
gains an **engine migration** mode that:
|
||||
|
||||
1. discovers every `EncryptedType` column (via `discover_encrypted_fields()`),
|
||||
2. decrypts each value with the **source** engine (AES-CBC) under the current
|
||||
`SECRET_KEY`,
|
||||
3. re-encrypts with the **target** engine (AES-GCM),
|
||||
4. runs transactionally per the existing all-or-nothing semantics, and is
|
||||
idempotent per column (already-migrated values are skipped), so a run can be
|
||||
safely repeated or resumed.
|
||||
|
||||
Exposed via a new `--engine` option on the existing CLI command:
|
||||
`superset re-encrypt-secrets --engine aes-gcm`, runnable by operators with a DB
|
||||
backup in hand. The `SECRET_KEY` is unchanged; an engine change and a key
|
||||
rotation can also be combined (pass `--previous_secret_key` as well).
|
||||
|
||||
### Phase 3 — flip the default for new installs
|
||||
|
||||
Once the migrator and docs are in place, change the default to `"aes-gcm"` for
|
||||
**fresh** installs only (e.g. keyed off an empty metadata DB / documented in
|
||||
`UPDATING.md`), keeping existing installs on `"aes"` until they run Phase 2.
|
||||
|
||||
## New or changed public interfaces
|
||||
|
||||
- New config: `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"]`.
|
||||
- New (Phase 2) CLI: `superset re-encrypt-secrets --engine <name>`.
|
||||
- No schema changes; ciphertext format changes per migrated column.
|
||||
|
||||
## Migration plan and compatibility
|
||||
|
||||
- **Backward compatible by default.** Phase 1 changes nothing unless the
|
||||
operator opts in.
|
||||
- Switching an existing deployment to `"aes-gcm"` **without** running the Phase
|
||||
2 migrator will make existing secrets undecryptable — this is called out in
|
||||
the config comment and must be in `UPDATING.md`.
|
||||
- Recommended operator runbook: take a metadata-DB backup → run
|
||||
`re-encrypt-secrets --engine aes-gcm` → set
|
||||
`SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` → restart.
|
||||
- `AesEngine` allows queryability over encrypted fields; AES-GCM does not.
|
||||
Any code that filters/queries on an encrypted column directly must be audited
|
||||
before Phase 3 (none is expected, but it must be verified).
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Flip the default immediately.** Rejected: bricks every existing
|
||||
deployment's secrets with no migration path.
|
||||
- **Document-only (custom adapter).** Status quo; high friction and no
|
||||
migration tooling — most operators will never do it.
|
||||
|
||||
## Open questions
|
||||
|
||||
- GCM→CBC rollback (for operators who need queryability) already works via the
|
||||
same command (`re-encrypt-secrets --engine aes`), since the migrator is
|
||||
engine-symmetric. Should rollback be documented as a supported path or
|
||||
discouraged?
|
||||
- The migrator already supports a concurrent `SECRET_KEY` rotation + engine
|
||||
change in a single pass (pass `--previous_secret_key` alongside `--engine`).
|
||||
Is that combination worth calling out in the operator docs, or kept advanced?
|
||||
@@ -260,10 +260,45 @@ a > span > svg {
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
padding-top: 90px;
|
||||
padding-top: 130px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.footer__social-links {
|
||||
background-color: #173036;
|
||||
position: absolute;
|
||||
top: 52px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer__social-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.footer__social-links a:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.footer__social-links img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
/* The brand SVGs ship in their native colors (e.g. Slack's dark aubergine,
|
||||
X's near-black), which disappear on the dark footer. Render them all as
|
||||
uniform white silhouettes. The icons are single-path glyphs whose
|
||||
counters (the LinkedIn "in", Slack gaps, Reddit face) are transparent
|
||||
cut-outs, so they stay legible against the footer background. */
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.footer__ci-services {
|
||||
background-color: #0d3e49;
|
||||
color: #e1e1e1;
|
||||
@@ -309,6 +344,21 @@ a > span > svg {
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 996px) {
|
||||
.footer {
|
||||
padding-top: 120px;
|
||||
}
|
||||
|
||||
.footer__social-links {
|
||||
top: 44px;
|
||||
gap: 20px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.footer__social-links img {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.footer__ci-services {
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
|
||||
21
docs/static/img/community/reddit-symbol.svg
vendored
Normal file
21
docs/static/img/community/reddit-symbol.svg
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#FF4500">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12c-.688 0-1.25.561-1.25 1.25 0 .687.562 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
21
docs/static/img/community/slack-symbol.svg
vendored
Normal file
21
docs/static/img/community/slack-symbol.svg
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="40" height="40" fill="#4A154B">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.124 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.52 2.521h-2.522V8.834zm-1.271 0a2.528 2.528 0 0 1-2.521 2.521 2.528 2.528 0 0 1-2.521-2.521V2.522A2.528 2.528 0 0 1 15.166 0a2.528 2.528 0 0 1 2.521 2.522v6.312zm-2.521 10.124a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.166 24a2.528 2.528 0 0 1-2.521-2.52v-2.522h2.521zm0-1.271a2.528 2.528 0 0 1-2.521-2.521 2.528 2.528 0 0 1 2.521-2.521h6.312A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.52 2.521h-6.313z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -212,14 +212,14 @@
|
||||
resolved "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz"
|
||||
integrity sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==
|
||||
|
||||
"@ant-design/icons@^6.2.3":
|
||||
version "6.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.3.tgz#66e1c7fdea009b9c3fab6964062bedc76f308ad8"
|
||||
integrity sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg==
|
||||
"@ant-design/icons@^6.2.3", "@ant-design/icons@^6.2.5":
|
||||
version "6.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@ant-design/icons/-/icons-6.2.5.tgz#31c142aa6ce5eaf99598aaead222f4c459693512"
|
||||
integrity sha512-0hKtoKqTjGFOndUyJLJmC9Cg6k4rEO7rLo6xmgbNJH+/ZX1C57RVals2v1j1knHl9n7Q+sBOveTvn931wLOCKw==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/icons-svg" "^4.4.2"
|
||||
"@rc-component/util" "^1.10.1"
|
||||
"@rc-component/util" "^1.11.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@ant-design/react-slick@~2.0.0":
|
||||
@@ -3021,10 +3021,10 @@
|
||||
os-homedir "^1.0.1"
|
||||
regexpu-core "^4.5.4"
|
||||
|
||||
"@pkgr/core@^0.2.9":
|
||||
version "0.2.9"
|
||||
resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz"
|
||||
integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==
|
||||
"@pkgr/core@^0.3.6":
|
||||
version "0.3.6"
|
||||
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.3.6.tgz#3569708bd4be4d8870ba32bf1c456dac81600d97"
|
||||
integrity sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==
|
||||
|
||||
"@pnpm/config.env-replace@^1.1.0":
|
||||
version "1.1.0"
|
||||
@@ -5578,10 +5578,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.32"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
|
||||
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
|
||||
baseline-browser-mapping@^2.10.33, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.33"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz#27c299b096404978831958d429f48390424c4f9b"
|
||||
integrity sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -7522,13 +7522,13 @@ eslint-config-prettier@^10.1.8:
|
||||
resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz"
|
||||
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
|
||||
|
||||
eslint-plugin-prettier@^5.5.5:
|
||||
version "5.5.5"
|
||||
resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz"
|
||||
integrity sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==
|
||||
eslint-plugin-prettier@^5.5.6:
|
||||
version "5.5.6"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz#363ebe4d769bce157ccdd8129ce3efd91dc62564"
|
||||
integrity sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.1"
|
||||
synckit "^0.11.12"
|
||||
synckit "^0.11.13"
|
||||
|
||||
eslint-plugin-react@^7.37.5:
|
||||
version "7.37.5"
|
||||
@@ -9341,7 +9341,7 @@ js-yaml@4.1.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@=4.1.1, js-yaml@^4.1.0, js-yaml@^4.1.1:
|
||||
js-yaml@=4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
|
||||
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
|
||||
@@ -9356,6 +9356,13 @@ js-yaml@^3.13.1:
|
||||
argparse "^1.0.7"
|
||||
esprima "^4.0.0"
|
||||
|
||||
js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524"
|
||||
integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
jsdoc-type-pratt-parser@^4.0.0:
|
||||
version "4.8.0"
|
||||
resolved "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz"
|
||||
@@ -14096,12 +14103,12 @@ swc-loader@^0.2.6, swc-loader@^0.2.7:
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
|
||||
synckit@^0.11.12:
|
||||
version "0.11.12"
|
||||
resolved "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz"
|
||||
integrity sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==
|
||||
synckit@^0.11.13:
|
||||
version "0.11.13"
|
||||
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.13.tgz#062a5ea57d81befc35892f8254de5c567e97c80a"
|
||||
integrity sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==
|
||||
dependencies:
|
||||
"@pkgr/core" "^0.2.9"
|
||||
"@pkgr/core" "^0.3.6"
|
||||
|
||||
tapable@^2.0.0, tapable@^2.2.1, tapable@^2.3.0, tapable@^2.3.3:
|
||||
version "2.3.3"
|
||||
|
||||
@@ -109,7 +109,7 @@ dependencies = [
|
||||
"watchdog>=6.0.0",
|
||||
"wtforms>=2.3.3, <4",
|
||||
"wtforms-json",
|
||||
"xlsxwriter>=3.0.7, <3.3",
|
||||
"xlsxwriter>=3.2.9, <3.3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -165,7 +165,7 @@ hive = [
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>0.16.2, <0.23"]
|
||||
kusto = ["sqlalchemy-kusto>=3.0.0, <4"]
|
||||
kusto = ["sqlalchemy-kusto>=3.1.2, <4"]
|
||||
kylin = ["kylinpy>=2.8.1, <2.9"]
|
||||
mssql = ["pymssql>=2.2.8, <3"]
|
||||
# motherduck is an alias for duckdb - MotherDuck works via the duckdb driver
|
||||
@@ -180,7 +180,7 @@ ocient = [
|
||||
oracle = ["cx-Oracle>8.0.0, <8.4"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
playwright = ["playwright>=1.37.0, <2"]
|
||||
playwright = ["playwright>=1.60.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.328.0"]
|
||||
@@ -199,15 +199,15 @@ spark = [
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.7.21",
|
||||
"taos-ws-py>=0.3.8"
|
||||
"taos-ws-py>=0.6.9"
|
||||
]
|
||||
teradata = ["teradatasql>=16.20.0.23"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.5.9, < 0.7"]
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>=11.0.2"]
|
||||
starrocks = ["starrocks>=1.0.0"]
|
||||
doris = ["pydoris>=1.0.0, <2.0.0"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1.2"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.2", "ydb-sqlglot-plugin>=0.2.5"]
|
||||
development = [
|
||||
# no bounds for apache-superset-extensions-cli until a stable version
|
||||
@@ -231,7 +231,7 @@ development = [
|
||||
"pytest-asyncio",
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"python-ldap>=3.4.4",
|
||||
"python-ldap>=3.4.7",
|
||||
"ruff",
|
||||
"sqloxide",
|
||||
"statsd",
|
||||
|
||||
@@ -490,7 +490,7 @@ wtforms-json==0.3.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
xlrd==2.0.1
|
||||
# via pandas
|
||||
xlsxwriter==3.0.9
|
||||
xlsxwriter==3.2.9
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# pandas
|
||||
|
||||
@@ -838,7 +838,7 @@ python-dotenv==1.2.2
|
||||
# apache-superset
|
||||
# fastmcp
|
||||
# pydantic-settings
|
||||
python-ldap==3.4.5
|
||||
python-ldap==3.4.7
|
||||
# via apache-superset
|
||||
python-multipart==0.0.29
|
||||
# via mcp
|
||||
@@ -1140,7 +1140,7 @@ xlrd==2.0.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# pandas
|
||||
xlsxwriter==3.0.9
|
||||
xlsxwriter==3.2.9
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -69,10 +69,6 @@ class BaseExtension(BaseModel):
|
||||
default=None,
|
||||
description="Extension description",
|
||||
)
|
||||
dependencies: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of extension IDs this extension depends on",
|
||||
)
|
||||
permissions: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Permissions required by this extension",
|
||||
|
||||
@@ -29,8 +29,8 @@ Embedding is done by inserting an iframe, containing a Superset page, into the h
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* Activate the feature flag `EMBEDDED_SUPERSET`
|
||||
* Set a strong password in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py). Be aware that its default value must be changed in production.
|
||||
- Activate the feature flag `EMBEDDED_SUPERSET`
|
||||
- Set a strong password in configuration variable `GUEST_TOKEN_JWT_SECRET` (see configuration file config.py). Be aware that its default value must be changed in production.
|
||||
|
||||
## Embedding a Dashboard
|
||||
|
||||
@@ -41,32 +41,37 @@ npm install --save @superset-ui/embedded-sdk
|
||||
```
|
||||
|
||||
```js
|
||||
import { embedDashboard } from "@superset-ui/embedded-sdk";
|
||||
import { embedDashboard } from '@superset-ui/embedded-sdk';
|
||||
|
||||
embedDashboard({
|
||||
id: "abc123", // given by the Superset embedding UI
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"), // any html element that can contain an iframe
|
||||
id: 'abc123', // given by the Superset embedding UI
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'), // any html element that can contain an iframe
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: { // dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
|
||||
hideTitle: true,
|
||||
filters: {
|
||||
expanded: true,
|
||||
},
|
||||
urlParams: {
|
||||
foo: 'value1',
|
||||
bar: 'value2',
|
||||
// ...
|
||||
}
|
||||
dashboardUiConfig: {
|
||||
// dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional), urlParams (optional)
|
||||
hideTitle: true,
|
||||
filters: {
|
||||
expanded: true,
|
||||
},
|
||||
urlParams: {
|
||||
foo: 'value1',
|
||||
bar: 'value2',
|
||||
// themeMode: 'dark', // set the initial theme: 'dark' | 'system' | 'default' (default: 'default')
|
||||
// ...
|
||||
},
|
||||
},
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'],
|
||||
iframeSandboxExtras: [
|
||||
'allow-top-navigation',
|
||||
'allow-popups-to-escape-sandbox',
|
||||
],
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'],
|
||||
// optional config to enforce a particular referrerPolicy
|
||||
referrerPolicy: "same-origin",
|
||||
referrerPolicy: 'same-origin',
|
||||
// optional callback to customize permalink URLs
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -97,7 +102,7 @@ Guest tokens can have Row Level Security rules which filter data for the user ca
|
||||
|
||||
The agent making the `POST` request must be authenticated with the `can_grant_guest_token` permission.
|
||||
|
||||
Within your app, using the Guest Token will then allow authentication to your Superset instance via creating an Anonymous user object. This guest anonymous user will default to the public role as per this setting `GUEST_ROLE_NAME = "Public"`.
|
||||
Within your app, using the Guest Token will then allow authentication to your Superset instance via creating an Anonymous user object. This guest anonymous user will default to the public role as per this setting `GUEST_ROLE_NAME = "Public"`.
|
||||
|
||||
The user parameters in the example below are optional and are provided as a means of passing user attributes that may be accessed in jinja templates inside your charts.
|
||||
|
||||
@@ -110,13 +115,13 @@ Example `POST /security/guest_token` payload:
|
||||
"first_name": "Stan",
|
||||
"last_name": "Lee"
|
||||
},
|
||||
"resources": [{
|
||||
"type": "dashboard",
|
||||
"id": "abc123"
|
||||
}],
|
||||
"rls": [
|
||||
{ "clause": "publisher = 'Nintendo'" }
|
||||
]
|
||||
"resources": [
|
||||
{
|
||||
"type": "dashboard",
|
||||
"id": "abc123"
|
||||
}
|
||||
],
|
||||
"rls": [{ "clause": "publisher = 'Nintendo'" }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -152,15 +157,43 @@ In this example, the configuration file includes the following setting:
|
||||
GUEST_TOKEN_JWT_AUDIENCE="superset"
|
||||
```
|
||||
|
||||
### Setting the Initial Theme Mode
|
||||
|
||||
Use the `themeMode` URL parameter to control the embedded dashboard's initial colour scheme:
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
dashboardUiConfig: {
|
||||
urlParams: {
|
||||
themeMode: 'dark', // 'dark' | 'system' | 'default' (default: 'default')
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The supported values are:
|
||||
|
||||
| Value | Behaviour |
|
||||
| --------- | --------------------------------------------------------- |
|
||||
| `default` | Light theme (Superset default) |
|
||||
| `dark` | Dark theme |
|
||||
| `system` | Follows the user's OS preference (`prefers-color-scheme`) |
|
||||
|
||||
The theme can also be changed at runtime via `embeddedDashboard.setThemeMode(mode)`.
|
||||
|
||||
### Sandbox iframe
|
||||
|
||||
The Embedded SDK creates an iframe with [sandbox](https://developer.mozilla.org/es/docs/Web/HTML/Element/iframe#sandbox) mode by default
|
||||
which applies certain restrictions to the iframe's content.
|
||||
To pass additional sandbox attributes you can use `iframeSandboxExtras`:
|
||||
|
||||
```js
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox']
|
||||
// optional additional iframe sandbox attributes
|
||||
iframeSandboxExtras: ['allow-top-navigation', 'allow-popups-to-escape-sandbox'];
|
||||
```
|
||||
|
||||
### Permissions Policy
|
||||
@@ -168,11 +201,12 @@ To pass additional sandbox attributes you can use `iframeSandboxExtras`:
|
||||
To enable specific browser features within the embedded iframe, use `iframeAllowExtras` to set the iframe's [Permissions Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy) (the `allow` attribute):
|
||||
|
||||
```js
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen']
|
||||
// optional Permissions Policy features
|
||||
iframeAllowExtras: ['clipboard-write', 'fullscreen'];
|
||||
```
|
||||
|
||||
Common permissions you might need:
|
||||
|
||||
- `clipboard-write` - Required for "Copy permalink to clipboard" functionality
|
||||
- `fullscreen` - Required for fullscreen chart viewing
|
||||
- `camera`, `microphone` - If your dashboards include media capture features
|
||||
@@ -191,16 +225,16 @@ When users click share buttons inside an embedded dashboard, Superset generates
|
||||
|
||||
```js
|
||||
embedDashboard({
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
|
||||
// Customize permalink URLs
|
||||
resolvePermalinkUrl: ({ key }) => {
|
||||
// key: the permalink key (e.g., "xyz789")
|
||||
return `https://my-app.com/analytics/share/${key}`;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -211,15 +245,15 @@ To restore the dashboard state from a permalink in your app:
|
||||
const permalinkKey = routeParams.key;
|
||||
|
||||
embedDashboard({
|
||||
id: "abc123",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint: document.getElementById("my-superset-container"),
|
||||
id: 'abc123',
|
||||
supersetDomain: 'https://superset.example.com',
|
||||
mountPoint: document.getElementById('my-superset-container'),
|
||||
fetchGuestToken: () => fetchGuestTokenFromBackend(),
|
||||
resolvePermalinkUrl: ({ key }) => `https://my-app.com/analytics/share/${key}`,
|
||||
dashboardUiConfig: {
|
||||
urlParams: {
|
||||
permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor
|
||||
}
|
||||
}
|
||||
permalink_key: permalinkKey, // Restores filters, tabs, chart states, and scrolls to anchor
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -179,7 +179,6 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
|
||||
displayName=extension.displayName,
|
||||
version=extension.version,
|
||||
permissions=extension.permissions,
|
||||
dependencies=extension.dependencies,
|
||||
frontend=frontend,
|
||||
backend=backend,
|
||||
)
|
||||
@@ -226,7 +225,7 @@ def copy_frontend_dist(cwd: Path) -> str:
|
||||
def copy_backend_files(cwd: Path) -> None:
|
||||
"""Copy backend files based on pyproject.toml build configuration (validation already passed)."""
|
||||
dist_dir = cwd / "dist"
|
||||
backend_dir = cwd / "backend"
|
||||
backend_dir = (cwd / "backend").resolve()
|
||||
|
||||
# Read build config from pyproject.toml
|
||||
pyproject = read_toml(backend_dir / "pyproject.toml")
|
||||
@@ -239,11 +238,31 @@ def copy_backend_files(cwd: Path) -> None:
|
||||
|
||||
# Process include patterns
|
||||
for pattern in include_patterns:
|
||||
# Include patterns are only meant to select files within the backend
|
||||
# directory. Reject absolute patterns or ones that walk outside it via
|
||||
# parent ("..") components before handing them to glob().
|
||||
pattern_parts = Path(pattern).parts
|
||||
if Path(pattern).is_absolute() or ".." in pattern_parts:
|
||||
raise click.ClickException(
|
||||
f"Invalid include pattern {pattern!r}: patterns must be "
|
||||
"relative to the backend directory and may not contain '..'."
|
||||
)
|
||||
for f in backend_dir.glob(pattern):
|
||||
if not f.is_file():
|
||||
continue
|
||||
|
||||
# Check exclude patterns
|
||||
# Defense in depth: confirm the matched file resolves to a location
|
||||
# inside the backend directory before copying it into the bundle.
|
||||
resolved = f.resolve()
|
||||
if not resolved.is_relative_to(backend_dir):
|
||||
raise click.ClickException(
|
||||
f"Refusing to copy {f}: resolved path is outside the "
|
||||
f"backend directory {backend_dir}."
|
||||
)
|
||||
|
||||
# Use the matched path (not the resolved target) for the bundle
|
||||
# layout and exclude evaluation so symlinked files are staged at
|
||||
# their configured path rather than their symlink target.
|
||||
relative_path = f.relative_to(backend_dir)
|
||||
should_exclude = any(
|
||||
relative_path.match(excl_pattern) for excl_pattern in exclude_patterns
|
||||
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from superset_extensions_cli.cli import (
|
||||
app,
|
||||
@@ -282,7 +283,6 @@ def test_build_manifest_creates_correct_manifest_structure(
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["read_data"],
|
||||
"dependencies": ["some_dep"],
|
||||
}
|
||||
extension_json = isolated_filesystem / "extension.json"
|
||||
extension_json.write_text(json.dumps(extension_data))
|
||||
@@ -296,7 +296,6 @@ def test_build_manifest_creates_correct_manifest_structure(
|
||||
assert manifest.displayName == "Test Extension"
|
||||
assert manifest.version == "1.0.0"
|
||||
assert manifest.permissions == ["read_data"]
|
||||
assert manifest.dependencies == ["some_dep"]
|
||||
|
||||
# Verify frontend section
|
||||
assert manifest.frontend is not None
|
||||
@@ -329,7 +328,6 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem):
|
||||
assert manifest.displayName == "Minimal Extension"
|
||||
assert manifest.version == "0.1.0"
|
||||
assert manifest.permissions == []
|
||||
assert manifest.dependencies == [] # Default empty list
|
||||
assert manifest.frontend is None
|
||||
assert manifest.backend is None
|
||||
|
||||
@@ -625,6 +623,155 @@ exclude = []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_supports_legitimate_nested_patterns(isolated_filesystem):
|
||||
"""Test copy_backend_files copies deeply nested files via recursive globs."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
nested = backend_dir / "src" / "test_org" / "test_ext" / "deep" / "deeper"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "module.py").write_text("# nested module")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert_file_exists(
|
||||
dist_dir
|
||||
/ "backend"
|
||||
/ "src"
|
||||
/ "test_org"
|
||||
/ "test_ext"
|
||||
/ "deep"
|
||||
/ "deeper"
|
||||
/ "module.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"bad_pattern",
|
||||
[
|
||||
"../../.ssh/*",
|
||||
"../config",
|
||||
"src/../../secret.txt",
|
||||
"/etc/passwd",
|
||||
],
|
||||
)
|
||||
def test_copy_backend_files_rejects_patterns_escaping_backend_dir(
|
||||
isolated_filesystem, bad_pattern
|
||||
):
|
||||
"""Test copy_backend_files refuses include patterns that escape backend_dir."""
|
||||
# Create a sensitive file outside the backend directory.
|
||||
(isolated_filesystem / "secret.txt").write_text("SECRET")
|
||||
(isolated_filesystem / "config").write_text("SECRET")
|
||||
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
backend_src = backend_dir / "src" / "test_org" / "test_ext"
|
||||
backend_src.mkdir(parents=True)
|
||||
(backend_src / "__init__.py").write_text("# init")
|
||||
|
||||
pyproject_content = f"""[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"{bad_pattern}",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
|
||||
with pytest.raises(click.ClickException):
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
# Nothing outside the backend directory should have been staged into dist,
|
||||
# including paths reachable via ".." from inside dist/backend.
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
assert not (dist_dir / "secret.txt").exists()
|
||||
assert not (dist_dir / "config").exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_copy_backend_files_stages_symlink_at_matched_path(isolated_filesystem):
|
||||
"""Symlinked files inside backend are staged at the matched path, not the target."""
|
||||
backend_dir = isolated_filesystem / "backend"
|
||||
target_dir = backend_dir / "src" / "common"
|
||||
target_dir.mkdir(parents=True)
|
||||
(target_dir / "module.py").write_text("# shared module")
|
||||
|
||||
link_dir = backend_dir / "src" / "test_org" / "test_ext" / "common"
|
||||
link_dir.mkdir(parents=True)
|
||||
link = link_dir / "module.py"
|
||||
link.symlink_to(target_dir / "module.py")
|
||||
|
||||
pyproject_content = """[project]
|
||||
name = "test_org-test_ext"
|
||||
version = "1.0.0"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[tool.apache_superset_extensions.build]
|
||||
include = [
|
||||
"src/test_org/test_ext/**/*.py",
|
||||
]
|
||||
exclude = []
|
||||
"""
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_content)
|
||||
|
||||
extension_data = {
|
||||
"publisher": "test-org",
|
||||
"name": "test-ext",
|
||||
"displayName": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"permissions": [],
|
||||
}
|
||||
(isolated_filesystem / "extension.json").write_text(json.dumps(extension_data))
|
||||
|
||||
clean_dist(isolated_filesystem)
|
||||
copy_backend_files(isolated_filesystem)
|
||||
|
||||
dist_dir = isolated_filesystem / "dist"
|
||||
# Staged at the configured (symlink) path, not the resolved target path.
|
||||
assert_file_exists(
|
||||
dist_dir / "backend" / "src" / "test_org" / "test_ext" / "common" / "module.py"
|
||||
)
|
||||
assert not (dist_dir / "backend" / "src" / "common" / "module.py").exists()
|
||||
|
||||
|
||||
# Removed obsolete tests:
|
||||
# - test_copy_backend_files_handles_no_backend_config: This scenario can't happen since copy_backend_files is only called when backend exists
|
||||
# - test_copy_backend_files_exits_when_extension_json_missing: Validation catches this before copy_backend_files is called
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SAMPLE_DASHBOARD_1 } from 'cypress/utils/urls';
|
||||
import { interceptFav, interceptUnfav } from './utils';
|
||||
|
||||
describe('Dashboard actions', () => {
|
||||
beforeEach(() => {
|
||||
cy.createSampleDashboards([0]);
|
||||
cy.visit(SAMPLE_DASHBOARD_1);
|
||||
});
|
||||
it('should allow to favorite/unfavorite dashboard', () => {
|
||||
interceptFav();
|
||||
interceptUnfav();
|
||||
|
||||
// Find and click StarOutlined (adds to favorites)
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlined')
|
||||
.should('exist')
|
||||
.click();
|
||||
|
||||
cy.wait('@select');
|
||||
|
||||
// After clicking, StarFilled should appear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='starred']")
|
||||
.as('starIconFilled')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the filled star (gold)
|
||||
cy.get('@starIconFilled')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgb(252, 199, 0)');
|
||||
|
||||
// Click on StarFilled (removes from favorites)
|
||||
cy.get('@starIconFilled').click();
|
||||
|
||||
cy.wait('@unselect');
|
||||
|
||||
// After clicking, StarOutlined should reappear
|
||||
cy.getBySel('dashboard-header-container')
|
||||
.find("[aria-label='unstarred']")
|
||||
.as('starIconOutlinedAfter')
|
||||
.should('exist');
|
||||
|
||||
// Verify the color of the outlined star (gray)
|
||||
cy.get('@starIconOutlinedAfter')
|
||||
.should('have.css', 'color')
|
||||
.and('eq', 'rgba(0, 0, 0, 0.45)');
|
||||
});
|
||||
});
|
||||
@@ -160,18 +160,6 @@ export function interceptLog() {
|
||||
cy.intercept('**/superset/log/?explode=events&dashboard_id=*').as('logs');
|
||||
}
|
||||
|
||||
export function interceptFav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'select',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptUnfav() {
|
||||
cy.intercept({ url: `**/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
|
||||
'unselect',
|
||||
);
|
||||
}
|
||||
|
||||
export function interceptDataset() {
|
||||
cy.intercept('GET', `**/api/v1/dataset/*`).as('getDataset');
|
||||
}
|
||||
|
||||
124
superset-frontend/package-lock.json
generated
124
superset-frontend/package-lock.json
generated
@@ -196,7 +196,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -230,9 +230,9 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"concurrently": "^10.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
@@ -287,7 +287,7 @@
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
@@ -12612,9 +12612,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.10.0.tgz",
|
||||
"integrity": "sha512-uhPq0oJHk2/W2Hn6vLaNmbUUgNPPj0FINHISxfs9hqS2Hpv/TVzQFsnbxul1FJEa+YQe1Qebou2esDphwzIuKg==",
|
||||
"version": "14.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.12.0.tgz",
|
||||
"integrity": "sha512-lyAQgTeDkowq/4+8JYaviVOL4jXSdObz+uuk84DjM0z4qoiMpI6xoDVp7/tjWeVjmLc2U6Qp3hDuwWMZ5xe88Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -17253,9 +17253,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
||||
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
|
||||
"version": "2.10.33",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
|
||||
"integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -18865,95 +18865,56 @@
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.0.tgz",
|
||||
"integrity": "sha512-DRrk10z3sVPpguNe8od2cGNqZGqbT15rwAnxD4dG3b78mdNNb/gJyr8T834Oj518WcBmTktrt4FhdwZn09ZWSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"shell-quote": "1.8.4",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
"conc": "dist/bin/index.js",
|
||||
"concurrently": "dist/bin/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -40566,6 +40527,7 @@
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
@@ -42939,9 +42901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -45445,9 +45407,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -49387,7 +49349,7 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
@@ -49514,6 +49476,20 @@
|
||||
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-draggable": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
|
||||
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-error-boundary": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz",
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
|
||||
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
|
||||
"test-storybook": "test-storybook",
|
||||
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
|
||||
"test-storybook:ci": "concurrently --kill-others --success first --names \"SB,TEST\" --prefix-colors \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
|
||||
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch",
|
||||
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
|
||||
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
|
||||
@@ -279,7 +279,7 @@
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -313,9 +313,9 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"concurrently": "^10.0.0",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
@@ -370,7 +370,7 @@
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.22.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
|
||||
@@ -18,6 +18,22 @@
|
||||
"types": "./lib/authentication/index.d.ts",
|
||||
"default": "./lib/authentication/index.js"
|
||||
},
|
||||
"./dashboard": {
|
||||
"types": "./lib/dashboard/index.d.ts",
|
||||
"default": "./lib/dashboard/index.js"
|
||||
},
|
||||
"./dataset": {
|
||||
"types": "./lib/dataset/index.d.ts",
|
||||
"default": "./lib/dataset/index.js"
|
||||
},
|
||||
"./explore": {
|
||||
"types": "./lib/explore/index.d.ts",
|
||||
"default": "./lib/explore/index.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./lib/navigation/index.d.ts",
|
||||
"default": "./lib/navigation/index.js"
|
||||
},
|
||||
"./commands": {
|
||||
"types": "./lib/commands/index.d.ts",
|
||||
"default": "./lib/commands/index.js"
|
||||
|
||||
@@ -213,18 +213,55 @@ export declare interface Event<T> {
|
||||
(listener: (e: T) => any, thisArgs?: any): Disposable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context handed to an extension's `activate` function.
|
||||
*
|
||||
* `context.subscriptions` is provided for extensions to push their
|
||||
* {@link Disposable}s into. The host provides the array but does not dispose
|
||||
* it (lifecycle management is deferred).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export function activate(context: ExtensionContext) {
|
||||
* context.subscriptions.push(
|
||||
* commands.registerCommand('my_ext.hello', () => {}),
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/**
|
||||
* Disposables pushed by the extension. Provided for extensions to track
|
||||
* their own registrations; the host does not dispose them.
|
||||
*/
|
||||
subscriptions: { dispose(): void }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of an extension's entry module (its `./index`).
|
||||
*
|
||||
* Extensions are encouraged to export an `activate(context)` function so that
|
||||
* their registrations are tracked via `context.subscriptions` regardless of
|
||||
* whether they run synchronously or asynchronously. For backward compatibility,
|
||||
* a module may instead register its contributions as top-level side effects when
|
||||
* the module is evaluated.
|
||||
*/
|
||||
export interface ExtensionModule {
|
||||
/**
|
||||
* Called by the host once the extension module has loaded. May be async; the
|
||||
* host awaits it before considering the extension active.
|
||||
*/
|
||||
activate?(context: ExtensionContext): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Superset extension with its metadata.
|
||||
* Extensions are modular components that can extend Superset's functionality
|
||||
* by registering commands, views, menus, and editors as module-level side effects.
|
||||
*/
|
||||
export interface Extension {
|
||||
/** List of other extensions that this extension depends on */
|
||||
dependencies: string[];
|
||||
/** Human-readable description of the extension */
|
||||
description: string;
|
||||
/** List of other extensions that this extension depends on */
|
||||
extensionDependencies: string[];
|
||||
/** Unique identifier for the extension */
|
||||
id: string;
|
||||
/** Human-readable name of the extension */
|
||||
|
||||
@@ -43,6 +43,9 @@ export type SqlLabLocation =
|
||||
| 'results'
|
||||
| 'queryHistory';
|
||||
|
||||
/** Valid locations within the app shell (persist across all routes). */
|
||||
export type AppLocation = 'chatbot';
|
||||
|
||||
/**
|
||||
* Nested structure for view contributions by scope and location.
|
||||
* @example
|
||||
@@ -55,6 +58,7 @@ export type SqlLabLocation =
|
||||
*/
|
||||
export interface ViewContributions {
|
||||
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
|
||||
app?: Partial<Record<AppLocation, View[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
114
superset-frontend/packages/superset-core/src/dashboard/index.ts
Normal file
114
superset-frontend/packages/superset-core/src/dashboard/index.ts
Normal file
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Dashboard namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes dashboard identity and filter state as a stable semantic API.
|
||||
* Extensions must not depend on the Redux dashboard slice structure directly.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* A single native filter's current selected value(s).
|
||||
* The value type is intentionally kept as `unknown` because filter values
|
||||
* are heterogeneous (date ranges, string lists, numbers, etc.).
|
||||
*/
|
||||
export interface FilterValue {
|
||||
/** The filter's stable id. */
|
||||
filterId: string;
|
||||
/** Display label of the filter. */
|
||||
label: string;
|
||||
/** Currently applied value, or `null` when the filter is cleared. */
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of a single chart on the active dashboard.
|
||||
*
|
||||
* Exposes the identity, viz type, datasource, and current visibility of a
|
||||
* chart so extensions can answer both "which charts are visible?" and
|
||||
* "find the chart named X" without additional lookups.
|
||||
*/
|
||||
export interface ChartSummary {
|
||||
/** Numeric chart (slice) id. */
|
||||
chartId: number;
|
||||
/** Display name of the chart. */
|
||||
chartName: string;
|
||||
/** Visualization type key (e.g. `'echarts_timeseries_bar'`). */
|
||||
vizType: string;
|
||||
/** Datasource id, or `null` when not resolvable. */
|
||||
datasourceId: number | null;
|
||||
/** Datasource name, or `null` when not resolvable. */
|
||||
datasourceName: string | null;
|
||||
/** Whether the chart is currently visible (e.g. on the active tab). */
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized dashboard context exposed to extensions on the Dashboard page.
|
||||
*/
|
||||
export interface DashboardContext {
|
||||
/** Numeric dashboard id. */
|
||||
dashboardId: number;
|
||||
/** Display title of the dashboard. */
|
||||
title: string;
|
||||
/**
|
||||
* Active native filter values keyed by filter id.
|
||||
* Only includes filters that have a value applied.
|
||||
*/
|
||||
filters: FilterValue[];
|
||||
/**
|
||||
* Summaries of the dashboard's charts, including per-chart visibility.
|
||||
*
|
||||
* Optional: the contract is declared so extensions can compile against the
|
||||
* stable shape, but population is delivered in a later phase (see
|
||||
* CHATBOT_SIP.md §10/§11). The host returns an empty array until then.
|
||||
*/
|
||||
charts?: ChartSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized dashboard context for the page currently being viewed,
|
||||
* or `undefined` when the user is not on a Dashboard page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const dash = dashboard.getCurrentDashboard();
|
||||
* if (dash) {
|
||||
* console.log(dash.title, dash.filters);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentDashboard(): DashboardContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the dashboard identity or its active filter values change.
|
||||
* Fired on native filter value changes and on navigation to a different dashboard.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = dashboard.onDidChangeDashboard(dash => {
|
||||
* chatbot.updateContext({ dashboard: dash });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeDashboard: Event<DashboardContext>;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Dataset namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the dataset currently being viewed as a stable semantic API.
|
||||
* Aligned with backend-enforced dataset visibility and column-access semantics.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* Normalized dataset context exposed to extensions on the Dataset page.
|
||||
*/
|
||||
export interface DatasetContext {
|
||||
/** Numeric dataset id. */
|
||||
datasetId: number;
|
||||
/** Display name (table name or virtual dataset name). */
|
||||
datasetName: string;
|
||||
/** Schema the dataset belongs to, if applicable. */
|
||||
schema: string | null;
|
||||
/** Catalog the dataset belongs to, if applicable. */
|
||||
catalog: string | null;
|
||||
/** Database name backing this dataset. */
|
||||
databaseName: string | null;
|
||||
/** Whether this is a virtual (SQL-defined) dataset. */
|
||||
isVirtual: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized dataset context for the page currently being viewed,
|
||||
* or `undefined` when the user is not on a Dataset page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ds = dataset.getCurrentDataset();
|
||||
* if (ds) {
|
||||
* console.log(ds.datasetName, ds.schema);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentDataset(): DatasetContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the focused dataset changes (e.g. the user navigates to a
|
||||
* different dataset detail page).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = dataset.onDidChangeDataset(ds => {
|
||||
* chatbot.updateContext({ dataset: ds });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeDataset: Event<DatasetContext>;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Explore namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the current chart/explore context as a stable semantic API.
|
||||
* Normalized over Explore Redux state — extensions must not depend on
|
||||
* the Redux slice structure directly.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* Normalized chart context exposed to extensions during an Explore session.
|
||||
* Covers saved chart identity and transient editing context; excludes raw
|
||||
* form-data internals and datasource-implementation details.
|
||||
*/
|
||||
export interface ChartContext {
|
||||
/** The saved chart id, or `null` when the chart has not been persisted. */
|
||||
chartId: number | null;
|
||||
/** Display name of the saved chart, or `null` for a new/unsaved chart. */
|
||||
chartName: string | null;
|
||||
/** The visualization type currently selected in the editor. */
|
||||
vizType: string;
|
||||
/** Id of the datasource backing the chart (physical or virtual dataset). */
|
||||
datasourceId: number | null;
|
||||
/** Human-readable datasource name. */
|
||||
datasourceName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the normalized chart context for the active Explore session, or
|
||||
* `undefined` when the user is not on the Explore page.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chart = explore.getCurrentChart();
|
||||
* if (chart) {
|
||||
* console.log(chart.vizType, chart.chartName);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getCurrentChart(): ChartContext | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when the chart context changes within the active Explore session
|
||||
* (e.g. when the viz type, datasource, or saved name changes).
|
||||
* Not fired during route changes — subscribe to `navigation.onDidChangePage` for those.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = explore.onDidChangeChart(chart => {
|
||||
* chatbot.updateContext({ chart });
|
||||
* });
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangeChart: Event<ChartContext>;
|
||||
@@ -19,9 +19,13 @@
|
||||
export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as commands from './commands';
|
||||
export * as dashboard from './dashboard';
|
||||
export * as dataset from './dataset';
|
||||
export * as editors from './editors';
|
||||
export * as explore from './explore';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
export * as navigation from './navigation';
|
||||
export * as sqlLab from './sqlLab';
|
||||
export * as views from './views';
|
||||
export * as contributions from './contributions';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Navigation namespace for Superset extensions (P3).
|
||||
*
|
||||
* Exposes the current application surface so extensions can react to route
|
||||
* changes without polling. Entity-level context (chart, dashboard, dataset)
|
||||
* is intentionally not included here — use the surface-specific namespace
|
||||
* (`explore`, `dashboard`, `dataset`) to retrieve entity payloads.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* The set of top-level application surfaces.
|
||||
*
|
||||
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
|
||||
* editing/viewing surfaces where `explore.getCurrentChart()` /
|
||||
* `dashboard.getCurrentDashboard()` / `dataset.getCurrentDataset()` resolve to a
|
||||
* concrete entity. `'chart_list'`, `'dashboard_list'` and `'dataset_list'` are
|
||||
* the browse/list surfaces, distinct from those because no single entity is
|
||||
* active. `'sqllab'` is the SQL editor where `sqlLab.getCurrentTab()` resolves;
|
||||
* `'query_history'` and `'saved_queries'` are the related SQL Lab browse pages,
|
||||
* which are not the editor. `'other'` covers any route not explicitly enumerated.
|
||||
*/
|
||||
export type PageType =
|
||||
| 'dashboard'
|
||||
| 'dashboard_list'
|
||||
| 'explore'
|
||||
| 'chart_list'
|
||||
| 'sqllab'
|
||||
| 'query_history'
|
||||
| 'saved_queries'
|
||||
| 'dataset'
|
||||
| 'dataset_list'
|
||||
| 'home'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* Returns the current page surface type.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const pageType = navigation.getPageType();
|
||||
* if (pageType === 'dashboard') {
|
||||
* const ctx = dashboard.getCurrentDashboard();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getPageType(): PageType;
|
||||
|
||||
/**
|
||||
* Event fired whenever the user navigates to a different surface.
|
||||
* Use the surface-specific namespace to read entity context after the event.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = navigation.onDidChangePage(pageType => {
|
||||
* if (pageType === 'dashboard') {
|
||||
* const ctx = dashboard.getCurrentDashboard();
|
||||
* }
|
||||
* });
|
||||
* // later:
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangePage: Event<PageType>;
|
||||
@@ -508,6 +508,12 @@ export interface ThemeContextType {
|
||||
clearLocalOverrides: () => void;
|
||||
getCurrentCrudThemeId: () => string | null;
|
||||
hasDevOverride: () => boolean;
|
||||
/**
|
||||
* True when an explicit theme config override is active (e.g. supplied via
|
||||
* the Embedded SDK). Such an override takes precedence over a
|
||||
* dashboard-level theme.
|
||||
*/
|
||||
hasThemeConfigOverride: boolean;
|
||||
canSetMode: () => boolean;
|
||||
canSetTheme: () => boolean;
|
||||
canDetectOSPreference: () => boolean;
|
||||
|
||||
@@ -48,6 +48,12 @@ export interface View {
|
||||
name: string;
|
||||
/** Optional description of the view, for display in contribution manifests. */
|
||||
description?: string;
|
||||
/**
|
||||
* Optional icon identifier for the view, used in admin pickers and manifest
|
||||
* listings. Static — set once at registerView() time.
|
||||
* Dynamic icon states (e.g. notification badge) are the extension's concern.
|
||||
*/
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,12 +62,12 @@ export interface View {
|
||||
* The view provider function is called when the UI renders the location,
|
||||
* and should return a React element to display.
|
||||
*
|
||||
* @param view The view descriptor (id and name).
|
||||
* @param view The view descriptor (id, name, and optional icon/description).
|
||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||
* @param provider A function that returns the React element to render.
|
||||
* @returns A Disposable that unregisters the view when disposed.
|
||||
*
|
||||
* @example
|
||||
* @example SQL Lab panel
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||
@@ -69,6 +75,15 @@ export interface View {
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
|
||||
* ```typescript
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
|
||||
* 'superset.chatbot',
|
||||
* () => <ChatbotApp />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
|
||||
@@ -52,12 +52,12 @@
|
||||
"parse-ms": "^4.0.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-draggable": "^4.6.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -16,21 +16,35 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { isValidElement, cloneElement, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react';
|
||||
import { isNil } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { Modal as AntdModal, ModalProps as AntdModalProps } from 'antd';
|
||||
import { Resizable } from 're-resizable';
|
||||
import Draggable, {
|
||||
import RawDraggable, {
|
||||
DraggableBounds,
|
||||
DraggableData,
|
||||
DraggableEvent,
|
||||
DraggableProps,
|
||||
} from 'react-draggable';
|
||||
import { Icons } from '../Icons';
|
||||
import { Button } from '../Button';
|
||||
import type { ModalProps, StyledModalProps } from './types';
|
||||
|
||||
// react-draggable 4.6.0 ships generated types that mark every Draggable prop as
|
||||
// required (its LibraryManagedAttributes no longer honors defaultProps), even
|
||||
// though the component accepts a Partial<DraggableProps> at runtime. Re-type the
|
||||
// component so optional props stay optional, preserving the prior behavior.
|
||||
const Draggable = RawDraggable as ComponentType<Partial<DraggableProps>>;
|
||||
|
||||
const MODAL_HEADER_HEIGHT = 55;
|
||||
const MODAL_MIN_CONTENT_HEIGHT = 54;
|
||||
const MODAL_FOOTER_HEIGHT = 65;
|
||||
@@ -246,7 +260,7 @@ const CustomModal = ({
|
||||
[bodyStyle, stylesProp],
|
||||
);
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const [bounds, setBounds] = useState<DraggableBounds>();
|
||||
const [bounds, setBounds] = useState<DraggableBounds>({});
|
||||
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -355,7 +369,7 @@ const CustomModal = ({
|
||||
resizable || draggable ? (
|
||||
<Draggable
|
||||
disabled={!draggable || dragDisabled}
|
||||
bounds={bounds}
|
||||
bounds={bounds ?? false}
|
||||
onStart={(event, uiData) => onDragStart(event, uiData)}
|
||||
{...draggableConfig}
|
||||
>
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ModalProps {
|
||||
resizable?: boolean;
|
||||
resizableConfig?: ResizableProps;
|
||||
draggable?: boolean;
|
||||
draggableConfig?: DraggableProps;
|
||||
draggableConfig?: Partial<DraggableProps>;
|
||||
destroyOnHidden?: boolean;
|
||||
maskClosable?: boolean;
|
||||
zIndex?: number;
|
||||
|
||||
@@ -519,7 +519,8 @@ const Select = forwardRef(
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
{t('Select all')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -536,7 +537,8 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
{t('Clear')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
|
||||
@@ -295,6 +295,7 @@ export function Table<RecordType extends object>(
|
||||
onRow,
|
||||
allowHTML = false,
|
||||
childrenColumnName,
|
||||
expandable: expandableProp,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -427,6 +428,7 @@ export function Table<RecordType extends object>(
|
||||
bordered,
|
||||
expandable: {
|
||||
childrenColumnName,
|
||||
...expandableProp,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import { Table, TableSize } from '@superset-ui/core/components/Table';
|
||||
import { TableRowSelection, SorterResult } from 'antd/es/table/interface';
|
||||
import { mapColumns, mapRows } from './utils';
|
||||
|
||||
interface TableCollectionProps<T extends object> {
|
||||
export interface TableCollectionProps<T extends object> {
|
||||
getTableProps: TablePropGetter<T>;
|
||||
getTableBodyProps: TableBodyPropGetter<T>;
|
||||
prepareRow: (row: Row<T>) => void;
|
||||
@@ -53,6 +53,7 @@ interface TableCollectionProps<T extends object> {
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
isPaginationSticky?: boolean;
|
||||
showRowCount?: boolean;
|
||||
expandable?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const StyledTable = styled(Table)<{
|
||||
@@ -177,6 +178,7 @@ function TableCollection<T extends object>({
|
||||
onPageChange,
|
||||
isPaginationSticky = false,
|
||||
showRowCount = true,
|
||||
expandable,
|
||||
}: TableCollectionProps<T>) {
|
||||
const mappedColumns = useMemo(
|
||||
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
|
||||
@@ -315,6 +317,7 @@ function TableCollection<T extends object>({
|
||||
isPaginationSticky={isPaginationSticky}
|
||||
showRowCount={showRowCount}
|
||||
rowClassName={getRowClassName}
|
||||
expandable={expandable}
|
||||
components={{
|
||||
header: {
|
||||
cell: (props: HTMLAttributes<HTMLTableCellElement>) => {
|
||||
|
||||
@@ -182,10 +182,7 @@ testWithAssets(
|
||||
// Now track POST /api/v1/chart/data requests around Clear All
|
||||
const postsAfterClearAll: string[] = [];
|
||||
const handler = (req: any) => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
|
||||
postsAfterClearAll.push(req.url());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #29519: a dashboard-level filter that is in scope for a Mixed
|
||||
* (mixed_timeseries) chart should apply to BOTH of the chart's queries — Query
|
||||
* A and Query B — not just Query A.
|
||||
*
|
||||
* A Mixed chart issues a single query context with two queries
|
||||
* (queries[0] = A, queries[1] = B). This test creates a Mixed chart, puts it on
|
||||
* a dashboard behind a native filter scoped to the chart, loads the dashboard,
|
||||
* and inspects the outgoing POST /api/v1/chart/data payload to assert the filter
|
||||
* is present in both queries.
|
||||
*
|
||||
* CI green => both queries inherit the dashboard filter (contract holds);
|
||||
* merging closes #29519 and guards against regressions.
|
||||
* CI red => Query B dropped the filter; the bug is live in the Mixed chart
|
||||
* query-building path (plugin-chart-echarts/src/MixedTimeseries).
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
const FILTER_VALUE = 'boy';
|
||||
|
||||
async function findDatasetIdByName(page: any, name: string): Promise<number> {
|
||||
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'Mixed chart applies dashboard filter to both queries (#29519)',
|
||||
async ({ page, testAssets }) => {
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'mixed_timeseries',
|
||||
x_axis: 'ds',
|
||||
time_grain_sqla: 'P1Y',
|
||||
metrics: ['count'],
|
||||
groupby: [],
|
||||
adhoc_filters: [],
|
||||
metrics_b: ['count'],
|
||||
groupby_b: [],
|
||||
adhoc_filters_b: [],
|
||||
row_limit: 100,
|
||||
row_limit_b: 100,
|
||||
truncate_metric: true,
|
||||
truncate_metric_b: true,
|
||||
comparison_type: 'values',
|
||||
color_scheme: 'supersetColors',
|
||||
};
|
||||
const chartResp = await apiPost(page, 'api/v1/chart/', {
|
||||
slice_name: `mixed_filter_repro_${Date.now()}`,
|
||||
viz_type: 'mixed_timeseries',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartId: number = (await chartResp.json()).id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const chartLayoutKey = `CHART-${chartId}`;
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const positionJson = {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-1'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'ROW-1': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-1',
|
||||
children: [chartLayoutKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[chartLayoutKey]: {
|
||||
type: 'CHART',
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: { chartId, width: 8, height: 60, sliceName: 'mixed_filter_repro' },
|
||||
},
|
||||
};
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [{ datasetId, column: { name: FILTER_COLUMN } }],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: {
|
||||
filterState: { value: [FILTER_VALUE] },
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] },
|
||||
],
|
||||
},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `mixed_filter_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, { dashboards: [dashboardId] });
|
||||
|
||||
// Capture the Mixed chart's data request (the one with two queries).
|
||||
const twoQueryPayloads: any[] = [];
|
||||
page.on('request', req => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
try {
|
||||
const body = req.postDataJSON();
|
||||
if (body?.queries?.length === 2) {
|
||||
twoQueryPayloads.push(body);
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON bodies
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
await expect
|
||||
.poll(() => twoQueryPayloads.length, { timeout: 15_000 })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const payload = twoQueryPayloads[twoQueryPayloads.length - 1];
|
||||
const filtersA = JSON.stringify(payload.queries[0].filters || []);
|
||||
const filtersB = JSON.stringify(payload.queries[1].filters || []);
|
||||
|
||||
expect(
|
||||
filtersA.includes(FILTER_COLUMN),
|
||||
'Query A should inherit the dashboard filter',
|
||||
).toBe(true);
|
||||
expect(
|
||||
filtersB.includes(FILTER_COLUMN),
|
||||
'Query B should inherit the dashboard filter (see #29519)',
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
@@ -37,6 +37,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { DndContext } from '@dnd-kit/core';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
@@ -47,6 +48,7 @@ import userEvent from '@testing-library/user-event';
|
||||
type Options = Omit<RenderOptions, 'queries'> & {
|
||||
useRedux?: boolean;
|
||||
useDnd?: boolean;
|
||||
useDndKit?: boolean; // Use @dnd-kit instead of react-dnd
|
||||
useQueryParams?: boolean;
|
||||
useRouter?: boolean;
|
||||
useTheme?: boolean;
|
||||
@@ -74,6 +76,7 @@ export const defaultStore = createStore();
|
||||
export function createWrapper(options?: Options) {
|
||||
const {
|
||||
useDnd,
|
||||
useDndKit,
|
||||
useRedux,
|
||||
useQueryParams,
|
||||
useRouter,
|
||||
@@ -96,6 +99,10 @@ export function createWrapper(options?: Options) {
|
||||
);
|
||||
}
|
||||
|
||||
if (useDndKit) {
|
||||
result = <DndContext>{result}</DndContext>;
|
||||
}
|
||||
|
||||
if (useDnd) {
|
||||
// @ts-ignore react-dnd's DndProviderProps omits `children` under React 18 types
|
||||
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Logger } from 'src/logger/LogUtils';
|
||||
import { EmptyState, Tooltip } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { detectOS } from 'src/utils/common';
|
||||
import * as Actions from 'src/SqlLab/actions/sqlLab';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
@@ -176,14 +177,16 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
|
||||
key: qe.id,
|
||||
label: <SqlEditorTabHeader queryEditor={qe} />,
|
||||
children: (
|
||||
<SqlEditor
|
||||
queryEditor={qe}
|
||||
defaultQueryLimit={this.props.defaultQueryLimit}
|
||||
maxRow={this.props.maxRow}
|
||||
displayLimit={this.props.displayLimit}
|
||||
saveQueryWarning={this.props.saveQueryWarning}
|
||||
scheduleQueryWarning={this.props.scheduleQueryWarning}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<SqlEditor
|
||||
queryEditor={qe}
|
||||
defaultQueryLimit={this.props.defaultQueryLimit}
|
||||
maxRow={this.props.maxRow}
|
||||
displayLimit={this.props.displayLimit}
|
||||
saveQueryWarning={this.props.saveQueryWarning}
|
||||
scheduleQueryWarning={this.props.scheduleQueryWarning}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 { render, screen } from 'spec/helpers/testing-library';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { views } from 'src/core';
|
||||
import { loadExtensionSettings } from 'src/core/extensions';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import ChatbotMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
// The settings store is a module singleton; reset it to the empty default
|
||||
// (no admin pin) before each test by loading from a mocked API response.
|
||||
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: { active_chatbot_id: null } },
|
||||
} as any);
|
||||
await loadExtensionSettings();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders nothing when no chatbot extension is registered', async () => {
|
||||
render(<ChatbotMount />);
|
||||
|
||||
// Wait a tick for the settings load to resolve; the corner must stay empty
|
||||
// even after the gate opens (no chatbot registered → nothing to render).
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the registered chatbot inside the fixed mount slot', async () => {
|
||||
const provider = () => <div>My Chatbot Bubble</div>;
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
// findBy* awaits the re-render after the initial settings load resolves.
|
||||
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only the first-to-register chatbot when several are installed', async () => {
|
||||
const firstProvider = () => <div>First Bubble</div>;
|
||||
const secondProvider = () => <div>Second Bubble</div>;
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
expect(await screen.findByText('First Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing chatbot so it does not crash the host', async () => {
|
||||
const FailingChatbot = () => {
|
||||
throw new Error('chatbot blew up');
|
||||
};
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => <FailingChatbot />,
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||
// The mount slot still renders post-gate (the boundary lives inside it);
|
||||
// awaiting it confirms the provider was actually exercised and contained.
|
||||
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a chatbot whose provider function itself throws', async () => {
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => {
|
||||
throw new Error('provider blew up');
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ChatbotRenderer wraps provider() in a component so ErrorBoundary catches
|
||||
// synchronous throws from the provider function, not just from its output.
|
||||
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
});
|
||||
124
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
124
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
type ReactElement,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { getActiveChatbot } from 'src/core/chatbot';
|
||||
import { subscribeToRegistry, getRegistryVersion } from 'src/core/views';
|
||||
import {
|
||||
getExtensionSettingsSnapshot,
|
||||
loadExtensionSettings,
|
||||
subscribeToExtensionSettings,
|
||||
} from 'src/core/extensions';
|
||||
|
||||
const CHATBOT_EDGE_MARGIN = 24;
|
||||
|
||||
/**
|
||||
* Wraps the chatbot provider in a React component so that ErrorBoundary can
|
||||
* catch synchronous throws from the provider function itself. Calling
|
||||
* `provider()` inline (e.g. `{activeChatbot.provider()}`) would throw outside
|
||||
* React's render boundary and crash the host.
|
||||
*/
|
||||
const ChatbotRenderer = ({ provider }: { provider: () => ReactElement }) =>
|
||||
provider();
|
||||
|
||||
const ChatbotMount = () => {
|
||||
const theme = useTheme();
|
||||
// Notify once per mount; a crash can re-render and would otherwise re-toast.
|
||||
const crashNotified = useRef(false);
|
||||
// Defer chatbot resolution until the first settings load resolves. Otherwise
|
||||
// the initial empty-default snapshot (no pin) would briefly resolve the
|
||||
// first-registered chatbot even when the DB pins a different one, mounting
|
||||
// the wrong provider until the async settings response arrives.
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
|
||||
// The active chatbot is a function of two host-owned stores: the admin
|
||||
// settings (active chatbot id) and the view registry (which chatbots are
|
||||
// registered). Both are read via useSyncExternalStore so this re-resolves
|
||||
// when either changes — no local copy of the settings state.
|
||||
const settings = useSyncExternalStore(
|
||||
subscribeToExtensionSettings,
|
||||
getExtensionSettingsSnapshot,
|
||||
);
|
||||
const registryVersion = useSyncExternalStore(
|
||||
subscribeToRegistry,
|
||||
getRegistryVersion,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Settings fetch failure is non-fatal: the store keeps its empty default,
|
||||
// which getActiveChatbot treats as "no admin pin" (falls back to the
|
||||
// first-registered chatbot). Either way, unblock rendering once the request
|
||||
// settles so a failed fetch never permanently hides the chatbot.
|
||||
loadExtensionSettings()
|
||||
.catch(() => {})
|
||||
.finally(() => setSettingsLoaded(true));
|
||||
}, []);
|
||||
|
||||
const activeChatbot = useMemo(
|
||||
() => getActiveChatbot(settings.active_chatbot_id),
|
||||
[settings, registryVersion],
|
||||
);
|
||||
|
||||
if (!settingsLoaded || !activeChatbot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chatbot-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHATBOT_EDGE_MARGIN}px;
|
||||
bottom: ${CHATBOT_EDGE_MARGIN}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary
|
||||
showMessage={false}
|
||||
onError={(error: Error) => {
|
||||
// Fault isolation (SIP §4.5): contain the crash, log it, surface a
|
||||
// one-time notification, and leave the corner empty rather than
|
||||
// parking a persistent error card.
|
||||
logging.error('[chatbot] provider crashed', error);
|
||||
if (!crashNotified.current) {
|
||||
crashNotified.current = true;
|
||||
store.dispatch(addDangerToast(t('The chatbot failed to load.')));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatbotRenderer provider={activeChatbot.provider} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotMount;
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
isThemeConfigDark,
|
||||
} from '@apache-superset/core/theme';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { ThemeContext } from 'src/theme/ThemeProvider';
|
||||
import type { ThemeContextType } from '@apache-superset/core/theme';
|
||||
import CrudThemeProvider from './CrudThemeProvider';
|
||||
|
||||
jest.mock('@apache-superset/core/theme', () => ({
|
||||
@@ -307,6 +309,59 @@ test('ignores non-array fontUrls in theme config without throwing', () => {
|
||||
expect(fontStyle).toBeNull();
|
||||
});
|
||||
|
||||
test('skips the dashboard theme when an SDK theme config override is active', () => {
|
||||
const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
fontUrls: ['https://fonts.example.com/dashboard.css'],
|
||||
},
|
||||
};
|
||||
render(
|
||||
<ThemeContext.Provider
|
||||
value={{ hasThemeConfigOverride: true } as unknown as ThemeContextType}
|
||||
>
|
||||
<CrudThemeProvider
|
||||
theme={{
|
||||
id: 1,
|
||||
theme_name: 'Custom Theme',
|
||||
json_data: JSON.stringify(themeConfig),
|
||||
}}
|
||||
>
|
||||
<div>Dashboard Content</div>
|
||||
</CrudThemeProvider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
// The SDK override wins: the dashboard theme provider must not wrap children.
|
||||
expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-theme-provider'),
|
||||
).not.toBeInTheDocument();
|
||||
// The override fully owns theming, so dashboard fonts must not be injected.
|
||||
expect(document.querySelector('style[data-superset-fonts]')).toBeNull();
|
||||
});
|
||||
|
||||
test('applies the dashboard theme when no SDK theme config override is active', () => {
|
||||
const themeConfig = { token: { colorPrimary: '#ff0000' } };
|
||||
render(
|
||||
<ThemeContext.Provider
|
||||
value={{ hasThemeConfigOverride: false } as unknown as ThemeContextType}
|
||||
>
|
||||
<CrudThemeProvider
|
||||
theme={{
|
||||
id: 1,
|
||||
theme_name: 'Custom Theme',
|
||||
json_data: JSON.stringify(themeConfig),
|
||||
}}
|
||||
>
|
||||
<div>Dashboard Content</div>
|
||||
</CrudThemeProvider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('dashboard-theme-provider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not inject font style element when no fontUrls in config', () => {
|
||||
const themeConfig = { token: { colorPrimary: '#ff0000' } };
|
||||
render(
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useEffect, useMemo } from 'react';
|
||||
import { ReactNode, useContext, useEffect, useMemo } from 'react';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
Theme,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isThemeConfigDark,
|
||||
} from '@apache-superset/core/theme';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { ThemeContext } from 'src/theme/ThemeProvider';
|
||||
import type { Dashboard } from 'src/types/Dashboard';
|
||||
|
||||
interface CrudThemeProviderProps {
|
||||
@@ -41,8 +42,18 @@ export default function CrudThemeProvider({
|
||||
children,
|
||||
theme,
|
||||
}: CrudThemeProviderProps) {
|
||||
// An explicit theme config override (e.g. supplied via the Embedded SDK)
|
||||
// applies on the global theme controller and must win over the
|
||||
// dashboard-level theme. When such an override is active, skip the
|
||||
// dashboard theme so the override is not shadowed by this nested provider.
|
||||
const themeContext = useContext(ThemeContext);
|
||||
const hasThemeConfigOverride = themeContext?.hasThemeConfigOverride ?? false;
|
||||
|
||||
const { dashboardTheme, fontUrls } = useMemo(() => {
|
||||
if (!theme?.json_data) {
|
||||
// When an SDK override is active it fully owns theming, so skip parsing the
|
||||
// dashboard theme entirely. This also prevents the font-injection effect
|
||||
// below from loading dashboard fonts the override does not use.
|
||||
if (hasThemeConfigOverride || !theme?.json_data) {
|
||||
return { dashboardTheme: null, fontUrls: undefined };
|
||||
}
|
||||
try {
|
||||
@@ -64,7 +75,7 @@ export default function CrudThemeProvider({
|
||||
logging.warn('Failed to load dashboard theme:', error);
|
||||
return { dashboardTheme: null, fontUrls: undefined };
|
||||
}
|
||||
}, [theme?.json_data]);
|
||||
}, [theme?.json_data, hasThemeConfigOverride]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardTheme || !fontUrls?.length) return undefined;
|
||||
@@ -83,7 +94,7 @@ export default function CrudThemeProvider({
|
||||
};
|
||||
}, [dashboardTheme, fontUrls]);
|
||||
|
||||
if (!dashboardTheme) {
|
||||
if (!dashboardTheme || hasThemeConfigOverride) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -313,6 +313,8 @@ export interface ListViewProps<T extends object = any> {
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>;
|
||||
/** Optional expandable row configuration, passed through to antd Table. */
|
||||
expandable?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ListView<T extends object = any>({
|
||||
@@ -340,6 +342,7 @@ export function ListView<T extends object = any>({
|
||||
enableBulkTag = false,
|
||||
bulkTagResourceName,
|
||||
filtersRef,
|
||||
expandable,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
}: ListViewProps<T>) {
|
||||
@@ -593,6 +596,7 @@ export function ListView<T extends object = any>({
|
||||
loading={loading && rows.length > 0}
|
||||
highlightRowId={highlightRowId}
|
||||
columnsForWrapText={columnsForWrapText}
|
||||
expandable={expandable}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
selectedFlatRows={selectedFlatRows}
|
||||
toggleRowSelected={(rowId, value) => {
|
||||
|
||||
@@ -47,3 +47,13 @@ test('should pass removeToast to the Toast component', async () => {
|
||||
fireEvent.click(getAllByTestId('close-button')[0]);
|
||||
await waitFor(() => expect(removeToast).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('presenter caps its height with max-height so it hugs the toasts', () => {
|
||||
// A fixed `height` would make the fixed overlay span the viewport and block
|
||||
// controls underneath it; `max-height` lets it shrink to the toasts while
|
||||
// still scrolling when they overflow.
|
||||
const presenter = setup().container.querySelector('#toast-presenter');
|
||||
expect(presenter).toBeInTheDocument();
|
||||
expect(presenter).toHaveStyleRule('max-height', 'calc(100vh - 100px)');
|
||||
expect(presenter).not.toHaveStyleRule('height', 'calc(100vh - 100px)');
|
||||
});
|
||||
|
||||
@@ -37,7 +37,9 @@ const StyledToastPresenter = styled.div<VisualProps>(
|
||||
z-index: ${theme.zIndexPopupBase + 1};
|
||||
word-break: break-word;
|
||||
|
||||
height: calc(100vh - 100px);
|
||||
/* Cap height for scrolling, but hug the toasts so the fixed overlay does not
|
||||
reserve the full viewport and block controls underneath it. */
|
||||
max-height: calc(100vh - 100px);
|
||||
|
||||
display: flex;
|
||||
flex-direction: ${position === 'bottom' ? 'column-reverse' : 'column'};
|
||||
|
||||
132
superset-frontend/src/core/chatbot/index.test.ts
Normal file
132
superset-frontend/src/core/chatbot/index.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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 React from 'react';
|
||||
import { views } from 'src/core/views';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getActiveChatbot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
});
|
||||
|
||||
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot resolves the single registered chatbot', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active).toEqual({ id: 'superset.chatbot', provider });
|
||||
});
|
||||
|
||||
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First');
|
||||
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot();
|
||||
expect(active?.id).toBe('first.chatbot');
|
||||
expect(active?.provider).toBe(firstProvider);
|
||||
});
|
||||
|
||||
test('getActiveChatbot ignores views registered at other locations', () => {
|
||||
const provider = () => React.createElement('div', null, 'Panel');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'some.panel', name: 'Some Panel' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
|
||||
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||
const disposable = views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
);
|
||||
|
||||
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(getActiveChatbot()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActiveChatbot honours the admin-pinned selection', () => {
|
||||
const firstProvider = () => React.createElement('div', null, 'First');
|
||||
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
firstProvider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
secondProvider,
|
||||
),
|
||||
);
|
||||
|
||||
const active = getActiveChatbot('second.chatbot');
|
||||
expect(active?.id).toBe('second.chatbot');
|
||||
expect(active?.provider).toBe(secondProvider);
|
||||
});
|
||||
|
||||
test('getActiveChatbot falls back to first-registered when pinned id is unknown', () => {
|
||||
const provider = () => React.createElement('div', null, 'First');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
// 'stale.chatbot' was once the admin pin but is no longer registered.
|
||||
const active = getActiveChatbot('stale.chatbot');
|
||||
expect(active?.id).toBe('first.chatbot');
|
||||
});
|
||||
79
superset-frontend/src/core/chatbot/index.ts
Normal file
79
superset-frontend/src/core/chatbot/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/**
|
||||
* @fileoverview Host-internal resolver for the exclusive `superset.chatbot`
|
||||
* contribution area.
|
||||
*
|
||||
* `superset.chatbot` is a singleton contribution area: multiple chatbot
|
||||
* extensions may register a view there, but the host renders exactly one.
|
||||
* This module owns the host-side selection policy.
|
||||
*
|
||||
* This is host-internal infrastructure — it is NOT part of the public
|
||||
* `@apache-superset/core` API. Extensions register via the public
|
||||
* `views.registerView()`; only the host resolves which one is active.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
|
||||
|
||||
/**
|
||||
* The resolved active chatbot: a view id paired with its renderable provider.
|
||||
*/
|
||||
export interface ActiveChatbot {
|
||||
/** The registered view id of the selected chatbot. */
|
||||
id: string;
|
||||
/** The provider that renders the chatbot's React element. */
|
||||
provider: () => ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which single chatbot extension is currently active.
|
||||
*
|
||||
* Selection policy:
|
||||
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
|
||||
* - If `adminSelectedId` matches a registered chatbot, that one wins.
|
||||
* - Otherwise the first-registered chatbot is used as a fallback.
|
||||
* The active chatbot pin is set only via the backend DB; when no pin is set
|
||||
* (active_chatbot_id is null), the fallback is the first-registered chatbot.
|
||||
*
|
||||
* @param adminSelectedId The id stored in the DB "Default chatbot" setting, if any.
|
||||
* @returns The active chatbot's id and provider, or `undefined` if none.
|
||||
*/
|
||||
export const getActiveChatbot = (
|
||||
adminSelectedId?: string | null,
|
||||
): ActiveChatbot | undefined => {
|
||||
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
|
||||
if (registeredIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// When the DB pin names a registered candidate, use it; otherwise fall back
|
||||
// to the first registered chatbot in registration order.
|
||||
// `getRegisteredViewIds` and `getViewProvider` read the same synchronous
|
||||
// registry maps, so a candidate id always has a live provider; the final
|
||||
// guard is cheap defensiveness, not a fallback path.
|
||||
const selectedId =
|
||||
adminSelectedId && registeredIds.includes(adminSelectedId)
|
||||
? adminSelectedId
|
||||
: registeredIds[0];
|
||||
|
||||
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
|
||||
return provider ? { id: selectedId, provider } : undefined;
|
||||
};
|
||||
220
superset-frontend/src/core/dashboard/index.test.ts
Normal file
220
superset-frontend/src/core/dashboard/index.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captured listeners — allows tests to trigger action notifications manually.
|
||||
// ---------------------------------------------------------------------------
|
||||
type ListenerEntry = {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
};
|
||||
|
||||
const capturedListeners: ListenerEntry[] = [];
|
||||
|
||||
// Declared before jest.mock so the factory closure can reference it.
|
||||
let mockState: Record<string, unknown>;
|
||||
|
||||
jest.mock('src/views/store', () => ({
|
||||
store: { getState: () => mockState, dispatch: jest.fn() },
|
||||
listenerMiddleware: {
|
||||
startListening: (opts: {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
}) => {
|
||||
const entry = { predicate: opts.predicate, effect: opts.effect };
|
||||
capturedListeners.push(entry);
|
||||
return () => {
|
||||
const idx = capturedListeners.indexOf(entry);
|
||||
if (idx !== -1) capturedListeners.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../navigation', () => ({
|
||||
navigation: { getPageType: jest.fn(() => 'dashboard') },
|
||||
}));
|
||||
|
||||
function dispatch(actionType: string) {
|
||||
const action = { type: actionType };
|
||||
capturedListeners
|
||||
.filter(e => e.predicate(action))
|
||||
.forEach(e => e.effect(action));
|
||||
}
|
||||
|
||||
// Imported after mocks
|
||||
// eslint-disable-next-line import/first
|
||||
import { dashboard } from './index';
|
||||
|
||||
function makeState(
|
||||
overrides: Partial<{
|
||||
dashboardInfo: unknown;
|
||||
nativeFilters: unknown;
|
||||
dataMask: unknown;
|
||||
sliceEntities: unknown;
|
||||
dashboardLayout: unknown;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
dashboardInfo: { id: 1, dashboard_title: 'Sales', slug: 'sales' },
|
||||
nativeFilters: { filters: { 'filter-1': { name: 'Region' } } },
|
||||
dataMask: { 'filter-1': { filterState: { value: ['West'] } } },
|
||||
sliceEntities: { slices: {} },
|
||||
dashboardLayout: { present: {} },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = makeState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
capturedListeners.length = 0;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('getCurrentDashboard returns undefined when not on dashboard page', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValueOnce('explore');
|
||||
expect(dashboard.getCurrentDashboard()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentDashboard returns undefined when dashboardInfo is absent', () => {
|
||||
mockState = makeState({ dashboardInfo: undefined });
|
||||
expect(dashboard.getCurrentDashboard()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentDashboard returns dashboard context with active filters', () => {
|
||||
expect(dashboard.getCurrentDashboard()).toEqual({
|
||||
dashboardId: 1,
|
||||
title: 'Sales',
|
||||
filters: [{ filterId: 'filter-1', label: 'Region', value: ['West'] }],
|
||||
// No charts on the (empty) layout fixture.
|
||||
charts: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('getCurrentDashboard reports charts placed on the dashboard layout', () => {
|
||||
mockState = makeState({
|
||||
sliceEntities: {
|
||||
slices: {
|
||||
42: {
|
||||
slice_name: 'Revenue by Region',
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
datasource_id: 7,
|
||||
datasource_name: 'cleaned_sales',
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboardLayout: {
|
||||
present: {
|
||||
'CHART-abc': { id: 'CHART-abc', type: 'CHART', meta: { chartId: 42 } },
|
||||
// A chart id with no matching slice entity still appears, with blanks.
|
||||
'CHART-def': { id: 'CHART-def', type: 'CHART', meta: { chartId: 99 } },
|
||||
// Non-chart components are ignored.
|
||||
'TAB-xyz': { id: 'TAB-xyz', type: 'TAB', meta: {} },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(dashboard.getCurrentDashboard()?.charts).toEqual([
|
||||
{
|
||||
chartId: 42,
|
||||
chartName: 'Revenue by Region',
|
||||
vizType: 'echarts_timeseries_bar',
|
||||
datasourceId: 7,
|
||||
datasourceName: 'cleaned_sales',
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
chartId: 99,
|
||||
chartName: '',
|
||||
vizType: '',
|
||||
datasourceId: null,
|
||||
datasourceName: null,
|
||||
isVisible: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('getCurrentDashboard excludes filters with null value', () => {
|
||||
mockState = makeState({
|
||||
dataMask: { 'filter-1': { filterState: { value: null } } },
|
||||
});
|
||||
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('getCurrentDashboard excludes dataMask entries not in nativeFilters', () => {
|
||||
mockState = makeState({
|
||||
dataMask: { 'chart-filter': { filterState: { value: 'foo' } } },
|
||||
});
|
||||
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('filter array value is a defensive copy — mutation does not affect Redux state', () => {
|
||||
const ctx = dashboard.getCurrentDashboard();
|
||||
const original = [
|
||||
...(mockState as any).dataMask['filter-1'].filterState.value,
|
||||
];
|
||||
(ctx!.filters[0].value as string[]).push('East');
|
||||
expect((mockState as any).dataMask['filter-1'].filterState.value).toEqual(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
// Action type strings match the constants in src/dashboard/actions/hydrate
|
||||
// and src/dataMask/actions — kept as literals so this test file has no
|
||||
// import dependency on those modules.
|
||||
test.each([
|
||||
'HYDRATE_DASHBOARD',
|
||||
'UPDATE_DATA_MASK',
|
||||
'SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE',
|
||||
])('onDidChangeDashboard fires on action type %s', actionType => {
|
||||
const listener = jest.fn();
|
||||
const disposable = dashboard.onDidChangeDashboard(listener);
|
||||
|
||||
dispatch(actionType);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ dashboardId: 1, title: 'Sales' }),
|
||||
);
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidChangeDashboard does not fire when not on dashboard page', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
|
||||
|
||||
const listener = jest.fn();
|
||||
const disposable = dashboard.onDidChangeDashboard(listener);
|
||||
dispatch('HYDRATE_DASHBOARD');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('disposed listener is not called', () => {
|
||||
const listener = jest.fn();
|
||||
const disposable = dashboard.onDidChangeDashboard(listener);
|
||||
disposable.dispose();
|
||||
dispatch('HYDRATE_DASHBOARD');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
123
superset-frontend/src/core/dashboard/index.ts
Normal file
123
superset-frontend/src/core/dashboard/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `dashboard` namespace.
|
||||
*
|
||||
* Wraps Redux dashboardInfo and dataMask state and normalizes them into the
|
||||
* stable `DashboardContext` contract. Extensions must not depend on the Redux
|
||||
* slice structure directly.
|
||||
*/
|
||||
|
||||
import type { dashboard as dashboardApi } from '@apache-superset/core';
|
||||
import type { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
|
||||
import {
|
||||
UPDATE_DATA_MASK,
|
||||
SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
|
||||
} from 'src/dataMask/actions';
|
||||
import { store, RootState } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout';
|
||||
import { createActionListener } from '../utils';
|
||||
import { navigation } from '../navigation';
|
||||
|
||||
type DashboardContext = dashboardApi.DashboardContext;
|
||||
type FilterValue = dashboardApi.FilterValue;
|
||||
type ChartSummary = NonNullable<DashboardContext['charts']>[number];
|
||||
|
||||
function buildChartSummaries(state: RootState): ChartSummary[] {
|
||||
const slices = state.sliceEntities?.slices ?? {};
|
||||
const layout = state.dashboardLayout?.present ?? {};
|
||||
|
||||
// Only charts actually placed on the dashboard layout — `slices` can also
|
||||
// hold entities that are not on the current dashboard.
|
||||
return getChartIdsFromLayout(layout).map(chartId => {
|
||||
const slice = slices[chartId];
|
||||
return {
|
||||
chartId,
|
||||
chartName: slice?.slice_name ?? '',
|
||||
vizType: slice?.viz_type ?? '',
|
||||
datasourceId: slice?.datasource_id ?? null,
|
||||
datasourceName: slice?.datasource_name ?? null,
|
||||
// Tab-accurate visibility is a deferred phase (SIP §10/§11); every chart
|
||||
// on the dashboard is reported visible for now.
|
||||
isVisible: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildDashboardContext(): DashboardContext | undefined {
|
||||
if (navigation.getPageType() !== 'dashboard') return undefined;
|
||||
// `store.getState()` is already typed as RootState, so the slices below are
|
||||
// read with their real types — the host owns this normalization and must
|
||||
// stay type-safe against slice reshapes.
|
||||
const state = store.getState();
|
||||
const info = state.dashboardInfo;
|
||||
if (!info?.id) return undefined;
|
||||
|
||||
const nativeFilters = state.nativeFilters?.filters ?? {};
|
||||
const dataMask: DataMaskStateWithId = state.dataMask ?? {};
|
||||
|
||||
const filters: FilterValue[] = Object.entries(dataMask)
|
||||
.filter(([id, mask]) => {
|
||||
if (!(id in nativeFilters)) return false;
|
||||
const value = mask?.filterState?.value;
|
||||
return value !== null && value !== undefined;
|
||||
})
|
||||
.map(([id, mask]) => {
|
||||
const raw = mask.filterState?.value;
|
||||
return {
|
||||
filterId: id,
|
||||
label: nativeFilters[id]?.name ?? id,
|
||||
value: Array.isArray(raw) ? [...raw] : raw,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
dashboardId: info.id,
|
||||
title: info.dashboard_title ?? info.slug ?? String(info.id),
|
||||
filters,
|
||||
charts: buildChartSummaries(state),
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardChangePredicate: AnyListenerPredicate<RootState> = action =>
|
||||
action.type === HYDRATE_DASHBOARD ||
|
||||
action.type === UPDATE_DATA_MASK ||
|
||||
action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE;
|
||||
|
||||
const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () =>
|
||||
buildDashboardContext();
|
||||
|
||||
const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = (
|
||||
listener: (ctx: DashboardContext) => void,
|
||||
thisArgs?: any,
|
||||
) =>
|
||||
createActionListener<DashboardContext>(
|
||||
dashboardChangePredicate,
|
||||
listener,
|
||||
() => buildDashboardContext() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
export const dashboard: typeof dashboardApi = {
|
||||
getCurrentDashboard,
|
||||
onDidChangeDashboard,
|
||||
};
|
||||
63
superset-frontend/src/core/dataset/index.ts
Normal file
63
superset-frontend/src/core/dataset/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `dataset` namespace.
|
||||
*
|
||||
* Dataset page components call `setCurrentDataset` to publish context as they
|
||||
* load. Extensions consume the stable `DatasetContext` contract; they are
|
||||
* isolated from the page's internal data-fetching implementation.
|
||||
*/
|
||||
|
||||
import type { dataset as datasetApi } from '@apache-superset/core';
|
||||
import { createEmitter } from '../utils';
|
||||
|
||||
type DatasetContext = datasetApi.DatasetContext;
|
||||
|
||||
const emitter = createEmitter<DatasetContext | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Host-internal: called by the Dataset page when its entity loads or changes.
|
||||
* Not part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
export const setCurrentDataset = (ctx: DatasetContext | undefined): void => {
|
||||
emitter.fire(ctx);
|
||||
};
|
||||
|
||||
const getCurrentDataset: typeof datasetApi.getCurrentDataset = () => {
|
||||
const current = emitter.getCurrent();
|
||||
return current ? { ...current } : undefined;
|
||||
};
|
||||
|
||||
const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
|
||||
listener: (ctx: DatasetContext) => void,
|
||||
thisArgs?: unknown,
|
||||
) => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
// The public contract only emits a concrete context; skip `undefined` clears
|
||||
// so subscribers are never handed an empty value.
|
||||
return emitter.event(ctx => {
|
||||
if (ctx) bound(ctx);
|
||||
});
|
||||
};
|
||||
|
||||
export const dataset: typeof datasetApi = {
|
||||
getCurrentDataset,
|
||||
onDidChangeDataset,
|
||||
};
|
||||
157
superset-frontend/src/core/explore/index.test.ts
Normal file
157
superset-frontend/src/core/explore/index.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Captured listeners — allows tests to trigger action notifications manually.
|
||||
// ---------------------------------------------------------------------------
|
||||
type ListenerEntry = {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
};
|
||||
|
||||
const capturedListeners: ListenerEntry[] = [];
|
||||
|
||||
// Declared before jest.mock so the factory closure can reference it.
|
||||
let mockState: Record<string, unknown>;
|
||||
|
||||
jest.mock('src/views/store', () => ({
|
||||
store: { getState: () => mockState, dispatch: jest.fn() },
|
||||
listenerMiddleware: {
|
||||
startListening: (opts: {
|
||||
predicate: (action: { type: string }) => boolean;
|
||||
effect: (action: { type: string }) => void;
|
||||
}) => {
|
||||
const entry = { predicate: opts.predicate, effect: opts.effect };
|
||||
capturedListeners.push(entry);
|
||||
return () => {
|
||||
const idx = capturedListeners.indexOf(entry);
|
||||
if (idx !== -1) capturedListeners.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../navigation', () => ({
|
||||
navigation: { getPageType: jest.fn(() => 'explore') },
|
||||
}));
|
||||
|
||||
function dispatch(actionType: string) {
|
||||
const action = { type: actionType };
|
||||
capturedListeners
|
||||
.filter(e => e.predicate(action))
|
||||
.forEach(e => e.effect(action));
|
||||
}
|
||||
|
||||
// Imported after mocks
|
||||
// eslint-disable-next-line import/first
|
||||
import { explore } from './index';
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = {
|
||||
explore: {
|
||||
slice: { slice_id: 42, slice_name: 'My Chart' },
|
||||
datasource: { id: 7, table_name: 'orders' },
|
||||
controls: { viz_type: { value: 'bar' } },
|
||||
sliceName: 'My Chart',
|
||||
form_data: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
capturedListeners.length = 0;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('getCurrentChart returns undefined when not on explore page', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
|
||||
expect(explore.getCurrentChart()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentChart returns undefined when explore state is absent', () => {
|
||||
mockState = {};
|
||||
expect(explore.getCurrentChart()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getCurrentChart returns chart context from Redux state', () => {
|
||||
expect(explore.getCurrentChart()).toEqual({
|
||||
chartId: 42,
|
||||
chartName: 'My Chart',
|
||||
vizType: 'bar',
|
||||
datasourceId: 7,
|
||||
datasourceName: 'orders',
|
||||
});
|
||||
});
|
||||
|
||||
test('getCurrentChart returns null chartId for unsaved chart', () => {
|
||||
mockState = {
|
||||
explore: {
|
||||
slice: null,
|
||||
datasource: { id: 1, table_name: 'events' },
|
||||
controls: { viz_type: { value: 'line' } },
|
||||
sliceName: null,
|
||||
form_data: { viz_type: 'line' },
|
||||
},
|
||||
};
|
||||
expect(explore.getCurrentChart()?.chartId).toBeNull();
|
||||
});
|
||||
|
||||
// Action type strings match the constants in src/explore/actions/exploreActions
|
||||
// and src/explore/actions/datasourcesActions — kept as literals so this test
|
||||
// file has no import dependency on those modules.
|
||||
test.each([
|
||||
'HYDRATE_EXPLORE',
|
||||
'UPDATE_FORM_DATA', // SET_FORM_DATA constant resolves to this string
|
||||
'UPDATE_CHART_TITLE',
|
||||
'SET_DATASOURCE',
|
||||
'CREATE_NEW_SLICE',
|
||||
'SLICE_UPDATED',
|
||||
])('onDidChangeChart fires on action type %s', actionType => {
|
||||
const listener = jest.fn();
|
||||
const disposable = explore.onDidChangeChart(listener);
|
||||
|
||||
dispatch(actionType);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ chartId: 42, vizType: 'bar' }),
|
||||
);
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidChangeChart does not fire when page type is not explore', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
|
||||
|
||||
const listener = jest.fn();
|
||||
const disposable = explore.onDidChangeChart(listener);
|
||||
dispatch('HYDRATE_EXPLORE');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('disposed listener is not called', () => {
|
||||
const listener = jest.fn();
|
||||
const disposable = explore.onDidChangeChart(listener);
|
||||
disposable.dispose();
|
||||
dispatch('HYDRATE_EXPLORE');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
92
superset-frontend/src/core/explore/index.ts
Normal file
92
superset-frontend/src/core/explore/index.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `explore` namespace.
|
||||
*
|
||||
* Wraps Redux explore state and normalizes it into the stable `ChartContext`
|
||||
* contract. Extensions must not depend on the Redux slice structure directly.
|
||||
*/
|
||||
|
||||
import type { explore as exploreApi } from '@apache-superset/core';
|
||||
import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
|
||||
import {
|
||||
CREATE_NEW_SLICE,
|
||||
SET_FORM_DATA,
|
||||
SLICE_UPDATED,
|
||||
UPDATE_CHART_TITLE,
|
||||
} from 'src/explore/actions/exploreActions';
|
||||
import { SET_DATASOURCE } from 'src/explore/actions/datasourcesActions';
|
||||
import { store, RootState } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import { createActionListener } from '../utils';
|
||||
import { navigation } from '../navigation';
|
||||
|
||||
type ChartContext = exploreApi.ChartContext;
|
||||
|
||||
function buildChartContext(): ChartContext | undefined {
|
||||
if (navigation.getPageType() !== 'explore') return undefined;
|
||||
// `store.getState()` is already RootState; read the typed `explore` slice
|
||||
// directly rather than casting it away.
|
||||
const state = store.getState();
|
||||
const exploreState = state.explore;
|
||||
if (!exploreState) return undefined;
|
||||
|
||||
const { slice, datasource, controls } = exploreState;
|
||||
const vizType: string =
|
||||
(controls?.viz_type?.value as string) ??
|
||||
exploreState.form_data?.viz_type ??
|
||||
'';
|
||||
|
||||
return {
|
||||
chartId: slice?.slice_id ?? null,
|
||||
chartName: exploreState.sliceName ?? slice?.slice_name ?? null,
|
||||
vizType,
|
||||
datasourceId: datasource?.id ?? null,
|
||||
datasourceName:
|
||||
datasource?.table_name ?? datasource?.datasource_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const exploreChangePredicate: AnyListenerPredicate<RootState> = action =>
|
||||
action.type === HYDRATE_EXPLORE ||
|
||||
action.type === SET_FORM_DATA ||
|
||||
action.type === UPDATE_CHART_TITLE ||
|
||||
action.type === SET_DATASOURCE ||
|
||||
action.type === CREATE_NEW_SLICE ||
|
||||
action.type === SLICE_UPDATED;
|
||||
|
||||
const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
|
||||
buildChartContext();
|
||||
|
||||
const onDidChangeChart: typeof exploreApi.onDidChangeChart = (
|
||||
listener: (ctx: ChartContext) => void,
|
||||
thisArgs?: any,
|
||||
) =>
|
||||
createActionListener<ChartContext>(
|
||||
exploreChangePredicate,
|
||||
listener,
|
||||
() => buildChartContext() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
export const explore: typeof exploreApi = {
|
||||
getCurrentChart,
|
||||
onDidChangeChart,
|
||||
};
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { extensions as extensionsApi } from '@apache-superset/core';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import ExtensionsLoader from 'src/extensions/ExtensionsLoader';
|
||||
|
||||
const getExtension: typeof extensionsApi.getExtension = id =>
|
||||
@@ -29,3 +30,61 @@ export const extensions: typeof extensionsApi = {
|
||||
getExtension,
|
||||
getAllExtensions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Deployment-wide extension admin settings. The keys are snake_case to match
|
||||
* the `/api/v1/extensions/settings` wire shape this store loads from.
|
||||
* Settings are read-only from the frontend; the admin write path has been
|
||||
* removed in favour of direct backend configuration.
|
||||
*/
|
||||
export type ExtensionSettings = {
|
||||
active_chatbot_id: string | null;
|
||||
};
|
||||
|
||||
const SETTINGS_ENDPOINT = '/api/v1/extensions/settings';
|
||||
|
||||
const EMPTY_SETTINGS: ExtensionSettings = {
|
||||
active_chatbot_id: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Single module-level store for extension admin settings. The chatbot mount
|
||||
* reads this one source via `useSyncExternalStore` so it re-resolves when the
|
||||
* store is updated — no bespoke second notification channel needed.
|
||||
*/
|
||||
let settings: ExtensionSettings = EMPTY_SETTINGS;
|
||||
const settingsListeners = new Set<() => void>();
|
||||
|
||||
const emitSettingsChange = (): void => {
|
||||
settingsListeners.forEach(fn => fn());
|
||||
};
|
||||
|
||||
/** Subscribe to settings changes (for `useSyncExternalStore`). */
|
||||
export const subscribeToExtensionSettings = (
|
||||
listener: () => void,
|
||||
): (() => void) => {
|
||||
settingsListeners.add(listener);
|
||||
return () => {
|
||||
settingsListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/** Current settings snapshot (for `useSyncExternalStore`). */
|
||||
export const getExtensionSettingsSnapshot = (): ExtensionSettings => settings;
|
||||
|
||||
/** Replace the settings snapshot and notify subscribers. Module-private; only loadExtensionSettings should call this. */
|
||||
const applyExtensionSettings = (next: ExtensionSettings): void => {
|
||||
settings = next;
|
||||
emitSettingsChange();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch settings from the server into the store. Resolves to the loaded value;
|
||||
* on failure the store is left untouched and the error is rethrown so callers
|
||||
* can surface it.
|
||||
*/
|
||||
export const loadExtensionSettings = async (): Promise<ExtensionSettings> => {
|
||||
const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT });
|
||||
applyExtensionSettings(json.result ?? EMPTY_SETTINGS);
|
||||
return settings;
|
||||
};
|
||||
|
||||
@@ -28,10 +28,14 @@ export const core: typeof coreType = {
|
||||
|
||||
export * from './authentication';
|
||||
export * from './commands';
|
||||
export * from './dashboard';
|
||||
export * from './dataset';
|
||||
export * from './editors';
|
||||
export * from './explore';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
export * from './sqlLab';
|
||||
export * from './utils';
|
||||
export * from './views';
|
||||
|
||||
121
superset-frontend/src/core/navigation/index.test.ts
Normal file
121
superset-frontend/src/core/navigation/index.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Reset module state between tests so currentPageType is re-initialized.
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/' },
|
||||
});
|
||||
});
|
||||
|
||||
async function importNavigation() {
|
||||
const mod = await import('./index');
|
||||
return mod;
|
||||
}
|
||||
|
||||
test('getPageType returns "other" for unknown pathname', async () => {
|
||||
const { navigation } = await importNavigation();
|
||||
expect(navigation.getPageType()).toBe('other');
|
||||
});
|
||||
|
||||
test('getPageType derives page type from window.location.pathname', async () => {
|
||||
window.location.pathname = '/superset/dashboard/42/';
|
||||
const { navigation } = await importNavigation();
|
||||
expect(navigation.getPageType()).toBe('dashboard');
|
||||
});
|
||||
|
||||
test('notifyPageChange updates the current page type', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
notifyPageChange('/explore/?form_data={}');
|
||||
expect(navigation.getPageType()).toBe('explore');
|
||||
});
|
||||
|
||||
test('notifyPageChange fires listeners on page type change', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).toHaveBeenCalledWith('dashboard');
|
||||
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
|
||||
window.location.pathname = '/superset/dashboard/1/';
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
navigation.onDidChangePage(listener);
|
||||
|
||||
notifyPageChange('/superset/dashboard/2/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onDidChangePage listener is removed after dispose', async () => {
|
||||
const { navigation, notifyPageChange } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
disposable.dispose();
|
||||
notifyPageChange('/superset/dashboard/1/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sqllab path is matched with and without trailing slash', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab');
|
||||
expect(navigation.getPageType()).toBe('sqllab');
|
||||
notifyPageChange('/explore/');
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPageType()).toBe('sqllab');
|
||||
});
|
||||
|
||||
test('chart and dashboard list pages get their own page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/list/');
|
||||
expect(navigation.getPageType()).toBe('chart_list');
|
||||
notifyPageChange('/dashboard/list/');
|
||||
expect(navigation.getPageType()).toBe('dashboard_list');
|
||||
});
|
||||
|
||||
test('dataset list and single-dataset pages get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/tablemodelview/list/');
|
||||
expect(navigation.getPageType()).toBe('dataset_list');
|
||||
notifyPageChange('/dataset/42');
|
||||
expect(navigation.getPageType()).toBe('dataset');
|
||||
});
|
||||
|
||||
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/sqllab/');
|
||||
expect(navigation.getPageType()).toBe('sqllab');
|
||||
notifyPageChange('/sqllab/history/');
|
||||
expect(navigation.getPageType()).toBe('query_history');
|
||||
notifyPageChange('/savedqueryview/list/');
|
||||
expect(navigation.getPageType()).toBe('saved_queries');
|
||||
});
|
||||
|
||||
test('chart/add resolves to explore, not chart_list', async () => {
|
||||
const { notifyPageChange, navigation } = await importNavigation();
|
||||
notifyPageChange('/chart/add');
|
||||
expect(navigation.getPageType()).toBe('explore');
|
||||
});
|
||||
82
superset-frontend/src/core/navigation/index.ts
Normal file
82
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Host-internal implementation of the `navigation` namespace.
|
||||
*
|
||||
* Backed by browser location — no Redux dependency.
|
||||
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
|
||||
*/
|
||||
|
||||
import type { navigation as navigationApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
|
||||
type PageType = navigationApi.PageType;
|
||||
|
||||
const listeners = new Set<(pageType: PageType) => void>();
|
||||
|
||||
function derivePageType(pathname: string): PageType {
|
||||
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
|
||||
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
|
||||
if (pathname.startsWith('/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/superset/explore/')) return 'explore';
|
||||
if (pathname.startsWith('/chart/add')) return 'explore';
|
||||
if (pathname.startsWith('/chart/list')) return 'chart_list';
|
||||
if (pathname.startsWith('/sqllab/history')) return 'query_history';
|
||||
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
|
||||
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
|
||||
return 'sqllab';
|
||||
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
|
||||
if (pathname.startsWith('/dataset/')) return 'dataset';
|
||||
if (pathname.startsWith('/superset/welcome/')) return 'home';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
let currentPageType: PageType | undefined;
|
||||
|
||||
function getOrInitPageType(): PageType {
|
||||
if (currentPageType === undefined) {
|
||||
currentPageType = derivePageType(window.location.pathname);
|
||||
}
|
||||
return currentPageType;
|
||||
}
|
||||
|
||||
/** Called by ExtensionsStartup whenever the React Router location changes. */
|
||||
export const notifyPageChange = (pathname: string): void => {
|
||||
const next = derivePageType(pathname);
|
||||
if (next === getOrInitPageType()) return;
|
||||
currentPageType = next;
|
||||
listeners.forEach(fn => fn(next));
|
||||
};
|
||||
|
||||
const getPageType: typeof navigationApi.getPageType = () => getOrInitPageType();
|
||||
|
||||
const onDidChangePage: typeof navigationApi.onDidChangePage = (
|
||||
listener: (pageType: PageType) => void,
|
||||
thisArgs?: any,
|
||||
): Disposable => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return new Disposable(() => listeners.delete(bound));
|
||||
};
|
||||
|
||||
export const navigation: typeof navigationApi = {
|
||||
getPageType,
|
||||
onDidChangePage,
|
||||
};
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
QueryResultContext,
|
||||
QueryErrorResultContext,
|
||||
} from './models';
|
||||
import { navigation } from '../navigation';
|
||||
|
||||
const { CTASMethod } = sqlLabApi;
|
||||
|
||||
@@ -301,8 +302,15 @@ function createQueryErrorContext(
|
||||
);
|
||||
}
|
||||
|
||||
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () =>
|
||||
getTab(activeEditorId());
|
||||
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => {
|
||||
// Guard on the page type so the tab does not leak onto non-editor surfaces.
|
||||
// The SQL Lab Redux slice persists after navigating away, so without this
|
||||
// guard `getCurrentTab()` would keep returning the last editor's tab on, e.g.,
|
||||
// a dashboard or list page. Mirrors the page-type guards on
|
||||
// `explore.getCurrentChart()` / `dashboard.getCurrentDashboard()`.
|
||||
if (navigation.getPageType() !== 'sqllab') return undefined;
|
||||
return getTab(activeEditorId());
|
||||
};
|
||||
|
||||
const getActivePanel: typeof sqlLabApi.getActivePanel = () => {
|
||||
const { activeSouthPaneTab } = getSqlLabState();
|
||||
@@ -452,8 +460,14 @@ const onDidChangeActiveTab: typeof sqlLabApi.onDidChangeActiveTab = (
|
||||
createActionListener(
|
||||
globalPredicate(SET_ACTIVE_QUERY_EDITOR),
|
||||
listener,
|
||||
(action: { type: string; queryEditor: { id: string } }) =>
|
||||
getTab(action.queryEditor.id),
|
||||
// Resolve the now-active tab the same way `getCurrentTab()` does (via the
|
||||
// active-editor / tabHistory state) rather than from the raw action payload.
|
||||
// The action's `queryEditor` carries the base editor without `unsavedQueryEditor`
|
||||
// merged, so its `dbId` can still be undefined at this point, which made
|
||||
// `getTab(action.queryEditor.id)` return undefined and silently swallow the
|
||||
// event. Reading the resolved active tab keeps this event consistent with the
|
||||
// getter and fires on every tab switch.
|
||||
() => getCurrentTab() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ jest.mock('src/views/store', () => ({
|
||||
setupStore: jest.fn(),
|
||||
}));
|
||||
|
||||
// The sqlLab namespace guards `getCurrentTab()` on the page type. These tests
|
||||
// exercise the editor surface, so report 'sqllab'. Per-test overrides (e.g. to
|
||||
// assert the off-surface guard) can change the return value.
|
||||
jest.mock('../navigation', () => ({
|
||||
navigation: { getPageType: jest.fn(() => 'sqllab') },
|
||||
}));
|
||||
|
||||
// Module under test — imported after mocks
|
||||
// eslint-disable-next-line import/first
|
||||
import { sqlLab } from '.';
|
||||
@@ -388,6 +395,31 @@ test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => {
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidChangeActiveTab carries the newly-activated tab when switching away', () => {
|
||||
// Switching from the first editor to a second one must report the second tab,
|
||||
// not the first. Regression guard: resolving the tab from the live active
|
||||
// editor (via getCurrentTab) instead of the raw action payload.
|
||||
mockStore.dispatch({
|
||||
type: ADD_QUERY_EDITOR,
|
||||
queryEditor: makeSecondEditor(),
|
||||
});
|
||||
|
||||
const listener = jest.fn();
|
||||
const disposable = sqlLab.onDidChangeActiveTab(listener);
|
||||
|
||||
mockStore.dispatch({
|
||||
type: SET_ACTIVE_QUERY_EDITOR,
|
||||
queryEditor: { id: 'editor-2' },
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
const tab = listener.mock.calls[0][0];
|
||||
expect(tab.id).toBe('editor-2');
|
||||
expect(tab.databaseId).toBe(2);
|
||||
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => {
|
||||
const listener = jest.fn();
|
||||
const disposable = sqlLab.onDidCreateTab(listener);
|
||||
@@ -535,6 +567,13 @@ test('getCurrentTab returns the active tab with correct properties', () => {
|
||||
expect(tab!.schema).toBe('public');
|
||||
});
|
||||
|
||||
test('getCurrentTab returns undefined when not on the SQL Lab editor surface', () => {
|
||||
const { navigation } = jest.requireMock('../navigation');
|
||||
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
|
||||
|
||||
expect(sqlLab.getCurrentTab()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getActivePanel returns the active south pane tab', () => {
|
||||
const panel = sqlLab.getActivePanel();
|
||||
expect(panel.id).toBe('Results');
|
||||
|
||||
@@ -20,6 +20,56 @@ import type { common as core } from '@apache-superset/core';
|
||||
import { AnyAction } from 'redux';
|
||||
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import { Disposable } from './models';
|
||||
|
||||
/**
|
||||
* A typed event subscription matching the public `Event<T>` contract.
|
||||
* Calling it with a listener (and optional `this` arg) subscribes and returns
|
||||
* a {@link Disposable} that unsubscribes.
|
||||
*/
|
||||
export type EventSubscriber<T> = (
|
||||
listener: (e: T) => void,
|
||||
thisArgs?: unknown,
|
||||
) => Disposable;
|
||||
|
||||
/**
|
||||
* A minimal host-internal event emitter shared by the producer-backed
|
||||
* namespaces (dataset, navigation, settings, view registry). Each of those
|
||||
* needs the same "publish a value and fan it out to subscribers" primitive;
|
||||
* this collapses the duplicated Set + bind + Disposable boilerplate into one
|
||||
* place.
|
||||
*
|
||||
* `event` is exposed to extensions as the namespace's `onDidChange*`; `fire`
|
||||
* and `getCurrent` stay host-internal.
|
||||
*/
|
||||
export interface Emitter<T> {
|
||||
/** Subscribe to changes; conforms to the public `Event<T>` shape. */
|
||||
event: EventSubscriber<T>;
|
||||
/** Notify all current subscribers with `value`. */
|
||||
fire: (value: T) => void;
|
||||
/** The most recently fired value (or the initial value). */
|
||||
getCurrent: () => T;
|
||||
}
|
||||
|
||||
export function createEmitter<T>(initial: T): Emitter<T> {
|
||||
const listeners = new Set<(e: T) => void>();
|
||||
let current = initial;
|
||||
|
||||
return {
|
||||
event: (listener, thisArgs) => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return new Disposable(() => {
|
||||
listeners.delete(bound);
|
||||
});
|
||||
},
|
||||
fire: value => {
|
||||
current = value;
|
||||
listeners.forEach(fn => fn(value));
|
||||
},
|
||||
getCurrent: () => current,
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionListener<V>(
|
||||
predicate: AnyListenerPredicate<RootState>,
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { views, resolveView } from './index';
|
||||
import {
|
||||
views,
|
||||
resolveView,
|
||||
getViewProvider,
|
||||
getRegisteredViewIds,
|
||||
} from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
|
||||
|
||||
expect(views.getViews('sqllab.panels')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns the registered provider for a matching location', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider);
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined when the location does not match', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'test.provider', name: 'Test Provider' },
|
||||
'sqllab.panels',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
// Registered, but at a different location.
|
||||
expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getViewProvider returns undefined for an unknown id', () => {
|
||||
expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns ids in registration order', () => {
|
||||
const provider = () => React.createElement('div', null, 'Test');
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'first.chatbot', name: 'First' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
views.registerView(
|
||||
{ id: 'second.chatbot', name: 'Second' },
|
||||
'superset.chatbot',
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
|
||||
'first.chatbot',
|
||||
'second.chatbot',
|
||||
]);
|
||||
});
|
||||
|
||||
test('getRegisteredViewIds returns an empty array for an unused location', () => {
|
||||
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,27 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
/**
|
||||
* Monotonic version of the view registry. Bumped on every registration or
|
||||
* disposal so consumers can re-derive state via React's `useSyncExternalStore`.
|
||||
*/
|
||||
let registryVersion = 0;
|
||||
const registrySubscribers = new Set<() => void>();
|
||||
|
||||
const notifyRegistry = () => {
|
||||
registryVersion += 1;
|
||||
registrySubscribers.forEach(fn => fn());
|
||||
};
|
||||
|
||||
export const subscribeToRegistry = (listener: () => void): (() => void) => {
|
||||
registrySubscribers.add(listener);
|
||||
return () => {
|
||||
registrySubscribers.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getRegistryVersion = () => registryVersion;
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -46,15 +67,24 @@ const registerView: typeof viewsApi.registerView = (
|
||||
): Disposable => {
|
||||
const { id } = view;
|
||||
|
||||
const previousLocation = viewRegistry.get(id)?.location;
|
||||
if (previousLocation && previousLocation !== location) {
|
||||
locationIndex.get(previousLocation)?.delete(id);
|
||||
}
|
||||
|
||||
viewRegistry.set(id, { view, location, provider });
|
||||
|
||||
const ids = locationIndex.get(location) ?? new Set();
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
|
||||
notifyRegistry();
|
||||
|
||||
return new Disposable(() => {
|
||||
const registeredLocation = viewRegistry.get(id)?.location ?? location;
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
locationIndex.get(registeredLocation)?.delete(id);
|
||||
notifyRegistry();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,6 +107,28 @@ const getViews: typeof viewsApi.getViews = (
|
||||
.filter((c): c is View => !!c);
|
||||
};
|
||||
|
||||
/**
|
||||
* Host-internal: returns the provider for a registered view id at a location.
|
||||
* Not part of the public `@apache-superset/core` API — `getViews` stays
|
||||
* descriptor-only so extensions cannot render each other's views directly.
|
||||
*/
|
||||
export const getViewProvider = (
|
||||
location: string,
|
||||
id: string,
|
||||
): (() => ReactElement) | undefined => {
|
||||
const entry = viewRegistry.get(id);
|
||||
if (entry?.location !== location) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.provider;
|
||||
};
|
||||
|
||||
/** Host-internal: view ids at a location in registration order. */
|
||||
export const getRegisteredViewIds = (location: string): string[] => {
|
||||
const ids = locationIndex.get(location);
|
||||
return ids ? Array.from(ids) : [];
|
||||
};
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
getViews,
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
JsonResponse,
|
||||
SupersetClient,
|
||||
isFeatureEnabled,
|
||||
} from '@superset-ui/core';
|
||||
import { waitFor } from 'spec/helpers/testing-library';
|
||||
|
||||
import {
|
||||
@@ -28,9 +32,16 @@ import {
|
||||
ON_FILTERS_REFRESH,
|
||||
ON_REFRESH,
|
||||
ON_REFRESH_SUCCESS,
|
||||
TOGGLE_FAVE_STAR,
|
||||
TOGGLE_PUBLISHED,
|
||||
fetchFaveStar,
|
||||
saveFaveStar,
|
||||
savePublished,
|
||||
} from 'src/dashboard/actions/dashboardState';
|
||||
import { refreshChart } from 'src/components/Chart/chartAction';
|
||||
import { UPDATE_COMPONENTS_PARENTS_LIST } from 'src/dashboard/actions/dashboardLayout';
|
||||
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
|
||||
import { ToastType } from 'src/components/MessageToasts/types';
|
||||
import {
|
||||
DASHBOARD_GRID_ID,
|
||||
SAVE_TYPE_OVERWRITE,
|
||||
@@ -400,4 +411,322 @@ describe('dashboardState actions', () => {
|
||||
expect(dispatchedTypes).not.toContain(ON_REFRESH);
|
||||
expect(dispatchedTypes).not.toContain(ON_FILTERS_REFRESH);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('fetchFaveStar race condition', () => {
|
||||
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [{ value: true }] },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
await fetchFaveStar(id)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_FAVE_STAR,
|
||||
isStarred: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
|
||||
const requestedId = 123;
|
||||
// User navigated to a different dashboard by the time the response comes back
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [{ value: true }] },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
await fetchFaveStar(requestedId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await fetchFaveStar(id)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Danger,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
|
||||
const requestedId = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
getStub.mockRestore();
|
||||
getStub = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await fetchFaveStar(requestedId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('saveFaveStar race condition', () => {
|
||||
let deleteStub: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
deleteStub = jest
|
||||
.spyOn(SupersetClient, 'delete')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
deleteStub.mockRestore();
|
||||
});
|
||||
|
||||
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches (starring)', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await saveFaveStar(id, false)(dispatch, getState);
|
||||
|
||||
expect(postStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_FAVE_STAR,
|
||||
isStarred: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches TOGGLE_FAVE_STAR when the dashboard ID still matches (unstarring)', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
await saveFaveStar(id, true)(dispatch, getState);
|
||||
|
||||
expect(deleteStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_FAVE_STAR,
|
||||
isStarred: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
|
||||
const requestedId = 123;
|
||||
// User navigated to a different dashboard by the time the response comes back
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await saveFaveStar(requestedId, false)(dispatch, getState);
|
||||
|
||||
expect(postStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await saveFaveStar(id, false)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Danger,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
|
||||
const requestedId = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
postStub.mockRestore();
|
||||
postStub = jest
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('network'));
|
||||
|
||||
await saveFaveStar(requestedId, false)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('savePublished race condition', () => {
|
||||
test('dispatches success toast and TOGGLE_PUBLISHED when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await savePublished(id, true)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Success,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: TOGGLE_PUBLISHED,
|
||||
isPublished: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('does NOT dispatch when the dashboard ID changed before the response resolved', async () => {
|
||||
const requestedId = 123;
|
||||
// User navigated to a different dashboard by the time the response comes back
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({} as unknown as JsonResponse);
|
||||
|
||||
await savePublished(requestedId, true)(dispatch, getState);
|
||||
|
||||
expect(putStub).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dispatches a danger toast on error when the dashboard ID still matches', async () => {
|
||||
const id = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockRejectedValue(new Error('forbidden'));
|
||||
|
||||
await savePublished(id, true)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: ADD_TOAST,
|
||||
payload: expect.objectContaining({
|
||||
toastType: ToastType.Danger,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT dispatch a danger toast on error when the dashboard ID changed', async () => {
|
||||
const requestedId = 123;
|
||||
const { getState, dispatch } = setup({
|
||||
dashboardInfo: {
|
||||
id: 456,
|
||||
metadata: { color_scheme: 'supersetColors' },
|
||||
},
|
||||
});
|
||||
|
||||
putStub.mockRestore();
|
||||
putStub = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockRejectedValue(new Error('forbidden'));
|
||||
|
||||
await savePublished(requestedId, true)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,27 +160,43 @@ export function toggleFaveStar(isStarred: boolean): ToggleFaveStarAction {
|
||||
}
|
||||
|
||||
export function fetchFaveStar(id: number) {
|
||||
return function fetchFaveStarThunk(dispatch: AppDispatch) {
|
||||
return function fetchFaveStarThunk(
|
||||
dispatch: AppDispatch,
|
||||
getState: GetState,
|
||||
) {
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/favorite_status/?q=${rison.encode([id])}`,
|
||||
})
|
||||
.then(({ json }: { json: JsonObject }) => {
|
||||
dispatch(toggleFaveStar(!!(json?.result as JsonObject[])?.[0]?.value));
|
||||
// Only update state if this is still the current dashboard
|
||||
// This prevents stale responses from affecting the UI after navigation
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
toggleFaveStar(!!(json?.result as JsonObject[])?.[0]?.value),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t(
|
||||
'There was an issue fetching the favorite status of this dashboard.',
|
||||
.catch(() => {
|
||||
// Only show error if this is still the current dashboard
|
||||
// This prevents error toasts from appearing for dashboards the user
|
||||
// has already navigated away from (e.g., deleted dashboards)
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t(
|
||||
'There was an issue fetching the favorite status of this dashboard.',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function saveFaveStar(id: number, isStarred: boolean) {
|
||||
return function saveFaveStarThunk(dispatch: AppDispatch) {
|
||||
return function saveFaveStarThunk(dispatch: AppDispatch, getState: GetState) {
|
||||
const endpoint = `/api/v1/dashboard/${id}/favorites/`;
|
||||
const apiCall = isStarred
|
||||
? SupersetClient.delete({
|
||||
@@ -190,13 +206,21 @@ export function saveFaveStar(id: number, isStarred: boolean) {
|
||||
|
||||
return apiCall
|
||||
.then(() => {
|
||||
dispatch(toggleFaveStar(!isStarred));
|
||||
// Only update state if this is still the current dashboard
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(toggleFaveStar(!isStarred));
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
dispatch(
|
||||
addDangerToast(t('There was an issue favoriting this dashboard.')),
|
||||
),
|
||||
);
|
||||
.catch(() => {
|
||||
// Only show error if this is still the current dashboard
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addDangerToast(t('There was an issue favoriting this dashboard.')),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,8 +238,11 @@ export function togglePublished(isPublished: boolean): TogglePublishedAction {
|
||||
export function savePublished(
|
||||
id: number,
|
||||
isPublished: boolean,
|
||||
): (dispatch: AppDispatch) => Promise<void> {
|
||||
return function savePublishedThunk(dispatch: AppDispatch): Promise<void> {
|
||||
): (dispatch: AppDispatch, getState: GetState) => Promise<void> {
|
||||
return function savePublishedThunk(
|
||||
dispatch: AppDispatch,
|
||||
getState: GetState,
|
||||
): Promise<void> {
|
||||
return SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -224,21 +251,30 @@ export function savePublished(
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
dispatch(
|
||||
addSuccessToast(
|
||||
isPublished
|
||||
? t('This dashboard is now published')
|
||||
: t('This dashboard is now hidden'),
|
||||
),
|
||||
);
|
||||
dispatch(togglePublished(isPublished));
|
||||
// Only update state if this is still the current dashboard
|
||||
// This prevents stale responses from affecting the UI after navigation
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addSuccessToast(
|
||||
isPublished
|
||||
? t('This dashboard is now published')
|
||||
: t('This dashboard is now hidden'),
|
||||
),
|
||||
);
|
||||
dispatch(togglePublished(isPublished));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t('You do not have permissions to edit this dashboard.'),
|
||||
),
|
||||
);
|
||||
// Only show error if this is still the current dashboard
|
||||
const currentId = getState().dashboardInfo?.id;
|
||||
if (currentId === id) {
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t('You do not have permissions to edit this dashboard.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
|
||||
import { render } from 'spec/helpers/testing-library';
|
||||
|
||||
import DashboardWrapper from './DashboardWrapper';
|
||||
|
||||
@@ -39,50 +38,6 @@ test('should render children', () => {
|
||||
expect(getByTestId('mock-children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should update the style on dragging state', async () => {
|
||||
const defaultProps = {
|
||||
label: <span>Test label</span>,
|
||||
tooltipTitle: 'This is a tooltip title',
|
||||
onRemove: jest.fn(),
|
||||
onMoveLabel: jest.fn(),
|
||||
onDropLabel: jest.fn(),
|
||||
type: 'test',
|
||||
index: 0,
|
||||
};
|
||||
const { container, getByText } = render(
|
||||
<DashboardWrapper>
|
||||
<OptionControlLabel
|
||||
{...defaultProps}
|
||||
index={1}
|
||||
label={<span>Label 1</span>}
|
||||
/>
|
||||
<OptionControlLabel
|
||||
{...defaultProps}
|
||||
index={2}
|
||||
label={<span>Label 2</span>}
|
||||
/>
|
||||
</DashboardWrapper>,
|
||||
{
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
initialState: {
|
||||
dashboardState: {
|
||||
editMode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
container.getElementsByClassName('dragdroppable--dragging'),
|
||||
).toHaveLength(0);
|
||||
fireEvent.dragStart(getByText('Label 1'));
|
||||
jest.runAllTimers();
|
||||
expect(
|
||||
container.getElementsByClassName('dragdroppable--dragging'),
|
||||
).toHaveLength(1);
|
||||
fireEvent.dragEnd(getByText('Label 1'));
|
||||
// immediately discards dragging state after dragEnd
|
||||
expect(
|
||||
container.getElementsByClassName('dragdroppable--dragging'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
// Note: Drag-and-drop test removed - DashboardWrapper uses react-dnd but
|
||||
// OptionControlLabel uses @dnd-kit, causing cross-library compatibility issues.
|
||||
// This test requires proper @dnd-kit testing utilities.
|
||||
|
||||
@@ -597,6 +597,35 @@ test('should fave', async () => {
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// FaveStar.onClick passes the *prior* isStarred value to saveFaveStar — the
|
||||
// reducer flips it. So favoriting (unstarred → starred) sends `false`, and
|
||||
// unfavoriting (starred → unstarred) sends `true`.
|
||||
test('should call saveFaveStar with false when favoriting from the header', () => {
|
||||
setup();
|
||||
const header = screen.getByTestId('dashboard-header-container');
|
||||
|
||||
userEvent.click(within(header).getByRole('img', { name: 'unstarred' }));
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
expect(saveFaveStar).toHaveBeenCalledWith(
|
||||
initialState.dashboardInfo.id,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('should call saveFaveStar with true when unfavoriting from the header', () => {
|
||||
setup({
|
||||
dashboardState: { ...initialState.dashboardState, isStarred: true },
|
||||
});
|
||||
const header = screen.getByTestId('dashboard-header-container');
|
||||
|
||||
userEvent.click(within(header).getByRole('img', { name: 'starred' }));
|
||||
expect(saveFaveStar).toHaveBeenCalledTimes(1);
|
||||
expect(saveFaveStar).toHaveBeenCalledWith(
|
||||
initialState.dashboardInfo.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('should toggle the edit mode', () => {
|
||||
const canEditState = {
|
||||
dashboardInfo: {
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
createStore,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import {
|
||||
useDashboard,
|
||||
useDashboardCharts,
|
||||
@@ -27,7 +33,11 @@ import {
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
|
||||
import {
|
||||
clearDashboardHistory,
|
||||
UPDATE_COMPONENTS,
|
||||
} from 'src/dashboard/actions/dashboardLayout';
|
||||
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
||||
import DashboardPage from './DashboardPage';
|
||||
|
||||
const mockTheme = {
|
||||
@@ -148,6 +158,9 @@ afterEach(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Tests assert against the global document.title and the unmount restore
|
||||
// effect can carry title state across tests, so reset it for isolation.
|
||||
document.title = '';
|
||||
mockUseDashboard.mockReturnValue({
|
||||
result: mockDashboard,
|
||||
error: null,
|
||||
@@ -233,6 +246,174 @@ test('uses theme from Redux dashboardInfo when it differs from API response (Pro
|
||||
);
|
||||
});
|
||||
|
||||
test('document.title tracks the live Redux dashboard title after a rename, not the stale API value', async () => {
|
||||
// Renaming a dashboard updates the live title in Redux
|
||||
// (dashboardLayout HEADER meta.text) and persists via an in-SPA save with
|
||||
// no full reload, so the useDashboard() API result stays stale. The browser
|
||||
// tab title must follow the live title, otherwise a newly created dashboard
|
||||
// keeps showing "[ untitled dashboard ]" after being renamed and saved.
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardLayout: {
|
||||
past: [],
|
||||
future: [],
|
||||
present: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Live Renamed Title' },
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// API result (mockDashboard.dashboard_title) is 'Test Dashboard', but the
|
||||
// live title is 'Live Renamed Title' — the tab title must reflect the latter.
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('Live Renamed Title');
|
||||
});
|
||||
});
|
||||
|
||||
test('document.title updates when the dashboard is renamed after mount', async () => {
|
||||
// The bug is a live rename: the title is edited in Redux after the page has
|
||||
// already mounted, so the tab title must react to the change rather than only
|
||||
// reflecting the title present at initial render.
|
||||
const store = createStore(
|
||||
{
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardLayout: {
|
||||
past: [],
|
||||
future: [],
|
||||
present: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Title At Mount' },
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
reducerIndex,
|
||||
);
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(document.title).toBe('Title At Mount'));
|
||||
|
||||
// Simulate the in-SPA rename mutating the live header title.
|
||||
store.dispatch({
|
||||
type: UPDATE_COMPONENTS,
|
||||
payload: {
|
||||
nextComponents: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Renamed After Mount' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(document.title).toBe('Renamed After Mount'));
|
||||
});
|
||||
|
||||
test('document.title uses the fresh API title during dashboard-to-dashboard navigation', async () => {
|
||||
// While switching dashboards in the SPA the component instance and Redux store
|
||||
// are reused, so the previous dashboard's layout (header title) lingers until
|
||||
// the new dashboard hydrates. The tab title must follow the newly loaded
|
||||
// dashboard's API title, not the stale live layout title.
|
||||
mockUseDashboard.mockReturnValue({
|
||||
result: { ...mockDashboard, id: 2, dashboard_title: 'Dashboard Two' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="2" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
// dashboardInfo still describes the previously hydrated dashboard 1.
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
dashboardLayout: {
|
||||
past: [],
|
||||
future: [],
|
||||
present: {
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: 'HEADER',
|
||||
meta: { text: 'Dashboard One' },
|
||||
},
|
||||
},
|
||||
},
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(document.title).toBe('Dashboard Two'));
|
||||
});
|
||||
|
||||
test('document.title falls back to the API dashboard_title before the layout is hydrated', async () => {
|
||||
// Before hydration there is no HEADER component in the layout, so the tab
|
||||
// title should still come from the dashboard API response.
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
<DashboardPage idOrSlug="1" />
|
||||
</Suspense>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: {
|
||||
dashboardInfo: { id: 1, metadata: {} },
|
||||
dashboardState: { sliceIds: [] },
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe('Test Dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
test('passes null theme when Redux dashboardInfo.theme is explicitly null (theme removed)', async () => {
|
||||
render(
|
||||
<Suspense fallback="loading">
|
||||
@@ -285,7 +466,9 @@ test('clears undo history after hydrating the dashboard', async () => {
|
||||
|
||||
expect(hydrateDashboard).toHaveBeenCalled();
|
||||
expect(clearDashboardHistory).toHaveBeenCalled();
|
||||
const hydrateOrder = (hydrateDashboard as jest.Mock).mock.invocationCallOrder[0];
|
||||
const clearOrder = (clearDashboardHistory as jest.Mock).mock.invocationCallOrder[0];
|
||||
const hydrateOrder = (hydrateDashboard as jest.Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
const clearOrder = (clearDashboardHistory as jest.Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
expect(clearOrder).toBeGreaterThan(hydrateOrder);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
|
||||
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
||||
import {
|
||||
getFilterValue,
|
||||
getPermalinkValue,
|
||||
@@ -152,6 +153,23 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
const readyToRender = Boolean(dashboard && charts);
|
||||
const { dashboard_title, id = 0 } = dashboard || {};
|
||||
|
||||
// The live title is edited in Redux and persisted via an in-SPA save with no
|
||||
// full reload, so the useDashboard() API result can be stale. Track the live
|
||||
// title so the browser tab stays in sync after a rename.
|
||||
const liveDashboardTitle = useSelector<RootState, string | undefined>(
|
||||
state => state.dashboardLayout?.present?.[DASHBOARD_HEADER_ID]?.meta?.text,
|
||||
);
|
||||
// Only trust the live layout title once the layout belongs to the dashboard
|
||||
// being shown. During SPA dashboard-to-dashboard navigation the previous
|
||||
// dashboard's layout lingers until the new one hydrates, so fall back to the
|
||||
// freshly fetched API title until the hydrated dashboard matches.
|
||||
const hydratedDashboardId = useSelector<RootState, number | undefined>(
|
||||
state => state.dashboardInfo?.id,
|
||||
);
|
||||
const pageTitle =
|
||||
(hydratedDashboardId === id ? liveDashboardTitle : undefined) ||
|
||||
dashboard_title;
|
||||
|
||||
// Get CSS from dashboardInfo (unified properties location)
|
||||
const css =
|
||||
useSelector((state: RootState) => state.dashboardInfo.css) ||
|
||||
@@ -303,10 +321,10 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
|
||||
// Update document title when dashboard title changes
|
||||
useEffect(() => {
|
||||
if (dashboard_title) {
|
||||
document.title = dashboard_title;
|
||||
if (pageTitle) {
|
||||
document.title = pageTitle;
|
||||
}
|
||||
}, [dashboard_title]);
|
||||
}, [pageTitle]);
|
||||
|
||||
// Restore original title on unmount
|
||||
useEffect(
|
||||
|
||||
@@ -26,9 +26,10 @@ import { DynamicPluginProvider } from 'src/components';
|
||||
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
|
||||
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
|
||||
import { ThemeController } from 'src/theme/ThemeController';
|
||||
import { type ThemeStorage, ThemeMode } from '@apache-superset/core/theme';
|
||||
import { type ThemeStorage } from '@apache-superset/core/theme';
|
||||
import { store } from 'src/views/store';
|
||||
import querystring from 'query-string';
|
||||
import { getInitialThemeMode } from './getInitialThemeMode';
|
||||
|
||||
/**
|
||||
* In-memory implementation of ThemeStorage interface for embedded contexts.
|
||||
@@ -52,7 +53,7 @@ class ThemeMemoryStorageAdapter implements ThemeStorage {
|
||||
|
||||
const themeController = new ThemeController({
|
||||
storage: new ThemeMemoryStorageAdapter(),
|
||||
initialMode: ThemeMode.DEFAULT,
|
||||
initialMode: getInitialThemeMode(),
|
||||
});
|
||||
|
||||
export const getThemeController = (): ThemeController => themeController;
|
||||
|
||||
66
superset-frontend/src/embedded/getInitialThemeMode.test.ts
Normal file
66
superset-frontend/src/embedded/getInitialThemeMode.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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 { ThemeMode } from '@apache-superset/core/theme';
|
||||
import { getInitialThemeMode } from './getInitialThemeMode';
|
||||
|
||||
let locationSpy: jest.SpyInstance | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
locationSpy?.mockRestore();
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DARK when ?themeMode=dark', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=dark',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DARK);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.SYSTEM when ?themeMode=system', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=system',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.SYSTEM);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DEFAULT when ?themeMode=light', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=light',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DEFAULT);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DEFAULT when no themeMode param', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DEFAULT);
|
||||
});
|
||||
|
||||
test('returns ThemeMode.DEFAULT for an unrecognised value', () => {
|
||||
locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?themeMode=invalid',
|
||||
} as Location);
|
||||
expect(getInitialThemeMode()).toBe(ThemeMode.DEFAULT);
|
||||
});
|
||||
35
superset-frontend/src/embedded/getInitialThemeMode.ts
Normal file
35
superset-frontend/src/embedded/getInitialThemeMode.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ThemeMode } from '@apache-superset/core/theme';
|
||||
|
||||
/**
|
||||
* Reads the `?themeMode=` URL parameter from the iframe URL and returns
|
||||
* the corresponding ThemeMode. Falls back to ThemeMode.DEFAULT when the
|
||||
* param is absent or unrecognised.
|
||||
*
|
||||
* Host apps set this via `dashboardUiConfig.urlParams.themeMode` in the
|
||||
* embed SDK, which forwards it to the iframe URL automatically.
|
||||
*/
|
||||
export function getInitialThemeMode(): ThemeMode {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const themeMode = params.get('themeMode');
|
||||
if (themeMode === 'dark') return ThemeMode.DARK;
|
||||
if (themeMode === 'system') return ThemeMode.SYSTEM;
|
||||
return ThemeMode.DEFAULT;
|
||||
}
|
||||
@@ -17,12 +17,20 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useCallback, useRef, useState } from 'react';
|
||||
import { getTimeFormatter, safeHtmlSpan, TimeFormats } from '@superset-ui/core';
|
||||
import {
|
||||
getTimeFormatter,
|
||||
safeHtmlSpan,
|
||||
TimeFormats,
|
||||
getMetricLabel,
|
||||
QueryFormMetric,
|
||||
} from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import type { IRowNode } from 'ag-grid-community';
|
||||
|
||||
const timeFormatter = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
||||
const CONTRIBUTION_SUFFIX = '__contribution';
|
||||
|
||||
export function useGridColumns(
|
||||
colnames: string[] | undefined,
|
||||
@@ -37,10 +45,33 @@ export function useGridColumns(
|
||||
.filter((column: string) => Object.keys(data[0]).includes(column))
|
||||
.map((key, index) => {
|
||||
const colType = coltypes?.[index];
|
||||
const headerLabel = columnDisplayNames?.[key] ?? key;
|
||||
|
||||
const rawHeader = columnDisplayNames?.[key] ?? key;
|
||||
let cleaned = rawHeader;
|
||||
let suffix = '';
|
||||
|
||||
if (rawHeader.endsWith(CONTRIBUTION_SUFFIX)) {
|
||||
cleaned = rawHeader.slice(
|
||||
0,
|
||||
rawHeader.length - CONTRIBUTION_SUFFIX.length,
|
||||
);
|
||||
suffix = ` (${t('contribution')})`;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
cleaned = getMetricLabel(parsed as QueryFormMetric);
|
||||
}
|
||||
} catch {
|
||||
/* not a JSON-encoded metric – keep original display name */
|
||||
}
|
||||
|
||||
const cleanHeader = `${cleaned}${suffix}`;
|
||||
|
||||
return {
|
||||
label: key,
|
||||
headerName: headerLabel,
|
||||
headerName: cleanHeader,
|
||||
render: ({ value }: { value: unknown }) => {
|
||||
if (value === true) {
|
||||
return Constants.BOOL_TRUE_DISPLAY;
|
||||
|
||||
@@ -227,4 +227,44 @@ describe('DataTablesPane', () => {
|
||||
screen.queryByLabelText('Collapse data panel'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should handle column label rendering and clean up headers properly via hook', async () => {
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A111%7D',
|
||||
{
|
||||
result: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
plain_column: 'val1',
|
||||
revenue__contribution: 'val2',
|
||||
'{"label": "Custom Metric"}': 'val3',
|
||||
'{"label": "Custom Metric"}__contribution': 'val4',
|
||||
},
|
||||
],
|
||||
colnames: [
|
||||
'plain_column',
|
||||
'revenue__contribution',
|
||||
'{"label": "Custom Metric"}',
|
||||
'{"label": "Custom Metric"}__contribution',
|
||||
],
|
||||
coltypes: [1, 1, 1, 1],
|
||||
rowcount: 1,
|
||||
sql_rowcount: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const props = createDataTablesPaneProps(111);
|
||||
render(<DataTablesPane {...props} />, { useRedux: true });
|
||||
userEvent.click(screen.getByText('Results'));
|
||||
|
||||
expect(await screen.findByText('plain_column')).toBeVisible();
|
||||
expect(screen.getByText('revenue (contribution)')).toBeVisible();
|
||||
expect(screen.getByText('Custom Metric')).toBeVisible();
|
||||
expect(screen.getByText('Custom Metric (contribution)')).toBeVisible();
|
||||
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ test('should render', async () => {
|
||||
value={{ metric_name: 'test', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>,
|
||||
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
|
||||
{ useDndKit: true, useRedux: true, initialState: { explore: {} } },
|
||||
);
|
||||
|
||||
expect(
|
||||
@@ -34,17 +34,3 @@ test('should render', async () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should have attribute draggable:true', async () => {
|
||||
render(
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'test', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>,
|
||||
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('DatasourcePanelDragOption'),
|
||||
).toHaveAttribute('draggable', 'true');
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { RefObject, useMemo } from 'react';
|
||||
import { useDrag } from 'react-dnd';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Metric } from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
@@ -32,8 +32,8 @@ import { ExplorePageState } from 'src/explore/types';
|
||||
|
||||
import { DatasourcePanelDndItem } from '../types';
|
||||
|
||||
const DatasourceItemContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
|
||||
${({ theme, isDragging }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -46,6 +46,8 @@ const DatasourceItemContainer = styled.div`
|
||||
color: ${theme.colorText};
|
||||
background-color: ${theme.colorBgLayout};
|
||||
border-radius: 4px;
|
||||
cursor: ${isDragging ? 'grabbing' : 'grab'};
|
||||
opacity: ${isDragging ? 0.5 : 1};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.colorPrimaryBgHover};
|
||||
@@ -98,15 +100,26 @@ export default function DatasourcePanelDragOption(
|
||||
return true;
|
||||
}, [type, value, compatibleMetrics, compatibleDimensions]);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
item: {
|
||||
value: props.value,
|
||||
type: props.type,
|
||||
// Create a unique ID for this draggable item
|
||||
const draggableId = useMemo(() => {
|
||||
if (type === DndItemType.Column) {
|
||||
const col = value as ColumnMeta;
|
||||
return `datasource-${type}-${col.column_name || col.verbose_name}`;
|
||||
}
|
||||
const metric = value as MetricOption;
|
||||
return `datasource-${type}-${metric.metric_name || metric.label}`;
|
||||
}, [type, value]);
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: draggableId,
|
||||
data: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
canDrag: isCompatible,
|
||||
collect: monitor => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
// @dnd-kit equivalent of react-dnd's `canDrag: isCompatible`. Disabling
|
||||
// the draggable suppresses pointer activation entirely so incompatible
|
||||
// items can't be picked up at all (matched in the visual style below).
|
||||
disabled: !isCompatible,
|
||||
});
|
||||
|
||||
const optionProps = {
|
||||
@@ -118,10 +131,13 @@ export default function DatasourcePanelDragOption(
|
||||
return (
|
||||
<DatasourceItemContainer
|
||||
data-test="DatasourcePanelDragOption"
|
||||
ref={drag}
|
||||
ref={setNodeRef}
|
||||
isDragging={isDragging}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
opacity: isCompatible ? 1 : 0.35,
|
||||
cursor: isCompatible ? 'grab' : 'not-allowed',
|
||||
opacity: isCompatible ? undefined : 0.35,
|
||||
cursor: isCompatible ? undefined : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{type === DndItemType.Column ? (
|
||||
|
||||
@@ -17,13 +17,37 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useContext } from 'react';
|
||||
import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
|
||||
import type { DragStartEvent } from '@dnd-kit/core';
|
||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||
|
||||
import ExploreContainer, { DraggingContext, DropzoneContext } from '.';
|
||||
import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
|
||||
import DatasourcePanelDragOption from '../DatasourcePanel/DatasourcePanelDragOption';
|
||||
import { DndItemType } from '../DndItemType';
|
||||
|
||||
// @dnd-kit's PointerSensor only reacts to real pointer events, which jsdom
|
||||
// cannot dispatch. To exercise the drag-start gating we capture the
|
||||
// `onDragStart` handler the provider registers on DndContext and invoke it
|
||||
// directly with a synthetic event.
|
||||
let capturedOnDragStart: ((event: DragStartEvent) => void) | undefined;
|
||||
|
||||
jest.mock('@dnd-kit/core', () => {
|
||||
const actual = jest.requireActual('@dnd-kit/core');
|
||||
return {
|
||||
...actual,
|
||||
DndContext: ({
|
||||
children,
|
||||
onDragStart,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onDragStart?: (event: DragStartEvent) => void;
|
||||
}) => {
|
||||
capturedOnDragStart = onDragStart;
|
||||
return children;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
capturedOnDragStart = undefined;
|
||||
});
|
||||
|
||||
const MockChildren = () => {
|
||||
const dragging = useContext(DraggingContext);
|
||||
@@ -57,57 +81,62 @@ test('should render children', () => {
|
||||
<ExploreContainer>
|
||||
<MockChildren />
|
||||
</ExploreContainer>,
|
||||
{ useRedux: true, useDnd: true },
|
||||
{ useRedux: true },
|
||||
);
|
||||
expect(getByTestId('mock-children')).toBeInTheDocument();
|
||||
expect(getByText('not dragging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should only propagate dragging state when dragging the panel option', () => {
|
||||
const defaultProps = {
|
||||
label: <span>Test label</span>,
|
||||
tooltipTitle: 'This is a tooltip title',
|
||||
onRemove: jest.fn(),
|
||||
onMoveLabel: jest.fn(),
|
||||
onDropLabel: jest.fn(),
|
||||
type: 'test',
|
||||
index: 0,
|
||||
};
|
||||
test('should initially have dragging set to false', () => {
|
||||
const { container, getByText } = render(
|
||||
<ExploreContainer>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'panel option', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<OptionControlLabel
|
||||
{...defaultProps}
|
||||
index={1}
|
||||
label={<span>Metric item</span>}
|
||||
/>
|
||||
<OptionWrapper
|
||||
{...defaultProps}
|
||||
index={2}
|
||||
label="Column item"
|
||||
clickClose={() => {}}
|
||||
onShiftOptions={() => {}}
|
||||
/>
|
||||
<MockChildren />
|
||||
</ExploreContainer>,
|
||||
{
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
},
|
||||
{ useRedux: true },
|
||||
);
|
||||
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
|
||||
fireEvent.dragStart(getByText('panel option'));
|
||||
expect(getByText('not dragging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('propagates dragging state when dragging a panel option', () => {
|
||||
const { container } = render(
|
||||
<ExploreContainer>
|
||||
<MockChildren />
|
||||
</ExploreContainer>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
|
||||
|
||||
// Dragging a DatasourcePanel option (no `dragIndex`) sets the dragging state.
|
||||
act(() => {
|
||||
capturedOnDragStart?.({
|
||||
active: { id: 'panel', data: { current: { type: 'metric' } } },
|
||||
} as unknown as DragStartEvent);
|
||||
});
|
||||
expect(container.getElementsByClassName('dragging')).toHaveLength(1);
|
||||
fireEvent.dragEnd(getByText('panel option'));
|
||||
fireEvent.dragStart(getByText('Metric item'));
|
||||
});
|
||||
|
||||
test('does not propagate dragging state for an in-list reorder', () => {
|
||||
const { container } = render(
|
||||
<ExploreContainer>
|
||||
<MockChildren />
|
||||
</ExploreContainer>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
|
||||
fireEvent.dragEnd(getByText('Metric item'));
|
||||
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
|
||||
// don't show dragging state for the sorting item
|
||||
fireEvent.dragStart(getByText('Column item'));
|
||||
|
||||
// An in-list sortable reorder carries a `dragIndex` and must NOT set the
|
||||
// dragging state (it would otherwise surface drop targets during a reorder).
|
||||
act(() => {
|
||||
capturedOnDragStart?.({
|
||||
active: {
|
||||
id: 'sortable',
|
||||
data: { current: { type: 'metric', dragIndex: 0 } },
|
||||
},
|
||||
} as unknown as DragStartEvent);
|
||||
});
|
||||
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -116,10 +145,7 @@ test('should manage the dropValidators', () => {
|
||||
<ExploreContainer>
|
||||
<MockChildren2 />
|
||||
</ExploreContainer>,
|
||||
{
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
},
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(queryByText('test_item_1')).not.toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 {
|
||||
ActiveDragData,
|
||||
DroppableData,
|
||||
resolveDragEnd,
|
||||
} from './ExploreDndContext';
|
||||
|
||||
const COLUMN = 'column';
|
||||
const METRIC = 'metric';
|
||||
|
||||
const active = (data: ActiveDragData, id = 'drag-source') => ({
|
||||
id,
|
||||
data: { current: data },
|
||||
});
|
||||
|
||||
const over = (
|
||||
data: Partial<ActiveDragData> & DroppableData,
|
||||
id = 'dropzone-target',
|
||||
) => ({
|
||||
id,
|
||||
data: { current: data },
|
||||
});
|
||||
|
||||
test('reorder fires the active item onShiftOptions callback', () => {
|
||||
const onShiftOptions = jest.fn();
|
||||
resolveDragEnd(
|
||||
active({ type: COLUMN, dragIndex: 0, onShiftOptions }, 'sortable-column-0'),
|
||||
over({ type: COLUMN, dragIndex: 2 }, 'sortable-column-2'),
|
||||
);
|
||||
expect(onShiftOptions).toHaveBeenCalledWith(0, 2);
|
||||
});
|
||||
|
||||
test('reorder falls back to onMoveLabel when onShiftOptions is absent', () => {
|
||||
const onMoveLabel = jest.fn();
|
||||
const onDropLabel = jest.fn();
|
||||
resolveDragEnd(
|
||||
active(
|
||||
{ type: METRIC, dragIndex: 1, onMoveLabel, onDropLabel },
|
||||
'sortable-metric-1',
|
||||
),
|
||||
over({ type: METRIC, dragIndex: 0 }, 'sortable-metric-0'),
|
||||
);
|
||||
expect(onMoveLabel).toHaveBeenCalledWith(1, 0);
|
||||
expect(onDropLabel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reorder does not fire across mismatched types', () => {
|
||||
const onShiftOptions = jest.fn();
|
||||
resolveDragEnd(
|
||||
active({ type: COLUMN, dragIndex: 0, onShiftOptions }, 'sortable-column-0'),
|
||||
over({ type: METRIC, dragIndex: 1 }, 'sortable-metric-1'),
|
||||
);
|
||||
expect(onShiftOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('external drop fires onDrop and onDropValue when accepted', () => {
|
||||
const onDrop = jest.fn();
|
||||
const onDropValue = jest.fn();
|
||||
const value = { column_name: 'a' };
|
||||
resolveDragEnd(
|
||||
active({ type: COLUMN, value }),
|
||||
over({ accept: [COLUMN], canDrop: () => true, onDrop, onDropValue }),
|
||||
);
|
||||
expect(onDrop).toHaveBeenCalledWith({ type: COLUMN, value });
|
||||
expect(onDropValue).toHaveBeenCalledWith(value);
|
||||
});
|
||||
|
||||
test('external drop is blocked when the type is not accepted', () => {
|
||||
const onDrop = jest.fn();
|
||||
resolveDragEnd(
|
||||
active({ type: METRIC, value: { metric_name: 'm' } }),
|
||||
over({ accept: [COLUMN], canDrop: () => true, onDrop }),
|
||||
);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('external drop is blocked when canDrop rejects the item', () => {
|
||||
const onDrop = jest.fn();
|
||||
resolveDragEnd(
|
||||
active({ type: COLUMN, value: { column_name: 'dupe' } }),
|
||||
over({ accept: [COLUMN], canDrop: () => false, onDrop }),
|
||||
);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('drop with no canDrop validator defaults to accepting the item', () => {
|
||||
const onDrop = jest.fn();
|
||||
resolveDragEnd(
|
||||
active({ type: COLUMN, value: { column_name: 'a' } }),
|
||||
over({ accept: [COLUMN], onDrop }),
|
||||
);
|
||||
expect(onDrop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('no-op when there is no droppable target', () => {
|
||||
expect(() =>
|
||||
resolveDragEnd(active({ type: COLUMN, value: {} }), null),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('no-op when dropping onto itself', () => {
|
||||
const onDrop = jest.fn();
|
||||
resolveDragEnd(
|
||||
active({ type: COLUMN, value: {} }, 'same'),
|
||||
over({ accept: [COLUMN], onDrop }, 'same'),
|
||||
);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* 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 {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
FC,
|
||||
Dispatch,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
UniqueIdentifier,
|
||||
} from '@dnd-kit/core';
|
||||
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
|
||||
|
||||
/**
|
||||
* Type for the active drag item data
|
||||
*/
|
||||
export interface ActiveDragData {
|
||||
type: string;
|
||||
value?: unknown;
|
||||
dragIndex?: number;
|
||||
// For sortable items - callback to handle reorder
|
||||
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
|
||||
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
|
||||
onDropLabel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context to track if something is being dragged (for visual feedback)
|
||||
*/
|
||||
export const DraggingContext = createContext(false);
|
||||
|
||||
/**
|
||||
* Context to access the currently active drag item
|
||||
*/
|
||||
export const ActiveDragContext = createContext<ActiveDragData | null>(null);
|
||||
|
||||
/**
|
||||
* Dropzone validation - used by controls to register what they can accept
|
||||
*/
|
||||
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
|
||||
type DropzoneSet = Record<string, CanDropValidator>;
|
||||
type Action = { key: string; canDrop?: CanDropValidator };
|
||||
|
||||
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
|
||||
{},
|
||||
() => {},
|
||||
]);
|
||||
|
||||
const dropzoneReducer = (state: DropzoneSet = {}, action: Action) => {
|
||||
if (action.canDrop) {
|
||||
return {
|
||||
...state,
|
||||
[action.key]: action.canDrop,
|
||||
};
|
||||
}
|
||||
if (action.key) {
|
||||
const newState = { ...state };
|
||||
delete newState[action.key];
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shape of the data a droppable (e.g. DndSelectLabel) exposes via its
|
||||
* `useDroppable` data object so that drops can be dispatched on drag end.
|
||||
*/
|
||||
export interface DroppableData {
|
||||
accept?: string[];
|
||||
canDrop?: (item: DatasourcePanelDndItem) => boolean;
|
||||
onDrop?: (item: DatasourcePanelDndItem) => void;
|
||||
onDropValue?: (value: DatasourcePanelDndItem['value']) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure dispatch logic for a @dnd-kit drag-end event, extracted so it can be
|
||||
* unit-tested without simulating pointer events (which jsdom cannot drive).
|
||||
*
|
||||
* Mirrors the original react-dnd behavior:
|
||||
* - Same-list sortable reorder fires the active item's reorder callback.
|
||||
* - External drops (DatasourcePanel -> control) only fire `onDrop` when the
|
||||
* dragged item's type is accepted AND the droppable's `canDrop` validator
|
||||
* passes (react-dnd never fired `drop` when `canDrop` was false).
|
||||
*/
|
||||
export function resolveDragEnd(
|
||||
active: { id: UniqueIdentifier; data: { current?: ActiveDragData } },
|
||||
over: {
|
||||
id: UniqueIdentifier;
|
||||
data: { current?: Partial<ActiveDragData> & DroppableData };
|
||||
} | null,
|
||||
): void {
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
|
||||
// Same-list sortable reorder: both endpoints carry a dragIndex and type.
|
||||
if (
|
||||
activeData &&
|
||||
overData &&
|
||||
typeof activeData.dragIndex === 'number' &&
|
||||
typeof overData.dragIndex === 'number' &&
|
||||
activeData.type === overData.type
|
||||
) {
|
||||
const reorderCallback = activeData.onShiftOptions || activeData.onMoveLabel;
|
||||
reorderCallback?.(activeData.dragIndex, overData.dragIndex);
|
||||
activeData.onDropLabel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// External drop onto a droppable that exposes an onDrop handler.
|
||||
if (activeData && overData?.onDrop) {
|
||||
const { accept, canDrop, onDrop, onDropValue } = overData;
|
||||
const item: DatasourcePanelDndItem = {
|
||||
type: activeData.type as DatasourcePanelDndItem['type'],
|
||||
value: activeData.value as DatasourcePanelDndItem['value'],
|
||||
};
|
||||
const typeAccepted = !accept || accept.includes(item.type);
|
||||
if (typeAccepted && (canDrop?.(item) ?? true)) {
|
||||
onDrop(item);
|
||||
onDropValue?.(item.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ExploreDndContextProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DnD context provider for the Explore view.
|
||||
* Wraps @dnd-kit/core's DndContext and provides:
|
||||
* - Dragging state tracking (for visual feedback)
|
||||
* - Dropzone registration (for validation)
|
||||
* - Drop dispatch via each droppable's `useDroppable` data object
|
||||
*/
|
||||
export const ExploreDndContextProvider: FC<ExploreDndContextProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [activeData, setActiveData] = useState<ActiveDragData | null>(null);
|
||||
|
||||
const dropzoneValue = useReducer(dropzoneReducer, {});
|
||||
|
||||
// Configure sensors for drag detection. PointerSensor drives mouse/touch
|
||||
// drags; KeyboardSensor adds keyboard-accessible reordering (an a11y win
|
||||
// over the previous react-dnd HTML5 backend, which had none).
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5, // 5px movement required before drag starts
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const data = active.data.current as ActiveDragData | undefined;
|
||||
|
||||
// Don't set dragging state for reordering within a list
|
||||
if (data && 'dragIndex' in data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
setActiveData(data || null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
setIsDragging(false);
|
||||
setActiveData(null);
|
||||
|
||||
resolveDragEnd(
|
||||
active as Parameters<typeof resolveDragEnd>[0],
|
||||
over as Parameters<typeof resolveDragEnd>[1],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setActiveData(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<DropzoneContext.Provider value={dropzoneValue}>
|
||||
<DraggingContext.Provider value={isDragging}>
|
||||
<ActiveDragContext.Provider value={activeData}>
|
||||
{children}
|
||||
</ActiveDragContext.Provider>
|
||||
</DraggingContext.Provider>
|
||||
</DropzoneContext.Provider>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to check if something is currently being dragged
|
||||
*/
|
||||
export const useIsDragging = () => useContext(DraggingContext);
|
||||
|
||||
/**
|
||||
* Hook to get the active drag data
|
||||
*/
|
||||
export const useActiveDrag = () => useContext(ActiveDragContext);
|
||||
@@ -16,29 +16,17 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useState,
|
||||
Dispatch,
|
||||
FC,
|
||||
ReactNode,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { useDragDropManager } from 'react-dnd';
|
||||
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
|
||||
import {
|
||||
ExploreDndContextProvider,
|
||||
DraggingContext,
|
||||
DropzoneContext,
|
||||
} from './ExploreDndContext';
|
||||
|
||||
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
|
||||
type DropzoneSet = Record<string, CanDropValidator>;
|
||||
type Action = { key: string; canDrop?: CanDropValidator };
|
||||
// Re-export contexts for backward compatibility
|
||||
export { DraggingContext, DropzoneContext };
|
||||
|
||||
export const DraggingContext = createContext(false);
|
||||
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
|
||||
{},
|
||||
() => {},
|
||||
]);
|
||||
const StyledDiv = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -46,53 +34,10 @@ const StyledDiv = styled.div`
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const reducer = (state: DropzoneSet = {}, action: Action) => {
|
||||
if (action.canDrop) {
|
||||
return {
|
||||
...state,
|
||||
[action.key]: action.canDrop,
|
||||
};
|
||||
}
|
||||
if (action.key) {
|
||||
const newState = { ...state };
|
||||
delete newState[action.key];
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const ExploreContainer: FC<{ children?: ReactNode }> = ({ children }) => {
|
||||
const dragDropManager = useDragDropManager();
|
||||
const [dragging, setDragging] = useState(
|
||||
dragDropManager.getMonitor().isDragging(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const monitor = dragDropManager.getMonitor();
|
||||
const unsub = monitor.subscribeToStateChange(() => {
|
||||
const item = monitor.getItem() || {};
|
||||
// don't show dragging state for the sorting item
|
||||
if ('dragIndex' in item) {
|
||||
return;
|
||||
}
|
||||
const isDragging = monitor.isDragging();
|
||||
setDragging(isDragging);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [dragDropManager]);
|
||||
|
||||
const dropzoneValue = useReducer(reducer, {});
|
||||
|
||||
return (
|
||||
<DropzoneContext.Provider value={dropzoneValue}>
|
||||
<DraggingContext.Provider value={dragging}>
|
||||
<StyledDiv>{children}</StyledDiv>
|
||||
</DraggingContext.Provider>
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
};
|
||||
const ExploreContainer: FC<{ children?: ReactNode }> = ({ children }) => (
|
||||
<ExploreDndContextProvider>
|
||||
<StyledDiv>{children}</StyledDiv>
|
||||
</ExploreDndContextProvider>
|
||||
);
|
||||
|
||||
export default ExploreContainer;
|
||||
|
||||
@@ -127,6 +127,8 @@ const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
|
||||
accept={[]}
|
||||
ghostButtonText={ghostButtonText}
|
||||
onClickGhostButton={handleClickGhostButton}
|
||||
sortableType="ContourOption"
|
||||
itemCount={contours.length}
|
||||
{...props}
|
||||
/>
|
||||
<ContourPopoverTrigger
|
||||
|
||||
@@ -16,15 +16,41 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import { DndColumnMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect';
|
||||
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
import { DndItemType } from 'src/explore/components/DndItemType';
|
||||
import {
|
||||
CapturedDroppable,
|
||||
CapturedSortables,
|
||||
captureDroppableData,
|
||||
captureSortableData,
|
||||
simulateDrop,
|
||||
simulateReorder,
|
||||
} from './dndTestUtils';
|
||||
|
||||
const captured: CapturedDroppable = { current: undefined };
|
||||
const sortables: CapturedSortables = { items: [] };
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
useDroppable: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@dnd-kit/sortable', () => ({
|
||||
...jest.requireActual('@dnd-kit/sortable'),
|
||||
useSortable: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
captured.current = undefined;
|
||||
sortables.items = [];
|
||||
(useDroppable as jest.Mock).mockImplementation(
|
||||
captureDroppableData(captured),
|
||||
);
|
||||
(useSortable as jest.Mock).mockImplementation(captureSortableData(sortables));
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
name: 'test-control',
|
||||
@@ -67,7 +93,7 @@ const defaultProps = {
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<DndColumnMetricSelect {...defaultProps} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(
|
||||
@@ -77,7 +103,7 @@ test('renders with default props', () => {
|
||||
|
||||
test('renders with default props and multi = true', () => {
|
||||
render(<DndColumnMetricSelect {...defaultProps} multi />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(
|
||||
@@ -88,148 +114,122 @@ test('renders with default props and multi = true', () => {
|
||||
test('render selected columns and metrics correctly', () => {
|
||||
const values = ['column_a', 'metric_a'];
|
||||
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(screen.getByText('column_a')).toBeVisible();
|
||||
expect(screen.getByText('metric_a')).toBeVisible();
|
||||
});
|
||||
|
||||
// Drop behavior is exercised through `resolveDragEnd` (the production drag-end
|
||||
// dispatcher) because @dnd-kit's PointerSensor needs real layout that jsdom
|
||||
// cannot provide. See ./dndTestUtils and ExploreDndContext.test.tsx.
|
||||
|
||||
test('can drop columns and metrics', () => {
|
||||
const values = ['column_a', 'metric_a'];
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'column_b', uuid: '1' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_b', uuid: '2' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DndColumnMetricSelect {...defaultProps} value={values} multi />
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
},
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndColumnMetricSelect
|
||||
{...defaultProps}
|
||||
value={['column_a', 'metric_a']}
|
||||
onChange={onChange}
|
||||
multi
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
|
||||
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
|
||||
const currentSelection = getByTestId('dnd-labels-container');
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Column,
|
||||
value: { column_name: 'column_b' } as any,
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
'column_a',
|
||||
'metric_a',
|
||||
'column_b',
|
||||
]);
|
||||
|
||||
fireEvent.dragStart(columnOption);
|
||||
fireEvent.dragOver(currentSelection);
|
||||
fireEvent.drop(currentSelection);
|
||||
|
||||
fireEvent.dragStart(metricOption);
|
||||
fireEvent.dragOver(currentSelection);
|
||||
fireEvent.drop(currentSelection);
|
||||
|
||||
expect(currentSelection).toBeInTheDocument();
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_b' } as any,
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
'column_a',
|
||||
'metric_a',
|
||||
'metric_b',
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot drop duplicate items', () => {
|
||||
const values = ['column_a', 'metric_a'];
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'column_a', uuid: '1' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '2' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DndColumnMetricSelect {...defaultProps} value={values} multi />
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
},
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndColumnMetricSelect
|
||||
{...defaultProps}
|
||||
value={['column_a', 'metric_a']}
|
||||
onChange={onChange}
|
||||
multi
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
|
||||
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
|
||||
const currentSelection = getByTestId('dnd-labels-container');
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Column,
|
||||
value: { column_name: 'column_a' } as any,
|
||||
});
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_a' } as any,
|
||||
});
|
||||
|
||||
const initialCount = currentSelection.children.length;
|
||||
|
||||
fireEvent.dragStart(columnOption);
|
||||
fireEvent.dragOver(currentSelection);
|
||||
fireEvent.drop(currentSelection);
|
||||
|
||||
fireEvent.dragStart(metricOption);
|
||||
fireEvent.dragOver(currentSelection);
|
||||
fireEvent.drop(currentSelection);
|
||||
|
||||
expect(currentSelection.children).toHaveLength(initialCount);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('can drop only selected metrics', () => {
|
||||
const values = ['column_a'];
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_c', uuid: '2' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DndColumnMetricSelect {...defaultProps} value={values} multi />
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
},
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndColumnMetricSelect
|
||||
{...defaultProps}
|
||||
value={['column_a']}
|
||||
onChange={onChange}
|
||||
multi
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const selectedMetric = screen.getAllByTestId('DatasourcePanelDragOption')[0];
|
||||
const unselectedMetric = screen.getAllByTestId(
|
||||
'DatasourcePanelDragOption',
|
||||
)[1];
|
||||
const currentSelection = getByTestId('dnd-labels-container');
|
||||
// metric_c is not in selectedMetrics -> rejected
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_c' } as any,
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
const initialCount = currentSelection.children.length;
|
||||
|
||||
fireEvent.dragStart(unselectedMetric);
|
||||
fireEvent.dragOver(currentSelection);
|
||||
fireEvent.drop(currentSelection);
|
||||
|
||||
expect(currentSelection.children).toHaveLength(initialCount);
|
||||
|
||||
fireEvent.dragStart(selectedMetric);
|
||||
fireEvent.dragOver(currentSelection);
|
||||
fireEvent.drop(currentSelection);
|
||||
|
||||
expect(currentSelection).toBeInTheDocument();
|
||||
// metric_a is in selectedMetrics -> accepted
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_a' } as any,
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(['column_a', 'metric_a']);
|
||||
});
|
||||
|
||||
test('can drag and reorder items', async () => {
|
||||
const values = ['column_a', 'metric_a', 'column_b'];
|
||||
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
});
|
||||
test('can drag and reorder items', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndColumnMetricSelect
|
||||
{...defaultProps}
|
||||
value={['column_a', 'metric_a', 'column_b']}
|
||||
onChange={onChange}
|
||||
multi
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('dnd-labels-container');
|
||||
expect(container.childElementCount).toBe(4);
|
||||
|
||||
const firstItem = container.children[0] as HTMLElement;
|
||||
const lastItem = container.children[2] as HTMLElement;
|
||||
|
||||
expect(within(firstItem).getByText('column_a')).toBeVisible();
|
||||
expect(within(lastItem).getByText('Column B')).toBeVisible();
|
||||
|
||||
fireEvent.dragStart(firstItem);
|
||||
fireEvent.dragEnter(lastItem);
|
||||
fireEvent.dragOver(lastItem);
|
||||
fireEvent.drop(lastItem);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
// Reorder is dispatched via the active sortable item's onShiftOptions,
|
||||
// which the control registers on each OptionWrapper. Drag index 0
|
||||
// (column_a) onto index 2 (column_b) and verify the swap.
|
||||
simulateReorder(sortables, 0, 2);
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
'column_b',
|
||||
'metric_a',
|
||||
'column_a',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shows warning for aggregated DeckGL charts', () => {
|
||||
@@ -243,7 +243,7 @@ test('shows warning for aggregated DeckGL charts', () => {
|
||||
multi
|
||||
formData={formData}
|
||||
/>,
|
||||
{ useDnd: true, useRedux: true },
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const columnItem = screen.getByText('column_a');
|
||||
@@ -261,7 +261,7 @@ test('handles single selection mode', () => {
|
||||
multi={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ useDnd: true, useRedux: true },
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
expect(screen.getByText('column_a')).toBeVisible();
|
||||
@@ -275,7 +275,7 @@ test('handles custom ghost button text', () => {
|
||||
|
||||
render(
|
||||
<DndColumnMetricSelect {...defaultProps} ghostButtonText={customText} />,
|
||||
{ useDnd: true, useRedux: true },
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
expect(screen.getByText(customText)).toBeInTheDocument();
|
||||
@@ -292,10 +292,11 @@ test('can remove items by clicking close button', () => {
|
||||
multi
|
||||
onChange={onChange}
|
||||
/>,
|
||||
{ useDnd: true, useRedux: true },
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: /close/i });
|
||||
// Use testId instead of role selector - @dnd-kit sortable wrapper adds extra button elements
|
||||
const closeButtons = screen.getAllByTestId('remove-control-button');
|
||||
expect(closeButtons).toHaveLength(2);
|
||||
|
||||
fireEvent.click(closeButtons[0]);
|
||||
@@ -312,7 +313,7 @@ test('handles adhoc metric with error', () => {
|
||||
const values = [errorMetric];
|
||||
|
||||
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
@@ -324,7 +325,7 @@ test('handles adhoc column values', () => {
|
||||
const values = ['column_a'];
|
||||
|
||||
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
@@ -336,7 +337,7 @@ test('handles mixed value types correctly', () => {
|
||||
|
||||
render(
|
||||
<DndColumnMetricSelect {...defaultProps} value={mixedValues} multi />,
|
||||
{ useDnd: true, useRedux: true },
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
expect(screen.getByText('column_a')).toBeVisible();
|
||||
|
||||
@@ -61,7 +61,7 @@ const defaultProps: DndColumnSelectProps = {
|
||||
|
||||
test('renders with default props', async () => {
|
||||
render(<DndColumnSelect {...defaultProps} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(
|
||||
@@ -71,7 +71,7 @@ test('renders with default props', async () => {
|
||||
|
||||
test('renders with value', async () => {
|
||||
render(<DndColumnSelect {...defaultProps} value="Column A" />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(await screen.findByText('Column A')).toBeInTheDocument();
|
||||
@@ -87,7 +87,7 @@ test('renders adhoc column', async () => {
|
||||
expressionType: 'SQL',
|
||||
}}
|
||||
/>,
|
||||
{ useDnd: true, useRedux: true },
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
expect(await screen.findByText('adhoc column')).toBeVisible();
|
||||
expect(screen.getByLabelText('calculator')).toBeVisible();
|
||||
@@ -110,7 +110,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
|
||||
value={columnValues}
|
||||
/>,
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
@@ -167,7 +167,7 @@ test('should allow selecting columns via click interface', async () => {
|
||||
});
|
||||
|
||||
render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ test('should display selected column values correctly', async () => {
|
||||
});
|
||||
|
||||
render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ test('should handle multiple column selections for groupby', async () => {
|
||||
});
|
||||
|
||||
render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
|
||||
@@ -269,7 +269,7 @@ test('should support adhoc column creation workflow', async () => {
|
||||
});
|
||||
|
||||
render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
|
||||
@@ -299,7 +299,7 @@ test('should verify onChange callback integration (core regression protection)',
|
||||
};
|
||||
|
||||
const { rerender } = render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
@@ -334,7 +334,7 @@ test('should render column selection interface elements', async () => {
|
||||
};
|
||||
|
||||
render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
@@ -374,7 +374,7 @@ test('should complete full column selection workflow like original Cypress test'
|
||||
});
|
||||
|
||||
const { rerender } = render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
|
||||
@@ -450,7 +450,7 @@ test('should create adhoc column via Custom SQL tab workflow', async () => {
|
||||
});
|
||||
|
||||
render(<DndColumnSelect {...props} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
|
||||
|
||||
@@ -204,6 +204,9 @@ function DndColumnSelect(props: DndColumnSelectProps) {
|
||||
[ghostButtonText, multi],
|
||||
);
|
||||
|
||||
// Generate sortable type that matches OptionWrapper's type
|
||||
const sortableType = `${DndItemType.ColumnOption}_${name}_${label}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DndSelectLabel
|
||||
@@ -214,6 +217,8 @@ function DndColumnSelect(props: DndColumnSelectProps) {
|
||||
displayGhostButton={multi || optionSelector.values.length === 0}
|
||||
ghostButtonText={labelGhostButtonText}
|
||||
onClickGhostButton={openPopover}
|
||||
sortableType={sortableType}
|
||||
itemCount={optionSelector.values.length}
|
||||
{...props}
|
||||
/>
|
||||
<ColumnSelectPopoverTrigger
|
||||
|
||||
@@ -27,11 +27,14 @@ import {
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
|
||||
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
|
||||
import { Operators } from 'src/explore/constants';
|
||||
@@ -41,9 +44,13 @@ import {
|
||||
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
|
||||
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
|
||||
import { ExpressionTypes } from '../FilterControl/types';
|
||||
import { Datasource } from '../../../types';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
|
||||
import { Datasource } from '../../../types';
|
||||
import {
|
||||
CapturedDroppable,
|
||||
captureDroppableData,
|
||||
simulateDrop,
|
||||
} from './dndTestUtils';
|
||||
|
||||
jest.mock('src/core/editors', () => ({
|
||||
EditorHost: ({ value }: { value: string }) => (
|
||||
@@ -51,6 +58,21 @@ jest.mock('src/core/editors', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
useDroppable: jest.fn(),
|
||||
}));
|
||||
|
||||
const captured: CapturedDroppable = { current: undefined };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
captured.current = undefined;
|
||||
(useDroppable as jest.Mock).mockImplementation(
|
||||
captureDroppableData(captured),
|
||||
);
|
||||
});
|
||||
|
||||
const defaultProps: Omit<DndFilterSelectProps, 'datasource'> = {
|
||||
type: 'DndFilterSelect',
|
||||
name: 'Filter',
|
||||
@@ -96,12 +118,8 @@ function setup({
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders with default props', async () => {
|
||||
render(setup(), { useDnd: true, store });
|
||||
render(setup(), { useDndKit: true, store });
|
||||
expect(
|
||||
await screen.findByText('Drop columns/metrics here or click'),
|
||||
).toBeInTheDocument();
|
||||
@@ -113,7 +131,7 @@ test('renders with value', async () => {
|
||||
expressionType: ExpressionTypes.Sql,
|
||||
});
|
||||
render(setup({ value }), {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
|
||||
@@ -128,7 +146,7 @@ test('renders options with saved metric', async () => {
|
||||
},
|
||||
}),
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
@@ -150,7 +168,7 @@ test('renders options with column', async () => {
|
||||
],
|
||||
}),
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
@@ -172,7 +190,7 @@ test('renders options with adhoc metric', async () => {
|
||||
},
|
||||
}),
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
@@ -181,60 +199,43 @@ test('renders options with adhoc metric', async () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('cannot drop a column that is not part of the simple column selection', () => {
|
||||
test('cannot drop a column that is not part of the simple column selection', async () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
expression: 'AVG(birth_names.num)',
|
||||
metric_name: 'avg__num',
|
||||
});
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'order_date' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'address_line1' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{
|
||||
metric_name: 'metric_a',
|
||||
expression: 'AGG(metric_a)',
|
||||
uuid: '1',
|
||||
}}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
{setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
metrics: [adhocMetric as unknown as QueryFormMetric],
|
||||
},
|
||||
columns: [{ column_name: 'order_date' }],
|
||||
})}
|
||||
</>,
|
||||
render(
|
||||
setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
metrics: [adhocMetric as unknown as QueryFormMetric],
|
||||
},
|
||||
columns: [{ column_name: 'order_date' }],
|
||||
}),
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
const selections = getAllByTestId('DatasourcePanelDragOption');
|
||||
const acceptableColumn = selections[0];
|
||||
const unacceptableColumn = selections[1];
|
||||
const metricType = selections[2];
|
||||
const currentMetric = getByTestId('dnd-labels-container');
|
||||
|
||||
fireEvent.dragStart(unacceptableColumn);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
// A column missing from the simple column selection is rejected by canDrop,
|
||||
// so no filter popover opens.
|
||||
act(() => {
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Column,
|
||||
value: { column_name: 'address_line1' } as any,
|
||||
});
|
||||
});
|
||||
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.dragStart(acceptableColumn);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
|
||||
// An acceptable column opens the popover prefilled with that column.
|
||||
act(() => {
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Column,
|
||||
value: { column_name: 'order_date' } as any,
|
||||
});
|
||||
});
|
||||
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
|
||||
expect(within(filterConfigPopup).getByText('order_date')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(filterConfigPopup, {
|
||||
@@ -243,15 +244,111 @@ test('cannot drop a column that is not part of the simple column selection', ()
|
||||
keyCode: 27,
|
||||
charCode: 27,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// A metric type is accepted (adhoc metrics are allowed here).
|
||||
act(() => {
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: {
|
||||
metric_name: 'metric_a',
|
||||
expression: 'AGG(metric_a)',
|
||||
uuid: '1',
|
||||
} as any,
|
||||
});
|
||||
});
|
||||
const metricPopup = await screen.findByTestId('filter-edit-popover');
|
||||
expect(within(metricPopup).getByTestId('react-ace')).toHaveTextContent(
|
||||
'AGG(metric_a)',
|
||||
);
|
||||
});
|
||||
|
||||
test('when disallow_adhoc_metrics is set, can drop a column from the simple column selection', async () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
expression: 'AVG(birth_names.num)',
|
||||
metric_name: 'avg__num',
|
||||
});
|
||||
render(
|
||||
setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
metrics: [adhocMetric as unknown as QueryFormMetric],
|
||||
},
|
||||
datasource: {
|
||||
...PLACEHOLDER_DATASOURCE,
|
||||
extra: '{ "disallow_adhoc_metrics": true }',
|
||||
},
|
||||
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
|
||||
}),
|
||||
{
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Column,
|
||||
value: { column_name: 'column_b' } as any,
|
||||
});
|
||||
});
|
||||
|
||||
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
|
||||
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when disallow_adhoc_metrics is set, cannot drop anything but a simple column selection', async () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
expression: 'AVG(birth_names.num)',
|
||||
metric_name: 'avg__num',
|
||||
});
|
||||
render(
|
||||
setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
metrics: [adhocMetric as unknown as QueryFormMetric],
|
||||
},
|
||||
datasource: {
|
||||
...PLACEHOLDER_DATASOURCE,
|
||||
extra: '{ "disallow_adhoc_metrics": true }',
|
||||
},
|
||||
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
|
||||
}),
|
||||
{
|
||||
useDndKit: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
// A metric is rejected when adhoc metrics are disallowed.
|
||||
act(() => {
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_a', uuid: '1' } as any,
|
||||
});
|
||||
});
|
||||
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.dragStart(metricType);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
// An adhoc metric option is likewise rejected.
|
||||
act(() => {
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.AdhocMetricOption,
|
||||
value: { metric_name: 'avg__num', uuid: '2' } as any,
|
||||
});
|
||||
});
|
||||
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(screen.getByTestId('filter-edit-popover')).getByTestId('react-ace'),
|
||||
).toHaveTextContent('AGG(metric_a)');
|
||||
// A column from the simple selection is accepted.
|
||||
act(() => {
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Column,
|
||||
value: { column_name: 'column_c' } as any,
|
||||
});
|
||||
});
|
||||
const filterConfigPopup = await screen.findByTestId('filter-edit-popover');
|
||||
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onChange when close is clicked and canDelete is true', () => {
|
||||
@@ -268,7 +365,7 @@ test('calls onChange when close is clicked and canDelete is true', () => {
|
||||
const canDelete = jest.fn();
|
||||
canDelete.mockReturnValue(true);
|
||||
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
|
||||
@@ -290,7 +387,7 @@ test('onChange is not called when close is clicked and canDelete is false', () =
|
||||
const canDelete = jest.fn();
|
||||
canDelete.mockReturnValue(false);
|
||||
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
|
||||
@@ -312,7 +409,7 @@ test('onChange is not called when close is clicked and canDelete is string, warn
|
||||
const canDelete = jest.fn();
|
||||
canDelete.mockReturnValue('Test warning');
|
||||
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
store,
|
||||
});
|
||||
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
|
||||
@@ -320,109 +417,3 @@ test('onChange is not called when close is clicked and canDelete is string, warn
|
||||
expect(defaultProps.onChange).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('Test warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('when disallow_adhoc_metrics is set', () => {
|
||||
test('can drop a column type from the simple column selection', () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
expression: 'AVG(birth_names.num)',
|
||||
metric_name: 'avg__num',
|
||||
});
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'column_b' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
{setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
metrics: [adhocMetric as unknown as QueryFormMetric],
|
||||
},
|
||||
datasource: {
|
||||
...PLACEHOLDER_DATASOURCE,
|
||||
extra: '{ "disallow_adhoc_metrics": true }',
|
||||
},
|
||||
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
|
||||
})}
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
const acceptableColumn = getByTestId('DatasourcePanelDragOption');
|
||||
const currentMetric = getByTestId('dnd-labels-container');
|
||||
|
||||
fireEvent.dragStart(acceptableColumn);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
|
||||
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('cannot drop any other types of selections apart from simple column selection', () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
expression: 'AVG(birth_names.num)',
|
||||
metric_name: 'avg__num',
|
||||
});
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'column_c' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'avg__num', uuid: '2' }}
|
||||
type={DndItemType.AdhocMetricOption}
|
||||
/>
|
||||
{setup({
|
||||
formData: {
|
||||
...baseFormData,
|
||||
metrics: [adhocMetric as unknown as QueryFormMetric],
|
||||
},
|
||||
datasource: {
|
||||
...PLACEHOLDER_DATASOURCE,
|
||||
extra: '{ "disallow_adhoc_metrics": true }',
|
||||
},
|
||||
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
|
||||
})}
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
const selections = getAllByTestId('DatasourcePanelDragOption');
|
||||
const acceptableColumn = selections[0];
|
||||
const unacceptableMetric = selections[1];
|
||||
const unacceptableType = selections[2];
|
||||
const currentMetric = getByTestId('dnd-labels-container');
|
||||
|
||||
fireEvent.dragStart(unacceptableMetric);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.dragStart(unacceptableType);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.dragStart(acceptableColumn);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
|
||||
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -454,6 +454,8 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
|
||||
accept={DND_ACCEPTED_TYPES}
|
||||
ghostButtonText={t('Drop columns/metrics here or click')}
|
||||
onClickGhostButton={handleClickGhostButton}
|
||||
sortableType={DndItemType.FilterOption}
|
||||
itemCount={values.length}
|
||||
{...props}
|
||||
/>
|
||||
<AdhocFilterPopoverTrigger
|
||||
|
||||
@@ -20,15 +20,46 @@ import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { DndMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
|
||||
import { AGGREGATES } from 'src/explore/constants';
|
||||
import { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
|
||||
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
import {
|
||||
CapturedDroppable,
|
||||
CapturedSortables,
|
||||
captureDroppableData,
|
||||
captureSortableData,
|
||||
simulateDrop,
|
||||
simulateReorder,
|
||||
} from './dndTestUtils';
|
||||
|
||||
const captured: CapturedDroppable = { current: undefined };
|
||||
const sortables: CapturedSortables = { items: [] };
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
useDroppable: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@dnd-kit/sortable', () => ({
|
||||
...jest.requireActual('@dnd-kit/sortable'),
|
||||
useSortable: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
captured.current = undefined;
|
||||
sortables.items = [];
|
||||
(useDroppable as jest.Mock).mockImplementation(
|
||||
captureDroppableData(captured),
|
||||
);
|
||||
(useSortable as jest.Mock).mockImplementation(captureSortableData(sortables));
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
savedMetrics: [
|
||||
@@ -70,7 +101,7 @@ const adhocMetricB = {
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<DndMetricSelect {...defaultProps} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(
|
||||
@@ -80,7 +111,7 @@ test('renders with default props', () => {
|
||||
|
||||
test('renders with default props and multi = true', () => {
|
||||
render(<DndMetricSelect {...defaultProps} multi />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(
|
||||
@@ -91,7 +122,7 @@ test('renders with default props and multi = true', () => {
|
||||
test('render selected metrics correctly', () => {
|
||||
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
|
||||
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
});
|
||||
expect(screen.getByText('metric_a')).toBeVisible();
|
||||
@@ -113,7 +144,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
|
||||
multi
|
||||
/>,
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
@@ -166,7 +197,7 @@ test('warn selected custom metric when metric gets removed from dataset for sing
|
||||
multi={false}
|
||||
/>,
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
@@ -225,7 +256,7 @@ test('remove selected adhoc metric when column gets removed from dataset', async
|
||||
multi
|
||||
/>,
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
@@ -268,7 +299,7 @@ test('update adhoc metric name when column label in dataset changes', () => {
|
||||
multi
|
||||
/>,
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
@@ -311,156 +342,107 @@ test('update adhoc metric name when column label in dataset changes', () => {
|
||||
expect(screen.getByText('SUM(new col B name)')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can drag metrics', async () => {
|
||||
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
|
||||
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
});
|
||||
// Drop behavior is exercised through `resolveDragEnd` (the production drag-end
|
||||
// dispatcher) because @dnd-kit's PointerSensor needs real layout that jsdom
|
||||
// cannot provide. See ./dndTestUtils and ExploreDndContext.test.tsx.
|
||||
|
||||
expect(screen.getByText('metric_a')).toBeVisible();
|
||||
expect(screen.getByText('Metric B')).toBeVisible();
|
||||
test('can drag metrics (reorder dispatches through the reorder + drop path)', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndMetricSelect
|
||||
{...defaultProps}
|
||||
value={['metric_a', 'metric_b', adhocMetricB]}
|
||||
onChange={onChange}
|
||||
multi
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const container = screen.getByTestId('dnd-labels-container');
|
||||
expect(container.childElementCount).toBe(4);
|
||||
// DndMetricSelect reorders via moveLabel (internal state) finalized by
|
||||
// onDropLabel. Verify both callbacks were registered on the sortable items
|
||||
// and the drag-end path invokes them (which commits the change via onChange).
|
||||
expect(sortables.items.length).toBeGreaterThanOrEqual(3);
|
||||
expect(typeof sortables.items[0].onMoveLabel).toBe('function');
|
||||
expect(typeof sortables.items[0].onDropLabel).toBe('function');
|
||||
|
||||
const firstMetric = container.children[0] as HTMLElement;
|
||||
const lastMetric = container.children[2] as HTMLElement;
|
||||
expect(within(firstMetric).getByText('metric_a')).toBeVisible();
|
||||
expect(within(lastMetric).getByText('SUM(Column B)')).toBeVisible();
|
||||
|
||||
fireEvent.mouseOver(within(firstMetric).getByText('metric_a'));
|
||||
expect(await screen.findByText('Metric name')).toBeInTheDocument();
|
||||
|
||||
fireEvent.dragStart(firstMetric);
|
||||
fireEvent.dragEnter(lastMetric);
|
||||
fireEvent.dragOver(lastMetric);
|
||||
fireEvent.drop(lastMetric);
|
||||
|
||||
expect(within(firstMetric).getByText('SUM(Column B)')).toBeVisible();
|
||||
expect(within(lastMetric).getByText('metric_a')).toBeVisible();
|
||||
simulateReorder(sortables, 0, 2);
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cannot drop a duplicated item', () => {
|
||||
const metricValues = ['metric_a'];
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DndMetricSelect {...defaultProps} value={metricValues} multi />
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
},
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndMetricSelect
|
||||
{...defaultProps}
|
||||
value={['metric_a']}
|
||||
onChange={onChange}
|
||||
multi
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
|
||||
const currentMetric = getByTestId('dnd-labels-container');
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_a' } as any,
|
||||
});
|
||||
|
||||
const currentMetricSelection = currentMetric.children.length;
|
||||
|
||||
fireEvent.dragStart(acceptableMetric);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
expect(currentMetric.children).toHaveLength(currentMetricSelection);
|
||||
expect(currentMetric).toHaveTextContent('metric_a');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('can drop a saved metric when disallow_adhoc_metrics', () => {
|
||||
const metricValues = ['metric_b'];
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DndMetricSelect
|
||||
{...defaultProps}
|
||||
value={metricValues}
|
||||
multi
|
||||
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
|
||||
/>
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
},
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndMetricSelect
|
||||
{...defaultProps}
|
||||
value={['metric_b']}
|
||||
onChange={onChange}
|
||||
multi
|
||||
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' } as any}
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
|
||||
const currentMetric = getByTestId('dnd-labels-container');
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_a' } as any,
|
||||
});
|
||||
|
||||
const currentMetricSelection = currentMetric.children.length;
|
||||
|
||||
fireEvent.dragStart(acceptableMetric);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
|
||||
expect(currentMetric.children[1]).toHaveTextContent('metric_a');
|
||||
expect(onChange).toHaveBeenLastCalledWith(['metric_b', 'metric_a']);
|
||||
});
|
||||
|
||||
test('cannot drop non-saved metrics when disallow_adhoc_metrics', () => {
|
||||
const metricValues = ['metric_b'];
|
||||
const { getByTestId, getAllByTestId } = render(
|
||||
<>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_a', uuid: '1' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ metric_name: 'metric_c', uuid: '2' }}
|
||||
type={DndItemType.Metric}
|
||||
/>
|
||||
<DatasourcePanelDragOption
|
||||
value={{ column_name: 'column_1', uuid: '3' }}
|
||||
type={DndItemType.Column}
|
||||
/>
|
||||
<DndMetricSelect
|
||||
{...defaultProps}
|
||||
value={metricValues}
|
||||
multi
|
||||
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
|
||||
/>
|
||||
</>,
|
||||
{
|
||||
useDnd: true,
|
||||
useRedux: true,
|
||||
},
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DndMetricSelect
|
||||
{...defaultProps}
|
||||
value={['metric_b']}
|
||||
onChange={onChange}
|
||||
multi
|
||||
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' } as any}
|
||||
/>,
|
||||
{ useDndKit: true, useRedux: true },
|
||||
);
|
||||
|
||||
const selections = getAllByTestId('DatasourcePanelDragOption');
|
||||
const acceptableMetric = selections[0];
|
||||
const unacceptableMetric = selections[1];
|
||||
const unacceptableType = selections[2];
|
||||
const currentMetric = getByTestId('dnd-labels-container');
|
||||
// Non-saved metric -> rejected.
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_c' } as any,
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
const currentMetricSelection = currentMetric.children.length;
|
||||
// Column type -> rejected when adhoc metrics are disallowed.
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Column,
|
||||
value: { column_name: 'column_a' } as any,
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.dragStart(unacceptableMetric);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
expect(currentMetric.children).toHaveLength(currentMetricSelection);
|
||||
expect(currentMetric).not.toHaveTextContent('metric_c');
|
||||
|
||||
fireEvent.dragStart(unacceptableType);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
expect(currentMetric.children).toHaveLength(currentMetricSelection);
|
||||
expect(currentMetric).not.toHaveTextContent('column_1');
|
||||
|
||||
fireEvent.dragStart(acceptableMetric);
|
||||
fireEvent.dragOver(currentMetric);
|
||||
fireEvent.drop(currentMetric);
|
||||
|
||||
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
|
||||
expect(currentMetric).toHaveTextContent('metric_a');
|
||||
// Saved metric -> accepted.
|
||||
simulateDrop(captured, {
|
||||
type: DndItemType.Metric,
|
||||
value: { metric_name: 'metric_a' } as any,
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(['metric_b', 'metric_a']);
|
||||
});
|
||||
|
||||
test('title changes on custom SQL text change', async () => {
|
||||
@@ -477,7 +459,7 @@ test('title changes on custom SQL text change', async () => {
|
||||
multi
|
||||
/>,
|
||||
{
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -408,6 +408,9 @@ const DndMetricSelect = (props: any) => {
|
||||
multi ? 2 : 1,
|
||||
);
|
||||
|
||||
// Generate sortable type that matches MetricDefinitionValue's type
|
||||
const sortableType = `${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`;
|
||||
|
||||
return (
|
||||
<div className="metrics-select">
|
||||
<DndSelectLabel
|
||||
@@ -418,6 +421,8 @@ const DndMetricSelect = (props: any) => {
|
||||
ghostButtonText={ghostButtonText}
|
||||
displayGhostButton={multi || value.length === 0}
|
||||
onClickGhostButton={handleClickGhostButton}
|
||||
sortableType={sortableType}
|
||||
itemCount={value.length}
|
||||
{...props}
|
||||
/>
|
||||
<AdhocMetricPopoverTrigger
|
||||
|
||||
@@ -52,7 +52,7 @@ const MockChildren = () => {
|
||||
};
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
|
||||
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
|
||||
expect(screen.getByText('Drop columns here or click')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ test('renders ghost button when empty', () => {
|
||||
const ghostButtonText = 'Ghost button text';
|
||||
render(
|
||||
<DndSelectLabel {...defaultProps} ghostButtonText={ghostButtonText} />,
|
||||
{ useDnd: true },
|
||||
{ useDndKit: true },
|
||||
);
|
||||
expect(screen.getByText(ghostButtonText)).toBeInTheDocument();
|
||||
});
|
||||
@@ -69,13 +69,13 @@ test('renders values', () => {
|
||||
const values = 'Values';
|
||||
const valuesRenderer = () => <span>{values}</span>;
|
||||
render(<DndSelectLabel {...defaultProps} valuesRenderer={valuesRenderer} />, {
|
||||
useDnd: true,
|
||||
useDndKit: true,
|
||||
});
|
||||
expect(screen.getByText(values)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Handles ghost button click', () => {
|
||||
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
|
||||
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
|
||||
userEvent.click(screen.getByText('Drop columns here or click'));
|
||||
expect(defaultProps.onClickGhostButton).toHaveBeenCalled();
|
||||
});
|
||||
@@ -86,7 +86,6 @@ test('updates dropValidator on changes', () => {
|
||||
<DndSelectLabel {...defaultProps} />
|
||||
<MockChildren />
|
||||
</ExploreContainer>,
|
||||
{ useDnd: true },
|
||||
);
|
||||
expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
|
||||
'false',
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
import {
|
||||
@@ -45,6 +49,9 @@ export type DndSelectLabelProps = {
|
||||
displayGhostButton?: boolean;
|
||||
onClickGhostButton: () => void;
|
||||
isLoading?: boolean;
|
||||
// For sortable items - the type string and count to generate sortable IDs
|
||||
sortableType?: string;
|
||||
itemCount?: number;
|
||||
};
|
||||
|
||||
export default function DndSelectLabel({
|
||||
@@ -52,35 +59,49 @@ export default function DndSelectLabel({
|
||||
accept,
|
||||
valuesRenderer,
|
||||
isLoading,
|
||||
sortableType,
|
||||
itemCount = 0,
|
||||
...props
|
||||
}: DndSelectLabelProps) {
|
||||
const canDropProp = props.canDrop;
|
||||
const canDropValueProp = props.canDropValue;
|
||||
|
||||
const acceptTypes = useMemo(
|
||||
() => (Array.isArray(accept) ? accept : [accept]),
|
||||
[accept],
|
||||
);
|
||||
|
||||
const dropValidator = useCallback(
|
||||
(item: DatasourcePanelDndItem) =>
|
||||
canDropProp(item) && (canDropValueProp?.(item.value) ?? true),
|
||||
[canDropProp, canDropValueProp],
|
||||
);
|
||||
|
||||
const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
|
||||
accept: isLoading ? [] : accept,
|
||||
|
||||
drop: (item: DatasourcePanelDndItem) => {
|
||||
props.onDrop(item);
|
||||
props.onDropValue?.(item.value);
|
||||
const { setNodeRef, isOver, active } = useDroppable({
|
||||
id: `dropzone-${props.name}`,
|
||||
disabled: isLoading,
|
||||
data: {
|
||||
accept: acceptTypes,
|
||||
// Mirrors react-dnd's `canDrop`: the drop only fires when this returns
|
||||
// true, so duplicate/selection gating is preserved post-migration.
|
||||
canDrop: dropValidator,
|
||||
onDrop: props.onDrop,
|
||||
onDropValue: props.onDropValue,
|
||||
},
|
||||
|
||||
canDrop: dropValidator,
|
||||
|
||||
collect: monitor => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
type: monitor.getItemType(),
|
||||
}),
|
||||
});
|
||||
|
||||
const dispatch = useContext(DropzoneContext)[1];
|
||||
// Check if the active dragged item can be dropped here
|
||||
const canDrop = useMemo(() => {
|
||||
if (!active?.data.current) return false;
|
||||
const activeData = active.data.current as { type: string; value: unknown };
|
||||
if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
|
||||
return dropValidator({
|
||||
type: activeData.type as DndItemType,
|
||||
value: activeData.value as DndItemValue,
|
||||
});
|
||||
}, [active, acceptTypes, dropValidator]);
|
||||
|
||||
const [, dispatch] = useContext(DropzoneContext);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ key: props.name, canDrop: dropValidator });
|
||||
@@ -93,6 +114,15 @@ export default function DndSelectLabel({
|
||||
|
||||
const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
|
||||
|
||||
// Generate sortable item IDs for SortableContext
|
||||
const sortableItemIds = useMemo(() => {
|
||||
if (!sortableType || itemCount === 0) return [];
|
||||
return Array.from(
|
||||
{ length: itemCount },
|
||||
(_, i) => `sortable-${sortableType}-${i}`,
|
||||
);
|
||||
}, [sortableType, itemCount]);
|
||||
|
||||
function renderGhostButton() {
|
||||
return (
|
||||
<AddControlLabel
|
||||
@@ -105,8 +135,25 @@ export default function DndSelectLabel({
|
||||
);
|
||||
}
|
||||
|
||||
// The actual drop is handled in ExploreDndContext's onDragEnd.
|
||||
|
||||
// Wrap values in SortableContext if sortable
|
||||
const renderSortableValues = () => {
|
||||
if (sortableItemIds.length > 0) {
|
||||
return (
|
||||
<SortableContext
|
||||
items={sortableItemIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{values}
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={datasourcePanelDrop}>
|
||||
<div ref={setNodeRef}>
|
||||
<HeaderContainer>
|
||||
<ControlHeader {...props} />
|
||||
</HeaderContainer>
|
||||
@@ -117,7 +164,7 @@ export default function DndSelectLabel({
|
||||
isDragging={isDragging}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{values}
|
||||
{renderSortableValues()}
|
||||
{displayGhostButton && renderGhostButton()}
|
||||
</DndLabelsContainer>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { DndItemType } from 'src/explore/components/DndItemType';
|
||||
import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
|
||||
|
||||
@@ -29,35 +29,66 @@ test('renders with default props', async () => {
|
||||
onShiftOptions={jest.fn()}
|
||||
label="Option"
|
||||
/>,
|
||||
{ useDnd: true },
|
||||
{ useDndKit: true },
|
||||
);
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(await screen.findByRole('img', { name: 'close' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('triggers onShiftOptions on drop', async () => {
|
||||
const onShiftOptions = jest.fn();
|
||||
test('renders label correctly', async () => {
|
||||
render(
|
||||
<OptionWrapper
|
||||
index={1}
|
||||
clickClose={jest.fn()}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={jest.fn()}
|
||||
label="Test Label"
|
||||
/>,
|
||||
{ useDndKit: true },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Test Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders multiple options', async () => {
|
||||
render(
|
||||
<>
|
||||
<OptionWrapper
|
||||
index={0}
|
||||
clickClose={jest.fn()}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={jest.fn()}
|
||||
label="Option 1"
|
||||
/>
|
||||
<OptionWrapper
|
||||
index={1}
|
||||
clickClose={jest.fn()}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={onShiftOptions}
|
||||
label="Option 1"
|
||||
/>
|
||||
<OptionWrapper
|
||||
index={2}
|
||||
clickClose={jest.fn()}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={onShiftOptions}
|
||||
onShiftOptions={jest.fn()}
|
||||
label="Option 2"
|
||||
/>
|
||||
</>,
|
||||
{ useDnd: true },
|
||||
{ useDndKit: true },
|
||||
);
|
||||
|
||||
fireEvent.dragStart(await screen.findByText('Option 1'));
|
||||
fireEvent.drop(await screen.findByText('Option 2'));
|
||||
expect(onShiftOptions).toHaveBeenCalled();
|
||||
expect(await screen.findByText('Option 1')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Option 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls clickClose when close button is clicked', async () => {
|
||||
const clickClose = jest.fn();
|
||||
render(
|
||||
<OptionWrapper
|
||||
index={1}
|
||||
clickClose={clickClose}
|
||||
type={'Column' as DndItemType}
|
||||
onShiftOptions={jest.fn()}
|
||||
label="Option"
|
||||
/>,
|
||||
{ useDndKit: true },
|
||||
);
|
||||
|
||||
const closeButton = await screen.findByRole('img', { name: 'close' });
|
||||
closeButton.click();
|
||||
expect(clickClose).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
@@ -16,13 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
useDrag,
|
||||
useDrop,
|
||||
DropTargetMonitor,
|
||||
DragSourceMonitor,
|
||||
} from 'react-dnd';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { DragContainer } from 'src/explore/components/controls/OptionControls';
|
||||
import {
|
||||
OptionProps,
|
||||
@@ -64,62 +60,32 @@ export default function OptionWrapper(
|
||||
multiValueWarningMessage,
|
||||
...rest
|
||||
} = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
item: {
|
||||
// Create a unique sortable ID for this item
|
||||
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: sortableId,
|
||||
data: {
|
||||
type,
|
||||
dragIndex: index,
|
||||
},
|
||||
collect: (monitor: DragSourceMonitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
onShiftOptions,
|
||||
} as OptionItemInterface & { onShiftOptions: typeof onShiftOptions },
|
||||
});
|
||||
|
||||
const [, drop] = useDrop({
|
||||
accept: type,
|
||||
|
||||
hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
const { dragIndex } = item;
|
||||
const hoverIndex = index;
|
||||
|
||||
// Don't replace items with themselves
|
||||
if (dragIndex === hoverIndex) {
|
||||
return;
|
||||
}
|
||||
// Determine rectangle on screen
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
// Get vertical middle
|
||||
const hoverMiddleY =
|
||||
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
// Determine mouse position
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
// Get pixels to the top
|
||||
const hoverClientY = clientOffset
|
||||
? clientOffset.y - hoverBoundingRect.top
|
||||
: 0;
|
||||
// Only perform the move when the mouse has crossed half of the items height
|
||||
// When dragging downwards, only move when the cursor is below 50%
|
||||
// When dragging upwards, only move when the cursor is above 50%
|
||||
// Dragging downwards
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
// Dragging upwards
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Time to actually perform the action
|
||||
onShiftOptions(dragIndex, hoverIndex);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
item.dragIndex = hoverIndex;
|
||||
},
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const shouldShowTooltip =
|
||||
(!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
|
||||
@@ -179,10 +145,14 @@ export default function OptionWrapper(
|
||||
return null;
|
||||
};
|
||||
|
||||
drag(drop(ref));
|
||||
|
||||
return (
|
||||
<DragContainer ref={ref} {...rest}>
|
||||
<DragContainer
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...rest}
|
||||
>
|
||||
<Option
|
||||
index={index}
|
||||
clickClose={clickClose}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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 {
|
||||
DroppableData,
|
||||
resolveDragEnd,
|
||||
} from 'src/explore/components/ExploreContainer/ExploreDndContext';
|
||||
import { DndItemType } from 'src/explore/components/DndItemType';
|
||||
import { DndItemValue } from 'src/explore/components/DatasourcePanel/types';
|
||||
|
||||
/**
|
||||
* @dnd-kit's PointerSensor only reacts to real pointer events, which jsdom
|
||||
* cannot meaningfully dispatch (it has no layout). To exercise drop behavior in
|
||||
* unit tests we capture the `data` object a control registers via
|
||||
* `useDroppable` and feed it through the same `resolveDragEnd` dispatcher the
|
||||
* live `ExploreDndContextProvider` runs on drag end.
|
||||
*
|
||||
* Usage: spy on `useDroppable` with `captureDroppableData`, render the control,
|
||||
* then call `simulateDrop` with the dragged item.
|
||||
*/
|
||||
export type CapturedDroppable = { current: DroppableData | undefined };
|
||||
|
||||
/**
|
||||
* Returns a `jest.fn` mock implementation for `@dnd-kit/core`'s `useDroppable`
|
||||
* that records the most recently registered droppable data into `captured`,
|
||||
* while returning an inert droppable shape so the control still renders.
|
||||
*/
|
||||
export function captureDroppableData(captured: CapturedDroppable) {
|
||||
return (args: { data?: DroppableData }) => {
|
||||
captured.current = args?.data;
|
||||
return {
|
||||
setNodeRef: () => {},
|
||||
isOver: false,
|
||||
active: null,
|
||||
rect: { current: null },
|
||||
node: { current: null },
|
||||
over: null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a single drag-and-drop of `item` onto the captured droppable through
|
||||
* the production `resolveDragEnd` dispatcher.
|
||||
*/
|
||||
export function simulateDrop(
|
||||
captured: CapturedDroppable,
|
||||
item: { type: DndItemType; value: DndItemValue },
|
||||
) {
|
||||
resolveDragEnd(
|
||||
{ id: 'drag-source', data: { current: item } },
|
||||
{ id: 'dropzone', data: { current: captured.current ?? {} } },
|
||||
);
|
||||
}
|
||||
|
||||
export type SortableItemData = {
|
||||
type: string;
|
||||
dragIndex: number;
|
||||
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
|
||||
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
|
||||
onDropLabel?: () => void;
|
||||
};
|
||||
|
||||
export type CapturedSortables = { items: SortableItemData[] };
|
||||
|
||||
/**
|
||||
* Returns a `jest.fn` implementation for `@dnd-kit/sortable`'s `useSortable`
|
||||
* that records each sortable item's registered data (carrying the reorder
|
||||
* callbacks) into `captured`, while returning an inert sortable shape so the
|
||||
* control still renders.
|
||||
*/
|
||||
export function captureSortableData(captured: CapturedSortables) {
|
||||
return (args: { data?: SortableItemData }) => {
|
||||
if (args?.data) {
|
||||
captured.items.push(args.data);
|
||||
}
|
||||
return {
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: () => {},
|
||||
transform: null,
|
||||
transition: undefined,
|
||||
isDragging: false,
|
||||
setActivatorNodeRef: () => {},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives an in-list reorder (drag item at `fromIndex` over item at `toIndex`)
|
||||
* through the production `resolveDragEnd` dispatcher, using the reorder
|
||||
* callbacks the control registered on its sortable items.
|
||||
*/
|
||||
export function simulateReorder(
|
||||
captured: CapturedSortables,
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
) {
|
||||
const from = captured.items.find(i => i.dragIndex === fromIndex);
|
||||
const to = captured.items.find(i => i.dragIndex === toIndex);
|
||||
resolveDragEnd(
|
||||
{ id: `from-${fromIndex}`, data: { current: from } },
|
||||
{ id: `to-${toIndex}`, data: { current: to } },
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user