mirror of
https://github.com/apache/superset.git
synced 2026-07-01 20:35:35 +00:00
Compare commits
63 Commits
chore/ci/s
...
mcp-update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892143071b | ||
|
|
7ea015050d | ||
|
|
84e07df735 | ||
|
|
b8f3918bcf | ||
|
|
ee43d8869f | ||
|
|
01a0c66c79 | ||
|
|
35365d639d | ||
|
|
7e17c70cba | ||
|
|
0d43c2c12c | ||
|
|
7410ff73c0 | ||
|
|
f08f068240 | ||
|
|
2b09b6bc1d | ||
|
|
d763255e15 | ||
|
|
8fed514e79 | ||
|
|
c94bc7178f | ||
|
|
95ecdd3753 | ||
|
|
aac02ab679 | ||
|
|
de01fe2ff0 | ||
|
|
9965c05699 | ||
|
|
d8bcc66472 | ||
|
|
4b9b8187b3 | ||
|
|
83f7dc9d5b | ||
|
|
baca76ebe0 | ||
|
|
9a11c15a33 | ||
|
|
a90c8e0347 | ||
|
|
fe2424ec14 | ||
|
|
b4f43bd7e0 | ||
|
|
2b25345ed9 | ||
|
|
e0f3f93cd4 | ||
|
|
0667ba6097 | ||
|
|
81f7e42f4e | ||
|
|
0fd244b5c6 | ||
|
|
1f16d10cbf | ||
|
|
4f4663418f | ||
|
|
4519a5c52d | ||
|
|
da9fbadaf6 | ||
|
|
f40abbbefd | ||
|
|
6166af3c3c | ||
|
|
076d8c1508 | ||
|
|
518cadd907 | ||
|
|
b955c90de4 | ||
|
|
7363774869 | ||
|
|
6f12d17313 | ||
|
|
09c7ba14df | ||
|
|
3ec4bd23c4 | ||
|
|
f6ce105450 | ||
|
|
7bb4e82a82 | ||
|
|
2d78a8733c | ||
|
|
3261d10270 | ||
|
|
a57b5f6078 | ||
|
|
d1b523b97f | ||
|
|
91188a0302 | ||
|
|
ac234d0fb2 | ||
|
|
8eb753eab2 | ||
|
|
779fa13679 | ||
|
|
caf81e71d2 | ||
|
|
1b8c6d109d | ||
|
|
eb60e5477b | ||
|
|
7b9bcdd951 | ||
|
|
d9d395bde1 | ||
|
|
584d41759b | ||
|
|
8f22b71898 | ||
|
|
1ea3584dcb |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
# Notify translation maintainers of changes to translations
|
# Notify translation maintainers of changes to translations
|
||||||
|
|
||||||
/superset/translations/ @sfirke @rusackas
|
/superset/translations/ @sfirke @rusackas @villebro @sadpandajoe @hainenber
|
||||||
|
|
||||||
# Notify PMC members of changes to extension-related files
|
# Notify PMC members of changes to extension-related files
|
||||||
|
|
||||||
|
|||||||
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
Normal file
177
.github/workflows/scheduled-docker-image-refresh.yml
vendored
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
name: Scheduled Docker image refresh
|
||||||
|
|
||||||
|
# Re-runs the Docker image build against the latest published release on a
|
||||||
|
# weekly cadence. The code being built doesn't change — but the base image
|
||||||
|
# layers (python:*-slim-trixie and its OS packages) DO get upstream
|
||||||
|
# security patches between Superset releases, and those patches don't
|
||||||
|
# reach our published images unless we rebuild.
|
||||||
|
#
|
||||||
|
# Without this workflow, `apache/superset:<latest>` lags behind upstream
|
||||||
|
# Debian/Python base patches by whatever interval falls between Superset
|
||||||
|
# releases (typically 3–6 weeks). With it, the lag drops to at most one
|
||||||
|
# week regardless of release cadence.
|
||||||
|
#
|
||||||
|
# This is a security-hygiene cron, not a release. It overwrites the
|
||||||
|
# existing tags for the most recent release (e.g. `apache/superset:5.0.0`
|
||||||
|
# and `apache/superset:latest`) with bit-for-bit-equivalent contents
|
||||||
|
# layered on a refreshed base. Image digests change; everything users
|
||||||
|
# actually pin against (image content, code, deps) does not.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Mondays at 06:00 UTC — gives the weekend for upstream patches to
|
||||||
|
# settle and surfaces failures at the start of the work week so a
|
||||||
|
# human can react.
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
|
||||||
|
# Manual trigger so operators can force a refresh on demand (e.g.
|
||||||
|
# immediately after a high-severity base-image CVE drops).
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Serialize with itself and with the release publisher (tag-release.yml) —
|
||||||
|
# both push to the same Docker Hub tags, so a race could end with stale
|
||||||
|
# layers winning. Both workflows must declare this group for the lock to work.
|
||||||
|
concurrency:
|
||||||
|
group: docker-publish-latest-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
config:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
has-secrets: ${{ steps.check.outputs.has-secrets }}
|
||||||
|
latest-release: ${{ steps.latest.outputs.tag }}
|
||||||
|
force-latest: ${{ steps.latest.outputs.force-latest }}
|
||||||
|
steps:
|
||||||
|
- name: Check for Docker Hub secrets
|
||||||
|
id: check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -n "${DOCKERHUB_USER}" ]; then
|
||||||
|
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }}
|
||||||
|
|
||||||
|
- name: Look up latest published release
|
||||||
|
id: latest
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
# `releases/latest` returns the latest non-prerelease, non-draft
|
||||||
|
# release — which is exactly what `apache/superset:latest`
|
||||||
|
# should reflect.
|
||||||
|
TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name)
|
||||||
|
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
|
||||||
|
echo "::error::Could not determine latest release tag"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Latest release: $TAG"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Only move `:latest` when the release flagged "latest" is also the
|
||||||
|
# highest semver release. This guards against a mis-click leaving an
|
||||||
|
# older maintenance release (e.g. a 5.x patch shipped after 6.0 GA)
|
||||||
|
# marked latest, which would otherwise roll `:latest` back a major
|
||||||
|
# version on the next cron run. If it isn't the newest, we still
|
||||||
|
# refresh that release's own version tag but leave `:latest` alone.
|
||||||
|
HIGHEST=$(gh api --paginate "repos/${REPOSITORY}/releases" \
|
||||||
|
--jq '.[] | select(.draft|not) | select(.prerelease|not) | .tag_name' \
|
||||||
|
| sed 's/^v//' | sort -V | tail -n1)
|
||||||
|
if [ "${TAG#v}" = "$HIGHEST" ]; then
|
||||||
|
echo "force-latest=1" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "::warning::Latest-flagged release $TAG is not the highest semver ($HIGHEST); refreshing its version tag but leaving :latest untouched"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-rebuild:
|
||||||
|
needs: config
|
||||||
|
if: needs.config.outputs.has-secrets == '1'
|
||||||
|
name: docker-rebuild
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
strategy:
|
||||||
|
# Mirror the same matrix the release publisher uses so every variant
|
||||||
|
# operators consume from Docker Hub gets the refreshed base.
|
||||||
|
matrix:
|
||||||
|
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
|
||||||
|
fail-fast: false
|
||||||
|
steps:
|
||||||
|
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
|
||||||
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.config.outputs.latest-release }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Docker Environment
|
||||||
|
uses: ./.github/actions/setup-docker
|
||||||
|
with:
|
||||||
|
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||||
|
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
install-docker-compose: "false"
|
||||||
|
build: "true"
|
||||||
|
|
||||||
|
- name: Use Node.js 20
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Setup supersetbot
|
||||||
|
uses: ./.github/actions/setup-supersetbot/
|
||||||
|
|
||||||
|
- name: Rebuild and push
|
||||||
|
env:
|
||||||
|
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
BUILD_PRESET: ${{ matrix.build_preset }}
|
||||||
|
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
|
||||||
|
FORCE_LATEST_FLAG: ${{ needs.config.outputs.force-latest == '1' && '--force-latest' || '' }}
|
||||||
|
run: |
|
||||||
|
# Reuses the same supersetbot invocation as the release
|
||||||
|
# publisher (`tag-release.yml`), so the resulting tags are
|
||||||
|
# identical to what a manual release dispatch would produce —
|
||||||
|
# just with a freshly-pulled base image layer underneath.
|
||||||
|
# `--force-latest` is only passed when the config job confirmed the
|
||||||
|
# fetched release is the newest one (see FORCE_LATEST_FLAG above).
|
||||||
|
supersetbot docker \
|
||||||
|
--push \
|
||||||
|
--preset "$BUILD_PRESET" \
|
||||||
|
--context release \
|
||||||
|
--context-ref "$LATEST_RELEASE" \
|
||||||
|
$FORCE_LATEST_FLAG \
|
||||||
|
--platform "linux/arm64" \
|
||||||
|
--platform "linux/amd64"
|
||||||
|
|
||||||
|
# The whole point of this cron is catching base-image CVEs, so a silent
|
||||||
|
# failure is the expensive case — a red X in the Actions tab nobody is
|
||||||
|
# watching on a Monday. File a tracked issue when any rebuild leg fails so
|
||||||
|
# a missed security refresh surfaces instead of sitting unnoticed.
|
||||||
|
notify-on-failure:
|
||||||
|
needs: [config, docker-rebuild]
|
||||||
|
if: failure() && needs.config.outputs.has-secrets == '1'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Open a tracking issue
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
gh issue create \
|
||||||
|
--repo "$REPOSITORY" \
|
||||||
|
--title "Scheduled Docker image refresh failed for ${LATEST_RELEASE}" \
|
||||||
|
--label "infra:container" \
|
||||||
|
--label "bug" \
|
||||||
|
--body "The weekly Docker base-image refresh failed for release \`${LATEST_RELEASE}\`. Published images may be missing upstream base-layer security patches until this is resolved.
|
||||||
|
|
||||||
|
Failed run: ${RUN_URL}"
|
||||||
6
.github/workflows/tag-release.yml
vendored
6
.github/workflows/tag-release.yml
vendored
@@ -24,6 +24,12 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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:
|
jobs:
|
||||||
config:
|
config:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|||||||
12
UPDATING.md
12
UPDATING.md
@@ -24,6 +24,18 @@ assists people when migrating to a new version.
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
|
### Guest-token RLS rules reject unknown fields
|
||||||
|
|
||||||
|
The `rls` rules passed to `POST /api/v1/security/guest_token/` are now validated strictly: a rule may only contain `dataset` and `clause`. Previously unknown fields were silently dropped, so a mistyped or legacy scope key (most commonly `datasource` instead of `dataset`) produced a rule with no `dataset`, which is treated as a *global* rule applied to every dataset the embedded resource can reach. Such a request now returns HTTP 400 identifying the offending field instead of issuing a token with an unintended global rule. Integrators that were sending extra fields in RLS rules must remove them; valid dataset-scoped (`{"dataset": 41, "clause": "..."}`) and global (`{"clause": "..."}`) rules are unaffected.
|
||||||
|
|
||||||
|
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
|
||||||
|
|
||||||
|
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
|
||||||
|
|
||||||
|
### Swagger UI is opt-in (off by default)
|
||||||
|
|
||||||
|
`FAB_API_SWAGGER_UI` now defaults to `False` and is driven by the `SUPERSET_ENABLE_SWAGGER_UI` environment variable. The interactive Swagger UI / OpenAPI documentation endpoints (e.g. `/swagger/v1`) are therefore no longer exposed by default. To enable them, set `SUPERSET_ENABLE_SWAGGER_UI=true` (the bundled Docker development environment sets this) or override `FAB_API_SWAGGER_UI = True` in `superset_config.py`.
|
||||||
|
|
||||||
### Pivot table First/Last aggregations follow data order
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ SUPERSET_LOG_LEVEL=info
|
|||||||
|
|
||||||
SUPERSET_APP_ROOT="/"
|
SUPERSET_APP_ROOT="/"
|
||||||
SUPERSET_ENV=development
|
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
|
SUPERSET_LOAD_EXAMPLES=yes
|
||||||
CYPRESS_CONFIG=false
|
CYPRESS_CONFIG=false
|
||||||
SUPERSET_PORT=8088
|
SUPERSET_PORT=8088
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
|
|||||||
|
|
||||||
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
|
- [ ] 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 `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.
|
- [ ] Conduct quarterly access reviews for all users.
|
||||||
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
|
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
|
||||||
|
|
||||||
@@ -173,6 +174,24 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
|
|||||||
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
|
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
|
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
|
:::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: 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/)
|
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)
|
||||||
|
|||||||
@@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface
|
|||||||
|
|
||||||
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
|
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
|
||||||
|
|
||||||
```tsx
|
```typescript
|
||||||
import React from 'react';
|
|
||||||
import { views } from '@apache-superset/core';
|
import { views } from '@apache-superset/core';
|
||||||
import MyPanel from './MyPanel';
|
import MyPanel from './MyPanel';
|
||||||
|
|
||||||
views.registerView(
|
views.registerView(
|
||||||
{ id: 'my-extension.main', name: 'My Panel Name' },
|
{ id: 'my-extension.main', name: 'My Panel Name' },
|
||||||
'sqllab.panels',
|
'sqllab.panels',
|
||||||
() => <MyPanel />,
|
MyPanel,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,6 +111,24 @@ editors.registerEditor(
|
|||||||
|
|
||||||
See [Editors Extension Point](./extension-points/editors.md) for implementation details.
|
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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
141
docs/developer_docs/extensions/extension-points/chat.md
Normal file
141
docs/developer_docs/extensions/extension-points/chat.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
title: Chat
|
||||||
|
sidebar_position: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Chat Contributions
|
||||||
|
|
||||||
|
Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A chat registration consists of two React components:
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|-----------|------|
|
||||||
|
| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. |
|
||||||
|
| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. |
|
||||||
|
|
||||||
|
## Display Modes
|
||||||
|
|
||||||
|
The host supports two display modes, switchable by the user or the extension at runtime:
|
||||||
|
|
||||||
|
| Mode | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `floating` | Panel floats above page content, anchored to the bottom-right corner. |
|
||||||
|
| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. |
|
||||||
|
|
||||||
|
The user's last selected mode and open/closed state are persisted across page reloads.
|
||||||
|
|
||||||
|
## Registering a Chat
|
||||||
|
|
||||||
|
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { chat } from '@apache-superset/core';
|
||||||
|
import ChatTrigger from './ChatTrigger';
|
||||||
|
import ChatPanel from './ChatPanel';
|
||||||
|
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'my-org.my-chat', name: 'My Chat' },
|
||||||
|
ChatTrigger,
|
||||||
|
ChatPanel,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged.
|
||||||
|
|
||||||
|
## Opening and Closing the Chat
|
||||||
|
|
||||||
|
The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { chat } from '@apache-superset/core';
|
||||||
|
|
||||||
|
export default function ChatTrigger() {
|
||||||
|
return (
|
||||||
|
<button onClick={() => (chat.isOpen() ? chat.close() : chat.open())}>
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also subscribe to open/close events from any component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const { dispose } = chat.onDidOpen(() => console.log('chat opened'));
|
||||||
|
return dispose;
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changing the Display Mode
|
||||||
|
|
||||||
|
Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { chat } from '@apache-superset/core';
|
||||||
|
|
||||||
|
export default function ChatPanel() {
|
||||||
|
const [mode, setMode] = useState(chat.getDisplayMode());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m));
|
||||||
|
return dispose;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: mode === 'panel' ? '100%' : '80vh' }}>
|
||||||
|
<button onClick={() => chat.setDisplayMode(mode === 'panel' ? 'floating' : 'panel')}>
|
||||||
|
{mode === 'panel' ? 'Float' : 'Dock'}
|
||||||
|
</button>
|
||||||
|
{/* message list and input */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chat API Reference
|
||||||
|
|
||||||
|
All methods are available on the `chat` namespace from `@apache-superset/core`:
|
||||||
|
|
||||||
|
| Method / Event | Description |
|
||||||
|
|----------------|-------------|
|
||||||
|
| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. |
|
||||||
|
| `open()` | Open the chat panel. No-op if already open or no registration. |
|
||||||
|
| `close()` | Close the chat panel. |
|
||||||
|
| `isOpen()` | Returns `true` if the panel is currently open. |
|
||||||
|
| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). |
|
||||||
|
| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. |
|
||||||
|
| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. |
|
||||||
|
| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. |
|
||||||
|
| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. |
|
||||||
|
| `onDidRegisterChat(listener)` | Subscribe to registration events. |
|
||||||
|
| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. |
|
||||||
|
| `onDidResizePanel(listener)` | Subscribe to panel resize events (panel mode only). Not all hosts provide a resizer — do not rely on this firing. Returns a `Disposable`. |
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
|
||||||
|
- **[Development](../development.md)** — Set up your development environment
|
||||||
@@ -47,6 +47,8 @@ module.exports = {
|
|||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
items: [
|
||||||
'extensions/extension-points/sqllab',
|
'extensions/extension-points/sqllab',
|
||||||
|
'extensions/extension-points/editors',
|
||||||
|
'extensions/extension-points/chat',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'extensions/development',
|
'extensions/development',
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
"@superset-ui/core": "^0.20.4",
|
"@superset-ui/core": "^0.20.4",
|
||||||
"@swc/core": "^1.15.41",
|
"@swc/core": "^1.15.41",
|
||||||
"antd": "^6.4.4",
|
"antd": "^6.4.4",
|
||||||
"baseline-browser-mapping": "^2.10.37",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"caniuse-lite": "^1.0.30001799",
|
"caniuse-lite": "^1.0.30001799",
|
||||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||||
|
|||||||
@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
|||||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||||
|
|
||||||
baseline-browser-mapping@^2.10.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||||
version "2.10.37"
|
version "2.10.38"
|
||||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
|
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
|
||||||
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
|
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
|
||||||
|
|
||||||
batch@0.6.1:
|
batch@0.6.1:
|
||||||
version "0.6.1"
|
version "0.6.1"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ maintainers:
|
|||||||
- name: craig-rueda
|
- name: craig-rueda
|
||||||
email: craig@craigrueda.com
|
email: craig@craigrueda.com
|
||||||
url: https://github.com/craig-rueda
|
url: https://github.com/craig-rueda
|
||||||
version: 0.17.2 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
version: 0.17.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: postgresql
|
- name: postgresql
|
||||||
version: 16.7.27
|
version: 16.7.27
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
|||||||
|
|
||||||
# superset
|
# superset
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,6 @@ else:
|
|||||||
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
|
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = True
|
|
||||||
|
|
||||||
class CeleryConfig:
|
class CeleryConfig:
|
||||||
imports = ("superset.sql_lab", )
|
imports = ("superset.sql_lab", )
|
||||||
broker_url = CELERY_REDIS_URL
|
broker_url = CELERY_REDIS_URL
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ pygeohash==3.2.2
|
|||||||
# via apache-superset (pyproject.toml)
|
# via apache-superset (pyproject.toml)
|
||||||
pygments==2.20.0
|
pygments==2.20.0
|
||||||
# via rich
|
# via rich
|
||||||
pyjwt==2.12.0
|
pyjwt==2.13.0
|
||||||
# via
|
# via
|
||||||
# apache-superset (pyproject.toml)
|
# apache-superset (pyproject.toml)
|
||||||
# flask-appbuilder
|
# flask-appbuilder
|
||||||
|
|||||||
@@ -769,7 +769,7 @@ pyhive==0.7.0
|
|||||||
# via apache-superset
|
# via apache-superset
|
||||||
pyinstrument==5.1.2
|
pyinstrument==5.1.2
|
||||||
# via apache-superset
|
# via apache-superset
|
||||||
pyjwt==2.12.0
|
pyjwt==2.13.0
|
||||||
# via
|
# via
|
||||||
# -c requirements/base-constraint.txt
|
# -c requirements/base-constraint.txt
|
||||||
# apache-superset
|
# apache-superset
|
||||||
|
|||||||
226
superset-frontend/package-lock.json
generated
226
superset-frontend/package-lock.json
generated
@@ -109,13 +109,13 @@
|
|||||||
"json-bigint": "^1.0.0",
|
"json-bigint": "^1.0.0",
|
||||||
"json-stringify-pretty-compact": "^4.0.0",
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"mapbox-gl": "^3.24.1",
|
"mapbox-gl": "^3.25.0",
|
||||||
"markdown-to-jsx": "^9.8.2",
|
"markdown-to-jsx": "^9.8.2",
|
||||||
"match-sorter": "^8.3.0",
|
"match-sorter": "^8.3.0",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"nanoid": "^5.1.11",
|
"nanoid": "^5.1.14",
|
||||||
"ol": "^10.9.0",
|
"ol": "^10.9.0",
|
||||||
"query-string": "9.4.0",
|
"query-string": "9.4.0",
|
||||||
"re-resizable": "^6.11.2",
|
"re-resizable": "^6.11.2",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||||
"babel-plugin-lodash": "^3.3.4",
|
"babel-plugin-lodash": "^3.3.4",
|
||||||
"baseline-browser-mapping": "^2.10.37",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"cheerio": "1.2.0",
|
"cheerio": "1.2.0",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"copy-webpack-plugin": "^14.0.0",
|
"copy-webpack-plugin": "^14.0.0",
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"react-dnd-test-backend": "^16.0.1",
|
"react-dnd-test-backend": "^16.0.1",
|
||||||
"react-refresh": "^0.18.0",
|
"react-refresh": "^0.18.0",
|
||||||
"react-resizable": "^4.0.1",
|
"react-resizable": "^4.0.2",
|
||||||
"redux-mock-store": "^1.5.4",
|
"redux-mock-store": "^1.5.4",
|
||||||
"source-map": "^0.7.6",
|
"source-map": "^0.7.6",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
@@ -6326,12 +6326,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mapbox/mapbox-gl-supported": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/@mapbox/martini": {
|
"node_modules/@mapbox/martini": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz",
|
||||||
@@ -11470,15 +11464,6 @@
|
|||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/glob-to-regexp": {
|
||||||
"version": "0.4.4",
|
"version": "0.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
|
||||||
@@ -11776,12 +11761,6 @@
|
|||||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
@@ -14961,9 +14940,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.37",
|
"version": "2.10.38",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||||
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
"integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -15954,12 +15933,6 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/check-error": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
@@ -17283,12 +17256,6 @@
|
|||||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -18617,11 +18584,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.7",
|
"version": "3.4.11",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optional": true,
|
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@@ -21450,12 +21416,6 @@
|
|||||||
"integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==",
|
"integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==",
|
||||||
"license": "BSD-2-Clause"
|
"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": {
|
"node_modules/geolib": {
|
||||||
"version": "3.3.14",
|
"version": "3.3.14",
|
||||||
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
|
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
|
||||||
@@ -23260,9 +23220,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/http-proxy-middleware": {
|
"node_modules/http-proxy-middleware": {
|
||||||
"version": "2.0.9",
|
"version": "2.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -28361,9 +28321,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mapbox-gl": {
|
"node_modules/mapbox-gl": {
|
||||||
"version": "3.24.1",
|
"version": "3.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz",
|
||||||
"integrity": "sha512-e9Wj1TtGGOjzE/jtWaUvdFN7RYL3H0keEzH7gwzHbEdFAsmi03RaDVhnATmtFtIRXQUYf944CIQN0jQv+obeNg==",
|
"integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==",
|
||||||
"license": "SEE LICENSE IN LICENSE.txt",
|
"license": "SEE LICENSE IN LICENSE.txt",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/style-spec",
|
"src/style-spec",
|
||||||
@@ -28371,66 +28331,7 @@
|
|||||||
"test/build/vite",
|
"test/build/vite",
|
||||||
"test/build/webpack",
|
"test/build/webpack",
|
||||||
"test/build/typings"
|
"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": {
|
"node_modules/maplibre-gl": {
|
||||||
"version": "5.24.0",
|
"version": "5.24.0",
|
||||||
@@ -28548,23 +28449,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/martinez-polygon-clipping": {
|
|
||||||
"version": "0.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
|
|
||||||
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"robust-predicates": "^2.0.4",
|
|
||||||
"splaytree": "^0.1.4",
|
|
||||||
"tinyqueue": "3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/martinez-polygon-clipping/node_modules/robust-predicates": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
|
||||||
"license": "Unlicense"
|
|
||||||
},
|
|
||||||
"node_modules/match-sorter": {
|
"node_modules/match-sorter": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz",
|
||||||
@@ -31553,9 +31437,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "5.1.11",
|
"version": "5.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.14.tgz",
|
||||||
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
"integrity": "sha512-5c8l8kVzqpnDPaicbEop/fV0Q1w16FmbWtVhMqugTozAwYdlIQojWH5a/M7UfziFmGdQRrUdV+EPzc9Xng3VAQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -36406,9 +36290,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-resizable": {
|
"node_modules/react-resizable": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-4.0.2.tgz",
|
||||||
"integrity": "sha512-FR25Rcfxpi1iKiC7taIrqc1Tz6VnslqM94/IrA1LxoM5C3ap2EqaKLnCit/aKrcn3H4wfzO0nFBadFBc+SzEWA==",
|
"integrity": "sha512-jZD9ghYRmyJCw0+awYctSZ+9pmX1WXQvzDrTovELYc8obC/BShTI2r4c14LIVzeQ+vJZNb0yKM7bG2eqv7Vfyg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -39137,12 +39021,6 @@
|
|||||||
"webpack": "^1 || ^2 || ^3 || ^4 || ^5"
|
"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": {
|
"node_modules/split": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
|
||||||
@@ -44943,7 +44821,7 @@
|
|||||||
"parse-ms": "^4.0.0",
|
"parse-ms": "^4.0.0",
|
||||||
"re-resizable": "^6.11.2",
|
"re-resizable": "^6.11.2",
|
||||||
"react-ace": "^14.0.1",
|
"react-ace": "^14.0.1",
|
||||||
"react-draggable": "^4.6.0",
|
"react-draggable": "^4.7.0",
|
||||||
"react-error-boundary": "6.0.0",
|
"react-error-boundary": "6.0.0",
|
||||||
"react-js-cron": "^5.2.0",
|
"react-js-cron": "^5.2.0",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
@@ -45044,15 +44922,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"packages/superset-ui-core/node_modules/react-ace": {
|
||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||||
@@ -45071,9 +44940,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/superset-ui-core/node_modules/react-draggable": {
|
"packages/superset-ui-core/node_modules/react-draggable": {
|
||||||
"version": "4.6.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz",
|
||||||
"integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==",
|
"integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -45423,15 +45292,6 @@
|
|||||||
"react": "^18.3.0"
|
"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": {
|
"plugins/plugin-chart-ag-grid-table": {
|
||||||
"name": "@superset-ui/plugin-chart-ag-grid-table",
|
"name": "@superset-ui/plugin-chart-ag-grid-table",
|
||||||
"version": "0.20.3",
|
"version": "0.20.3",
|
||||||
@@ -45611,7 +45471,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@math.gl/web-mercator": "^4.1.0",
|
"@math.gl/web-mercator": "^4.1.0",
|
||||||
"mapbox-gl": "^3.24.1",
|
"mapbox-gl": "^3.25.0",
|
||||||
"maplibre-gl": "^5.24.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
"react-map-gl": "^8.1.1",
|
"react-map-gl": "^8.1.1",
|
||||||
"supercluster": "^8.0.1"
|
"supercluster": "^8.0.1"
|
||||||
@@ -45791,6 +45651,36 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"extraneous": true,
|
"extraneous": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"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/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/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,13 +192,13 @@
|
|||||||
"json-bigint": "^1.0.0",
|
"json-bigint": "^1.0.0",
|
||||||
"json-stringify-pretty-compact": "^4.0.0",
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"mapbox-gl": "^3.24.1",
|
"mapbox-gl": "^3.25.0",
|
||||||
"markdown-to-jsx": "^9.8.2",
|
"markdown-to-jsx": "^9.8.2",
|
||||||
"match-sorter": "^8.3.0",
|
"match-sorter": "^8.3.0",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"nanoid": "^5.1.11",
|
"nanoid": "^5.1.14",
|
||||||
"ol": "^10.9.0",
|
"ol": "^10.9.0",
|
||||||
"query-string": "9.4.0",
|
"query-string": "9.4.0",
|
||||||
"re-resizable": "^6.11.2",
|
"re-resizable": "^6.11.2",
|
||||||
@@ -303,7 +303,7 @@
|
|||||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||||
"babel-plugin-lodash": "^3.3.4",
|
"babel-plugin-lodash": "^3.3.4",
|
||||||
"baseline-browser-mapping": "^2.10.37",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"cheerio": "1.2.0",
|
"cheerio": "1.2.0",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"copy-webpack-plugin": "^14.0.0",
|
"copy-webpack-plugin": "^14.0.0",
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"react-dnd-test-backend": "^16.0.1",
|
"react-dnd-test-backend": "^16.0.1",
|
||||||
"react-refresh": "^0.18.0",
|
"react-refresh": "^0.18.0",
|
||||||
"react-resizable": "^4.0.1",
|
"react-resizable": "^4.0.2",
|
||||||
"redux-mock-store": "^1.5.4",
|
"redux-mock-store": "^1.5.4",
|
||||||
"source-map": "^0.7.6",
|
"source-map": "^0.7.6",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
"types": "./lib/authentication/index.d.ts",
|
"types": "./lib/authentication/index.d.ts",
|
||||||
"default": "./lib/authentication/index.js"
|
"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": {
|
"./commands": {
|
||||||
"types": "./lib/commands/index.d.ts",
|
"types": "./lib/commands/index.d.ts",
|
||||||
"default": "./lib/commands/index.js"
|
"default": "./lib/commands/index.js"
|
||||||
|
|||||||
156
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
156
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Chat contribution API for Superset extensions.
|
||||||
|
*
|
||||||
|
* Chat is a dedicated contribution type: an extension registers
|
||||||
|
* a chat via {@link registerChat} and the host owns where and how it is
|
||||||
|
* mounted. The host applies singleton resolution — multiple chat extensions
|
||||||
|
* may register, but exactly one is active at a time.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { chat } from '@apache-superset/core';
|
||||||
|
*
|
||||||
|
* chat.registerChat(
|
||||||
|
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
* AcmeTrigger,
|
||||||
|
* AcmePanel,
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
import type { Disposable, Event } from '../common';
|
||||||
|
|
||||||
|
export interface Chat {
|
||||||
|
/** The unique identifier for the chat. */
|
||||||
|
id: string;
|
||||||
|
/** The display name of the chat. */
|
||||||
|
name: string;
|
||||||
|
/** Optional description of the chat. */
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayMode = 'floating' | 'panel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a chat provider. Only one chat is active at a time; the most
|
||||||
|
* recently registered chat wins. Disposing the returned Disposable unregisters
|
||||||
|
* the chat.
|
||||||
|
*
|
||||||
|
* @param chat The chat descriptor (id, name).
|
||||||
|
* @param trigger The trigger component — the collapsed bubble entry point.
|
||||||
|
* Owns dynamic state such as unread counts.
|
||||||
|
* @param panel The panel component, rendered in either display mode. In
|
||||||
|
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
|
||||||
|
* alongside the main content.
|
||||||
|
* @returns A Disposable that unregisters the chat when disposed.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* chat.registerChat(
|
||||||
|
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
* AcmeTrigger,
|
||||||
|
* AcmePanel,
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare function registerChat(
|
||||||
|
chat: Chat,
|
||||||
|
trigger: ComponentType,
|
||||||
|
panel: ComponentType,
|
||||||
|
): Disposable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active chat descriptor, or undefined if none is registered.
|
||||||
|
*/
|
||||||
|
export declare function getChat(): Chat | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a chat is registered.
|
||||||
|
*/
|
||||||
|
export declare const onDidRegisterChat: Event<Chat>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a chat is unregistered.
|
||||||
|
*/
|
||||||
|
export declare const onDidUnregisterChat: Event<Chat>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the active chat's panel.
|
||||||
|
*
|
||||||
|
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||||
|
* No-op when no chat is registered or the panel is already open.
|
||||||
|
*/
|
||||||
|
export declare function open(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the active chat's panel.
|
||||||
|
*
|
||||||
|
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||||
|
* No-op when the panel is not open.
|
||||||
|
*/
|
||||||
|
export declare function close(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the active chat's panel is currently open.
|
||||||
|
*/
|
||||||
|
export declare function isOpen(): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when the chat panel opens. Also fired by the host's own
|
||||||
|
* controls, not only by an extension's open() call.
|
||||||
|
*/
|
||||||
|
export declare const onDidOpen: Event<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when the chat panel closes, whether triggered by an extension
|
||||||
|
* or by the host.
|
||||||
|
*/
|
||||||
|
export declare const onDidClose: Event<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current display mode.
|
||||||
|
*/
|
||||||
|
export declare function getDisplayMode(): DisplayMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the display mode. The mode is host-global and applies to whichever
|
||||||
|
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
|
||||||
|
* including those triggered by the host.
|
||||||
|
*/
|
||||||
|
export declare function setDisplayMode(displayMode: DisplayMode): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when the display mode changes, whether triggered by an
|
||||||
|
* extension via setDisplayMode() or by host-provided controls.
|
||||||
|
*/
|
||||||
|
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when the panel is resized in panel mode. Not all hosts provide
|
||||||
|
* a resizer — do not rely on this event firing.
|
||||||
|
*/
|
||||||
|
export declare const onDidResizePanel: Event<{ width: number }>;
|
||||||
|
|
||||||
|
// TODO: client actions API — tool availability functions will be added here
|
||||||
|
// once the client_actions SIP is finalized. The chat namespace is the
|
||||||
|
// intended integration point between the two SIPs.
|
||||||
@@ -223,8 +223,6 @@ export interface Extension {
|
|||||||
dependencies: string[];
|
dependencies: string[];
|
||||||
/** Human-readable description of the extension */
|
/** Human-readable description of the extension */
|
||||||
description: string;
|
description: string;
|
||||||
/** List of other extensions that this extension depends on */
|
|
||||||
extensionDependencies: string[];
|
|
||||||
/** Unique identifier for the extension */
|
/** Unique identifier for the extension */
|
||||||
id: string;
|
id: string;
|
||||||
/** Human-readable name of the extension */
|
/** Human-readable name of the extension */
|
||||||
|
|||||||
@@ -23,9 +23,10 @@
|
|||||||
* This module defines the aggregate interfaces used by the extension.json
|
* This module defines the aggregate interfaces used by the extension.json
|
||||||
* manifest and the `superset-extensions` build command. Individual metadata
|
* manifest and the `superset-extensions` build command. Individual metadata
|
||||||
* types are defined in their respective namespace modules (commands, views,
|
* types are defined in their respective namespace modules (commands, views,
|
||||||
* menus, editors) and re-exported here for the manifest schema.
|
* menus, editors, chat) and re-exported here for the manifest schema.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Chat } from '../chat';
|
||||||
import { Command } from '../commands';
|
import { Command } from '../commands';
|
||||||
import { View } from '../views';
|
import { View } from '../views';
|
||||||
import { Menu } from '../menus';
|
import { Menu } from '../menus';
|
||||||
@@ -71,7 +72,8 @@ export interface MenuContributions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
* Aggregates all contributions (commands, menus, views, editors, and chat)
|
||||||
|
* provided by an extension or module.
|
||||||
*/
|
*/
|
||||||
export interface Contributions {
|
export interface Contributions {
|
||||||
/** List of commands. */
|
/** List of commands. */
|
||||||
@@ -82,4 +84,10 @@ export interface Contributions {
|
|||||||
views: ViewContributions;
|
views: ViewContributions;
|
||||||
/** List of editors. */
|
/** List of editors. */
|
||||||
editors?: Editor[];
|
editors?: Editor[];
|
||||||
|
/**
|
||||||
|
* The chat contributed by the extension — at most one per extension, since
|
||||||
|
* the host applies singleton resolution and renders exactly one active
|
||||||
|
* chat at a time.
|
||||||
|
*/
|
||||||
|
chat?: Chat;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,12 @@
|
|||||||
*/
|
*/
|
||||||
export * as common from './common';
|
export * as common from './common';
|
||||||
export * as authentication from './authentication';
|
export * as authentication from './authentication';
|
||||||
|
export * as chat from './chat';
|
||||||
export * as commands from './commands';
|
export * as commands from './commands';
|
||||||
export * as editors from './editors';
|
export * as editors from './editors';
|
||||||
export * as extensions from './extensions';
|
export * as extensions from './extensions';
|
||||||
export * as menus from './menus';
|
export * as menus from './menus';
|
||||||
|
export * as navigation from './navigation';
|
||||||
export * as sqlLab from './sqlLab';
|
export * as sqlLab from './sqlLab';
|
||||||
export * as views from './views';
|
export * as views from './views';
|
||||||
export * as contributions from './contributions';
|
export * as contributions from './contributions';
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Navigation namespace for Superset extensions.
|
||||||
|
*
|
||||||
|
* Exposes the current application surface so extensions can react to route
|
||||||
|
* changes without polling. Entity-level context (chart, dashboard, dataset)
|
||||||
|
* is intentionally not included here — surface-specific namespaces that
|
||||||
|
* resolve entity payloads are introduced in later phases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Event } from '../common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of top-level application surfaces.
|
||||||
|
*
|
||||||
|
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
|
||||||
|
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
|
||||||
|
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
|
||||||
|
* single entity is active. `'sqllab'` is the SQL editor where
|
||||||
|
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
|
||||||
|
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
|
||||||
|
* the welcome surface and the fallback for any route not explicitly enumerated.
|
||||||
|
*/
|
||||||
|
export type Page =
|
||||||
|
| 'dashboard'
|
||||||
|
| 'dashboard_list'
|
||||||
|
| 'explore'
|
||||||
|
| 'chart_list'
|
||||||
|
| 'sqllab'
|
||||||
|
| 'query_history'
|
||||||
|
| 'saved_queries'
|
||||||
|
| 'dataset'
|
||||||
|
| 'dataset_list'
|
||||||
|
| 'home';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current page surface.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const page = navigation.getPage();
|
||||||
|
* if (page === 'dashboard') {
|
||||||
|
* // react to being on a dashboard surface
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare function getPage(): Page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired whenever the user navigates to a different surface.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const sub = navigation.onDidChangePage(page => {
|
||||||
|
* if (page === 'dashboard') {
|
||||||
|
* // react to navigating onto a dashboard surface
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* // later:
|
||||||
|
* sub.dispose();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export declare const onDidChangePage: Event<Page>;
|
||||||
@@ -30,12 +30,12 @@
|
|||||||
*
|
*
|
||||||
* views.registerView(
|
* views.registerView(
|
||||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
||||||
* () => <ResultStatsPanel />,
|
* ResultStatsPanel,
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactElement } from 'react';
|
import { ComponentType } from 'react';
|
||||||
import { Disposable, Event } from '../common';
|
import { Disposable, Event } from '../common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +58,7 @@ export interface View {
|
|||||||
*
|
*
|
||||||
* @param view The view descriptor (id and name).
|
* @param view The view descriptor (id and name).
|
||||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||||
* @param provider A function that returns the React element to render.
|
* @param component The React component to render at that location.
|
||||||
* @returns A Disposable that unregisters the view when disposed.
|
* @returns A Disposable that unregisters the view when disposed.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@@ -66,14 +66,14 @@ export interface View {
|
|||||||
* views.registerView(
|
* views.registerView(
|
||||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||||
* 'sqllab.panels',
|
* 'sqllab.panels',
|
||||||
* () => <ResultStatsPanel />,
|
* ResultStatsPanel,
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export declare function registerView(
|
export declare function registerView(
|
||||||
view: View,
|
view: View,
|
||||||
location: string,
|
location: string,
|
||||||
provider: () => ReactElement,
|
component: ComponentType,
|
||||||
): Disposable;
|
): Disposable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -132,6 +132,26 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'time_compare_full_range',
|
||||||
|
config: {
|
||||||
|
type: 'CheckboxControl',
|
||||||
|
label: t('Show full range for time shift'),
|
||||||
|
default: false,
|
||||||
|
description: t(
|
||||||
|
'Plot each time-shifted series across its full time range instead ' +
|
||||||
|
'of truncating it to the main series. Useful for comparing a ' +
|
||||||
|
'partial current period (e.g. today so far) against complete ' +
|
||||||
|
'prior periods (e.g. all of yesterday).',
|
||||||
|
),
|
||||||
|
visibility: ({ controls }) =>
|
||||||
|
Boolean(controls?.time_compare?.value) &&
|
||||||
|
(!Array.isArray(controls?.time_compare?.value) ||
|
||||||
|
controls.time_compare.value.length > 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: 'comparison_type',
|
name: 'comparison_type',
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function RadioButtonControl({
|
|||||||
...props
|
...props
|
||||||
}: RadioButtonControlProps) {
|
}: RadioButtonControlProps) {
|
||||||
const normalizedOptions = options.map(normalizeOption);
|
const normalizedOptions = options.map(normalizeOption);
|
||||||
const currentValue = initialValue || normalizedOptions[0].value;
|
const currentValue = initialValue ?? normalizedOptions[0]?.value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -359,6 +359,51 @@ test('handles empty options array gracefully', () => {
|
|||||||
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
|
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', () => {
|
test('renders with hovered prop', () => {
|
||||||
const { container } = setup({
|
const { container } = setup({
|
||||||
label: 'Test',
|
label: 'Test',
|
||||||
|
|||||||
@@ -22,21 +22,50 @@ under the License.
|
|||||||
[](https://www.npmjs.com/package/@superset-ui/core)
|
[](https://www.npmjs.com/package/@superset-ui/core)
|
||||||
[](https://libraries.io/npm/@superset-ui%2Fcore)
|
[](https://libraries.io/npm/@superset-ui%2Fcore)
|
||||||
|
|
||||||
Description
|
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`.
|
||||||
|
|
||||||
#### Example usage
|
#### Example usage
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { xxx } from '@superset-ui/core';
|
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',
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
#### API
|
|
||||||
|
|
||||||
`fn(args)`
|
|
||||||
|
|
||||||
- TBD
|
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
`@data-ui/build-config` is used to manage the build configuration for this package including babel
|
`@data-ui/build-config` is used to manage the build configuration for this package
|
||||||
builds, jest testing, eslint, and prettier.
|
including babel builds, jest testing, eslint, and prettier.
|
||||||
|
|
||||||
|
Run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd superset-frontend
|
||||||
|
npx jest packages/superset-ui-core
|
||||||
|
```
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
"parse-ms": "^4.0.0",
|
"parse-ms": "^4.0.0",
|
||||||
"re-resizable": "^6.11.2",
|
"re-resizable": "^6.11.2",
|
||||||
"react-ace": "^14.0.1",
|
"react-ace": "^14.0.1",
|
||||||
"react-draggable": "^4.6.0",
|
"react-draggable": "^4.7.0",
|
||||||
"react-error-boundary": "6.0.0",
|
"react-error-boundary": "6.0.0",
|
||||||
"react-js-cron": "^5.2.0",
|
"react-js-cron": "^5.2.0",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
|||||||
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
|
// 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.
|
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { mergeWith } from 'lodash';
|
import { cloneDeep, mergeWith } from 'lodash';
|
||||||
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
||||||
|
|
||||||
interface SafeMarkdownProps {
|
interface SafeMarkdownProps {
|
||||||
@@ -85,8 +85,15 @@ export function getOverrideHtmlSchema(
|
|||||||
originalSchema: typeof defaultSchema,
|
originalSchema: typeof defaultSchema,
|
||||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||||
) {
|
) {
|
||||||
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
|
// Merge into a fresh clone: mergeWith mutates its first argument, and the
|
||||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
// 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1146,6 +1146,127 @@ test('pasting an non-existent option should not add it if allowNewOptions is fal
|
|||||||
expect(await findAllSelectOptions()).toHaveLength(0);
|
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 () => {
|
test('does not fire onChange if the same value is selected in single mode', async () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
render(<Select {...defaultProps} onChange={onChange} />);
|
render(<Select {...defaultProps} onChange={onChange} />);
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const Select = forwardRef(
|
|||||||
className,
|
className,
|
||||||
allowClear,
|
allowClear,
|
||||||
allowNewOptions = false,
|
allowNewOptions = false,
|
||||||
|
allowNewOptionsOnPaste = false,
|
||||||
allowSelectAll = true,
|
allowSelectAll = true,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
autoClearSearchValue = false,
|
autoClearSearchValue = false,
|
||||||
@@ -692,20 +693,34 @@ const Select = forwardRef(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const token = tokenSeparators.find(token => pastedText.includes(token));
|
const token = tokenSeparators.find(token => pastedText.includes(token));
|
||||||
const array = token ? uniq(pastedText.split(token)) : [pastedText];
|
const array = token
|
||||||
|
? uniq(
|
||||||
|
pastedText
|
||||||
|
.split(token)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
: [pastedText.trim()].filter(Boolean);
|
||||||
|
|
||||||
const newOptions: SelectOptionsType = [];
|
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
|
const values = array
|
||||||
.map(item => {
|
.map(item => {
|
||||||
const option = getOption(item, fullSelectOptions, true);
|
const option = getOption(item, fullSelectOptions, true);
|
||||||
if (!option && allowNewOptions) {
|
if (!option && keepUnknownValues) {
|
||||||
const newOption = {
|
const newOption = {
|
||||||
label: item,
|
label: item,
|
||||||
value: item,
|
value: item,
|
||||||
isNewOption: true,
|
isNewOption: true,
|
||||||
};
|
};
|
||||||
newOptions.push(newOption);
|
newOptions.push(newOption);
|
||||||
|
return labelInValue ? { label: item, value: item } : item;
|
||||||
}
|
}
|
||||||
return getPastedTextValue(item);
|
return getPastedTextValue(item);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ export interface BaseSelectProps extends AntdExposedProps {
|
|||||||
* False by default.
|
* False by default.
|
||||||
* */
|
* */
|
||||||
allowNewOptions?: boolean;
|
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.
|
* It adds the aria-label tag for accessibility standards.
|
||||||
* Must be plain English and localized.
|
* Must be plain English and localized.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
|
|||||||
request: request => getInstance().request(request),
|
request: request => getInstance().request(request),
|
||||||
getCSRFToken: () => getInstance().getCSRFToken(),
|
getCSRFToken: () => getInstance().getCSRFToken(),
|
||||||
getUrl: (...args) => getInstance().getUrl(...args),
|
getUrl: (...args) => getInstance().getUrl(...args),
|
||||||
|
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
|
||||||
get guestTokenHeaderName() {
|
get guestTokenHeaderName() {
|
||||||
try {
|
try {
|
||||||
return getInstance().guestTokenHeaderName;
|
return getInstance().guestTokenHeaderName;
|
||||||
|
|||||||
@@ -150,6 +150,26 @@ export default class SupersetClientClass {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST request that returns a blob for file downloads.
|
||||||
|
* Unlike postForm, this uses AJAX so errors can be caught and handled.
|
||||||
|
* @param endpoint - API endpoint
|
||||||
|
* @param payload - Request payload
|
||||||
|
* @returns Promise resolving to Response with blob
|
||||||
|
*/
|
||||||
|
async postBlob(
|
||||||
|
endpoint: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
): Promise<Response> {
|
||||||
|
await this.ensureAuth();
|
||||||
|
return this.post({
|
||||||
|
endpoint,
|
||||||
|
postPayload: payload,
|
||||||
|
parseMethod: 'raw',
|
||||||
|
stringify: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async reAuthenticate() {
|
async reAuthenticate() {
|
||||||
return this.init(true);
|
return this.init(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
|
|||||||
| 'get'
|
| 'get'
|
||||||
| 'post'
|
| 'post'
|
||||||
| 'postForm'
|
| 'postForm'
|
||||||
|
| 'postBlob'
|
||||||
| 'put'
|
| 'put'
|
||||||
| 'request'
|
| 'request'
|
||||||
| 'init'
|
| 'init'
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { defaultSchema } from 'rehype-sanitize';
|
||||||
import {
|
import {
|
||||||
getOverrideHtmlSchema,
|
getOverrideHtmlSchema,
|
||||||
SafeMarkdown,
|
SafeMarkdown,
|
||||||
@@ -51,6 +53,36 @@ describe('getOverrideHtmlSchema', () => {
|
|||||||
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
|
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
|
||||||
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
|
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', () => {
|
describe('transformLinkUri', () => {
|
||||||
|
|||||||
@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
|
|||||||
getUrl: (...args: unknown[]) => string;
|
getUrl: (...args: unknown[]) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||||
expect(typeof SupersetClient.configure).toBe('function');
|
expect(typeof SupersetClient.configure).toBe('function');
|
||||||
expect(typeof SupersetClient.init).toBe('function');
|
expect(typeof SupersetClient.init).toBe('function');
|
||||||
expect(typeof SupersetClient.get).toBe('function');
|
expect(typeof SupersetClient.get).toBe('function');
|
||||||
expect(typeof SupersetClient.post).toBe('function');
|
expect(typeof SupersetClient.post).toBe('function');
|
||||||
expect(typeof SupersetClient.postForm).toBe('function');
|
expect(typeof SupersetClient.postForm).toBe('function');
|
||||||
|
expect(typeof SupersetClient.postBlob).toBe('function');
|
||||||
expect(typeof SupersetClient.delete).toBe('function');
|
expect(typeof SupersetClient.delete).toBe('function');
|
||||||
expect(typeof SupersetClient.put).toBe('function');
|
expect(typeof SupersetClient.put).toBe('function');
|
||||||
expect(typeof SupersetClient.request).toBe('function');
|
expect(typeof SupersetClient.request).toBe('function');
|
||||||
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
|
|||||||
expect(typeof SupersetClient.reAuthenticate).toBe('function');
|
expect(typeof SupersetClient.reAuthenticate).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||||
expect(SupersetClient.init).toThrow();
|
expect(SupersetClient.init).toThrow();
|
||||||
expect(SupersetClient.get).toThrow();
|
expect(SupersetClient.get).toThrow();
|
||||||
expect(SupersetClient.post).toThrow();
|
expect(SupersetClient.post).toThrow();
|
||||||
expect(SupersetClient.postForm).toThrow();
|
expect(SupersetClient.postForm).toThrow();
|
||||||
|
expect(SupersetClient.postBlob).toThrow();
|
||||||
expect(SupersetClient.delete).toThrow();
|
expect(SupersetClient.delete).toThrow();
|
||||||
expect(SupersetClient.put).toThrow();
|
expect(SupersetClient.put).toThrow();
|
||||||
expect(SupersetClient.request).toThrow();
|
expect(SupersetClient.request).toThrow();
|
||||||
|
|||||||
@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
|
|||||||
expect(authSpy).toHaveBeenCalledTimes(0);
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -305,36 +305,16 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
|||||||
key: JSON.stringify,
|
key: JSON.stringify,
|
||||||
},
|
},
|
||||||
done: datamap => {
|
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
|
datamap.svg
|
||||||
.selectAll('.datamaps-subunit')
|
.selectAll('.datamaps-subunit')
|
||||||
.on('contextmenu', handleContextMenu)
|
.on('contextmenu', handleContextMenu)
|
||||||
.on('click', handleClick)
|
.on('click', handleClick);
|
||||||
// Use namespaced events to avoid overriding Datamaps' default tooltip handlers
|
|
||||||
.on('mouseover.fillPreserve', function onMouseOver() {
|
|
||||||
if (inContextMenu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const element = d3.select(this);
|
|
||||||
const classes = element.attr('class') || '';
|
|
||||||
const countryId = classes.split(' ')[1];
|
|
||||||
const countryData = mapData[countryId];
|
|
||||||
const originalFill =
|
|
||||||
(countryData && countryData.fillColor) || theme.colorBorder;
|
|
||||||
// Store original fill color for restoration
|
|
||||||
element.attr('data-original-fill', originalFill);
|
|
||||||
})
|
|
||||||
.on('mouseout.fillPreserve', function onMouseOut() {
|
|
||||||
if (inContextMenu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const element = d3.select(this);
|
|
||||||
const originalFill = element.attr('data-original-fill');
|
|
||||||
// Restore the original fill color (data-based or default no-data color)
|
|
||||||
if (originalFill) {
|
|
||||||
element.style('fill', originalFill);
|
|
||||||
element.attr('data-original-fill', null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,15 +58,6 @@ interface WorldMapProps {
|
|||||||
formatter: ValueFormatter;
|
formatter: ValueFormatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
type MouseEventHandler = (this: HTMLElement) => void;
|
|
||||||
|
|
||||||
interface MockD3Selection {
|
|
||||||
attr: jest.Mock;
|
|
||||||
style: jest.Mock;
|
|
||||||
classed: jest.Mock;
|
|
||||||
selectAll: jest.Mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock Datamap
|
// Mock Datamap
|
||||||
const mockBubbles = jest.fn();
|
const mockBubbles = jest.fn();
|
||||||
const mockUpdateChoropleth = jest.fn();
|
const mockUpdateChoropleth = jest.fn();
|
||||||
@@ -157,244 +148,36 @@ afterEach(() => {
|
|||||||
document.body.removeChild(container);
|
document.body.removeChild(container);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sets up mouseover and mouseout handlers on countries', () => {
|
test('relies on Datamaps highlightOnHover without adding conflicting fill handlers', () => {
|
||||||
|
// Regression test for #37761. The hover highlight got stuck on Chrome/Edge
|
||||||
|
// because hand-written mouseover/mouseout handlers competed with Datamaps'
|
||||||
|
// built-in highlightOnHover restore path, and the winning path was
|
||||||
|
// browser-timing-dependent. The chart should rely on the single built-in
|
||||||
|
// path and register no custom fill-restoring hover handlers on the countries.
|
||||||
WorldMap(container, baseProps);
|
WorldMap(container, baseProps);
|
||||||
|
|
||||||
expect(mockSvg.selectAll).toHaveBeenCalledWith('.datamaps-subunit');
|
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
|
||||||
const onCalls = mockSvg.on.mock.calls;
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(geographyConfig?.highlightOnHover).toBe(true);
|
||||||
|
|
||||||
// Find mouseover and mouseout handler registrations (namespaced events)
|
const hoverHandlers = mockSvg.on.mock.calls.filter((call: [string]) =>
|
||||||
const hasMouseover = onCalls.some(
|
/^mouse(over|out)/.test(call[0]),
|
||||||
call => call[0] === 'mouseover.fillPreserve',
|
|
||||||
);
|
);
|
||||||
const hasMouseout = onCalls.some(call => call[0] === 'mouseout.fillPreserve');
|
expect(hoverHandlers).toEqual([]);
|
||||||
|
|
||||||
expect(hasMouseover).toBe(true);
|
|
||||||
expect(hasMouseout).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stores original fill color on mouseover', () => {
|
test('disables Datamaps highlightOnHover while the context menu is open', () => {
|
||||||
// Create a mock DOM element with d3 selection capabilities
|
// Companion to the regression guard above: when the context menu is open we
|
||||||
const mockElement = document.createElement('path');
|
// pass highlightOnHover: false so hover highlighting is suppressed at init.
|
||||||
mockElement.setAttribute('class', 'datamaps-subunit USA');
|
WorldMap(container, { ...baseProps, inContextMenu: true });
|
||||||
mockElement.style.fill = 'rgb(100, 150, 200)';
|
|
||||||
container.appendChild(mockElement);
|
|
||||||
|
|
||||||
let mouseoverHandler: MouseEventHandler | null = null;
|
const geographyConfig = lastDatamapConfig?.geographyConfig as Record<
|
||||||
|
string,
|
||||||
// Mock d3.select to return the mock element
|
unknown
|
||||||
const mockD3Selection: MockD3Selection = {
|
>;
|
||||||
attr: jest.fn((attrName: string, value?: string) => {
|
expect(geographyConfig?.highlightOnHover).toBe(false);
|
||||||
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', () => {
|
test('does not throw error when onContextMenu is undefined', () => {
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
|||||||
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
||||||
const { extras = {} } = baseQueryObject;
|
const { extras = {} } = baseQueryObject;
|
||||||
let postProcessing: PostProcessingRule[] = [];
|
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(
|
const nonCustomNorInheritShifts = ensureIsArray(
|
||||||
formData.time_compare,
|
formData.time_compare,
|
||||||
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
||||||
@@ -157,15 +164,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
|||||||
metrics.concat(percentMetrics),
|
metrics.concat(percentMetrics),
|
||||||
getMetricLabel,
|
getMetricLabel,
|
||||||
);
|
);
|
||||||
postProcessing = [
|
contributionPostProcessing = {
|
||||||
{
|
operation: 'contribution',
|
||||||
operation: 'contribution',
|
options: {
|
||||||
options: {
|
columns: percentMetricLabels,
|
||||||
columns: percentMetricLabels,
|
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
||||||
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
};
|
||||||
|
postProcessing = [contributionPostProcessing];
|
||||||
}
|
}
|
||||||
// Add the operator for the time comparison if some is selected
|
// Add the operator for the time comparison if some is selected
|
||||||
if (!isEmpty(timeOffsets)) {
|
if (!isEmpty(timeOffsets)) {
|
||||||
@@ -658,7 +664,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
|||||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||||
row_limit: 0,
|
row_limit: 0,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
post_processing: [],
|
// Reapply only the percent-metric contribution rule so the totals row
|
||||||
|
// exposes `%metric` keys (value/value = 100% on the single aggregated
|
||||||
|
// row). The time-comparison operator from the main query is omitted on
|
||||||
|
// purpose; it must not run against the single-row totals query.
|
||||||
|
post_processing: contributionPostProcessing
|
||||||
|
? [contributionPostProcessing]
|
||||||
|
: [],
|
||||||
order_desc: undefined, // we don't need orderby stuff here,
|
order_desc: undefined, // we don't need orderby stuff here,
|
||||||
orderby: undefined, // because this query will be used for get total aggregation.
|
orderby: undefined, // because this query will be used for get total aggregation.
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -852,6 +852,75 @@ describe('plugin-chart-ag-grid-table', () => {
|
|||||||
expect(totalsQuery.columns).toEqual([]);
|
expect(totalsQuery.columns).toEqual([]);
|
||||||
expect(totalsQuery.row_limit).toBe(0);
|
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', () => {
|
describe('Integration - all filter types together', () => {
|
||||||
|
|||||||
@@ -231,6 +231,56 @@ describe('BigNumberTotal transformProps', () => {
|
|||||||
expect(result.headerFormatter(500)).toBe('$500');
|
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', () => {
|
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
|
||||||
// Override the getColorFormatters mock to return specific value
|
// Override the getColorFormatters mock to return specific value
|
||||||
const mockFormatters = [{ formatter: 'red' }];
|
const mockFormatters = [{ formatter: 'red' }];
|
||||||
|
|||||||
@@ -79,8 +79,15 @@ export default function transformProps(
|
|||||||
const formattedSubtitleFontSize = subtitle?.trim()
|
const formattedSubtitleFontSize = subtitle?.trim()
|
||||||
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
|
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
|
||||||
: (subheaderFontSize ?? 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 =
|
const bigNumber =
|
||||||
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
|
parsedValue === null &&
|
||||||
|
typeof rawValue === 'string' &&
|
||||||
|
rawValue.trim() !== ''
|
||||||
|
? rawValue
|
||||||
|
: parsedValue;
|
||||||
|
|
||||||
let metricEntry: Metric | undefined;
|
let metricEntry: Metric | undefined;
|
||||||
if (chartProps.datasource?.metrics) {
|
if (chartProps.datasource?.metrics) {
|
||||||
|
|||||||
@@ -189,8 +189,10 @@ function BigNumberVis({
|
|||||||
text = t('No data');
|
text = t('No data');
|
||||||
} else if (typeof bigNumber === 'number') {
|
} else if (typeof bigNumber === 'number') {
|
||||||
text = headerFormatter(bigNumber);
|
text = headerFormatter(bigNumber);
|
||||||
|
} else if (typeof bigNumber === 'string') {
|
||||||
|
text = bigNumber;
|
||||||
} else {
|
} else {
|
||||||
// For string/boolean/Date values, convert to number if possible, else show as string
|
// For boolean/Date values, convert to number if possible, else show as string
|
||||||
const numValue = Number(bigNumber);
|
const numValue = Number(bigNumber);
|
||||||
text = Number.isNaN(numValue)
|
text = Number.isNaN(numValue)
|
||||||
? String(bigNumber)
|
? String(bigNumber)
|
||||||
|
|||||||
@@ -318,14 +318,25 @@ function createAdvancedAnalyticsSection(
|
|||||||
): ControlPanelSectionConfig {
|
): ControlPanelSectionConfig {
|
||||||
const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls);
|
const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls);
|
||||||
aaWithSuffix.label = label;
|
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) {
|
if (!controlSuffix) {
|
||||||
return aaWithSuffix;
|
return aaWithSuffix;
|
||||||
}
|
}
|
||||||
aaWithSuffix.controlSetRows.forEach(row =>
|
aaWithSuffix.controlSetRows.forEach(row =>
|
||||||
row.forEach((control: CustomControlItem) => {
|
row.forEach(control => {
|
||||||
if (control?.name) {
|
const item = control as CustomControlItem;
|
||||||
// eslint-disable-next-line no-param-reassign
|
if (item?.name) {
|
||||||
control.name = `${control.name}${controlSuffix}`;
|
item.name = `${item.name}${controlSuffix}`;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -331,10 +331,16 @@ export default function transformProps(
|
|||||||
type: legendType,
|
type: legendType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const chartPadding = getChartPadding(
|
||||||
|
showLegend,
|
||||||
|
legendOrientation,
|
||||||
|
effectiveLegendMargin,
|
||||||
|
);
|
||||||
|
|
||||||
const series: RadarSeriesOption[] = [
|
const series: RadarSeriesOption[] = [
|
||||||
{
|
{
|
||||||
type: 'radar',
|
type: 'radar',
|
||||||
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
|
...chartPadding,
|
||||||
animation: false,
|
animation: false,
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: {
|
label: {
|
||||||
@@ -361,6 +367,15 @@ export default function transformProps(
|
|||||||
numberFormatter,
|
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 = {
|
const echartOptions: EChartsCoreOption = {
|
||||||
grid: {
|
grid: {
|
||||||
...defaultGrid,
|
...defaultGrid,
|
||||||
@@ -390,6 +405,7 @@ export default function transformProps(
|
|||||||
color: theme.colorSplit,
|
color: theme.colorSplit,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
center: radarCenter,
|
||||||
splitArea: {
|
splitArea: {
|
||||||
show: true,
|
show: true,
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
|
|||||||
@@ -92,6 +92,20 @@ const config: ControlPanelConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'show_null_values',
|
||||||
|
config: {
|
||||||
|
type: 'CheckboxControl',
|
||||||
|
label: t('Show Null Values'),
|
||||||
|
default: true,
|
||||||
|
renderTrigger: true,
|
||||||
|
description: t(
|
||||||
|
'Whether to display entries with null values in the hierarchy',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
name: 'label_type',
|
name: 'label_type',
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ export default function transformProps(
|
|||||||
showLabels,
|
showLabels,
|
||||||
showLabelsThreshold,
|
showLabelsThreshold,
|
||||||
showTotal,
|
showTotal,
|
||||||
|
// Default to true so charts saved before this control existed keep
|
||||||
|
// showing null values instead of silently hiding them on upgrade.
|
||||||
|
showNullValues = true,
|
||||||
sliceId,
|
sliceId,
|
||||||
} = formData;
|
} = formData;
|
||||||
const {
|
const {
|
||||||
@@ -251,6 +254,7 @@ export default function transformProps(
|
|||||||
columnLabels,
|
columnLabels,
|
||||||
metricLabel,
|
metricLabel,
|
||||||
secondaryMetricLabel,
|
secondaryMetricLabel,
|
||||||
|
!showNullValues,
|
||||||
);
|
);
|
||||||
const totalValue = treeData.reduce(
|
const totalValue = treeData.reduce(
|
||||||
(result, treeNode) => result + treeNode.value,
|
(result, treeNode) => result + treeNode.value,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '../../../../spec/helpers/testing-library';
|
} from '../../../../spec/helpers/testing-library';
|
||||||
import { AxisType } from '@superset-ui/core';
|
import { AxisType } from '@superset-ui/core';
|
||||||
import type { EChartsCoreOption } from 'echarts/core';
|
import type { EChartsCoreOption } from 'echarts/core';
|
||||||
|
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import {
|
import {
|
||||||
LegendOrientation,
|
LegendOrientation,
|
||||||
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
|
|||||||
onFocusedSeries: jest.fn(),
|
onFocusedSeries: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function getLatestHeight() {
|
function getLatestEchartProps() {
|
||||||
const lastCall = mockEchart.mock.calls.at(-1);
|
const lastCall = mockEchart.mock.calls.at(-1);
|
||||||
expect(lastCall).toBeDefined();
|
expect(lastCall).toBeDefined();
|
||||||
const [props] = lastCall as [EchartsProps];
|
const [props] = lastCall as [EchartsProps];
|
||||||
return props.height;
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestHeight() {
|
||||||
|
return getLatestEchartProps().height;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('observes extra control height changes when ResizeObserver is available', async () => {
|
test('observes extra control height changes when ResizeObserver is available', async () => {
|
||||||
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
|
|||||||
const clickHandler = props.eventHandlers?.click;
|
const clickHandler = props.eventHandlers?.click;
|
||||||
if (clickHandler) {
|
if (clickHandler) {
|
||||||
clickHandler({
|
clickHandler({
|
||||||
|
componentType: 'series',
|
||||||
seriesName: 'Sales', // This is the metric name
|
seriesName: 'Sales', // This is the metric name
|
||||||
data: ['Product A', 100], // X-axis value is 'Product A'
|
data: ['Product A', 100], // X-axis value is 'Product A'
|
||||||
name: 'Product A',
|
name: 'Product A',
|
||||||
@@ -361,6 +367,149 @@ 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 () => {
|
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
|
||||||
const setDataMaskMock = jest.fn();
|
const setDataMaskMock = jest.fn();
|
||||||
|
|
||||||
@@ -385,6 +534,7 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
|
|||||||
const clickHandler = props.eventHandlers?.click;
|
const clickHandler = props.eventHandlers?.click;
|
||||||
if (clickHandler) {
|
if (clickHandler) {
|
||||||
clickHandler({
|
clickHandler({
|
||||||
|
componentType: 'series',
|
||||||
seriesName: 'Sales',
|
seriesName: 'Sales',
|
||||||
data: [1609459200000, 100], // Timestamp
|
data: [1609459200000, 100], // Timestamp
|
||||||
name: '2021-01-01',
|
name: '2021-01-01',
|
||||||
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
|||||||
...defaultProps,
|
...defaultProps,
|
||||||
emitCrossFilters: true,
|
emitCrossFilters: true,
|
||||||
setDataMask: setDataMaskMock,
|
setDataMask: setDataMaskMock,
|
||||||
|
formData: {
|
||||||
|
...defaultFormData,
|
||||||
|
orientation: OrientationType.Horizontal,
|
||||||
|
},
|
||||||
groupby: [], // No dimensions
|
groupby: [], // No dimensions
|
||||||
xAxis: {
|
xAxis: {
|
||||||
label: 'category_column',
|
label: 'category_column',
|
||||||
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
|
|||||||
const clickHandler = props.eventHandlers?.click;
|
const clickHandler = props.eventHandlers?.click;
|
||||||
if (clickHandler) {
|
if (clickHandler) {
|
||||||
clickHandler({
|
clickHandler({
|
||||||
|
componentType: 'series',
|
||||||
seriesName: 'Sales', // This is the metric name
|
seriesName: 'Sales', // This is the metric name
|
||||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||||
name: 'Product A',
|
name: 'Product A',
|
||||||
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
|||||||
...defaultProps,
|
...defaultProps,
|
||||||
emitCrossFilters: true,
|
emitCrossFilters: true,
|
||||||
onContextMenu: onContextMenuMock,
|
onContextMenu: onContextMenuMock,
|
||||||
|
formData: {
|
||||||
|
...defaultFormData,
|
||||||
|
orientation: OrientationType.Horizontal,
|
||||||
|
},
|
||||||
groupby: [], // No dimensions
|
groupby: [], // No dimensions
|
||||||
xAxis: {
|
xAxis: {
|
||||||
label: 'category_column',
|
label: 'category_column',
|
||||||
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
|
|||||||
expect(contextMenuHandler).toBeDefined();
|
expect(contextMenuHandler).toBeDefined();
|
||||||
if (contextMenuHandler) {
|
if (contextMenuHandler) {
|
||||||
await contextMenuHandler({
|
await contextMenuHandler({
|
||||||
|
componentType: 'series',
|
||||||
seriesName: 'Sales', // This is the metric name
|
seriesName: 'Sales', // This is the metric name
|
||||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||||
name: 'Product A',
|
name: 'Product A',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
DTTM_ALIAS,
|
DTTM_ALIAS,
|
||||||
BinaryQueryObjectFilterClause,
|
BinaryQueryObjectFilterClause,
|
||||||
@@ -27,12 +27,15 @@ import {
|
|||||||
LegendState,
|
LegendState,
|
||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import type { ViewRootGroup } from 'echarts/types/src/util/types';
|
import type {
|
||||||
|
ECElementEvent,
|
||||||
|
ViewRootGroup,
|
||||||
|
} from 'echarts/types/src/util/types';
|
||||||
import type GlobalModel from 'echarts/types/src/model/Global';
|
import type GlobalModel from 'echarts/types/src/model/Global';
|
||||||
import type ComponentModel from 'echarts/types/src/model/Component';
|
import type ComponentModel from 'echarts/types/src/model/Component';
|
||||||
import { EchartsHandler, EventHandlers } from '../types';
|
import { EchartsHandler, EventHandlers } from '../types';
|
||||||
import Echart from '../components/Echart';
|
import Echart from '../components/Echart';
|
||||||
import { TimeseriesChartTransformedProps } from './types';
|
import { OrientationType, TimeseriesChartTransformedProps } from './types';
|
||||||
import { formatSeriesName } from '../utils/series';
|
import { formatSeriesName } from '../utils/series';
|
||||||
import { ExtraControls } from '../components/ExtraControls';
|
import { ExtraControls } from '../components/ExtraControls';
|
||||||
|
|
||||||
@@ -218,6 +221,26 @@ export default function EchartsTimeseries({
|
|||||||
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
|
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
|
||||||
const canCrossFilterByXAxis =
|
const canCrossFilterByXAxis =
|
||||||
!hasDimensions && xAxis.type === AxisType.Category;
|
!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 = {
|
const eventHandlers: EventHandlers = {
|
||||||
click: props => {
|
click: props => {
|
||||||
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
|
|||||||
// Cross-filter by dimension (original behavior)
|
// Cross-filter by dimension (original behavior)
|
||||||
const { seriesName: name } = props;
|
const { seriesName: name } = props;
|
||||||
handleChange(name);
|
handleChange(name);
|
||||||
} else if (canCrossFilterByXAxis && props.name != null) {
|
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
|
||||||
// Cross-filter by X-axis value when no dimensions (issue #25334).
|
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
||||||
// Use `name` (the category-axis value) instead of `data[0]`: for
|
const categoryAxisValue = getCategoryAxisValue(
|
||||||
// horizontal bars the data tuple is value-first, so `data[0]` would
|
props.data,
|
||||||
// be the metric value rather than the category (issue #41102).
|
props.name,
|
||||||
handleXAxisChange(props.name);
|
);
|
||||||
|
if (categoryAxisValue !== undefined) {
|
||||||
|
handleXAxisChange(categoryAxisValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, TIMER_DURATION);
|
}, TIMER_DURATION);
|
||||||
},
|
},
|
||||||
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
|
|||||||
let crossFilter;
|
let crossFilter;
|
||||||
if (hasDimensions) {
|
if (hasDimensions) {
|
||||||
crossFilter = getCrossFilterDataMask(seriesName);
|
crossFilter = getCrossFilterDataMask(seriesName);
|
||||||
} else if (canCrossFilterByXAxis && eventParams.name != null) {
|
} else if (
|
||||||
// Use `name` (the category-axis value), not `data[0]`, so horizontal
|
canCrossFilterByXAxis &&
|
||||||
// bars cross-filter on the category and not the metric (issue #41102).
|
eventParams.componentType === 'series'
|
||||||
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
|
) {
|
||||||
|
const categoryAxisValue = getCategoryAxisValue(
|
||||||
|
data,
|
||||||
|
eventParams.name,
|
||||||
|
);
|
||||||
|
if (categoryAxisValue !== undefined) {
|
||||||
|
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||||
@@ -336,6 +369,33 @@ 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 = {
|
const zrEventHandlers: EventHandlers = {
|
||||||
dblclick: params => {
|
dblclick: params => {
|
||||||
// clear single click timer
|
// clear single click timer
|
||||||
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
|
|||||||
width={width}
|
width={width}
|
||||||
echartOptions={echartOptions}
|
echartOptions={echartOptions}
|
||||||
eventHandlers={eventHandlers}
|
eventHandlers={eventHandlers}
|
||||||
|
queryEventHandlers={queryEventHandlers}
|
||||||
zrEventHandlers={zrEventHandlers}
|
zrEventHandlers={zrEventHandlers}
|
||||||
selectedValues={selectedValues}
|
selectedValues={selectedValues}
|
||||||
vizType={formData.vizType}
|
vizType={formData.vizType}
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ export default function buildQuery(formData: QueryFormData) {
|
|||||||
? formData.time_compare
|
? 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 [
|
return [
|
||||||
{
|
{
|
||||||
...baseQueryObject,
|
...baseQueryObject,
|
||||||
@@ -92,6 +97,7 @@ export default function buildQuery(formData: QueryFormData) {
|
|||||||
// todo: move `normalizeOrderBy to extractQueryFields`
|
// todo: move `normalizeOrderBy to extractQueryFields`
|
||||||
orderby: normalizeOrderBy(baseQueryObject).orderby,
|
orderby: normalizeOrderBy(baseQueryObject).orderby,
|
||||||
time_offsets,
|
time_offsets,
|
||||||
|
time_compare_full_range,
|
||||||
/* Note that:
|
/* Note that:
|
||||||
1. The resample, rolling, cum, timeCompare operators should be after pivot.
|
1. The resample, rolling, cum, timeCompare operators should be after pivot.
|
||||||
2. Resample must come before rolling so that imputed values are
|
2. Resample must come before rolling so that imputed values are
|
||||||
|
|||||||
@@ -381,6 +381,15 @@ export default function transformProps(
|
|||||||
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
|
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
|
||||||
const inverted = invert(verboseMap);
|
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 } = {};
|
const offsetLineWidths: { [key: string]: number } = {};
|
||||||
|
|
||||||
// For horizontal bar charts, calculate min/max from data to avoid cutting off labels
|
// For horizontal bar charts, calculate min/max from data to avoid cutting off labels
|
||||||
@@ -478,7 +487,7 @@ export default function transformProps(
|
|||||||
colorScaleKey,
|
colorScaleKey,
|
||||||
{
|
{
|
||||||
area,
|
area,
|
||||||
connectNulls: derivedSeries,
|
connectNulls: derivedSeries || timeCompareFullRange,
|
||||||
filterState,
|
filterState,
|
||||||
seriesContexts,
|
seriesContexts,
|
||||||
markerEnabled,
|
markerEnabled,
|
||||||
@@ -889,6 +898,10 @@ export default function transformProps(
|
|||||||
name: xAxisTitle,
|
name: xAxisTitle,
|
||||||
nameGap: convertInteger(xAxisTitleMargin),
|
nameGap: convertInteger(xAxisTitleMargin),
|
||||||
nameLocation: 'middle',
|
nameLocation: 'middle',
|
||||||
|
...(xAxisType === AxisType.Category &&
|
||||||
|
groupBy.length === 0 && {
|
||||||
|
triggerEvent: true,
|
||||||
|
}),
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
// When rotation is applied on time axes, hideOverlap can
|
// When rotation is applied on time axes, hideOverlap can
|
||||||
// aggressively hide the last label. Rotated labels already
|
// aggressively hide the last label. Rotated labels already
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { render, waitFor } from '../../../../spec/helpers/testing-library';
|
||||||
|
import type { EChartsCoreOption } from 'echarts/core';
|
||||||
|
import Echart from './Echart';
|
||||||
|
import type { EchartsProps } from '../types';
|
||||||
|
|
||||||
|
type Handler = (params: unknown) => void;
|
||||||
|
type Listener = {
|
||||||
|
query?: string;
|
||||||
|
handler: Handler;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Record<string, Listener[]> = {};
|
||||||
|
|
||||||
|
const mockChart = {
|
||||||
|
dispatchAction: jest.fn(),
|
||||||
|
dispose: jest.fn(),
|
||||||
|
getOption: jest.fn(() => ({})),
|
||||||
|
getZr: jest.fn(() => ({
|
||||||
|
off: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
})),
|
||||||
|
off: jest.fn((name: string, handler?: Handler) => {
|
||||||
|
if (!handler) {
|
||||||
|
delete listeners[name];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listeners[name] = (listeners[name] || []).filter(
|
||||||
|
listener => listener.handler !== handler,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
on: jest.fn(
|
||||||
|
(name: string, queryOrHandler: string | Handler, handler?: Handler) => {
|
||||||
|
listeners[name] = listeners[name] || [];
|
||||||
|
listeners[name].push(
|
||||||
|
handler
|
||||||
|
? { query: queryOrHandler as string, handler }
|
||||||
|
: { handler: queryOrHandler as Handler },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
resize: jest.fn(),
|
||||||
|
setOption: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('echarts/core', () => ({
|
||||||
|
init: jest.fn(() => mockChart),
|
||||||
|
registerLocale: jest.fn(),
|
||||||
|
use: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('echarts/charts', () => ({
|
||||||
|
BarChart: 'BarChart',
|
||||||
|
BoxplotChart: 'BoxplotChart',
|
||||||
|
CustomChart: 'CustomChart',
|
||||||
|
FunnelChart: 'FunnelChart',
|
||||||
|
GaugeChart: 'GaugeChart',
|
||||||
|
GraphChart: 'GraphChart',
|
||||||
|
HeatmapChart: 'HeatmapChart',
|
||||||
|
LineChart: 'LineChart',
|
||||||
|
PieChart: 'PieChart',
|
||||||
|
RadarChart: 'RadarChart',
|
||||||
|
SankeyChart: 'SankeyChart',
|
||||||
|
ScatterChart: 'ScatterChart',
|
||||||
|
SunburstChart: 'SunburstChart',
|
||||||
|
TreeChart: 'TreeChart',
|
||||||
|
TreemapChart: 'TreemapChart',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('echarts/components', () => ({
|
||||||
|
AriaComponent: 'AriaComponent',
|
||||||
|
DataZoomComponent: 'DataZoomComponent',
|
||||||
|
GraphicComponent: 'GraphicComponent',
|
||||||
|
GridComponent: 'GridComponent',
|
||||||
|
LegendComponent: 'LegendComponent',
|
||||||
|
MarkAreaComponent: 'MarkAreaComponent',
|
||||||
|
MarkLineComponent: 'MarkLineComponent',
|
||||||
|
TitleComponent: 'TitleComponent',
|
||||||
|
ToolboxComponent: 'ToolboxComponent',
|
||||||
|
TooltipComponent: 'TooltipComponent',
|
||||||
|
VisualMapComponent: 'VisualMapComponent',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('echarts/features', () => ({
|
||||||
|
LabelLayout: 'LabelLayout',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('echarts/renderers', () => ({
|
||||||
|
CanvasRenderer: 'CanvasRenderer',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
common: {
|
||||||
|
locale: 'en',
|
||||||
|
},
|
||||||
|
dashboardState: {
|
||||||
|
isRefreshing: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps: EchartsProps = {
|
||||||
|
echartOptions: { series: [] } as EChartsCoreOption,
|
||||||
|
height: 100,
|
||||||
|
refs: {},
|
||||||
|
width: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEchart = (props: Partial<EchartsProps> = {}) => (
|
||||||
|
<Echart {...defaultProps} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const trigger = (name: string) => {
|
||||||
|
(listeners[name] || []).forEach(listener => listener.handler({}));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.keys(listeners).forEach(name => {
|
||||||
|
delete listeners[name];
|
||||||
|
});
|
||||||
|
Object.values(mockChart).forEach(value => {
|
||||||
|
if (jest.isMockFunction(value)) {
|
||||||
|
value.mockClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces stale query event handlers without clearing regular event handlers', async () => {
|
||||||
|
const regularClickHandler = jest.fn();
|
||||||
|
const firstQueryHandler = jest.fn();
|
||||||
|
const secondQueryHandler = jest.fn();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
renderEchart({
|
||||||
|
eventHandlers: {
|
||||||
|
click: regularClickHandler,
|
||||||
|
},
|
||||||
|
queryEventHandlers: [
|
||||||
|
{
|
||||||
|
handler: firstQueryHandler,
|
||||||
|
name: 'click',
|
||||||
|
query: 'xAxis.category',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ initialState, useRedux: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockChart.on).toHaveBeenCalledWith(
|
||||||
|
'click',
|
||||||
|
'xAxis.category',
|
||||||
|
firstQueryHandler,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
renderEchart({
|
||||||
|
eventHandlers: {
|
||||||
|
click: regularClickHandler,
|
||||||
|
},
|
||||||
|
queryEventHandlers: [
|
||||||
|
{
|
||||||
|
handler: secondQueryHandler,
|
||||||
|
name: 'click',
|
||||||
|
query: 'xAxis.category',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockChart.on).toHaveBeenCalledWith(
|
||||||
|
'click',
|
||||||
|
'xAxis.category',
|
||||||
|
secondQueryHandler,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
trigger('click');
|
||||||
|
|
||||||
|
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||||
|
expect(secondQueryHandler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
regularClickHandler.mockClear();
|
||||||
|
secondQueryHandler.mockClear();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
renderEchart({
|
||||||
|
eventHandlers: {
|
||||||
|
click: regularClickHandler,
|
||||||
|
},
|
||||||
|
queryEventHandlers: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockChart.off).toHaveBeenCalledWith('click', secondQueryHandler),
|
||||||
|
);
|
||||||
|
|
||||||
|
trigger('click');
|
||||||
|
|
||||||
|
expect(regularClickHandler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(firstQueryHandler).not.toHaveBeenCalled();
|
||||||
|
expect(secondQueryHandler).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -64,7 +64,12 @@ import {
|
|||||||
MarkLineComponent,
|
MarkLineComponent,
|
||||||
} from 'echarts/components';
|
} from 'echarts/components';
|
||||||
import { LabelLayout } from 'echarts/features';
|
import { LabelLayout } from 'echarts/features';
|
||||||
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
|
import {
|
||||||
|
EchartsHandler,
|
||||||
|
EchartsProps,
|
||||||
|
EchartsStylesProps,
|
||||||
|
QueryEventHandlers,
|
||||||
|
} from '../types';
|
||||||
import { DEFAULT_LOCALE } from '../constants';
|
import { DEFAULT_LOCALE } from '../constants';
|
||||||
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
|
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
|
||||||
|
|
||||||
@@ -132,6 +137,7 @@ function Echart(
|
|||||||
height,
|
height,
|
||||||
echartOptions,
|
echartOptions,
|
||||||
eventHandlers,
|
eventHandlers,
|
||||||
|
queryEventHandlers,
|
||||||
zrEventHandlers,
|
zrEventHandlers,
|
||||||
selectedValues = {},
|
selectedValues = {},
|
||||||
refs,
|
refs,
|
||||||
@@ -147,6 +153,7 @@ function Echart(
|
|||||||
}
|
}
|
||||||
const [didMount, setDidMount] = useState(false);
|
const [didMount, setDidMount] = useState(false);
|
||||||
const chartRef = useRef<EChartsType>();
|
const chartRef = useRef<EChartsType>();
|
||||||
|
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
|
||||||
const currentSelection = useMemo(
|
const currentSelection = useMemo(
|
||||||
() => Object.keys(selectedValues) || [],
|
() => Object.keys(selectedValues) || [],
|
||||||
[selectedValues],
|
[selectedValues],
|
||||||
@@ -196,11 +203,19 @@ function Echart(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didMount) {
|
if (didMount) {
|
||||||
|
previousQueryEventHandlers.current.forEach(({ name, handler }) => {
|
||||||
|
chartRef.current?.off(name, handler);
|
||||||
|
});
|
||||||
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
|
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
|
||||||
chartRef.current?.off(name);
|
chartRef.current?.off(name);
|
||||||
chartRef.current?.on(name, handler);
|
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]) => {
|
Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
|
||||||
chartRef.current?.getZr().off(name);
|
chartRef.current?.getZr().off(name);
|
||||||
chartRef.current?.getZr().on(name, handler);
|
chartRef.current?.getZr().on(name, handler);
|
||||||
@@ -336,7 +351,15 @@ function Echart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
|
||||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
|
}, [
|
||||||
|
didMount,
|
||||||
|
echartOptions,
|
||||||
|
eventHandlers,
|
||||||
|
queryEventHandlers,
|
||||||
|
zrEventHandlers,
|
||||||
|
theme,
|
||||||
|
vizType,
|
||||||
|
]);
|
||||||
|
|
||||||
// Clear tooltip on refresh start to avoid stale content (#39247)
|
// Clear tooltip on refresh start to avoid stale content (#39247)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import type { EChartsCoreOption, EChartsType } from 'echarts/core';
|
import type { EChartsCoreOption, EChartsType } from 'echarts/core';
|
||||||
import type { TooltipMarker } from 'echarts/types/src/util/format';
|
import type { TooltipMarker } from 'echarts/types/src/util/format';
|
||||||
|
import type { ECElementEvent } from 'echarts/types/src/util/types';
|
||||||
import { StackControlsValue } from './constants';
|
import { StackControlsValue } from './constants';
|
||||||
|
|
||||||
export type EchartsStylesProps = {
|
export type EchartsStylesProps = {
|
||||||
@@ -51,6 +52,7 @@ export interface EchartsProps {
|
|||||||
width: number;
|
width: number;
|
||||||
echartOptions: EChartsCoreOption;
|
echartOptions: EChartsCoreOption;
|
||||||
eventHandlers?: EventHandlers;
|
eventHandlers?: EventHandlers;
|
||||||
|
queryEventHandlers?: QueryEventHandlers;
|
||||||
zrEventHandlers?: EventHandlers;
|
zrEventHandlers?: EventHandlers;
|
||||||
selectedValues?: Record<number, string>;
|
selectedValues?: Record<number, string>;
|
||||||
forceClear?: boolean;
|
forceClear?: boolean;
|
||||||
@@ -105,6 +107,12 @@ export type LegendFormData = {
|
|||||||
|
|
||||||
export type EventHandlers = Record<string, { (props: any): void }>;
|
export type EventHandlers = Record<string, { (props: any): void }>;
|
||||||
|
|
||||||
|
export type QueryEventHandlers = {
|
||||||
|
name: string;
|
||||||
|
query: string;
|
||||||
|
handler: (props: ECElementEvent) => void;
|
||||||
|
}[];
|
||||||
|
|
||||||
export enum LabelPositionEnum {
|
export enum LabelPositionEnum {
|
||||||
Top = 'top',
|
Top = 'top',
|
||||||
Left = 'left',
|
Left = 'left',
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ export function treeBuilder(
|
|||||||
groupBy: string[],
|
groupBy: string[],
|
||||||
metric: string,
|
metric: string,
|
||||||
secondaryMetric?: string,
|
secondaryMetric?: string,
|
||||||
|
filterNullNames?: boolean,
|
||||||
): TreeNode[] {
|
): TreeNode[] {
|
||||||
const [curGroupBy, ...restGroupby] = groupBy;
|
const [curGroupBy, ...restGroupby] = groupBy;
|
||||||
const curData = _groupBy(data, curGroupBy);
|
const curData = _groupBy(data, curGroupBy);
|
||||||
return transform(
|
const nodes = transform(
|
||||||
curData,
|
curData,
|
||||||
(result, value, key) => {
|
(result, value, key) => {
|
||||||
const name = curData[key][0][curGroupBy]!;
|
const name = curData[key][0][curGroupBy]!;
|
||||||
@@ -58,11 +59,15 @@ export function treeBuilder(
|
|||||||
result.push(item);
|
result.push(item);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// Children are already null-filtered by the recursive call, so the
|
||||||
|
// parent's value/secondaryValue exclude hidden nulls. This keeps the
|
||||||
|
// parent arc sized to its visible children (no empty gap).
|
||||||
const children = treeBuilder(
|
const children = treeBuilder(
|
||||||
value,
|
value,
|
||||||
restGroupby,
|
restGroupby,
|
||||||
metric,
|
metric,
|
||||||
secondaryMetric,
|
secondaryMetric,
|
||||||
|
filterNullNames,
|
||||||
);
|
);
|
||||||
const metricValue = children.reduce(
|
const metricValue = children.reduce(
|
||||||
(prev, cur) => prev + (cur.value as number),
|
(prev, cur) => prev + (cur.value as number),
|
||||||
@@ -85,4 +90,13 @@ export function treeBuilder(
|
|||||||
},
|
},
|
||||||
[] as TreeNode[],
|
[] as TreeNode[],
|
||||||
);
|
);
|
||||||
|
// Filter at every level so single-level charts and root nodes are covered,
|
||||||
|
// not just nested children. A parent whose children were all null-filtered
|
||||||
|
// is dropped too: keeping it would leave a zero-value arc that yields a NaN
|
||||||
|
// secondaryValue/value ratio for coloring and tooltips.
|
||||||
|
return filterNullNames
|
||||||
|
? nodes.filter(
|
||||||
|
node => node.name !== null && node.children?.length !== 0,
|
||||||
|
)
|
||||||
|
: nodes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
EchartsRadarChartProps,
|
EchartsRadarChartProps,
|
||||||
EchartsRadarFormData,
|
EchartsRadarFormData,
|
||||||
} from '../../src/Radar/types';
|
} from '../../src/Radar/types';
|
||||||
|
import { LegendOrientation } from '../../src/types';
|
||||||
|
|
||||||
interface RadarIndicator {
|
interface RadarIndicator {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -202,3 +203,58 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ import { supersetTheme } from '@apache-superset/core/theme';
|
|||||||
import { EchartsSunburstChartProps } from '../../src/Sunburst/types';
|
import { EchartsSunburstChartProps } from '../../src/Sunburst/types';
|
||||||
import transformProps from '../../src/Sunburst/transformProps';
|
import transformProps from '../../src/Sunburst/transformProps';
|
||||||
|
|
||||||
|
type SunburstSeries = {
|
||||||
|
label?: Record<string, unknown>;
|
||||||
|
data: { value: number }[];
|
||||||
|
};
|
||||||
|
const firstSeries = (echartOptions: unknown) =>
|
||||||
|
(echartOptions as { series: SunburstSeries[] }).series[0];
|
||||||
|
|
||||||
const formData = {
|
const formData = {
|
||||||
colorScheme: 'bnbColors',
|
colorScheme: 'bnbColors',
|
||||||
datasource: '3__table',
|
datasource: '3__table',
|
||||||
@@ -47,7 +54,52 @@ test('series label has no textBorderColor or textBorderWidth', () => {
|
|||||||
const { echartOptions } = transformProps(
|
const { echartOptions } = transformProps(
|
||||||
chartProps as EchartsSunburstChartProps,
|
chartProps as EchartsSunburstChartProps,
|
||||||
);
|
);
|
||||||
const series = (echartOptions as any).series[0];
|
const series = firstSeries(echartOptions);
|
||||||
expect(series.label).not.toHaveProperty('textBorderColor');
|
expect(series.label).not.toHaveProperty('textBorderColor');
|
||||||
expect(series.label).not.toHaveProperty('textBorderWidth');
|
expect(series.label).not.toHaveProperty('textBorderWidth');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nullValueProps = (showNullValues?: boolean) =>
|
||||||
|
new ChartProps({
|
||||||
|
formData: {
|
||||||
|
colorScheme: 'bnbColors',
|
||||||
|
datasource: '3__table',
|
||||||
|
columns: ['category'],
|
||||||
|
metric: 'sum__value',
|
||||||
|
...(showNullValues === undefined ? {} : { showNullValues }),
|
||||||
|
},
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
queriesData: [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{ category: 'A', sum__value: 10 },
|
||||||
|
{ category: 'B', sum__value: 20 },
|
||||||
|
{ category: null, sum__value: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: supersetTheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
const seriesValues = (props: ChartProps) => {
|
||||||
|
const { echartOptions } = transformProps(props as EchartsSunburstChartProps);
|
||||||
|
return firstSeries(echartOptions)
|
||||||
|
.data.map(node => node.value)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Charts saved before the "Show Null Values" control existed have no
|
||||||
|
// `showNullValues` in form data; they must keep showing nulls (non-breaking).
|
||||||
|
test('keeps null values when showNullValues is unset (legacy charts)', () => {
|
||||||
|
expect(seriesValues(nullValueProps(undefined))).toEqual([5, 10, 20]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps null values when showNullValues is true', () => {
|
||||||
|
expect(seriesValues(nullValueProps(true))).toEqual([5, 10, 20]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Single-column sunburst: the toggle must actually drop the null node.
|
||||||
|
test('removes null values when showNullValues is false', () => {
|
||||||
|
expect(seriesValues(nullValueProps(false))).toEqual([10, 20]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1564,9 +1564,13 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { echartOptions } = transformProps(chartProps);
|
const { echartOptions } = transformProps(chartProps);
|
||||||
const xAxis = echartOptions.xAxis as { type: string };
|
const xAxis = echartOptions.xAxis as {
|
||||||
|
triggerEvent?: boolean;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
expect(xAxis.type).toBe(AxisType.Category);
|
expect(xAxis.type).toBe(AxisType.Category);
|
||||||
|
expect(xAxis.triggerEvent).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('temporal x coltype wires the time formatter and Time axis', () => {
|
test('temporal x coltype wires the time formatter and Time axis', () => {
|
||||||
|
|||||||
@@ -271,4 +271,379 @@ 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 in a nested layer (parent total excludes hidden nulls)', () => {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The null `bar` child is removed AND its value is excluded from the
|
||||||
|
// parent total, so the arc stays sized to its visible children (no gap).
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
groupBy: 'bar',
|
||||||
|
name: 'a',
|
||||||
|
secondaryValue: 2,
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupBy: 'foo',
|
||||||
|
name: 'a-2',
|
||||||
|
secondaryValue: 2,
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: a single-level (single column) sunburst previously never
|
||||||
|
// filtered, because filtering only happened in the multi-level branch.
|
||||||
|
test('single-level: shows null nodes when filtering is off', () => {
|
||||||
|
const tree = treeBuilder(
|
||||||
|
[
|
||||||
|
{ foo: 'a', count: 2, count2: 3 },
|
||||||
|
{ foo: null, count: 5, count2: 7 },
|
||||||
|
],
|
||||||
|
['foo'],
|
||||||
|
'count',
|
||||||
|
);
|
||||||
|
expect(tree).toEqual([
|
||||||
|
{ groupBy: 'foo', name: 'a', secondaryValue: 2, value: 2 },
|
||||||
|
{ groupBy: 'foo', name: null, secondaryValue: 5, value: 5 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single-level: removes null nodes when filtering is on', () => {
|
||||||
|
const tree = treeBuilder(
|
||||||
|
[
|
||||||
|
{ foo: 'a', count: 2, count2: 3 },
|
||||||
|
{ foo: null, count: 5, count2: 7 },
|
||||||
|
],
|
||||||
|
['foo'],
|
||||||
|
'count',
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(tree).toEqual([
|
||||||
|
{ groupBy: 'foo', name: 'a', secondaryValue: 2, value: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: a null in the *root* (first) column previously slipped through
|
||||||
|
// because the top-level result array was never filtered.
|
||||||
|
test('multi-level: shows null root node when filtering is off', () => {
|
||||||
|
const tree = treeBuilder(
|
||||||
|
[
|
||||||
|
{ foo: 'a-1', bar: 'a', count: 2, count2: 3 },
|
||||||
|
{ foo: null, bar: 'x', count: 5, count2: 7 },
|
||||||
|
],
|
||||||
|
['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: 'x', secondaryValue: 5, value: 5 }],
|
||||||
|
groupBy: 'foo',
|
||||||
|
name: null,
|
||||||
|
secondaryValue: 5,
|
||||||
|
value: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-level: removes null root node (and its subtree) when filtering is on', () => {
|
||||||
|
const tree = treeBuilder(
|
||||||
|
[
|
||||||
|
{ foo: 'a-1', bar: 'a', count: 2, count2: 3 },
|
||||||
|
{ foo: null, bar: 'x', count: 5, count2: 7 },
|
||||||
|
],
|
||||||
|
['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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// With a secondary metric, the parent's secondaryValue must also exclude the
|
||||||
|
// hidden null child rather than leaving a stale (inflated) total.
|
||||||
|
test('filtering excludes hidden nulls from secondary-metric totals', () => {
|
||||||
|
const tree = treeBuilder(
|
||||||
|
[
|
||||||
|
{ foo: 'p', bar: 'a', count: 2, count2: 3 },
|
||||||
|
{ foo: 'p', bar: null, count: 2, count2: 7 },
|
||||||
|
],
|
||||||
|
['foo', 'bar'],
|
||||||
|
'count',
|
||||||
|
'count2',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(tree).toEqual([
|
||||||
|
{
|
||||||
|
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 3, value: 2 }],
|
||||||
|
groupBy: 'foo',
|
||||||
|
name: 'p',
|
||||||
|
secondaryValue: 3,
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A parent whose children are all null must be dropped, not kept as a
|
||||||
|
// zero-value arc: a retained `value: 0` node yields NaN for the
|
||||||
|
// secondaryValue/value ratio used in linear coloring and tooltips.
|
||||||
|
test('filtering drops parents left with no children', () => {
|
||||||
|
const tree = treeBuilder(
|
||||||
|
[
|
||||||
|
{ foo: 'keep', bar: 'a', count: 2, count2: 3 },
|
||||||
|
{ foo: 'drop', bar: null, count: 5, count2: 7 },
|
||||||
|
],
|
||||||
|
['foo', 'bar'],
|
||||||
|
'count',
|
||||||
|
'count2',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(tree).toEqual([
|
||||||
|
{
|
||||||
|
children: [{ groupBy: 'bar', name: 'a', secondaryValue: 3, value: 2 }],
|
||||||
|
groupBy: 'foo',
|
||||||
|
name: 'keep',
|
||||||
|
secondaryValue: 3,
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@math.gl/web-mercator": "^4.1.0",
|
"@math.gl/web-mercator": "^4.1.0",
|
||||||
"mapbox-gl": "^3.24.1",
|
"mapbox-gl": "^3.25.0",
|
||||||
"maplibre-gl": "^5.24.0",
|
"maplibre-gl": "^5.24.0",
|
||||||
"react-map-gl": "^8.1.1",
|
"react-map-gl": "^8.1.1",
|
||||||
"supercluster": "^8.0.1"
|
"supercluster": "^8.0.1"
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
|||||||
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
||||||
const { extras = {} } = baseQueryObject;
|
const { extras = {} } = baseQueryObject;
|
||||||
const postProcessing: PostProcessingRule[] = [];
|
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(
|
const nonCustomNorInheritShifts = ensureIsArray(
|
||||||
formData.time_compare,
|
formData.time_compare,
|
||||||
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
||||||
@@ -137,12 +144,6 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
|||||||
orderby = [[metrics[0], false]];
|
orderby = [[metrics[0], false]];
|
||||||
}
|
}
|
||||||
// add postprocessing for percent metrics only when in aggregation mode
|
// 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) {
|
if (percentMetrics && percentMetrics.length > 0) {
|
||||||
const percentMetricsLabelsWithTimeComparison = isTimeComparison(
|
const percentMetricsLabelsWithTimeComparison = isTimeComparison(
|
||||||
formData,
|
formData,
|
||||||
@@ -162,23 +163,14 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
|||||||
getMetricLabel,
|
getMetricLabel,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (calculationMode === 'all_records') {
|
contributionPostProcessing = {
|
||||||
postProcessing.push({
|
operation: 'contribution',
|
||||||
operation: 'contribution',
|
options: {
|
||||||
options: {
|
columns: percentMetricLabels,
|
||||||
columns: percentMetricLabels,
|
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
},
|
||||||
},
|
};
|
||||||
});
|
postProcessing.push(contributionPostProcessing);
|
||||||
} 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
|
// Add the operator for the time comparison if some is selected
|
||||||
@@ -357,7 +349,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
|||||||
columns: [],
|
columns: [],
|
||||||
row_limit: 0,
|
row_limit: 0,
|
||||||
row_offset: 0,
|
row_offset: 0,
|
||||||
post_processing: [],
|
// Reapply only the percent-metric contribution rule so the totals row
|
||||||
|
// exposes `%metric` keys (value/value = 100% on the single aggregated
|
||||||
|
// row). The time-comparison operator from the main query is omitted on
|
||||||
|
// purpose; it must not run against the single-row totals query.
|
||||||
|
post_processing: contributionPostProcessing
|
||||||
|
? [contributionPostProcessing]
|
||||||
|
: [],
|
||||||
order_desc: undefined,
|
order_desc: undefined,
|
||||||
orderby: undefined,
|
orderby: undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,6 +236,83 @@ describe('plugin-chart-table', () => {
|
|||||||
expect(queries).toHaveLength(1);
|
expect(queries).toHaveLength(1);
|
||||||
expect(queries[0].post_processing).toEqual([]);
|
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', () => {
|
describe('Testing for server pagination with search filter', () => {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import Legend from './Legend';
|
||||||
|
|
||||||
|
const renderWithTheme = (component: ReactElement) =>
|
||||||
|
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||||
|
|
||||||
|
test('formats interval-notation labels while preserving brackets', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Legend
|
||||||
|
format=",.2f"
|
||||||
|
categories={{
|
||||||
|
'[1, 81)': { enabled: true, color: [0, 0, 0] },
|
||||||
|
'[81, 212)': { enabled: true, color: [0, 0, 0] },
|
||||||
|
'[212, 369]': { enabled: true, color: [0, 0, 0] },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('[1.00, 81.00)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('[81.00, 212.00)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('[212.00, 369.00]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still formats legacy "a - b" delimiter labels', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Legend
|
||||||
|
format=",.1f"
|
||||||
|
categories={{
|
||||||
|
'0 - 100000': { enabled: true, color: [0, 0, 0] },
|
||||||
|
'100001 - 200000': { enabled: true, color: [0, 0, 0] },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('0.0 - 100,000.0')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('100,001.0 - 200,000.0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves labels untouched when no format is provided', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Legend
|
||||||
|
format={null}
|
||||||
|
categories={{ '[1, 81)': { enabled: true, color: [0, 0, 0] } }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('[1, 81)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -59,6 +59,33 @@ const StyledLegend = styled.div`
|
|||||||
|
|
||||||
const categoryDelimiter = ' - ';
|
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 = {
|
export type LegendProps = {
|
||||||
format: string | null;
|
format: string | null;
|
||||||
forceCategorical?: boolean;
|
forceCategorical?: boolean;
|
||||||
@@ -91,6 +118,15 @@ const Legend = ({
|
|||||||
return k;
|
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)) {
|
if (k.includes(categoryDelimiter)) {
|
||||||
const values = k.split(categoryDelimiter);
|
const values = k.split(categoryDelimiter);
|
||||||
|
|
||||||
@@ -105,8 +141,22 @@ const Legend = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const categories = Object.entries(categoriesObject).map(([k, v]) => {
|
const categories = Object.entries(categoriesObject).map(([k, v]) => {
|
||||||
const style = { color: `rgba(${v.color?.join(', ')})` };
|
const color = `rgba(${v.color?.join(', ')})`;
|
||||||
const icon = v.enabled ? '\u25FC' : '\u25FB';
|
// Render the swatch as a real coloured box rather than a colour-tinted
|
||||||
|
// text glyph. U+25FC/U+25FB are in Unicode's Emoji set but lack
|
||||||
|
// Emoji_Presentation, so Chromium resolves them to a colour-emoji font
|
||||||
|
// whose glyphs carry baked-in colour and ignore the CSS `color` property,
|
||||||
|
// producing a black square regardless of the category colour. A bordered
|
||||||
|
// box has no such dependency: filled when enabled, hollow when disabled.
|
||||||
|
const swatchStyle = {
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
backgroundColor: v.enabled ? color : 'transparent',
|
||||||
|
alignSelf: 'center',
|
||||||
|
flex: '0 0 auto',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={k}>
|
<li key={k}>
|
||||||
@@ -122,7 +172,7 @@ const Legend = ({
|
|||||||
showSingleCategory(k);
|
showSingleCategory(k);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={style}>{icon}</span> {formatCategoryLabel(k)}
|
<span aria-hidden style={swatchStyle} /> {formatCategoryLabel(k)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -137,7 +187,7 @@ const Legend = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledLegend className="dupa" style={style}>
|
<StyledLegend style={style}>
|
||||||
<ul>{categories}</ul>
|
<ul>{categories}</ul>
|
||||||
</StyledLegend>
|
</StyledLegend>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,13 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { getColorBreakpointsBuckets, getBreakPoints } from './utils';
|
import { JsonObject, QueryFormData } from '@superset-ui/core';
|
||||||
|
import {
|
||||||
|
getColorBreakpointsBuckets,
|
||||||
|
getBreakPoints,
|
||||||
|
getBuckets,
|
||||||
|
BucketsWithColorScale,
|
||||||
|
} from './utils';
|
||||||
import { ColorBreakpointType } from './types';
|
import { ColorBreakpointType } from './types';
|
||||||
|
|
||||||
describe('getColorBreakpointsBuckets', () => {
|
describe('getColorBreakpointsBuckets', () => {
|
||||||
@@ -488,3 +494,42 @@ describe('getBreakPoints', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getBuckets', () => {
|
||||||
|
const accessor = (d: JsonObject) => d.value;
|
||||||
|
|
||||||
|
const buildFeatures = (values: number[]) => values.map(value => ({ value }));
|
||||||
|
|
||||||
|
test('produces non-overlapping bucket labels (no shared endpoints)', () => {
|
||||||
|
// With break points [1, 81, 212, 369] the legacy behavior produced
|
||||||
|
// "1 - 81", "81 - 212", "212 - 369" where each interior breakpoint
|
||||||
|
// (81, 212) appeared in two adjacent labels, reading as overlapping
|
||||||
|
// ranges. Labels should instead form a clean, non-overlapping partition.
|
||||||
|
const fd: QueryFormData & BucketsWithColorScale = {
|
||||||
|
datasource: '1__table',
|
||||||
|
viz_type: 'deck_polygon',
|
||||||
|
break_points: ['1', '81', '212', '369'],
|
||||||
|
num_buckets: '3',
|
||||||
|
linear_color_scheme: ['#000000', '#ffffff'],
|
||||||
|
opacity: 100,
|
||||||
|
metric: 'count',
|
||||||
|
};
|
||||||
|
const features = buildFeatures([1, 50, 100, 200, 300, 369]);
|
||||||
|
|
||||||
|
const buckets = getBuckets(fd, features, accessor);
|
||||||
|
const labels = Object.keys(buckets);
|
||||||
|
|
||||||
|
// Three buckets for four breakpoints
|
||||||
|
expect(labels).toHaveLength(3);
|
||||||
|
|
||||||
|
// Interval notation: half-open everywhere except the last bucket, which
|
||||||
|
// is closed so the maximum value is included.
|
||||||
|
expect(labels).toEqual(['[1, 81)', '[81, 212)', '[212, 369]']);
|
||||||
|
|
||||||
|
// No numeric endpoint should appear as both an upper bound of one bucket
|
||||||
|
// and a lower bound of the next in an ambiguous "a - b" form.
|
||||||
|
labels.forEach(label => {
|
||||||
|
expect(label).not.toMatch(/^\d+(\.\d+)?\s-\s\d+(\.\d+)?$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -200,8 +200,15 @@ export function getBuckets(
|
|||||||
string,
|
string,
|
||||||
{ color: Color | undefined; enabled: boolean }
|
{ color: Color | undefined; enabled: boolean }
|
||||||
> = {};
|
> = {};
|
||||||
|
const lastBucketIndex = breakPoints.length - 2;
|
||||||
breakPoints.slice(1).forEach((_, i) => {
|
breakPoints.slice(1).forEach((_, i) => {
|
||||||
const range = `${breakPoints[i]} - ${breakPoints[i + 1]}`;
|
// Use half-open interval notation so adjacent buckets don't share an
|
||||||
|
// ambiguous endpoint. This mirrors the d3 `scaleThreshold` binning used
|
||||||
|
// for coloring, where each breakpoint is a half-open cut point. The final
|
||||||
|
// bucket is closed on the right so the maximum value is included.
|
||||||
|
const isLastBucket = i === lastBucketIndex;
|
||||||
|
const closingBracket = isLastBucket ? ']' : ')';
|
||||||
|
const range = `[${breakPoints[i]}, ${breakPoints[i + 1]}${closingBracket}`;
|
||||||
const mid =
|
const mid =
|
||||||
0.5 * (parseFloat(breakPoints[i]) + parseFloat(breakPoints[i + 1]));
|
0.5 * (parseFloat(breakPoints[i]) + parseFloat(breakPoints[i + 1]));
|
||||||
// fix polygon doesn't show
|
// fix polygon doesn't show
|
||||||
|
|||||||
@@ -632,6 +632,35 @@ function processFile(filepath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application source trees that must be authored in TypeScript. Matches the
|
||||||
|
* top-level `src/` directory as well as each package/plugin `src/` directory.
|
||||||
|
*/
|
||||||
|
const TS_ONLY_SOURCE_PATTERN =
|
||||||
|
/^(src|packages\/[^/]+\/src|plugins\/[^/]+\/src)\//;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the TypeScript-only frontend convention: no `.js`/`.jsx` files may be
|
||||||
|
* added under the application source trees (including test files). Build
|
||||||
|
* artifacts and root-level config files (e.g. `.storybook/preview.jsx`,
|
||||||
|
* `webpack.config.js`) live outside these trees and are intentionally allowed.
|
||||||
|
*
|
||||||
|
* @param {string[]} candidateFiles paths relative to `superset-frontend/`
|
||||||
|
*/
|
||||||
|
function checkTypeScriptOnlySource(candidateFiles) {
|
||||||
|
candidateFiles.forEach(file => {
|
||||||
|
if (TS_ONLY_SOURCE_PATTERN.test(file) && /\.(js|jsx)$/.test(file)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(
|
||||||
|
`${RED}✗${RESET} ${file}: frontend source must be TypeScript. ` +
|
||||||
|
`Rename to .ts/.tsx (the codebase is mid-migration to full ` +
|
||||||
|
`TypeScript; no new .js/.jsx files in src/).`,
|
||||||
|
);
|
||||||
|
errorCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main function
|
* Main function
|
||||||
*/
|
*/
|
||||||
@@ -666,6 +695,22 @@ function main() {
|
|||||||
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
|
/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 no files specified, check all
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
|
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
|
||||||
@@ -706,22 +751,23 @@ function main() {
|
|||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('No files to check.');
|
console.log('No files to check.');
|
||||||
return;
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`Checking ${files.length} files for Superset custom rules...\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
// Resolve the file path
|
||||||
|
const resolvedPath = path.resolve(file);
|
||||||
|
if (fs.existsSync(resolvedPath)) {
|
||||||
|
processFile(resolvedPath);
|
||||||
|
} else if (fs.existsSync(file)) {
|
||||||
|
processFile(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log(`Checking ${files.length} files for Superset custom rules...\n`);
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
// Resolve the file path
|
|
||||||
const resolvedPath = path.resolve(file);
|
|
||||||
if (fs.existsSync(resolvedPath)) {
|
|
||||||
processFile(resolvedPath);
|
|
||||||
} else if (fs.existsSync(file)) {
|
|
||||||
processFile(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`\n${errorCount} errors, ${warningCount} warnings`);
|
console.log(`\n${errorCount} errors, ${warningCount} warnings`);
|
||||||
|
|
||||||
@@ -740,4 +786,5 @@ module.exports = {
|
|||||||
checkNoFaIcons,
|
checkNoFaIcons,
|
||||||
checkI18nTemplates,
|
checkI18nTemplates,
|
||||||
checkUntranslatedStrings,
|
checkUntranslatedStrings,
|
||||||
|
checkTypeScriptOnlySource,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -135,14 +135,17 @@ describe('SqlEditorTabHeader', () => {
|
|||||||
|
|
||||||
test('should dispatch queryEditorSetTitle action', async () => {
|
test('should dispatch queryEditorSetTitle action', async () => {
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
const expectedTitle = 'typed text';
|
const expectedTitle = 'typed text';
|
||||||
const mockPrompt = jest
|
|
||||||
.spyOn(window, 'prompt')
|
|
||||||
.mockImplementation(() => expectedTitle);
|
|
||||||
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
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();
|
const actions = store.getActions();
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(actions[0]).toEqual({
|
expect(actions[0]).toEqual({
|
||||||
@@ -153,7 +156,127 @@ describe('SqlEditorTabHeader', () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
mockPrompt.mockClear();
|
});
|
||||||
|
|
||||||
|
test('prefills the rename input with the current tab name', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
expect(input).toHaveValue(defaultQueryEditor.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focuses the rename input when the modal opens', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables Save when the input is empty or whitespace', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
fireEvent.change(input, { target: { value: ' ' } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not dispatch or dismiss on Enter when the input is empty', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
fireEvent.change(input, { target: { value: ' ' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13, charCode: 13 });
|
||||||
|
|
||||||
|
const dispatchedTitleChange = store
|
||||||
|
.getActions()
|
||||||
|
.some(action => action.type === QUERY_EDITOR_SET_TITLE);
|
||||||
|
expect(dispatchedTitleChange).toBe(false);
|
||||||
|
// the modal must stay open so the user can correct the name,
|
||||||
|
// mirroring the disabled Save button rather than dismissing like Escape
|
||||||
|
expect(screen.queryByRole('dialog')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not dispatch a title change when the modal is cancelled', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'discarded text' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
|
||||||
|
expect(store.getActions()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not dispatch a title change when dismissed with the close button', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'discarded text' } });
|
||||||
|
fireEvent.click(screen.getByTestId('close-modal-btn'));
|
||||||
|
|
||||||
|
expect(store.getActions()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns focus to the tab header after the modal is cancelled', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
await screen.findByTestId('rename-tab-input');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns focus to the tab header after a successful rename', async () => {
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId('rename-tab-menu-option'),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'renamed tab' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('sql-editor-tab-header')).toHaveFocus(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should dispatch removeAllOtherQueryEditors action', async () => {
|
test('should dispatch removeAllOtherQueryEditors action', async () => {
|
||||||
@@ -196,4 +319,42 @@ describe('SqlEditorTabHeader', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not leak tab-editing keystrokes from the rename input to the surrounding tabs', async () => {
|
||||||
|
const onContainerKeyDown = jest.fn();
|
||||||
|
const store = mockStore(initialState);
|
||||||
|
render(
|
||||||
|
<div onKeyDown={onContainerKeyDown}>
|
||||||
|
<SqlEditorTabHeader queryEditor={defaultQueryEditor} />
|
||||||
|
</div>,
|
||||||
|
{ useRedux: true, store },
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.click(screen.getByTestId('dropdown-trigger'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('rename-tab-menu-option')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
|
||||||
|
const input = await screen.findByTestId('rename-tab-input');
|
||||||
|
|
||||||
|
// The modal portals over the editable-card tabs, whose keyboard handler would
|
||||||
|
// otherwise remove, navigate, or activate a tab (and swallow Space). None of
|
||||||
|
// these keys should escape the modal to the surrounding container.
|
||||||
|
[
|
||||||
|
'Delete',
|
||||||
|
'Backspace',
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'Home',
|
||||||
|
'End',
|
||||||
|
' ',
|
||||||
|
].forEach(key => fireEvent.keyDown(input, { key }));
|
||||||
|
expect(onContainerKeyDown).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Escape (close) and Tab (focus trap) must still reach the Modal.
|
||||||
|
fireEvent.keyDown(input, { key: 'Tab' });
|
||||||
|
fireEvent.keyDown(input, { key: 'Escape' });
|
||||||
|
const reached = onContainerKeyDown.mock.calls.map(call => call[0].key);
|
||||||
|
expect(reached).toEqual(expect.arrayContaining(['Tab', 'Escape']));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,12 +16,17 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useMemo, FC } from 'react';
|
import { useEffect, useMemo, useRef, useState, FC } from 'react';
|
||||||
|
|
||||||
import { bindActionCreators } from 'redux';
|
import { bindActionCreators } from 'redux';
|
||||||
import { useSelector, shallowEqual } from 'react-redux';
|
import { useSelector, shallowEqual } from 'react-redux';
|
||||||
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
import { useAppDispatch } from 'src/SqlLab/hooks/useAppDispatch';
|
||||||
import { MenuDotsDropdown } from '@superset-ui/core/components';
|
import {
|
||||||
|
MenuDotsDropdown,
|
||||||
|
Modal,
|
||||||
|
Input,
|
||||||
|
InputRef,
|
||||||
|
} from '@superset-ui/core/components';
|
||||||
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { QueryState } from '@superset-ui/core';
|
import { QueryState } from '@superset-ui/core';
|
||||||
@@ -107,13 +112,35 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
|||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
function renameTab() {
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||||
// TODO: Replace native prompt with a proper modal dialog
|
const [newTitle, setNewTitle] = useState('');
|
||||||
// eslint-disable-next-line no-alert
|
const renameInputRef = useRef<InputRef>(null);
|
||||||
const newTitle = prompt(t('Enter a new title for the tab'));
|
const tabHeaderRef = useRef<HTMLDivElement>(null);
|
||||||
if (newTitle) {
|
const trimmedTitle = newTitle.trim();
|
||||||
actions.queryEditorSetTitle(qe, newTitle, qe.id);
|
|
||||||
|
function openRenameModal() {
|
||||||
|
setNewTitle(qe.name);
|
||||||
|
setIsRenameModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// antd's Modal moves focus to the dialog container on open, which overrides
|
||||||
|
// the Input's autoFocus, so focus and select the field via a ref once the
|
||||||
|
// modal is open (select lets the prefilled name be overtyped, like prompt()).
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRenameModalOpen) {
|
||||||
|
renameInputRef.current?.focus();
|
||||||
|
renameInputRef.current?.select();
|
||||||
}
|
}
|
||||||
|
}, [isRenameModalOpen]);
|
||||||
|
|
||||||
|
function handleRenameTab() {
|
||||||
|
if (trimmedTitle) {
|
||||||
|
actions.queryEditorSetTitle(qe, trimmedTitle, qe.id);
|
||||||
|
}
|
||||||
|
setIsRenameModalOpen(false);
|
||||||
|
// Save closes via the show prop rather than the Modal's onHide, so return
|
||||||
|
// focus to the tab header here, matching what openerRef does on dismiss.
|
||||||
|
tabHeaderRef.current?.focus();
|
||||||
}
|
}
|
||||||
const getStatusColor = (state: QueryState, theme: SupersetTheme): string => {
|
const getStatusColor = (state: QueryState, theme: SupersetTheme): string => {
|
||||||
const statusColors: Record<QueryState, string> = {
|
const statusColors: Record<QueryState, string> = {
|
||||||
@@ -131,7 +158,11 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
|||||||
return statusColors[state] || theme.colorIcon;
|
return statusColors[state] || theme.colorIcon;
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<TabTitleWrapper>
|
<TabTitleWrapper
|
||||||
|
ref={tabHeaderRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-test="sql-editor-tab-header"
|
||||||
|
>
|
||||||
<MenuDotsDropdown
|
<MenuDotsDropdown
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
overlay={
|
overlay={
|
||||||
@@ -158,7 +189,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
|||||||
} as MenuItemType,
|
} as MenuItemType,
|
||||||
{
|
{
|
||||||
key: '2',
|
key: '2',
|
||||||
onClick: renameTab,
|
onClick: openRenameModal,
|
||||||
'data-test': 'rename-tab-menu-option',
|
'data-test': 'rename-tab-menu-option',
|
||||||
label: (
|
label: (
|
||||||
<>
|
<>
|
||||||
@@ -220,6 +251,37 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
|||||||
iconSize="m"
|
iconSize="m"
|
||||||
iconColor={getStatusColor(queryState, theme)}
|
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>
|
</TabTitleWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import type { Column, GridApi } from 'ag-grid-community';
|
import type { Column, GridApi, IHeaderParams } from 'ag-grid-community';
|
||||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { PIVOT_COL_ID } from './constants';
|
import { PIVOT_COL_ID } from './constants';
|
||||||
@@ -38,9 +38,70 @@ jest.mock('@superset-ui/core/components/Icons', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
class MockApi extends EventTarget {
|
class MockColumn {
|
||||||
|
private colListeners = new Map<string, Set<Function>>();
|
||||||
|
|
||||||
|
sortValue: string | null = 'asc';
|
||||||
|
|
||||||
|
sortIndexValue: number | null = null;
|
||||||
|
|
||||||
|
getColId() {
|
||||||
|
return '123';
|
||||||
|
}
|
||||||
|
|
||||||
|
isPinnedLeft() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPinnedRight() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isVisible() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSort() {
|
||||||
|
return this.sortValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSortIndex() {
|
||||||
|
return this.sortIndexValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(eventType: string, listener: Function) {
|
||||||
|
if (!this.colListeners.has(eventType)) {
|
||||||
|
this.colListeners.set(eventType, new Set());
|
||||||
|
}
|
||||||
|
this.colListeners.get(eventType)!.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(eventType: string, listener: Function) {
|
||||||
|
this.colListeners.get(eventType)?.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerEvent(eventType: string) {
|
||||||
|
this.colListeners.get(eventType)?.forEach(listener => listener({}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockOtherColumn extends MockColumn {
|
||||||
|
getColId() {
|
||||||
|
return 'other-col';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockApi {
|
||||||
|
mockColumn = new MockColumn();
|
||||||
|
|
||||||
|
otherColumn = new MockOtherColumn();
|
||||||
|
|
||||||
getAllDisplayedColumns() {
|
getAllDisplayedColumns() {
|
||||||
return [];
|
return [this.mockColumn, this.otherColumn];
|
||||||
|
}
|
||||||
|
|
||||||
|
getColumns() {
|
||||||
|
return [this.mockColumn, this.otherColumn];
|
||||||
}
|
}
|
||||||
|
|
||||||
isDestroyed() {
|
isDestroyed() {
|
||||||
@@ -48,48 +109,76 @@ class MockApi extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockApi = new MockApi();
|
||||||
|
|
||||||
const mockedProps = {
|
const mockedProps = {
|
||||||
displayName: 'test column',
|
displayName: 'test column',
|
||||||
setSort: jest.fn(),
|
progressSort: jest.fn(),
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
column: {
|
column: mockApi.mockColumn as any as Column,
|
||||||
getColId: () => '123',
|
api: mockApi as any as GridApi,
|
||||||
isPinnedLeft: () => true,
|
} as unknown as IHeaderParams;
|
||||||
isPinnedRight: () => false,
|
|
||||||
getSort: () => 'asc',
|
|
||||||
getSortIndex: () => null,
|
|
||||||
} as any as Column,
|
|
||||||
api: new MockApi() as any as GridApi,
|
|
||||||
};
|
|
||||||
|
|
||||||
test('renders display name for the column', () => {
|
test('renders display name for the column', () => {
|
||||||
const { queryByText } = render(<Header {...mockedProps} />);
|
const { queryByText } = render(<Header {...mockedProps} />);
|
||||||
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
|
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sorts by clicking a column header', () => {
|
test('calls progressSort without shiftKey on click', () => {
|
||||||
const { getByText, queryByTestId } = render(<Header {...mockedProps} />);
|
const { getByText } = render(<Header {...mockedProps} />);
|
||||||
fireEvent.click(getByText(mockedProps.displayName));
|
fireEvent.click(getByText(mockedProps.displayName));
|
||||||
expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false);
|
expect(mockedProps.progressSort).toHaveBeenCalledWith(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 () => {
|
test('calls progressSort with shiftKey on shift-click', () => {
|
||||||
const { findByTestId } = render(<Header {...mockedProps} />);
|
const { getByText } = render(<Header {...mockedProps} />);
|
||||||
|
fireEvent.click(getByText(mockedProps.displayName), { shiftKey: true });
|
||||||
|
expect(mockedProps.progressSort).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('synchronizes sort icon when columnStateUpdated fires on column', async () => {
|
||||||
|
const { findByTestId, queryByTestId } = render(<Header {...mockedProps} />);
|
||||||
|
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
mockedProps.api.dispatchEvent(new Event('sortChanged'));
|
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortAsc = await findByTestId('mock-sort-asc');
|
const sortAsc = await findByTestId('mock-sort-asc');
|
||||||
expect(sortAsc).toBeInTheDocument();
|
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', () => {
|
test('disable menu when enableFilterButton is false', () => {
|
||||||
const { queryByText, queryByTestId } = render(
|
const { queryByText, queryByTestId } = render(
|
||||||
<Header {...mockedProps} enableFilterButton={false} />,
|
<Header {...mockedProps} enableFilterButton={false} />,
|
||||||
@@ -99,18 +188,39 @@ test('disable menu when enableFilterButton is false', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('hide display name for PIVOT_COL_ID', () => {
|
test('hide display name for PIVOT_COL_ID', () => {
|
||||||
|
const pivotColumn = new MockColumn();
|
||||||
|
(pivotColumn as any).getColId = () => PIVOT_COL_ID;
|
||||||
|
|
||||||
const { queryByText } = render(
|
const { queryByText } = render(
|
||||||
<Header
|
<Header {...mockedProps} column={pivotColumn as any as Column} />,
|
||||||
{...mockedProps}
|
|
||||||
column={
|
|
||||||
{
|
|
||||||
getColId: () => PIVOT_COL_ID,
|
|
||||||
isPinnedLeft: () => true,
|
|
||||||
isPinnedRight: () => false,
|
|
||||||
getSortIndex: () => null,
|
|
||||||
} as any as Column
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
|
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not attach click handler when enableSorting is false', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<Header {...mockedProps} enableSorting={false} />,
|
||||||
|
);
|
||||||
|
const cell = getByText(mockedProps.displayName).closest(
|
||||||
|
'.ag-header-cell-label',
|
||||||
|
);
|
||||||
|
expect(cell).not.toHaveAttribute('role', 'button');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not call progressSort on click when enableSorting is false', () => {
|
||||||
|
const progressSort = jest.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<Header {...mockedProps} enableSorting={false} progressSort={progressSort} />,
|
||||||
|
);
|
||||||
|
fireEvent.click(getByText(mockedProps.displayName));
|
||||||
|
expect(progressSort).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not render sort icons when enableSorting is false', () => {
|
||||||
|
const { queryByTestId } = render(
|
||||||
|
<Header {...mockedProps} enableSorting={false} />,
|
||||||
|
);
|
||||||
|
expect(queryByTestId('mock-sort')).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,32 +16,16 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import {
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
type MouseEvent,
|
import type { IHeaderParams, Column, SortDirection } from 'ag-grid-community';
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||||
import type { Column, GridApi } from 'ag-grid-community';
|
|
||||||
|
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
|
|
||||||
import { PIVOT_COL_ID } from './constants';
|
import { PIVOT_COL_ID } from './constants';
|
||||||
import { HeaderMenu } from './HeaderMenu';
|
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`
|
const HeaderCell = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -87,30 +71,26 @@ const IconPlaceholder = styled.div`
|
|||||||
top: 0;
|
top: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const Header: React.FC<Params> = ({
|
export const Header: React.FC<IHeaderParams> = ({
|
||||||
enableFilterButton,
|
enableFilterButton,
|
||||||
enableSorting,
|
enableSorting,
|
||||||
displayName,
|
displayName,
|
||||||
setSort,
|
progressSort,
|
||||||
column,
|
column,
|
||||||
api,
|
api,
|
||||||
}: Params) => {
|
}: IHeaderParams) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const colId = column.getColId();
|
const colId = column.getColId();
|
||||||
const pinnedLeft = column.isPinnedLeft();
|
const pinnedLeft = column.isPinnedLeft();
|
||||||
const pinnedRight = column.isPinnedRight();
|
const pinnedRight = column.isPinnedRight();
|
||||||
const sortOption = useRef<number>(0);
|
|
||||||
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
|
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
|
||||||
const [currentSort, setCurrentSort] = useState<string | null>(null);
|
const [currentSort, setCurrentSort] = useState<SortDirection>(null);
|
||||||
const [sortIndex, setSortIndex] = useState<number | null>();
|
const [sortIndex, setSortIndex] = useState<number | null>();
|
||||||
const onSort = useCallback(
|
const onSort = useCallback(
|
||||||
(event: MouseEvent) => {
|
(event: React.MouseEvent) => {
|
||||||
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length;
|
progressSort(event.shiftKey);
|
||||||
const sort = SORT_DIRECTION[sortOption.current];
|
|
||||||
setSort(sort, event.shiftKey);
|
|
||||||
setCurrentSort(sort);
|
|
||||||
},
|
},
|
||||||
[setSort],
|
[progressSort],
|
||||||
);
|
);
|
||||||
const onVisibleChange = useCallback(
|
const onVisibleChange = useCallback(
|
||||||
(isVisible: boolean) => {
|
(isVisible: boolean) => {
|
||||||
@@ -123,24 +103,22 @@ export const Header: React.FC<Params> = ({
|
|||||||
[api],
|
[api],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSortChanged = useCallback(() => {
|
const syncSortState = useCallback(() => {
|
||||||
const hasMultiSort = api
|
const hasMultiSort = api
|
||||||
.getAllDisplayedColumns()
|
.getAllDisplayedColumns()
|
||||||
.some(c => c.getSortIndex());
|
.some(c => c.getColId() !== colId && c.getSort() !== null);
|
||||||
const updatedSortIndex = column.getSortIndex();
|
|
||||||
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
|
|
||||||
setCurrentSort(column.getSort() ?? null);
|
setCurrentSort(column.getSort() ?? null);
|
||||||
setSortIndex(hasMultiSort ? updatedSortIndex : null);
|
setSortIndex(hasMultiSort ? column.getSortIndex() : null);
|
||||||
}, [api, column]);
|
}, [api, column, colId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.addEventListener('sortChanged', onSortChanged);
|
column.addEventListener('columnStateUpdated', syncSortState);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (api.isDestroyed()) return;
|
if (api.isDestroyed()) return;
|
||||||
api.removeEventListener('sortChanged', onSortChanged);
|
column.removeEventListener('columnStateUpdated', syncSortState);
|
||||||
};
|
};
|
||||||
}, [api, onSortChanged]);
|
}, [column, syncSortState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
277
superset-frontend/src/core/chat/ChatHost.test.tsx
Normal file
277
superset-frontend/src/core/chat/ChatHost.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { act, render, screen } from 'spec/helpers/testing-library';
|
||||||
|
import { chat } from 'src/core/chat';
|
||||||
|
import ChatProvider from './ChatProvider';
|
||||||
|
import { ChatFloatingHost as ChatHost, ChatPanelHost } from './ChatHost';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ChatProvider.getInstance().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders nothing when no chat extension is registered', () => {
|
||||||
|
render(<ChatHost />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the trigger bubble of the registered chat', () => {
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <button type="button">Acme Bubble</button>,
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatHost />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||||
|
// The panel stays unmounted until the chat is opened.
|
||||||
|
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mounts the panel when the chat opens and unmounts it on close', () => {
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <button type="button">Acme Bubble</button>,
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatHost />);
|
||||||
|
|
||||||
|
act(() => chat.open());
|
||||||
|
|
||||||
|
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||||
|
// In floating mode the trigger stays mounted alongside the open panel.
|
||||||
|
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => chat.close());
|
||||||
|
|
||||||
|
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the last-registered chat when several are installed', () => {
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'first.chat', name: 'First Chat' },
|
||||||
|
() => <div>First Bubble</div>,
|
||||||
|
() => <div>First Panel</div>,
|
||||||
|
);
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'second.chat', name: 'Second Chat' },
|
||||||
|
() => <div>Second Bubble</div>,
|
||||||
|
() => <div>Second Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
render(<ChatHost />);
|
||||||
|
|
||||||
|
// Last-loaded wins: the second registration takes over the singleton slot.
|
||||||
|
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reacts to a chat registering after the initial render', () => {
|
||||||
|
render(<ChatHost />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <button type="button">Acme Bubble</button>,
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a takeover mounts the incoming chat closed', () => {
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'first.chat', name: 'First Chat' },
|
||||||
|
() => <div>First Bubble</div>,
|
||||||
|
() => <div>First Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatHost />);
|
||||||
|
act(() => chat.open());
|
||||||
|
expect(screen.getByText('First Panel')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'second.chat', name: 'Second Chat' },
|
||||||
|
() => <div>Second Bubble</div>,
|
||||||
|
() => <div>Second Panel</div>,
|
||||||
|
);
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The displaced chat's open state must not leak into the winner.
|
||||||
|
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ChatPanelHost renders the panel when open in panel mode', () => {
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <button type="button">Acme Bubble</button>,
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatPanelHost />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
chat.setDisplayMode('panel');
|
||||||
|
chat.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ChatFloatingHost suppresses the floating panel in panel mode but keeps the trigger', () => {
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <button type="button">Acme Bubble</button>,
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatHost />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
chat.setDisplayMode('panel');
|
||||||
|
chat.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
// In panel mode the floating panel is suppressed (ChatPanelHost owns that slot).
|
||||||
|
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||||
|
// The trigger stays rendered so the user can reopen after collapsing.
|
||||||
|
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => chat.close());
|
||||||
|
|
||||||
|
// Trigger remains visible even when closed — it's the user's only way back.
|
||||||
|
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a crashing panel does not take the trigger down with it', () => {
|
||||||
|
const FailingPanel = () => {
|
||||||
|
throw new Error('panel blew up');
|
||||||
|
};
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <button type="button">Acme Bubble</button>,
|
||||||
|
() => <FailingPanel />,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatHost />);
|
||||||
|
act(() => chat.open());
|
||||||
|
|
||||||
|
// The panel's boundary contains the crash; the trigger keeps rendering so
|
||||||
|
// the user is not stranded without a way back.
|
||||||
|
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isolates a failing trigger so it does not crash the host', () => {
|
||||||
|
const FailingTrigger = () => {
|
||||||
|
throw new Error('chat blew up');
|
||||||
|
};
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <FailingTrigger />,
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The host-owned error boundary catches the failure; render does not throw.
|
||||||
|
expect(() => render(<ChatHost />)).not.toThrow();
|
||||||
|
// The mount slot still renders (the boundary lives inside it), confirming
|
||||||
|
// the provider was actually exercised and contained.
|
||||||
|
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isolates a component that throws during render', () => {
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => {
|
||||||
|
throw new Error('provider blew up');
|
||||||
|
},
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => render(<ChatHost />)).not.toThrow();
|
||||||
|
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers from a crashed chat when a different chat takes over', () => {
|
||||||
|
const FailingTrigger = () => {
|
||||||
|
throw new Error('first chat blew up');
|
||||||
|
};
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'first.chat', name: 'First Chat' },
|
||||||
|
() => <FailingTrigger />,
|
||||||
|
() => <div>First Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatHost />);
|
||||||
|
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'second.chat', name: 'Second Chat' },
|
||||||
|
() => <div>Second Bubble</div>,
|
||||||
|
() => <div>Second Panel</div>,
|
||||||
|
);
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The boundary is keyed per registration, so the latched crash from the
|
||||||
|
// first chat does not blank the second one.
|
||||||
|
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers from a crashed chat when a different id takes over', () => {
|
||||||
|
const FailingTrigger = () => {
|
||||||
|
throw new Error('broken release');
|
||||||
|
};
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||||
|
() => <FailingTrigger />,
|
||||||
|
() => <div>Acme Panel</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatHost />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
chat.registerChat(
|
||||||
|
{ id: 'fixed.chat', name: 'Fixed Chat' },
|
||||||
|
() => <div>Fixed Bubble</div>,
|
||||||
|
() => <div>Fixed Panel</div>,
|
||||||
|
);
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Different id: boundary key changes, latch resets, fix renders.
|
||||||
|
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
|
||||||
|
});
|
||||||
133
superset-frontend/src/core/chat/ChatHost.tsx
Normal file
133
superset-frontend/src/core/chat/ChatHost.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { type ComponentType, useRef } from 'react';
|
||||||
|
import { t } from '@apache-superset/core/translation';
|
||||||
|
import { logging } from '@apache-superset/core/utils';
|
||||||
|
import { css, useTheme } from '@apache-superset/core/theme';
|
||||||
|
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||||
|
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||||
|
import { store } from 'src/views/store';
|
||||||
|
import { useChat } from '.';
|
||||||
|
|
||||||
|
const CHAT_EDGE_MARGIN = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an onError handler that shows a toast on crash, once per chat id.
|
||||||
|
*/
|
||||||
|
function useCrashNotifier(chatId: string | undefined) {
|
||||||
|
const notifiedFor = useRef<string | undefined>(undefined);
|
||||||
|
return (error: Error) => {
|
||||||
|
if (!chatId) return;
|
||||||
|
logging.error('[chat] provider crashed', error);
|
||||||
|
if (notifiedFor.current !== chatId) {
|
||||||
|
notifiedFor.current = chatId;
|
||||||
|
store.dispatch(addDangerToast(t('The chat failed to load.')));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a component in an ErrorBoundary, keyed by chat id so the boundary
|
||||||
|
* resets when a different chat takes over.
|
||||||
|
*/
|
||||||
|
const ChatBoundary = ({
|
||||||
|
component: Component,
|
||||||
|
onError,
|
||||||
|
}: {
|
||||||
|
component: ComponentType;
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
}) => (
|
||||||
|
<ErrorBoundary showMessage={false} onError={onError}>
|
||||||
|
<Component />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the chat panel content in panel mode. Fills its container height.
|
||||||
|
*/
|
||||||
|
export const ChatPanelHost = () => {
|
||||||
|
const { chat, panel } = useChat();
|
||||||
|
const onError = useCrashNotifier(chat?.id);
|
||||||
|
|
||||||
|
if (!chat || !panel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-test="chat-mount"
|
||||||
|
css={css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ChatBoundary key={chat.id} component={panel} onError={onError} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the chat trigger and, when the panel is open in floating mode, the
|
||||||
|
* floating panel overlay. The trigger is always visible when a chat is
|
||||||
|
* registered; the panel overlay is suppressed in panel mode.
|
||||||
|
*/
|
||||||
|
export const ChatFloatingHost = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { open: panelOpen, mode, chat, trigger, panel } = useChat();
|
||||||
|
const onError = useCrashNotifier(chat?.id);
|
||||||
|
|
||||||
|
if (!chat || !trigger || !panel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-test="chat-mount"
|
||||||
|
css={css`
|
||||||
|
position: fixed;
|
||||||
|
right: ${CHAT_EDGE_MARGIN}px;
|
||||||
|
bottom: ${CHAT_EDGE_MARGIN}px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: ${theme.sizeUnit * 2}px;
|
||||||
|
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||||
|
z-index: ${theme.zIndexPopupBase + 2};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
Separate boundaries so a crashing panel cannot take the trigger down
|
||||||
|
with it — the trigger is the user's only way back.
|
||||||
|
*/}
|
||||||
|
{panelOpen && mode !== 'panel' && (
|
||||||
|
<ChatBoundary
|
||||||
|
key={`panel-${chat.id}`}
|
||||||
|
component={panel}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChatBoundary
|
||||||
|
key={`trigger-${chat.id}`}
|
||||||
|
component={trigger}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
257
superset-frontend/src/core/chat/ChatProvider.test.ts
Normal file
257
superset-frontend/src/core/chat/ChatProvider.test.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import ChatProvider from './ChatProvider';
|
||||||
|
|
||||||
|
const trigger = () => createElement('button', null, 'Bubble');
|
||||||
|
const panel = () => createElement('div', null, 'Panel');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ChatProvider.getInstance().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the singleton instance', () => {
|
||||||
|
expect(ChatProvider.getInstance()).toBe(ChatProvider.getInstance());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getChat returns undefined when no chat is registered', () => {
|
||||||
|
expect(ChatProvider.getInstance().getChat()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerChat sets the registration and returns the descriptor copy', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
|
||||||
|
const disposable = provider.registerChat(descriptor, trigger, panel);
|
||||||
|
|
||||||
|
expect(provider.getChat()).toEqual(descriptor);
|
||||||
|
disposable.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the last-registered chat wins and logs a warning', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||||
|
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||||
|
|
||||||
|
expect(provider.getChat()?.id).toBe('second.chat');
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warn.mock.calls[0][0]).toContain('second.chat');
|
||||||
|
expect(warn.mock.calls[0][0]).toContain('first.chat');
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-registering with a different id replaces the active chat', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||||
|
expect(provider.getChat()?.id).toBe('first.chat');
|
||||||
|
|
||||||
|
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||||
|
expect(provider.getChat()?.id).toBe('second.chat');
|
||||||
|
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disposing the registration clears it', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const disposable = provider.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme' },
|
||||||
|
trigger,
|
||||||
|
panel,
|
||||||
|
);
|
||||||
|
|
||||||
|
disposable.dispose();
|
||||||
|
|
||||||
|
expect(provider.getChat()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disposing twice fires unregister only once', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const unregistered = jest.fn();
|
||||||
|
provider.onDidUnregisterChat(unregistered);
|
||||||
|
|
||||||
|
const disposable = provider.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme' },
|
||||||
|
trigger,
|
||||||
|
panel,
|
||||||
|
);
|
||||||
|
disposable.dispose();
|
||||||
|
disposable.dispose();
|
||||||
|
|
||||||
|
expect(unregistered).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const registered = jest.fn();
|
||||||
|
const unregistered = jest.fn();
|
||||||
|
provider.onDidRegisterChat(registered);
|
||||||
|
provider.onDidUnregisterChat(unregistered);
|
||||||
|
|
||||||
|
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||||
|
const disposable = provider.registerChat(descriptor, trigger, panel);
|
||||||
|
|
||||||
|
expect(registered).toHaveBeenCalledWith(descriptor);
|
||||||
|
expect(unregistered).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
disposable.dispose();
|
||||||
|
|
||||||
|
expect(unregistered).toHaveBeenCalledWith(descriptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open and close toggle the panel state', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||||
|
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
|
||||||
|
provider.open();
|
||||||
|
expect(provider.isOpen()).toBe(true);
|
||||||
|
|
||||||
|
provider.close();
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open fires once; duplicate open is a no-op', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const opened = jest.fn();
|
||||||
|
provider.onDidOpen(opened);
|
||||||
|
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||||
|
|
||||||
|
provider.open();
|
||||||
|
provider.open();
|
||||||
|
|
||||||
|
expect(opened).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close fires once; duplicate close is a no-op', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const closed = jest.fn();
|
||||||
|
provider.onDidClose(closed);
|
||||||
|
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||||
|
|
||||||
|
provider.open();
|
||||||
|
provider.close();
|
||||||
|
provider.close();
|
||||||
|
|
||||||
|
expect(closed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open is a no-op when no chat is registered', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const opened = jest.fn();
|
||||||
|
provider.onDidOpen(opened);
|
||||||
|
|
||||||
|
provider.open();
|
||||||
|
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
expect(opened).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registering a second chat while open closes the panel', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const closed = jest.fn();
|
||||||
|
provider.onDidClose(closed);
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||||
|
provider.open();
|
||||||
|
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||||
|
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
expect(closed).toHaveBeenCalledTimes(1);
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disposing the active chat while open closes the panel', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const closed = jest.fn();
|
||||||
|
provider.onDidClose(closed);
|
||||||
|
|
||||||
|
const disposable = provider.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme' },
|
||||||
|
trigger,
|
||||||
|
panel,
|
||||||
|
);
|
||||||
|
provider.open();
|
||||||
|
disposable.dispose();
|
||||||
|
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
expect(closed).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a late registration does not inherit a stale open state', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const disposable = provider.registerChat(
|
||||||
|
{ id: 'acme.chat', name: 'Acme' },
|
||||||
|
trigger,
|
||||||
|
panel,
|
||||||
|
);
|
||||||
|
provider.open();
|
||||||
|
disposable.dispose();
|
||||||
|
|
||||||
|
provider.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel);
|
||||||
|
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDisplayMode defaults to floating', () => {
|
||||||
|
expect(ChatProvider.getInstance().getDisplayMode()).toBe('floating');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDisplayMode updates mode and fires event only on change', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
const modeChanged = jest.fn();
|
||||||
|
provider.onDidChangeDisplayMode(modeChanged);
|
||||||
|
|
||||||
|
provider.setDisplayMode('floating');
|
||||||
|
expect(modeChanged).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
provider.setDisplayMode('panel');
|
||||||
|
expect(provider.getDisplayMode()).toBe('panel');
|
||||||
|
expect(modeChanged).toHaveBeenCalledWith('panel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state reflects changes after registration and open', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
|
||||||
|
expect(provider.getChat()).toBeUndefined();
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
|
||||||
|
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||||
|
provider.open();
|
||||||
|
|
||||||
|
expect(provider.isOpen()).toBe(true);
|
||||||
|
expect(provider.getChat()?.id).toBe('acme.chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset clears all state', () => {
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||||
|
provider.open();
|
||||||
|
provider.setDisplayMode('panel');
|
||||||
|
|
||||||
|
provider.reset();
|
||||||
|
|
||||||
|
expect(provider.getChat()).toBeUndefined();
|
||||||
|
expect(provider.isOpen()).toBe(false);
|
||||||
|
expect(provider.getDisplayMode()).toBe('floating');
|
||||||
|
});
|
||||||
209
superset-frontend/src/core/chat/ChatProvider.ts
Normal file
209
superset-frontend/src/core/chat/ChatProvider.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
import type { chat as chatApi } from '@apache-superset/core';
|
||||||
|
import {
|
||||||
|
LocalStorageKeys,
|
||||||
|
getItem,
|
||||||
|
setItem,
|
||||||
|
} from 'src/utils/localStorageHelpers';
|
||||||
|
import { Disposable } from '../models';
|
||||||
|
import { createValueEventEmitter, createEventEmitter } from '../utils';
|
||||||
|
|
||||||
|
type Chat = chatApi.Chat;
|
||||||
|
type DisplayMode = chatApi.DisplayMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton manager for the chat provider.
|
||||||
|
* Handles registration, open/close state, and display mode.
|
||||||
|
*/
|
||||||
|
class ChatProvider {
|
||||||
|
private static instance: ChatProvider;
|
||||||
|
|
||||||
|
private chat: Chat | undefined;
|
||||||
|
|
||||||
|
private trigger: ComponentType | undefined;
|
||||||
|
|
||||||
|
private panel: ComponentType | undefined;
|
||||||
|
|
||||||
|
private opened: boolean;
|
||||||
|
|
||||||
|
private stateSubscribers = new Set<() => void>();
|
||||||
|
|
||||||
|
private registerEmitter = createEventEmitter<Chat>();
|
||||||
|
|
||||||
|
private unregisterEmitter = createEventEmitter<Chat>();
|
||||||
|
|
||||||
|
private openEmitter = createEventEmitter<void>();
|
||||||
|
|
||||||
|
private closeEmitter = createEventEmitter<void>();
|
||||||
|
|
||||||
|
private resizePanelEmitter = createEventEmitter<{ width: number }>();
|
||||||
|
|
||||||
|
private modeEmitter: ReturnType<typeof createValueEventEmitter<DisplayMode>>;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
const persisted = getItem(LocalStorageKeys.ChatState, {
|
||||||
|
open: false,
|
||||||
|
mode: 'floating',
|
||||||
|
});
|
||||||
|
const mode = (
|
||||||
|
persisted.mode === 'panel' ? 'panel' : 'floating'
|
||||||
|
) as DisplayMode;
|
||||||
|
this.opened = persisted.open === true;
|
||||||
|
this.modeEmitter = createValueEventEmitter<DisplayMode>(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): ChatProvider {
|
||||||
|
if (!ChatProvider.instance) {
|
||||||
|
ChatProvider.instance = new ChatProvider();
|
||||||
|
}
|
||||||
|
return ChatProvider.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe = (listener: () => void): (() => void) => {
|
||||||
|
this.stateSubscribers.add(listener);
|
||||||
|
return () => this.stateSubscribers.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
|
private notifyState(): void {
|
||||||
|
setItem(LocalStorageKeys.ChatState, {
|
||||||
|
open: this.opened,
|
||||||
|
mode: this.modeEmitter.getCurrent(),
|
||||||
|
});
|
||||||
|
this.stateSubscribers.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private closePanel(): void {
|
||||||
|
this.opened = false;
|
||||||
|
this.closeEmitter.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerChat(
|
||||||
|
chat: Chat,
|
||||||
|
trigger: ComponentType,
|
||||||
|
panel: ComponentType,
|
||||||
|
): Disposable {
|
||||||
|
if (this.chat) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`,
|
||||||
|
);
|
||||||
|
this.unregisterEmitter.fire(this.chat);
|
||||||
|
if (this.opened) this.closePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chat = chat;
|
||||||
|
this.trigger = trigger;
|
||||||
|
this.panel = panel;
|
||||||
|
this.registerEmitter.fire(chat);
|
||||||
|
this.notifyState();
|
||||||
|
|
||||||
|
return new Disposable(() => {
|
||||||
|
if (this.chat !== chat) return;
|
||||||
|
this.chat = undefined;
|
||||||
|
this.trigger = undefined;
|
||||||
|
this.panel = undefined;
|
||||||
|
this.unregisterEmitter.fire(chat);
|
||||||
|
if (this.opened) this.closePanel();
|
||||||
|
this.notifyState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public getChat(): Chat | undefined {
|
||||||
|
return this.chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTrigger(): ComponentType | undefined {
|
||||||
|
return this.trigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getPanel(): ComponentType | undefined {
|
||||||
|
return this.panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public open(): void {
|
||||||
|
if (this.opened || !this.chat) return;
|
||||||
|
this.opened = true;
|
||||||
|
this.openEmitter.fire();
|
||||||
|
this.notifyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(): void {
|
||||||
|
if (!this.opened || !this.chat) return;
|
||||||
|
this.closePanel();
|
||||||
|
this.notifyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public isOpen(): boolean {
|
||||||
|
return this.opened;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDisplayMode(): DisplayMode {
|
||||||
|
return this.modeEmitter.getCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setDisplayMode(displayMode: DisplayMode): void {
|
||||||
|
if (displayMode === this.modeEmitter.getCurrent()) return;
|
||||||
|
this.modeEmitter.fire(displayMode);
|
||||||
|
this.notifyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get onDidRegisterChat() {
|
||||||
|
return this.registerEmitter.subscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get onDidUnregisterChat() {
|
||||||
|
return this.unregisterEmitter.subscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get onDidOpen() {
|
||||||
|
return this.openEmitter.subscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get onDidClose() {
|
||||||
|
return this.closeEmitter.subscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get onDidChangeDisplayMode() {
|
||||||
|
return this.modeEmitter.subscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get onDidResizePanel() {
|
||||||
|
return this.resizePanelEmitter.subscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.chat = undefined;
|
||||||
|
this.trigger = undefined;
|
||||||
|
this.panel = undefined;
|
||||||
|
this.opened = false;
|
||||||
|
this.registerEmitter = createEventEmitter<Chat>();
|
||||||
|
this.unregisterEmitter = createEventEmitter<Chat>();
|
||||||
|
this.openEmitter = createEventEmitter<void>();
|
||||||
|
this.closeEmitter = createEventEmitter<void>();
|
||||||
|
this.resizePanelEmitter = createEventEmitter<{ width: number }>();
|
||||||
|
this.modeEmitter = createValueEventEmitter<DisplayMode>('floating');
|
||||||
|
this.stateSubscribers.clear();
|
||||||
|
setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatProvider;
|
||||||
68
superset-frontend/src/core/chat/index.test.ts
Normal file
68
superset-frontend/src/core/chat/index.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { chat } from './index';
|
||||||
|
import ChatProvider from './ChatProvider';
|
||||||
|
|
||||||
|
const trigger = () => createElement('button', null, 'Bubble');
|
||||||
|
const panel = () => createElement('div', null, 'Panel');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ChatProvider.getInstance().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getChat returns undefined when no chat is registered', () => {
|
||||||
|
expect(chat.getChat()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerChat makes the chat retrievable via getChat', () => {
|
||||||
|
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
|
||||||
|
chat.registerChat(descriptor, trigger, panel);
|
||||||
|
|
||||||
|
expect(chat.getChat()).toEqual(descriptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the last-registered chat wins when multiple are registered', () => {
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||||
|
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||||
|
|
||||||
|
expect(chat.getChat()?.id).toBe('second.chat');
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open and close toggle isOpen', () => {
|
||||||
|
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||||
|
|
||||||
|
expect(chat.isOpen()).toBe(false);
|
||||||
|
chat.open();
|
||||||
|
expect(chat.isOpen()).toBe(true);
|
||||||
|
chat.close();
|
||||||
|
expect(chat.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDisplayMode defaults to floating', () => {
|
||||||
|
expect(chat.getDisplayMode()).toBe('floating');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDisplayMode updates the display mode', () => {
|
||||||
|
chat.setDisplayMode('panel');
|
||||||
|
expect(chat.getDisplayMode()).toBe('panel');
|
||||||
|
});
|
||||||
82
superset-frontend/src/core/chat/index.ts
Normal file
82
superset-frontend/src/core/chat/index.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Host implementation of the `chat` contribution type.
|
||||||
|
*
|
||||||
|
* Extensions register via the public `chat.registerChat()` and the host owns
|
||||||
|
* mounting, open/close state, and the display mode. Only the last-registered
|
||||||
|
* chat is active at a time.
|
||||||
|
*
|
||||||
|
* The public namespace (`chat`) is exposed to extensions on `window.superset`.
|
||||||
|
* `useChat` is host-internal and NOT part of the public `@apache-superset/core` API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import memoizeOne from 'memoize-one';
|
||||||
|
import type { chat as chatApi } from '@apache-superset/core';
|
||||||
|
import ChatProvider from './ChatProvider';
|
||||||
|
|
||||||
|
export { ChatFloatingHost, ChatPanelHost } from './ChatHost';
|
||||||
|
|
||||||
|
const provider = ChatProvider.getInstance();
|
||||||
|
|
||||||
|
const buildSnapshot = memoizeOne(
|
||||||
|
(
|
||||||
|
open: boolean,
|
||||||
|
mode: chatApi.DisplayMode,
|
||||||
|
chat: chatApi.Chat | undefined,
|
||||||
|
trigger: ReturnType<typeof provider.getTrigger>,
|
||||||
|
panel: ReturnType<typeof provider.getPanel>,
|
||||||
|
) => ({ open, mode, chat, trigger, panel }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSnapshot = () =>
|
||||||
|
buildSnapshot(
|
||||||
|
provider.isOpen(),
|
||||||
|
provider.getDisplayMode(),
|
||||||
|
provider.getChat(),
|
||||||
|
provider.getTrigger(),
|
||||||
|
provider.getPanel(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-internal hook. Returns the current open/mode state and the active chat
|
||||||
|
* (trigger, panel, descriptor).
|
||||||
|
*/
|
||||||
|
export const useChat = () =>
|
||||||
|
useSyncExternalStore(provider.subscribe, getSnapshot);
|
||||||
|
|
||||||
|
export const chat: typeof chatApi = {
|
||||||
|
registerChat: provider.registerChat.bind(provider),
|
||||||
|
getChat: provider.getChat.bind(provider),
|
||||||
|
onDidRegisterChat: provider.onDidRegisterChat,
|
||||||
|
onDidUnregisterChat: provider.onDidUnregisterChat,
|
||||||
|
open: provider.open.bind(provider),
|
||||||
|
close: provider.close.bind(provider),
|
||||||
|
isOpen: provider.isOpen.bind(provider),
|
||||||
|
onDidOpen: provider.onDidOpen,
|
||||||
|
onDidClose: provider.onDidClose,
|
||||||
|
getDisplayMode: provider.getDisplayMode.bind(provider),
|
||||||
|
setDisplayMode: provider.setDisplayMode.bind(provider),
|
||||||
|
onDidChangeDisplayMode: provider.onDidChangeDisplayMode,
|
||||||
|
// The host fires this from its panel resizer; until that chrome exists the
|
||||||
|
// event is exposed but never fires.
|
||||||
|
onDidResizePanel: provider.onDidResizePanel,
|
||||||
|
};
|
||||||
@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
|
|||||||
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
|
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles errors in event listeners gracefully', () => {
|
|
||||||
const manager = EditorProviders.getInstance();
|
|
||||||
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
||||||
|
|
||||||
const errorListener = jest.fn(() => {
|
|
||||||
throw new Error('Listener error');
|
|
||||||
});
|
|
||||||
const successListener = jest.fn();
|
|
||||||
|
|
||||||
manager.onDidRegister(errorListener);
|
|
||||||
manager.onDidRegister(successListener);
|
|
||||||
|
|
||||||
manager.registerProvider(createMockEditor(), createMockEditorComponent());
|
|
||||||
|
|
||||||
// Both listeners should have been called
|
|
||||||
expect(errorListener).toHaveBeenCalledTimes(1);
|
|
||||||
expect(successListener).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Error should have been logged
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
'Error in event listener:',
|
|
||||||
expect.any(Error),
|
|
||||||
);
|
|
||||||
|
|
||||||
consoleErrorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reset clears all providers and language mappings', () => {
|
test('reset clears all providers and language mappings', () => {
|
||||||
const manager = EditorProviders.getInstance();
|
const manager = EditorProviders.getInstance();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import type { editors } from '@apache-superset/core';
|
import type { editors } from '@apache-superset/core';
|
||||||
import { Disposable } from '../models';
|
import { Disposable } from '../models';
|
||||||
|
import { createEventEmitter } from '../utils';
|
||||||
|
|
||||||
type EditorLanguage = editors.EditorLanguage;
|
type EditorLanguage = editors.EditorLanguage;
|
||||||
type EditorProvider = editors.EditorProvider;
|
type EditorProvider = editors.EditorProvider;
|
||||||
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
|
|||||||
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
|
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
|
||||||
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
|
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
|
||||||
|
|
||||||
/**
|
|
||||||
* Listener function type for events.
|
|
||||||
*/
|
|
||||||
type Listener<T> = (e: T) => void;
|
type Listener<T> = (e: T) => void;
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple event emitter for editor provider lifecycle events.
|
|
||||||
*/
|
|
||||||
class EventEmitter<T> {
|
|
||||||
private listeners: Set<Listener<T>> = new Set();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to this event.
|
|
||||||
* @param listener The listener function to call when the event is fired.
|
|
||||||
* @returns A Disposable to unsubscribe from the event.
|
|
||||||
*/
|
|
||||||
subscribe(listener: Listener<T>): Disposable {
|
|
||||||
this.listeners.add(listener);
|
|
||||||
return new Disposable(() => {
|
|
||||||
this.listeners.delete(listener);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire the event with the given data.
|
|
||||||
* @param data The event data to pass to listeners.
|
|
||||||
*/
|
|
||||||
fire(data: T): void {
|
|
||||||
this.listeners.forEach(listener => {
|
|
||||||
try {
|
|
||||||
listener(data);
|
|
||||||
} catch (error) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('Error in event listener:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton manager for editor providers.
|
* Singleton manager for editor providers.
|
||||||
* Handles registration, resolution, and lifecycle of custom editor implementations.
|
* Handles registration, resolution, and lifecycle of custom editor implementations.
|
||||||
@@ -83,15 +47,9 @@ class EditorProviders {
|
|||||||
*/
|
*/
|
||||||
private languageToProvider: Map<EditorLanguage, string> = new Map();
|
private languageToProvider: Map<EditorLanguage, string> = new Map();
|
||||||
|
|
||||||
/**
|
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
|
||||||
* Event emitter for provider registration events.
|
|
||||||
*/
|
|
||||||
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
|
|
||||||
|
|
||||||
/**
|
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
|
||||||
* Event emitter for provider unregistration events.
|
|
||||||
*/
|
|
||||||
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
|
|
||||||
|
|
||||||
private syncListeners: Set<() => void> = new Set();
|
private syncListeners: Set<() => void> = new Set();
|
||||||
|
|
||||||
@@ -226,8 +184,11 @@ class EditorProviders {
|
|||||||
* @param listener The listener function.
|
* @param listener The listener function.
|
||||||
* @returns A Disposable to unsubscribe.
|
* @returns A Disposable to unsubscribe.
|
||||||
*/
|
*/
|
||||||
public onDidRegister(listener: Listener<EditorRegisteredEvent>): Disposable {
|
public onDidRegister(
|
||||||
return this.registerEmitter.subscribe(listener);
|
listener: Listener<EditorRegisteredEvent>,
|
||||||
|
thisArgs?: unknown,
|
||||||
|
): Disposable {
|
||||||
|
return this.registerEmitter.subscribe(listener, thisArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,8 +198,9 @@ class EditorProviders {
|
|||||||
*/
|
*/
|
||||||
public onDidUnregister(
|
public onDidUnregister(
|
||||||
listener: Listener<EditorUnregisteredEvent>,
|
listener: Listener<EditorUnregisteredEvent>,
|
||||||
|
thisArgs?: unknown,
|
||||||
): Disposable {
|
): Disposable {
|
||||||
return this.unregisterEmitter.subscribe(listener);
|
return this.unregisterEmitter.subscribe(listener, thisArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,6 +210,8 @@ class EditorProviders {
|
|||||||
this.providers.clear();
|
this.providers.clear();
|
||||||
this.languageToProvider.clear();
|
this.languageToProvider.clear();
|
||||||
this.syncListeners.clear();
|
this.syncListeners.clear();
|
||||||
|
this.registerEmitter = createEventEmitter<EditorRegisteredEvent>();
|
||||||
|
this.unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,130 +18,39 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fileoverview Implementation of the editors API for Superset.
|
* @fileoverview Host implementation of the `editors` contribution type.
|
||||||
*
|
*
|
||||||
* This module provides the runtime implementation of the editor registration
|
* Extensions register via the public `editors.registerEditor()` and the host
|
||||||
* and resolution functions declared in the API types.
|
* resolves the appropriate provider per language, falling back to the built-in
|
||||||
|
* AceEditorProvider when no extension is registered.
|
||||||
|
*
|
||||||
|
* The public namespace (`editors`) is exposed to extensions on `window.superset`.
|
||||||
|
* `EditorHost` is the host-internal component for rendering editors and is NOT
|
||||||
|
* part of the public `@apache-superset/core` API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
import { editors as editorsApi } from '@apache-superset/core';
|
import { editors as editorsApi } from '@apache-superset/core';
|
||||||
import { Disposable } from '../models';
|
|
||||||
import EditorProviders from './EditorProviders';
|
import EditorProviders from './EditorProviders';
|
||||||
|
|
||||||
type EditorLanguage = editorsApi.EditorLanguage;
|
export type { EditorHostProps } from './EditorHost';
|
||||||
type Editor = editorsApi.Editor;
|
export { default as EditorHost } from './EditorHost';
|
||||||
type EditorProvider = editorsApi.EditorProvider;
|
export { default as AceEditorProvider } from './AceEditorProvider';
|
||||||
type EditorComponent = editorsApi.EditorComponent;
|
|
||||||
type EditorRegisteredEvent = editorsApi.EditorRegisteredEvent;
|
|
||||||
type EditorUnregisteredEvent = editorsApi.EditorUnregisteredEvent;
|
|
||||||
|
|
||||||
/**
|
const provider = EditorProviders.getInstance();
|
||||||
* Register an editor provider as a module-level side effect.
|
|
||||||
* Takes the editor descriptor directly rather than looking it up
|
|
||||||
* from a manifest by ID.
|
|
||||||
*
|
|
||||||
* @param editor The editor descriptor.
|
|
||||||
* @param component The React component implementing the editor.
|
|
||||||
* @returns A Disposable to unregister the provider.
|
|
||||||
*/
|
|
||||||
export const registerEditor = (
|
|
||||||
editor: Editor,
|
|
||||||
component: EditorComponent,
|
|
||||||
): Disposable => {
|
|
||||||
const providers = EditorProviders.getInstance();
|
|
||||||
return providers.registerProvider(editor, component);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
export const useEditor = (language: editorsApi.EditorLanguage) =>
|
||||||
* Get the editor provider for a specific language.
|
useSyncExternalStore(
|
||||||
* Returns the extension's editor if registered, otherwise undefined.
|
provider.subscribe,
|
||||||
*
|
() => provider.getProvider(language),
|
||||||
* @param language The language to get an editor for
|
|
||||||
* @returns The editor provider or undefined if no extension provides one
|
|
||||||
*/
|
|
||||||
export const getEditor = (
|
|
||||||
language: EditorLanguage,
|
|
||||||
): EditorProvider | undefined => {
|
|
||||||
const manager = EditorProviders.getInstance();
|
|
||||||
return manager.getProvider(language);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an extension has registered an editor for a language.
|
|
||||||
*
|
|
||||||
* @param language The language to check
|
|
||||||
* @returns True if an extension provides an editor for this language
|
|
||||||
*/
|
|
||||||
export const hasEditor = (language: EditorLanguage): boolean => {
|
|
||||||
const manager = EditorProviders.getInstance();
|
|
||||||
return manager.hasProvider(language);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all registered editor providers.
|
|
||||||
*
|
|
||||||
* @returns Array of all registered editor providers
|
|
||||||
*/
|
|
||||||
export const getAllEditors = (): EditorProvider[] => {
|
|
||||||
const manager = EditorProviders.getInstance();
|
|
||||||
return manager.getAllProviders();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event fired when an editor is registered.
|
|
||||||
* Subscribe to this event to react when extensions register new editors.
|
|
||||||
*/
|
|
||||||
export const onDidRegisterEditor = (
|
|
||||||
listener: (e: EditorRegisteredEvent) => void,
|
|
||||||
): Disposable => {
|
|
||||||
const manager = EditorProviders.getInstance();
|
|
||||||
return manager.onDidRegister(listener);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Event fired when an editor is unregistered.
|
|
||||||
* Subscribe to this event to react when extensions unregister editors.
|
|
||||||
*/
|
|
||||||
export const onDidUnregisterEditor = (
|
|
||||||
listener: (e: EditorUnregisteredEvent) => void,
|
|
||||||
): Disposable => {
|
|
||||||
const manager = EditorProviders.getInstance();
|
|
||||||
return manager.onDidUnregister(listener);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that returns the editor provider for a specific language and re-renders when it changes.
|
|
||||||
*
|
|
||||||
* @param language The language to get an editor for
|
|
||||||
* @returns The editor provider or undefined if no extension provides one
|
|
||||||
*/
|
|
||||||
export const useEditor = (
|
|
||||||
language: EditorLanguage,
|
|
||||||
): EditorProvider | undefined => {
|
|
||||||
const manager = EditorProviders.getInstance();
|
|
||||||
return useSyncExternalStore(
|
|
||||||
manager.subscribe,
|
|
||||||
() => manager.getProvider(language),
|
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Editors API object for use in the extension system.
|
|
||||||
*/
|
|
||||||
export const editors: typeof editorsApi = {
|
export const editors: typeof editorsApi = {
|
||||||
registerEditor,
|
registerEditor: provider.registerProvider.bind(provider),
|
||||||
getEditor,
|
getEditor: provider.getProvider.bind(provider),
|
||||||
hasEditor,
|
hasEditor: provider.hasProvider.bind(provider),
|
||||||
getAllEditors,
|
getAllEditors: provider.getAllProviders.bind(provider),
|
||||||
onDidRegisterEditor,
|
onDidRegisterEditor: provider.onDidRegister.bind(provider),
|
||||||
onDidUnregisterEditor,
|
onDidUnregisterEditor: provider.onDidUnregister.bind(provider),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { EditorProviders };
|
|
||||||
|
|
||||||
// Component exports
|
|
||||||
export { default as EditorHost } from './EditorHost';
|
|
||||||
export type { EditorHostProps } from './EditorHost';
|
|
||||||
export { default as AceEditorProvider } from './AceEditorProvider';
|
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ export const core: typeof coreType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export * from './authentication';
|
export * from './authentication';
|
||||||
|
export * from './chat';
|
||||||
export * from './commands';
|
export * from './commands';
|
||||||
export * from './editors';
|
export * from './editors';
|
||||||
export * from './extensions';
|
export * from './extensions';
|
||||||
export * from './menus';
|
export * from './menus';
|
||||||
export * from './models';
|
export * from './models';
|
||||||
|
export * from './navigation';
|
||||||
export * from './sqlLab';
|
export * from './sqlLab';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './views';
|
export * from './views';
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
import type { menus as menusApi } from '@apache-superset/core';
|
import type { menus as menusApi } from '@apache-superset/core';
|
||||||
import { Disposable } from '../models';
|
import { Disposable } from '../models';
|
||||||
|
import { createEventEmitter } from '../utils';
|
||||||
|
|
||||||
type MenuItem = menusApi.MenuItem;
|
type MenuItem = menusApi.MenuItem;
|
||||||
type Menu = menusApi.Menu;
|
type Menu = menusApi.Menu;
|
||||||
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
|
|||||||
return () => syncListeners.delete(listener);
|
return () => syncListeners.delete(listener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
|
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
|
||||||
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
|
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
|
||||||
|
|
||||||
const menuCache = new Map<string, Menu | undefined>();
|
const menuCache = new Map<string, Menu | undefined>();
|
||||||
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
||||||
menuCache.clear();
|
menuCache.clear();
|
||||||
syncListeners.forEach(l => l());
|
syncListeners.forEach(l => l());
|
||||||
registerListeners.forEach(l => l(event));
|
registerEmitter.fire(event);
|
||||||
};
|
};
|
||||||
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
||||||
menuCache.clear();
|
menuCache.clear();
|
||||||
syncListeners.forEach(l => l());
|
syncListeners.forEach(l => l());
|
||||||
unregisterListeners.forEach(l => l(event));
|
unregisterEmitter.fire(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||||
@@ -117,16 +118,14 @@ export const useMenu = (location: string): Menu | undefined =>
|
|||||||
|
|
||||||
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
||||||
listener: (e: MenuItemRegisteredEvent) => void,
|
listener: (e: MenuItemRegisteredEvent) => void,
|
||||||
): Disposable => {
|
thisArgs?: unknown,
|
||||||
registerListeners.add(listener);
|
): Disposable => registerEmitter.subscribe(listener, thisArgs);
|
||||||
return new Disposable(() => registerListeners.delete(listener));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
||||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
|
(
|
||||||
unregisterListeners.add(listener);
|
listener: (e: MenuItemUnregisteredEvent) => void,
|
||||||
return new Disposable(() => unregisterListeners.delete(listener));
|
thisArgs?: unknown,
|
||||||
};
|
): Disposable => unregisterEmitter.subscribe(listener, thisArgs);
|
||||||
|
|
||||||
export const menus: typeof menusApi = {
|
export const menus: typeof menusApi = {
|
||||||
registerMenuItem,
|
registerMenuItem,
|
||||||
|
|||||||
124
superset-frontend/src/core/navigation/index.test.ts
Normal file
124
superset-frontend/src/core/navigation/index.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Reset module state between tests so currentPage is re-initialized.
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
writable: true,
|
||||||
|
value: { pathname: '/' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importNavigation() {
|
||||||
|
const mod = await import('./index');
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
|
||||||
|
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||||
|
// The default pathname ('/') is not enumerated and falls back to home.
|
||||||
|
expect(navigation.getPage()).toBe('home');
|
||||||
|
notifyLocationChanged('/superset/welcome/');
|
||||||
|
expect(navigation.getPage()).toBe('home');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPage derives the page from window.location.pathname', async () => {
|
||||||
|
window.location.pathname = '/superset/dashboard/42/';
|
||||||
|
const { navigation } = await importNavigation();
|
||||||
|
expect(navigation.getPage()).toBe('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyLocationChanged updates the current page type', async () => {
|
||||||
|
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||||
|
notifyLocationChanged('/explore/?form_data={}');
|
||||||
|
expect(navigation.getPage()).toBe('explore');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyLocationChanged fires listeners on page type change', async () => {
|
||||||
|
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||||
|
const listener = jest.fn();
|
||||||
|
const disposable = navigation.onDidChangePage(listener);
|
||||||
|
|
||||||
|
notifyLocationChanged('/superset/dashboard/1/');
|
||||||
|
expect(listener).toHaveBeenCalledWith('dashboard');
|
||||||
|
|
||||||
|
disposable.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => {
|
||||||
|
window.location.pathname = '/superset/dashboard/1/';
|
||||||
|
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||||
|
const listener = jest.fn();
|
||||||
|
navigation.onDidChangePage(listener);
|
||||||
|
|
||||||
|
notifyLocationChanged('/superset/dashboard/2/');
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onDidChangePage listener is removed after dispose', async () => {
|
||||||
|
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||||
|
const listener = jest.fn();
|
||||||
|
const disposable = navigation.onDidChangePage(listener);
|
||||||
|
|
||||||
|
disposable.dispose();
|
||||||
|
notifyLocationChanged('/superset/dashboard/1/');
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sqllab path is matched with and without trailing slash', async () => {
|
||||||
|
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||||
|
notifyLocationChanged('/sqllab');
|
||||||
|
expect(navigation.getPage()).toBe('sqllab');
|
||||||
|
notifyLocationChanged('/explore/');
|
||||||
|
notifyLocationChanged('/sqllab/');
|
||||||
|
expect(navigation.getPage()).toBe('sqllab');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chart and dashboard list pages get their own page types', async () => {
|
||||||
|
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||||
|
notifyLocationChanged('/chart/list/');
|
||||||
|
expect(navigation.getPage()).toBe('chart_list');
|
||||||
|
notifyLocationChanged('/dashboard/list/');
|
||||||
|
expect(navigation.getPage()).toBe('dashboard_list');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dataset list and single-dataset pages get distinct page types', async () => {
|
||||||
|
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||||
|
notifyLocationChanged('/tablemodelview/list/');
|
||||||
|
expect(navigation.getPage()).toBe('dataset_list');
|
||||||
|
notifyLocationChanged('/dataset/42');
|
||||||
|
expect(navigation.getPage()).toBe('dataset');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
|
||||||
|
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||||
|
notifyLocationChanged('/sqllab/');
|
||||||
|
expect(navigation.getPage()).toBe('sqllab');
|
||||||
|
notifyLocationChanged('/sqllab/history/');
|
||||||
|
expect(navigation.getPage()).toBe('query_history');
|
||||||
|
notifyLocationChanged('/savedqueryview/list/');
|
||||||
|
expect(navigation.getPage()).toBe('saved_queries');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chart/add resolves to explore, not chart_list', async () => {
|
||||||
|
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||||
|
notifyLocationChanged('/chart/add');
|
||||||
|
expect(navigation.getPage()).toBe('explore');
|
||||||
|
});
|
||||||
94
superset-frontend/src/core/navigation/index.ts
Normal file
94
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview Host-internal implementation of the `navigation` namespace.
|
||||||
|
*
|
||||||
|
* Derives the current {@link Page} from the browser location by matching
|
||||||
|
* against {@link RoutePaths}. Call {@link useNavigationTracker} once in the
|
||||||
|
* app shell to keep the page in sync with React Router.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation, matchPath } from 'react-router-dom';
|
||||||
|
import type { navigation as navigationApi } from '@apache-superset/core';
|
||||||
|
import { RoutePaths } from '../../views/routePaths';
|
||||||
|
import { Disposable } from '../models';
|
||||||
|
import { createValueEventEmitter } from '../utils';
|
||||||
|
|
||||||
|
type Page = navigationApi.Page;
|
||||||
|
|
||||||
|
/** Maps route path patterns to their corresponding Page type. */
|
||||||
|
const PAGE_ROUTES: { path: string; page: Page }[] = [
|
||||||
|
{ path: RoutePaths.DASHBOARD, page: 'dashboard' },
|
||||||
|
{ path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
|
||||||
|
{ path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
|
||||||
|
{ path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
|
||||||
|
{ path: RoutePaths.SQLLAB, page: 'sqllab' },
|
||||||
|
{ path: RoutePaths.CHART_ADD, page: 'explore' },
|
||||||
|
{ path: RoutePaths.CHART_LIST, page: 'chart_list' },
|
||||||
|
{ path: RoutePaths.EXPLORE, page: 'explore' },
|
||||||
|
{ path: RoutePaths.EXPLORE_PERMALINK, page: 'explore' },
|
||||||
|
{ path: RoutePaths.DATASET_LIST, page: 'dataset_list' },
|
||||||
|
{ path: RoutePaths.DATASET_ADD, page: 'dataset' },
|
||||||
|
{ path: RoutePaths.DATASET, page: 'dataset' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function derivePage(pathname: string): Page {
|
||||||
|
for (const { path, page } of PAGE_ROUTES) {
|
||||||
|
if (matchPath(pathname, { path, exact: false })) return page;
|
||||||
|
}
|
||||||
|
return 'home';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageEmitter = createValueEventEmitter<Page>(
|
||||||
|
derivePage(window.location.pathname),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Updates the current page from a pathname. No-op when the page is unchanged. */
|
||||||
|
export const notifyLocationChanged = (pathname: string): void => {
|
||||||
|
const next = derivePage(pathname);
|
||||||
|
if (next === pageEmitter.getCurrent()) return;
|
||||||
|
pageEmitter.fire(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent();
|
||||||
|
|
||||||
|
const onDidChangePage: typeof navigationApi.onDidChangePage = (
|
||||||
|
listener: (page: Page) => void,
|
||||||
|
thisArgs?: unknown,
|
||||||
|
): Disposable => pageEmitter.subscribe(listener, thisArgs);
|
||||||
|
|
||||||
|
/** Synchronizes the navigation module with React Router. Call once in the app shell. */
|
||||||
|
export const useNavigationTracker = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const prevPathname = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevPathname.current !== location.pathname) {
|
||||||
|
prevPathname.current = location.pathname;
|
||||||
|
notifyLocationChanged(location.pathname);
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navigation: typeof navigationApi = {
|
||||||
|
getPage,
|
||||||
|
onDidChangePage,
|
||||||
|
};
|
||||||
@@ -48,7 +48,7 @@ import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
|||||||
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||||
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
|
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
|
||||||
import { Database, Disposable } from '../models';
|
import { Database, Disposable } from '../models';
|
||||||
import { createActionListener } from '../utils';
|
import { createActionListener } from '../storeUtils';
|
||||||
import {
|
import {
|
||||||
Panel,
|
Panel,
|
||||||
Tab,
|
Tab,
|
||||||
|
|||||||
48
superset-frontend/src/core/storeUtils.ts
Normal file
48
superset-frontend/src/core/storeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import type { common as core } from '@apache-superset/core';
|
||||||
|
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
||||||
|
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export function createActionListener<V, A = unknown>(
|
||||||
|
predicate: AnyListenerPredicate<RootState>,
|
||||||
|
listener: (v: V) => void,
|
||||||
|
valueParser: (action: A, state: RootState) => V | null | undefined,
|
||||||
|
thisArgs?: unknown,
|
||||||
|
): core.Disposable {
|
||||||
|
const boundListener = thisArgs ? listener.bind(thisArgs as object) : listener;
|
||||||
|
|
||||||
|
const unsubscribe = listenerMiddleware.startListening({
|
||||||
|
predicate,
|
||||||
|
effect: action => {
|
||||||
|
const state = store.getState();
|
||||||
|
// The predicate already ensures the action matches type A at runtime.
|
||||||
|
const value = valueParser(action as unknown as A, state);
|
||||||
|
if (value != null) {
|
||||||
|
boundListener(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispose: () => {
|
||||||
|
unsubscribe();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,33 +17,54 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import type { common as core } from '@apache-superset/core';
|
import type { common as core } from '@apache-superset/core';
|
||||||
import { AnyAction } from 'redux';
|
|
||||||
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
|
||||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
export function createActionListener<V>(
|
type Listener<T> = (e: T) => unknown;
|
||||||
predicate: AnyListenerPredicate<RootState>,
|
|
||||||
listener: (v: V) => void,
|
|
||||||
valueParser: (action: AnyAction, state: RootState) => V | null | undefined,
|
|
||||||
thisArgs?: any,
|
|
||||||
): core.Disposable {
|
|
||||||
const boundListener = thisArgs ? listener.bind(thisArgs) : listener;
|
|
||||||
|
|
||||||
const unsubscribe = listenerMiddleware.startListening({
|
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
|
||||||
predicate,
|
export interface EventEmitter<T> {
|
||||||
effect: (action: AnyAction) => {
|
/** Notifies every current subscriber with `value`. */
|
||||||
const state = store.getState();
|
fire(value: T): void;
|
||||||
const value = valueParser(action, state);
|
/** Registers a listener; returns a Disposable that removes it. */
|
||||||
// Skip calling listener if valueParser returns null/undefined
|
subscribe: core.Event<T>;
|
||||||
if (value != null) {
|
}
|
||||||
boundListener(value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
/** An event emitter that also retains the last fired value. */
|
||||||
|
export interface ValueEventEmitter<T> extends EventEmitter<T> {
|
||||||
|
/** Returns the value last passed to {@link fire} (or the initial value). */
|
||||||
|
getCurrent(): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a stateless event emitter. Listeners registered via `event` receive
|
||||||
|
* every subsequent `fire`; a returned Disposable removes the listener.
|
||||||
|
*/
|
||||||
|
export function createEventEmitter<T>(): EventEmitter<T> {
|
||||||
|
const listeners = new Set<Listener<T>>();
|
||||||
|
const subscribe: core.Event<T> = (listener, thisArgs) => {
|
||||||
|
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||||
|
listeners.add(bound);
|
||||||
|
return { dispose: () => listeners.delete(bound) };
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
dispose: () => {
|
fire: value => listeners.forEach(fn => fn(value)),
|
||||||
unsubscribe();
|
subscribe,
|
||||||
},
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a value event emitter seeded with `initial`. Behaves like
|
||||||
|
* {@link createEventEmitter} but also tracks the last fired value, readable
|
||||||
|
* via `getCurrent` — useful for state that is both observed and queried.
|
||||||
|
*/
|
||||||
|
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
|
||||||
|
const { fire, subscribe } = createEventEmitter<T>();
|
||||||
|
let current = initial;
|
||||||
|
return {
|
||||||
|
fire: value => {
|
||||||
|
current = value;
|
||||||
|
fire(value);
|
||||||
|
},
|
||||||
|
subscribe,
|
||||||
|
getCurrent: () => current,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,12 @@
|
|||||||
* Extensions register views as side effects at import time.
|
* Extensions register views as side effects at import time.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ReactElement, useSyncExternalStore } from 'react';
|
import React, { ComponentType, useSyncExternalStore } from 'react';
|
||||||
import type { views as viewsApi } from '@apache-superset/core';
|
import type { views as viewsApi } from '@apache-superset/core';
|
||||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||||
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
|
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
|
||||||
import { Disposable } from '../models';
|
import { Disposable } from '../models';
|
||||||
|
import { createEventEmitter } from '../utils';
|
||||||
|
|
||||||
type View = viewsApi.View;
|
type View = viewsApi.View;
|
||||||
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
|
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
|
||||||
@@ -36,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
|
|||||||
|
|
||||||
const viewRegistry: Map<
|
const viewRegistry: Map<
|
||||||
string,
|
string,
|
||||||
{ view: View; location: string; provider: () => ReactElement }
|
{ view: View; location: string; component: ComponentType }
|
||||||
> = new Map();
|
> = new Map();
|
||||||
|
|
||||||
const locationIndex: Map<string, Set<string>> = new Map();
|
const locationIndex: Map<string, Set<string>> = new Map();
|
||||||
@@ -47,29 +48,29 @@ const subscribe = (listener: () => void) => {
|
|||||||
return () => syncListeners.delete(listener);
|
return () => syncListeners.delete(listener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
|
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
|
||||||
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
|
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
|
||||||
|
|
||||||
const viewsCache = new Map<string, View[] | undefined>();
|
const viewsCache = new Map<string, View[] | undefined>();
|
||||||
const notifyRegister = (event: ViewRegisteredEvent) => {
|
const notifyRegister = (event: ViewRegisteredEvent) => {
|
||||||
viewsCache.clear();
|
viewsCache.clear();
|
||||||
syncListeners.forEach(l => l());
|
syncListeners.forEach(l => l());
|
||||||
registerListeners.forEach(l => l(event));
|
registerEmitter.fire(event);
|
||||||
};
|
};
|
||||||
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||||
viewsCache.clear();
|
viewsCache.clear();
|
||||||
syncListeners.forEach(l => l());
|
syncListeners.forEach(l => l());
|
||||||
unregisterListeners.forEach(l => l(event));
|
unregisterEmitter.fire(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerView: typeof viewsApi.registerView = (
|
const registerView: typeof viewsApi.registerView = (
|
||||||
view: View,
|
view: View,
|
||||||
location: string,
|
location: string,
|
||||||
provider: () => ReactElement,
|
component: ComponentType,
|
||||||
): Disposable => {
|
): Disposable => {
|
||||||
const { id } = view;
|
const { id } = view;
|
||||||
|
|
||||||
viewRegistry.set(id, { view, location, provider });
|
viewRegistry.set(id, { view, location, component });
|
||||||
|
|
||||||
const ids = locationIndex.get(location) ?? new Set();
|
const ids = locationIndex.get(location) ?? new Set();
|
||||||
ids.add(id);
|
ids.add(id);
|
||||||
@@ -83,12 +84,16 @@ const registerView: typeof viewsApi.registerView = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveView = (id: string): ReactElement => {
|
export const resolveView = (id: string): React.ReactElement => {
|
||||||
const provider = viewRegistry.get(id)?.provider;
|
const entry = viewRegistry.get(id);
|
||||||
if (!provider) {
|
if (!entry) {
|
||||||
return React.createElement(ExtensionPlaceholder, { id });
|
return React.createElement(ExtensionPlaceholder, { id });
|
||||||
}
|
}
|
||||||
return React.createElement(ErrorBoundary, null, provider());
|
return React.createElement(
|
||||||
|
ErrorBoundary,
|
||||||
|
null,
|
||||||
|
React.createElement(entry.component),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getViews: typeof viewsApi.getViews = (
|
const getViews: typeof viewsApi.getViews = (
|
||||||
@@ -116,17 +121,11 @@ export const useViews = (location: string): View[] | undefined =>
|
|||||||
|
|
||||||
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
||||||
listener: (e: ViewRegisteredEvent) => void,
|
listener: (e: ViewRegisteredEvent) => void,
|
||||||
): Disposable => {
|
): Disposable => registerEmitter.subscribe(listener);
|
||||||
registerListeners.add(listener);
|
|
||||||
return new Disposable(() => registerListeners.delete(listener));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
||||||
listener: (e: ViewUnregisteredEvent) => void,
|
listener: (e: ViewUnregisteredEvent) => void,
|
||||||
): Disposable => {
|
): Disposable => unregisterEmitter.subscribe(listener);
|
||||||
unregisterListeners.add(listener);
|
|
||||||
return new Disposable(() => unregisterListeners.delete(listener));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const views: typeof viewsApi = {
|
export const views: typeof viewsApi = {
|
||||||
registerView,
|
registerView,
|
||||||
|
|||||||
@@ -379,6 +379,79 @@ test('should fallback to formData state when runtime state not available', () =>
|
|||||||
expect(getByTestId('chart-container')).toBeInTheDocument();
|
expect(getByTestId('chart-container')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('chart height is reduced on first render in expanded state (guards against useEffect regression)', () => {
|
||||||
|
const DESCRIPTION_HEIGHT = 60;
|
||||||
|
const CHART_HEIGHT = 300;
|
||||||
|
// Matches the DEFAULT_HEADER_HEIGHT constant in Chart.tsx.
|
||||||
|
const DEFAULT_HEADER_HEIGHT = 22;
|
||||||
|
|
||||||
|
// Stabilise getHeaderHeight(): emotion injects margin-bottom CSS during
|
||||||
|
// React's commit phase, so getComputedStyle returns different values in
|
||||||
|
// initial renders vs re-renders. Mock it to always return empty so
|
||||||
|
// getHeaderHeight() consistently falls back to DEFAULT_HEADER_HEIGHT.
|
||||||
|
const getComputedStyleSpy = jest
|
||||||
|
.spyOn(window, 'getComputedStyle')
|
||||||
|
.mockReturnValue({
|
||||||
|
getPropertyValue: () => '',
|
||||||
|
} as unknown as CSSStyleDeclaration);
|
||||||
|
|
||||||
|
// JSDOM doesn't compute layout, so mock offsetHeight to simulate a real
|
||||||
|
// description element with height.
|
||||||
|
const offsetHeightSpy = jest
|
||||||
|
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
|
||||||
|
.mockImplementation(function (this: HTMLElement) {
|
||||||
|
return this.classList.contains('slice_description')
|
||||||
|
? DESCRIPTION_HEIGHT
|
||||||
|
: 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suppress all passive effects to simulate the first-paint moment — the
|
||||||
|
// point at which the original useEffect bug caused clipping. useLayoutEffect
|
||||||
|
// (the fix) runs synchronously before paint and is intentionally NOT mocked
|
||||||
|
// here. If the implementation were reverted to useEffect, this spy would
|
||||||
|
// prevent the height measurement and the assertion below would fail.
|
||||||
|
const useEffectSpy = jest
|
||||||
|
.spyOn(global.React, 'useEffect')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { container } = setup(
|
||||||
|
{ height: CHART_HEIGHT },
|
||||||
|
{
|
||||||
|
charts: {
|
||||||
|
...defaultState.charts,
|
||||||
|
[queryId]: {
|
||||||
|
...defaultState.charts[queryId],
|
||||||
|
// ChartOverlay renders with an inline height style when loading —
|
||||||
|
// this is the observable proxy for getChartHeight() without real layout.
|
||||||
|
chartStatus: 'loading',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboardState: {
|
||||||
|
...defaultState.dashboardState,
|
||||||
|
expandedSlices: { [queryId]: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartHeight = parseInt(
|
||||||
|
container.querySelector<HTMLDivElement>('.dashboard-chart > div[style]')!
|
||||||
|
.style.height,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
// useLayoutEffect must have measured and applied descriptionHeight
|
||||||
|
// synchronously. If useEffect were used instead, descriptionHeight would
|
||||||
|
// still be 0 here (suppressed by useEffectSpy) and chartHeight would equal
|
||||||
|
// CHART_HEIGHT - DEFAULT_HEADER_HEIGHT rather than the value below.
|
||||||
|
expect(chartHeight).toBe(
|
||||||
|
CHART_HEIGHT - DEFAULT_HEADER_HEIGHT - DESCRIPTION_HEIGHT,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffectSpy.mockRestore();
|
||||||
|
getComputedStyleSpy.mockRestore();
|
||||||
|
offsetHeightSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
test('should not show a close button on chart error banners', () => {
|
test('should not show a close button on chart error banners', () => {
|
||||||
const { queryByRole } = setup(
|
const { queryByRole } = setup(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import cx from 'classnames';
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
@@ -318,13 +319,9 @@ const Chart = (props: ChartProps) => {
|
|||||||
[dispatch, props.id, sliceVizType],
|
[dispatch, props.id, sliceVizType],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isExpanded) {
|
if (isExpanded && descriptionRef.current) {
|
||||||
const descHeight =
|
setDescriptionHeight(descriptionRef.current.offsetHeight);
|
||||||
isExpanded && descriptionRef.current
|
|
||||||
? descriptionRef.current?.offsetHeight
|
|
||||||
: 0;
|
|
||||||
setDescriptionHeight(descHeight);
|
|
||||||
} else {
|
} else {
|
||||||
setDescriptionHeight(0);
|
setDescriptionHeight(0);
|
||||||
}
|
}
|
||||||
@@ -484,7 +481,7 @@ const Chart = (props: ChartProps) => {
|
|||||||
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
||||||
|
|
||||||
const exportTable = useCallback(
|
const exportTable = useCallback(
|
||||||
(format: string, isFullCSV: boolean, isPivot = false) => {
|
async (format: string, isFullCSV: boolean, isPivot = false) => {
|
||||||
const logAction =
|
const logAction =
|
||||||
format === 'csv'
|
format === 'csv'
|
||||||
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
|
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
|
||||||
@@ -559,24 +556,48 @@ const Chart = (props: ChartProps) => {
|
|||||||
}
|
}
|
||||||
: baseOwnState;
|
: baseOwnState;
|
||||||
|
|
||||||
exportChart({
|
try {
|
||||||
formData:
|
await exportChart({
|
||||||
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
|
formData:
|
||||||
resultType,
|
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
|
||||||
resultFormat: format,
|
resultType,
|
||||||
force: true,
|
resultFormat: format,
|
||||||
ownState: exportOwnState,
|
force: true,
|
||||||
onStartStreamingExport: shouldUseStreaming
|
ownState: exportOwnState,
|
||||||
? (exportParams: JsonObject) => {
|
onStartStreamingExport: shouldUseStreaming
|
||||||
setIsStreamingModalVisible(true);
|
? (exportParams: JsonObject) => {
|
||||||
startExport({
|
setIsStreamingModalVisible(true);
|
||||||
...(exportParams as Record<string, unknown>),
|
startExport({
|
||||||
filename,
|
...(exportParams as Record<string, unknown>),
|
||||||
expectedRows: actualRowCount,
|
filename,
|
||||||
} as Parameters<typeof startExport>[0]);
|
expectedRows: actualRowCount,
|
||||||
}
|
} as Parameters<typeof startExport>[0]);
|
||||||
: null,
|
}
|
||||||
});
|
: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const exportError = error as Error & {
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
response?: { status?: number };
|
||||||
|
};
|
||||||
|
const status = exportError.status || exportError.response?.status;
|
||||||
|
if (status === 413) {
|
||||||
|
boundActionCreators.addDangerToast(
|
||||||
|
t(
|
||||||
|
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
exportError.message ||
|
||||||
|
exportError.statusText ||
|
||||||
|
t(
|
||||||
|
'Failed to export chart data. Please try again or contact your administrator.',
|
||||||
|
);
|
||||||
|
boundActionCreators.addDangerToast(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
sliceSliceId,
|
sliceSliceId,
|
||||||
@@ -588,6 +609,7 @@ const Chart = (props: ChartProps) => {
|
|||||||
chartState,
|
chartState,
|
||||||
props.id,
|
props.id,
|
||||||
boundActionCreators.logEvent,
|
boundActionCreators.logEvent,
|
||||||
|
boundActionCreators.addDangerToast,
|
||||||
queriesResponse,
|
queriesResponse,
|
||||||
startExport,
|
startExport,
|
||||||
resetExport,
|
resetExport,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
extractLabel,
|
extractLabel,
|
||||||
getAppliedColumnsWithFallback,
|
getAppliedColumnsWithFallback,
|
||||||
getCrossFilterIndicator,
|
getCrossFilterIndicator,
|
||||||
|
IndicatorStatus,
|
||||||
|
selectNativeIndicatorsForChart,
|
||||||
} from './selectors';
|
} from './selectors';
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
@@ -207,6 +209,21 @@ test('getAppliedColumnsWithFallback returns columns from query response when ava
|
|||||||
expect(result).toEqual(new Set(['age', 'name']));
|
expect(result).toEqual(new Set(['age', 'name']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getAppliedColumnsWithFallback returns columns from all query responses', () => {
|
||||||
|
const chart = {
|
||||||
|
queriesResponse: [
|
||||||
|
{
|
||||||
|
applied_filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
applied_filters: [{ column: 'age' }, { column: 'name' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const result = getAppliedColumnsWithFallback(chart);
|
||||||
|
expect(result).toEqual(new Set(['age', 'name']));
|
||||||
|
});
|
||||||
|
|
||||||
test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => {
|
test('getAppliedColumnsWithFallback returns empty set when query response has no applied_filters and no fallback params', () => {
|
||||||
const chart = {
|
const chart = {
|
||||||
queriesResponse: [{ applied_filters: [] }],
|
queriesResponse: [{ applied_filters: [] }],
|
||||||
@@ -565,3 +582,47 @@ test('getAppliedColumnsWithFallback prioritizes query response over fallback', (
|
|||||||
);
|
);
|
||||||
expect(result).toEqual(new Set(['query_column']));
|
expect(result).toEqual(new Set(['query_column']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('selectNativeIndicatorsForChart marks rejected filters from later query responses incompatible', () => {
|
||||||
|
const chartId = 987;
|
||||||
|
const nativeFilters = {
|
||||||
|
filter1: {
|
||||||
|
id: 'filter1',
|
||||||
|
name: 'Age',
|
||||||
|
type: NativeFilterType.NativeFilter,
|
||||||
|
chartsInScope: [chartId],
|
||||||
|
targets: [{ column: { name: 'age' } }],
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const dataMask = {
|
||||||
|
filter1: {
|
||||||
|
id: 'filter1',
|
||||||
|
filterState: { value: '25' },
|
||||||
|
extraFormData: {},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const chart = {
|
||||||
|
queriesResponse: [
|
||||||
|
{ rejected_filters: [] },
|
||||||
|
{ rejected_filters: [{ column: 'age' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectNativeIndicatorsForChart(
|
||||||
|
nativeFilters,
|
||||||
|
dataMask,
|
||||||
|
chartId,
|
||||||
|
chart,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
column: 'age',
|
||||||
|
name: 'Age',
|
||||||
|
path: ['filter1'],
|
||||||
|
status: IndicatorStatus.Incompatible,
|
||||||
|
value: '25',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -141,9 +141,20 @@ const selectIndicatorsForChartFromFilter = (
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getQueryFilterMetadata = (
|
||||||
|
chart: any,
|
||||||
|
metadataKey: 'applied_filters' | 'rejected_filters',
|
||||||
|
) =>
|
||||||
|
ensureIsArray(chart?.queriesResponse).flatMap(
|
||||||
|
queryResponse =>
|
||||||
|
(metadataKey === 'applied_filters'
|
||||||
|
? queryResponse?.applied_filters
|
||||||
|
: queryResponse?.rejected_filters) || [],
|
||||||
|
);
|
||||||
|
|
||||||
const getAppliedColumns = (chart: any): Set<string> =>
|
const getAppliedColumns = (chart: any): Set<string> =>
|
||||||
new Set(
|
new Set(
|
||||||
(chart?.queriesResponse?.[0]?.applied_filters || []).map(
|
getQueryFilterMetadata(chart, 'applied_filters').map(
|
||||||
(filter: any) => filter.column,
|
(filter: any) => filter.column,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -161,8 +172,7 @@ export const getAppliedColumnsWithFallback = (
|
|||||||
chartId?: number,
|
chartId?: number,
|
||||||
): Set<string> => {
|
): Set<string> => {
|
||||||
// First try to get from query response (preferred source of truth)
|
// First try to get from query response (preferred source of truth)
|
||||||
const queryAppliedFilters =
|
const queryAppliedFilters = getQueryFilterMetadata(chart, 'applied_filters');
|
||||||
chart?.queriesResponse?.[0]?.applied_filters || [];
|
|
||||||
if (queryAppliedFilters.length > 0) {
|
if (queryAppliedFilters.length > 0) {
|
||||||
return new Set(queryAppliedFilters.map((filter: any) => filter.column));
|
return new Set(queryAppliedFilters.map((filter: any) => filter.column));
|
||||||
}
|
}
|
||||||
@@ -191,7 +201,7 @@ export const getAppliedColumnsWithFallback = (
|
|||||||
|
|
||||||
const getRejectedColumns = (chart: any): Set<string> =>
|
const getRejectedColumns = (chart: any): Set<string> =>
|
||||||
new Set(
|
new Set(
|
||||||
(chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) =>
|
getQueryFilterMetadata(chart, 'rejected_filters').map((filter: any) =>
|
||||||
getColumnLabel(filter.column),
|
getColumnLabel(filter.column),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
|||||||
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
||||||
import { URL_PARAMS } from 'src/constants';
|
import { URL_PARAMS } from 'src/constants';
|
||||||
import { getUrlParam } from 'src/utils/urlUtils';
|
import { getUrlParam } from 'src/utils/urlUtils';
|
||||||
|
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
|
||||||
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
|
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
|
||||||
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
||||||
import {
|
import {
|
||||||
@@ -337,7 +338,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
|||||||
// Update document title when dashboard title changes
|
// Update document title when dashboard title changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageTitle) {
|
if (pageTitle) {
|
||||||
document.title = pageTitle;
|
document.title = sanitizeDocumentTitle(pageTitle);
|
||||||
}
|
}
|
||||||
}, [pageTitle]);
|
}, [pageTitle]);
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
|
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
|
||||||
} from 'src/logger/LogUtils';
|
} from 'src/logger/LogUtils';
|
||||||
import { getUrlParam } from 'src/utils/urlUtils';
|
import { getUrlParam } from 'src/utils/urlUtils';
|
||||||
|
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import * as chartActions from 'src/components/Chart/chartAction';
|
import * as chartActions from 'src/components/Chart/chartAction';
|
||||||
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
|
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
|
||||||
@@ -397,7 +398,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
|
|||||||
// Update document title when slice name changes
|
// Update document title when slice name changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.sliceName) {
|
if (props.sliceName) {
|
||||||
document.title = props.sliceName;
|
document.title = sanitizeDocumentTitle(props.sliceName);
|
||||||
}
|
}
|
||||||
}, [props.sliceName]);
|
}, [props.sliceName]);
|
||||||
|
|
||||||
|
|||||||
@@ -339,7 +339,34 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
}
|
}
|
||||||
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
|
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
|
||||||
|
|
||||||
const exportCSV = useCallback(() => {
|
const handleExportError = useCallback(
|
||||||
|
(error: unknown) => {
|
||||||
|
const exportError = error as Error & {
|
||||||
|
status?: number;
|
||||||
|
statusText?: string;
|
||||||
|
response?: { status?: number };
|
||||||
|
};
|
||||||
|
const status = exportError.status || exportError.response?.status;
|
||||||
|
if (status === 413) {
|
||||||
|
addDangerToast(
|
||||||
|
t(
|
||||||
|
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const errorMessage =
|
||||||
|
exportError.message ||
|
||||||
|
exportError.statusText ||
|
||||||
|
t(
|
||||||
|
'Failed to export chart data. Please try again or contact your administrator.',
|
||||||
|
);
|
||||||
|
addDangerToast(errorMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addDangerToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportCSV = useCallback(async () => {
|
||||||
if (!canDownloadCSV) return null;
|
if (!canDownloadCSV) return null;
|
||||||
|
|
||||||
// Determine row count for streaming threshold check
|
// Determine row count for streaming threshold check
|
||||||
@@ -378,26 +405,31 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
filename = `${safeChartName}${timestamp}.csv`;
|
filename = `${safeChartName}${timestamp}.csv`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return exportChart({
|
try {
|
||||||
formData: latestQueryFormData as QueryFormData,
|
await exportChart({
|
||||||
ownState,
|
formData: latestQueryFormData as QueryFormData,
|
||||||
resultType: 'full',
|
ownState,
|
||||||
resultFormat: 'csv',
|
resultType: 'full',
|
||||||
onStartStreamingExport: shouldUseStreaming
|
resultFormat: 'csv',
|
||||||
? exportParams => {
|
onStartStreamingExport: shouldUseStreaming
|
||||||
if (exportParams.url) {
|
? exportParams => {
|
||||||
setIsStreamingModalVisible(true);
|
if (exportParams.url) {
|
||||||
startExport({
|
setIsStreamingModalVisible(true);
|
||||||
...exportParams,
|
startExport({
|
||||||
url: exportParams.url,
|
...exportParams,
|
||||||
filename,
|
url: exportParams.url,
|
||||||
expectedRows: actualRowCount,
|
filename,
|
||||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
expectedRows: actualRowCount,
|
||||||
});
|
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
: null,
|
||||||
: null,
|
});
|
||||||
});
|
} catch (error) {
|
||||||
|
handleExportError(error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}, [
|
}, [
|
||||||
canDownloadCSV,
|
canDownloadCSV,
|
||||||
latestQueryFormData,
|
latestQueryFormData,
|
||||||
@@ -406,46 +438,59 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
streamingThreshold,
|
streamingThreshold,
|
||||||
slice,
|
slice,
|
||||||
startExport,
|
startExport,
|
||||||
|
handleExportError,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const exportCSVPivoted = useCallback(
|
const exportCSVPivoted = useCallback(async () => {
|
||||||
() =>
|
if (!canDownloadCSV) {
|
||||||
canDownloadCSV
|
return null;
|
||||||
? exportChart({
|
}
|
||||||
formData: latestQueryFormData as QueryFormData,
|
try {
|
||||||
ownState,
|
await exportChart({
|
||||||
resultType: 'post_processed',
|
formData: latestQueryFormData as QueryFormData,
|
||||||
resultFormat: 'csv',
|
ownState,
|
||||||
})
|
resultType: 'post_processed',
|
||||||
: null,
|
resultFormat: 'csv',
|
||||||
[canDownloadCSV, latestQueryFormData, ownState],
|
});
|
||||||
);
|
} catch (error) {
|
||||||
|
handleExportError(error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||||
|
|
||||||
const exportJson = useCallback(
|
const exportJson = useCallback(async () => {
|
||||||
() =>
|
if (!canDownloadCSV) {
|
||||||
canDownloadCSV
|
return null;
|
||||||
? exportChart({
|
}
|
||||||
formData: latestQueryFormData as QueryFormData,
|
try {
|
||||||
ownState,
|
await exportChart({
|
||||||
resultType: 'results',
|
formData: latestQueryFormData as QueryFormData,
|
||||||
resultFormat: 'json',
|
ownState,
|
||||||
})
|
resultType: 'results',
|
||||||
: null,
|
resultFormat: 'json',
|
||||||
[canDownloadCSV, latestQueryFormData, ownState],
|
});
|
||||||
);
|
} catch (error) {
|
||||||
|
handleExportError(error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||||
|
|
||||||
const exportExcel = useCallback(
|
const exportExcel = useCallback(async () => {
|
||||||
() =>
|
if (!canDownloadCSV) {
|
||||||
canDownloadCSV
|
return null;
|
||||||
? exportChart({
|
}
|
||||||
formData: latestQueryFormData as QueryFormData,
|
try {
|
||||||
ownState,
|
await exportChart({
|
||||||
resultType: 'results',
|
formData: latestQueryFormData as QueryFormData,
|
||||||
resultFormat: 'xlsx',
|
ownState,
|
||||||
})
|
resultType: 'results',
|
||||||
: null,
|
resultFormat: 'xlsx',
|
||||||
[canDownloadCSV, latestQueryFormData, ownState],
|
});
|
||||||
);
|
} catch (error) {
|
||||||
|
handleExportError(error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||||
|
|
||||||
const copyLink = useCallback(async () => {
|
const copyLink = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -805,7 +850,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
label: dataExportLabel(t('Export to .CSV')),
|
label: dataExportLabel(t('Export to .CSV')),
|
||||||
icon: <Icons.FileOutlined />,
|
icon: <Icons.FileOutlined />,
|
||||||
disabled: !canDownloadCSV,
|
disabled: !canDownloadCSV,
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
// Use 'results' to export the *current view* (as opposed to 'full').
|
// Use 'results' to export the *current view* (as opposed to 'full').
|
||||||
// Pass ownState so client/UI state (e.g., filters) can be respected when supported.
|
// Pass ownState so client/UI state (e.g., filters) can be respected when supported.
|
||||||
if (
|
if (
|
||||||
@@ -820,12 +865,16 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
slice?.slice_name || 'current_view',
|
slice?.slice_name || 'current_view',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
exportChart({
|
try {
|
||||||
formData: latestQueryFormData as QueryFormData,
|
await exportChart({
|
||||||
ownState,
|
formData: latestQueryFormData as QueryFormData,
|
||||||
resultType: 'results',
|
ownState,
|
||||||
resultFormat: 'csv',
|
resultType: 'results',
|
||||||
});
|
resultFormat: 'csv',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleExportError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setIsDropdownVisible(false);
|
setIsDropdownVisible(false);
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -1058,6 +1107,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
exportCSVPivoted,
|
exportCSVPivoted,
|
||||||
exportExcel,
|
exportExcel,
|
||||||
exportJson,
|
exportJson,
|
||||||
|
handleExportError,
|
||||||
latestQueryFormData,
|
latestQueryFormData,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
onOpenPropertiesModal,
|
onOpenPropertiesModal,
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useExploreAdditionalActionsMenu } from './index';
|
||||||
|
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||||
|
|
||||||
|
jest.mock('src/explore/exploreUtils', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
...jest.requireActual('src/explore/exploreUtils'),
|
||||||
|
exportChart: jest.fn(),
|
||||||
|
getChartKey: jest.fn(() => 'test_chart_key'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockExportChart = exploreUtils.exportChart as jest.Mock;
|
||||||
|
|
||||||
|
const mockAddDangerToast = jest.fn();
|
||||||
|
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (component: ComponentType) => component,
|
||||||
|
useToasts: () => ({
|
||||||
|
addDangerToast: mockAddDangerToast,
|
||||||
|
addSuccessToast: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/logger/actions', () => ({
|
||||||
|
logEvent: jest.fn(() => ({ type: 'LOG_EVENT' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@superset-ui/core', () => ({
|
||||||
|
...jest.requireActual('@superset-ui/core'),
|
||||||
|
getChartMetadataRegistry: jest.fn(() => ({
|
||||||
|
get: jest.fn(() => ({ behaviors: ['EXPORT_CURRENT_VIEW'] })),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
latestQueryFormData: {
|
||||||
|
datasource: '1__table',
|
||||||
|
viz_type: 'pivot_table_v2',
|
||||||
|
},
|
||||||
|
canDownloadCSV: true,
|
||||||
|
slice: { slice_id: 1, slice_name: 'Test Chart' },
|
||||||
|
ownState: {},
|
||||||
|
dashboards: [],
|
||||||
|
onOpenInEditor: jest.fn(),
|
||||||
|
onOpenPropertiesModal: jest.fn(),
|
||||||
|
showReportModal: jest.fn(),
|
||||||
|
setCurrentReportDeleting: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestComponentProps = typeof defaultProps;
|
||||||
|
type HookParams = Parameters<typeof useExploreAdditionalActionsMenu>;
|
||||||
|
|
||||||
|
const TestComponent = (props: TestComponentProps) => {
|
||||||
|
const [menu] = useExploreAdditionalActionsMenu(
|
||||||
|
props.latestQueryFormData as HookParams[0],
|
||||||
|
props.canDownloadCSV,
|
||||||
|
props.slice as HookParams[2],
|
||||||
|
props.onOpenInEditor,
|
||||||
|
props.onOpenPropertiesModal,
|
||||||
|
props.ownState as HookParams[5],
|
||||||
|
props.dashboards as HookParams[6],
|
||||||
|
props.showReportModal,
|
||||||
|
props.setCurrentReportDeleting,
|
||||||
|
);
|
||||||
|
|
||||||
|
return <div>{menu}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockExportChart.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows 413 error toast when exportCSV fails with 413', async () => {
|
||||||
|
mockExportChart.mockRejectedValue({ status: 413 });
|
||||||
|
|
||||||
|
render(<TestComponent {...defaultProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||||
|
userEvent.hover(await screen.findByText('Export All Data'));
|
||||||
|
userEvent.click(await screen.findByText('Export to original .CSV'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/The chart data is too large to download/),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows 413 error toast when exportCSVPivoted fails with 413', async () => {
|
||||||
|
mockExportChart.mockRejectedValue({ status: 413 });
|
||||||
|
|
||||||
|
render(<TestComponent {...defaultProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||||
|
userEvent.hover(await screen.findByText('Export All Data'));
|
||||||
|
userEvent.click(await screen.findByText('Export to pivoted .CSV'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/The chart data is too large to download/),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows 413 error toast when Export Current View CSV server path fails with 413', async () => {
|
||||||
|
mockExportChart.mockRejectedValue({ status: 413 });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestComponent
|
||||||
|
{...defaultProps}
|
||||||
|
latestQueryFormData={{
|
||||||
|
datasource: '1__table',
|
||||||
|
viz_type: 'table',
|
||||||
|
}}
|
||||||
|
ownState={{}}
|
||||||
|
/>,
|
||||||
|
{ useRedux: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||||
|
userEvent.hover(await screen.findByText('Export Current View'));
|
||||||
|
userEvent.click(await screen.findByText('Export to .CSV'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/The chart data is too large to download/),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user