Compare commits

..

1 Commits

Author SHA1 Message Date
Evan
c32284d56a chore(ci): correct setup-python pin comment to match v6.2.0
The pinned SHA a309ff8 resolves to tag v6.2.0, but the inline comment
read "# v6", which zizmor flags as a ref-version-mismatch. Update the
comment to the precise version, matching the rest of the workflows that
use full semver in their pin comments.

Resolves code-scanning alert #2550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:56:35 -07:00
378 changed files with 6140 additions and 25099 deletions

2
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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"

View File

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

View File

@@ -24,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

View File

@@ -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

View File

@@ -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

View File

@@ -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/)

View File

@@ -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.

View 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

View File

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

View File

@@ -72,7 +72,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.41",
"antd": "^6.4.4",
"baseline-browser-mapping": "^2.10.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",

View File

@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.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"

View File

@@ -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

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.17.3](https://img.shields.io/badge/Version-0.17.3-informational?style=flat-square)
![Version: 0.17.2](https://img.shields.io/badge/Version-0.17.2-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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';

View File

@@ -109,7 +109,7 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.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"

View File

@@ -192,7 +192,7 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.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",

View File

@@ -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"

View File

@@ -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.

View File

@@ -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 */

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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>;

View File

@@ -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;
/**

View File

@@ -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',

View File

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

View File

@@ -359,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',

View File

@@ -22,50 +22,21 @@ under the License.
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](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.

View File

@@ -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);

View File

@@ -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,

View File

@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
import remarkGfm from 'remark-gfm';
import { 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,
);
}

View File

@@ -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} />);

View File

@@ -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);
})

View File

@@ -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.

View File

@@ -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;

View File

@@ -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
}`;
}
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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;
}

View File

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

View File

@@ -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);

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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', () => {

View File

@@ -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();

View File

@@ -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',
);
});
});

View File

@@ -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 });
});
});
});

View File

@@ -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);
});
});

View File

@@ -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,
);

View File

@@ -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}/`);
}
/**

View File

@@ -35,5 +35,5 @@ export const URL = {
LOGIN: 'login/',
SAVED_QUERIES_LIST: 'savedqueryview/list/',
SQLLAB: 'sqllab',
WELCOME: 'welcome/',
WELCOME: 'superset/welcome/',
} as const;

View File

@@ -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);
}
});
},
});

View File

@@ -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', () => {

View File

@@ -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.
});

View File

@@ -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', () => {

View File

@@ -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' }];

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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}`;
}
}),
);

View File

@@ -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: {

View File

@@ -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',

View File

@@ -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,

View File

@@ -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',

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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(() => {

View File

@@ -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',

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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', () => {

View File

@@ -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,
},
]);
});
});

View File

@@ -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"

View File

@@ -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,
});

View File

@@ -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', () => {

View File

@@ -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();
});

View File

@@ -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>
);

View File

@@ -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+)?$/);
});
});
});

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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 = ({

View File

@@ -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();
});
});

View File

@@ -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'),

View File

@@ -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 = {

View File

@@ -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']));
});
});

View File

@@ -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>
);
};

View File

@@ -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() {

View File

@@ -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/');
});

View File

@@ -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

View File

@@ -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';/,
);
});

View File

@@ -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';

View File

@@ -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();
});

View File

@@ -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