Compare commits

...

48 Commits

Author SHA1 Message Date
Joe Li
deff1bd782 test(dashboard): migrate drill-to-detail E2E from Cypress to Playwright
Port the Cypress "Drill to detail modal" suite (which was fully
`describe.skip`'d) to the Playwright E2E framework. Each test API-builds a
hermetic single-chart dashboard from birth_names, renders it, drives the real
drill menus, and asserts the backend round-trip (the `/datasource/samples`
POST and the filter the modal applies) — genuine E2E, not DOM-class checks.

The original suite was skipped due to Cypress auto-scrolling the chart under
its sticky header before each action and obstructing right-clicks. That failure
mode is Cypress-specific; Playwright scrolls once and the target stays put.

Coverage:
- Modal mechanics: open from the chart "More Options" menu, paginate, reload
  resets to page 1.
- Big number value right-click drills the whole chart (no filter).
- Table cell right-click drills by that value; clearing the filter restores the
  full set.
- Pivot table cell right-click drills by the cell value.
- Pie slice and line point right-click (canvas) drill by the value under the
  cursor.

Canvas charts previously relied on hard-coded pixel coordinates to hit a slice
or point. Instead, the canvas tests scan a small set of candidate points until
the context menu offers an enabled "Drill to detail by" submenu, then drill by
whichever value the menu offers and assert that same value round-trips into the
modal — keeping the assertion independent of exact chart geometry.

Excluded (matching the original's own skips): Bar, Area, World Map, Radar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:31:53 -07:00
Evan Rusackas
aac02ab679 fix(deck.gl): use interval notation for Polygon legend bucket labels (#41400)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:23:34 -07:00
madhushreeag
de01fe2ff0 fix(chart-controls): fix RadioButtonControl crash on empty options and false values (#41170)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-06-25 12:02:58 -07:00
Beto Dealmeida
9965c05699 fix(semantic layers): small fixes (#40474) 2026-06-25 14:59:49 -04:00
Greg Neighbors
d8bcc66472 feat(mcp): dashboard layout, theme, and CSS control + update_dashboard tool (#40399)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-MacBook-Air-2.local>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2026-06-25 10:41:07 -07:00
Evan Rusackas
4b9b8187b3 fix(config): make Swagger UI opt-in (off by default) (#41300)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 10:34:28 -07:00
Evan Rusackas
83f7dc9d5b chore(codeowners): add translation maintainers (#41429)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-25 10:09:16 -07:00
Elizabeth Thompson
baca76ebe0 fix(slack): fix indented triple-quoted string in v1 API deprecation warning (#41393) 2026-06-25 09:54:33 -07:00
Mehmet Salih Yavuz
9a11c15a33 feat(explore): add full-range option for time-shift comparison (#41334) 2026-06-25 18:30:33 +03:00
Michael S. Molina
a90c8e0347 feat(extensions): add Chat contribution type (SIP-214) (#41205)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:57:30 -03:00
dependabot[bot]
fe2424ec14 chore(deps): bump mapbox-gl from 3.24.1 to 3.25.0 in /superset-frontend (#41409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:48 -07:00
dependabot[bot]
b4f43bd7e0 chore(deps): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /docs (#41405)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:45 -07:00
dependabot[bot]
2b25345ed9 chore(deps-dev): bump baseline-browser-mapping from 2.10.37 to 2.10.38 in /superset-frontend (#41413)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 02:09:41 -07:00
Evan Rusackas
e0f3f93cd4 fix(mcp): require MCP_JWT_AUDIENCE when MCP JWT auth is enabled (#41292)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:53:36 -07:00
Evan Rusackas
0667ba6097 chore(deps): bump dompurify and http-proxy-middleware (security) (#41289)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:56 -07:00
Evan Rusackas
81f7e42f4e fix(rls): preserve tables/roles on partial RLS rule updates (#41294)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:47 -07:00
Evan Rusackas
0fd244b5c6 fix(security): reject unknown fields on guest-token RLS rules (#41217)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 16:16:43 -07:00
Evan Rusackas
1f16d10cbf chore(deps): bump pyjwt to 2.13.0 (CVE-2026-48526) (#41288)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:40 -07:00
Evan Rusackas
4f4663418f fix(tests): stabilize update_chart MCP test failing on previous-Python CI leg (#41310)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:16:14 -07:00
Evan Rusackas
4519a5c52d fix(safe-markdown): do not mutate the shared sanitization schema (#41298)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:16:06 -07:00
Evan Rusackas
da9fbadaf6 fix(logout): purge the namespaced Cache API store on logout (#41303)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:50 -07:00
Evan Rusackas
f40abbbefd fix(mcp): fail closed when the JWT verifier has no pinned algorithm (#41296)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:42 -07:00
Evan Rusackas
6166af3c3c fix(mcp): reject non-finite JWT exp instead of 500ing on int() overflow (#41394)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:29 -07:00
Evan Rusackas
076d8c1508 docs(security): add a secrets register and rotation schedule (#41308)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 16:15:17 -07:00
Elizabeth Thompson
518cadd907 fix(mcp_service): reduce deprecated authlib.jose.errors imports (#41248) 2026-06-24 15:01:58 -07:00
JUST.in DO IT
b955c90de4 fix(sqllab): Invalid multi sorting state in table header (#40680) 2026-06-25 06:43:02 +09:00
Evan Rusackas
7363774869 fix(theming): deep-merge partial THEME_DEFAULT overrides with built-in defaults (#41347)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 13:27:32 -07:00
Vansh Gilhotra
6f12d17313 fix(charts): show user-friendly error for HTTP 413 payload too large (#37131)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 11:21:59 -07:00
abhyudaytomar
09c7ba14df fix(export): sanitize control characters in titles to prevent export failures (#39294)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:03:46 -07:00
Elizabeth Thompson
3ec4bd23c4 fix(deps): restore np.nan in offset_metrics_df empty branch (#41267)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:49:38 -07:00
yousoph
f6ce105450 fix(pandas-postprocessing): handle prophet errors and validate minimum data points for forecast (#41180)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 10:44:23 -07:00
Evan Rusackas
7bb4e82a82 fix(dashboard): Remove 308 redirect when creating new dashboards (#41343)
Co-authored-by: ericsong <eric.song@example.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:31:31 -07:00
Kamil Gabryjelski
2d78a8733c fix(plugin-chart-ag-grid-table): show correct percent-metric totals in summary row (#41247)
Signed-off-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
2026-06-24 19:21:00 +02:00
Evan Rusackas
3261d10270 chore(frontend): enforce TypeScript-only source files (#41385)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-24 05:54:37 -07:00
Shlummie
a57b5f6078 fix(deckgl): show dashboard filter badges for multi-layer charts (#40003)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:14:25 -07:00
MelikHajlawi
d1b523b97f docs: fix placeholder text in @superset-ui/core README (#40002)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 02:07:24 -07:00
Shashwati Bhattacharyaa
91188a0302 fix(config): Wire LOGO_TARGET_PATH and document custom spinner usage (#36951)
Co-authored-by: Shashwati <shashwatibhattacaharya21.2@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-24 01:56:15 -07:00
MUHAMMED SINAN D
ac234d0fb2 fix(dashboard): prevent x-axis clipping when toggling chart description (#38307) 2026-06-24 01:54:43 -07:00
felipegr0ssi
8eb753eab2 fix(dashboard): keep native filter dropdown from covering input (#40032)
Co-authored-by: feehgrossi <felipe.leite@sptech.school>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:44 -07:00
abhyudaytomar
779fa13679 fix(security): prevent duplicate items in permissions dropdown on scroll (#39292)
Co-authored-by: Abhyuday Tomar <abhyuday.tomar@exotel.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:53:27 -07:00
Greg Neighbors
caf81e71d2 feat(mcp): add typed Pydantic response schemas to generate_explore_link tool (#39900)
Co-authored-by: gkneighb <26003+gkneighb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-24 01:53:08 -07:00
Eddy
1b8c6d109d feat: added deterministic field generation to dashboard export (#36339)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-24 01:41:44 -07:00
Viktor Högberg
eb60e5477b fix(radar): correct legend margin control in the radar chart (#39414) 2026-06-24 01:41:24 -07:00
Puneet Dixit
7b9bcdd951 fix(bigquery): preserve catalog in partition metadata lookup (#40200)
Co-authored-by: Puneet Dixit <rvit23bcs086.rvitm@rvei.edu.in>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 01:41:06 -07:00
ruhz3
d9d395bde1 fix(helm): remove unused SQLALCHEMY_TRACK_MODIFICATIONS setting (#37259) 2026-06-24 01:28:30 -07:00
Jay Masiwal
584d41759b refactor: migrate test files from nested describe blocks and remove stale lint ignores (#39202)
Co-authored-by: Joe Li <joe@preset.io>
2026-06-24 01:19:15 -07:00
abdullah reveha
8f22b71898 feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)
Co-authored-by: Abdullah Sahin <you@example.comclear>
2026-06-24 01:17:29 -07:00
omkarhall
1ea3584dcb fix(chart): added Big Number chart support for MAX metric with VARCHAR column (#41182) 2026-06-24 01:11:13 -07:00
173 changed files with 9112 additions and 1600 deletions

2
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.37"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.38"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
batch@0.6.1:
version "0.6.1"

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
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:
- name: postgresql
version: 16.7.27

View File

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

View File

@@ -108,8 +108,6 @@ else:
{{ fail (printf "Unsupported database type: %s. Please use 'postgresql' or 'mysql'." .Values.supersetNode.connections.db_type) }}
{{- end }}
SQLALCHEMY_TRACK_MODIFICATIONS = True
class CeleryConfig:
imports = ("superset.sql_lab", )
broker_url = CELERY_REDIS_URL

View File

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

View File

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

View File

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

View File

@@ -192,7 +192,7 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"mapbox-gl": "^3.24.1",
"mapbox-gl": "^3.25.0",
"markdown-to-jsx": "^9.8.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -303,7 +303,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.10.37",
"baseline-browser-mapping": "^2.10.38",
"cheerio": "1.2.0",
"concurrently": "^10.0.3",
"copy-webpack-plugin": "^14.0.0",

View File

@@ -18,6 +18,14 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"

View File

@@ -0,0 +1,156 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type: an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
import { ComponentType } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. Only one chat is active at a time; the most
* recently registered chat wins. Disposing the returned Disposable unregisters
* the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger The trigger component — the collapsed bubble entry point.
* Owns dynamic state such as unread counts.
* @param panel The panel component, rendered in either display mode. In
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
* alongside the main content.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* AcmeTrigger,
* AcmePanel,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable;
/**
* Returns the active chat descriptor, or undefined if none is registered.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes, whether triggered by an extension
* or by the host.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode. The mode is host-global and applies to whichever
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
* including those triggered by the host.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode. Not all hosts provide
* a resizer — do not rely on this event firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -223,8 +223,6 @@ export interface Extension {
dependencies: string[];
/** Human-readable description of the extension */
description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */
id: string;
/** Human-readable name of the extension */

View File

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

View File

@@ -18,10 +18,12 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';

View File

@@ -0,0 +1,81 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Navigation namespace for Superset extensions.
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,21 +22,50 @@ under the License.
[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://www.npmjs.com/package/@superset-ui/core)
[![Libraries.io](https://img.shields.io/librariesio/release/npm/%40superset-ui%2Fcore?style=flat)](https://libraries.io/npm/@superset-ui%2Fcore)
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
```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
`@data-ui/build-config` is used to manage the build configuration for this package including babel
builds, jest testing, eslint, and prettier.
`@data-ui/build-config` is used to manage the build configuration for this package
including babel builds, jest testing, eslint, and prettier.
Run tests:
```bash
cd superset-frontend
npx jest packages/superset-ui-core
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { Page, Download } from '@playwright/test';
import { Page, Download, Locator } from '@playwright/test';
import { Menu } from '../components/core';
import { gotoWithRetry } from '../helpers/navigation';
import { TIMEOUT } from '../utils/constants';
@@ -126,4 +126,119 @@ export class DashboardPage {
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
// ---------------------------------------------------------------------------
// Drill to detail
//
// Charts that implement the DRILL_TO_DETAIL behavior expose two entry points:
// the chart's "More Options" header menu, and a right-click context menu on
// the chart body (a cell, the big-number value, or a canvas data point). Both
// open the same DrillDetailModal, which renders the underlying sample rows for
// the (optionally filtered) chart by calling the `/datasource/samples` API.
// ---------------------------------------------------------------------------
/**
* Locator for a chart card on the dashboard by its visualization type, e.g.
* `table`, `pie`, `big_number_total`.
*/
chartByVizType(vizType: string): Locator {
return this.page.locator(`[data-test-viz-type="${vizType}"]`);
}
/**
* Open the "Drill to detail" item from a chart's "More Options" header menu.
* This is the whole-chart entry point (no row-level filters applied).
*/
async openDrillToDetailFromMenu(vizType: string): Promise<void> {
await this.chartByVizType(vizType)
.getByLabel('More Options', { exact: true })
.click();
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
*/
drillModal(): Locator {
return this.page.getByRole('dialog', { name: /^Drill to detail:/ });
}
/**
* Close the drill-to-detail modal if it is open (idempotent).
*/
async closeDrillModal(): Promise<void> {
const close = this.page.locator('[data-test="close-drilltodetail-modal"]');
if (await close.count()) {
await close.first().click();
await this.drillModal().waitFor({ state: 'hidden' });
}
}
/**
* The applied-filter value tags inside the drill modal (`<col>=<val>`).
*/
drillFilterValues(): Locator {
return this.page.locator('[data-test="filter-val"]');
}
/**
* Click the plain "Drill to detail" item in an open chart context menu
* (whole chart, no row-level filter).
*/
async contextMenuDrillToDetail(): Promise<void> {
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The "Drill to detail by" submenu parent (title) in an open context menu.
* Targeted by its submenu-title element rather than role+name because antd
* appends the arrow-icon name ("right") to the accessible name, and the leaf
* items ("Drill to detail by boy") would otherwise match a role+name lookup.
*/
private drillBySubmenuTitle(): Locator {
return this.page.locator('.ant-dropdown-menu-submenu-title', {
hasText: 'Drill to detail by',
});
}
/** The "Drill to detail by" submenu popup (its leaf value items live here). */
private drillBySubmenu(): Locator {
return this.page.locator('.chart-context-submenu');
}
/**
* From an open chart context menu, hover the "Drill to detail by" submenu and
* click the entry for a specific value (e.g. "boy", "1965", "all").
*/
async contextMenuDrillToDetailBy(value: string): Promise<void> {
await this.drillBySubmenuTitle().hover();
await this.drillBySubmenu()
.getByRole('menuitem', {
name: `Drill to detail by ${value}`,
exact: true,
})
.click();
}
/**
* From an open chart context menu, hover "Drill to detail by" and return the
* concrete values offered by the submenu (e.g. ["1965", "boy"]), skipping the
* aggregate "all" entry. Used by canvas charts where the value under the
* cursor is data-dependent: the test drills by whatever the menu actually
* offers and asserts that same value round-trips into the modal, which keeps
* the assertion independent of exact pixel/slice geometry.
*/
async drillByOfferedValues(): Promise<string[]> {
await this.drillBySubmenuTitle().hover();
const items = this.drillBySubmenu().locator('[role="menuitem"]');
await items.first().waitFor();
const labels = await items.allInnerTexts();
return labels
.map(l => l.replace(/^Drill to detail by\s*/i, '').trim())
.filter(v => v.length > 0 && v.toLowerCase() !== 'all');
}
}

View File

@@ -0,0 +1,512 @@
/**
* 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.
*/
/**
* E2E migration of the Cypress "Drill to detail modal" suite
* (dashboard/drilltodetail.test.ts).
*
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
* chart — optionally filtered to a single data point — by either the chart's
* "More Options" header menu or a right-click context menu on the chart body.
* The modal calls the real `/datasource/samples` API, so this is genuinely
* end-to-end: each test API-builds a hermetic dashboard from the `birth_names`
* dataset, renders it in the browser, drives the real menus, and asserts the
* resulting backend round-trip (the samples POST and the filter the modal
* applies).
*
* Why the original suite was fully `describe.skip`:
* "it has issues with autoscrolling and the locked title flakes intricately
* when the rightClick is obstructed by the title."
* That failure mode is Cypress-specific — Cypress auto-scrolls the target under
* the sticky chart header before every action. Playwright scrolls once and the
* target stays put, so the entry points are portable here.
*
* What is migrated, and how it is kept deterministic:
* - Modal mechanics (open from header menu, pagination, reload-resets-page)
* and the no-filter big-number drill use stable DOM elements.
* - Table and Pivot drills right-click real DOM cells (no canvas pixels).
* - Canvas charts (Pie, Line) DID rely on hard-coded pixel coordinates in
* Cypress to land on a specific slice/point. Instead of reproducing those
* brittle pixels, these tests right-click a stable region of the canvas,
* read whichever value the drill submenu actually offers for the point under
* the cursor, drill by that value, and assert the SAME value round-trips
* into the modal filter. This exercises the full canvas → contextmenu →
* datum → samples pipeline while staying independent of exact geometry.
*
* Excluded (kept out, matching the original's own `describe.skip`s): Bar, Area,
* World Map, Radar — skipped upstream for chart-specific reasons.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import type { Page } from '@playwright/test';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
async function findDatasetIdByName(page: Page, name: string): Promise<number> {
const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
const body = await resp.json();
if (!body.result?.length) {
throw new Error(`Dataset ${name} not found`);
}
return body.result[0].id;
}
/**
* Parse a RowCountLabel value ("75.7k rows", "1,234 rows") into a number so
* tests can assert the *invariant* (filtered < unfiltered) without hard-coding
* the dataset-specific totals the original Cypress suite baked in.
*/
function parseRowCount(text: string): number {
const m = text.match(/([\d.,]+)\s*([kKmM]?)/);
if (!m) return NaN;
let n = parseFloat(m[1].replace(/,/g, ''));
const suffix = m[2].toLowerCase();
if (suffix === 'k') n *= 1e3;
if (suffix === 'm') n *= 1e6;
return n;
}
interface ChartSpec {
vizType: string;
sliceName: string;
params: Record<string, unknown>;
}
/**
* API-build a hermetic single-chart dashboard from birth_names and return its
* id. Mirrors the build pattern used by the other migrated dashboard specs
* (create chart → build positionJson → create dashboard → link chart).
*/
async function buildSingleChartDashboard(
page: Page,
testAssets: {
trackChart(id: number): void;
trackDashboard(id: number): void;
},
spec: ChartSpec,
): Promise<number> {
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
const datasource = `${datasetId}__table`;
const chartResp = await apiPost(page, 'api/v1/chart/', {
slice_name: `${spec.sliceName}_${Date.now()}`,
viz_type: spec.vizType,
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify({
datasource,
viz_type: spec.vizType,
...spec.params,
}),
});
expect(chartResp.ok()).toBe(true);
const chart = await chartResp.json();
const chartId: number = chart.id ?? chart.result?.id;
testAssets.trackChart(chartId);
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: { chartId, width: 12, height: 60, sliceName: spec.sliceName },
},
};
const dashResp = await apiPostDashboard(page, {
dashboard_title: `${spec.sliceName}_dash_${Date.now()}`,
published: true,
position_json: JSON.stringify(positionJson),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
testAssets.trackDashboard(dashboardId);
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
dashboards: [dashboardId],
});
expect(linkResp.ok()).toBe(true);
return dashboardId;
}
/**
* Right-click an echarts canvas until a data point is hit — i.e. until the
* context menu offers an *enabled* "Drill to detail by" submenu (a miss renders
* that item disabled, as a plain menu item rather than a submenu title).
*
* echarts renders to a single canvas, so there is no per-datum DOM element to
* target and the exact pixel of a mark depends on chart geometry (donut hole,
* legend size, axis padding). Rather than hard-code Cypress's brittle pixel
* coordinates, this scans a small set of candidate points — a radial ring for
* pie/radial charts, a grid for cartesian charts — and stops at the first that
* lands on a mark. The drill value is then whatever that mark represents, so the
* caller asserts a value round-trip rather than a specific geometry.
*/
async function rightClickCanvasDatum(
page: Page,
canvas: ReturnType<Page['locator']>,
pattern: 'ring' | 'grid',
): Promise<void> {
const box = await canvas.boundingBox();
if (!box) throw new Error('canvas has no bounding box');
const candidates: Array<{ x: number; y: number }> = [];
if (pattern === 'ring') {
const cx = box.width / 2;
const cy = box.height / 2;
const minSide = Math.min(box.width, box.height);
for (const rf of [0.3, 0.22, 0.38]) {
for (let a = 0; a < 360; a += 45) {
const rad = (a * Math.PI) / 180;
candidates.push({
x: cx + Math.cos(rad) * minSide * rf,
y: cy + Math.sin(rad) * minSide * rf,
});
}
}
} else {
for (const yf of [0.5, 0.4, 0.6, 0.3, 0.7]) {
for (const xf of [0.3, 0.45, 0.6, 0.2, 0.75]) {
candidates.push({ x: box.width * xf, y: box.height * yf });
}
}
}
// The submenu *title* element only exists when "Drill to detail by" is an
// enabled submenu (a real datum was hit); a miss renders a disabled item.
const enabledDrillBy = page
.locator('.ant-dropdown-menu-submenu-title')
.filter({ hasText: 'Drill to detail by' });
for (const pt of candidates) {
await canvas.click({ button: 'right', position: pt });
const hit = await enabledDrillBy
.waitFor({ state: 'visible', timeout: 400 })
.then(() => true)
.catch(() => false);
if (hit) return;
await page.keyboard.press('Escape');
}
throw new Error(
`no drillable datum found on canvas after scanning ${candidates.length} points`,
);
}
/** A samples POST fired (proves the modal hit the real backend). */
function expectSamplesPost(page: Page) {
return page.waitForResponse(
r =>
r.url().includes('/datasource/samples') &&
r.request().method() === 'POST',
{ timeout: TIMEOUT.API_RESPONSE },
);
}
async function loadDashboardWithChart(
dashboard: DashboardPage,
dashboardId: number,
vizType: string,
): Promise<void> {
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard
.chartByVizType(vizType)
.locator('[data-test="chart-container"]')
.first()
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
await dashboard.waitForChartsToLoad();
}
testWithAssets(
'drill-to-detail modal: opens from the header menu, paginates, and reload resets to page 1',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'big_number_total',
sliceName: 'drill_bignum',
params: { metric: 'count', adhoc_filters: [] },
});
await loadDashboardWithChart(dashboard, dashboardId, 'big_number_total');
// Open the modal from the chart's "More Options" header menu.
const samplesOnOpen = expectSamplesPost(page);
await dashboard.openDrillToDetailFromMenu('big_number_total');
await samplesOnOpen;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(modal).toContainText('Drill to detail:');
// The metadata bar and a real row count prove the modal loaded backend data.
await expect(modal.locator('[data-test="metadata-bar"]')).toBeVisible();
await expect(modal.locator('[data-test="row-count-label"]')).toContainText(
'rows',
);
// No drill filter was applied (whole-chart drill).
await expect(dashboard.drillFilterValues()).toHaveCount(0);
// The full dataset spans multiple pages, and the grid has rendered rows.
const pageItems = modal.locator('.ant-pagination-item');
expect(await pageItems.count()).toBeGreaterThan(1);
await expect(modal.locator('.virtual-table-cell').first()).toBeVisible();
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'1',
);
// Paginate forward: clicking page 2 fires a real samples fetch and moves the
// active page to 2.
const samplesOnPage2 = expectSamplesPost(page);
await modal.locator('.ant-pagination-item').nth(1).click();
await samplesOnPage2;
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'2',
);
// Reload re-fetches and resets back to the first page.
const samplesOnReload = expectSamplesPost(page);
await modal.getByRole('button', { name: 'Reload' }).click();
await samplesOnReload;
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'1',
);
},
);
testWithAssets(
'drill-to-detail modal: big number value right-click drills the whole chart (no filter)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'big_number_total',
sliceName: 'drill_bignum_rc',
params: { metric: 'count', adhoc_filters: [] },
});
await loadDashboardWithChart(dashboard, dashboardId, 'big_number_total');
// Right-click the rendered number itself opens the context menu.
const samples = expectSamplesPost(page);
await dashboard
.chartByVizType('big_number_total')
.locator('.header-line')
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetail();
await samples;
await expect(dashboard.drillModal()).toBeVisible();
// Whole-chart drill: no per-value filter tag.
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect(
dashboard.drillModal().locator('[data-test="row-count-label"]'),
).toContainText('rows');
},
);
testWithAssets(
'drill-to-detail modal: table cell right-click drills by that value and clearing the filter restores the full set',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'table',
sliceName: 'drill_table',
params: {
query_mode: 'aggregate',
groupby: ['gender'],
metrics: ['count'],
row_limit: 100,
server_pagination: false,
},
});
await loadDashboardWithChart(dashboard, dashboardId, 'table');
// Right-click the "boy" dimension cell and drill by it.
const samplesOnDrill = expectSamplesPost(page);
await dashboard
.chartByVizType('table')
.getByText('boy', { exact: true })
.first()
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetailBy('boy');
await samplesOnDrill;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText('boy');
const filteredCount = parseRowCount(
await modal.locator('[data-test="row-count-label"]').innerText(),
);
expect(filteredCount).toBeGreaterThan(0);
// Clearing the filter reloads the samples and restores the larger, unfiltered total.
const samplesOnClear = expectSamplesPost(page);
await modal.locator('[data-test="filter-col"]').getByLabel('Close').click();
await samplesOnClear;
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect
.poll(async () =>
parseRowCount(
await modal.locator('[data-test="row-count-label"]').innerText(),
),
)
.toBeGreaterThan(filteredCount);
},
);
testWithAssets(
'drill-to-detail modal: pivot table cell right-click drills by the cell value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'pivot_table_v2',
sliceName: 'drill_pivot',
params: {
groupbyRows: ['gender'],
groupbyColumns: [],
metrics: ['count'],
aggregateFunction: 'Sum',
rowTotals: false,
colTotals: false,
},
});
await loadDashboardWithChart(dashboard, dashboardId, 'pivot_table_v2');
await dashboard
.chartByVizType('pivot_table_v2')
.locator('[role="gridcell"]')
.first()
.click({ button: 'right' });
// The cell's row dimension determines the offered value; drill by it and
// assert the same value lands in the modal filter.
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const value = offered[0];
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
},
);
testWithAssets(
'drill-to-detail modal: pie slice right-click (canvas) drills by the slice value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'pie',
sliceName: 'drill_pie',
params: { groupby: ['gender'], metric: 'count' },
});
await loadDashboardWithChart(dashboard, dashboardId, 'pie');
const canvas = dashboard.chartByVizType('pie').locator('canvas').first();
await expect(canvas).toBeVisible();
// Pie is a donut by default (center is a hole), so scan the ring for a slice.
await rightClickCanvasDatum(page, canvas, 'ring');
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const value = offered[0];
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
},
);
testWithAssets(
'drill-to-detail modal: line chart point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const dashboardId = await buildSingleChartDashboard(page, testAssets, {
vizType: 'echarts_timeseries_line',
sliceName: 'drill_line',
params: {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
row_limit: 1000,
},
});
await loadDashboardWithChart(
dashboard,
dashboardId,
'echarts_timeseries_line',
);
const canvas = dashboard
.chartByVizType('echarts_timeseries_line')
.locator('canvas')
.first();
await expect(canvas).toBeVisible();
// Scan the plot grid for a point on one of the series lines.
await rightClickCanvasDatum(page, canvas, 'grid');
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const value = offered[0];
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
},
);

View File

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

View File

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

View File

@@ -231,6 +231,56 @@ describe('BigNumberTotal transformProps', () => {
expect(result.headerFormatter(500)).toBe('$500');
});
test('should pass through non-numeric raw string when parseMetricValue returns null (e.g. VARCHAR MAX)', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: 'some-varchar-result' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('some-varchar-result');
});
test('should pass through numeric-looking VARCHAR string literally (e.g. "123")', () => {
const { parseMetricValue } = jest.requireMock('../utils');
parseMetricValue.mockReturnValueOnce(null);
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [{ value: '123' }],
coltypes: [GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
};
const result = transformProps(
chartProps as unknown as BigNumberTotalChartProps,
);
expect(result.bigNumber).toBe('123');
});
test('should propagate colorThresholdFormatters from getColorFormatters', () => {
// Override the getColorFormatters mock to return specific value
const mockFormatters = [{ formatter: 'red' }];

View File

@@ -79,8 +79,15 @@ export default function transformProps(
const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? PROPORTION.SUBHEADER)
: (subheaderFontSize ?? subtitleFontSize ?? PROPORTION.SUBHEADER);
const rawValue = data.length === 0 ? null : data[0][metricName];
const parsedValue = rawValue == null ? null : parseMetricValue(rawValue);
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
parsedValue === null &&
typeof rawValue === 'string' &&
rawValue.trim() !== ''
? rawValue
: parsedValue;
let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) {

View File

@@ -189,8 +189,10 @@ function BigNumberVis({
text = t('No data');
} else if (typeof bigNumber === 'number') {
text = headerFormatter(bigNumber);
} else if (typeof bigNumber === 'string') {
text = bigNumber;
} 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);
text = Number.isNaN(numValue)
? String(bigNumber)

View File

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

View File

@@ -331,10 +331,16 @@ export default function transformProps(
type: legendType,
});
const chartPadding = getChartPadding(
showLegend,
legendOrientation,
effectiveLegendMargin,
);
const series: RadarSeriesOption[] = [
{
type: 'radar',
...getChartPadding(showLegend, legendOrientation, effectiveLegendMargin),
...chartPadding,
animation: false,
emphasis: {
label: {
@@ -361,6 +367,15 @@ export default function transformProps(
numberFormatter,
);
const centerX = width
? ((width + chartPadding.left - chartPadding.right) / 2 / width) * 100
: 50;
const centerY = height
? ((height + chartPadding.top - chartPadding.bottom) / 2 / height) * 100
: 50;
const radarCenter: [string, string] = [`${centerX}%`, `${centerY}%`];
const echartOptions: EChartsCoreOption = {
grid: {
...defaultGrid,
@@ -390,6 +405,7 @@ export default function transformProps(
color: theme.colorSplit,
},
},
center: radarCenter,
splitArea: {
show: true,
areaStyle: {

View File

@@ -23,6 +23,7 @@ import {
} from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
import {
LegendOrientation,
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
onFocusedSeries: jest.fn(),
};
function getLatestHeight() {
function getLatestEchartProps() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
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 () => {
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: ['Product A', 100], // X-axis value is 'Product A'
name: 'Product A',
@@ -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 () => {
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;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales',
data: [1609459200000, 100], // Timestamp
name: '2021-01-01',
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
...defaultProps,
emitCrossFilters: true,
setDataMask: setDataMaskMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions
xAxis: {
label: 'category_column',
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
const clickHandler = props.eventHandlers?.click;
if (clickHandler) {
clickHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A',
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
...defaultProps,
emitCrossFilters: true,
onContextMenu: onContextMenuMock,
formData: {
...defaultFormData,
orientation: OrientationType.Horizontal,
},
groupby: [], // No dimensions
xAxis: {
label: 'category_column',
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
expect(contextMenuHandler).toBeDefined();
if (contextMenuHandler) {
await contextMenuHandler({
componentType: 'series',
seriesName: 'Sales', // This is the metric name
data: [100, 'Product A'], // Horizontal: value first, category second
name: 'Product A',

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DTTM_ALIAS,
BinaryQueryObjectFilterClause,
@@ -27,12 +27,15 @@ import {
LegendState,
ensureIsArray,
} 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 ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart';
import { TimeseriesChartTransformedProps } from './types';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series';
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)
const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category;
const categoryAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
(data: unknown, name: unknown) => {
if (Array.isArray(data)) {
const categoryAxisValue = data[categoryAxisValueIndex];
if (
typeof categoryAxisValue === 'string' ||
typeof categoryAxisValue === 'number'
) {
return categoryAxisValue;
}
}
if (typeof name === 'string' || typeof name === 'number') {
return name;
}
return undefined;
},
[categoryAxisValueIndex],
);
const eventHandlers: EventHandlers = {
click: props => {
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
// Cross-filter by dimension (original behavior)
const { seriesName: name } = props;
handleChange(name);
} else if (canCrossFilterByXAxis && props.name != null) {
// Cross-filter by X-axis value when no dimensions (issue #25334).
// Use `name` (the category-axis value) instead of `data[0]`: for
// horizontal bars the data tuple is value-first, so `data[0]` would
// be the metric value rather than the category (issue #41102).
handleXAxisChange(props.name);
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
// Cross-filter by X-axis value when no dimensions (issue #25334)
const categoryAxisValue = getCategoryAxisValue(
props.data,
props.name,
);
if (categoryAxisValue !== undefined) {
handleXAxisChange(categoryAxisValue);
}
}
}, TIMER_DURATION);
},
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
let crossFilter;
if (hasDimensions) {
crossFilter = getCrossFilterDataMask(seriesName);
} else if (canCrossFilterByXAxis && eventParams.name != null) {
// Use `name` (the category-axis value), not `data[0]`, so horizontal
// bars cross-filter on the category and not the metric (issue #41102).
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
} else if (
canCrossFilterByXAxis &&
eventParams.componentType === 'series'
) {
const categoryAxisValue = getCategoryAxisValue(
data,
eventParams.name,
);
if (categoryAxisValue !== undefined) {
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
}
}
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 = {
dblclick: params => {
// clear single click timer
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
queryEventHandlers={queryEventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}

View File

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

View File

@@ -381,6 +381,15 @@ export default function transformProps(
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
// With the "full range" time-shift option, offset series are outer-joined onto
// the main series, which inserts null rows into the main series wherever the
// comparison period has data the current period lacks. Connect nulls so the
// main line stays continuous (matching the default left-join appearance) rather
// than fragmenting at every inserted gap.
const timeCompareFullRange = Boolean(
chartProps.rawFormData?.time_compare_full_range,
);
const offsetLineWidths: { [key: string]: number } = {};
// For horizontal bar charts, calculate min/max from data to avoid cutting off labels
@@ -478,7 +487,7 @@ export default function transformProps(
colorScaleKey,
{
area,
connectNulls: derivedSeries,
connectNulls: derivedSeries || timeCompareFullRange,
filterState,
seriesContexts,
markerEnabled,
@@ -889,6 +898,10 @@ export default function transformProps(
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
...(xAxisType === AxisType.Category &&
groupBy.length === 0 && {
triggerEvent: true,
}),
axisLabel: {
// When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already

View File

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

View File

@@ -64,7 +64,12 @@ import {
MarkLineComponent,
} from 'echarts/components';
import { LabelLayout } from 'echarts/features';
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
import {
EchartsHandler,
EchartsProps,
EchartsStylesProps,
QueryEventHandlers,
} from '../types';
import { DEFAULT_LOCALE } from '../constants';
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
@@ -132,6 +137,7 @@ function Echart(
height,
echartOptions,
eventHandlers,
queryEventHandlers,
zrEventHandlers,
selectedValues = {},
refs,
@@ -147,6 +153,7 @@ function Echart(
}
const [didMount, setDidMount] = useState(false);
const chartRef = useRef<EChartsType>();
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
const currentSelection = useMemo(
() => Object.keys(selectedValues) || [],
[selectedValues],
@@ -196,11 +203,19 @@ function Echart(
useEffect(() => {
if (didMount) {
previousQueryEventHandlers.current.forEach(({ name, handler }) => {
chartRef.current?.off(name, handler);
});
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
chartRef.current?.off(name);
chartRef.current?.on(name, handler);
});
(queryEventHandlers || []).forEach(({ name, query, handler }) => {
chartRef.current?.on(name, query, handler);
});
previousQueryEventHandlers.current = queryEventHandlers || [];
Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
chartRef.current?.getZr().off(name);
chartRef.current?.getZr().on(name, handler);
@@ -336,7 +351,15 @@ function Echart(
}
}
// 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)
useEffect(() => {

View File

@@ -34,6 +34,7 @@ import {
} from '@superset-ui/core';
import type { EChartsCoreOption, EChartsType } from 'echarts/core';
import type { TooltipMarker } from 'echarts/types/src/util/format';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import { StackControlsValue } from './constants';
export type EchartsStylesProps = {
@@ -51,6 +52,7 @@ export interface EchartsProps {
width: number;
echartOptions: EChartsCoreOption;
eventHandlers?: EventHandlers;
queryEventHandlers?: QueryEventHandlers;
zrEventHandlers?: EventHandlers;
selectedValues?: Record<number, string>;
forceClear?: boolean;
@@ -105,6 +107,12 @@ export type LegendFormData = {
export type EventHandlers = Record<string, { (props: any): void }>;
export type QueryEventHandlers = {
name: string;
query: string;
handler: (props: ECElementEvent) => void;
}[];
export enum LabelPositionEnum {
Top = 'top',
Left = 'left',

View File

@@ -24,6 +24,7 @@ import {
EchartsRadarChartProps,
EchartsRadarFormData,
} from '../../src/Radar/types';
import { LegendOrientation } from '../../src/types';
interface RadarIndicator {
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);
});
});

View File

@@ -1564,9 +1564,13 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
});
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.triggerEvent).toBe(true);
});
test('temporal x coltype wires the time formatter and Time axis', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,33 @@ const StyledLegend = styled.div`
const categoryDelimiter = ' - ';
const OPENING_BRACKETS = '[(';
const CLOSING_BRACKETS = '])';
// Recognize half-open interval labels like "[1, 81)" or "[81, 212]" emitted by
// getBuckets: brackets on the ends, two comma-separated bounds in between.
// Returns the parsed pieces, or null when the label isn't interval notation.
const parseInterval = (label: string) => {
const open = label[0];
const close = label[label.length - 1];
if (!OPENING_BRACKETS.includes(open) || !CLOSING_BRACKETS.includes(close)) {
return null;
}
const bounds = label.slice(1, -1).split(',');
if (bounds.length !== 2) {
return null;
}
const lower = bounds[0].trim();
const upper = bounds[1].trim();
if (!lower || !upper) {
return null;
}
return { open, lower, upper, close };
};
export type LegendProps = {
format: string | null;
forceCategorical?: boolean;
@@ -91,6 +118,15 @@ const Legend = ({
return k;
}
// Format each numeric bound of an interval label while preserving the
// brackets and separator, e.g. "[1, 81)" -> "[1.00, 81.00)".
const interval = parseInterval(k);
if (interval) {
const { open, lower, upper, close } = interval;
return `${open}${format(lower)}, ${format(upper)}${close}`;
}
if (k.includes(categoryDelimiter)) {
const values = k.split(categoryDelimiter);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,277 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { act, render, screen } from 'spec/helpers/testing-library';
import { chat } from 'src/core/chat';
import ChatProvider from './ChatProvider';
import { ChatFloatingHost as ChatHost, ChatPanelHost } from './ChatHost';
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('renders nothing when no chat extension is registered', () => {
render(<ChatHost />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('renders the trigger bubble of the registered chat', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
// The panel stays unmounted until the chat is opened.
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('mounts the panel when the chat opens and unmounts it on close', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => chat.open());
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
// In floating mode the trigger stays mounted alongside the open panel.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('renders the last-registered chat when several are installed', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
);
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
render(<ChatHost />);
// Last-loaded wins: the second registration takes over the singleton slot.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
test('reacts to a chat registering after the initial render', () => {
render(<ChatHost />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
act(() => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
});
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a takeover mounts the incoming chat closed', () => {
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
);
render(<ChatHost />);
act(() => chat.open());
expect(screen.getByText('First Panel')).toBeInTheDocument();
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
});
// The displaced chat's open state must not leak into the winner.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
});
test('ChatPanelHost renders the panel when open in panel mode', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatPanelHost />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
});
test('ChatFloatingHost suppresses the floating panel in panel mode but keeps the trigger', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
// In panel mode the floating panel is suppressed (ChatPanelHost owns that slot).
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
// The trigger stays rendered so the user can reopen after collapsing.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
// Trigger remains visible even when closed — it's the user's only way back.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a crashing panel does not take the trigger down with it', () => {
const FailingPanel = () => {
throw new Error('panel blew up');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <FailingPanel />,
);
render(<ChatHost />);
act(() => chat.open());
// The panel's boundary contains the crash; the trigger keeps rendering so
// the user is not stranded without a way back.
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('isolates a failing trigger so it does not crash the host', () => {
const FailingTrigger = () => {
throw new Error('chat blew up');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatHost />)).not.toThrow();
// The mount slot still renders (the boundary lives inside it), confirming
// the provider was actually exercised and contained.
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('isolates a component that throws during render', () => {
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => {
throw new Error('provider blew up');
},
() => <div>Acme Panel</div>,
);
expect(() => render(<ChatHost />)).not.toThrow();
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different chat takes over', () => {
const FailingTrigger = () => {
throw new Error('first chat blew up');
};
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <FailingTrigger />,
() => <div>First Panel</div>,
);
render(<ChatHost />);
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
);
jest.restoreAllMocks();
});
// The boundary is keyed per registration, so the latched crash from the
// first chat does not blank the second one.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different id takes over', () => {
const FailingTrigger = () => {
throw new Error('broken release');
};
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
);
render(<ChatHost />);
act(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat(
{ id: 'fixed.chat', name: 'Fixed Chat' },
() => <div>Fixed Bubble</div>,
() => <div>Fixed Panel</div>,
);
jest.restoreAllMocks();
});
// Different id: boundary key changes, latch resets, fix renders.
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
});

View File

@@ -0,0 +1,133 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { type ComponentType, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
import { useChat } from '.';
const CHAT_EDGE_MARGIN = 24;
/**
* Returns an onError handler that shows a toast on crash, once per chat id.
*/
function useCrashNotifier(chatId: string | undefined) {
const notifiedFor = useRef<string | undefined>(undefined);
return (error: Error) => {
if (!chatId) return;
logging.error('[chat] provider crashed', error);
if (notifiedFor.current !== chatId) {
notifiedFor.current = chatId;
store.dispatch(addDangerToast(t('The chat failed to load.')));
}
};
}
/**
* Wraps a component in an ErrorBoundary, keyed by chat id so the boundary
* resets when a different chat takes over.
*/
const ChatBoundary = ({
component: Component,
onError,
}: {
component: ComponentType;
onError: (error: Error) => void;
}) => (
<ErrorBoundary showMessage={false} onError={onError}>
<Component />
</ErrorBoundary>
);
/**
* Renders the chat panel content in panel mode. Fills its container height.
*/
export const ChatPanelHost = () => {
const { chat, panel } = useChat();
const onError = useCrashNotifier(chat?.id);
if (!chat || !panel) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
display: flex;
flex-direction: column;
height: 100%;
`}
>
<ChatBoundary key={chat.id} component={panel} onError={onError} />
</div>
);
};
/**
* Renders the chat trigger and, when the panel is open in floating mode, the
* floating panel overlay. The trigger is always visible when a chat is
* registered; the panel overlay is suppressed in panel mode.
*/
export const ChatFloatingHost = () => {
const theme = useTheme();
const { open: panelOpen, mode, chat, trigger, panel } = useChat();
const onError = useCrashNotifier(chat?.id);
if (!chat || !trigger || !panel) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
right: ${CHAT_EDGE_MARGIN}px;
bottom: ${CHAT_EDGE_MARGIN}px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: ${theme.sizeUnit * 2}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
{/*
Separate boundaries so a crashing panel cannot take the trigger down
with it — the trigger is the user's only way back.
*/}
{panelOpen && mode !== 'panel' && (
<ChatBoundary
key={`panel-${chat.id}`}
component={panel}
onError={onError}
/>
)}
<ChatBoundary
key={`trigger-${chat.id}`}
component={trigger}
onError={onError}
/>
</div>
);
};

View File

@@ -0,0 +1,257 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createElement } from 'react';
import ChatProvider from './ChatProvider';
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('returns the singleton instance', () => {
expect(ChatProvider.getInstance()).toBe(ChatProvider.getInstance());
});
test('getChat returns undefined when no chat is registered', () => {
expect(ChatProvider.getInstance().getChat()).toBeUndefined();
});
test('registerChat sets the registration and returns the descriptor copy', () => {
const provider = ChatProvider.getInstance();
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
const disposable = provider.registerChat(descriptor, trigger, panel);
expect(provider.getChat()).toEqual(descriptor);
disposable.dispose();
});
test('the last-registered chat wins and logs a warning', () => {
const provider = ChatProvider.getInstance();
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.getChat()?.id).toBe('second.chat');
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain('second.chat');
expect(warn.mock.calls[0][0]).toContain('first.chat');
warn.mockRestore();
});
test('re-registering with a different id replaces the active chat', () => {
const provider = ChatProvider.getInstance();
jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
expect(provider.getChat()?.id).toBe('first.chat');
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.getChat()?.id).toBe('second.chat');
jest.restoreAllMocks();
});
test('disposing the registration clears it', () => {
const provider = ChatProvider.getInstance();
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
disposable.dispose();
expect(provider.getChat()).toBeUndefined();
});
test('disposing twice fires unregister only once', () => {
const provider = ChatProvider.getInstance();
const unregistered = jest.fn();
provider.onDidUnregisterChat(unregistered);
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
disposable.dispose();
disposable.dispose();
expect(unregistered).toHaveBeenCalledTimes(1);
});
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
const provider = ChatProvider.getInstance();
const registered = jest.fn();
const unregistered = jest.fn();
provider.onDidRegisterChat(registered);
provider.onDidUnregisterChat(unregistered);
const descriptor = { id: 'acme.chat', name: 'Acme' };
const disposable = provider.registerChat(descriptor, trigger, panel);
expect(registered).toHaveBeenCalledWith(descriptor);
expect(unregistered).not.toHaveBeenCalled();
disposable.dispose();
expect(unregistered).toHaveBeenCalledWith(descriptor);
});
test('open and close toggle the panel state', () => {
const provider = ChatProvider.getInstance();
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
provider.open();
expect(provider.isOpen()).toBe(true);
provider.close();
expect(provider.isOpen()).toBe(false);
});
test('open fires once; duplicate open is a no-op', () => {
const provider = ChatProvider.getInstance();
const opened = jest.fn();
provider.onDidOpen(opened);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.open();
expect(opened).toHaveBeenCalledTimes(1);
});
test('close fires once; duplicate close is a no-op', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.close();
provider.close();
expect(closed).toHaveBeenCalledTimes(1);
});
test('open is a no-op when no chat is registered', () => {
const provider = ChatProvider.getInstance();
const opened = jest.fn();
provider.onDidOpen(opened);
provider.open();
expect(provider.isOpen()).toBe(false);
expect(opened).not.toHaveBeenCalled();
});
test('registering a second chat while open closes the panel', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
jest.spyOn(console, 'warn').mockImplementation(() => {});
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
provider.open();
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
jest.restoreAllMocks();
});
test('disposing the active chat while open closes the panel', () => {
const provider = ChatProvider.getInstance();
const closed = jest.fn();
provider.onDidClose(closed);
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
provider.open();
disposable.dispose();
expect(provider.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('a late registration does not inherit a stale open state', () => {
const provider = ChatProvider.getInstance();
const disposable = provider.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
provider.open();
disposable.dispose();
provider.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel);
expect(provider.isOpen()).toBe(false);
});
test('getDisplayMode defaults to floating', () => {
expect(ChatProvider.getInstance().getDisplayMode()).toBe('floating');
});
test('setDisplayMode updates mode and fires event only on change', () => {
const provider = ChatProvider.getInstance();
const modeChanged = jest.fn();
provider.onDidChangeDisplayMode(modeChanged);
provider.setDisplayMode('floating');
expect(modeChanged).not.toHaveBeenCalled();
provider.setDisplayMode('panel');
expect(provider.getDisplayMode()).toBe('panel');
expect(modeChanged).toHaveBeenCalledWith('panel');
});
test('state reflects changes after registration and open', () => {
const provider = ChatProvider.getInstance();
expect(provider.getChat()).toBeUndefined();
expect(provider.isOpen()).toBe(false);
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
expect(provider.isOpen()).toBe(true);
expect(provider.getChat()?.id).toBe('acme.chat');
});
test('reset clears all state', () => {
const provider = ChatProvider.getInstance();
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
provider.open();
provider.setDisplayMode('panel');
provider.reset();
expect(provider.getChat()).toBeUndefined();
expect(provider.isOpen()).toBe(false);
expect(provider.getDisplayMode()).toBe('floating');
});

View File

@@ -0,0 +1,209 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ComponentType } from 'react';
import type { chat as chatApi } from '@apache-superset/core';
import {
LocalStorageKeys,
getItem,
setItem,
} from 'src/utils/localStorageHelpers';
import { Disposable } from '../models';
import { createValueEventEmitter, createEventEmitter } from '../utils';
type Chat = chatApi.Chat;
type DisplayMode = chatApi.DisplayMode;
/**
* Singleton manager for the chat provider.
* Handles registration, open/close state, and display mode.
*/
class ChatProvider {
private static instance: ChatProvider;
private chat: Chat | undefined;
private trigger: ComponentType | undefined;
private panel: ComponentType | undefined;
private opened: boolean;
private stateSubscribers = new Set<() => void>();
private registerEmitter = createEventEmitter<Chat>();
private unregisterEmitter = createEventEmitter<Chat>();
private openEmitter = createEventEmitter<void>();
private closeEmitter = createEventEmitter<void>();
private resizePanelEmitter = createEventEmitter<{ width: number }>();
private modeEmitter: ReturnType<typeof createValueEventEmitter<DisplayMode>>;
private constructor() {
const persisted = getItem(LocalStorageKeys.ChatState, {
open: false,
mode: 'floating',
});
const mode = (
persisted.mode === 'panel' ? 'panel' : 'floating'
) as DisplayMode;
this.opened = persisted.open === true;
this.modeEmitter = createValueEventEmitter<DisplayMode>(mode);
}
public static getInstance(): ChatProvider {
if (!ChatProvider.instance) {
ChatProvider.instance = new ChatProvider();
}
return ChatProvider.instance;
}
public subscribe = (listener: () => void): (() => void) => {
this.stateSubscribers.add(listener);
return () => this.stateSubscribers.delete(listener);
};
private notifyState(): void {
setItem(LocalStorageKeys.ChatState, {
open: this.opened,
mode: this.modeEmitter.getCurrent(),
});
this.stateSubscribers.forEach(fn => fn());
}
private closePanel(): void {
this.opened = false;
this.closeEmitter.fire();
}
public registerChat(
chat: Chat,
trigger: ComponentType,
panel: ComponentType,
): Disposable {
if (this.chat) {
// eslint-disable-next-line no-console
console.warn(
`[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`,
);
this.unregisterEmitter.fire(this.chat);
if (this.opened) this.closePanel();
}
this.chat = chat;
this.trigger = trigger;
this.panel = panel;
this.registerEmitter.fire(chat);
this.notifyState();
return new Disposable(() => {
if (this.chat !== chat) return;
this.chat = undefined;
this.trigger = undefined;
this.panel = undefined;
this.unregisterEmitter.fire(chat);
if (this.opened) this.closePanel();
this.notifyState();
});
}
public getChat(): Chat | undefined {
return this.chat;
}
public getTrigger(): ComponentType | undefined {
return this.trigger;
}
public getPanel(): ComponentType | undefined {
return this.panel;
}
public open(): void {
if (this.opened || !this.chat) return;
this.opened = true;
this.openEmitter.fire();
this.notifyState();
}
public close(): void {
if (!this.opened || !this.chat) return;
this.closePanel();
this.notifyState();
}
public isOpen(): boolean {
return this.opened;
}
public getDisplayMode(): DisplayMode {
return this.modeEmitter.getCurrent();
}
public setDisplayMode(displayMode: DisplayMode): void {
if (displayMode === this.modeEmitter.getCurrent()) return;
this.modeEmitter.fire(displayMode);
this.notifyState();
}
public get onDidRegisterChat() {
return this.registerEmitter.subscribe;
}
public get onDidUnregisterChat() {
return this.unregisterEmitter.subscribe;
}
public get onDidOpen() {
return this.openEmitter.subscribe;
}
public get onDidClose() {
return this.closeEmitter.subscribe;
}
public get onDidChangeDisplayMode() {
return this.modeEmitter.subscribe;
}
public get onDidResizePanel() {
return this.resizePanelEmitter.subscribe;
}
public reset(): void {
this.chat = undefined;
this.trigger = undefined;
this.panel = undefined;
this.opened = false;
this.registerEmitter = createEventEmitter<Chat>();
this.unregisterEmitter = createEventEmitter<Chat>();
this.openEmitter = createEventEmitter<void>();
this.closeEmitter = createEventEmitter<void>();
this.resizePanelEmitter = createEventEmitter<{ width: number }>();
this.modeEmitter = createValueEventEmitter<DisplayMode>('floating');
this.stateSubscribers.clear();
setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' });
}
}
export default ChatProvider;

View File

@@ -0,0 +1,68 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createElement } from 'react';
import { chat } from './index';
import ChatProvider from './ChatProvider';
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
beforeEach(() => {
ChatProvider.getInstance().reset();
});
test('getChat returns undefined when no chat is registered', () => {
expect(chat.getChat()).toBeUndefined();
});
test('registerChat makes the chat retrievable via getChat', () => {
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
chat.registerChat(descriptor, trigger, panel);
expect(chat.getChat()).toEqual(descriptor);
});
test('the last-registered chat wins when multiple are registered', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
expect(chat.getChat()?.id).toBe('second.chat');
jest.restoreAllMocks();
});
test('open and close toggle isOpen', () => {
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
expect(chat.isOpen()).toBe(false);
chat.open();
expect(chat.isOpen()).toBe(true);
chat.close();
expect(chat.isOpen()).toBe(false);
});
test('getDisplayMode defaults to floating', () => {
expect(chat.getDisplayMode()).toBe('floating');
});
test('setDisplayMode updates the display mode', () => {
chat.setDisplayMode('panel');
expect(chat.getDisplayMode()).toBe('panel');
});

View File

@@ -0,0 +1,82 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host implementation of the `chat` contribution type.
*
* Extensions register via the public `chat.registerChat()` and the host owns
* mounting, open/close state, and the display mode. Only the last-registered
* chat is active at a time.
*
* The public namespace (`chat`) is exposed to extensions on `window.superset`.
* `useChat` is host-internal and NOT part of the public `@apache-superset/core` API.
*/
import { useSyncExternalStore } from 'react';
import memoizeOne from 'memoize-one';
import type { chat as chatApi } from '@apache-superset/core';
import ChatProvider from './ChatProvider';
export { ChatFloatingHost, ChatPanelHost } from './ChatHost';
const provider = ChatProvider.getInstance();
const buildSnapshot = memoizeOne(
(
open: boolean,
mode: chatApi.DisplayMode,
chat: chatApi.Chat | undefined,
trigger: ReturnType<typeof provider.getTrigger>,
panel: ReturnType<typeof provider.getPanel>,
) => ({ open, mode, chat, trigger, panel }),
);
const getSnapshot = () =>
buildSnapshot(
provider.isOpen(),
provider.getDisplayMode(),
provider.getChat(),
provider.getTrigger(),
provider.getPanel(),
);
/**
* Host-internal hook. Returns the current open/mode state and the active chat
* (trigger, panel, descriptor).
*/
export const useChat = () =>
useSyncExternalStore(provider.subscribe, getSnapshot);
export const chat: typeof chatApi = {
registerChat: provider.registerChat.bind(provider),
getChat: provider.getChat.bind(provider),
onDidRegisterChat: provider.onDidRegisterChat,
onDidUnregisterChat: provider.onDidUnregisterChat,
open: provider.open.bind(provider),
close: provider.close.bind(provider),
isOpen: provider.isOpen.bind(provider),
onDidOpen: provider.onDidOpen,
onDidClose: provider.onDidClose,
getDisplayMode: provider.getDisplayMode.bind(provider),
setDisplayMode: provider.setDisplayMode.bind(provider),
onDidChangeDisplayMode: provider.onDidChangeDisplayMode,
// The host fires this from its panel resizer; until that chrome exists the
// event is exposed but never fires.
onDidResizePanel: provider.onDidResizePanel,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Reset module state between tests so currentPage is re-initialized.
beforeEach(() => {
jest.resetModules();
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/' },
});
});
async function importNavigation() {
const mod = await import('./index');
return mod;
}
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
// The default pathname ('/') is not enumerated and falls back to home.
expect(navigation.getPage()).toBe('home');
notifyLocationChanged('/superset/welcome/');
expect(navigation.getPage()).toBe('home');
});
test('getPage derives the page from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPage()).toBe('dashboard');
});
test('notifyLocationChanged updates the current page type', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
notifyLocationChanged('/explore/?form_data={}');
expect(navigation.getPage()).toBe('explore');
});
test('notifyLocationChanged fires listeners on page type change', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyLocationChanged('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyLocationChanged('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyLocationChanged } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyLocationChanged('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/sqllab');
expect(navigation.getPage()).toBe('sqllab');
notifyLocationChanged('/explore/');
notifyLocationChanged('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/chart/list/');
expect(navigation.getPage()).toBe('chart_list');
notifyLocationChanged('/dashboard/list/');
expect(navigation.getPage()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/tablemodelview/list/');
expect(navigation.getPage()).toBe('dataset_list');
notifyLocationChanged('/dataset/42');
expect(navigation.getPage()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
notifyLocationChanged('/sqllab/history/');
expect(navigation.getPage()).toBe('query_history');
notifyLocationChanged('/savedqueryview/list/');
expect(navigation.getPage()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyLocationChanged, navigation } = await importNavigation();
notifyLocationChanged('/chart/add');
expect(navigation.getPage()).toBe('explore');
});

View File

@@ -0,0 +1,94 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview Host-internal implementation of the `navigation` namespace.
*
* Derives the current {@link Page} from the browser location by matching
* against {@link RoutePaths}. Call {@link useNavigationTracker} once in the
* app shell to keep the page in sync with React Router.
*/
import { useEffect, useRef } from 'react';
import { useLocation, matchPath } from 'react-router-dom';
import type { navigation as navigationApi } from '@apache-superset/core';
import { RoutePaths } from '../../views/routePaths';
import { Disposable } from '../models';
import { createValueEventEmitter } from '../utils';
type Page = navigationApi.Page;
/** Maps route path patterns to their corresponding Page type. */
const PAGE_ROUTES: { path: string; page: Page }[] = [
{ path: RoutePaths.DASHBOARD, page: 'dashboard' },
{ path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
{ path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
{ path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
{ path: RoutePaths.SQLLAB, page: 'sqllab' },
{ path: RoutePaths.CHART_ADD, page: 'explore' },
{ path: RoutePaths.CHART_LIST, page: 'chart_list' },
{ path: RoutePaths.EXPLORE, page: 'explore' },
{ path: RoutePaths.EXPLORE_PERMALINK, page: 'explore' },
{ path: RoutePaths.DATASET_LIST, page: 'dataset_list' },
{ path: RoutePaths.DATASET_ADD, page: 'dataset' },
{ path: RoutePaths.DATASET, page: 'dataset' },
];
function derivePage(pathname: string): Page {
for (const { path, page } of PAGE_ROUTES) {
if (matchPath(pathname, { path, exact: false })) return page;
}
return 'home';
}
const pageEmitter = createValueEventEmitter<Page>(
derivePage(window.location.pathname),
);
/** Updates the current page from a pathname. No-op when the page is unchanged. */
export const notifyLocationChanged = (pathname: string): void => {
const next = derivePage(pathname);
if (next === pageEmitter.getCurrent()) return;
pageEmitter.fire(next);
};
const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (page: Page) => void,
thisArgs?: unknown,
): Disposable => pageEmitter.subscribe(listener, thisArgs);
/** Synchronizes the navigation module with React Router. Call once in the app shell. */
export const useNavigationTracker = () => {
const location = useLocation();
const prevPathname = useRef<string | null>(null);
useEffect(() => {
if (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyLocationChanged(location.pathname);
}
}, [location.pathname]);
};
export const navigation: typeof navigationApi = {
getPage,
onDidChangePage,
};

View File

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

View File

@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { common as core } from '@apache-superset/core';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
export function createActionListener<V, A = unknown>(
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,
valueParser: (action: A, state: RootState) => V | null | undefined,
thisArgs?: unknown,
): core.Disposable {
const boundListener = thisArgs ? listener.bind(thisArgs as object) : listener;
const unsubscribe = listenerMiddleware.startListening({
predicate,
effect: action => {
const state = store.getState();
// The predicate already ensures the action matches type A at runtime.
const value = valueParser(action as unknown as A, state);
if (value != null) {
boundListener(value);
}
},
});
return {
dispose: () => {
unsubscribe();
},
};
}

View File

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

View File

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

View File

@@ -379,6 +379,79 @@ test('should fallback to formData state when runtime state not available', () =>
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', () => {
const { queryByRole } = setup(
{},

View File

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

View File

@@ -22,6 +22,8 @@ import {
extractLabel,
getAppliedColumnsWithFallback,
getCrossFilterIndicator,
IndicatorStatus,
selectNativeIndicatorsForChart,
} from './selectors';
// 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']));
});
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', () => {
const chart = {
queriesResponse: [{ applied_filters: [] }],
@@ -565,3 +582,47 @@ test('getAppliedColumnsWithFallback prioritizes query response over fallback', (
);
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',
},
]);
});

View File

@@ -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> =>
new Set(
(chart?.queriesResponse?.[0]?.applied_filters || []).map(
getQueryFilterMetadata(chart, 'applied_filters').map(
(filter: any) => filter.column,
),
);
@@ -161,8 +172,7 @@ export const getAppliedColumnsWithFallback = (
chartId?: number,
): Set<string> => {
// First try to get from query response (preferred source of truth)
const queryAppliedFilters =
chart?.queriesResponse?.[0]?.applied_filters || [];
const queryAppliedFilters = getQueryFilterMetadata(chart, 'applied_filters');
if (queryAppliedFilters.length > 0) {
return new Set(queryAppliedFilters.map((filter: any) => filter.column));
}
@@ -191,7 +201,7 @@ export const getAppliedColumnsWithFallback = (
const getRejectedColumns = (chart: any): Set<string> =>
new Set(
(chart?.queriesResponse?.[0]?.rejected_filters || []).map((filter: any) =>
getQueryFilterMetadata(chart, 'rejected_filters').map((filter: any) =>
getColumnLabel(filter.column),
),
);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,150 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ComponentType } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { useExploreAdditionalActionsMenu } from './index';
import * as exploreUtils from 'src/explore/exploreUtils';
jest.mock('src/explore/exploreUtils', () => ({
__esModule: true,
...jest.requireActual('src/explore/exploreUtils'),
exportChart: jest.fn(),
getChartKey: jest.fn(() => 'test_chart_key'),
}));
const mockExportChart = exploreUtils.exportChart as jest.Mock;
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (component: ComponentType) => component,
useToasts: () => ({
addDangerToast: mockAddDangerToast,
addSuccessToast: jest.fn(),
}),
}));
jest.mock('src/logger/actions', () => ({
logEvent: jest.fn(() => ({ type: 'LOG_EVENT' })),
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
getChartMetadataRegistry: jest.fn(() => ({
get: jest.fn(() => ({ behaviors: ['EXPORT_CURRENT_VIEW'] })),
})),
}));
const defaultProps = {
latestQueryFormData: {
datasource: '1__table',
viz_type: 'pivot_table_v2',
},
canDownloadCSV: true,
slice: { slice_id: 1, slice_name: 'Test Chart' },
ownState: {},
dashboards: [],
onOpenInEditor: jest.fn(),
onOpenPropertiesModal: jest.fn(),
showReportModal: jest.fn(),
setCurrentReportDeleting: jest.fn(),
};
type TestComponentProps = typeof defaultProps;
type HookParams = Parameters<typeof useExploreAdditionalActionsMenu>;
const TestComponent = (props: TestComponentProps) => {
const [menu] = useExploreAdditionalActionsMenu(
props.latestQueryFormData as HookParams[0],
props.canDownloadCSV,
props.slice as HookParams[2],
props.onOpenInEditor,
props.onOpenPropertiesModal,
props.ownState as HookParams[5],
props.dashboards as HookParams[6],
props.showReportModal,
props.setCurrentReportDeleting,
);
return <div>{menu}</div>;
};
beforeEach(() => {
jest.clearAllMocks();
mockExportChart.mockResolvedValue(undefined);
});
test('shows 413 error toast when exportCSV fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to original .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when exportCSVPivoted fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(<TestComponent {...defaultProps} />, { useRedux: true });
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export All Data'));
userEvent.click(await screen.findByText('Export to pivoted .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});
test('shows 413 error toast when Export Current View CSV server path fails with 413', async () => {
mockExportChart.mockRejectedValue({ status: 413 });
render(
<TestComponent
{...defaultProps}
latestQueryFormData={{
datasource: '1__table',
viz_type: 'table',
}}
ownState={{}}
/>,
{ useRedux: true },
);
userEvent.hover(await screen.findByText('Data Export Options'));
userEvent.hover(await screen.findByText('Export Current View'));
userEvent.click(await screen.findByText('Export to .CSV'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
expect.stringMatching(/The chart data is too large to download/),
);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Global `window.superset` type augmentation.
*
* Lives in its own module (rather than inline in ExtensionsStartup) so every
* file that reads or writes `window.superset` — notably ExtensionsLoader —
* sees the type regardless of how files are batched during compilation. Both
* the startup component and the loader import this module for its side effect.
*/
import type {
authentication,
chat,
commands,
core,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
/** The host namespaces exposed to extensions on `window.superset`. */
export interface Namespaces {
authentication: typeof authentication;
core: typeof core;
chat: typeof chat;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
navigation: typeof navigation;
sqlLab: typeof sqlLab;
views: typeof views;
}
declare global {
interface Window {
superset: Namespaces;
}
}

View File

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

View File

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

View File

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

View File

@@ -89,7 +89,7 @@ const dropdownItems = [
},
{
label: 'Dashboard',
url: '/dashboard/new',
url: '/dashboard/new/',
icon: 'fa-fw fa-dashboard',
perm: 'can_write',
view: 'Dashboard',

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import {
RoleNameField,
PermissionsField,
UsersField,
GroupsField,
} from './RoleFormItems';
jest.mock('./utils', () => ({
fetchPermissionOptions: jest.fn(),
fetchGroupOptions: jest.fn(),
}));
jest.mock('../groups/utils', () => ({
fetchUserOptions: jest.fn(),
}));
const addDangerToast = jest.fn();
test('RoleNameField renders label and input', () => {
render(<RoleNameField />);
expect(screen.getByText('Role Name')).toBeInTheDocument();
expect(screen.getByTestId('role-name-input')).toBeInTheDocument();
});
test('PermissionsField renders label and select', () => {
render(<PermissionsField addDangerToast={addDangerToast} />);
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
});
test('PermissionsField renders loading state', () => {
render(<PermissionsField addDangerToast={addDangerToast} loading />);
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByTestId('permissions-select')).toBeInTheDocument();
});
test('UsersField renders label and select', () => {
render(<UsersField addDangerToast={addDangerToast} loading={false} />);
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByTestId('roles-select')).toBeInTheDocument();
});
test('GroupsField renders label and select', () => {
render(<GroupsField addDangerToast={addDangerToast} />);
expect(screen.getByText('Groups')).toBeInTheDocument();
expect(screen.getByTestId('groups-select')).toBeInTheDocument();
});

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