mirror of
https://github.com/apache/superset.git
synced 2026-06-28 10:55:36 +00:00
Compare commits
1 Commits
claude/sub
...
chore/ci/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c32284d56a |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -38,7 +38,7 @@
|
||||
|
||||
# Notify translation maintainers of changes to translations
|
||||
|
||||
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
|
||||
/superset/translations/ @sfirke @rusackas
|
||||
|
||||
# Notify PMC members of changes to extension-related files
|
||||
|
||||
|
||||
2
.github/workflows/bump-python-package.yml
vendored
2
.github/workflows/bump-python-package.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
@@ -1,177 +0,0 @@
|
||||
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 3–6 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}"
|
||||
6
.github/workflows/tag-release.yml
vendored
6
.github/workflows/tag-release.yml
vendored
@@ -24,12 +24,6 @@ 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
|
||||
|
||||
30
UPDATING.md
30
UPDATING.md
@@ -24,35 +24,6 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- [39925](https://github.com/apache/superset/pull/39925): URL prefixing for `SUPERSET_APP_ROOT` subdirectory deployments is now handled automatically by helpers in `src/utils/navigationUtils` (`openInNewTab`, `redirect`, `getShareableUrl`, `<AppLink>`). Direct imports of `ensureAppRoot` / `makeUrl` from `src/utils/pathUtils` are forbidden outside `navigationUtils.ts` (enforced by a static-invariant test); contributors writing new code should use the focused helpers instead. No runtime behaviour change for existing callers — all 19 prior call sites have been migrated and four pre-existing double-prefix and missing-prefix bugs are fixed as part of the migration.
|
||||
|
||||
- [39925](https://github.com/apache/superset/pull/39925): `SupersetClient.getUrl()` now strips a single leading application-root segment from the supplied `endpoint` before building the request URL, so a caller that accidentally pre-prefixes its endpoint (for example by wrapping it with `ensureAppRoot` before passing it to the client) no longer produces a doubled `/superset/superset/...` URL under subdirectory deployment. The strip is **single-pass** — a genuine `/superset/superset/<slug>` route is preserved, not collapsed — and **silent** (no console warning); the static-invariant test remains the primary signal for pre-prefixing at the call site, and this runtime strip is a safety net beneath it. Code that intentionally targeted a literal `/<app_root>/<app_root>/...` endpoint through `getUrl` (a configuration that has no legitimate use under the prefixing model) would have its first redundant segment removed.
|
||||
|
||||
- **Breaking — `Superset` view class route prefix removed.** The `Superset` view in `superset/views/core.py` now declares `route_base = ""`, overriding Flask-AppBuilder's auto-derived `/superset` prefix. Routes that previously lived at `/superset/welcome/`, `/superset/dashboard/<id>/`, `/superset/dashboard/p/<key>/`, `/superset/explore/`, etc. now respond at `/welcome/`, `/dashboard/<id>/`, `/dashboard/p/<key>/`, `/explore/`, etc. Under subdirectory deployment (`SUPERSET_APP_ROOT=/superset`) the URLs are unchanged from end-user perspective — `AppRootMiddleware` re-applies the prefix via `SCRIPT_NAME`. Under root deployments, any external integration or bookmark that hard-codes `/superset/<endpoint>/` paths must be updated to drop the prefix. This fixes the doubled `/superset/superset/...` URLs that `url_for` emitted for these endpoints under subdirectory deployment and the related 404s on the routes themselves.
|
||||
|
||||
- **Breaking — Three sibling view classes route prefix removed.** Following the same rationale as the `Superset` class above, `ExplorePermalinkView` (`superset/views/explore.py`), `TagModelView`, and `TaggedObjectsModelView` (`superset/views/tags.py`, `superset/views/all_entities.py`) now mount at the application root rather than a hard-coded `/superset/...`. The user-visible URLs `/superset/explore/p/<key>/`, `/superset/tags/`, and `/superset/all_entities/` are unchanged under subdirectory deployment; under root deployments these views now serve `/explore/p/<key>/`, `/tags/`, and `/all_entities/`, so any external integration or bookmark must drop the `/superset/` prefix. `Dashboard.url` and `Dashboard.get_url` likewise return `/dashboard/<id>/` instead of the prior `/superset/dashboard/<id>/` literal so downstream consumers (DashboardList row hrefs, MCP service `dashboard_url`) emit a single, deployment-correct prefix.
|
||||
|
||||
- **Legacy `/superset/*` path support.** A new outermost WSGI middleware `LegacyPrefixRedirectMiddleware` (`superset/middleware/legacy_prefix_redirect.py`) 308-redirects every enumerated legacy `/superset/<canonical>` path to its post-`route_base=""` canonical location (e.g. `/superset/welcome/` → `/welcome/` under root; → `/superset/welcome/` under `SUPERSET_APP_ROOT=/superset`, because the canonical resolves through `AppRootMiddleware`). Bookmarks, email links, and external integrations survive the route-base collapse for one release cycle. POST against a GET-only canonical returns 410 Gone instead of 308 (308 would 405 on retry). The shim is removed at EOL `5.0.0`, matching the `@deprecated(eol_version="5.0.0")` gate on `Superset.explore` and `Superset.explore_json`.
|
||||
|
||||
- **PWA web app manifest served dynamically.** The PWA manifest is now served at `/pwa-manifest.json` (under `APPLICATION_ROOT`) by a new `PwaManifestView` (`superset/views/pwa_manifest.py`) instead of the static file at `/static/assets/pwa-manifest.json`. The legacy static source at `superset-frontend/src/pwa-manifest.json` has been removed (along with its `webpack.config.js` `CopyPlugin` rule). The new endpoint resolves `APPLICATION_ROOT` and `STATIC_ASSETS_PREFIX` at request time so PWA install works under subdirectory deployments and split static-prefix / app-root deployments (where `STATIC_ASSETS_PREFIX` points to a CDN host while the Superset backend stays under `APPLICATION_ROOT`). The `<link rel="manifest">` href in `superset/templates/superset/spa.html` was updated correspondingly (using a new `application_root_rstrip` template global). Operators with a forked `spa.html` should switch any manifest `<link>` to `{{ application_root_rstrip }}/pwa-manifest.json`.
|
||||
|
||||
- **Hard re-bookmark break — `/superset/sql/<database_id>/`.** SQL Lab moved to its own blueprint at `/sqllab/`. The legacy `/superset/sql/<id>/` shape changed to a query-string form (`/sqllab/?dbid=<id>`); no 1:1 path mapping exists, so `LegacyPrefixRedirectMiddleware` does **not** redirect this route — it passes through and surfaces a 404. Users with bookmarks to `/superset/sql/<id>/` must update them to `/sqllab/?dbid=<id>`.
|
||||
|
||||
- **`SqlaTable.sql_url` query-string format.** `SqlaTable.sql_url` now URL-encodes `table_name` and joins it as a query parameter rather than concatenating a second `?`. Previously, with `Database.sql_url` returning `/sqllab/?dbid=<id>`, the concatenation produced `/sqllab/?dbid=<id>?table_name=<raw>` — a malformed second `?` that broke the query parser. External code that parsed the legacy `<base>?table_name=<raw>` shape now sees properly percent-encoded values (e.g. `/` → `%2F`, ` ` → `+` or `%20`); decode with `urllib.parse.parse_qsl`.
|
||||
|
||||
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
|
||||
### 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.
|
||||
@@ -76,7 +47,6 @@ Deployments that intentionally point webhooks at internal targets (chatops bridg
|
||||
### Impala cancel_query blocks private/internal hosts by default
|
||||
|
||||
The Impala engine spec's `cancel_query` issues an HTTP request from the Superset backend to the host configured on the Impala database connection. That host is now validated before the request: if it resolves to a private/internal IP range, the cancel call is refused and a warning is logged. Operators whose Impala cluster runs on an internal network can opt out by setting `IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS = True` in `superset_config.py`. This mirrors the dataset-import and webhook opt-out flags.
|
||||
|
||||
### Map chart renderer and OpenStreetMap migration behavior
|
||||
|
||||
The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on
|
||||
|
||||
@@ -70,8 +70,6 @@ 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
|
||||
|
||||
@@ -161,7 +161,6 @@ 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.
|
||||
|
||||
@@ -174,24 +173,6 @@ 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/)
|
||||
|
||||
@@ -34,14 +34,15 @@ 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.
|
||||
|
||||
```typescript
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { views } from '@apache-superset/core';
|
||||
import MyPanel from './MyPanel';
|
||||
|
||||
views.registerView(
|
||||
{ id: 'my-extension.main', name: 'My Panel Name' },
|
||||
'sqllab.panels',
|
||||
MyPanel,
|
||||
() => <MyPanel />,
|
||||
);
|
||||
```
|
||||
|
||||
@@ -111,24 +112,6 @@ 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.
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -47,8 +47,6 @@ module.exports = {
|
||||
collapsed: true,
|
||||
items: [
|
||||
'extensions/extension-points/sqllab',
|
||||
'extensions/extension-points/editors',
|
||||
'extensions/extension-points/chat',
|
||||
],
|
||||
},
|
||||
'extensions/development',
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.41",
|
||||
"antd": "^6.4.4",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
|
||||
@@ -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.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==
|
||||
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==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 16.7.27
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ else:
|
||||
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
|
||||
{{- end }}
|
||||
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
||||
|
||||
class CeleryConfig:
|
||||
imports = ("superset.sql_lab", )
|
||||
broker_url = CELERY_REDIS_URL
|
||||
|
||||
@@ -315,7 +315,7 @@ pygeohash==3.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
pygments==2.20.0
|
||||
# via rich
|
||||
pyjwt==2.13.0
|
||||
pyjwt==2.12.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -769,7 +769,7 @@ pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==5.1.2
|
||||
# via apache-superset
|
||||
pyjwt==2.13.0
|
||||
pyjwt==2.12.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
export const DASHBOARD_LIST = '/dashboard/list/';
|
||||
export const CHART_LIST = '/chart/list/';
|
||||
export const WORLD_HEALTH_DASHBOARD = '/dashboard/world_health/';
|
||||
export const SAMPLE_DASHBOARD_1 = '/dashboard/1-sample-dashboard/';
|
||||
export const SUPPORTED_CHARTS_DASHBOARD = '/dashboard/supported_charts_dash/';
|
||||
export const TABBED_DASHBOARD = '/dashboard/tabbed_dash/';
|
||||
export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/';
|
||||
export const SAMPLE_DASHBOARD_1 = '/superset/dashboard/1-sample-dashboard/';
|
||||
export const SUPPORTED_CHARTS_DASHBOARD =
|
||||
'/superset/dashboard/supported_charts_dash/';
|
||||
export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/';
|
||||
export const DATABASE_LIST = '/databaseview/list';
|
||||
|
||||
202
superset-frontend/package-lock.json
generated
202
superset-frontend/package-lock.json
generated
@@ -109,7 +109,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"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.38",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -6326,6 +6326,12 @@
|
||||
"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",
|
||||
@@ -11464,6 +11470,15 @@
|
||||
"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",
|
||||
@@ -11761,6 +11776,12 @@
|
||||
"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",
|
||||
@@ -14940,9 +14961,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"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==",
|
||||
"version": "2.10.37",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -15933,6 +15954,12 @@
|
||||
"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",
|
||||
@@ -17256,6 +17283,12 @@
|
||||
"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",
|
||||
@@ -18584,10 +18617,11 @@
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -21416,6 +21450,12 @@
|
||||
"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",
|
||||
@@ -23220,9 +23260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -26533,21 +26573,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -28336,9 +28361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz",
|
||||
"integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==",
|
||||
"version": "3.24.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.1.tgz",
|
||||
"integrity": "sha512-e9Wj1TtGGOjzE/jtWaUvdFN7RYL3H0keEzH7gwzHbEdFAsmi03RaDVhnATmtFtIRXQUYf944CIQN0jQv+obeNg==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -28346,7 +28371,66 @@
|
||||
"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",
|
||||
@@ -28464,6 +28548,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -39036,6 +39137,12 @@
|
||||
"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",
|
||||
@@ -43585,21 +43692,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -44952,6 +45044,15 @@
|
||||
"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",
|
||||
@@ -45322,6 +45423,15 @@
|
||||
"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",
|
||||
@@ -45501,7 +45611,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"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.38",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
|
||||
@@ -18,14 +18,6 @@
|
||||
"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"
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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.
|
||||
@@ -223,6 +223,8 @@ 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 */
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
* 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, chat) and re-exported here for the manifest schema.
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
@@ -72,8 +71,7 @@ export interface MenuContributions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, editors, and chat)
|
||||
* provided by an extension or module.
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
@@ -84,10 +82,4 @@ 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;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@
|
||||
*/
|
||||
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';
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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>;
|
||||
@@ -30,12 +30,12 @@
|
||||
*
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
||||
* ResultStatsPanel,
|
||||
* () => <ResultStatsPanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { ReactElement } 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 component The React component to render at that location.
|
||||
* @param provider A function that returns the React element to render.
|
||||
* @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,
|
||||
component: ComponentType,
|
||||
provider: () => ReactElement,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
|
||||
@@ -132,26 +132,6 @@ 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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -359,51 +359,6 @@ 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',
|
||||
|
||||
@@ -22,50 +22,21 @@ under the License.
|
||||
[](https://www.npmjs.com/package/@superset-ui/core)
|
||||
[](https://libraries.io/npm/@superset-ui%2Fcore)
|
||||
|
||||
The core package for Apache Superset's frontend. It provides shared utilities,
|
||||
types, and abstractions used across all Superset chart plugins and UI components.
|
||||
|
||||
Key modules include:
|
||||
|
||||
- **query** — Utilities for building queries and calling the Superset API
|
||||
(including `makeApi`)
|
||||
- **number-format** — Number formatting helpers powered by d3-format
|
||||
- **time-format** — Time/date formatting helpers powered by d3-time-format
|
||||
- **connection** — `SupersetClient`, the HTTP client for the Superset REST API
|
||||
- **chart** — Base classes and types for building chart plugins
|
||||
|
||||
> **Note:** i18n utilities (`t`, `tn`, etc.) are no longer part of this package.
|
||||
> They now live in `@apache-superset/core`, imported from
|
||||
> `@apache-superset/core/translation`.
|
||||
Description
|
||||
|
||||
#### Example usage
|
||||
|
||||
```js
|
||||
import { getNumberFormatter, makeApi } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
// Format a number
|
||||
const formatter = getNumberFormatter('.2f');
|
||||
console.log(formatter(1234.5)); // "1234.50"
|
||||
|
||||
// Translate a string
|
||||
console.log(t('Hello %s', 'world'));
|
||||
|
||||
// Call a Superset API endpoint
|
||||
const fetchDashboards = makeApi({
|
||||
method: 'GET',
|
||||
endpoint: '/api/v1/dashboard',
|
||||
});
|
||||
import { xxx } from '@superset-ui/core';
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
`fn(args)`
|
||||
|
||||
- TBD
|
||||
|
||||
### Development
|
||||
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package
|
||||
including babel builds, jest testing, eslint, and prettier.
|
||||
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cd superset-frontend
|
||||
npx jest packages/superset-ui-core
|
||||
```
|
||||
`@data-ui/build-config` is used to manage the build configuration for this package including babel
|
||||
builds, jest testing, eslint, and prettier.
|
||||
|
||||
@@ -109,7 +109,7 @@ export default class ChartClient {
|
||||
(await buildQueryRegistry.get(visType)) ?? (() => formData);
|
||||
const requestConfig: RequestConfig = useLegacyApi
|
||||
? {
|
||||
endpoint: '/explore_json/',
|
||||
endpoint: '/superset/explore_json/',
|
||||
postPayload: {
|
||||
form_data: buildQuery(formData),
|
||||
},
|
||||
@@ -139,7 +139,7 @@ export default class ChartClient {
|
||||
): Promise<Datasource> {
|
||||
return this.client
|
||||
.get({
|
||||
endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
...options,
|
||||
} as RequestConfig)
|
||||
.then(response => response.json as Datasource);
|
||||
|
||||
@@ -262,7 +262,9 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
if (!useLegacyApi && !queryContext.queries) {
|
||||
queryContext = { queries: [queryContext] };
|
||||
}
|
||||
const endpoint = useLegacyApi ? '/explore_json/' : '/api/v1/chart/data';
|
||||
const endpoint = useLegacyApi
|
||||
? '/superset/explore_json/'
|
||||
: '/api/v1/chart/data';
|
||||
|
||||
const requestConfig: RequestConfig = {
|
||||
endpoint,
|
||||
|
||||
@@ -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 { cloneDeep, mergeWith } from 'lodash';
|
||||
import { mergeWith } from 'lodash';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
||||
|
||||
interface SafeMarkdownProps {
|
||||
@@ -85,15 +85,8 @@ export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
) {
|
||||
// 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,
|
||||
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1146,127 +1146,6 @@ 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} />);
|
||||
|
||||
@@ -91,7 +91,6 @@ const Select = forwardRef(
|
||||
className,
|
||||
allowClear,
|
||||
allowNewOptions = false,
|
||||
allowNewOptionsOnPaste = false,
|
||||
allowSelectAll = true,
|
||||
ariaLabel,
|
||||
autoClearSearchValue = false,
|
||||
@@ -693,34 +692,20 @@ const Select = forwardRef(
|
||||
}
|
||||
} else {
|
||||
const token = tokenSeparators.find(token => pastedText.includes(token));
|
||||
const array = token
|
||||
? uniq(
|
||||
pastedText
|
||||
.split(token)
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
: [pastedText.trim()].filter(Boolean);
|
||||
const array = token ? uniq(pastedText.split(token)) : [pastedText];
|
||||
|
||||
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 && keepUnknownValues) {
|
||||
if (!option && allowNewOptions) {
|
||||
const newOption = {
|
||||
label: item,
|
||||
value: item,
|
||||
isNewOption: true,
|
||||
};
|
||||
newOptions.push(newOption);
|
||||
return labelInValue ? { label: item, value: item } : item;
|
||||
}
|
||||
return getPastedTextValue(item);
|
||||
})
|
||||
|
||||
@@ -88,18 +88,6 @@ 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.
|
||||
|
||||
@@ -52,7 +52,6 @@ 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;
|
||||
|
||||
@@ -82,11 +82,7 @@ export default class SupersetClientClass {
|
||||
unauthorizedHandler = undefined,
|
||||
}: ClientConfig = {}) {
|
||||
const url = new URL(`${protocol || 'https:'}//${host || 'localhost'}`);
|
||||
// Strip a trailing slash so the getUrl dedupe comparisons and the final
|
||||
// `${this.appRoot}/${...}` build stay correct regardless of how the root
|
||||
// was supplied. Mirrors normalizeBackendUrlString / AppRootMiddleware /
|
||||
// LegacyPrefixRedirectMiddleware, which all rstrip the root.
|
||||
this.appRoot = appRoot.replace(/\/$/, '');
|
||||
this.appRoot = appRoot;
|
||||
this.host = url.host;
|
||||
this.protocol = url.protocol as Protocol;
|
||||
this.headers = { Accept: 'application/json', ...headers }; // defaulting accept to json
|
||||
@@ -154,26 +150,6 @@ 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);
|
||||
}
|
||||
@@ -300,26 +276,8 @@ export default class SupersetClientClass {
|
||||
const host = inputHost ?? this.host;
|
||||
const cleanHost = host.slice(-1) === '/' ? host.slice(0, -1) : host; // no backslash
|
||||
|
||||
// Strip a single leading appRoot segment so callers that accidentally
|
||||
// pre-prefix their endpoint (e.g. by wrapping with ensureAppRoot before
|
||||
// passing to the client) do not produce a doubled `/superset/superset/...`
|
||||
// URL. Single-pass strip mirrors
|
||||
// `stripAppRoot` in `src/utils/pathUtils` and `normalizeBackendUrlString`
|
||||
// exactly: a genuine `/superset/superset/<slug>` is a legitimate route, not
|
||||
// a double-prefix bug. The L2 static invariant still flags pre-prefixing as
|
||||
// a migration issue; this is the runtime safety net.
|
||||
let cleanEndpoint = endpoint;
|
||||
const root = this.appRoot;
|
||||
if (root) {
|
||||
if (cleanEndpoint === root) {
|
||||
cleanEndpoint = '';
|
||||
} else if (cleanEndpoint.startsWith(`${root}/`)) {
|
||||
cleanEndpoint = cleanEndpoint.slice(root.length);
|
||||
}
|
||||
}
|
||||
|
||||
return `${this.protocol}//${cleanHost}${this.appRoot}/${
|
||||
cleanEndpoint[0] === '/' ? cleanEndpoint.slice(1) : cleanEndpoint
|
||||
endpoint[0] === '/' ? endpoint.slice(1) : endpoint
|
||||
}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,25 +55,24 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
if (parseMethod === 'json-bigint') {
|
||||
const rawData = await response.text();
|
||||
const json = JSONbig.parse(rawData);
|
||||
const decoded = cloneDeepWith(json, (value: any) => {
|
||||
if (
|
||||
value?.isInteger?.() === true &&
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
if (value?.isNaN?.() === false) {
|
||||
return value?.toNumber?.();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const result: JsonResponse = {
|
||||
response,
|
||||
json: decoded,
|
||||
json: cloneDeepWith(json, (value: any) => {
|
||||
if (
|
||||
value?.isInteger?.() === true &&
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
// toFixed() avoids scientific notation, which BigInt() rejects.
|
||||
return BigInt(value.toFixed());
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
if (value?.isNaN?.() === false) {
|
||||
return value?.toNumber?.();
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
return result as ReturnType;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ export { default as callApi } from './callApi';
|
||||
export { default as SupersetClient } from './SupersetClient';
|
||||
export { default as SupersetClientClass } from './SupersetClientClass';
|
||||
|
||||
export { normalizeBackendUrlString } from './normalizeBackendUrls';
|
||||
export type { NormalizeOptions } from './normalizeBackendUrls';
|
||||
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export { default as __hack_reexport_connection } from './types';
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strips the configured application root from a single backend-supplied URL
|
||||
* string so the frontend speaks router-relative paths. Apply it at the few call
|
||||
* sites that surface a router-relative URL from an API response (e.g. a
|
||||
* dataset's `explore_url`) before handing the value to a consumer that
|
||||
* re-prefixes the root — `SupersetClient.getUrl`, `makeUrl`, or a react-router
|
||||
* `<Link>` resolving against the Router `basename`. Without it those consumers
|
||||
* would re-prefix an already-rooted path into `/superset/superset/...`.
|
||||
*
|
||||
* Absolute (`https:`, `ftp:`, `mailto:`, `tel:`) and protocol-relative (`//`)
|
||||
* URLs pass through untouched, so an operator-configured external
|
||||
* `default_endpoint` on a dataset is left alone.
|
||||
*/
|
||||
|
||||
export interface NormalizeOptions {
|
||||
/** Application root to strip. Empty string disables normalisation. */
|
||||
applicationRoot: string;
|
||||
}
|
||||
|
||||
const SAFE_ABSOLUTE_URL_RE = /^(?:https?|ftp|mailto|tel):/i;
|
||||
|
||||
function stripTrailingSlash(root: string): string {
|
||||
return root.endsWith('/') ? root.slice(0, -1) : root;
|
||||
}
|
||||
|
||||
/** Normalise a single router-relative URL string. */
|
||||
export function normalizeBackendUrlString(
|
||||
value: string,
|
||||
options: NormalizeOptions,
|
||||
): string {
|
||||
const root = stripTrailingSlash(options.applicationRoot);
|
||||
if (!root) return value;
|
||||
if (SAFE_ABSOLUTE_URL_RE.test(value)) return value;
|
||||
if (value.startsWith('//')) return value;
|
||||
if (value === root) return '/';
|
||||
if (value.startsWith(`${root}/`)) {
|
||||
return value.slice(root.length);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -152,7 +152,6 @@ export interface SupersetClientInterface extends Pick<
|
||||
| 'get'
|
||||
| 'post'
|
||||
| 'postForm'
|
||||
| 'postBlob'
|
||||
| 'put'
|
||||
| 'request'
|
||||
| 'init'
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function getDatasourceMetadata({
|
||||
}: Params) {
|
||||
return client
|
||||
.get({
|
||||
endpoint: `/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`,
|
||||
...requestConfig,
|
||||
})
|
||||
.then(response => response.json as Datasource);
|
||||
|
||||
@@ -283,22 +283,6 @@ test('lookalike OpenStreetMap hostnames do not receive OSM attribution', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('relative raster tile templates do not receive OSM attribution', () => {
|
||||
// A host-relative template cannot be parsed by `new URL`, so the OSM
|
||||
// hostname check must fall through to "not OSM" rather than throw.
|
||||
const relativeTileUrl = '/local-tiles/{z}/{x}/{y}.png';
|
||||
const style = resolveMapStyle(
|
||||
`tile://${relativeTileUrl}`,
|
||||
'default-style.json',
|
||||
);
|
||||
|
||||
expect(typeof style).toBe('object');
|
||||
if (typeof style !== 'string') {
|
||||
expect(style.sources['osm-raster-tiles'].tiles).toEqual([relativeTileUrl]);
|
||||
expect(style.sources['osm-raster-tiles']).not.toHaveProperty('attribution');
|
||||
}
|
||||
});
|
||||
|
||||
test('style JSON URLs pass through without raster wrapping', () => {
|
||||
const styleUrl = 'https://example.com/styles/custom-style.json';
|
||||
|
||||
|
||||
@@ -88,9 +88,6 @@ type BootstrapData = {
|
||||
};
|
||||
|
||||
export function getBootstrapDataFromDocument(): unknown {
|
||||
/* istanbul ignore if -- a missing document only occurs in SSR/worker
|
||||
contexts, which Jest cannot simulate: jsdom pins `document` as a
|
||||
non-configurable global */
|
||||
if (typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -176,9 +176,7 @@ describe('ChartClient', () => {
|
||||
Promise.reject(new Error('Unexpected all to v1 API')),
|
||||
);
|
||||
|
||||
// post `Superset.route_base = ""`, the legacy endpoint
|
||||
// collapsed from `/superset/explore_json/` to `/explore_json/`.
|
||||
fetchMock.post('glob:*/explore_json/', {
|
||||
fetchMock.post('glob:*/superset/explore_json/', {
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
});
|
||||
@@ -200,10 +198,13 @@ describe('ChartClient', () => {
|
||||
|
||||
describe('.loadDatasource(datasourceKey, options)', () => {
|
||||
test('fetches datasource', () => {
|
||||
fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
});
|
||||
fetchMock.get(
|
||||
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
{
|
||||
field1: 'abc',
|
||||
field2: 'def',
|
||||
},
|
||||
);
|
||||
|
||||
return expect(chartClient.loadDatasource('1__table')).resolves.toEqual({
|
||||
field1: 'abc',
|
||||
@@ -263,10 +264,13 @@ describe('ChartClient', () => {
|
||||
color: 'living-coral',
|
||||
});
|
||||
|
||||
fetchMock.get('glob:*/fetch_datasource_metadata?datasourceKey=1__table', {
|
||||
name: 'transactions',
|
||||
schema: 'staging',
|
||||
});
|
||||
fetchMock.get(
|
||||
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
{
|
||||
name: 'transactions',
|
||||
schema: 'staging',
|
||||
},
|
||||
);
|
||||
|
||||
fetchMock.post('glob:*/api/v1/chart/data', {
|
||||
lorem: 'ipsum',
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { defaultSchema } from 'rehype-sanitize';
|
||||
import {
|
||||
getOverrideHtmlSchema,
|
||||
SafeMarkdown,
|
||||
@@ -53,36 +51,6 @@ 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', () => {
|
||||
|
||||
@@ -36,13 +36,12 @@ describe('SupersetClient', () => {
|
||||
getUrl: (...args: unknown[]) => string;
|
||||
};
|
||||
|
||||
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||
test('exposes configure, init, get, post, postForm, 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');
|
||||
@@ -54,12 +53,11 @@ describe('SupersetClient', () => {
|
||||
expect(typeof SupersetClient.reAuthenticate).toBe('function');
|
||||
});
|
||||
|
||||
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||
test('throws if you call init, get, post, postForm, 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();
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClientClass } from '@superset-ui/core';
|
||||
|
||||
// SupersetClient is expected to apply the configured appRoot exactly once.
|
||||
// Callers must pass router-relative endpoints; pre-prefixing causes the
|
||||
// double-prefix bug documented below.
|
||||
|
||||
describe('SupersetClient applies the application root exactly once', () => {
|
||||
const buildClient = () =>
|
||||
new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
appRoot: '/superset',
|
||||
});
|
||||
|
||||
test('endpoint without leading slash is concatenated correctly', () => {
|
||||
expect(buildClient().getUrl({ endpoint: 'api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
test('endpoint with leading slash is normalised to a single root segment', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
// A trailing slash on the configured appRoot is stripped at construction
|
||||
// (SupersetClientClass `appRoot.replace(/\/$/, '')`). Without it, a root of
|
||||
// '/superset/' produced 'https://host/superset//foo', and the dedupe block's
|
||||
// `startsWith('/superset//')` check silently failed to dedupe a pre-prefixed
|
||||
// endpoint. This pins both behaviours against regression.
|
||||
test('trailing-slash appRoot is normalised to a single root segment', () => {
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
appRoot: '/superset/',
|
||||
});
|
||||
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
// and a pre-prefixed endpoint is still deduped, not doubled
|
||||
expect(client.getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
// Runtime safety net: if a caller pre-prefixes the endpoint (e.g. by wrapping
|
||||
// with ensureAppRoot before calling), getUrl strips the duplicate. The L2
|
||||
// static invariant still flags the pattern at the call site — this guards
|
||||
// against the bug reaching production if the static check is bypassed.
|
||||
test('dedupes a leading application-root segment from a pre-prefixed endpoint', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
|
||||
'https://config_host/superset/api/v1/chart',
|
||||
);
|
||||
});
|
||||
|
||||
// Single-pass strip preserves a legitimate `/superset/superset/<slug>`
|
||||
// route. Backend-supplied router-relative URLs are stripped of the root at
|
||||
// the call sites that surface them (via `normalizeBackendUrlString`) before
|
||||
// any re-prefixing helper sees them, so a doubled leading segment reaching
|
||||
// `getUrl` is a real route, not a double-prefix bug. This pin guards against
|
||||
// silent regression to a greedy strip.
|
||||
test('strips exactly one application-root segment (single-pass)', () => {
|
||||
expect(
|
||||
buildClient().getUrl({ endpoint: '/superset/superset/api/v1/chart' }),
|
||||
).toBe('https://config_host/superset/superset/api/v1/chart');
|
||||
expect(
|
||||
buildClient().getUrl({
|
||||
endpoint: '/superset/superset/superset/api/v1/chart',
|
||||
}),
|
||||
).toBe('https://config_host/superset/superset/superset/api/v1/chart');
|
||||
});
|
||||
|
||||
test('dedupe is segment-boundary aware — `/supersetfoo` is not a prefix match', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/supersetfoo/x' })).toBe(
|
||||
'https://config_host/superset/supersetfoo/x',
|
||||
);
|
||||
});
|
||||
|
||||
test('dedupes the bare application root to an empty endpoint', () => {
|
||||
expect(buildClient().getUrl({ endpoint: '/superset' })).toBe(
|
||||
'https://config_host/superset/',
|
||||
);
|
||||
});
|
||||
|
||||
test('empty application root produces no prefix segment', () => {
|
||||
const client = new SupersetClientClass({
|
||||
protocol: 'https:',
|
||||
host: 'config_host',
|
||||
});
|
||||
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
|
||||
'https://config_host/api/v1/chart',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -780,75 +780,4 @@ 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { normalizeBackendUrlString } from '../../src/connection/normalizeBackendUrls';
|
||||
|
||||
const PREFIX = '/superset';
|
||||
|
||||
describe('normalizeBackendUrlString', () => {
|
||||
test('strips application root from a router-relative path', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset/explore/?slice_id=1', {
|
||||
applicationRoot: PREFIX,
|
||||
}),
|
||||
).toBe('/explore/?slice_id=1');
|
||||
});
|
||||
|
||||
test('strips a value that equals the application root exactly', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset', { applicationRoot: PREFIX }),
|
||||
).toBe('/');
|
||||
});
|
||||
|
||||
test('tolerates a trailing slash on applicationRoot', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset/foo', {
|
||||
applicationRoot: '/superset/',
|
||||
}),
|
||||
).toBe('/foo');
|
||||
});
|
||||
|
||||
// The negative cases below prove the helper is conservative: it doesn't
|
||||
// mutate external URLs or path segments that merely share text with the root.
|
||||
test('passes absolute URLs through unchanged', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('https://external.example.com/superset/foo', {
|
||||
applicationRoot: PREFIX,
|
||||
}),
|
||||
).toBe('https://external.example.com/superset/foo');
|
||||
});
|
||||
|
||||
test('passes protocol-relative URLs through unchanged', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('//cdn.example.com/superset/foo', {
|
||||
applicationRoot: PREFIX,
|
||||
}),
|
||||
).toBe('//cdn.example.com/superset/foo');
|
||||
});
|
||||
|
||||
test('does not strip a similar-but-different prefix segment', () => {
|
||||
// /superset-public/... shares text with /superset but is a different path
|
||||
// segment. Only /superset followed by / or end-of-string counts.
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset-public/explore/?slice_id=1', {
|
||||
applicationRoot: PREFIX,
|
||||
}),
|
||||
).toBe('/superset-public/explore/?slice_id=1');
|
||||
});
|
||||
|
||||
test('is a no-op when application root is empty', () => {
|
||||
expect(
|
||||
normalizeBackendUrlString('/superset/explore/?slice_id=1', {
|
||||
applicationRoot: '',
|
||||
}),
|
||||
).toBe('/superset/explore/?slice_id=1');
|
||||
});
|
||||
|
||||
test('is idempotent: normalize(normalize(x)) === normalize(x)', () => {
|
||||
const once = normalizeBackendUrlString('/superset/explore/?id=1', {
|
||||
applicationRoot: PREFIX,
|
||||
});
|
||||
const twice = normalizeBackendUrlString(once, { applicationRoot: PREFIX });
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
@@ -35,10 +35,8 @@ describe('getFormData()', () => {
|
||||
field2: 'def',
|
||||
};
|
||||
|
||||
// post-`route_base=""`, the legacy endpoint collapsed
|
||||
// from `/superset/fetch_datasource_metadata` to `/fetch_datasource_metadata`.
|
||||
fetchMock.get(
|
||||
'glob:*/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
'glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table',
|
||||
mockData,
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class DashboardPage {
|
||||
* @param slug - The dashboard slug (e.g., 'world_health')
|
||||
*/
|
||||
async gotoBySlug(slug: string): Promise<void> {
|
||||
await gotoWithRetry(this.page, `dashboard/${slug}/`);
|
||||
await gotoWithRetry(this.page, `superset/dashboard/${slug}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ export class DashboardPage {
|
||||
* @param id - The dashboard ID
|
||||
*/
|
||||
async gotoById(id: number): Promise<void> {
|
||||
await gotoWithRetry(this.page, `dashboard/${id}/`);
|
||||
await gotoWithRetry(this.page, `superset/dashboard/${id}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,5 +35,5 @@ export const URL = {
|
||||
LOGIN: 'login/',
|
||||
SAVED_QUERIES_LIST: 'savedqueryview/list/',
|
||||
SQLLAB: 'sqllab',
|
||||
WELCOME: 'welcome/',
|
||||
WELCOME: 'superset/welcome/',
|
||||
} as const;
|
||||
|
||||
@@ -305,16 +305,36 @@ 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);
|
||||
.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);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,15 @@ 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();
|
||||
@@ -148,36 +157,244 @@ afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
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.
|
||||
test('sets up mouseover and mouseout handlers on countries', () => {
|
||||
WorldMap(container, baseProps);
|
||||
|
||||
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(geographyConfig?.highlightOnHover).toBe(true);
|
||||
expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
|
||||
const onCalls = mockSvg.on.mock.calls;
|
||||
|
||||
const hoverHandlers = mockSvg.on.mock.calls.filter((call: [string]) =>
|
||||
/^mouse(over|out)/.test(call[0]),
|
||||
// Find mouseover and mouseout handler registrations (namespaced events)
|
||||
const hasMouseover = onCalls.some(
|
||||
call => call[0] === 'mouseover.fillPreserve',
|
||||
);
|
||||
expect(hoverHandlers).toEqual([]);
|
||||
const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve');
|
||||
|
||||
expect(hasMouseover).toBe(true);
|
||||
expect(hasMouseout).toBe(true);
|
||||
});
|
||||
|
||||
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 });
|
||||
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);
|
||||
|
||||
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(geographyConfig?.highlightOnHover).toBe(false);
|
||||
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);
|
||||
});
|
||||
|
||||
test('does not throw error when onContextMenu is undefined', () => {
|
||||
|
||||
@@ -90,13 +90,6 @@ 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');
|
||||
@@ -164,14 +157,15 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
metrics.concat(percentMetrics),
|
||||
getMetricLabel,
|
||||
);
|
||||
contributionPostProcessing = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
||||
postProcessing = [
|
||||
{
|
||||
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)) {
|
||||
@@ -664,13 +658,7 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
// 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]
|
||||
: [],
|
||||
post_processing: [],
|
||||
order_desc: undefined, // we don't need orderby stuff here,
|
||||
orderby: undefined, // because this query will be used for get total aggregation.
|
||||
});
|
||||
|
||||
@@ -852,75 +852,6 @@ 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', () => {
|
||||
|
||||
@@ -231,56 +231,6 @@ describe('BigNumberTotal transformProps', () => {
|
||||
expect(result.headerFormatter(500)).toBe('$500');
|
||||
});
|
||||
|
||||
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
|
||||
const { parseMetricValue } = jest.requireMock('../utils');
|
||||
parseMetricValue.mockReturnValueOnce(null);
|
||||
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ value: 'some-varchar-result' }],
|
||||
coltypes: [GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBe('some-varchar-result');
|
||||
});
|
||||
|
||||
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
|
||||
const { parseMetricValue } = jest.requireMock('../utils');
|
||||
parseMetricValue.mockReturnValueOnce(null);
|
||||
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ value: '123' }],
|
||||
coltypes: [GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBe('123');
|
||||
});
|
||||
|
||||
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
|
||||
// Override the getColorFormatters mock to return specific value
|
||||
const mockFormatters = [{ formatter: 'red' }];
|
||||
|
||||
@@ -79,15 +79,8 @@ export default function transformProps(
|
||||
const formattedSubtitleFontSize = subtitle?.trim()
|
||||
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
|
||||
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
|
||||
const rawValue = data.length === 0 ? null : data[0][metricName];
|
||||
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
|
||||
|
||||
const bigNumber =
|
||||
parsedValue === null &&
|
||||
typeof rawValue === 'string' &&
|
||||
rawValue.trim() !== ''
|
||||
? rawValue
|
||||
: parsedValue;
|
||||
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
|
||||
|
||||
let metricEntry: Metric | undefined;
|
||||
if (chartProps.datasource?.metrics) {
|
||||
|
||||
@@ -189,10 +189,8 @@ function BigNumberVis({
|
||||
text = t('No data');
|
||||
} else if (typeof bigNumber === 'number') {
|
||||
text = headerFormatter(bigNumber);
|
||||
} else if (typeof bigNumber === 'string') {
|
||||
text = bigNumber;
|
||||
} else {
|
||||
// For boolean/Date values, convert to number if possible, else show as string
|
||||
// For string/boolean/Date values, convert to number if possible, else show as string
|
||||
const numValue = Number(bigNumber);
|
||||
text = Number.isNaN(numValue)
|
||||
? String(bigNumber)
|
||||
|
||||
@@ -318,25 +318,14 @@ 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 => {
|
||||
const item = control as CustomControlItem;
|
||||
if (item?.name) {
|
||||
item.name = `${item.name}${controlSuffix}`;
|
||||
row.forEach((control: CustomControlItem) => {
|
||||
if (control?.name) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
control.name = `${control.name}${controlSuffix}`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -331,16 +331,10 @@ export default function transformProps(
|
||||
type: legendType,
|
||||
});
|
||||
|
||||
const chartPadding = getChartPadding(
|
||||
showLegend,
|
||||
legendOrientation,
|
||||
effectiveLegendMargin,
|
||||
);
|
||||
|
||||
const series: RadarSeriesOption[] = [
|
||||
{
|
||||
type: 'radar',
|
||||
...chartPadding,
|
||||
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
|
||||
animation: false,
|
||||
emphasis: {
|
||||
label: {
|
||||
@@ -367,15 +361,6 @@ export default function transformProps(
|
||||
numberFormatter,
|
||||
);
|
||||
|
||||
const centerX = width
|
||||
? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100
|
||||
: 50;
|
||||
const centerY = height
|
||||
? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100
|
||||
: 50;
|
||||
|
||||
const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`];
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
@@ -405,7 +390,6 @@ export default function transformProps(
|
||||
color: theme.colorSplit,
|
||||
},
|
||||
},
|
||||
center: radarCenter,
|
||||
splitArea: {
|
||||
show: true,
|
||||
areaStyle: {
|
||||
|
||||
@@ -92,20 +92,6 @@ 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',
|
||||
|
||||
@@ -186,7 +186,6 @@ export default function transformProps(
|
||||
showLabels,
|
||||
showLabelsThreshold,
|
||||
showTotal,
|
||||
showNullValues,
|
||||
sliceId,
|
||||
} = formData;
|
||||
const {
|
||||
@@ -252,7 +251,6 @@ export default function transformProps(
|
||||
columnLabels,
|
||||
metricLabel,
|
||||
secondaryMetricLabel,
|
||||
!showNullValues,
|
||||
);
|
||||
const totalValue = treeData.reduce(
|
||||
(result, treeNode) => result + treeNode.value,
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from '../../../../spec/helpers/testing-library';
|
||||
import { AxisType } from '@superset-ui/core';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
LegendOrientation,
|
||||
@@ -203,15 +202,11 @@ const defaultProps: TimeseriesChartTransformedProps = {
|
||||
onFocusedSeries: jest.fn(),
|
||||
};
|
||||
|
||||
function getLatestEchartProps() {
|
||||
function getLatestHeight() {
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
return props;
|
||||
}
|
||||
|
||||
function getLatestHeight() {
|
||||
return getLatestEchartProps().height;
|
||||
return props.height;
|
||||
}
|
||||
|
||||
test('observes extra control height changes when ResizeObserver is available', async () => {
|
||||
@@ -340,7 +335,6 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: ['Product A', 100], // X-axis value is 'Product A'
|
||||
name: 'Product A',
|
||||
@@ -367,149 +361,6 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
||||
}
|
||||
});
|
||||
|
||||
test('emits cross-filter on category value for horizontal bar clicks', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [100, 'Product A'],
|
||||
name: 'Product A',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses rendered categorical axis for query event handlers', () => {
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'xAxis.category',
|
||||
);
|
||||
|
||||
cleanup();
|
||||
mockEchart.mockReset();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
|
||||
'yAxis.category',
|
||||
);
|
||||
});
|
||||
|
||||
test('emits cross-filter from horizontal categorical axis label clicks', () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
formData={{
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
}}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const labelClickHandler =
|
||||
getLatestEchartProps().queryEventHandlers?.[0].handler;
|
||||
expect(labelClickHandler).toBeDefined();
|
||||
labelClickHandler?.({
|
||||
value: 'Product A',
|
||||
} as ECElementEvent);
|
||||
|
||||
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMaskMock}
|
||||
xAxis={{
|
||||
label: 'category_column',
|
||||
type: AxisType.Category,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clickHandler = getLatestEchartProps().eventHandlers?.click;
|
||||
expect(clickHandler).toBeDefined();
|
||||
clickHandler?.({
|
||||
componentType: 'xAxis',
|
||||
name: 'Product A',
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
@@ -534,7 +385,6 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales',
|
||||
data: [1609459200000, 100], // Timestamp
|
||||
name: '2021-01-01',
|
||||
@@ -557,10 +407,6 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
setDataMask: setDataMaskMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
},
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
@@ -577,7 +423,6 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
@@ -612,10 +457,6 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
onContextMenu: onContextMenuMock,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
orientation: OrientationType.Horizontal,
|
||||
},
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
@@ -633,7 +474,6 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
if (contextMenuHandler) {
|
||||
await contextMenuHandler({
|
||||
componentType: 'series',
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
DTTM_ALIAS,
|
||||
BinaryQueryObjectFilterClause,
|
||||
@@ -27,15 +27,12 @@ import {
|
||||
LegendState,
|
||||
ensureIsArray,
|
||||
} from '@superset-ui/core';
|
||||
import type {
|
||||
ECElementEvent,
|
||||
ViewRootGroup,
|
||||
} from 'echarts/types/src/util/types';
|
||||
import type { ViewRootGroup } from 'echarts/types/src/util/types';
|
||||
import type GlobalModel from 'echarts/types/src/model/Global';
|
||||
import type ComponentModel from 'echarts/types/src/model/Component';
|
||||
import { EchartsHandler, EventHandlers } from '../types';
|
||||
import Echart from '../components/Echart';
|
||||
import { OrientationType, TimeseriesChartTransformedProps } from './types';
|
||||
import { TimeseriesChartTransformedProps } from './types';
|
||||
import { formatSeriesName } from '../utils/series';
|
||||
import { ExtraControls } from '../components/ExtraControls';
|
||||
|
||||
@@ -221,26 +218,6 @@ export default function EchartsTimeseries({
|
||||
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
|
||||
const canCrossFilterByXAxis =
|
||||
!hasDimensions && xAxis.type === AxisType.Category;
|
||||
const categoryAxisValueIndex =
|
||||
formData.orientation === OrientationType.Horizontal ? 1 : 0;
|
||||
const getCategoryAxisValue = useCallback(
|
||||
(data: unknown, name: unknown) => {
|
||||
if (Array.isArray(data)) {
|
||||
const categoryAxisValue = data[categoryAxisValueIndex];
|
||||
if (
|
||||
typeof categoryAxisValue === 'string' ||
|
||||
typeof categoryAxisValue === 'number'
|
||||
) {
|
||||
return categoryAxisValue;
|
||||
}
|
||||
}
|
||||
if (typeof name === 'string' || typeof name === 'number') {
|
||||
return name;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[categoryAxisValueIndex],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
click: props => {
|
||||
@@ -257,15 +234,12 @@ export default function EchartsTimeseries({
|
||||
// Cross-filter by dimension (original behavior)
|
||||
const { seriesName: name } = props;
|
||||
handleChange(name);
|
||||
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
props.data,
|
||||
props.name,
|
||||
);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
handleXAxisChange(categoryAxisValue);
|
||||
}
|
||||
} else if (canCrossFilterByXAxis && props.name != null) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334).
|
||||
// Use `name` (the category-axis value) instead of `data[0]`: for
|
||||
// horizontal bars the data tuple is value-first, so `data[0]` would
|
||||
// be the metric value rather than the category (issue #41102).
|
||||
handleXAxisChange(props.name);
|
||||
}
|
||||
}, TIMER_DURATION);
|
||||
},
|
||||
@@ -347,17 +321,10 @@ export default function EchartsTimeseries({
|
||||
let crossFilter;
|
||||
if (hasDimensions) {
|
||||
crossFilter = getCrossFilterDataMask(seriesName);
|
||||
} else if (
|
||||
canCrossFilterByXAxis &&
|
||||
eventParams.componentType === 'series'
|
||||
) {
|
||||
const categoryAxisValue = getCategoryAxisValue(
|
||||
data,
|
||||
eventParams.name,
|
||||
);
|
||||
if (categoryAxisValue !== undefined) {
|
||||
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
|
||||
}
|
||||
} else if (canCrossFilterByXAxis && eventParams.name != null) {
|
||||
// Use `name` (the category-axis value), not `data[0]`, so horizontal
|
||||
// bars cross-filter on the category and not the metric (issue #41102).
|
||||
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
|
||||
}
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
@@ -369,33 +336,6 @@ export default function EchartsTimeseries({
|
||||
},
|
||||
};
|
||||
|
||||
const handleXAxisLabelClick = useCallback(
|
||||
(event: ECElementEvent) => {
|
||||
const { value } = event;
|
||||
if (
|
||||
canCrossFilterByXAxis &&
|
||||
(typeof value === 'string' || typeof value === 'number')
|
||||
) {
|
||||
handleXAxisChange(value);
|
||||
}
|
||||
},
|
||||
[canCrossFilterByXAxis, handleXAxisChange],
|
||||
);
|
||||
|
||||
const categoryAxis =
|
||||
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
|
||||
|
||||
const queryEventHandlers = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'click',
|
||||
query: `${categoryAxis}.category`,
|
||||
handler: handleXAxisLabelClick,
|
||||
},
|
||||
],
|
||||
[categoryAxis, handleXAxisLabelClick],
|
||||
);
|
||||
|
||||
const zrEventHandlers: EventHandlers = {
|
||||
dblclick: params => {
|
||||
// clear single click timer
|
||||
@@ -437,7 +377,6 @@ export default function EchartsTimeseries({
|
||||
width={width}
|
||||
echartOptions={echartOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
queryEventHandlers={queryEventHandlers}
|
||||
zrEventHandlers={zrEventHandlers}
|
||||
selectedValues={selectedValues}
|
||||
vizType={formData.vizType}
|
||||
|
||||
@@ -82,11 +82,6 @@ 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,
|
||||
@@ -97,7 +92,6 @@ 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
|
||||
|
||||
@@ -381,15 +381,6 @@ 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
|
||||
@@ -487,7 +478,7 @@ export default function transformProps(
|
||||
colorScaleKey,
|
||||
{
|
||||
area,
|
||||
connectNulls: derivedSeries || timeCompareFullRange,
|
||||
connectNulls: derivedSeries,
|
||||
filterState,
|
||||
seriesContexts,
|
||||
markerEnabled,
|
||||
@@ -898,10 +889,6 @@ export default function transformProps(
|
||||
name: xAxisTitle,
|
||||
nameGap: convertInteger(xAxisTitleMargin),
|
||||
nameLocation: 'middle',
|
||||
...(xAxisType === AxisType.Category &&
|
||||
groupBy.length === 0 && {
|
||||
triggerEvent: true,
|
||||
}),
|
||||
axisLabel: {
|
||||
// When rotation is applied on time axes, hideOverlap can
|
||||
// aggressively hide the last label. Rotated labels already
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
import Echart from './Echart';
|
||||
import type { EchartsProps } from '../types';
|
||||
|
||||
type Handler = (params: unknown) => void;
|
||||
type Listener = {
|
||||
query?: string;
|
||||
handler: Handler;
|
||||
};
|
||||
|
||||
const listeners: Record<string, Listener[]> = {};
|
||||
|
||||
const mockChart = {
|
||||
dispatchAction: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
getOption: jest.fn(() => ({})),
|
||||
getZr: jest.fn(() => ({
|
||||
off: jest.fn(),
|
||||
on: jest.fn(),
|
||||
})),
|
||||
off: jest.fn((name: string, handler?: Handler) => {
|
||||
if (!handler) {
|
||||
delete listeners[name];
|
||||
return;
|
||||
}
|
||||
listeners[name] = (listeners[name] || []).filter(
|
||||
listener => listener.handler !== handler,
|
||||
);
|
||||
}),
|
||||
on: jest.fn(
|
||||
(name: string, queryOrHandler: string | Handler, handler?: Handler) => {
|
||||
listeners[name] = listeners[name] || [];
|
||||
listeners[name].push(
|
||||
handler
|
||||
? { query: queryOrHandler as string, handler }
|
||||
: { handler: queryOrHandler as Handler },
|
||||
);
|
||||
},
|
||||
),
|
||||
resize: jest.fn(),
|
||||
setOption: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('echarts/core', () => ({
|
||||
init: jest.fn(() => mockChart),
|
||||
registerLocale: jest.fn(),
|
||||
use: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('echarts/charts', () => ({
|
||||
BarChart: 'BarChart',
|
||||
BoxplotChart: 'BoxplotChart',
|
||||
CustomChart: 'CustomChart',
|
||||
FunnelChart: 'FunnelChart',
|
||||
GaugeChart: 'GaugeChart',
|
||||
GraphChart: 'GraphChart',
|
||||
HeatmapChart: 'HeatmapChart',
|
||||
LineChart: 'LineChart',
|
||||
PieChart: 'PieChart',
|
||||
RadarChart: 'RadarChart',
|
||||
SankeyChart: 'SankeyChart',
|
||||
ScatterChart: 'ScatterChart',
|
||||
SunburstChart: 'SunburstChart',
|
||||
TreeChart: 'TreeChart',
|
||||
TreemapChart: 'TreemapChart',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/components', () => ({
|
||||
AriaComponent: 'AriaComponent',
|
||||
DataZoomComponent: 'DataZoomComponent',
|
||||
GraphicComponent: 'GraphicComponent',
|
||||
GridComponent: 'GridComponent',
|
||||
LegendComponent: 'LegendComponent',
|
||||
MarkAreaComponent: 'MarkAreaComponent',
|
||||
MarkLineComponent: 'MarkLineComponent',
|
||||
TitleComponent: 'TitleComponent',
|
||||
ToolboxComponent: 'ToolboxComponent',
|
||||
TooltipComponent: 'TooltipComponent',
|
||||
VisualMapComponent: 'VisualMapComponent',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/features', () => ({
|
||||
LabelLayout: 'LabelLayout',
|
||||
}));
|
||||
|
||||
jest.mock('echarts/renderers', () => ({
|
||||
CanvasRenderer: 'CanvasRenderer',
|
||||
}));
|
||||
|
||||
const initialState = {
|
||||
common: {
|
||||
locale: 'en',
|
||||
},
|
||||
dashboardState: {
|
||||
isRefreshing: false,
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps: EchartsProps = {
|
||||
echartOptions: { series: [] } as EChartsCoreOption,
|
||||
height: 100,
|
||||
refs: {},
|
||||
width: 100,
|
||||
};
|
||||
|
||||
const renderEchart = (props: Partial<EchartsProps> = {}) => (
|
||||
<Echart {...defaultProps} {...props} />
|
||||
);
|
||||
|
||||
const trigger = (name: string) => {
|
||||
(listeners[name] || []).forEach(listener => listener.handler({}));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(listeners).forEach(name => {
|
||||
delete listeners[name];
|
||||
});
|
||||
Object.values(mockChart).forEach(value => {
|
||||
if (jest.isMockFunction(value)) {
|
||||
value.mockClear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('replaces stale query event handlers without clearing regular event handlers', async () => {
|
||||
const regularClickHandler = jest.fn();
|
||||
const firstQueryHandler = jest.fn();
|
||||
const secondQueryHandler = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [
|
||||
{
|
||||
handler: firstQueryHandler,
|
||||
name: 'click',
|
||||
query: 'xAxis.category',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ initialState, useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.on).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'xAxis.category',
|
||||
firstQueryHandler,
|
||||
),
|
||||
);
|
||||
|
||||
rerender(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [
|
||||
{
|
||||
handler: secondQueryHandler,
|
||||
name: 'click',
|
||||
query: 'xAxis.category',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.on).toHaveBeenCalledWith(
|
||||
'click',
|
||||
'xAxis.category',
|
||||
secondQueryHandler,
|
||||
),
|
||||
);
|
||||
|
||||
trigger('click');
|
||||
|
||||
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||
expect(secondQueryHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
regularClickHandler.mockClear();
|
||||
secondQueryHandler.mockClear();
|
||||
|
||||
rerender(
|
||||
renderEchart({
|
||||
eventHandlers: {
|
||||
click: regularClickHandler,
|
||||
},
|
||||
queryEventHandlers: [],
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockChart.off).toHaveBeenCalledWith('click', secondQueryHandler),
|
||||
);
|
||||
|
||||
trigger('click');
|
||||
|
||||
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||
expect(secondQueryHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -64,12 +64,7 @@ import {
|
||||
MarkLineComponent,
|
||||
} from 'echarts/components';
|
||||
import { LabelLayout } from 'echarts/features';
|
||||
import {
|
||||
EchartsHandler,
|
||||
EchartsProps,
|
||||
EchartsStylesProps,
|
||||
QueryEventHandlers,
|
||||
} from '../types';
|
||||
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
|
||||
import { DEFAULT_LOCALE } from '../constants';
|
||||
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
|
||||
|
||||
@@ -137,7 +132,6 @@ function Echart(
|
||||
height,
|
||||
echartOptions,
|
||||
eventHandlers,
|
||||
queryEventHandlers,
|
||||
zrEventHandlers,
|
||||
selectedValues = {},
|
||||
refs,
|
||||
@@ -153,7 +147,6 @@ function Echart(
|
||||
}
|
||||
const [didMount, setDidMount] = useState(false);
|
||||
const chartRef = useRef<EChartsType>();
|
||||
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
|
||||
const currentSelection = useMemo(
|
||||
() => Object.keys(selectedValues) || [],
|
||||
[selectedValues],
|
||||
@@ -203,19 +196,11 @@ function Echart(
|
||||
|
||||
useEffect(() => {
|
||||
if (didMount) {
|
||||
previousQueryEventHandlers.current.forEach(({ name, handler }) => {
|
||||
chartRef.current?.off(name, handler);
|
||||
});
|
||||
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
|
||||
chartRef.current?.off(name);
|
||||
chartRef.current?.on(name, handler);
|
||||
});
|
||||
|
||||
(queryEventHandlers || []).forEach(({ name, query, handler }) => {
|
||||
chartRef.current?.on(name, query, handler);
|
||||
});
|
||||
previousQueryEventHandlers.current = queryEventHandlers || [];
|
||||
|
||||
Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
|
||||
chartRef.current?.getZr().off(name);
|
||||
chartRef.current?.getZr().on(name, handler);
|
||||
@@ -351,15 +336,7 @@ function Echart(
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||
}, [
|
||||
didMount,
|
||||
echartOptions,
|
||||
eventHandlers,
|
||||
queryEventHandlers,
|
||||
zrEventHandlers,
|
||||
theme,
|
||||
vizType,
|
||||
]);
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
||||
|
||||
// Clear tooltip on refresh start to avoid stale content (#39247)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import type { EChartsCoreOption, EChartsType } from 'echarts/core';
|
||||
import type { TooltipMarker } from 'echarts/types/src/util/format';
|
||||
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||
import { StackControlsValue } from './constants';
|
||||
|
||||
export type EchartsStylesProps = {
|
||||
@@ -52,7 +51,6 @@ export interface EchartsProps {
|
||||
width: number;
|
||||
echartOptions: EChartsCoreOption;
|
||||
eventHandlers?: EventHandlers;
|
||||
queryEventHandlers?: QueryEventHandlers;
|
||||
zrEventHandlers?: EventHandlers;
|
||||
selectedValues?: Record<number, string>;
|
||||
forceClear?: boolean;
|
||||
@@ -107,12 +105,6 @@ export type LegendFormData = {
|
||||
|
||||
export type EventHandlers = Record<string, { (props: any): void }>;
|
||||
|
||||
export type QueryEventHandlers = {
|
||||
name: string;
|
||||
query: string;
|
||||
handler: (props: ECElementEvent) => void;
|
||||
}[];
|
||||
|
||||
export enum LabelPositionEnum {
|
||||
Top = 'top',
|
||||
Left = 'left',
|
||||
|
||||
@@ -36,7 +36,6 @@ export function treeBuilder(
|
||||
groupBy: string[],
|
||||
metric: string,
|
||||
secondaryMetric?: string,
|
||||
filterNullNames?: boolean,
|
||||
): TreeNode[] {
|
||||
const [curGroupBy, ...restGroupby] = groupBy;
|
||||
const curData = _groupBy(data, curGroupBy);
|
||||
@@ -64,7 +63,6 @@ export function treeBuilder(
|
||||
restGroupby,
|
||||
metric,
|
||||
secondaryMetric,
|
||||
filterNullNames,
|
||||
);
|
||||
const metricValue = children.reduce(
|
||||
(prev, cur) => prev + (cur.value as number),
|
||||
@@ -76,12 +74,9 @@ export function treeBuilder(
|
||||
0,
|
||||
)
|
||||
: metricValue;
|
||||
const validChildren = filterNullNames
|
||||
? children.filter(child => child.name !== null)
|
||||
: children;
|
||||
result.push({
|
||||
name,
|
||||
children: validChildren,
|
||||
children,
|
||||
value: metricValue,
|
||||
secondaryValue,
|
||||
groupBy: curGroupBy,
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
EchartsRadarChartProps,
|
||||
EchartsRadarFormData,
|
||||
} from '../../src/Radar/types';
|
||||
import { LegendOrientation } from '../../src/types';
|
||||
|
||||
interface RadarIndicator {
|
||||
name: string;
|
||||
@@ -203,58 +202,3 @@ describe('legend sorting', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('radar center positioning', () => {
|
||||
const getCenter = (overrides: Partial<EchartsRadarFormData> = {}) => {
|
||||
const props = new ChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
showLegend: true,
|
||||
legendMargin: 100,
|
||||
...overrides,
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
});
|
||||
const result = transformProps(props as EchartsRadarChartProps);
|
||||
const { center } = result.echartOptions.radar as {
|
||||
center: [string, string];
|
||||
};
|
||||
return {
|
||||
x: parseFloat(center[0]),
|
||||
y: parseFloat(center[1]),
|
||||
};
|
||||
};
|
||||
|
||||
test('keeps the center when the legend is hidden', () => {
|
||||
const { x, y } = getCenter({ showLegend: false });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center right (away from the legend) when legend is on the left', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Left });
|
||||
expect(x).toBeGreaterThan(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center left (away from the legend) when legend is on the right', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Right });
|
||||
expect(x).toBeLessThan(50);
|
||||
expect(y).toBe(50);
|
||||
});
|
||||
|
||||
test('shifts the center down (away from the legend) when legend is on the top', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Top });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
test('shifts the center up (away from the legend) when legend is on the bottom', () => {
|
||||
const { x, y } = getCenter({ legendOrientation: LegendOrientation.Bottom });
|
||||
expect(x).toBe(50);
|
||||
expect(y).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1564,13 +1564,9 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const xAxis = echartOptions.xAxis as {
|
||||
triggerEvent?: boolean;
|
||||
type: string;
|
||||
};
|
||||
const xAxis = echartOptions.xAxis as { type: string };
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Category);
|
||||
expect(xAxis.triggerEvent).toBe(true);
|
||||
});
|
||||
|
||||
test('temporal x coltype wires the time formatter and Time axis', () => {
|
||||
|
||||
@@ -271,244 +271,4 @@ 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -86,13 +86,6 @@ 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');
|
||||
@@ -144,6 +137,12 @@ 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,
|
||||
@@ -163,14 +162,23 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
getMetricLabel,
|
||||
);
|
||||
|
||||
contributionPostProcessing = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||
},
|
||||
};
|
||||
postProcessing.push(contributionPostProcessing);
|
||||
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}`),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add the operator for the time comparison if some is selected
|
||||
@@ -349,13 +357,7 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
columns: [],
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
// 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]
|
||||
: [],
|
||||
post_processing: [],
|
||||
order_desc: undefined,
|
||||
orderby: undefined,
|
||||
});
|
||||
|
||||
@@ -236,83 +236,6 @@ 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', () => {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// 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();
|
||||
});
|
||||
@@ -59,33 +59,6 @@ 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;
|
||||
@@ -118,15 +91,6 @@ 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);
|
||||
|
||||
@@ -141,22 +105,8 @@ const Legend = ({
|
||||
}
|
||||
|
||||
const categories = Object.entries(categoriesObject).map(([k, v]) => {
|
||||
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',
|
||||
};
|
||||
const style = { color: `rgba(${v.color?.join(', ')})` };
|
||||
const icon = v.enabled ? '\u25FC' : '\u25FB';
|
||||
|
||||
return (
|
||||
<li key={k}>
|
||||
@@ -172,7 +122,7 @@ const Legend = ({
|
||||
showSingleCategory(k);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={swatchStyle} /> {formatCategoryLabel(k)}
|
||||
<span style={style}>{icon}</span> {formatCategoryLabel(k)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
@@ -187,7 +137,7 @@ const Legend = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledLegend style={style}>
|
||||
<StyledLegend className="dupa" style={style}>
|
||||
<ul>{categories}</ul>
|
||||
</StyledLegend>
|
||||
);
|
||||
|
||||
@@ -16,13 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||
import {
|
||||
getColorBreakpointsBuckets,
|
||||
getBreakPoints,
|
||||
getBuckets,
|
||||
BucketsWithColorScale,
|
||||
} from './utils';
|
||||
import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
|
||||
import { ColorBreakpointType } from './types';
|
||||
|
||||
describe('getColorBreakpointsBuckets', () => {
|
||||
@@ -494,42 +488,3 @@ 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+)?$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,15 +200,8 @@ export function getBuckets(
|
||||
string,
|
||||
{ color: Color | undefined; enabled: boolean }
|
||||
> = {};
|
||||
const lastBucketIndex = breakPoints.length - 2;
|
||||
breakPoints.slice(1).forEach((_, i) => {
|
||||
// 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 range = `${breakPoints[i]} - ${breakPoints[i + 1]}`;
|
||||
const mid =
|
||||
0.5 * (parseFloat(breakPoints[i]) + parseFloat(breakPoints[i + 1]));
|
||||
// fix polygon doesn't show
|
||||
|
||||
@@ -632,35 +632,6 @@ 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
|
||||
*/
|
||||
@@ -695,22 +666,6 @@ 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}', {
|
||||
@@ -751,23 +706,22 @@ function main() {
|
||||
if (files.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('No files to check.');
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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`);
|
||||
|
||||
@@ -786,5 +740,4 @@ module.exports = {
|
||||
checkNoFaIcons,
|
||||
checkI18nTemplates,
|
||||
checkUntranslatedStrings,
|
||||
checkTypeScriptOnlySource,
|
||||
};
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join, relative, resolve, sep } from 'path';
|
||||
|
||||
const DEFAULT_ROOTS = ['src', 'packages/superset-ui-core/src'];
|
||||
|
||||
const ALWAYS_SKIP_SEGMENTS = new Set([
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'coverage',
|
||||
'__mocks__',
|
||||
'cypress-base',
|
||||
'playwright',
|
||||
]);
|
||||
|
||||
const ALWAYS_SKIP_SUFFIXES = [
|
||||
'.test.ts',
|
||||
'.test.tsx',
|
||||
'.stories.ts',
|
||||
'.stories.tsx',
|
||||
];
|
||||
|
||||
const SOURCE_EXTENSIONS = ['.ts', '.tsx'];
|
||||
|
||||
export interface ScanOptions {
|
||||
/** Workspace-relative directories to scan. Defaults to the source tree. */
|
||||
roots?: string[];
|
||||
/** Extra path segments to skip on top of {@link ALWAYS_SKIP_SEGMENTS}. */
|
||||
ignoreSegments?: string[];
|
||||
/** Regex run against each line of each file. */
|
||||
pattern: RegExp;
|
||||
/** Workspace-relative paths (forward slashes) exempt from this scan. */
|
||||
allowlist?: string[];
|
||||
}
|
||||
|
||||
export interface ScanHit {
|
||||
/** Workspace-relative path with forward slashes. */
|
||||
file: string;
|
||||
/** 1-based line number. */
|
||||
line: number;
|
||||
/** The text of the matching line, trimmed. */
|
||||
text: string;
|
||||
/** The substring captured by `pattern`. */
|
||||
match: string;
|
||||
}
|
||||
|
||||
// __dirname resolves to <workspace>/spec/helpers regardless of cwd.
|
||||
const WORKSPACE_ROOT = resolve(__dirname, '..', '..');
|
||||
|
||||
function isSourceFile(name: string): boolean {
|
||||
return (
|
||||
SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) &&
|
||||
!ALWAYS_SKIP_SUFFIXES.some(suffix => name.endsWith(suffix))
|
||||
);
|
||||
}
|
||||
|
||||
function walk(directory: string, ignoreSegments: Set<string>): string[] {
|
||||
const found: string[] = [];
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
return found;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (ignoreSegments.has(entry.name)) continue;
|
||||
const absolute = join(directory, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...walk(absolute, ignoreSegments));
|
||||
} else if (entry.isFile() && isSourceFile(entry.name)) {
|
||||
found.push(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function toForwardSlashes(path: string): string {
|
||||
return sep === '/' ? path : path.split(sep).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-by-line regex scan over the source tree. Returns one {@link ScanHit}
|
||||
* per matching line. Textual (not AST-based) — false positives on string
|
||||
* literals should be fixed by tightening the regex.
|
||||
*/
|
||||
export function scanSource(options: ScanOptions): ScanHit[] {
|
||||
const {
|
||||
roots = DEFAULT_ROOTS,
|
||||
ignoreSegments = [],
|
||||
pattern,
|
||||
allowlist = [],
|
||||
} = options;
|
||||
|
||||
const ignoreSet = new Set([...ALWAYS_SKIP_SEGMENTS, ...ignoreSegments]);
|
||||
const allowSet = new Set(allowlist);
|
||||
const hits: ScanHit[] = [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const root of roots) {
|
||||
const absoluteRoot = resolve(WORKSPACE_ROOT, root);
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(absoluteRoot);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stat.isDirectory()) continue;
|
||||
|
||||
for (const absoluteFile of walk(absoluteRoot, ignoreSet)) {
|
||||
if (seen.has(absoluteFile)) continue;
|
||||
seen.add(absoluteFile);
|
||||
|
||||
const relativePath = toForwardSlashes(
|
||||
relative(WORKSPACE_ROOT, absoluteFile),
|
||||
);
|
||||
if (allowSet.has(relativePath)) continue;
|
||||
|
||||
const contents = readFileSync(absoluteFile, 'utf8');
|
||||
const lines = contents.split('\n');
|
||||
|
||||
// Reuse the regex per file. Without the `g` flag, `.exec` ignores
|
||||
// lastIndex, so recompiling per-line was wasted allocation.
|
||||
const lineRegex = pattern.flags.includes('g')
|
||||
? new RegExp(pattern.source, pattern.flags.replace('g', ''))
|
||||
: pattern;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const lineText = lines[index];
|
||||
const match = lineRegex.exec(lineText);
|
||||
if (match) {
|
||||
hits.push({
|
||||
file: relativePath,
|
||||
line: index + 1,
|
||||
text: lineText.trim(),
|
||||
match: match[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Format hits as a multi-line failure message: ` file:line — text`. */
|
||||
export function formatHits(hits: ScanHit[], header: string): string {
|
||||
if (hits.length === 0) return header;
|
||||
const lines = hits
|
||||
.slice(0, 50)
|
||||
.map(hit => ` ${hit.file}:${hit.line} — ${hit.text}`);
|
||||
const overflow =
|
||||
hits.length > 50 ? `\n ... and ${hits.length - 50} more` : '';
|
||||
return `${header}\n${lines.join('\n')}${overflow}`;
|
||||
}
|
||||
|
||||
/** Throw with a formatted message if `hits` is non-empty. */
|
||||
export function expectNoHits(hits: ScanHit[], header: string): void {
|
||||
if (hits.length > 0) {
|
||||
throw new Error(formatHits(hits, header));
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run `callback` with `getBootstrapData().common.application_root` set to
|
||||
* `applicationRoot`. Resets modules so any imports inside the callback see
|
||||
* the configured value, then restores the prior DOM and module cache on exit.
|
||||
* Pass `''` to simulate the default root-of-domain deployment.
|
||||
*/
|
||||
export async function withApplicationRoot<T>(
|
||||
applicationRoot: string,
|
||||
callback: () => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const previousBody = document.body.innerHTML;
|
||||
|
||||
try {
|
||||
const bootstrapData = { common: { application_root: applicationRoot } };
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(bootstrapData)}'></div>`;
|
||||
jest.resetModules();
|
||||
await import('src/utils/getBootstrapData');
|
||||
return await callback();
|
||||
} finally {
|
||||
document.body.innerHTML = previousBody;
|
||||
jest.resetModules();
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `body` once per scenario, each under a different application root. */
|
||||
export async function applicationRootScenarios<S extends { root: string }>(
|
||||
scenarios: S[],
|
||||
body: (scenario: S) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
for (const scenario of scenarios) {
|
||||
// eslint-disable-next-line no-await-in-loop -- intentional: scenarios share document state.
|
||||
await withApplicationRoot(scenario.root, () => body(scenario));
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
import { fDuration, extendedDayjs } from '@superset-ui/core/utils/dates';
|
||||
import { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import ResultSet from '../ResultSet';
|
||||
import HighlightedSql from '../HighlightedSql';
|
||||
import { StaticPosition, StyledTooltip, ModalResultSetWrapper } from './styles';
|
||||
@@ -80,7 +80,8 @@ interface QueryTableProps {
|
||||
}
|
||||
|
||||
const openQuery = (id: number) => {
|
||||
openInNewTab(`/sqllab?queryId=${id}`);
|
||||
const url = makeUrl(`/sqllab?queryId=${id}`);
|
||||
window.open(url);
|
||||
};
|
||||
|
||||
const QueryTable = ({
|
||||
|
||||
@@ -53,28 +53,7 @@ jest.mock('@superset-ui/core', () => ({
|
||||
isFeatureEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
// Mock openInNewTab so the Create-chart "new window" branch can be asserted
|
||||
// without spawning a real window. The rest of navigationUtils stays real so
|
||||
// existing CSV-download tests keep using the genuine `redirect`/`makeUrl`.
|
||||
jest.mock('src/utils/navigationUtils', () => ({
|
||||
...jest.requireActual('src/utils/navigationUtils'),
|
||||
openInNewTab: jest.fn(),
|
||||
}));
|
||||
// eslint-disable-next-line import/order, import/first
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
|
||||
// Stub postFormData so the Create-chart click resolves quickly; this lets
|
||||
// the test focus on the URL composition that happens after the resolve.
|
||||
jest.mock('src/explore/exploreUtils/formData', () => ({
|
||||
...jest.requireActual('src/explore/exploreUtils/formData'),
|
||||
postFormData: jest.fn(),
|
||||
}));
|
||||
// eslint-disable-next-line import/order, import/first
|
||||
import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
const mockOpenInNewTab = openInNewTab as jest.Mock;
|
||||
const mockPostFormData = postFormData as jest.Mock;
|
||||
|
||||
jest.mock('src/components/ErrorMessage', () => ({
|
||||
ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
|
||||
@@ -181,9 +160,6 @@ describe('ResultSet', () => {
|
||||
beforeEach(() => {
|
||||
applicationRootMock.mockReturnValue('');
|
||||
mockStartExport.mockClear();
|
||||
mockOpenInNewTab.mockClear();
|
||||
mockPostFormData.mockReset();
|
||||
mockPostFormData.mockResolvedValue('test-form-data-key');
|
||||
});
|
||||
|
||||
// Add cleanup after each test
|
||||
@@ -1033,103 +1009,4 @@ describe('ResultSet', () => {
|
||||
screen.getByRole('button', { name: 'Results Action' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Create chart in new window opens single-prefixed explore URL under subdirectory deployment', async () => {
|
||||
// When the user metaKey-clicks "Create chart", the SQL-Lab result handoff
|
||||
// composes an explore URL via mountExploreUrl(..., includeAppRoot=true).
|
||||
// Under SUPERSET_APP_ROOT=/superset, the resulting URL must contain the
|
||||
// prefix exactly once. A doubled prefix (/superset/superset/explore/…)
|
||||
// produces a blank Explore page.
|
||||
const appRoot = '/superset';
|
||||
applicationRootMock.mockReturnValue(appRoot);
|
||||
|
||||
const queryWithId = {
|
||||
...queries[0],
|
||||
results: {
|
||||
...queries[0].results,
|
||||
query_id: 42,
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId } = setup(
|
||||
{
|
||||
...mockedProps,
|
||||
queryId: queryWithId.id,
|
||||
database: { allows_subquery: true, allows_virtual_table_explore: true },
|
||||
},
|
||||
mockStore({
|
||||
...initialState,
|
||||
user,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queryWithId.id]: queryWithId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const exploreButton = await waitFor(() =>
|
||||
getByTestId('explore-results-button'),
|
||||
);
|
||||
fireEvent.click(exploreButton, { metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenInNewTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const url = mockOpenInNewTab.mock.calls[0][0] as string;
|
||||
expect(url).toMatch(/^\/superset\/explore\/\?.*form_data_key=/);
|
||||
expect(url).not.toMatch(/\/superset\/superset\//);
|
||||
});
|
||||
|
||||
test('Create chart in same window pushes router-relative explore URL under subdirectory deployment', async () => {
|
||||
// Same-tab click (no metaKey) goes through history.push under the SPA
|
||||
// basename Router, so mountExploreUrl is called with includeAppRoot=false.
|
||||
// The composed URL must NOT carry an app-root prefix — the router applies
|
||||
// it once via <Router basename={applicationRoot()}>. A premature prefix
|
||||
// here would compound with the basename and yield /superset/superset/…
|
||||
const appRoot = '/superset';
|
||||
applicationRootMock.mockReturnValue(appRoot);
|
||||
|
||||
const queryWithId = {
|
||||
...queries[0],
|
||||
results: {
|
||||
...queries[0].results,
|
||||
query_id: 99,
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore({
|
||||
...initialState,
|
||||
user,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queryWithId.id]: queryWithId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ResultSet
|
||||
{...mockedProps}
|
||||
queryId={queryWithId.id}
|
||||
database={{
|
||||
allows_subquery: true,
|
||||
allows_virtual_table_explore: true,
|
||||
}}
|
||||
/>,
|
||||
{ useRedux: true, store, useRouter: true },
|
||||
);
|
||||
|
||||
const exploreButton = await waitFor(() =>
|
||||
getByTestId('explore-results-button'),
|
||||
);
|
||||
fireEvent.click(exploreButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPostFormData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mockOpenInNewTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -87,7 +88,7 @@ import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||
import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
|
||||
import { useConfirmModal } from 'src/hooks/useConfirmModal';
|
||||
import { makeUrl, openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
|
||||
import ExploreResultsButton from '../ExploreResultsButton';
|
||||
import HighlightedSql from '../HighlightedSql';
|
||||
@@ -311,9 +312,7 @@ const ResultSet = ({
|
||||
includeAppRoot,
|
||||
);
|
||||
if (openInNewWindow) {
|
||||
// `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
|
||||
// helper re-applies `ensureAppRoot` idempotently.
|
||||
openInNewTab(url);
|
||||
window.open(url, '_blank', 'noreferrer');
|
||||
} else {
|
||||
history.push(url);
|
||||
}
|
||||
@@ -380,13 +379,7 @@ const ResultSet = ({
|
||||
{ rows: rowsCount.toLocaleString() },
|
||||
),
|
||||
onConfirm: () => {
|
||||
// `getExportCsvUrl` already runs the path through `makeUrl`;
|
||||
// `redirect` re-applies `ensureAppRoot` idempotently and routes
|
||||
// the sink through navigationUtils' barriers (scheme allowlist,
|
||||
// userinfo rejection, backslash rejection), which is a
|
||||
// strict superset of what `sanitizeUrl` from master PR #40546
|
||||
// provides.
|
||||
redirect(getExportCsvUrl(query.id));
|
||||
window.location.href = sanitizeUrl(getExportCsvUrl(query.id));
|
||||
},
|
||||
confirmText: t('OK'),
|
||||
cancelText: t('Close'),
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { sanitizeUrl } from '@braintree/sanitize-url';
|
||||
import { useCallback, useState, FormEvent } from 'react';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio';
|
||||
@@ -57,7 +58,6 @@ import { postFormData } from 'src/explore/exploreUtils/formData';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
@@ -244,16 +244,10 @@ export const SaveDatasetModal = ({
|
||||
useState(false);
|
||||
|
||||
const createWindow = (url: string) => {
|
||||
// `url` is from `mountExploreUrl(..., includeAppRoot=true)`; the
|
||||
// navigationUtils helpers re-apply `ensureAppRoot` idempotently.
|
||||
if (openWindow) {
|
||||
// `openInNewTab` / `redirect` route the sink through navigationUtils'
|
||||
// barriers (scheme allowlist, userinfo rejection, backslash
|
||||
// rejection) — strictly stronger than master PR #40546's `sanitizeUrl`
|
||||
// wrap, which only rejects `javascript:` / `data:` / `vbscript:`.
|
||||
openInNewTab(url);
|
||||
window.open(sanitizeUrl(url), '_blank', 'noreferrer');
|
||||
} else {
|
||||
redirect(url);
|
||||
window.location.href = sanitizeUrl(url);
|
||||
}
|
||||
};
|
||||
const formDataWithDefaults = {
|
||||
|
||||
@@ -135,17 +135,14 @@ describe('SqlEditorTabHeader', () => {
|
||||
|
||||
test('should dispatch queryEditorSetTitle action', async () => {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('rename-tab-menu-option'),
|
||||
).toBeInTheDocument(),
|
||||
expect(screen.getByTestId('close-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({
|
||||
@@ -156,127 +153,7 @@ describe('SqlEditorTabHeader', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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(),
|
||||
);
|
||||
mockPrompt.mockClear();
|
||||
});
|
||||
|
||||
test('should dispatch removeAllOtherQueryEditors action', async () => {
|
||||
@@ -319,42 +196,4 @@ 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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,17 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, FC } from 'react';
|
||||
import { useMemo, FC } from 'react';
|
||||
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { useSelector, shallowEqual } from 'react-redux';
|
||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||
import {
|
||||
MenuDotsDropdown,
|
||||
Modal,
|
||||
Input,
|
||||
InputRef,
|
||||
} from '@superset-ui/core/components';
|
||||
import { MenuDotsDropdown } 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';
|
||||
@@ -112,35 +107,13 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}, [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> = {
|
||||
@@ -158,11 +131,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
||||
return statusColors[state] || theme.colorIcon;
|
||||
};
|
||||
return (
|
||||
<TabTitleWrapper
|
||||
ref={tabHeaderRef}
|
||||
tabIndex={-1}
|
||||
data-test="sql-editor-tab-header"
|
||||
>
|
||||
<TabTitleWrapper>
|
||||
<MenuDotsDropdown
|
||||
trigger={['click']}
|
||||
overlay={
|
||||
@@ -189,7 +158,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
||||
} as MenuItemType,
|
||||
{
|
||||
key: '2',
|
||||
onClick: openRenameModal,
|
||||
onClick: renameTab,
|
||||
'data-test': 'rename-tab-menu-option',
|
||||
label: (
|
||||
<>
|
||||
@@ -251,37 +220,6 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ import {
|
||||
} from 'src/database/actions';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { makeUrl, openInNewTab } from 'src/utils/navigationUtils';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import {
|
||||
OwnerSelectLabel,
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
@@ -1158,10 +1158,7 @@ class DatasourceEditor extends PureComponent<
|
||||
}
|
||||
|
||||
openOnSqlLab() {
|
||||
// `getSQLLabUrl()` already runs the path through `makeUrl`; `openInNewTab`
|
||||
// re-applies `ensureAppRoot`, which is idempotent on already-prefixed
|
||||
// paths (see navigationUtils.appRoot.test.tsx).
|
||||
openInNewTab(this.getSQLLabUrl());
|
||||
window.open(this.getSQLLabUrl(), '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
tableChangeAndSyncMetadata() {
|
||||
|
||||
@@ -66,7 +66,7 @@ test('renders single dashboard link correctly', () => {
|
||||
|
||||
const link = screen.getByText('Sales Dashboard');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.closest('a')).toHaveAttribute('href', '/dashboard/1/');
|
||||
expect(link.closest('a')).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
expect(link.closest('a')).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
@@ -98,9 +98,9 @@ test('links have correct href attributes', () => {
|
||||
.getByText(', Very Long Dashboard Name That Should Be Truncated')
|
||||
.closest('a');
|
||||
|
||||
expect(salesLink).toHaveAttribute('href', '/dashboard/1/');
|
||||
expect(analyticsLink).toHaveAttribute('href', '/dashboard/2/');
|
||||
expect(longNameLink).toHaveAttribute('href', '/dashboard/3/');
|
||||
expect(salesLink).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
expect(analyticsLink).toHaveAttribute('href', '/superset/dashboard/2/');
|
||||
expect(longNameLink).toHaveAttribute('href', '/superset/dashboard/3/');
|
||||
});
|
||||
|
||||
test('applies correct styling classes', () => {
|
||||
@@ -124,5 +124,5 @@ test('handles dashboard with empty title', () => {
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveTextContent('');
|
||||
expect(link).toHaveAttribute('href', '/dashboard/1/');
|
||||
expect(link).toHaveAttribute('href', '/superset/dashboard/1/');
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ const DashboardLinksExternal = ({
|
||||
{dashboards.map((dashboard, index) => (
|
||||
<GenericLink
|
||||
key={dashboard.id}
|
||||
to={`/dashboard/${dashboard.id}/`}
|
||||
to={`/superset/dashboard/${dashboard.id}/`}
|
||||
target="_blank"
|
||||
>
|
||||
{index === 0
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
@@ -822,57 +821,3 @@ test('calculated column search is case-insensitive', async () => {
|
||||
expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('Open in SQL lab href is single-prefixed under subdirectory deployment', () => {
|
||||
// The Open-in-SQL-Lab link's href is produced by `getSQLLabUrl()`:
|
||||
// return makeUrl(`/sqllab/?${queryParams.toString()}`);
|
||||
// `makeUrl` is the idempotent app-root prefix helper from
|
||||
// `src/utils/navigationUtils`. Rendering the link requires both the
|
||||
// virtual datasourceType state AND a populated Redux `database.queryResult`
|
||||
// slice (which is not part of the default test reducer tree). Calling
|
||||
// `makeUrl` directly with a `/superset` mock exercises the exact path the
|
||||
// component takes and pins the dedupe invariant for the underlying helper.
|
||||
const applicationRootSpy = jest
|
||||
.spyOn(getBootstrapData, 'applicationRoot')
|
||||
.mockReturnValue('/superset');
|
||||
try {
|
||||
const { makeUrl } = jest.requireActual('src/utils/navigationUtils');
|
||||
const queryParams = new URLSearchParams({
|
||||
dbid: '1',
|
||||
sql: 'SELECT * FROM users',
|
||||
name: 'Vehicle Sales',
|
||||
schema: 'public',
|
||||
autorun: 'true',
|
||||
isDataset: 'true',
|
||||
});
|
||||
const url = makeUrl(`/sqllab/?${queryParams.toString()}`);
|
||||
expect(url).toMatch(/^\/superset\/sqllab\/\?/);
|
||||
expect(url).not.toMatch(/\/superset\/superset\//);
|
||||
} finally {
|
||||
applicationRootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('DatasourceEditor source pins getSQLLabUrl/openOnSqlLab to the makeUrl + openInNewTab helpers', () => {
|
||||
// Source-pin: lock the exact two-line shape the runtime behaviour depends
|
||||
// on. `getSQLLabUrl` MUST wrap its `/sqllab/?...` path in `makeUrl` so the
|
||||
// Layer-2 idempotent prefix runs at the click boundary; `openOnSqlLab`
|
||||
// MUST delegate to `openInNewTab` so `ensureAppRoot` runs again (idempotent
|
||||
// dedupe, see `navigationUtils.appRoot.test.tsx`). A refactor that drops
|
||||
// either layer would let a doubled-prefix URL escape into a new tab.
|
||||
// eslint-disable-next-line global-require
|
||||
const { readFileSync } = require('fs');
|
||||
// eslint-disable-next-line global-require
|
||||
const { join } = require('path');
|
||||
const src = readFileSync(
|
||||
join(__dirname, '..', 'DatasourceEditor.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/return makeUrl\(`\/sqllab\/\?\$\{queryParams\.toString\(\)\}`\);/,
|
||||
);
|
||||
expect(src).toMatch(/openInNewTab\(this\.getSQLLabUrl\(\)\);/);
|
||||
expect(src).toMatch(
|
||||
/import \{ makeUrl, openInNewTab \} from 'src\/utils\/navigationUtils';/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import getOwnerName from 'src/utils/getOwnerName';
|
||||
import { Avatar, AvatarGroup, Tooltip } from '@superset-ui/core/components';
|
||||
import { ensureAppRoot } from 'src/utils/navigationUtils';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { getRandomColor } from './utils';
|
||||
import type { FacePileProps } from './types';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Column, GridApi, IHeaderParams } from 'ag-grid-community';
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { Header } from './Header';
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
@@ -38,70 +38,9 @@ jest.mock('@superset-ui/core/components/Icons', () => {
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
class MockApi extends EventTarget {
|
||||
getAllDisplayedColumns() {
|
||||
return [this.mockColumn, this.otherColumn];
|
||||
}
|
||||
|
||||
getColumns() {
|
||||
return [this.mockColumn, this.otherColumn];
|
||||
return [];
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
@@ -109,76 +48,48 @@ class MockApi {
|
||||
}
|
||||
}
|
||||
|
||||
const mockApi = new MockApi();
|
||||
|
||||
const mockedProps = {
|
||||
displayName: 'test column',
|
||||
progressSort: jest.fn(),
|
||||
setSort: jest.fn(),
|
||||
enableSorting: true,
|
||||
column: mockApi.mockColumn as any as Column,
|
||||
api: mockApi as any as GridApi,
|
||||
} as unknown as IHeaderParams;
|
||||
column: {
|
||||
getColId: () => '123',
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSort: () => 'asc',
|
||||
getSortIndex: () => null,
|
||||
} as any as Column,
|
||||
api: new MockApi() as any as GridApi,
|
||||
};
|
||||
|
||||
test('renders display name for the column', () => {
|
||||
const { queryByText } = render(<Header {...mockedProps} />);
|
||||
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls progressSort without shiftKey on click', () => {
|
||||
const { getByText } = render(<Header {...mockedProps} />);
|
||||
test('sorts by clicking a column header', () => {
|
||||
const { getByText, queryByTestId } = render(<Header {...mockedProps} />);
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.progressSort).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
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(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();
|
||||
});
|
||||
|
||||
test('synchronizes the current sort when sortChanged event occurred', async () => {
|
||||
const { findByTestId } = render(<Header {...mockedProps} />);
|
||||
act(() => {
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
mockedProps.api.dispatchEvent(new Event('sortChanged'));
|
||||
});
|
||||
|
||||
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} />,
|
||||
@@ -188,39 +99,18 @@ 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={pivotColumn as any as Column} />,
|
||||
<Header
|
||||
{...mockedProps}
|
||||
column={
|
||||
{
|
||||
getColId: () => PIVOT_COL_ID,
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSortIndex: () => null,
|
||||
} 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();
|
||||
});
|
||||
|
||||
@@ -16,16 +16,32 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { IHeaderParams, Column, SortDirection } from 'ag-grid-community';
|
||||
|
||||
import {
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
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;
|
||||
@@ -71,26 +87,30 @@ const IconPlaceholder = styled.div`
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
export const Header: React.FC<IHeaderParams> = ({
|
||||
export const Header: React.FC<Params> = ({
|
||||
enableFilterButton,
|
||||
enableSorting,
|
||||
displayName,
|
||||
progressSort,
|
||||
setSort,
|
||||
column,
|
||||
api,
|
||||
}: IHeaderParams) => {
|
||||
}: Params) => {
|
||||
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<SortDirection>(null);
|
||||
const [currentSort, setCurrentSort] = useState<string | null>(null);
|
||||
const [sortIndex, setSortIndex] = useState<number | null>();
|
||||
const onSort = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
progressSort(event.shiftKey);
|
||||
(event: MouseEvent) => {
|
||||
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length;
|
||||
const sort = SORT_DIRECTION[sortOption.current];
|
||||
setSort(sort, event.shiftKey);
|
||||
setCurrentSort(sort);
|
||||
},
|
||||
[progressSort],
|
||||
[setSort],
|
||||
);
|
||||
const onVisibleChange = useCallback(
|
||||
(isVisible: boolean) => {
|
||||
@@ -103,22 +123,24 @@ export const Header: React.FC<IHeaderParams> = ({
|
||||
[api],
|
||||
);
|
||||
|
||||
const syncSortState = useCallback(() => {
|
||||
const onSortChanged = useCallback(() => {
|
||||
const hasMultiSort = api
|
||||
.getAllDisplayedColumns()
|
||||
.some(c => c.getColId() !== colId && c.getSort() !== null);
|
||||
.some(c => c.getSortIndex());
|
||||
const updatedSortIndex = column.getSortIndex();
|
||||
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
|
||||
setCurrentSort(column.getSort() ?? null);
|
||||
setSortIndex(hasMultiSort ? column.getSortIndex() : null);
|
||||
}, [api, column, colId]);
|
||||
setSortIndex(hasMultiSort ? updatedSortIndex : null);
|
||||
}, [api, column]);
|
||||
|
||||
useEffect(() => {
|
||||
column.addEventListener('columnStateUpdated', syncSortState);
|
||||
api.addEventListener('sortChanged', onSortChanged);
|
||||
|
||||
return () => {
|
||||
if (api.isDestroyed()) return;
|
||||
column.removeEventListener('columnStateUpdated', syncSortState);
|
||||
api.removeEventListener('sortChanged', onSortChanged);
|
||||
};
|
||||
}, [column, syncSortState]);
|
||||
}, [api, onSortChanged]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user