Compare commits

...

43 Commits

Author SHA1 Message Date
Brett Smith
35365d639d fix(deckgl): render legend swatch as a coloured box, not an emoji glyph (#40784)
Signed-off-by: Brett Smith <brett@pukekos.co.nz>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Damian Pendrak <dpendrak@gmail.com>
2026-06-26 10:07:29 +02:00
Michael Gerber
7e17c70cba fix: Filter null child names in treeBuilder utility (#31477)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 22:03:45 -07:00
SkinnyPigeon
0d43c2c12c feat(reports): trigger alerts (#41336) 2026-06-25 22:01:39 -07:00
Evan Rusackas
7410ff73c0 ci: schedule a weekly Docker image rebuild against the latest release (#40426)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 17:15:31 -07:00
Debabrata Saha
f08f068240 fix(sqllab): replace native prompt with modal for tab rename (#41329)
Signed-off-by: debabsah <debasaha.uw@gmail.com>
2026-06-25 17:15:07 -07:00
Greg Neighbors
2b09b6bc1d feat(mcp): list_charts accepts dashboards filter (#40397)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
2026-06-25 17:14:11 -07:00
Özgür YÜKSEL
d763255e15 chore(i18n): update Turkish translations messages.po (#39064)
Co-authored-by: Özgür YÜKSEL <o.yuksel@gardiyan.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 17:11:40 -07:00
Evan Rusackas
8fed514e79 fix(dashboard): keep pasted filter values outside the loaded page (#41136)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 15:33:57 -07:00
Evan Rusackas
c94bc7178f fix(world-map): rely on built-in highlightOnHover to reset hover highlight (#41158)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 15:33:46 -07:00
Evan Rusackas
95ecdd3753 fix(menu): highlight active nav tab in non-English locales (#41183)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-25 15:33:30 -07:00
Evan Rusackas
aac02ab679 fix(deck.gl): use interval notation for Polygon legend bucket labels (#41400)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:23:34 -07:00
madhushreeag
de01fe2ff0 fix(chart-controls): fix RadioButtonControl crash on empty options and false values (#41170)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-25 12:02:58 -07:00
Beto Dealmeida
9965c05699 fix(semantic layers): small fixes (#40474) 2026-06-25 14:59:49 -04:00
Greg Neighbors
d8bcc66472 feat(mcp): dashboard layout, theme, and CSS control + update_dashboard tool (#40399)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-MacBook-Air-2.local>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2026-06-25 10:41:07 -07:00
Evan Rusackas
4b9b8187b3 fix(config): make Swagger UI opt-in (off by default) (#41300)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 10:34:28 -07:00
Evan Rusackas
83f7dc9d5b chore(codeowners): add translation maintainers (#41429)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 10:09:16 -07:00
Elizabeth Thompson
baca76ebe0 fix(slack): fix indented triple-quoted string in v1 API deprecation warning (#41393) 2026-06-25 09:54:33 -07:00
Mehmet Salih Yavuz
9a11c15a33 feat(explore): add full-range option for time-shift comparison (#41334) 2026-06-25 18:30:33 +03:00
Michael S. Molina
a90c8e0347 feat(extensions): add Chat contribution type (SIP-214) (#41205)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:57:30 -03:00
dependabot[bot]
fe2424ec14 chore(deps): bump mapbox-gl from 3.24.1 to 3.25.0 in /superset-frontend (#41409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:48 -07:00
dependabot[bot]
b4f43bd7e0 chore(deps): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /docs (#41405)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:45 -07:00
dependabot[bot]
2b25345ed9 chore(deps-dev): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /superset-frontend (#41413)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:41 -07:00
Evan Rusackas
e0f3f93cd4 fix(mcp): require MCP_JWT_AUDIENCE when MCP JWT auth is enabled (#41292)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:53:36 -07:00
Evan Rusackas
0667ba6097 chore(deps): bump dompurify and http-proxy-middleware (security) (#41289)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:56 -07:00
Evan Rusackas
81f7e42f4e fix(rls): preserve tables/roles on partial RLS rule updates (#41294)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:47 -07:00
Evan Rusackas
0fd244b5c6 fix(security): reject unknown fields on guest-token RLS rules (#41217)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 16:16:43 -07:00
Evan Rusackas
1f16d10cbf chore(deps): bump pyjwt to 2.13.0 (CVE-2026-48526) (#41288)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:40 -07:00
Evan Rusackas
4f4663418f fix(tests): stabilize update_chart MCP test failing on previous-Python CI leg (#41310)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:16:14 -07:00
Evan Rusackas
4519a5c52d fix(safe-markdown): do not mutate the shared sanitization schema (#41298)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:06 -07:00
Evan Rusackas
da9fbadaf6 fix(logout): purge the namespaced Cache API store on logout (#41303)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:50 -07:00
Evan Rusackas
f40abbbefd fix(mcp): fail closed when the JWT verifier has no pinned algorithm (#41296)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:42 -07:00
Evan Rusackas
6166af3c3c fix(mcp): reject non-finite JWT exp instead of 500ing on int() overflow (#41394)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:29 -07:00
Evan Rusackas
076d8c1508 docs(security): add a secrets register and rotation schedule (#41308)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:17 -07:00
Elizabeth Thompson
518cadd907 fix(mcp_service): reduce deprecated authlib.jose.errors imports (#41248) 2026-06-24 15:01:58 -07:00
JUST.in DO IT
b955c90de4 fix(sqllab): Invalid multi sorting state in table header (#40680) 2026-06-25 06:43:02 +09:00
Evan Rusackas
7363774869 fix(theming): deep-merge partial THEME_DEFAULT overrides with built-in defaults (#41347)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 13:27:32 -07:00
Vansh Gilhotra
6f12d17313 fix(charts): show user-friendly error for HTTP 413 payload too large (#37131)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 11:21:59 -07:00
abhyudaytomar
09c7ba14df fix(export): sanitize control characters in titles to prevent export failures (#39294)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:46 -07:00
Elizabeth Thompson
3ec4bd23c4 fix(deps): restore np.nan in offset_metrics_df empty branch (#41267)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:49:38 -07:00
yousoph
f6ce105450 fix(pandas-postprocessing): handle prophet errors and validate minimum data points for forecast (#41180)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:44:23 -07:00
Evan Rusackas
7bb4e82a82 fix(dashboard): Remove 308 redirect when creating new dashboards (#41343)
Co-authored-by: ericsong <eric.song@example.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:31:31 -07:00
Kamil Gabryjelski
2d78a8733c fix(plugin-chart-ag-grid-table): show correct percent-metric totals in summary row (#41247)
Signed-off-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
2026-06-24 19:21:00 +02:00
Evan Rusackas
3261d10270 chore(frontend): enforce TypeScript-only source files (#41385)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 05:54:37 -07:00
164 changed files with 13104 additions and 4805 deletions

2
.github/CODEOWNERS vendored
View File

@@ -38,7 +38,7 @@
# Notify translation maintainers of changes to translations
/superset/translations/ @sfirke @rusackas
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
# Notify PMC members of changes to extension-related files

View File

@@ -0,0 +1,177 @@
name: Scheduled Docker image refresh
# Re-runs the Docker image build against the latest published release on a
# weekly cadence. The code being built doesn't change — but the base image
# layers (python:*-slim-trixie and its OS packages) DO get upstream
# security patches between Superset releases, and those patches don't
# reach our published images unless we rebuild.
#
# Without this workflow, `apache/superset:<latest>` lags behind upstream
# Debian/Python base patches by whatever interval falls between Superset
# releases (typically 36 weeks). With it, the lag drops to at most one
# week regardless of release cadence.
#
# This is a security-hygiene cron, not a release. It overwrites the
# existing tags for the most recent release (e.g. `apache/superset:5.0.0`
# and `apache/superset:latest`) with bit-for-bit-equivalent contents
# layered on a refreshed base. Image digests change; everything users
# actually pin against (image content, code, deps) does not.
on:
schedule:
# Mondays at 06:00 UTC — gives the weekend for upstream patches to
# settle and surfaces failures at the start of the work week so a
# human can react.
- cron: "0 6 * * 1"
# Manual trigger so operators can force a refresh on demand (e.g.
# immediately after a high-severity base-image CVE drops).
workflow_dispatch: {}
permissions:
contents: read
# Serialize with itself and with the release publisher (tag-release.yml) —
# both push to the same Docker Hub tags, so a race could end with stale
# layers winning. Both workflows must declare this group for the lock to work.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
latest-release: ${{ steps.latest.outputs.tag }}
force-latest: ${{ steps.latest.outputs.force-latest }}
steps:
- name: Check for Docker Hub secrets
id: check
shell: bash
run: |
if [ -n "${DOCKERHUB_USER}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }}
- name: Look up latest published release
id: latest
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
run: |
# `releases/latest` returns the latest non-prerelease, non-draft
# release — which is exactly what `apache/superset:latest`
# should reflect.
TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name)
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
echo "::error::Could not determine latest release tag"
exit 1
fi
echo "Latest release: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
# Only move `:latest` when the release flagged "latest" is also the
# highest semver release. This guards against a mis-click leaving an
# older maintenance release (e.g. a 5.x patch shipped after 6.0 GA)
# marked latest, which would otherwise roll `:latest` back a major
# version on the next cron run. If it isn't the newest, we still
# refresh that release's own version tag but leave `:latest` alone.
HIGHEST=$(gh api --paginate "repos/${REPOSITORY}/releases" \
--jq '.[] | select(.draft|not) | select(.prerelease|not) | .tag_name' \
| sed 's/^v//' | sort -V | tail -n1)
if [ "${TAG#v}" = "$HIGHEST" ]; then
echo "force-latest=1" >> "$GITHUB_OUTPUT"
else
echo "::warning::Latest-flagged release $TAG is not the highest semver ($HIGHEST); refreshing its version tag but leaving :latest untouched"
fi
docker-rebuild:
needs: config
if: needs.config.outputs.has-secrets == '1'
name: docker-rebuild
runs-on: ubuntu-24.04
strategy:
# Mirror the same matrix the release publisher uses so every variant
# operators consume from Docker Hub gets the refreshed base.
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
fail-fast: false
steps:
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.config.outputs.latest-release }}
fetch-depth: 0
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
install-docker-compose: "false"
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Rebuild and push
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_PRESET: ${{ matrix.build_preset }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
FORCE_LATEST_FLAG: ${{ needs.config.outputs.force-latest == '1' && '--force-latest' || '' }}
run: |
# Reuses the same supersetbot invocation as the release
# publisher (`tag-release.yml`), so the resulting tags are
# identical to what a manual release dispatch would produce —
# just with a freshly-pulled base image layer underneath.
# `--force-latest` is only passed when the config job confirmed the
# fetched release is the newest one (see FORCE_LATEST_FLAG above).
supersetbot docker \
--push \
--preset "$BUILD_PRESET" \
--context release \
--context-ref "$LATEST_RELEASE" \
$FORCE_LATEST_FLAG \
--platform "linux/arm64" \
--platform "linux/amd64"
# The whole point of this cron is catching base-image CVEs, so a silent
# failure is the expensive case — a red X in the Actions tab nobody is
# watching on a Monday. File a tracked issue when any rebuild leg fails so
# a missed security refresh surfaces instead of sitting unnoticed.
notify-on-failure:
needs: [config, docker-rebuild]
if: failure() && needs.config.outputs.has-secrets == '1'
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
steps:
- name: Open a tracking issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh issue create \
--repo "$REPOSITORY" \
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
--label "infra:container" \
--label "bug" \
--body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved.
Failed run: ${RUN_URL}"

View File

@@ -24,6 +24,12 @@ on:
permissions:
contents: read
# Serialize with the scheduled Docker image refresh — both workflows push
# to the same Docker Hub tags and must not race on apache/superset:latest.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -24,6 +24,18 @@ assists people when migrating to a new version.
## Next
### Guest-token RLS rules reject unknown fields
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
### Swagger UI is opt-in (off by default)
`FAB_API_SWAGGER_UI` now defaults to `False` and is driven by the `SUPERSET_ENABLE_SWAGGER_UI` environment variable. The interactive Swagger UI / OpenAPI documentation endpoints (e.g. `/swagger/v1`) are therefore no longer exposed by default. To enable them, set `SUPERSET_ENABLE_SWAGGER_UI=true` (the bundled Docker development environment sets this) or override `FAB_API_SWAGGER_UI = True` in `superset_config.py`.
### Pivot table First/Last aggregations follow data order
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.

View File

@@ -70,6 +70,8 @@ SUPERSET_LOG_LEVEL=info
SUPERSET_APP_ROOT="/"
SUPERSET_ENV=development
# Swagger UI is opt-in (off by default); enable it for local development.
SUPERSET_ENABLE_SWAGGER_UI=true
SUPERSET_LOAD_EXAMPLES=yes
CYPRESS_CONFIG=false
SUPERSET_PORT=8088

View File

@@ -161,6 +161,7 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
- [ ] Rotate the other security-critical secrets (guest-token and async-query JWT secrets, SMTP and database credentials) on the cadence in Appendix C, and after any potential security incident.
- [ ] Conduct quarterly access reviews for all users.
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
@@ -173,6 +174,24 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
### **Appendix C: Secrets Register and Rotation Schedule**
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
| Secret | Purpose | Risk if leaked | Suggested rotation |
|---|---|---|---|
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
Notes:
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
:::resources
- [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/)
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)

View File

@@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
```tsx
import React from 'react';
```typescript
import { views } from '@apache-superset/core';
import MyPanel from './MyPanel';
views.registerView(
{ id: 'my-extension.main', name: 'My Panel Name' },
'sqllab.panels',
() => <MyPanel />,
MyPanel,
);
```
@@ -112,6 +111,24 @@ editors.registerEditor(
See [Editors Extension Point](./extension-points/editors.md) for implementation details.
### Chat
Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads.
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
See [Chat](./extension-points/chat.md) for implementation details.
## Backend
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.

View File

@@ -0,0 +1,141 @@
---
title: Chat
sidebar_position: 3
---
<!--
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.
-->
# Chat Contributions
Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components.
## Overview
A chat registration consists of two React components:
| Component | Role |
|-----------|------|
| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. |
| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. |
## Display Modes
The host supports two display modes, switchable by the user or the extension at runtime:
| Mode | Behavior |
|------|----------|
| `floating` | Panel floats above page content, anchored to the bottom-right corner. |
| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. |
The user's last selected mode and open/closed state are persisted across page reloads.
## Registering a Chat
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
```tsx
import { chat } from '@apache-superset/core';
import ChatTrigger from './ChatTrigger';
import ChatPanel from './ChatPanel';
chat.registerChat(
{ id: 'my-org.my-chat', name: 'My Chat' },
ChatTrigger,
ChatPanel,
);
```
Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged.
## Opening and Closing the Chat
The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility:
```tsx
import { chat } from '@apache-superset/core';
export default function ChatTrigger() {
return (
<button onClick={() => (chat.isOpen() ? chat.close() : chat.open())}>
💬
</button>
);
}
```
You can also subscribe to open/close events from any component:
```tsx
useEffect(() => {
const { dispose } = chat.onDidOpen(() => console.log('chat opened'));
return dispose;
}, []);
```
## Changing the Display Mode
Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user):
```tsx
import { useState, useEffect } from 'react';
import { chat } from '@apache-superset/core';
export default function ChatPanel() {
const [mode, setMode] = useState(chat.getDisplayMode());
useEffect(() => {
const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m));
return dispose;
}, []);
return (
<div style={{ height: mode === 'panel' ? '100%' : '80vh' }}>
<button onClick={() => chat.setDisplayMode(mode === 'panel' ? 'floating' : 'panel')}>
{mode === 'panel' ? 'Float' : 'Dock'}
</button>
{/* message list and input */}
</div>
);
}
```
## Chat API Reference
All methods are available on the `chat` namespace from `@apache-superset/core`:
| Method / Event | Description |
|----------------|-------------|
| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. |
| `open()` | Open the chat panel. No-op if already open or no registration. |
| `close()` | Close the chat panel. |
| `isOpen()` | Returns `true` if the panel is currently open. |
| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). |
| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. |
| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. |
| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. |
| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. |
| `onDidRegisterChat(listener)` | Subscribe to registration events. |
| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. |
| `onDidResizePanel(listener)` | Subscribe to panel resize events (panel mode only). Not all hosts provide a resizer — do not rely on this firing. Returns a `Disposable`. |
## Next Steps
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
- **[Development](../development.md)** — Set up your development environment

View File

@@ -47,6 +47,8 @@ module.exports = {
collapsed: true,
items: [
'extensions/extension-points/sqllab',
'extensions/extension-points/editors',
'extensions/extension-points/chat',
],
},
'extensions/development',

View File

@@ -72,7 +72,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.41",
"antd": "^6.4.4",
"baseline-browser-mapping": "^2.10.37",
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",

View File

@@ -5698,10 +5698,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.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.37"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.38"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
batch@0.6.1:
version "0.6.1"

View File

@@ -315,7 +315,7 @@ pygeohash==3.2.2
# via apache-superset (pyproject.toml)
pygments==2.20.0
# via rich
pyjwt==2.12.0
pyjwt==2.13.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder

View File

@@ -769,7 +769,7 @@ pyhive==0.7.0
# via apache-superset
pyinstrument==5.1.2
# via apache-superset
pyjwt==2.12.0
pyjwt==2.13.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -109,7 +109,7 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.24.1",
"mapbox-gl": "^3.25.0",
"markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -220,7 +220,7 @@
"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.37",
"baseline-browser-mapping": "^2.10.38",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",
@@ -6326,12 +6326,6 @@
"node": ">= 0.6"
}
},
"node_modules/@mapbox/mapbox-gl-supported": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
"license": "BSD-3-Clause"
},
"node_modules/@mapbox/martini": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz",
@@ -11470,15 +11464,6 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/geojson-vt": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/glob-to-regexp": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
@@ -11776,12 +11761,6 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
"license": "MIT"
},
"node_modules/@types/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
@@ -14961,9 +14940,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
"integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -15954,12 +15933,6 @@
"node": "*"
}
},
"node_modules/cheap-ruler": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
"license": "ISC"
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -17283,12 +17256,6 @@
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"license": "MIT"
},
"node_modules/csscolorparser": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -18617,11 +18584,10 @@
}
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -21450,12 +21416,6 @@
"integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==",
"license": "BSD-2-Clause"
},
"node_modules/geojson-vt": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
"license": "ISC"
},
"node_modules/geolib": {
"version": "3.3.14",
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
@@ -23260,9 +23220,9 @@
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -26573,6 +26533,21 @@
}
}
},
"node_modules/jsdom/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/jsdom/node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -28361,9 +28336,9 @@
}
},
"node_modules/mapbox-gl": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.1.tgz",
"integrity": "sha512-e9Wj1TtGGOjzE/jtWaUvdFN7RYL3H0keEzH7gwzHbEdFAsmi03RaDVhnATmtFtIRXQUYf944CIQN0jQv+obeNg==",
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz",
"integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
@@ -28371,66 +28346,7 @@
"test/build/vite",
"test/build/webpack",
"test/build/typings"
],
"dependencies": {
"@mapbox/mapbox-gl-supported": "^3.0.0",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@types/geojson": "^7946.0.16",
"@types/geojson-vt": "^3.2.5",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"cheap-ruler": "^4.0.0",
"csscolorparser": "~1.0.3",
"earcut": "^3.0.1",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"martinez-polygon-clipping": "^0.8.1",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0"
}
},
"node_modules/mapbox-gl/node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/mapbox-gl/node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/mapbox-gl/node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/mapbox-gl/node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
]
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
@@ -28548,23 +28464,6 @@
}
}
},
"node_modules/martinez-polygon-clipping": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
"license": "MIT",
"dependencies": {
"robust-predicates": "^2.0.4",
"splaytree": "^0.1.4",
"tinyqueue": "3.0.0"
}
},
"node_modules/martinez-polygon-clipping/node_modules/robust-predicates": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
"license": "Unlicense"
},
"node_modules/match-sorter": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz",
@@ -39137,12 +39036,6 @@
"webpack": "^1 || ^2 || ^3 || ^4 || ^5"
}
},
"node_modules/splaytree": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
"license": "MIT"
},
"node_modules/split": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
@@ -43692,6 +43585,21 @@
}
}
},
"node_modules/whatwg-url/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/whatwg-url/node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
@@ -45044,15 +44952,6 @@
"node": ">=12"
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
@@ -45423,15 +45322,6 @@
"react": "^18.3.0"
}
},
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"plugins/plugin-chart-ag-grid-table": {
"name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3",
@@ -45611,7 +45501,7 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.24.1",
"mapbox-gl": "^3.25.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"

View File

@@ -192,7 +192,7 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.24.1",
"mapbox-gl": "^3.25.0",
"markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -303,7 +303,7 @@
"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.37",
"baseline-browser-mapping": "^2.10.38",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",

View File

@@ -18,6 +18,14 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/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"

View File

@@ -0,0 +1,156 @@
/**
* 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 Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type: an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
import { ComponentType } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. Only one chat is active at a time; the most
* recently registered chat wins. Disposing the returned Disposable unregisters
* the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger The trigger component — the collapsed bubble entry point.
* Owns dynamic state such as unread counts.
* @param panel The panel component, rendered in either display mode. In
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
* alongside the main content.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable;
/**
* Returns the active chat descriptor, or undefined if none is registered.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes, whether triggered by an extension
* or by the host.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode. The mode is host-global and applies to whichever
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
* including those triggered by the host.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode. Not all hosts provide
* a resizer — do not rely on this event firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -223,8 +223,6 @@ export interface Extension {
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 */

View File

@@ -23,9 +23,10 @@
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
* menus, editors, chat) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -71,7 +72,8 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -82,4 +84,10 @@ export interface Contributions {
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
}

View File

@@ -18,10 +18,12 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as editors from './editors';
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';

View File

@@ -0,0 +1,81 @@
/**
* 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.
*
* 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 — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'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. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;

View File

@@ -30,12 +30,12 @@
*
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
* () => <ResultStatsPanel />,
* ResultStatsPanel,
* );
* ```
*/
import { ReactElement } from 'react';
import { ComponentType } from 'react';
import { Disposable, Event } from '../common';
/**
@@ -58,7 +58,7 @@ export interface View {
*
* @param view The view descriptor (id and name).
* @param location The location where this view should appear (e.g. "sqllab.panels").
* @param provider A function that returns the React element to render.
* @param component The React component to render at that location.
* @returns A Disposable that unregisters the view when disposed.
*
* @example
@@ -66,14 +66,14 @@ export interface View {
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats' },
* 'sqllab.panels',
* () => <ResultStatsPanel />,
* ResultStatsPanel,
* );
* ```
*/
export declare function registerView(
view: View,
location: string,
provider: () => ReactElement,
component: ComponentType,
): Disposable;
/**

View File

@@ -132,6 +132,26 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
},
},
],
[
{
name: 'time_compare_full_range',
config: {
type: 'CheckboxControl',
label: t('Show full range for time shift'),
default: false,
description: t(
'Plot each time-shifted series across its full time range instead ' +
'of truncating it to the main series. Useful for comparing a ' +
'partial current period (e.g. today so far) against complete ' +
'prior periods (e.g. all of yesterday).',
),
visibility: ({ controls }) =>
Boolean(controls?.time_compare?.value) &&
(!Array.isArray(controls?.time_compare?.value) ||
controls.time_compare.value.length > 0),
},
},
],
[
{
name: 'comparison_type',

View File

@@ -60,7 +60,7 @@ export default function RadioButtonControl({
...props
}: RadioButtonControlProps) {
const normalizedOptions = options.map(normalizeOption);
const currentValue = initialValue || normalizedOptions[0].value;
const currentValue = initialValue ?? normalizedOptions[0]?.value;
return (
<div>

View File

@@ -359,6 +359,51 @@ test('handles empty options array gracefully', () => {
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
});
test('currentValue is undefined when options are empty and no value is provided', () => {
expect(() => setup({ options: [] })).not.toThrow();
const { container } = setup({ options: [] });
expect(container.querySelectorAll('[id^="tab-"]').length).toBe(0);
});
test('preserves falsy numeric value 0 instead of falling back to first option', () => {
const { container } = setup({
options: [
[0, 'Zero'],
[1, 'One'],
[2, 'Two'],
],
value: 0,
});
expect(container.querySelector('#tab-0')).toHaveAttribute(
'aria-selected',
'true',
);
expect(container.querySelector('#tab-1')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('preserves falsy boolean value false instead of falling back to first option', () => {
const { container } = setup({
options: [
[true, 'True'],
[false, 'False'],
],
value: false,
});
expect(container.querySelector('#tab-true')).toHaveAttribute(
'aria-selected',
'false',
);
expect(container.querySelector('#tab-false')).toHaveAttribute(
'aria-selected',
'true',
);
});
test('renders with hovered prop', () => {
const { container } = setup({
label: 'Test',

View File

@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
import remarkGfm from 'remark-gfm';
import { mergeWith } from 'lodash';
import { cloneDeep, mergeWith } from 'lodash';
import { FeatureFlag, isFeatureEnabled } from '../../utils';
interface SafeMarkdownProps {
@@ -85,8 +85,15 @@ export function getOverrideHtmlSchema(
originalSchema: typeof defaultSchema,
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
) {
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
// Merge into a fresh clone: mergeWith mutates its first argument, and the
// array customizer concatenates, so merging into the shared defaultSchema
// import would progressively widen the sanitization allowlist for every
// SafeMarkdown instance app-wide.
return mergeWith(
cloneDeep(originalSchema),
htmlSchemaOverrides,
(objValue, srcValue) =>
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
);
}

View File

@@ -1146,6 +1146,127 @@ test('pasting an non-existent option should not add it if allowNewOptions is fal
expect(await findAllSelectOptions()).toHaveLength(0);
});
// Reference for the bug this tests: https://github.com/apache/superset/issues/32645
// Dashboard filters with "Dynamically search all filter values" only load a
// page of options client-side, so a pasted value outside that page used to be
// silently dropped. allowNewOptionsOnPaste keeps such values so the filter can
// still apply them.
test('keeps pasted values outside loaded options when allowNewOptionsOnPaste is true', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Liam is a loaded option; OutsideValue is not in the loaded page.
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
// The paste handler appends, so the loaded option resolves first.
expect(values).toEqual(['Liam', 'OutsideValue']);
});
// Assert the unloaded value actually reaches the change handler (the value
// that gets applied to the filter query), not just the rendered label.
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('trims whitespace around pasted comma-separated values', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
// Note the space after the comma — it must not leak into the value.
getData: () => 'Liam, OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam', 'OutsideValue']);
});
expect(onChange).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ value: 'OutsideValue' }),
]),
expect.anything(),
);
});
test('does not create an empty option when pasting blank text', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
allowNewOptions={false}
allowNewOptionsOnPaste
onChange={onChange}
/>,
);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => ' ',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual([]);
});
// No empty-string value should ever reach the handler.
onChange.mock.calls.forEach(([value]) => {
expect(value).not.toContain('');
});
});
test('drops pasted values outside loaded options when allowNewOptionsOnPaste is false', async () => {
render(<Select {...defaultProps} mode="multiple" allowNewOptions={false} />);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'Liam,OutsideValue',
},
});
fireEvent(input, paste);
await waitFor(() => {
const values = [
...getElementsByClassName('.ant-select-selection-item'),
].map(value => value.textContent);
expect(values).toEqual(['Liam']);
});
});
test('does not fire onChange if the same value is selected in single mode', async () => {
const onChange = jest.fn();
render(<Select {...defaultProps} onChange={onChange} />);

View File

@@ -91,6 +91,7 @@ const Select = forwardRef(
className,
allowClear,
allowNewOptions = false,
allowNewOptionsOnPaste = false,
allowSelectAll = true,
ariaLabel,
autoClearSearchValue = false,
@@ -692,20 +693,34 @@ const Select = forwardRef(
}
} else {
const token = tokenSeparators.find(token => pastedText.includes(token));
const array = token ? uniq(pastedText.split(token)) : [pastedText];
const array = token
? uniq(
pastedText
.split(token)
.map(item => item.trim())
.filter(Boolean),
)
: [pastedText.trim()].filter(Boolean);
const newOptions: SelectOptionsType = [];
// When `allowNewOptionsOnPaste` is set, accept pasted values that are
// not in the loaded options even if `allowNewOptions` is false. The
// full option set is searched server-side and only partially loaded
// client-side, so a pasted value can legitimately exist in the dataset
// but fall outside the loaded page.
const keepUnknownValues = allowNewOptions || allowNewOptionsOnPaste;
const values = array
.map(item => {
const option = getOption(item, fullSelectOptions, true);
if (!option && allowNewOptions) {
if (!option && keepUnknownValues) {
const newOption = {
label: item,
value: item,
isNewOption: true,
};
newOptions.push(newOption);
return labelInValue ? { label: item, value: item } : item;
}
return getPastedTextValue(item);
})

View File

@@ -88,6 +88,18 @@ export interface BaseSelectProps extends AntdExposedProps {
* False by default.
* */
allowNewOptions?: boolean;
/**
* Accept values pasted into the Select even when they are not part of the
* currently loaded options and `allowNewOptions` is false. Useful for
* selects whose full option set is searched server-side and only partially
* loaded on the client (e.g. dashboard filters with "Dynamically search all
* filter values"), where a pasted value can legitimately exist in the
* dataset but fall outside the loaded page.
* Only applies to multi-select paste; single-select paste resolves through
* `allowNewOptions` and ignores this flag.
* False by default.
* */
allowNewOptionsOnPaste?: boolean;
/**
* It adds the aria-label tag for accessibility standards.
* Must be plain English and localized.

View File

@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
request: request => getInstance().request(request),
getCSRFToken: () => getInstance().getCSRFToken(),
getUrl: (...args) => getInstance().getUrl(...args),
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
get guestTokenHeaderName() {
try {
return getInstance().guestTokenHeaderName;

View File

@@ -150,6 +150,26 @@ export default class SupersetClientClass {
}
}
/**
* POST request that returns a blob for file downloads.
* Unlike postForm, this uses AJAX so errors can be caught and handled.
* @param endpoint - API endpoint
* @param payload - Request payload
* @returns Promise resolving to Response with blob
*/
async postBlob(
endpoint: string,
payload: Record<string, any>,
): Promise<Response> {
await this.ensureAuth();
return this.post({
endpoint,
postPayload: payload,
parseMethod: 'raw',
stringify: false,
});
}
async reAuthenticate() {
return this.init(true);
}

View File

@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
| 'get'
| 'post'
| 'postForm'
| 'postBlob'
| 'put'
| 'request'
| 'init'

View File

@@ -17,6 +17,8 @@
* under the License.
*/
import { render } from '@testing-library/react';
import { cloneDeep } from 'lodash';
import { defaultSchema } from 'rehype-sanitize';
import {
getOverrideHtmlSchema,
SafeMarkdown,
@@ -51,6 +53,36 @@ describe('getOverrideHtmlSchema', () => {
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
});
test('should not mutate the original schema', () => {
const original = {
attributes: { '*': ['size'] },
tagNames: ['h1'],
};
getOverrideHtmlSchema(original, {
attributes: { '*': ['src'] },
tagNames: ['iframe'],
});
// The original passed in is left untouched.
expect(original.attributes).toEqual({ '*': ['size'] });
expect(original.tagNames).toEqual(['h1']);
});
test('should not mutate the shared defaultSchema import or accumulate across calls', () => {
const snapshot = cloneDeep(defaultSchema);
const overrides = { tagNames: ['iframe'] };
const first = getOverrideHtmlSchema(defaultSchema, overrides);
const second = getOverrideHtmlSchema(defaultSchema, overrides);
// The shared singleton is never modified...
expect(defaultSchema).toEqual(snapshot);
// ...and repeated calls do not accumulate the override (no growing arrays).
expect(first.tagNames).toEqual(second.tagNames);
expect(
(second.tagNames ?? []).filter(name => name === 'iframe'),
).toHaveLength(1);
});
});
describe('transformLinkUri', () => {

View File

@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
getUrl: (...args: unknown[]) => string;
};
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
expect(typeof SupersetClient.configure).toBe('function');
expect(typeof SupersetClient.init).toBe('function');
expect(typeof SupersetClient.get).toBe('function');
expect(typeof SupersetClient.post).toBe('function');
expect(typeof SupersetClient.postForm).toBe('function');
expect(typeof SupersetClient.postBlob).toBe('function');
expect(typeof SupersetClient.delete).toBe('function');
expect(typeof SupersetClient.put).toBe('function');
expect(typeof SupersetClient.request).toBe('function');
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
expect(typeof SupersetClient.reAuthenticate).toBe('function');
});
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
expect(SupersetClient.init).toThrow();
expect(SupersetClient.get).toThrow();
expect(SupersetClient.post).toThrow();
expect(SupersetClient.postForm).toThrow();
expect(SupersetClient.postBlob).toThrow();
expect(SupersetClient.delete).toThrow();
expect(SupersetClient.put).toThrow();
expect(SupersetClient.request).toThrow();

View File

@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
expect(authSpy).toHaveBeenCalledTimes(0);
});
});
describe('.postBlob()', () => {
const protocol = 'https:';
const host = 'host';
const mockPostBlobEndpoint = '/api/v1/chart/data';
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
let authSpy: jest.SpyInstance;
let client: SupersetClientClass;
beforeEach(async () => {
fetchMock.removeRoute(LOGIN_GLOB);
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
client = new SupersetClientClass({ protocol, host });
await client.init();
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
const mockResponse = new Response('csv data', { status: 200 });
const postSpy = jest
.spyOn(client, 'post')
.mockResolvedValue(mockResponse);
const response = await client.postBlob(
mockPostBlobEndpoint,
postBlobPayload,
);
expect(authSpy).toHaveBeenCalledTimes(1);
expect(postSpy).toHaveBeenCalledWith({
endpoint: mockPostBlobEndpoint,
postPayload: postBlobPayload,
parseMethod: 'raw',
stringify: false,
});
expect(response).toBe(mockResponse);
});
test('passes payload in request body', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 200,
body: 'csv data',
});
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
.options as CallApi;
const formData = fetchRequest.body as FormData;
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
});
test('rejects when response is not ok', async () => {
fetchMock.post(mockPostBlobUrl, {
status: 413,
body: 'Payload Too Large',
});
await expect(
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
).rejects.toMatchObject({ status: 413 });
});
});
});

View File

@@ -305,36 +305,16 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
key: JSON.stringify,
},
done: datamap => {
// Hover highlighting and its reset are handled entirely by Datamaps'
// built-in highlightOnHover, which saves each country's original fill on
// mouseover and restores it on mouseout. Adding our own mouseover/mouseout
// fill handlers here creates a second, competing restore path whose
// execution order is browser-timing-dependent, which left the highlight
// stuck on Chrome/Edge (see #37761).
datamap.svg
.selectAll('.datamaps-subunit')
.on('contextmenu', handleContextMenu)
.on('click', handleClick)
// Use namespaced events to avoid overriding Datamaps' default tooltip handlers
.on('mouseover.fillPreserve', function onMouseOver() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const classes = element.attr('class') || '';
const countryId = classes.split(' ')[1];
const countryData = mapData[countryId];
const originalFill =
(countryData && countryData.fillColor) || theme.colorBorder;
// Store original fill color for restoration
element.attr('data-original-fill', originalFill);
})
.on('mouseout.fillPreserve', function onMouseOut() {
if (inContextMenu) {
return;
}
const element = d3.select(this);
const originalFill = element.attr('data-original-fill');
// Restore the original fill color (data-based or default no-data color)
if (originalFill) {
element.style('fill', originalFill);
element.attr('data-original-fill', null);
}
});
.on('click', handleClick);
},
});

View File

@@ -58,15 +58,6 @@ interface WorldMapProps {
formatter: ValueFormatter;
}
type MouseEventHandler = (this: HTMLElement) => void;
interface MockD3Selection {
attr: jest.Mock;
style: jest.Mock;
classed: jest.Mock;
selectAll: jest.Mock;
}
// Mock Datamap
const mockBubbles = jest.fn();
const mockUpdateChoropleth = jest.fn();
@@ -157,244 +148,36 @@ afterEach(() => {
document.body.removeChild(container);
});
test('sets up mouseover and mouseout handlers on countries', () => {
test('relies on Datamaps highlightOnHover without adding conflicting fill handlers', () => {
// Regression test for #37761. The hover highlight got stuck on Chrome/Edge
// because hand-written mouseover/mouseout handlers competed with Datamaps'
// built-in highlightOnHover restore path, and the winning path was
// browser-timing-dependent. The chart should rely on the single built-in
// path and register no custom fill-restoring hover handlers on the countries.
WorldMap(container, baseProps);
expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
const onCalls = mockSvg.on.mock.calls;
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(true);
// Find mouseover and mouseout handler registrations (namespaced events)
const hasMouseover = onCalls.some(
call => call[0] === 'mouseover.fillPreserve',
const hoverHandlers = mockSvg.on.mock.calls.filter((call: [string]) =>
/^mouse(over|out)/.test(call[0]),
);
const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve');
expect(hasMouseover).toBe(true);
expect(hasMouseout).toBe(true);
expect(hoverHandlers).toEqual([]);
});
test('stores original fill color on mouseover', () => {
// Create a mock DOM element with d3 selection capabilities
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
test('disables Datamaps highlightOnHover while the context menu is open', () => {
// Companion to the regression guard above: when the context menu is open we
// pass highlightOnHover: false so hover highlighting is suppressed at init.
WorldMap(container, { ...baseProps, inContextMenu: true });
let mouseoverHandler: MouseEventHandler | null = null;
// Mock d3.select to return the mock element
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string) => {
if (value !== undefined) {
mockElement.setAttribute(attrName, value);
} else {
return mockElement.getAttribute(attrName);
}
return mockD3Selection;
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
} else {
return mockElement.style[styleName as any];
}
return mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseover handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseover
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
// Verify that data-original-fill attribute was set
expect(mockD3Selection.attr).toHaveBeenCalledWith(
'data-original-fill',
expect.any(String),
);
});
test('restores original fill color on mouseout for country with data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
mockElement.setAttribute('data-original-fill', 'rgb(100, 150, 200)');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that original fill was restored
expect(mockD3Selection.style).toHaveBeenCalledWith(
'fill',
'rgb(100, 150, 200)',
);
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('restores default fill color on mouseout for country with no data', () => {
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit XXX');
mockElement.style.fill = '#e0e0e0'; // Default border color
mockElement.setAttribute('data-original-fill', '#e0e0e0');
container.appendChild(mockElement);
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn((attrName: string, value?: string | null) => {
if (value !== undefined) {
if (value === null) {
mockElement.removeAttribute(attrName);
} else {
mockElement.setAttribute(attrName, value);
}
return mockD3Selection;
}
return mockElement.getAttribute(attrName);
}),
style: jest.fn((styleName: string, value?: string) => {
if (value !== undefined) {
mockElement.style[styleName as any] = value;
}
return mockElement.style[styleName as any] || mockD3Selection;
}),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture the mouseout handler (namespaced event)
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, baseProps);
// Simulate mouseout
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// Verify that default fill was restored (no-data color)
expect(mockD3Selection.style).toHaveBeenCalledWith('fill', '#e0e0e0');
expect(mockD3Selection.attr).toHaveBeenCalledWith('data-original-fill', null);
});
test('does not handle mouse events when inContextMenu is true', () => {
const propsWithContextMenu = {
...baseProps,
inContextMenu: true,
};
const mockElement = document.createElement('path');
mockElement.setAttribute('class', 'datamaps-subunit USA');
mockElement.style.fill = 'rgb(100, 150, 200)';
container.appendChild(mockElement);
let mouseoverHandler: MouseEventHandler | null = null;
let mouseoutHandler: MouseEventHandler | null = null;
const mockD3Selection: MockD3Selection = {
attr: jest.fn(() => mockD3Selection),
style: jest.fn(() => mockD3Selection),
classed: jest.fn().mockReturnThis(),
selectAll: jest.fn().mockReturnValue({ remove: jest.fn() }),
};
jest.spyOn(d3 as any, 'select').mockReturnValue(mockD3Selection as any);
// Capture namespaced event handlers
mockSvg.on.mockImplementation((event: string, handler: MouseEventHandler) => {
if (event === 'mouseover.fillPreserve') {
mouseoverHandler = handler;
}
if (event === 'mouseout.fillPreserve') {
mouseoutHandler = handler;
}
return mockSvg;
});
WorldMap(container, propsWithContextMenu);
// Simulate mouseover and mouseout
if (mouseoverHandler) {
(mouseoverHandler as MouseEventHandler).call(mockElement);
}
if (mouseoutHandler) {
(mouseoutHandler as MouseEventHandler).call(mockElement);
}
// When inContextMenu is true, handlers should exit early without modifying anything
// We verify this by checking that attr and style weren't called to change fill
const attrCalls = mockD3Selection.attr.mock.calls;
const fillChangeCalls = attrCalls.filter(
(call: [string, unknown]) =>
call[0] === 'data-original-fill' && call[1] !== undefined,
);
const styleCalls = mockD3Selection.style.mock.calls;
const fillStyleChangeCalls = styleCalls.filter(
(call: [string, unknown]) => call[0] === 'fill' && call[1] !== undefined,
);
// The handlers should return early, so no state changes
expect(fillChangeCalls.length).toBe(0);
expect(fillStyleChangeCalls.length).toBe(0);
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
string,
unknown
>;
expect(geographyConfig?.highlightOnHover).toBe(false);
});
test('does not throw error when onContextMenu is undefined', () => {

View File

@@ -90,6 +90,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
let postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. The totals query must rename percent-metric
// columns the same way (`metric` -> `%metric`) so the footer can look them
// up; without it the totals row renders 0.000%. We deliberately reuse only
// this rule and not the full `postProcessing` array, which may also contain
// a time-comparison operator that must not run on the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -157,15 +164,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
metrics.concat(percentMetrics),
getMetricLabel,
);
postProcessing = [
{
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
contributionPostProcessing = {
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(x => `%${x}`),
},
];
};
postProcessing = [contributionPostProcessing];
}
// Add the operator for the time comparison if some is selected
if (!isEmpty(timeOffsets)) {
@@ -658,7 +664,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
extras: totalsExtras, // Use extras with AG Grid WHERE removed
row_limit: 0,
row_offset: 0,
post_processing: [],
// Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
order_desc: undefined, // we don't need orderby stuff here,
orderby: undefined, // because this query will be used for get total aggregation.
});

View File

@@ -852,6 +852,75 @@ describe('plugin-chart-ag-grid-table', () => {
expect(totalsQuery.columns).toEqual([]);
expect(totalsQuery.row_limit).toBe(0);
});
test('should reapply percent-metric contribution op to totals query', () => {
// Regression test for #37627: when a percent metric is configured and
// Show Summary (show_totals) is enabled, the totals query must rename
// percent-metric columns (`metric` -> `%metric`) so the footer can
// look them up. Otherwise the totals row renders 0.000%.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
// No server pagination -> queries[1] is the totals query.
const totalsQuery = queries[1];
const contributionRule = {
operation: 'contribution',
options: {
columns: ['count'],
rename_columns: ['%count'],
},
};
expect(queries[0].post_processing).toContainEqual(contributionRule);
expect(totalsQuery.post_processing).toEqual([contributionRule]);
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
percent_metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
time_compare: ['1 year ago'],
comparison_type: 'values',
});
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const { queries } = buildQuery({
...basicFormData,
metrics: ['count'],
show_totals: true,
query_mode: QueryMode.Aggregate,
});
const totalsQuery = queries[1];
expect(totalsQuery.post_processing).toEqual([]);
});
});
describe('Integration - all filter types together', () => {

View File

@@ -318,14 +318,25 @@ function createAdvancedAnalyticsSection(
): ControlPanelSectionConfig {
const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls);
aaWithSuffix.label = label;
// `time_compare_full_range` is only wired into the regular timeseries query
// builder, not the mixed-timeseries one, so drop it here to avoid showing a
// control that has no effect.
aaWithSuffix.controlSetRows = aaWithSuffix.controlSetRows
.map(row =>
row.filter(
control =>
(control as CustomControlItem)?.name !== 'time_compare_full_range',
),
)
.filter(row => row.length > 0);
if (!controlSuffix) {
return aaWithSuffix;
}
aaWithSuffix.controlSetRows.forEach(row =>
row.forEach((control: CustomControlItem) => {
if (control?.name) {
// eslint-disable-next-line no-param-reassign
control.name = `${control.name}${controlSuffix}`;
row.forEach(control => {
const item = control as CustomControlItem;
if (item?.name) {
item.name = `${item.name}${controlSuffix}`;
}
}),
);

View File

@@ -92,6 +92,20 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'show_null_values',
config: {
type: 'CheckboxControl',
label: t('Show Null Values'),
default: true,
renderTrigger: true,
description: t(
'Whether to display entries with null values in the hierarchy',
),
},
},
],
[
{
name: 'label_type',

View File

@@ -186,6 +186,7 @@ export default function transformProps(
showLabels,
showLabelsThreshold,
showTotal,
showNullValues,
sliceId,
} = formData;
const {
@@ -251,6 +252,7 @@ export default function transformProps(
columnLabels,
metricLabel,
secondaryMetricLabel,
!showNullValues,
);
const totalValue = treeData.reduce(
(result, treeNode) => result + treeNode.value,

View File

@@ -82,6 +82,11 @@ export default function buildQuery(formData: QueryFormData) {
? formData.time_compare
: [];
// When comparing against prior periods, optionally keep each shifted series at
// its full time range instead of truncating it to the main series' range.
const time_compare_full_range =
time_offsets.length > 0 && Boolean(formData.time_compare_full_range);
return [
{
...baseQueryObject,
@@ -92,6 +97,7 @@ export default function buildQuery(formData: QueryFormData) {
// todo: move `normalizeOrderBy to extractQueryFields`
orderby: normalizeOrderBy(baseQueryObject).orderby,
time_offsets,
time_compare_full_range,
/* Note that:
1. The resample, rolling, cum, timeCompare operators should be after pivot.
2. Resample must come before rolling so that imputed values are

View File

@@ -381,6 +381,15 @@ export default function transformProps(
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
// With the "full range" time-shift option, offset series are outer-joined onto
// the main series, which inserts null rows into the main series wherever the
// comparison period has data the current period lacks. Connect nulls so the
// main line stays continuous (matching the default left-join appearance) rather
// than fragmenting at every inserted gap.
const timeCompareFullRange = Boolean(
chartProps.rawFormData?.time_compare_full_range,
);
const offsetLineWidths: { [key: string]: number } = {};
// For horizontal bar charts, calculate min/max from data to avoid cutting off labels
@@ -478,7 +487,7 @@ export default function transformProps(
colorScaleKey,
{
area,
connectNulls: derivedSeries,
connectNulls: derivedSeries || timeCompareFullRange,
filterState,
seriesContexts,
markerEnabled,

View File

@@ -36,6 +36,7 @@ export function treeBuilder(
groupBy: string[],
metric: string,
secondaryMetric?: string,
filterNullNames?: boolean,
): TreeNode[] {
const [curGroupBy, ...restGroupby] = groupBy;
const curData = _groupBy(data, curGroupBy);
@@ -63,6 +64,7 @@ export function treeBuilder(
restGroupby,
metric,
secondaryMetric,
filterNullNames,
);
const metricValue = children.reduce(
(prev, cur) => prev + (cur.value as number),
@@ -74,9 +76,12 @@ export function treeBuilder(
0,
)
: metricValue;
const validChildren = filterNullNames
? children.filter(child => child.name !== null)
: children;
result.push({
name,
children,
children: validChildren,
value: metricValue,
secondaryValue,
groupBy: curGroupBy,

View File

@@ -271,4 +271,244 @@ describe('test treeBuilder', () => {
},
]);
});
test('include null values', () => {
const tree = treeBuilder(
[
...data,
{
foo: 'a-2',
bar: null,
count: 2,
count2: 3,
},
],
['foo', 'bar'],
'count',
);
expect(tree).toEqual([
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
{
groupBy: 'bar',
name: null,
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-2',
secondaryValue: 4,
value: 4,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'd',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'd-1',
secondaryValue: 2,
value: 2,
},
]);
});
test('filter null values', () => {
const tree = treeBuilder(
[
...data,
{
foo: 'a-2',
bar: null,
count: 2,
count2: 3,
},
],
['foo', 'bar'],
'count',
undefined,
true,
);
expect(tree).toEqual([
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'a',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'a-2',
secondaryValue: 4,
value: 4,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'b',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'b-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-1',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'c',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'c-2',
secondaryValue: 2,
value: 2,
},
{
children: [
{
groupBy: 'bar',
name: 'd',
secondaryValue: 2,
value: 2,
},
],
groupBy: 'foo',
name: 'd-1',
secondaryValue: 2,
value: 2,
},
]);
});
});

View File

@@ -27,7 +27,7 @@
],
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.24.1",
"mapbox-gl": "^3.25.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"

View File

@@ -86,6 +86,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
let { metrics, orderby = [], columns = [] } = baseQueryObject;
const { extras = {} } = baseQueryObject;
const postProcessing: PostProcessingRule[] = [];
// Capture the percent-metric `contribution` rule so it can be reused for
// the totals query below. Without it the totals row's percent-metric
// columns are keyed `metric` instead of `%metric`, so the footer renders
// 0.000%. We reuse only this rule and not the full `postProcessing` array,
// which may also contain a time-comparison operator that must not run on
// the single totals row.
let contributionPostProcessing: PostProcessingRule | undefined;
const nonCustomNorInheritShifts = ensureIsArray(
formData.time_compare,
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
@@ -137,12 +144,6 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
orderby = [[metrics[0], false]];
}
// add postprocessing for percent metrics only when in aggregation mode
type PercentMetricCalculationMode = 'row_limit' | 'all_records';
const calculationMode: PercentMetricCalculationMode =
(formData.percent_metric_calculation as PercentMetricCalculationMode) ||
'row_limit';
if (percentMetrics && percentMetrics.length > 0) {
const percentMetricsLabelsWithTimeComparison = isTimeComparison(
formData,
@@ -162,23 +163,14 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
getMetricLabel,
);
if (calculationMode === 'all_records') {
postProcessing.push({
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(m => `%${m}`),
},
});
} else {
postProcessing.push({
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(m => `%${m}`),
},
});
}
contributionPostProcessing = {
operation: 'contribution',
options: {
columns: percentMetricLabels,
rename_columns: percentMetricLabels.map(m => `%${m}`),
},
};
postProcessing.push(contributionPostProcessing);
}
// Add the operator for the time comparison if some is selected
@@ -357,7 +349,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
columns: [],
row_limit: 0,
row_offset: 0,
post_processing: [],
// Reapply only the percent-metric contribution rule so the totals row
// exposes `%metric` keys (value/value = 100% on the single aggregated
// row). The time-comparison operator from the main query is omitted on
// purpose; it must not run against the single-row totals query.
post_processing: contributionPostProcessing
? [contributionPostProcessing]
: [],
order_desc: undefined,
orderby: undefined,
});

View File

@@ -236,6 +236,83 @@ describe('plugin-chart-table', () => {
expect(queries).toHaveLength(1);
expect(queries[0].post_processing).toEqual([]);
});
test('should reapply contribution op to totals query in row_limit mode', () => {
// Regression test for #37627: with a percent metric and Show Summary
// (show_totals) enabled, the totals query must rename percent-metric
// columns (`metric` -> `%metric`) so the footer can look them up.
// Otherwise the totals row renders 0.000%.
const formData = {
...baseFormDataWithPercents,
show_totals: true,
};
const { queries } = buildQuery(formData);
// row_limit mode + show_totals -> [main, totals].
expect(queries).toHaveLength(2);
const contributionRule = {
operation: 'contribution',
options: {
columns: ['sum_sales'],
rename_columns: ['%sum_sales'],
},
};
expect(queries[1]).toMatchObject({
columns: [],
post_processing: [contributionRule],
});
});
test('should omit time-comparison op from totals post_processing', () => {
// The totals query must reuse ONLY the contribution rule; the
// time-comparison operator from the main query must not run against
// the single-row totals query.
const formData = {
...baseFormDataWithPercents,
show_totals: true,
time_compare: ['1 year ago'],
comparison_type: 'values',
};
const { queries } = buildQuery(formData);
// row_limit mode + show_totals -> [main, totals].
expect(queries).toHaveLength(2);
const totalsQuery = queries[1];
// Exactly one op (contribution) — the time-comparison operator from the
// main query must not be carried over to the single-row totals query.
expect(totalsQuery.post_processing).toHaveLength(1);
expect(totalsQuery.post_processing?.[0]).toMatchObject({
operation: 'contribution',
});
// The reused rule matches the main query's contribution rule verbatim.
expect(totalsQuery.post_processing?.[0]).toEqual(
queries[0].post_processing?.find(
op => op?.operation === 'contribution',
),
);
});
test('should leave totals post_processing empty without percent metrics', () => {
const formData = {
...basicFormData,
query_mode: QueryMode.Aggregate,
metrics: ['count'],
percent_metrics: [],
groupby: ['category'],
show_totals: true,
};
const { queries } = buildQuery(formData);
expect(queries).toHaveLength(2);
expect(queries[1].post_processing).toEqual([]);
});
});
describe('Testing for server pagination with search filter', () => {

View File

@@ -0,0 +1,71 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { render, screen } from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import type { ReactElement } from 'react';
import Legend from './Legend';
const renderWithTheme = (component: ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
test('formats interval-notation labels while preserving brackets', () => {
renderWithTheme(
<Legend
format=",.2f"
categories={{
'[1, 81)': { enabled: true, color: [0, 0, 0] },
'[81, 212)': { enabled: true, color: [0, 0, 0] },
'[212, 369]': { enabled: true, color: [0, 0, 0] },
}}
/>,
);
expect(screen.getByText('[1.00, 81.00)')).toBeInTheDocument();
expect(screen.getByText('[81.00, 212.00)')).toBeInTheDocument();
expect(screen.getByText('[212.00, 369.00]')).toBeInTheDocument();
});
test('still formats legacy "a - b" delimiter labels', () => {
renderWithTheme(
<Legend
format=",.1f"
categories={{
'0 - 100000': { enabled: true, color: [0, 0, 0] },
'100001 - 200000': { enabled: true, color: [0, 0, 0] },
}}
/>,
);
expect(screen.getByText('0.0 - 100,000.0')).toBeInTheDocument();
expect(screen.getByText('100,001.0 - 200,000.0')).toBeInTheDocument();
});
test('leaves labels untouched when no format is provided', () => {
renderWithTheme(
<Legend
format={null}
categories={{ '[1, 81)': { enabled: true, color: [0, 0, 0] } }}
/>,
);
expect(screen.getByText('[1, 81)')).toBeInTheDocument();
});

View File

@@ -59,6 +59,33 @@ const StyledLegend = styled.div`
const categoryDelimiter = ' - ';
const OPENING_BRACKETS = '[(';
const CLOSING_BRACKETS = '])';
// Recognize half-open interval labels like "[1, 81)" or "[81, 212]" emitted by
// getBuckets: brackets on the ends, two comma-separated bounds in between.
// Returns the parsed pieces, or null when the label isn't interval notation.
const parseInterval = (label: string) => {
const open = label[0];
const close = label[label.length - 1];
if (!OPENING_BRACKETS.includes(open) || !CLOSING_BRACKETS.includes(close)) {
return null;
}
const bounds = label.slice(1, -1).split(',');
if (bounds.length !== 2) {
return null;
}
const lower = bounds[0].trim();
const upper = bounds[1].trim();
if (!lower || !upper) {
return null;
}
return { open, lower, upper, close };
};
export type LegendProps = {
format: string | null;
forceCategorical?: boolean;
@@ -91,6 +118,15 @@ const Legend = ({
return k;
}
// Format each numeric bound of an interval label while preserving the
// brackets and separator, e.g. "[1, 81)" -> "[1.00, 81.00)".
const interval = parseInterval(k);
if (interval) {
const { open, lower, upper, close } = interval;
return `${open}${format(lower)}, ${format(upper)}${close}`;
}
if (k.includes(categoryDelimiter)) {
const values = k.split(categoryDelimiter);
@@ -105,8 +141,22 @@ const Legend = ({
}
const categories = Object.entries(categoriesObject).map(([k, v]) => {
const style = { color: `rgba(${v.color?.join(', ')})` };
const icon = v.enabled ? '\u25FC' : '\u25FB';
const color = `rgba(${v.color?.join(', ')})`;
// Render the swatch as a real coloured box rather than a colour-tinted
// text glyph. U+25FC/U+25FB are in Unicode's Emoji set but lack
// Emoji_Presentation, so Chromium resolves them to a colour-emoji font
// whose glyphs carry baked-in colour and ignore the CSS `color` property,
// producing a black square regardless of the category colour. A bordered
// box has no such dependency: filled when enabled, hollow when disabled.
const swatchStyle = {
display: 'inline-block',
width: '12px',
height: '12px',
border: `1px solid ${color}`,
backgroundColor: v.enabled ? color : 'transparent',
alignSelf: 'center',
flex: '0 0 auto',
};
return (
<li key={k}>
@@ -122,7 +172,7 @@ const Legend = ({
showSingleCategory(k);
}}
>
<span style={style}>{icon}</span> {formatCategoryLabel(k)}
<span aria-hidden style={swatchStyle} /> {formatCategoryLabel(k)}
</a>
</li>
);
@@ -137,7 +187,7 @@ const Legend = ({
};
return (
<StyledLegend className="dupa" style={style}>
<StyledLegend style={style}>
<ul>{categories}</ul>
</StyledLegend>
);

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
import { JsonObject, QueryFormData } from '@superset-ui/core';
import {
getColorBreakpointsBuckets,
getBreakPoints,
getBuckets,
BucketsWithColorScale,
} from './utils';
import { ColorBreakpointType } from './types';
describe('getColorBreakpointsBuckets', () => {
@@ -488,3 +494,42 @@ describe('getBreakPoints', () => {
});
});
});
describe('getBuckets', () => {
const accessor = (d: JsonObject) => d.value;
const buildFeatures = (values: number[]) => values.map(value => ({ value }));
test('produces non-overlapping bucket labels (no shared endpoints)', () => {
// With break points [1, 81, 212, 369] the legacy behavior produced
// "1 - 81", "81 - 212", "212 - 369" where each interior breakpoint
// (81, 212) appeared in two adjacent labels, reading as overlapping
// ranges. Labels should instead form a clean, non-overlapping partition.
const fd: QueryFormData & BucketsWithColorScale = {
datasource: '1__table',
viz_type: 'deck_polygon',
break_points: ['1', '81', '212', '369'],
num_buckets: '3',
linear_color_scheme: ['#000000', '#ffffff'],
opacity: 100,
metric: 'count',
};
const features = buildFeatures([1, 50, 100, 200, 300, 369]);
const buckets = getBuckets(fd, features, accessor);
const labels = Object.keys(buckets);
// Three buckets for four breakpoints
expect(labels).toHaveLength(3);
// Interval notation: half-open everywhere except the last bucket, which
// is closed so the maximum value is included.
expect(labels).toEqual(['[1, 81)', '[81, 212)', '[212, 369]']);
// No numeric endpoint should appear as both an upper bound of one bucket
// and a lower bound of the next in an ambiguous "a - b" form.
labels.forEach(label => {
expect(label).not.toMatch(/^\d+(\.\d+)?\s-\s\d+(\.\d+)?$/);
});
});
});

View File

@@ -200,8 +200,15 @@ export function getBuckets(
string,
{ color: Color | undefined; enabled: boolean }
> = {};
const lastBucketIndex = breakPoints.length - 2;
breakPoints.slice(1).forEach((_, i) => {
const range = `${breakPoints[i]} - ${breakPoints[i + 1]}`;
// Use half-open interval notation so adjacent buckets don't share an
// ambiguous endpoint. This mirrors the d3 `scaleThreshold` binning used
// for coloring, where each breakpoint is a half-open cut point. The final
// bucket is closed on the right so the maximum value is included.
const isLastBucket = i === lastBucketIndex;
const closingBracket = isLastBucket ? ']' : ')';
const range = `[${breakPoints[i]}, ${breakPoints[i + 1]}${closingBracket}`;
const mid =
0.5 * (parseFloat(breakPoints[i]) + parseFloat(breakPoints[i + 1]));
// fix polygon doesn't show

View File

@@ -632,6 +632,35 @@ function processFile(filepath) {
}
}
/**
* Application source trees that must be authored in TypeScript. Matches the
* top-level `src/` directory as well as each package/plugin `src/` directory.
*/
const TS_ONLY_SOURCE_PATTERN =
/^(src|packages\/[^/]+\/src|plugins\/[^/]+\/src)\//;
/**
* Enforce the TypeScript-only frontend convention: no `.js`/`.jsx` files may be
* added under the application source trees (including test files). Build
* artifacts and root-level config files (e.g. `.storybook/preview.jsx`,
* `webpack.config.js`) live outside these trees and are intentionally allowed.
*
* @param {string[]} candidateFiles paths relative to `superset-frontend/`
*/
function checkTypeScriptOnlySource(candidateFiles) {
candidateFiles.forEach(file => {
if (TS_ONLY_SOURCE_PATTERN.test(file) && /\.(js|jsx)$/.test(file)) {
// eslint-disable-next-line no-console
console.error(
`${RED}${RESET} ${file}: frontend source must be TypeScript. ` +
`Rename to .ts/.tsx (the codebase is mid-migration to full ` +
`TypeScript; no new .js/.jsx files in src/).`,
);
errorCount += 1;
}
});
}
/**
* Main function
*/
@@ -666,6 +695,22 @@ function main() {
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
];
// Enforce TypeScript-only source. Run this on the raw file list (before the
// ignore patterns below strip out tests/stories) so that e.g. a new
// `*.test.jsx` is still rejected.
const tsOnlyCandidates =
args.length === 0
? glob.sync('{src,packages/*/src,plugins/*/src}/**/*.{js,jsx}', {
ignore: [
'**/node_modules/**',
'**/esm/**',
'**/lib/**',
'**/dist/**',
],
})
: args.map(f => f.replace(/^superset-frontend\//, ''));
checkTypeScriptOnlySource(tsOnlyCandidates);
// If no files specified, check all
if (files.length === 0) {
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
@@ -706,22 +751,23 @@ function main() {
if (files.length === 0) {
// eslint-disable-next-line no-console
console.log('No files to check.');
return;
} else {
// eslint-disable-next-line no-console
console.log(
`Checking ${files.length} files for Superset custom rules...\n`,
);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
}
// eslint-disable-next-line no-console
console.log(`Checking ${files.length} files for Superset custom rules...\n`);
files.forEach(file => {
// Resolve the file path
const resolvedPath = path.resolve(file);
if (fs.existsSync(resolvedPath)) {
processFile(resolvedPath);
} else if (fs.existsSync(file)) {
processFile(file);
}
});
// eslint-disable-next-line no-console
console.log(`\n${errorCount} errors, ${warningCount} warnings`);
@@ -740,4 +786,5 @@ module.exports = {
checkNoFaIcons,
checkI18nTemplates,
checkUntranslatedStrings,
checkTypeScriptOnlySource,
};

View File

@@ -135,14 +135,17 @@ describe('SqlEditorTabHeader', () => {
test('should dispatch queryEditorSetTitle action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
const expectedTitle = 'typed text';
const mockPrompt = jest
.spyOn(window, 'prompt')
.mockImplementation(() => expectedTitle);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: expectedTitle } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
@@ -153,7 +156,127 @@ describe('SqlEditorTabHeader', () => {
}),
}),
);
mockPrompt.mockClear();
});
test('prefills the rename input with the current tab name', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
expect(input).toHaveValue(defaultQueryEditor.name);
});
test('focuses the rename input when the modal opens', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
await waitFor(() => expect(input).toHaveFocus());
});
test('disables Save when the input is empty or whitespace', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
});
test('does not dispatch or dismiss on Enter when the input is empty', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: ' ' } });
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, charCode: 13 });
const dispatchedTitleChange = store
.getActions()
.some(action => action.type === QUERY_EDITOR_SET_TITLE);
expect(dispatchedTitleChange).toBe(false);
// the modal must stay open so the user can correct the name,
// mirroring the disabled Save button rather than dismissing like Escape
expect(screen.queryByRole('dialog')).toBeInTheDocument();
});
test('does not dispatch a title change when the modal is cancelled', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(store.getActions()).toEqual([]);
});
test('does not dispatch a title change when dismissed with the close button', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'discarded text' } });
fireEvent.click(screen.getByTestId('close-modal-btn'));
expect(store.getActions()).toEqual([]);
});
test('returns focus to the tab header after the modal is cancelled', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
await screen.findByTestId('rename-tab-input');
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('returns focus to the tab header after a successful rename', async () => {
await waitFor(() =>
expect(
screen.getByTestId('rename-tab-menu-option'),
).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
fireEvent.change(input, { target: { value: 'renamed tab' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() =>
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
);
});
test('should dispatch removeAllOtherQueryEditors action', async () => {
@@ -196,4 +319,42 @@ describe('SqlEditorTabHeader', () => {
);
});
});
test('does not leak tab-editing keystrokes from the rename input to the surrounding tabs', async () => {
const onContainerKeyDown = jest.fn();
const store = mockStore(initialState);
render(
<div onKeyDown={onContainerKeyDown}>
<SqlEditorTabHeader queryEditor={defaultQueryEditor} />
</div>,
{ useRedux: true, store },
);
userEvent.click(screen.getByTestId('dropdown-trigger'));
await waitFor(() =>
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const input = await screen.findByTestId('rename-tab-input');
// The modal portals over the editable-card tabs, whose keyboard handler would
// otherwise remove, navigate, or activate a tab (and swallow Space). None of
// these keys should escape the modal to the surrounding container.
[
'Delete',
'Backspace',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
' ',
].forEach(key => fireEvent.keyDown(input, { key }));
expect(onContainerKeyDown).not.toHaveBeenCalled();
// Escape (close) and Tab (focus trap) must still reach the Modal.
fireEvent.keyDown(input, { key: 'Tab' });
fireEvent.keyDown(input, { key: 'Escape' });
const reached = onContainerKeyDown.mock.calls.map(call => call[0].key);
expect(reached).toEqual(expect.arrayContaining(['Tab', 'Escape']));
});
});

View File

@@ -16,12 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo, FC } from 'react';
import { useEffect, useMemo, useRef, useState, FC } from 'react';
import { bindActionCreators } from 'redux';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
import { MenuDotsDropdown } from '@superset-ui/core/components';
import {
MenuDotsDropdown,
Modal,
Input,
InputRef,
} from '@superset-ui/core/components';
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
import { t } from '@apache-superset/core/translation';
import { QueryState } from '@superset-ui/core';
@@ -107,13 +112,35 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
[dispatch],
);
function renameTab() {
// TODO: Replace native prompt with a proper modal dialog
// eslint-disable-next-line no-alert
const newTitle = prompt(t('Enter a new title for the tab'));
if (newTitle) {
actions.queryEditorSetTitle(qe, newTitle, qe.id);
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
const [newTitle, setNewTitle] = useState('');
const renameInputRef = useRef<InputRef>(null);
const tabHeaderRef = useRef<HTMLDivElement>(null);
const trimmedTitle = newTitle.trim();
function openRenameModal() {
setNewTitle(qe.name);
setIsRenameModalOpen(true);
}
// antd's Modal moves focus to the dialog container on open, which overrides
// the Input's autoFocus, so focus and select the field via a ref once the
// modal is open (select lets the prefilled name be overtyped, like prompt()).
useEffect(() => {
if (isRenameModalOpen) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [isRenameModalOpen]);
function handleRenameTab() {
if (trimmedTitle) {
actions.queryEditorSetTitle(qe, trimmedTitle, qe.id);
}
setIsRenameModalOpen(false);
// Save closes via the show prop rather than the Modal's onHide, so return
// focus to the tab header here, matching what openerRef does on dismiss.
tabHeaderRef.current?.focus();
}
const getStatusColor = (state: QueryState, theme: SupersetTheme): string => {
const statusColors: Record<QueryState, string> = {
@@ -131,7 +158,11 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
return statusColors[state] || theme.colorIcon;
};
return (
<TabTitleWrapper>
<TabTitleWrapper
ref={tabHeaderRef}
tabIndex={-1}
data-test="sql-editor-tab-header"
>
<MenuDotsDropdown
trigger={['click']}
overlay={
@@ -158,7 +189,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
} as MenuItemType,
{
key: '2',
onClick: renameTab,
onClick: openRenameModal,
'data-test': 'rename-tab-menu-option',
label: (
<>
@@ -220,6 +251,37 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
iconSize="m"
iconColor={getStatusColor(queryState, theme)}
/>{' '}
<Modal
show={isRenameModalOpen}
onHide={() => setIsRenameModalOpen(false)}
title={t('Rename tab')}
onHandledPrimaryAction={handleRenameTab}
primaryButtonName={t('Save')}
disablePrimaryButton={!trimmedTitle}
openerRef={tabHeaderRef}
>
<Input
ref={renameInputRef}
data-test="rename-tab-input"
aria-label={t('Tab name')}
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
onPressEnter={() => {
if (trimmedTitle) {
handleRenameTab();
}
}}
onKeyDown={e => {
// The modal portals over the editable-card tabs; without this, keys
// bubble to their handler and remove, navigate, or activate a tab
// (Space included). Escape and Tab are left to bubble so the Modal
// can close and trap focus.
if (e.key !== 'Escape' && e.key !== 'Tab') {
e.stopPropagation();
}
}}
/>
</Modal>
</TabTitleWrapper>
);
};

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { Column, GridApi } from 'ag-grid-community';
import type { Column, GridApi, IHeaderParams } from 'ag-grid-community';
import { act, fireEvent, render } from 'spec/helpers/testing-library';
import { Header } from './Header';
import { PIVOT_COL_ID } from './constants';
@@ -38,9 +38,70 @@ jest.mock('@superset-ui/core/components/Icons', () => {
};
});
class MockApi extends EventTarget {
class MockColumn {
private colListeners = new Map<string, Set<Function>>();
sortValue: string | null = 'asc';
sortIndexValue: number | null = null;
getColId() {
return '123';
}
isPinnedLeft() {
return true;
}
isPinnedRight() {
return false;
}
isVisible() {
return true;
}
getSort() {
return this.sortValue;
}
getSortIndex() {
return this.sortIndexValue;
}
addEventListener(eventType: string, listener: Function) {
if (!this.colListeners.has(eventType)) {
this.colListeners.set(eventType, new Set());
}
this.colListeners.get(eventType)!.add(listener);
}
removeEventListener(eventType: string, listener: Function) {
this.colListeners.get(eventType)?.delete(listener);
}
triggerEvent(eventType: string) {
this.colListeners.get(eventType)?.forEach(listener => listener({}));
}
}
class MockOtherColumn extends MockColumn {
getColId() {
return 'other-col';
}
}
class MockApi {
mockColumn = new MockColumn();
otherColumn = new MockOtherColumn();
getAllDisplayedColumns() {
return [];
return [this.mockColumn, this.otherColumn];
}
getColumns() {
return [this.mockColumn, this.otherColumn];
}
isDestroyed() {
@@ -48,48 +109,76 @@ class MockApi extends EventTarget {
}
}
const mockApi = new MockApi();
const mockedProps = {
displayName: 'test column',
setSort: jest.fn(),
progressSort: jest.fn(),
enableSorting: true,
column: {
getColId: () => '123',
isPinnedLeft: () => true,
isPinnedRight: () => false,
getSort: () => 'asc',
getSortIndex: () => null,
} as any as Column,
api: new MockApi() as any as GridApi,
};
column: mockApi.mockColumn as any as Column,
api: mockApi as any as GridApi,
} as unknown as IHeaderParams;
test('renders display name for the column', () => {
const { queryByText } = render(<Header {...mockedProps} />);
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
});
test('sorts by clicking a column header', () => {
const { getByText, queryByTestId } = render(<Header {...mockedProps} />);
test('calls progressSort without shiftKey on click', () => {
const { getByText } = render(<Header {...mockedProps} />);
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false);
expect(queryByTestId('mock-sort-asc')).toBeInTheDocument();
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith('desc', false);
expect(queryByTestId('mock-sort-desc')).toBeInTheDocument();
fireEvent.click(getByText(mockedProps.displayName));
expect(mockedProps.setSort).toHaveBeenCalledWith(null, false);
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
expect(mockedProps.progressSort).toHaveBeenCalledWith(false);
});
test('synchronizes the current sort when sortChanged event occurred', async () => {
const { findByTestId } = render(<Header {...mockedProps} />);
test('calls progressSort with shiftKey on shift-click', () => {
const { getByText } = render(<Header {...mockedProps} />);
fireEvent.click(getByText(mockedProps.displayName), { shiftKey: true });
expect(mockedProps.progressSort).toHaveBeenCalledWith(true);
});
test('synchronizes sort icon when columnStateUpdated fires on column', async () => {
const { findByTestId, queryByTestId } = render(<Header {...mockedProps} />);
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
act(() => {
mockedProps.api.dispatchEvent(new Event('sortChanged'));
mockApi.mockColumn.triggerEvent('columnStateUpdated');
});
const sortAsc = await findByTestId('mock-sort-asc');
expect(sortAsc).toBeInTheDocument();
});
test('shows sortIndex label when multi-sort is active', async () => {
const { findByText } = render(<Header {...mockedProps} />);
act(() => {
mockApi.mockColumn.sortIndexValue = 1;
mockApi.otherColumn.sortValue = 'desc';
mockApi.mockColumn.triggerEvent('columnStateUpdated');
});
const label = await findByText('2');
expect(label).toBeInTheDocument();
});
test('hides sortIndex label when multi-sort is cleared', async () => {
const { queryByText } = render(<Header {...mockedProps} />);
act(() => {
mockApi.mockColumn.sortIndexValue = 1;
mockApi.otherColumn.sortValue = 'desc';
mockApi.mockColumn.triggerEvent('columnStateUpdated');
});
act(() => {
mockApi.mockColumn.sortIndexValue = null;
mockApi.otherColumn.sortValue = null;
mockApi.mockColumn.triggerEvent('columnStateUpdated');
});
expect(queryByText('2')).not.toBeInTheDocument();
});
test('disable menu when enableFilterButton is false', () => {
const { queryByText, queryByTestId } = render(
<Header {...mockedProps} enableFilterButton={false} />,
@@ -99,18 +188,39 @@ test('disable menu when enableFilterButton is false', () => {
});
test('hide display name for PIVOT_COL_ID', () => {
const pivotColumn = new MockColumn();
(pivotColumn as any).getColId = () => PIVOT_COL_ID;
const { queryByText } = render(
<Header
{...mockedProps}
column={
{
getColId: () => PIVOT_COL_ID,
isPinnedLeft: () => true,
isPinnedRight: () => false,
getSortIndex: () => null,
} as any as Column
}
/>,
<Header {...mockedProps} column={pivotColumn as any as Column} />,
);
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
});
test('does not attach click handler when enableSorting is false', () => {
const { getByText } = render(
<Header {...mockedProps} enableSorting={false} />,
);
const cell = getByText(mockedProps.displayName).closest(
'.ag-header-cell-label',
);
expect(cell).not.toHaveAttribute('role', 'button');
});
test('does not call progressSort on click when enableSorting is false', () => {
const progressSort = jest.fn();
const { getByText } = render(
<Header {...mockedProps} enableSorting={false} progressSort={progressSort} />,
);
fireEvent.click(getByText(mockedProps.displayName));
expect(progressSort).not.toHaveBeenCalled();
});
test('does not render sort icons when enableSorting is false', () => {
const { queryByTestId } = render(
<Header {...mockedProps} enableSorting={false} />,
);
expect(queryByTestId('mock-sort')).not.toBeInTheDocument();
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
});

View File

@@ -16,32 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
type MouseEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { IHeaderParams, Column, SortDirection } from 'ag-grid-community';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
import type { Column, GridApi } from 'ag-grid-community';
import { Icons } from '@superset-ui/core/components/Icons';
import { PIVOT_COL_ID } from './constants';
import { HeaderMenu } from './HeaderMenu';
interface Params {
enableFilterButton?: boolean;
enableSorting?: boolean;
displayName: string;
column: Column;
api: GridApi;
setSort: (sort: string | null, multiSort: boolean) => void;
}
const SORT_DIRECTION = [null, 'asc', 'desc'];
const HeaderCell = styled.div`
display: flex;
flex: 1;
@@ -87,30 +71,26 @@ const IconPlaceholder = styled.div`
top: 0;
`;
export const Header: React.FC<Params> = ({
export const Header: React.FC<IHeaderParams> = ({
enableFilterButton,
enableSorting,
displayName,
setSort,
progressSort,
column,
api,
}: Params) => {
}: IHeaderParams) => {
const theme = useTheme();
const colId = column.getColId();
const pinnedLeft = column.isPinnedLeft();
const pinnedRight = column.isPinnedRight();
const sortOption = useRef<number>(0);
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
const [currentSort, setCurrentSort] = useState<string | null>(null);
const [currentSort, setCurrentSort] = useState<SortDirection>(null);
const [sortIndex, setSortIndex] = useState<number | null>();
const onSort = useCallback(
(event: MouseEvent) => {
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length;
const sort = SORT_DIRECTION[sortOption.current];
setSort(sort, event.shiftKey);
setCurrentSort(sort);
(event: React.MouseEvent) => {
progressSort(event.shiftKey);
},
[setSort],
[progressSort],
);
const onVisibleChange = useCallback(
(isVisible: boolean) => {
@@ -123,24 +103,22 @@ export const Header: React.FC<Params> = ({
[api],
);
const onSortChanged = useCallback(() => {
const syncSortState = useCallback(() => {
const hasMultiSort = api
.getAllDisplayedColumns()
.some(c => c.getSortIndex());
const updatedSortIndex = column.getSortIndex();
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
.some(c => c.getColId() !== colId && c.getSort() !== null);
setCurrentSort(column.getSort() ?? null);
setSortIndex(hasMultiSort ? updatedSortIndex : null);
}, [api, column]);
setSortIndex(hasMultiSort ? column.getSortIndex() : null);
}, [api, column, colId]);
useEffect(() => {
api.addEventListener('sortChanged', onSortChanged);
column.addEventListener('columnStateUpdated', syncSortState);
return () => {
if (api.isDestroyed()) return;
api.removeEventListener('sortChanged', onSortChanged);
column.removeEventListener('columnStateUpdated', syncSortState);
};
}, [api, onSortChanged]);
}, [column, syncSortState]);
return (
<>

View File

@@ -0,0 +1,277 @@
/**
* 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 { act, render, screen } from 'spec/helpers/testing-library';
import { chat } from 'src/core/chat';
import ChatProvider from './ChatProvider';
import { ChatFloatingHost as ChatHost, ChatPanelHost } from './ChatHost';
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('renders nothing when no chat extension is registered', () => {
render(<ChatHost />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('renders the trigger bubble of the registered chat', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
// The panel stays unmounted until the chat is opened.
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('mounts the panel when the chat opens and unmounts it on close', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => chat.open());
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
// In floating mode the trigger stays mounted alongside the open panel.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('renders the last-registered chat when several are installed', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
);
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
render(<ChatHost />);
// Last-loaded wins: the second registration takes over the singleton slot.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
test('reacts to a chat registering after the initial render', () => {
render(<ChatHost />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
act(() => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
});
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a takeover mounts the incoming chat closed', () => {
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
);
render(<ChatHost />);
act(() => chat.open());
expect(screen.getByText('First Panel')).toBeInTheDocument();
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
});
// The displaced chat's open state must not leak into the winner.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
});
test('ChatPanelHost renders the panel when open in panel mode', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatPanelHost />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
});
test('ChatFloatingHost suppresses the floating panel in panel mode but keeps the trigger', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
// In panel mode the floating panel is suppressed (ChatPanelHost owns that slot).
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
// The trigger stays rendered so the user can reopen after collapsing.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
// Trigger remains visible even when closed — it's the user's only way back.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a crashing panel does not take the trigger down with it', () => {
const FailingPanel = () => {
throw new Error('panel blew up');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <FailingPanel />,
);
render(<ChatHost />);
act(() => chat.open());
// The panel's boundary contains the crash; the trigger keeps rendering so
// the user is not stranded without a way back.
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('isolates a failing trigger so it does not crash the host', () => {
const FailingTrigger = () => {
throw new Error('chat blew up');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatHost />)).not.toThrow();
// The mount slot still renders (the boundary lives inside it), confirming
// the provider was actually exercised and contained.
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('isolates a component that throws during render', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => {
throw new Error('provider blew up');
},
() => <div>Acme Panel</div>,
);
expect(() => render(<ChatHost />)).not.toThrow();
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different chat takes over', () => {
const FailingTrigger = () => {
throw new Error('first chat blew up');
};
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <FailingTrigger />,
() => <div>First Panel</div>,
);
render(<ChatHost />);
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
});
// The boundary is keyed per registration, so the latched crash from the
// first chat does not blank the second one.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different id takes over', () => {
const FailingTrigger = () => {
throw new Error('broken release');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'fixed.chat', name: 'Fixed Chat' },
() => <div>Fixed Bubble</div>,
() => <div>Fixed Panel</div>,
);
jest.restoreAllMocks();
});
// Different id: boundary key changes, latch resets, fix renders.
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
});

View File

@@ -0,0 +1,133 @@
/**
* 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 ComponentType, useRef } 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 { useChat } from '.';
const CHAT_EDGE_MARGIN = 24;
/**
* Returns an onError handler that shows a toast on crash, once per chat id.
*/
function useCrashNotifier(chatId: string | undefined) {
const notifiedFor = useRef<string | undefined>(undefined);
return (error: Error) => {
if (!chatId) return;
logging.error('[chat] provider crashed', error);
if (notifiedFor.current !== chatId) {
notifiedFor.current = chatId;
store.dispatch(addDangerToast(t('The chat failed to load.')));
}
};
}
/**
* Wraps a component in an ErrorBoundary, keyed by chat id so the boundary
* resets when a different chat takes over.
*/
const ChatBoundary = ({
component: Component,
onError,
}: {
component: ComponentType;
onError: (error: Error) => void;
}) => (
<ErrorBoundary showMessage={false} onError={onError}>
<Component />
</ErrorBoundary>
);
/**
* Renders the chat panel content in panel mode. Fills its container height.
*/
export const ChatPanelHost = () => {
const { chat, panel } = useChat();
const onError = useCrashNotifier(chat?.id);
if (!chat || !panel) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
display: flex;
flex-direction: column;
height: 100%;
`}
>
<ChatBoundary key={chat.id} component={panel} onError={onError} />
</div>
);
};
/**
* Renders the chat trigger and, when the panel is open in floating mode, the
* floating panel overlay. The trigger is always visible when a chat is
* registered; the panel overlay is suppressed in panel mode.
*/
export const ChatFloatingHost = () => {
const theme = useTheme();
const { open: panelOpen, mode, chat, trigger, panel } = useChat();
const onError = useCrashNotifier(chat?.id);
if (!chat || !trigger || !panel) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
right: ${CHAT_EDGE_MARGIN}px;
bottom: ${CHAT_EDGE_MARGIN}px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: ${theme.sizeUnit * 2}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
{/*
Separate boundaries so a crashing panel cannot take the trigger down
with it — the trigger is the user's only way back.
*/}
{panelOpen && mode !== 'panel' && (
<ChatBoundary
key={`panel-${chat.id}`}
component={panel}
onError={onError}
/>
)}
<ChatBoundary
key={`trigger-${chat.id}`}
component={trigger}
onError={onError}
/>
</div>
);
};

View File

@@ -0,0 +1,257 @@
/**
* 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 { createElement } from 'react';
import ChatProvider from './ChatProvider';
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('returns the singleton instance', () => {
expect(ChatProvider.getInstance()).toBe(ChatProvider.getInstance());
});
test('getChat returns undefined when no chat is registered', () => {
expect(ChatProvider.getInstance().getChat()).toBeUndefined();
});
test('registerChat sets the registration and returns the descriptor copy', () => {
const provider = ChatProvider.getInstance();
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
const disposable = provider.registerChat(descriptor, trigger, panel);
expect(provider.getChat()).toEqual(descriptor);
disposable.dispose();
});
test('the last-registered chat wins and logs a warning', () => {
const provider = ChatProvider.getInstance();
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.getChat()?.id).toBe('second.chat');
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('second.chat');
expect(warn.mock.calls[0][0]).toContain('first.chat');
warn.mockRestore();
});
test('re-registering with a different id replaces the active chat', () => {
const provider = ChatProvider.getInstance();
jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
expect(provider.getChat()?.id).toBe('first.chat');
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.getChat()?.id).toBe('second.chat');
jest.restoreAllMocks();
});
test('disposing the registration clears it', () => {
const provider = ChatProvider.getInstance();
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
disposable.dispose();
expect(provider.getChat()).toBeUndefined();
});
test('disposing twice fires unregister only once', () => {
const provider = ChatProvider.getInstance();
const unregistered = jest.fn();
provider.onDidUnregisterChat(unregistered);
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
disposable.dispose();
disposable.dispose();
expect(unregistered).toHaveBeenCalledTimes(1);
});
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
const provider = ChatProvider.getInstance();
const registered = jest.fn();
const unregistered = jest.fn();
provider.onDidRegisterChat(registered);
provider.onDidUnregisterChat(unregistered);
const descriptor = { id: 'acme.chat', name: 'Acme' };
const disposable = provider.registerChat(descriptor, trigger, panel);
expect(registered).toHaveBeenCalledWith(descriptor);
expect(unregistered).not.toHaveBeenCalled();
disposable.dispose();
expect(unregistered).toHaveBeenCalledWith(descriptor);
});
test('open and close toggle the panel state', () => {
const provider = ChatProvider.getInstance();
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
provider.open();
expect(provider.isOpen()).toBe(true);
provider.close();
expect(provider.isOpen()).toBe(false);
});
test('open fires once; duplicate open is a no-op', () => {
const provider = ChatProvider.getInstance();
const opened = jest.fn();
provider.onDidOpen(opened);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.open();
expect(opened).toHaveBeenCalledTimes(1);
});
test('close fires once; duplicate close is a no-op', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.close();
provider.close();
expect(closed).toHaveBeenCalledTimes(1);
});
test('open is a no-op when no chat is registered', () => {
const provider = ChatProvider.getInstance();
const opened = jest.fn();
provider.onDidOpen(opened);
provider.open();
expect(provider.isOpen()).toBe(false);
expect(opened).not.toHaveBeenCalled();
});
test('registering a second chat while open closes the panel', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
provider.open();
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
jest.restoreAllMocks();
});
test('disposing the active chat while open closes the panel', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
provider.open();
disposable.dispose();
expect(provider.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('a late registration does not inherit a stale open state', () => {
const provider = ChatProvider.getInstance();
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
provider.open();
disposable.dispose();
provider.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
});
test('getDisplayMode defaults to floating', () => {
expect(ChatProvider.getInstance().getDisplayMode()).toBe('floating');
});
test('setDisplayMode updates mode and fires event only on change', () => {
const provider = ChatProvider.getInstance();
const modeChanged = jest.fn();
provider.onDidChangeDisplayMode(modeChanged);
provider.setDisplayMode('floating');
expect(modeChanged).not.toHaveBeenCalled();
provider.setDisplayMode('panel');
expect(provider.getDisplayMode()).toBe('panel');
expect(modeChanged).toHaveBeenCalledWith('panel');
});
test('state reflects changes after registration and open', () => {
const provider = ChatProvider.getInstance();
expect(provider.getChat()).toBeUndefined();
expect(provider.isOpen()).toBe(false);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
expect(provider.isOpen()).toBe(true);
expect(provider.getChat()?.id).toBe('acme.chat');
});
test('reset clears all state', () => {
const provider = ChatProvider.getInstance();
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.setDisplayMode('panel');
provider.reset();
expect(provider.getChat()).toBeUndefined();
expect(provider.isOpen()).toBe(false);
expect(provider.getDisplayMode()).toBe('floating');
});

View File

@@ -0,0 +1,209 @@
/**
* 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 { ComponentType } from 'react';
import type { chat as chatApi } from '@apache-superset/core';
import {
LocalStorageKeys,
getItem,
setItem,
} from 'src/utils/localStorageHelpers';
import { Disposable } from '../models';
import { createValueEventEmitter, createEventEmitter } from '../utils';
type Chat = chatApi.Chat;
type DisplayMode = chatApi.DisplayMode;
/**
* Singleton manager for the chat provider.
* Handles registration, open/close state, and display mode.
*/
class ChatProvider {
private static instance: ChatProvider;
private chat: Chat | undefined;
private trigger: ComponentType | undefined;
private panel: ComponentType | undefined;
private opened: boolean;
private stateSubscribers = new Set<() => void>();
private registerEmitter = createEventEmitter<Chat>();
private unregisterEmitter = createEventEmitter<Chat>();
private openEmitter = createEventEmitter<void>();
private closeEmitter = createEventEmitter<void>();
private resizePanelEmitter = createEventEmitter<{ width: number }>();
private modeEmitter: ReturnType<typeof createValueEventEmitter<DisplayMode>>;
private constructor() {
const persisted = getItem(LocalStorageKeys.ChatState, {
open: false,
mode: 'floating',
});
const mode = (
persisted.mode === 'panel' ? 'panel' : 'floating'
) as DisplayMode;
this.opened = persisted.open === true;
this.modeEmitter = createValueEventEmitter<DisplayMode>(mode);
}
public static getInstance(): ChatProvider {
if (!ChatProvider.instance) {
ChatProvider.instance = new ChatProvider();
}
return ChatProvider.instance;
}
public subscribe = (listener: () => void): (() => void) => {
this.stateSubscribers.add(listener);
return () => this.stateSubscribers.delete(listener);
};
private notifyState(): void {
setItem(LocalStorageKeys.ChatState, {
open: this.opened,
mode: this.modeEmitter.getCurrent(),
});
this.stateSubscribers.forEach(fn => fn());
}
private closePanel(): void {
this.opened = false;
this.closeEmitter.fire();
}
public registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable {
if (this.chat) {
// eslint-disable-next-line no-console
console.warn(
`[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`,
);
this.unregisterEmitter.fire(this.chat);
if (this.opened) this.closePanel();
}
this.chat = chat;
this.trigger = trigger;
this.panel = panel;
this.registerEmitter.fire(chat);
this.notifyState();
return new Disposable(() => {
if (this.chat !== chat) return;
this.chat = undefined;
this.trigger = undefined;
this.panel = undefined;
this.unregisterEmitter.fire(chat);
if (this.opened) this.closePanel();
this.notifyState();
});
}
public getChat(): Chat | undefined {
return this.chat;
}
public getTrigger(): ComponentType | undefined {
return this.trigger;
}
public getPanel(): ComponentType | undefined {
return this.panel;
}
public open(): void {
if (this.opened || !this.chat) return;
this.opened = true;
this.openEmitter.fire();
this.notifyState();
}
public close(): void {
if (!this.opened || !this.chat) return;
this.closePanel();
this.notifyState();
}
public isOpen(): boolean {
return this.opened;
}
public getDisplayMode(): DisplayMode {
return this.modeEmitter.getCurrent();
}
public setDisplayMode(displayMode: DisplayMode): void {
if (displayMode === this.modeEmitter.getCurrent()) return;
this.modeEmitter.fire(displayMode);
this.notifyState();
}
public get onDidRegisterChat() {
return this.registerEmitter.subscribe;
}
public get onDidUnregisterChat() {
return this.unregisterEmitter.subscribe;
}
public get onDidOpen() {
return this.openEmitter.subscribe;
}
public get onDidClose() {
return this.closeEmitter.subscribe;
}
public get onDidChangeDisplayMode() {
return this.modeEmitter.subscribe;
}
public get onDidResizePanel() {
return this.resizePanelEmitter.subscribe;
}
public reset(): void {
this.chat = undefined;
this.trigger = undefined;
this.panel = undefined;
this.opened = false;
this.registerEmitter = createEventEmitter<Chat>();
this.unregisterEmitter = createEventEmitter<Chat>();
this.openEmitter = createEventEmitter<void>();
this.closeEmitter = createEventEmitter<void>();
this.resizePanelEmitter = createEventEmitter<{ width: number }>();
this.modeEmitter = createValueEventEmitter<DisplayMode>('floating');
this.stateSubscribers.clear();
setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' });
}
}
export default ChatProvider;

View File

@@ -0,0 +1,68 @@
/**
* 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 { createElement } from 'react';
import { chat } from './index';
import ChatProvider from './ChatProvider';
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('getChat returns undefined when no chat is registered', () => {
expect(chat.getChat()).toBeUndefined();
});
test('registerChat makes the chat retrievable via getChat', () => {
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
chat.registerChat(descriptor, trigger, panel);
expect(chat.getChat()).toEqual(descriptor);
});
test('the last-registered chat wins when multiple are registered', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(chat.getChat()?.id).toBe('second.chat');
jest.restoreAllMocks();
});
test('open and close toggle isOpen', () => {
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
expect(chat.isOpen()).toBe(false);
chat.open();
expect(chat.isOpen()).toBe(true);
chat.close();
expect(chat.isOpen()).toBe(false);
});
test('getDisplayMode defaults to floating', () => {
expect(chat.getDisplayMode()).toBe('floating');
});
test('setDisplayMode updates the display mode', () => {
chat.setDisplayMode('panel');
expect(chat.getDisplayMode()).toBe('panel');
});

View 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.
*/
/**
* @fileoverview Host implementation of the `chat` contribution type.
*
* Extensions register via the public `chat.registerChat()` and the host owns
* mounting, open/close state, and the display mode. Only the last-registered
* chat is active at a time.
*
* The public namespace (`chat`) is exposed to extensions on `window.superset`.
* `useChat` is host-internal and NOT part of the public `@apache-superset/core` API.
*/
import { useSyncExternalStore } from 'react';
import memoizeOne from 'memoize-one';
import type { chat as chatApi } from '@apache-superset/core';
import ChatProvider from './ChatProvider';
export { ChatFloatingHost, ChatPanelHost } from './ChatHost';
const provider = ChatProvider.getInstance();
const buildSnapshot = memoizeOne(
(
open: boolean,
mode: chatApi.DisplayMode,
chat: chatApi.Chat | undefined,
trigger: ReturnType<typeof provider.getTrigger>,
panel: ReturnType<typeof provider.getPanel>,
) => ({ open, mode, chat, trigger, panel }),
);
const getSnapshot = () =>
buildSnapshot(
provider.isOpen(),
provider.getDisplayMode(),
provider.getChat(),
provider.getTrigger(),
provider.getPanel(),
);
/**
* Host-internal hook. Returns the current open/mode state and the active chat
* (trigger, panel, descriptor).
*/
export const useChat = () =>
useSyncExternalStore(provider.subscribe, getSnapshot);
export const chat: typeof chatApi = {
registerChat: provider.registerChat.bind(provider),
getChat: provider.getChat.bind(provider),
onDidRegisterChat: provider.onDidRegisterChat,
onDidUnregisterChat: provider.onDidUnregisterChat,
open: provider.open.bind(provider),
close: provider.close.bind(provider),
isOpen: provider.isOpen.bind(provider),
onDidOpen: provider.onDidOpen,
onDidClose: provider.onDidClose,
getDisplayMode: provider.getDisplayMode.bind(provider),
setDisplayMode: provider.setDisplayMode.bind(provider),
onDidChangeDisplayMode: provider.onDidChangeDisplayMode,
// The host fires this from its panel resizer; until that chrome exists the
// event is exposed but never fires.
onDidResizePanel: provider.onDidResizePanel,
};

View File

@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
});
test('handles errors in event listeners gracefully', () => {
const manager = EditorProviders.getInstance();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const errorListener = jest.fn(() => {
throw new Error('Listener error');
});
const successListener = jest.fn();
manager.onDidRegister(errorListener);
manager.onDidRegister(successListener);
manager.registerProvider(createMockEditor(), createMockEditorComponent());
// Both listeners should have been called
expect(errorListener).toHaveBeenCalledTimes(1);
expect(successListener).toHaveBeenCalledTimes(1);
// Error should have been logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error in event listener:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
test('reset clears all providers and language mappings', () => {
const manager = EditorProviders.getInstance();

View File

@@ -19,6 +19,7 @@
import type { editors } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider;
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
/**
* Listener function type for events.
*/
type Listener<T> = (e: T) => void;
/**
* Simple event emitter for editor provider lifecycle events.
*/
class EventEmitter<T> {
private listeners: Set<Listener<T>> = new Set();
/**
* Subscribe to this event.
* @param listener The listener function to call when the event is fired.
* @returns A Disposable to unsubscribe from the event.
*/
subscribe(listener: Listener<T>): Disposable {
this.listeners.add(listener);
return new Disposable(() => {
this.listeners.delete(listener);
});
}
/**
* Fire the event with the given data.
* @param data The event data to pass to listeners.
*/
fire(data: T): void {
this.listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in event listener:', error);
}
});
}
}
/**
* Singleton manager for editor providers.
* Handles registration, resolution, and lifecycle of custom editor implementations.
@@ -83,15 +47,9 @@ class EditorProviders {
*/
private languageToProvider: Map<EditorLanguage, string> = new Map();
/**
* Event emitter for provider registration events.
*/
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
/**
* Event emitter for provider unregistration events.
*/
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set();
@@ -226,8 +184,11 @@ class EditorProviders {
* @param listener The listener function.
* @returns A Disposable to unsubscribe.
*/
public onDidRegister(listener: Listener<EditorRegisteredEvent>): Disposable {
return this.registerEmitter.subscribe(listener);
public onDidRegister(
listener: Listener<EditorRegisteredEvent>,
thisArgs?: unknown,
): Disposable {
return this.registerEmitter.subscribe(listener, thisArgs);
}
/**
@@ -237,8 +198,9 @@ class EditorProviders {
*/
public onDidUnregister(
listener: Listener<EditorUnregisteredEvent>,
thisArgs?: unknown,
): Disposable {
return this.unregisterEmitter.subscribe(listener);
return this.unregisterEmitter.subscribe(listener, thisArgs);
}
/**
@@ -248,6 +210,8 @@ class EditorProviders {
this.providers.clear();
this.languageToProvider.clear();
this.syncListeners.clear();
this.registerEmitter = createEventEmitter<EditorRegisteredEvent>();
this.unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
}
}

View File

@@ -18,130 +18,39 @@
*/
/**
* @fileoverview Implementation of the editors API for Superset.
* @fileoverview Host implementation of the `editors` contribution type.
*
* This module provides the runtime implementation of the editor registration
* and resolution functions declared in the API types.
* Extensions register via the public `editors.registerEditor()` and the host
* resolves the appropriate provider per language, falling back to the built-in
* AceEditorProvider when no extension is registered.
*
* The public namespace (`editors`) is exposed to extensions on `window.superset`.
* `EditorHost` is the host-internal component for rendering editors and is NOT
* part of the public `@apache-superset/core` API.
*/
import { useSyncExternalStore } from 'react';
import { editors as editorsApi } from '@apache-superset/core';
import { Disposable } from '../models';
import EditorProviders from './EditorProviders';
type EditorLanguage = editorsApi.EditorLanguage;
type Editor = editorsApi.Editor;
type EditorProvider = editorsApi.EditorProvider;
type EditorComponent = editorsApi.EditorComponent;
type EditorRegisteredEvent = editorsApi.EditorRegisteredEvent;
type EditorUnregisteredEvent = editorsApi.EditorUnregisteredEvent;
export type { EditorHostProps } from './EditorHost';
export { default as EditorHost } from './EditorHost';
export { default as AceEditorProvider } from './AceEditorProvider';
/**
* Register an editor provider as a module-level side effect.
* Takes the editor descriptor directly rather than looking it up
* from a manifest by ID.
*
* @param editor The editor descriptor.
* @param component The React component implementing the editor.
* @returns A Disposable to unregister the provider.
*/
export const registerEditor = (
editor: Editor,
component: EditorComponent,
): Disposable => {
const providers = EditorProviders.getInstance();
return providers.registerProvider(editor, component);
};
const provider = EditorProviders.getInstance();
/**
* Get the editor provider for a specific language.
* Returns the extension's editor if registered, otherwise undefined.
*
* @param language The language to get an editor for
* @returns The editor provider or undefined if no extension provides one
*/
export const getEditor = (
language: EditorLanguage,
): EditorProvider | undefined => {
const manager = EditorProviders.getInstance();
return manager.getProvider(language);
};
/**
* Check if an extension has registered an editor for a language.
*
* @param language The language to check
* @returns True if an extension provides an editor for this language
*/
export const hasEditor = (language: EditorLanguage): boolean => {
const manager = EditorProviders.getInstance();
return manager.hasProvider(language);
};
/**
* Get all registered editor providers.
*
* @returns Array of all registered editor providers
*/
export const getAllEditors = (): EditorProvider[] => {
const manager = EditorProviders.getInstance();
return manager.getAllProviders();
};
/**
* Event fired when an editor is registered.
* Subscribe to this event to react when extensions register new editors.
*/
export const onDidRegisterEditor = (
listener: (e: EditorRegisteredEvent) => void,
): Disposable => {
const manager = EditorProviders.getInstance();
return manager.onDidRegister(listener);
};
/**
* Event fired when an editor is unregistered.
* Subscribe to this event to react when extensions unregister editors.
*/
export const onDidUnregisterEditor = (
listener: (e: EditorUnregisteredEvent) => void,
): Disposable => {
const manager = EditorProviders.getInstance();
return manager.onDidUnregister(listener);
};
/**
* Hook that returns the editor provider for a specific language and re-renders when it changes.
*
* @param language The language to get an editor for
* @returns The editor provider or undefined if no extension provides one
*/
export const useEditor = (
language: EditorLanguage,
): EditorProvider | undefined => {
const manager = EditorProviders.getInstance();
return useSyncExternalStore(
manager.subscribe,
() => manager.getProvider(language),
export const useEditor = (language: editorsApi.EditorLanguage) =>
useSyncExternalStore(
provider.subscribe,
() => provider.getProvider(language),
() => undefined,
);
};
/**
* Editors API object for use in the extension system.
*/
export const editors: typeof editorsApi = {
registerEditor,
getEditor,
hasEditor,
getAllEditors,
onDidRegisterEditor,
onDidUnregisterEditor,
registerEditor: provider.registerProvider.bind(provider),
getEditor: provider.getProvider.bind(provider),
hasEditor: provider.hasProvider.bind(provider),
getAllEditors: provider.getAllProviders.bind(provider),
onDidRegisterEditor: provider.onDidRegister.bind(provider),
onDidUnregisterEditor: provider.onDidUnregister.bind(provider),
};
export { EditorProviders };
// Component exports
export { default as EditorHost } from './EditorHost';
export type { EditorHostProps } from './EditorHost';
export { default as AceEditorProvider } from './AceEditorProvider';

View File

@@ -27,11 +27,13 @@ export const core: typeof coreType = {
};
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './editors';
export * from './extensions';
export * from './menus';
export * from './models';
export * from './navigation';
export * from './sqlLab';
export * from './utils';
export * from './views';

View File

@@ -27,6 +27,7 @@
import { useSyncExternalStore } from 'react';
import type { menus as menusApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type MenuItem = menusApi.MenuItem;
type Menu = menusApi.Menu;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
const menuCache = new Map<string, Menu | undefined>();
const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -117,16 +118,14 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
thisArgs?: unknown,
): Disposable => registerEmitter.subscribe(listener, thisArgs);
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
(
listener: (e: MenuItemUnregisteredEvent) => void,
thisArgs?: unknown,
): Disposable => unregisterEmitter.subscribe(listener, thisArgs);
export const menus: typeof menusApi = {
registerMenuItem,

View 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.
*/
// Reset module state between tests so currentPage 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('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
// The default pathname ('/') is not enumerated and falls back to home.
expect(navigation.getPage()).toBe('home');
notifyLocationChanged('/superset/welcome/');
expect(navigation.getPage()).toBe('home');
});
test('getPage derives the page from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPage()).toBe('dashboard');
});
test('notifyLocationChanged updates the current page type', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
notifyLocationChanged('/explore/?form_data={}');
expect(navigation.getPage()).toBe('explore');
});
test('notifyLocationChanged fires listeners on page type change', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyLocationChanged('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyLocationChanged('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyLocationChanged('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/sqllab');
expect(navigation.getPage()).toBe('sqllab');
notifyLocationChanged('/explore/');
notifyLocationChanged('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/chart/list/');
expect(navigation.getPage()).toBe('chart_list');
notifyLocationChanged('/dashboard/list/');
expect(navigation.getPage()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/tablemodelview/list/');
expect(navigation.getPage()).toBe('dataset_list');
notifyLocationChanged('/dataset/42');
expect(navigation.getPage()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
notifyLocationChanged('/sqllab/history/');
expect(navigation.getPage()).toBe('query_history');
notifyLocationChanged('/savedqueryview/list/');
expect(navigation.getPage()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/chart/add');
expect(navigation.getPage()).toBe('explore');
});

View File

@@ -0,0 +1,94 @@
/**
* 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 implementation of the `navigation` namespace.
*
* Derives the current {@link Page} from the browser location by matching
* against {@link RoutePaths}. Call {@link useNavigationTracker} once in the
* app shell to keep the page in sync with React Router.
*/
import { useEffect, useRef } from 'react';
import { useLocation, matchPath } from 'react-router-dom';
import type { navigation as navigationApi } from '@apache-superset/core';
import { RoutePaths } from '../../views/routePaths';
import { Disposable } from '../models';
import { createValueEventEmitter } from '../utils';
type Page = navigationApi.Page;
/** Maps route path patterns to their corresponding Page type. */
const PAGE_ROUTES: { path: string; page: Page }[] = [
{ path: RoutePaths.DASHBOARD, page: 'dashboard' },
{ path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
{ path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
{ path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
{ path: RoutePaths.SQLLAB, page: 'sqllab' },
{ path: RoutePaths.CHART_ADD, page: 'explore' },
{ path: RoutePaths.CHART_LIST, page: 'chart_list' },
{ path: RoutePaths.EXPLORE, page: 'explore' },
{ path: RoutePaths.EXPLORE_PERMALINK, page: 'explore' },
{ path: RoutePaths.DATASET_LIST, page: 'dataset_list' },
{ path: RoutePaths.DATASET_ADD, page: 'dataset' },
{ path: RoutePaths.DATASET, page: 'dataset' },
];
function derivePage(pathname: string): Page {
for (const { path, page } of PAGE_ROUTES) {
if (matchPath(pathname, { path, exact: false })) return page;
}
return 'home';
}
const pageEmitter = createValueEventEmitter<Page>(
derivePage(window.location.pathname),
);
/** Updates the current page from a pathname. No-op when the page is unchanged. */
export const notifyLocationChanged = (pathname: string): void => {
const next = derivePage(pathname);
if (next === pageEmitter.getCurrent()) return;
pageEmitter.fire(next);
};
const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (page: Page) => void,
thisArgs?: unknown,
): Disposable => pageEmitter.subscribe(listener, thisArgs);
/** Synchronizes the navigation module with React Router. Call once in the app shell. */
export const useNavigationTracker = () => {
const location = useLocation();
const prevPathname = useRef<string | null>(null);
useEffect(() => {
if (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyLocationChanged(location.pathname);
}
}, [location.pathname]);
};
export const navigation: typeof navigationApi = {
getPage,
onDidChangePage,
};

View File

@@ -48,7 +48,7 @@ import { AnyListenerPredicate } from '@reduxjs/toolkit';
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
import { Database, Disposable } from '../models';
import { createActionListener } from '../utils';
import { createActionListener } from '../storeUtils';
import {
Panel,
Tab,

View File

@@ -0,0 +1,48 @@
/**
* 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 { common as core } from '@apache-superset/core';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
export function createActionListener<V, A = unknown>(
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,
valueParser: (action: A, state: RootState) => V | null | undefined,
thisArgs?: unknown,
): core.Disposable {
const boundListener = thisArgs ? listener.bind(thisArgs as object) : listener;
const unsubscribe = listenerMiddleware.startListening({
predicate,
effect: action => {
const state = store.getState();
// The predicate already ensures the action matches type A at runtime.
const value = valueParser(action as unknown as A, state);
if (value != null) {
boundListener(value);
}
},
});
return {
dispose: () => {
unsubscribe();
},
};
}

View File

@@ -17,33 +17,54 @@
* under the License.
*/
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';
export function createActionListener<V>(
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,
valueParser: (action: AnyAction, state: RootState) => V | null | undefined,
thisArgs?: any,
): core.Disposable {
const boundListener = thisArgs ? listener.bind(thisArgs) : listener;
type Listener<T> = (e: T) => unknown;
const unsubscribe = listenerMiddleware.startListening({
predicate,
effect: (action: AnyAction) => {
const state = store.getState();
const value = valueParser(action, state);
// Skip calling listener if valueParser returns null/undefined
if (value != null) {
boundListener(value);
}
},
});
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
export interface EventEmitter<T> {
/** Notifies every current subscriber with `value`. */
fire(value: T): void;
/** Registers a listener; returns a Disposable that removes it. */
subscribe: core.Event<T>;
}
/** An event emitter that also retains the last fired value. */
export interface ValueEventEmitter<T> extends EventEmitter<T> {
/** Returns the value last passed to {@link fire} (or the initial value). */
getCurrent(): T;
}
/**
* Creates a stateless event emitter. Listeners registered via `event` receive
* every subsequent `fire`; a returned Disposable removes the listener.
*/
export function createEventEmitter<T>(): EventEmitter<T> {
const listeners = new Set<Listener<T>>();
const subscribe: core.Event<T> = (listener, thisArgs) => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return { dispose: () => listeners.delete(bound) };
};
return {
dispose: () => {
unsubscribe();
},
fire: value => listeners.forEach(fn => fn(value)),
subscribe,
};
}
/**
* Creates a value event emitter seeded with `initial`. Behaves like
* {@link createEventEmitter} but also tracks the last fired value, readable
* via `getCurrent` — useful for state that is both observed and queried.
*/
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
const { fire, subscribe } = createEventEmitter<T>();
let current = initial;
return {
fire: value => {
current = value;
fire(value);
},
subscribe,
getCurrent: () => current,
};
}

View File

@@ -24,11 +24,12 @@
* Extensions register views as side effects at import time.
*/
import React, { ReactElement, useSyncExternalStore } from 'react';
import React, { ComponentType, useSyncExternalStore } from 'react';
import type { views as viewsApi } from '@apache-superset/core';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type View = viewsApi.View;
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
@@ -36,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
const viewRegistry: Map<
string,
{ view: View; location: string; provider: () => ReactElement }
{ view: View; location: string; component: ComponentType }
> = new Map();
const locationIndex: Map<string, Set<string>> = new Map();
@@ -47,29 +48,29 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
const viewsCache = new Map<string, View[] | undefined>();
const notifyRegister = (event: ViewRegisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: ViewUnregisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerView: typeof viewsApi.registerView = (
view: View,
location: string,
provider: () => ReactElement,
component: ComponentType,
): Disposable => {
const { id } = view;
viewRegistry.set(id, { view, location, provider });
viewRegistry.set(id, { view, location, component });
const ids = locationIndex.get(location) ?? new Set();
ids.add(id);
@@ -83,12 +84,16 @@ const registerView: typeof viewsApi.registerView = (
});
};
export const resolveView = (id: string): ReactElement => {
const provider = viewRegistry.get(id)?.provider;
if (!provider) {
export const resolveView = (id: string): React.ReactElement => {
const entry = viewRegistry.get(id);
if (!entry) {
return React.createElement(ExtensionPlaceholder, { id });
}
return React.createElement(ErrorBoundary, null, provider());
return React.createElement(
ErrorBoundary,
null,
React.createElement(entry.component),
);
};
const getViews: typeof viewsApi.getViews = (
@@ -116,17 +121,11 @@ export const useViews = (location: string): View[] | undefined =>
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
listener: (e: ViewRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
listener: (e: ViewUnregisteredEvent) => void,
): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
): Disposable => unregisterEmitter.subscribe(listener);
export const views: typeof viewsApi = {
registerView,

View File

@@ -481,7 +481,7 @@ const Chart = (props: ChartProps) => {
(formData as JsonObject).dashboardId = dashboardInfo.id;
const exportTable = useCallback(
(format: string, isFullCSV: boolean, isPivot = false) => {
async (format: string, isFullCSV: boolean, isPivot = false) => {
const logAction =
format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
@@ -556,24 +556,48 @@ const Chart = (props: ChartProps) => {
}
: baseOwnState;
exportChart({
formData:
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
resultType,
resultFormat: format,
force: true,
ownState: exportOwnState,
onStartStreamingExport: shouldUseStreaming
? (exportParams: JsonObject) => {
setIsStreamingModalVisible(true);
startExport({
...(exportParams as Record<string, unknown>),
filename,
expectedRows: actualRowCount,
} as Parameters<typeof startExport>[0]);
}
: null,
});
try {
await exportChart({
formData:
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
resultType,
resultFormat: format,
force: true,
ownState: exportOwnState,
onStartStreamingExport: shouldUseStreaming
? (exportParams: JsonObject) => {
setIsStreamingModalVisible(true);
startExport({
...(exportParams as Record<string, unknown>),
filename,
expectedRows: actualRowCount,
} as Parameters<typeof startExport>[0]);
}
: null,
});
} catch (error) {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
boundActionCreators.addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
boundActionCreators.addDangerToast(errorMessage);
}
}
},
[
sliceSliceId,
@@ -585,6 +609,7 @@ const Chart = (props: ChartProps) => {
chartState,
props.id,
boundActionCreators.logEvent,
boundActionCreators.addDangerToast,
queriesResponse,
startExport,
resetExport,

View File

@@ -42,6 +42,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
import {
@@ -337,7 +338,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// Update document title when dashboard title changes
useEffect(() => {
if (pageTitle) {
document.title = pageTitle;
document.title = sanitizeDocumentTitle(pageTitle);
}
}, [pageTitle]);

View File

@@ -66,6 +66,7 @@ import {
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
} from 'src/logger/LogUtils';
import { getUrlParam } from 'src/utils/urlUtils';
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
import cx from 'classnames';
import * as chartActions from 'src/components/Chart/chartAction';
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
@@ -397,7 +398,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
// Update document title when slice name changes
useEffect(() => {
if (props.sliceName) {
document.title = props.sliceName;
document.title = sanitizeDocumentTitle(props.sliceName);
}
}, [props.sliceName]);

View File

@@ -339,7 +339,34 @@ export const useExploreAdditionalActionsMenu = (
}
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
const exportCSV = useCallback(() => {
const handleExportError = useCallback(
(error: unknown) => {
const exportError = error as Error & {
status?: number;
statusText?: string;
response?: { status?: number };
};
const status = exportError.status || exportError.response?.status;
if (status === 413) {
addDangerToast(
t(
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
),
);
} else {
const errorMessage =
exportError.message ||
exportError.statusText ||
t(
'Failed to export chart data. Please try again or contact your administrator.',
);
addDangerToast(errorMessage);
}
},
[addDangerToast],
);
const exportCSV = useCallback(async () => {
if (!canDownloadCSV) return null;
// Determine row count for streaming threshold check
@@ -378,26 +405,31 @@ export const useExploreAdditionalActionsMenu = (
filename = `${safeChartName}${timestamp}.csv`;
}
return exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'full',
resultFormat: 'csv',
onStartStreamingExport: shouldUseStreaming
? exportParams => {
if (exportParams.url) {
setIsStreamingModalVisible(true);
startExport({
...exportParams,
url: exportParams.url,
filename,
expectedRows: actualRowCount,
exportType: exportParams.exportType as 'csv' | 'xlsx',
});
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'full',
resultFormat: 'csv',
onStartStreamingExport: shouldUseStreaming
? exportParams => {
if (exportParams.url) {
setIsStreamingModalVisible(true);
startExport({
...exportParams,
url: exportParams.url,
filename,
expectedRows: actualRowCount,
exportType: exportParams.exportType as 'csv' | 'xlsx',
});
}
}
}
: null,
});
: null,
});
} catch (error) {
handleExportError(error);
}
return null;
}, [
canDownloadCSV,
latestQueryFormData,
@@ -406,46 +438,59 @@ export const useExploreAdditionalActionsMenu = (
streamingThreshold,
slice,
startExport,
handleExportError,
]);
const exportCSVPivoted = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'post_processed',
resultFormat: 'csv',
})
: null,
[canDownloadCSV, latestQueryFormData, ownState],
);
const exportCSVPivoted = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'post_processed',
resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportJson = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'json',
})
: null,
[canDownloadCSV, latestQueryFormData, ownState],
);
const exportJson = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'json',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const exportExcel = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'xlsx',
})
: null,
[canDownloadCSV, latestQueryFormData, ownState],
);
const exportExcel = useCallback(async () => {
if (!canDownloadCSV) {
return null;
}
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'xlsx',
});
} catch (error) {
handleExportError(error);
}
return null;
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
const copyLink = useCallback(async () => {
try {
@@ -805,7 +850,7 @@ export const useExploreAdditionalActionsMenu = (
label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
onClick: async () => {
// Use 'results' to export the *current view* (as opposed to 'full').
// Pass ownState so client/UI state (e.g., filters) can be respected when supported.
if (
@@ -820,12 +865,16 @@ export const useExploreAdditionalActionsMenu = (
slice?.slice_name || 'current_view',
);
} else {
exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'csv',
});
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
}
setIsDropdownVisible(false);
dispatch(
@@ -1058,6 +1107,7 @@ export const useExploreAdditionalActionsMenu = (
exportCSVPivoted,
exportExcel,
exportJson,
handleExportError,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,

View File

@@ -0,0 +1,150 @@
/**
* 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 { ComponentType } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { useExploreAdditionalActionsMenu } from './index';
import * as exploreUtils from 'src/explore/exploreUtils';
jest.mock('src/explore/exploreUtils', () => ({
__esModule: true,
...jest.requireActual('src/explore/exploreUtils'),
exportChart: jest.fn(),
getChartKey: jest.fn(() => 'test_chart_key'),
}));
const mockExportChart = exploreUtils.exportChart as jest.Mock;
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (component: ComponentType) => component,
useToasts: () => ({
addDangerToast: mockAddDangerToast,
addSuccessToast: jest.fn(),
}),
}));
jest.mock('src/logger/actions', () => ({
logEvent: jest.fn(() => ({ type: 'LOG_EVENT' })),
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
getChartMetadataRegistry: jest.fn(() => ({
get: jest.fn(() => ({ behaviors: ['EXPORT_CURRENT_VIEW'] })),
})),
}));
const defaultProps = {
latestQueryFormData: {
datasource: '1__table',
viz_type: 'pivot_table_v2',
},
canDownloadCSV: true,
slice: { slice_id: 1, slice_name: 'Test Chart' },
ownState: {},
dashboards: [],
onOpenInEditor: jest.fn(),
onOpenPropertiesModal: jest.fn(),
showReportModal: jest.fn(),
setCurrentReportDeleting: jest.fn(),
};
type TestComponentProps = typeof defaultProps;
type HookParams = Parameters<typeof useExploreAdditionalActionsMenu>;
const TestComponent = (props: TestComponentProps) => {
const [menu] = useExploreAdditionalActionsMenu(
props.latestQueryFormData as HookParams[0],
props.canDownloadCSV,
props.slice as HookParams[2],
props.onOpenInEditor,
props.onOpenPropertiesModal,
props.ownState as HookParams[5],
props.dashboards as HookParams[6],
props.showReportModal,
props.setCurrentReportDeleting,
);
return <div>{menu}</div>;
};
beforeEach(() => {
jest.clearAllMocks();
mockExportChart.mockResolvedValue(undefined);
});
test('shows 413 error toast when exportCSV fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to original .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when exportCSVPivoted fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to pivoted .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when Export Current View CSV server path fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(
<TestComponent
{...defaultProps}
latestQueryFormData={{
datasource: '1__table',
viz_type: 'table',
}}
ownState={{}}
/>,
{ useRedux: true },
);
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
userEvent.click(await screen.findByText('Export to .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});

View File

@@ -18,6 +18,11 @@
*/
import { exportChart } from '.';
jest.mock('src/utils/export', () => ({
...jest.requireActual('src/utils/export'),
downloadBlob: jest.fn(),
}));
// Mock pathUtils to control app root prefix
jest.mock('src/utils/pathUtils', () => ({
ensureAppRoot: jest.fn((path: string) => path),
@@ -27,6 +32,7 @@ jest.mock('src/utils/pathUtils', () => ({
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
postBlob: jest.fn(),
postForm: jest.fn(),
get: jest.fn().mockResolvedValue({ json: {} }),
post: jest.fn().mockResolvedValue({ json: {} }),
@@ -41,6 +47,14 @@ jest.mock('@superset-ui/core', () => ({
const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils');
const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core');
const { downloadBlob } = jest.requireMock('src/utils/export');
const mockBlob = new Blob(['test data'], { type: 'text/csv' });
const createMockExportResponse = (headers: Headers = new Headers()) => ({
headers,
blob: jest.fn().mockResolvedValue(mockBlob),
});
// Minimal formData that won't trigger legacy API (useLegacyApi = false)
const baseFormData = {
@@ -113,22 +127,24 @@ test('exportChart v1 API passes nested prefix for deeply nested deployments', as
expect(callArgs.exportType).toBe('xlsx');
});
// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot
// Regression test for the double-prefix bug: SupersetClient.postBlob adds appRoot
// internally via getUrl(), so the URL passed must NOT already be prefixed.
test('exportChart v1 API calls postForm with unprefixed URL when app root is configured', async () => {
test('exportChart v1 API calls postBlob with unprefixed URL when app root is configured', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const appRoot = '/analytics';
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.mock.calls[0];
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postBlob.mock.calls[0];
expect(url).toBe('/api/v1/chart/data');
expect(url).not.toContain(appRoot);
expect(downloadBlob).toHaveBeenCalled();
});
test('exportChart passes csv exportType for CSV exports', async () => {
@@ -240,9 +256,10 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => {
expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
});
test('exportChart legacy API calls postForm with relative URL', async () => {
test('exportChart legacy API calls postBlob with relative URL', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
ensureAppRoot.mockImplementation((path: string) => path);
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
getChartMetadataRegistry.mockReturnValue({
get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
@@ -259,10 +276,11 @@ test('exportChart legacy API calls postForm with relative URL', async () => {
resultType: 'full',
});
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.mock.calls[0];
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postBlob.mock.calls[0];
expect(url).toBe('/superset/explore_json/?csv=true');
expect(url).not.toMatch(/^https?:\/\//);
expect(downloadBlob).toHaveBeenCalled();
});
test('exportChart legacy API includes force param when force=true', async () => {
@@ -289,3 +307,187 @@ test('exportChart legacy API includes force param when force=true', async () =>
const callArgs = onStartStreamingExport.mock.calls[0][0];
expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
});
test('exportChart successfully exports chart as CSV', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
resultType: 'full',
});
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
expect(mockResponse.blob).toHaveBeenCalled();
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringContaining('.csv'),
);
});
test('exportChart successfully exports chart as Excel', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'xlsx',
resultType: 'results',
});
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
expect(mockResponse.blob).toHaveBeenCalled();
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringContaining('.xlsx'),
);
});
test('exportChart throws error with status 413 when payload is too large', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockErrorResponse = new Response('Payload Too Large', {
status: 413,
statusText: 'Payload Too Large',
});
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'csv',
}),
).rejects.toMatchObject({
status: 413,
message: expect.stringContaining('413'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart throws error with status 500 for server errors', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockErrorResponse = new Response('Internal Server Error', {
status: 500,
statusText: 'Internal Server Error',
});
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'json',
}),
).rejects.toMatchObject({
status: 500,
message: expect.stringContaining('500'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart enhances errors without status property', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const genericError = new Error('Network error');
SupersetClient.postBlob.mockRejectedValue(genericError);
await expect(
exportChart({
formData: baseFormData,
resultFormat: 'csv',
}),
).rejects.toMatchObject({
status: 500,
message: expect.stringContaining('Network error'),
});
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart uses streaming export when onStartStreamingExport is provided', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockStreamingHandler = jest.fn();
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
onStartStreamingExport: mockStreamingHandler as unknown as null,
});
expect(mockStreamingHandler).toHaveBeenCalledTimes(1);
expect(mockStreamingHandler).toHaveBeenCalledWith(
expect.objectContaining({
url: '/api/v1/chart/data',
exportType: 'csv',
}),
);
expect(SupersetClient.postBlob).not.toHaveBeenCalled();
expect(downloadBlob).not.toHaveBeenCalled();
});
test('exportChart generates correct filename with timestamp', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse();
SupersetClient.postBlob.mockResolvedValue(mockResponse);
const mockDate = new Date('2025-01-14T12:34:56.789Z');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
expect.stringMatching(
/^chart_export_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.csv$/,
),
);
jest.spyOn(global, 'Date').mockRestore();
});
test('exportChart uses filename from Content-Disposition header', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockResponse = createMockExportResponse(
new Headers({
'Content-Disposition': 'attachment; filename="export.zip"',
}),
);
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(mockBlob, 'export.zip');
});
test('exportChart uses zip extension when Content-Type is application/zip', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const mockDate = new Date('2025-01-14T12:34:56.789Z');
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
const mockResponse = createMockExportResponse(
new Headers({
'Content-Type': 'application/zip',
}),
);
SupersetClient.postBlob.mockResolvedValue(mockResponse);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(downloadBlob).toHaveBeenCalledWith(
mockBlob,
'chart_export_2025-01-14T12-34-56.zip',
);
jest.spyOn(global, 'Date').mockRestore();
});

View File

@@ -34,6 +34,7 @@ import { availableDomains } from 'src/utils/hostNamesConfig';
import { safeStringify } from 'src/utils/safeStringify';
import { optionLabel } from 'src/utils/common';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { downloadBlob, getFilenameFromResponse } from 'src/utils/export';
import { URL_PARAMS } from 'src/constants';
import {
DISABLE_INPUT_OPERATORS,
@@ -398,11 +399,54 @@ export const exportChart = async ({
exportSource: 'chart',
});
} else {
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
// Use AJAX blob download instead of form submission to enable error handling.
// SupersetClient.postBlob calls getUrl({ endpoint }) internally, which prepends
// appRoot — so the URL must NOT be pre-prefixed here.
SupersetClient.postForm(url as string, {
form_data: safeStringify(payload),
});
try {
const response = await SupersetClient.postBlob(url as string, {
form_data: safeStringify(payload),
});
const extension = resultFormat === 'xlsx' ? 'xlsx' : resultFormat;
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, -5);
const fallbackFilename = `chart_export_${timestamp}.${extension}`;
const filename = getFilenameFromResponse(response, fallbackFilename);
const blob = await response.blob();
downloadBlob(blob, filename);
} catch (error) {
if (error instanceof Response) {
const responseError = new Error(
`HTTP ${error.status} ${error.statusText}`,
) as Error & {
status: number;
statusText: string;
response: Response;
};
responseError.status = error.status;
responseError.statusText = error.statusText;
responseError.response = error;
throw responseError;
}
const exportError = error as Error & {
status?: number;
originalError?: unknown;
};
if (!exportError.status) {
const enhancedError = new Error(
exportError.message || 'Export failed',
) as Error & { status: number; originalError: unknown };
enhancedError.status = 500;
enhancedError.originalError = error;
throw enhancedError;
}
throw error;
}
}
};

View File

@@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
version: '1.0.0',
dependencies: [],
remoteEntry: '',
extensionDependencies: [],
...overrides,
};
}

View File

@@ -72,6 +72,7 @@ afterEach(() => {
test('renders without crashing', () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
// Verify the global superset object is set up
expect((window as any).superset).toBeDefined();
expect((window as any).superset.authentication).toBeDefined();
expect((window as any).superset.chat).toBeDefined();
expect((window as any).superset.core).toBeDefined();
expect((window as any).superset.commands).toBeDefined();
expect((window as any).superset.extensions).toBeDefined();
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
test('does not set up global superset object when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
</ExtensionsStartup>,
{
useRedux: true,
useRouter: true,
initialState: mockInitialState,
},
);

View File

@@ -17,41 +17,32 @@
* under the License.
*/
import { useEffect } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
useNavigationTracker,
sqlLab,
views,
} from 'src/core';
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader';
declare global {
interface Window {
superset: {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
sqlLab: typeof sqlLab;
views: typeof views;
};
}
}
import 'src/extensions/Namespaces';
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
useNavigationTracker();
const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId,
);
@@ -59,15 +50,19 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
useEffect(() => {
if (userId == null) return;
// Provide the implementations for @apache-superset/core
// Provide the implementations for @apache-superset/core.
// Namespaces are listed explicitly — do not spread the core package here,
// as that would leak un-contracted symbols onto window.superset.
window.superset = {
...supersetCore,
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
};

View File

@@ -0,0 +1,60 @@
/**
* 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.
*/
/**
* Global `window.superset` type augmentation.
*
* Lives in its own module (rather than inline in ExtensionsStartup) so every
* file that reads or writes `window.superset` — notably ExtensionsLoader —
* sees the type regardless of how files are batched during compilation. Both
* the startup component and the loader import this module for its side effect.
*/
import type {
authentication,
chat,
commands,
core,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
/** The host namespaces exposed to extensions on `window.superset`. */
export interface Namespaces {
authentication: typeof authentication;
core: typeof core;
chat: typeof chat;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
navigation: typeof navigation;
sqlLab: typeof sqlLab;
views: typeof views;
}
declare global {
interface Window {
superset: Namespaces;
}
}

View File

@@ -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 { act, renderHook } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { SupersetClient } from '@superset-ui/core';
import {
useExecuteReportSchedule,
type ExecuteReportResponse,
} from './useExecuteReportSchedule';
const mockExecuteResponse: ExecuteReportResponse = {
execution_id: 'test-uuid-123',
message: 'Report schedule execution started successfully',
};
beforeAll(() => {
SupersetClient.configure().init();
});
afterEach(() => {
fetchMock.clearHistory().removeRoutes();
});
test('successfully executes a report', async () => {
const reportId = 123;
fetchMock.post(
`glob:*/api/v1/report/${reportId}/execute`,
mockExecuteResponse,
);
const { result } = renderHook(() => useExecuteReportSchedule());
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
let executeResult: ExecuteReportResponse | undefined;
await act(async () => {
executeResult = await result.current.executeReport(reportId);
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
expect(executeResult).toEqual(mockExecuteResponse);
expect(
fetchMock.callHistory.calls(`glob:*/api/v1/report/${reportId}/execute`),
).toHaveLength(1);
});
test('handles execution errors', async () => {
const reportId = 123;
const errorMessage = 'Report not found';
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
status: 404,
body: { message: errorMessage },
});
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
try {
await result.current.executeReport(reportId);
} catch {
// Expected to throw
}
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(errorMessage);
});
test('calls success callback on successful execution', async () => {
const reportId = 123;
const onSuccess = jest.fn();
fetchMock.post(
`glob:*/api/v1/report/${reportId}/execute`,
mockExecuteResponse,
);
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
await result.current.executeReport(reportId, onSuccess);
});
expect(onSuccess).toHaveBeenCalledWith(mockExecuteResponse);
});
test('calls error callback on failed execution', async () => {
const reportId = 123;
const onError = jest.fn();
const errorMessage = 'Execution failed';
fetchMock.post(`glob:*/api/v1/report/${reportId}/execute`, {
status: 500,
body: { message: errorMessage },
});
const { result } = renderHook(() => useExecuteReportSchedule());
await act(async () => {
try {
await result.current.executeReport(reportId, undefined, onError);
} catch {
// Expected to throw
}
});
expect(onError).toHaveBeenCalledWith(errorMessage);
});

View File

@@ -0,0 +1,96 @@
/**
* 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 { useState, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
export interface ExecuteReportResponse {
execution_id: string;
message: string;
}
interface ErrorPayload {
message?: string;
}
interface UseExecuteReportScheduleState {
loading: boolean;
error: string | null;
}
export function useExecuteReportSchedule() {
const [state, setState] = useState<UseExecuteReportScheduleState>({
loading: false,
error: null,
});
const executeReport = useCallback(
async (
reportId: number,
onSuccess?: (response: ExecuteReportResponse) => void,
onError?: (error: string) => void,
): Promise<ExecuteReportResponse> => {
setState({ loading: true, error: null });
try {
const response = await SupersetClient.post({
endpoint: `/api/v1/report/${reportId}/execute`,
});
const result = response.json as ExecuteReportResponse;
setState({ loading: false, error: null });
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (error) {
let errorMessage = t('An error occurred while triggering the report');
// SupersetClient rejects non-2xx responses with the raw Response object
if (error instanceof Response) {
try {
const errorJson: ErrorPayload = await error.json();
if (errorJson?.message) {
errorMessage = errorJson.message;
}
} catch {
// JSON parse failed — keep the default message
}
}
setState({ loading: false, error: errorMessage });
if (onError) {
onError(errorMessage);
}
throw error;
}
},
[],
);
return {
executeReport,
loading: state.loading,
error: state.error,
};
}

View File

@@ -243,7 +243,7 @@ test('handles create dashboard button click', async () => {
const createButton = screen.getByRole('button', { name: /dashboard$/i });
await userEvent.click(createButton);
expect(assignMock).toHaveBeenCalledWith('/dashboard/new');
expect(assignMock).toHaveBeenCalledWith('/dashboard/new/');
locationSpy.mockRestore();
});

View File

@@ -203,7 +203,7 @@ function DashboardTable({
name: t('Dashboard'),
buttonStyle: 'secondary',
onClick: () => {
navigateTo('/dashboard/new', { assign: true });
navigateTo('/dashboard/new/', { assign: true });
},
},
{

View File

@@ -57,7 +57,7 @@ const LABELS = {
const REDIRECTS = {
create: {
[WelcomeTable.Charts]: '/chart/add',
[WelcomeTable.Dashboards]: '/dashboard/new',
[WelcomeTable.Dashboards]: '/dashboard/new/',
// navigateTo() applies the application root internally; keep this
// relative so the prefix isn't added twice.
[WelcomeTable.SavedQueries]: '/sqllab?new=true',

View File

@@ -89,7 +89,7 @@ const dropdownItems = [
},
{
label: 'Dashboard',
url: '/dashboard/new',
url: '/dashboard/new/',
icon: 'fa-fw fa-dashboard',
perm: 'can_write',
view: 'Dashboard',
@@ -796,3 +796,111 @@ test('brand link falls back to brand.path when theme brandLogoUrl is absent', as
// ensureAppRoot must have been applied: /welcome/ → /superset/welcome/
expect(brandLink).toHaveAttribute('href', '/superset/welcome/');
});
// --- Active tab highlighting (regression tests for issue #36403) ---
//
// The active top-level tab is highlighted by matching the current route to a
// menu item. The matching must rely on a stable identifier (the FAB `name`),
// not the displayed label, otherwise highlighting breaks for any non-English
// locale where the label is translated.
// Returns the top-level <li> that contains the given visible text, so we can
// assert whether antd marked it as the selected menu item.
const getMenuItemByText = (text: string): HTMLElement | null =>
screen.getByText(text).closest('li');
// Scoped in a describe so the route-resetting afterEach only applies to these
// tests and does not leak into the rest of the file.
describe('active tab highlighting (regression #36403)', () => {
afterEach(() => {
// Reset the route so a pushed path does not leak into the next test.
window.history.pushState({}, '', '/');
});
test('highlights the active top-level tab on a matching route (English)', async () => {
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/dashboard/list/');
render(<Menu {...mockedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('Dashboards');
expect(getMenuItemByText('Dashboards')).toHaveClass(
'ant-menu-item-selected',
);
});
test('highlights the active top-level tab when the label is localized', async () => {
// Russian locale: the FAB `name` stays the stable English identifier while
// the displayed `label` is translated. Highlighting must still work.
const localizedProps = {
...mockedProps,
data: {
...mockedProps.data,
menu: mockedProps.data.menu.map(item =>
item.name === 'Dashboards' ? { ...item, label: 'Дашборды' } : item,
),
},
};
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/dashboard/list/');
render(<Menu {...localizedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('Дашборды');
expect(getMenuItemByText('Дашборды')).toHaveClass('ant-menu-item-selected');
});
test('highlights the active SQL tab when the label is localized', async () => {
// The SQL Lab top-level entry is a FAB category: its stable `name` is
// "SQL Lab" while its label ("SQL") is localized.
const localizedProps = {
...mockedProps,
data: {
...mockedProps.data,
menu: [
...mockedProps.data.menu,
{
name: 'SQL Lab',
icon: 'fa-flask',
label: 'SQL запросы',
childs: [
{
name: 'SQL Editor',
label: 'SQL Lab',
url: '/sqllab/',
index: 1,
},
],
},
],
},
};
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/sqllab/');
render(<Menu {...localizedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
await screen.findByText('SQL запросы');
// SQL Lab renders as a submenu, so antd marks it with the submenu variant.
expect(getMenuItemByText('SQL запросы')).toHaveClass(
'ant-menu-submenu-selected',
);
});
});

View File

@@ -211,6 +211,16 @@ export function Menu({
SavedQueries = '/savedqueryview',
}
// Stable Flask-AppBuilder menu identifiers (`name`), used as menu item keys.
// These are locale-independent, unlike the displayed labels, so matching the
// active tab against them keeps highlighting working in every language.
enum MenuKeys {
Dashboards = 'Dashboards',
Charts = 'Charts',
Datasets = 'Datasets',
SqlLab = 'SQL Lab',
}
const defaultTabSelection: string[] = [];
const [activeTabs, setActiveTabs] = useState(defaultTabSelection);
const location = useLocation();
@@ -218,16 +228,16 @@ export function Menu({
const path = location.pathname;
switch (true) {
case path.startsWith(Paths.Dashboard):
setActiveTabs([t('Dashboards')]);
setActiveTabs([MenuKeys.Dashboards]);
break;
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
setActiveTabs([t('Charts')]);
setActiveTabs([MenuKeys.Charts]);
break;
case path.startsWith(Paths.Datasets):
setActiveTabs([datasetsLabel()]);
setActiveTabs([MenuKeys.Datasets]);
break;
case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries):
setActiveTabs(['SQL']);
setActiveTabs([MenuKeys.SqlLab]);
break;
default:
setActiveTabs(defaultTabSelection);
@@ -242,10 +252,14 @@ export function Menu({
childs,
url,
isFrontendRoute,
name,
}: MenuObjectProps): MenuItem => {
// Key items by the stable FAB `name` so active-tab matching is independent
// of the localized label. Fall back to the label when no name is provided.
const key = name ?? label;
if (url && isFrontendRoute) {
return {
key: label,
key,
label: (
<NavLink role="button" to={url} activeClassName="is-active">
{label}
@@ -256,7 +270,7 @@ export function Menu({
if (url) {
return {
key: label,
key,
label: <Typography.Link href={url}>{label}</Typography.Link>,
};
}
@@ -268,7 +282,11 @@ export function Menu({
} else if (typeof child !== 'string') {
Object.assign(child, { label: t(child.label) });
childItems.push({
key: `${child.label}`,
// Key children by the stable FAB `name` as well, so a child whose
// localized label coincides with a parent key (e.g. the "SQL Editor"
// child labeled "SQL Lab" under the "SQL Lab" category) doesn't
// collide with that parent. Fall back to the label when no name.
key: child.name ?? `${child.label}`,
label: child.isFrontendRoute ? (
<NavLink to={child.url || ''} exact activeClassName="is-active">
{child.label}
@@ -281,7 +299,7 @@ export function Menu({
});
return {
key: label,
key,
label,
...(screens.md && {
icon: <Icons.DownOutlined iconSize="xs" />,

View File

@@ -24,7 +24,7 @@ import {
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { isFeatureEnabled, FeatureFlag, CACHE_KEY } from '@superset-ui/core';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import RightMenu from './RightMenu';
import { GlobalMenuDataOptions, RightMenuProps } from './types';
@@ -105,7 +105,7 @@ const dropdownItems = [
},
{
label: 'Dashboard',
url: '/dashboard/new',
url: '/dashboard/new/',
icon: 'fa-fw fa-dashboard',
perm: 'can_write',
view: 'Dashboard',
@@ -401,17 +401,35 @@ test('Logs out and clears local storage item redux', async () => {
expect(localStorage.getItem('redux')).not.toBeNull();
expect(sessionStorage.getItem('login_attempted')).not.toBeNull();
await userEvent.hover(await screen.findByText(/Settings/i));
// Mock the Cache API so we can assert the namespaced store is purged.
const cacheGlobal = global as unknown as { caches?: CacheStorage };
const priorCaches = cacheGlobal.caches;
const deleteMock = jest.fn().mockResolvedValue(true);
cacheGlobal.caches = { delete: deleteMock } as unknown as CacheStorage;
// Simulate user clicking the logout button
const logoutButton = await screen.findByText('Logout');
await userEvent.click(logoutButton);
try {
await userEvent.hover(await screen.findByText(/Settings/i));
// Wait for local and session storage to be cleared
await waitFor(() => {
expect(localStorage.getItem('redux')).toBeNull();
expect(sessionStorage.getItem('login_attempted')).toBeNull();
});
// Simulate user clicking the logout button
const logoutButton = await screen.findByText('Logout');
await userEvent.click(logoutButton);
// Wait for local and session storage to be cleared
await waitFor(() => {
expect(localStorage.getItem('redux')).toBeNull();
expect(sessionStorage.getItem('login_attempted')).toBeNull();
});
// The namespaced Cache API store is purged on logout.
expect(deleteMock).toHaveBeenCalledWith(CACHE_KEY);
} finally {
// Restore the global so an early assertion failure cannot leak the mock
// into other tests.
if (priorCaches === undefined) {
delete cacheGlobal.caches;
} else {
cacheGlobal.caches = priorCaches;
}
}
});
test('shows logout button when not embedded', async () => {

View File

@@ -28,6 +28,7 @@ import {
getExtensionsRegistry,
isFeatureEnabled,
FeatureFlag,
CACHE_KEY,
} from '@superset-ui/core';
import {
styled,
@@ -232,7 +233,7 @@ const RightMenu = ({
},
{
label: t('Dashboard'),
url: '/dashboard/new',
url: '/dashboard/new/',
icon: (
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
),
@@ -353,6 +354,14 @@ const RightMenu = ({
try {
window.localStorage.removeItem('redux');
window.sessionStorage.removeItem('login_attempted');
// Purge the namespaced Cache API store so cached GET responses are not
// retained on the device after the session ends. Best-effort: the
// returned promise is not awaited since logout navigates away.
if (typeof caches !== 'undefined') {
caches.delete(CACHE_KEY).catch(() => {
/* best-effort: ignore cache deletion failures */
});
}
} catch (error) {
console.warn('Failed to clear storage on logout:', error);
}

View File

@@ -17,15 +17,19 @@
* under the License.
*/
import { SupersetClient } from '@superset-ui/core';
import { render, waitFor } from 'spec/helpers/testing-library';
import { act, render, waitFor } from 'spec/helpers/testing-library';
import SemanticLayerModal from './SemanticLayerModal';
let mockJsonFormsChangeTriggered = false;
let capturedOnChange:
| ((value: { data: Record<string, unknown>; errors?: unknown[] }) => void)
| null = null;
jest.mock('@jsonforms/react', () => ({
...jest.requireActual('@jsonforms/react'),
JsonForms: ({ onChange }: { onChange: (value: unknown) => void }) => {
capturedOnChange = onChange as typeof capturedOnChange;
// eslint-disable-next-line react-hooks/rules-of-hooks
if (!mockJsonFormsChangeTriggered) {
mockJsonFormsChangeTriggered = true;
@@ -62,6 +66,7 @@ const props = {
beforeEach(() => {
mockJsonFormsChangeTriggered = false;
capturedOnChange = null;
jest.useFakeTimers({ advanceTimers: true });
mockedGet.mockReset();
mockedPost.mockReset();
@@ -128,3 +133,95 @@ test('posts configuration schema refresh after debounce', async () => {
});
});
});
// Schema with an external dependency: `schema_name` depends on `database`.
const schemaWithExternalDeps = {
type: 'object',
properties: {
database: {
type: 'string',
'x-dynamic': true,
'x-dependsOn': ['database'],
},
schema_name: {
type: 'string',
'x-dynamic': true,
'x-dependsOn': ['database'],
},
},
};
test('clears dependent field value when parent dependency changes', async () => {
mockedGet.mockReset();
mockedGet
.mockResolvedValueOnce({
json: {
result: [{ id: 'snowflake', name: 'Snowflake', description: '' }],
},
})
.mockResolvedValueOnce({
json: {
result: {
name: 'Layer 1',
type: 'snowflake',
configuration: { database: 'db1' },
},
},
});
mockedPost.mockResolvedValue({ json: { result: schemaWithExternalDeps } });
render(<SemanticLayerModal {...props} />);
// Wait for the initial schema fetch from fetchExistingLayer.
await waitFor(() => expect(mockedPost).toHaveBeenCalledTimes(1));
// Populate schema_name while keeping the same database — no clearing should occur.
await act(async () => {
capturedOnChange!({
data: { database: 'db1', schema_name: 'public' },
errors: [],
});
});
// Change the database — schema_name must be cleared to avoid stale selections.
await act(async () => {
capturedOnChange!({
data: { database: 'db2', schema_name: 'public' },
errors: [],
});
});
jest.advanceTimersByTime(501);
await waitFor(() => {
expect(mockedPost).toHaveBeenCalledTimes(2);
const config = (
mockedPost.mock.calls[1][0] as {
jsonPayload: { configuration: Record<string, unknown> };
}
).jsonPayload.configuration;
expect(config.database).toBe('db2');
// schema_name must not carry over the stale 'public' value.
expect(config.schema_name).not.toBe('public');
});
});
test('cancels pending schema refresh when dependencies become unsatisfied', async () => {
render(<SemanticLayerModal {...props} />);
// Wait for the initial fetchExistingLayer POST.
await waitFor(() => expect(mockedPost).toHaveBeenCalledTimes(1));
// The auto-fire from the mock set a debounce timer (warehouse='wh1' satisfies deps).
// Clear the dependency before the timer fires — the timer must be cancelled.
await act(async () => {
capturedOnChange!({ data: { warehouse: '' }, errors: [] });
});
jest.advanceTimersByTime(501);
await act(async () => {});
// No additional POST should have fired; the cancelled timer must not land.
expect(mockedPost).toHaveBeenCalledTimes(1);
});

View File

@@ -90,6 +90,10 @@ export default function SemanticLayerModal({
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastDepSnapshotRef = useRef<string>('');
const dynamicDepsRef = useRef<Record<string, string[]>>({});
// Tracks the most recent value we auto-populated into the Name field so we
// can overwrite it when the user switches type — but leave alone anything
// the user has hand-edited.
const autoFilledNameRef = useRef<string>('');
const fetchTypes = useCallback(async () => {
setLoading(true);
@@ -209,12 +213,21 @@ export default function SemanticLayerModal({
errorsRef.current = [];
lastDepSnapshotRef.current = '';
dynamicDepsRef.current = {};
autoFilledNameRef.current = '';
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
}
}, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]);
const handleStepAdvance = () => {
if (selectedType) {
// Pre-fill the Name with the type's display name so a user who just
// wants the defaults doesn't have to invent one. Skip the overwrite
// once the user has typed something the auto-fill didn't put there.
const type = types.find(t => t.id === selectedType);
if (type && (name === '' || name === autoFilledNameRef.current)) {
setName(type.name);
autoFilledNameRef.current = type.name;
}
fetchConfigSchema(selectedType);
}
};
@@ -261,8 +274,13 @@ export default function SemanticLayerModal({
}
};
// Edit mode skips the type-picker step. Gating on this prevents the brief
// flash of the Create modal's first step while the existing layer is being
// fetched.
const isTypeStep = step === 'type' && !isEditMode;
const handleSave = () => {
if (step === 'type') {
if (isTypeStep) {
handleStepAdvance();
} else {
// Trigger validation UI and submit only from explicit save action.
@@ -284,13 +302,26 @@ export default function SemanticLayerModal({
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
areDependenciesSatisfied(deps, data, configSchema ?? undefined),
);
if (!hasSatisfiedDeps) return;
if (!hasSatisfiedDeps) {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
setRefreshingSchema(false);
lastDepSnapshotRef.current = '';
return;
}
// Only re-fetch if dependency values actually changed
const snapshot = serializeDependencyValues(dynamicDeps, data);
if (snapshot === lastDepSnapshotRef.current) return;
lastDepSnapshotRef.current = snapshot;
// Flip the loading state immediately so dependent fields are disabled
// through the debounce window — otherwise the user keeps seeing the
// stale options for ~500ms before the request even fires.
setRefreshingSchema(true);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
fetchConfigSchema(selectedType, data);
@@ -307,12 +338,36 @@ export default function SemanticLayerModal({
data: Record<string, unknown>;
errors?: ErrorObject[];
}) => {
setFormData(data);
// When a dependency of a dynamic field changes, clear that field's
// value so we don't carry a stale selection across the refresh (e.g.
// ``schema=PUBLIC`` lingering after the user switches database).
const dynamicDeps = dynamicDepsRef.current;
let nextData = data;
if (Object.keys(dynamicDeps).length > 0) {
const cleared: Record<string, unknown> = {};
for (const [field, deps] of Object.entries(dynamicDeps)) {
// Self-deps don't count — a field shouldn't wipe its own value
// every time the user picks something in it.
const externalDeps = deps.filter(dep => dep !== field);
if (externalDeps.length === 0) continue;
const depsChanged = externalDeps.some(
dep => JSON.stringify(formData[dep]) !== JSON.stringify(data[dep]),
);
if (depsChanged && data[field] !== undefined && data[field] !== '') {
cleared[field] = undefined;
}
}
if (Object.keys(cleared).length > 0) {
nextData = { ...data, ...cleared };
}
}
setFormData(nextData);
errorsRef.current = errors ?? [];
setHasErrors(errorsRef.current.length > 0);
maybeRefreshSchema(data);
maybeRefreshSchema(nextData);
},
[maybeRefreshSchema],
[maybeRefreshSchema, formData],
);
const selectedTypeName =
@@ -320,7 +375,7 @@ export default function SemanticLayerModal({
const title = isEditMode
? t('Edit %s', selectedTypeName || t('Semantic Layer'))
: step === 'type'
: isTypeStep
? t('New Semantic Layer')
: t('Configure %s', selectedTypeName);
@@ -331,18 +386,16 @@ export default function SemanticLayerModal({
onSave={handleSave}
title={title}
icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />}
width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
width={isTypeStep ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
saveDisabled={
step === 'type' ? !selectedType : saving || !name.trim() || hasErrors
}
saveText={
step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')
isTypeStep ? !selectedType : saving || !name.trim() || hasErrors
}
saveText={isTypeStep ? undefined : isEditMode ? t('Save') : t('Create')}
saveLoading={saving}
contentLoading={loading}
>
<ModalContent>
{step === 'type' ? (
{isTypeStep ? (
<ModalFormField label={t('Type')}>
<Select
ariaLabel={t('Semantic layer type')}

View File

@@ -170,12 +170,17 @@ export function areDependenciesSatisfied(
* Renderer for fields marked `x-dynamic` in the JSON Schema.
* Shows a loading spinner inside the input while the schema is being
* refreshed with dynamic values from the backend.
*
* Enum-typed fields (e.g. the Snowflake ``schema`` dropdown) get an explicit
* Antd Select so the ``loading``/``disabled`` props work natively — wrapping
* TextControl with ``inputProps.suffix`` doesn't reach the underlying Select.
*/
function DynamicFieldControl(props: ControlProps) {
const { refreshingSchema, formData: cfgData } = props.config ?? {};
const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
const schema = props.schema as Record<string, unknown>;
const deps = schema?.['x-dependsOn'];
const refreshing =
refreshingSchema &&
!!refreshingSchema &&
Array.isArray(deps) &&
areDependenciesSatisfied(
deps as string[],
@@ -183,6 +188,47 @@ function DynamicFieldControl(props: ControlProps) {
props.rootSchema,
);
const enumValues = Array.isArray(schema.enum)
? (schema.enum as unknown[])
: undefined;
if (enumValues && enumValues.length > 0) {
// Honour ``x-enumNames`` when present so labels can differ from values
// (e.g. MetricFlow's mode picker maps "full" / "cube" to human strings).
const enumNames = Array.isArray(schema['x-enumNames'])
? (schema['x-enumNames'] as unknown[])
: undefined;
// The backend returns these as a set, so order is undefined. Sort by
// label so the dropdown is stable and alphabetised.
const options = enumValues
.map((value, index) => ({
value: value as string | number,
label:
enumNames?.[index] !== undefined
? String(enumNames[index])
: String(value),
}))
.sort((a, b) => a.label.localeCompare(b.label));
const tooltip = (props.uischema?.options as Record<string, unknown>)
?.tooltip as string | undefined;
const placeholder = (props.uischema?.options as Record<string, unknown>)
?.placeholderText as string | undefined;
return (
<Form.Item label={props.label} tooltip={tooltip}>
<Select
value={(props.data as string | number | undefined) ?? undefined}
onChange={value => props.handleChange(props.path, value)}
options={options}
style={{ width: '100%' }}
disabled={!props.enabled || refreshing}
loading={refreshing}
allowClear
placeholder={refreshing ? t('Loading...') : placeholder}
/>
</Form.Item>
);
}
if (!refreshing) {
return TextControl(props);
}
@@ -199,8 +245,12 @@ function DynamicFieldControl(props: ControlProps) {
}
const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
const dynamicFieldEntry = {
// Rank 6 so we beat ``@great-expectations`` ``EnumControl`` (rank 4) — when
// a field is both ``x-dynamic`` and has an ``enum`` (e.g. the Snowflake
// ``schema`` dropdown), the plain EnumControl would otherwise win and
// bypass our loading / dependency-clearing behavior entirely.
tester: rankWith(
3,
6,
and(
isStringControl,
schemaMatches(

View File

@@ -606,6 +606,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
allowClear
autoClearSearchValue
allowNewOptions={!searchAllOptions && creatable !== false}
allowNewOptionsOnPaste={multiSelect && searchAllOptions}
allowSelectAll={!searchAllOptions}
value={multiSelect ? filterState.value || [] : filterState.value}
disabled={isDisabled}

View File

@@ -482,3 +482,70 @@ test('empty API result shows empty state', async () => {
expect(await screen.findByText(/no alerts yet/i)).toBeInTheDocument();
});
test('trigger-now action calls execute API for owned alert', async () => {
fetchMock.post(
'glob:*/api/v1/report/*/execute',
{
execution_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
message: 'Triggered',
},
{ name: 'execute-report' },
);
renderAlertList();
await screen.findByText('Weekly Sales Alert');
// Alert 1 is owned by mockUser (userId: 1) so allowEdit is true,
// meaning the trigger-now button is rendered.
const triggerButtons = screen.getAllByTestId('trigger-now-action');
expect(triggerButtons.length).toBeGreaterThanOrEqual(1);
fireEvent.click(triggerButtons[0]);
// Execute endpoint is called exactly once for the owned alert.
await waitFor(() => {
expect(fetchMock.callHistory.calls('execute-report')).toHaveLength(1);
});
expect(fetchMock.callHistory.calls('execute-report')[0].url).toMatch(
/\/report\/\d+\/execute/,
);
});
test('trigger-now action does not duplicate in-flight requests', async () => {
// Slow down the response so we can fire two rapid clicks before it resolves.
let resolveExecute: (value: unknown) => void;
const pendingExecute = new Promise(resolve => {
resolveExecute = resolve;
});
fetchMock.post(
'glob:*/api/v1/report/*/execute',
() =>
pendingExecute.then(() => ({
execution_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
message: 'Triggered',
})),
{ name: 'execute-report-slow' },
);
renderAlertList();
await screen.findByText('Weekly Sales Alert');
const triggerButtons = screen.getAllByTestId('trigger-now-action');
fireEvent.click(triggerButtons[0]);
fireEvent.click(triggerButtons[0]); // rapid double-click
// Wait for the first (and only) request to be issued.
await waitFor(() => {
expect(fetchMock.callHistory.calls('execute-report-slow')).toHaveLength(1);
});
// Second click must not have triggered a second request.
expect(fetchMock.callHistory.calls('execute-report-slow')).toHaveLength(1);
// Resolve so the test cleanup is clean.
resolveExecute!(undefined);
await waitFor(() => {
expect(fetchMock.callHistory.calls('execute-report-slow')).toHaveLength(1);
});
});

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