Compare commits

..

1 Commits

Author SHA1 Message Date
amaannawab923
348d924c92 fix(plugin-chart-ag-grid-table): keep basic conditional formatting aligned after sort (#105973)
The basic (increase/decrease) color formatters were built in the original
query order and read positionally by the displayed AG Grid rowIndex. Once the
table was sorted client-side, the displayed index no longer matched the data
order, so the green/red background and arrow indicators were applied to the
wrong rows.

Attach each row's formatter to its row data object (non-enumerable, so it never
leaks into exports/cross-filters) so it travels with the row through sorting,
and resolve it via getRowBasicColorFormatter in both getCellStyle and
NumericCellRenderer, falling back to the positional lookup. Add unit tests.
2026-06-24 17:34:48 +05:30
142 changed files with 1234 additions and 6925 deletions

2
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
/**
* Drives an HTML5 drag-and-drop using synthetic native drag events.
*
* The dashboard grid uses react-dnd with the HTML5 backend
* (`react-dnd-html5-backend`), which listens for native `dragstart` /
* `dragenter` / `dragover` / `drop` events rather than the mouse events that
* Playwright's built-in `locator.dragTo()` produces. To trigger it we dispatch
* the native drag sequence ourselves, threading a single shared `DataTransfer`
* object through every event so react-dnd's monitor sees a consistent payload.
*
* Mirrors the synthetic-event sequence used by the deprecated Cypress `drag`
* helper (cypress-base/cypress/utils/index.ts).
*
* @param page - Playwright page (used to mint the shared DataTransfer)
* @param source - The draggable element (or a descendant; drag events bubble)
* @param target - The drop target element
*/
export async function html5DragAndDrop(
page: Page,
source: Locator,
target: Locator,
): Promise<void> {
// Note: we intentionally do not scrollIntoView the source. The chart card list
// is virtualized, so a separate scroll action can detach the element between
// resolution and use; dispatchEvent only requires the node to be attached.
// A single DataTransfer shared across every event in the sequence: react-dnd's
// HTML5 backend reads/writes drag state through it, so reusing one handle is
// what makes the monitor treat this as one coherent drag.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
await source.dispatchEvent('dragstart', { dataTransfer });
// react-dnd's HTML5 backend commits monitor state (the active drag source) on a
// microtask after dragstart; a short settle avoids a race where dragover/drop
// fire before the backend considers a drag to be in progress.
await page.waitForTimeout(50);
// dragenter must precede dragover for react-dnd to register the hover target.
await target.dispatchEvent('dragenter', { dataTransfer });
await target.dispatchEvent('dragover', { dataTransfer });
await page.waitForTimeout(50);
await target.dispatchEvent('drop', { dataTransfer });
await source.dispatchEvent('dragend', { dataTransfer });
await dataTransfer.dispose();
}

View File

@@ -17,10 +17,9 @@
* under the License.
*/
import { Page, Download, Locator } from '@playwright/test';
import { Button, Input, Menu, Tabs } from '../components/core';
import { Page, Download } from '@playwright/test';
import { Menu } from '../components/core';
import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
/**
@@ -34,16 +33,6 @@ export class DashboardPage {
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
// The header-actions-menu is the data-test for the dropdown menu content
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
EDIT_BUTTON: '[data-test="edit-dashboard-button"]',
BUILDER_PANE: '[data-test="dashboard-builder-sidepane"]',
CHARTS_SEARCH: '[data-test="dashboard-charts-filter-search-input"]',
CHART_CARD: '[data-test="chart-card"]',
GRID_CONTENT: '[data-test="grid-content"]',
EMPTY_DROPTARGET: '[data-test="grid-content"] .empty-droptarget',
NEW_COMPONENT: '[data-test="new-component"]',
CHART_HOLDER: '[data-test="dashboard-component-chart-holder"]',
DELETE_COMPONENT: '[data-test="dashboard-delete-component-button"]',
MARKDOWN_EDITOR: '[data-test="dashboard-markdown-editor"]',
} as const;
constructor(page: Page) {
@@ -137,106 +126,4 @@ export class DashboardPage {
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
/**
* Enter dashboard edit mode and wait for the builder side pane to appear.
*/
async enterEditMode(): Promise<void> {
const editButton = new Button(
this.page,
DashboardPage.SELECTORS.EDIT_BUTTON,
);
await editButton.click();
await this.page.waitForSelector(DashboardPage.SELECTORS.BUILDER_PANE, {
state: 'visible',
});
}
/**
* The builder side pane's tab bar (Charts / Layout elements).
*/
private builderTabs(): Tabs {
return new Tabs(
this.page,
this.page
.locator(`${DashboardPage.SELECTORS.BUILDER_PANE} .ant-tabs`)
.first(),
);
}
/**
* Switch the builder side pane to one of its tabs.
* @param tab - 'Charts' (existing slices) or 'Layout elements' (new components)
*/
async openBuilderTab(tab: 'Charts' | 'Layout elements'): Promise<void> {
await this.builderTabs().clickTab(tab);
}
/**
* Locator for chart-holder components currently placed on the grid.
*/
chartHolders(): Locator {
return this.page.locator(DashboardPage.SELECTORS.CHART_HOLDER);
}
/**
* Drag an existing chart from the Charts pane onto the dashboard grid.
* Requires edit mode to be active.
* @param sliceName - The slice name to search for and drag
*/
async addChartByName(sliceName: string): Promise<void> {
await this.openBuilderTab('Charts');
const search = new Input(this.page, DashboardPage.SELECTORS.CHARTS_SEARCH);
await search.fill(sliceName);
const card = this.page
.locator(DashboardPage.SELECTORS.CHART_CARD)
.filter({ hasText: sliceName })
.first();
await card.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, card, this.dropTarget());
}
/**
* Drag a new Layout element (by its label) onto the dashboard grid.
* Requires edit mode to be active.
* @param label - The new-component label, e.g. 'Text / Markdown'
*/
async addLayoutElement(label: string): Promise<void> {
await this.openBuilderTab('Layout elements');
const source = this.page
.locator(DashboardPage.SELECTORS.NEW_COMPONENT)
.filter({ hasText: label })
.first();
await source.waitFor({ state: 'visible' });
await html5DragAndDrop(this.page, source, this.dropTarget());
}
/**
* The grid drop target. Prefers the empty droptarget (empty grid) and falls
* back to the grid content container.
*/
private dropTarget(): Locator {
return this.page.locator(DashboardPage.SELECTORS.EMPTY_DROPTARGET).first();
}
/**
* Hover a placed chart-holder and click its delete button (edit mode).
* @param index - Which chart holder to delete (default 0)
*/
async deleteChartHolder(index = 0): Promise<void> {
const holder = this.chartHolders().nth(index);
await holder.hover();
const deleteButton = new Button(
this.page,
holder.locator(DashboardPage.SELECTORS.DELETE_COMPONENT),
);
await deleteButton.click();
}
/**
* Locator for markdown editor components on the grid.
*/
markdownEditors(): Locator {
return this.page.locator(DashboardPage.SELECTORS.MARKDOWN_EDITOR);
}
}

View File

@@ -1,192 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Dashboard edit-mode component tests — migrated from the deprecated Cypress
* suite (cypress-base/cypress/e2e/dashboard/editmode.test.ts, "Components"
* block). These cover the chart/markdown drag-and-drop workflows that the
* upstream Cypress notes flagged as the one part of edit mode that genuinely
* requires E2E coverage ("Chart drag/drop functionality requires true E2E
* testing"). The grid uses react-dnd with the HTML5 backend, so drags are
* driven by synthetic native drag events (see helpers/dnd.ts).
*
* The 21 skipped "Color consistency" tests from the same Cypress file are NOT
* migrated here: they assert per-series colors by reading an `.nv-legend-symbol`
* SVG `fill` attribute that no longer exists (ECharts renders to <canvas>, which
* is not DOM-inspectable — the upstream FIXME skipped them for exactly this
* reason). The underlying color-precedence logic is covered by Jest/RTL.
*/
import {
testWithAssets,
expect,
type TestAssets,
} from '../../helpers/fixtures';
import { apiPost } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { DashboardPage } from '../../pages/DashboardPage';
import type { Page } from '@playwright/test';
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;
}
/** Create a hermetic chart from birth_names, NOT placed on any dashboard. */
async function createChart(
page: Page,
testAssets: TestAssets,
): Promise<string> {
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
const sliceName = `edit_mode_chart_${Date.now()}_${Math.floor(
performance.now(),
)}`;
const resp = await apiPost(page, 'api/v1/chart/', {
slice_name: sliceName,
viz_type: 'big_number_total',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify({
datasource: `${datasetId}__table`,
viz_type: 'big_number_total',
metric: 'count',
}),
});
expect(resp.ok()).toBe(true);
testAssets.trackChart((await resp.json()).id);
return sliceName;
}
/** Create an empty published dashboard and return its id. */
async function createDashboard(
page: Page,
testAssets: TestAssets,
): Promise<number> {
const resp = await apiPostDashboard(page, {
dashboard_title: `edit_mode_${Date.now()}_${Math.floor(performance.now())}`,
published: true,
});
expect(resp.ok()).toBe(true);
const body = await resp.json();
const id: number = body.result?.id ?? body.id;
testAssets.trackDashboard(id);
return id;
}
testWithAssets(
'edit mode: add a chart to the dashboard via drag-and-drop',
async ({ page, testAssets }) => {
const sliceName = await createChart(page, testAssets);
const dashboardId = await createDashboard(page, testAssets);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await expect(dashboard.chartHolders()).toHaveCount(0);
await dashboard.addChartByName(sliceName);
await expect(dashboard.chartHolders()).toHaveCount(1);
},
);
testWithAssets(
'edit mode: remove an added chart from the dashboard',
async ({ page, testAssets }) => {
const sliceName = await createChart(page, testAssets);
const dashboardId = await createDashboard(page, testAssets);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await dashboard.addChartByName(sliceName);
await expect(dashboard.chartHolders()).toHaveCount(1);
await dashboard.deleteChartHolder();
await expect(dashboard.chartHolders()).toHaveCount(0);
},
);
testWithAssets(
'edit mode: add a markdown component via drag-and-drop',
async ({ page, testAssets }) => {
// Heaviest edit-mode flow (drag + ace edit + commit + mouse resize); give it
// extra headroom so it stays reliable when the suite runs in parallel.
testWithAssets.slow();
const dashboardId = await createDashboard(page, testAssets);
const dashboard = new DashboardPage(page);
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard.enterEditMode();
await dashboard.addLayoutElement('Text / Markdown');
const editor = dashboard.markdownEditors().first();
await expect(editor).toBeVisible();
// Enter edit mode by focusing the component. The markdown enters edit on a
// document-level focus handler attached after mount, so a single early click
// can be missed under load; retry until the ace editor appears. Click the
// rendered "Header 1" heading element specifically (never the trailing
// hyperlink in the default content), so a stray click can't navigate away.
const aceContent = editor.locator('.ace_content');
const heading = editor.locator('h1', { hasText: 'Header 1' });
await expect(async () => {
if (await aceContent.isVisible()) return;
await heading.click();
await expect(aceContent).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 20000 });
await expect(aceContent).toContainText('Header 1');
await expect(aceContent).toContainText('markdown formatting');
// Replace the content and confirm the edit is reflected.
const aceInput = editor.locator('.ace_text-input');
await aceInput.press('ControlOrMeta+a');
await aceInput.press('Delete');
await aceInput.type('Test resize');
await expect(aceContent).toContainText('Test resize');
// Commit by clicking outside the component; the preview keeps the text.
const boxBefore = await editor.boundingBox();
await page.locator('[data-test="editable-title-input"]').first().click();
await expect(editor).toContainText('Test resize');
// Resize via the bottom handle and confirm the component grew taller.
const handle = editor.locator('.resizable-container-handle--bottom').last();
const hb = await handle.boundingBox();
expect(hb).not.toBeNull();
if (hb && boxBefore) {
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
await page.mouse.down();
await page.mouse.move(hb.x + hb.width / 2, hb.y + 150, { steps: 10 });
await page.mouse.up();
const boxAfter = await editor.boundingBox();
expect(boxAfter!.height).toBeGreaterThan(boxBefore.height);
}
},
);

View File

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

View File

@@ -44,3 +44,8 @@ export const FILTER_CONDITION_BODY_INDEX = {
} as const;
export const ROW_NUMBER_COL_ID = '__row_number__';
// Non-enumerable key used to attach a row's basic (increase/decrease) color
// formatter to the row data object so it travels with the row through AG Grid
// client-side sorting (#105973).
export const BASIC_COLOR_FORMATTERS_ROW_KEY = '__basicColorFormatters__';

View File

@@ -24,6 +24,7 @@ import {
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn, ValueRange } from '../types';
import { useIsDark } from '../utils/useTableTheme';
import getRowBasicColorFormatter from '../utils/getRowBasicColorFormatter';
const StyledTotalCell = styled.div`
${() => `
@@ -163,13 +164,13 @@ export const NumericCellRenderer = (
let arrow = '';
let arrowColor = '';
if (hasBasicColorFormatters && col?.metricName) {
arrow =
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
?.mainArrow;
arrowColor =
basicColorFormatters?.[node?.rowIndex as number]?.[
col.metricName
]?.arrowColor?.toLowerCase();
const rowFormatter = getRowBasicColorFormatter(
node,
node?.rowIndex,
basicColorFormatters,
)?.[col.metricName];
arrow = rowFormatter?.mainArrow;
arrowColor = rowFormatter?.arrowColor?.toLowerCase();
}
const alignment =

View File

@@ -46,6 +46,7 @@ import {
} from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
import {
DataColumnMeta,
TableChartProps,
@@ -703,6 +704,23 @@ const transformProps = (
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
// Attach each row's basic (increase/decrease) color formatter to the row data
// object so it travels with the row through AG Grid client-side sorting.
// basicColorFormatters is built in the original query order and was previously
// read positionally by the displayed rowIndex, which applied colors to the
// wrong rows once the table was sorted (#105973). The property is
// non-enumerable so it never leaks into exports, cross-filters or spreads.
if (basicColorFormatters) {
passedData.forEach((row, index) => {
Object.defineProperty(row, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: basicColorFormatters[index],
enumerable: false,
configurable: true,
writable: true,
});
});
}
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData, theme) ?? [];

View File

@@ -24,6 +24,7 @@ import {
} from '@superset-ui/chart-controls';
import { CellClassParams } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn } from '../types';
import getRowBasicColorFormatter from './getRowBasicColorFormatter';
type CellStyleParams = CellClassParams & {
hasColumnColorFormatters: boolean | undefined;
@@ -84,8 +85,11 @@ const getCellStyle = (params: CellStyleParams) => {
col?.metricName &&
node?.rowPinned !== 'bottom'
) {
backgroundColor =
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
backgroundColor = getRowBasicColorFormatter(
node,
rowIndex,
basicColorFormatters,
)?.[col.metricName]?.backgroundColor;
}
const textAlign =

View File

@@ -0,0 +1,51 @@
/**
* 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 { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../consts';
import { BasicColorFormatterType } from '../types';
type RowFormatters = { [key: string]: BasicColorFormatterType };
/**
* Resolves the basic (increase/decrease) color formatters for a given AG Grid
* row node.
*
* The formatter is attached to the row data object itself (see transformProps),
* so it follows the row through client-side sorting. Looking it up positionally
* by the displayed `rowIndex` was wrong once the user sorted the table, because
* the displayed index no longer matched the original data order (#105973).
*
* Falls back to the positional array for safety when no attached formatter is
* present.
*/
export default function getRowBasicColorFormatter(
node: { data?: Record<string, unknown> } | undefined,
rowIndex: number | null | undefined,
basicColorFormatters: RowFormatters[] | undefined,
): RowFormatters | undefined {
const attached = node?.data?.[BASIC_COLOR_FORMATTERS_ROW_KEY] as
| RowFormatters
| undefined;
if (attached) {
return attached;
}
if (rowIndex == null) {
return undefined;
}
return basicColorFormatters?.[rowIndex];
}

View File

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

View File

@@ -0,0 +1,65 @@
/**
* 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 getRowBasicColorFormatter from '../../src/utils/getRowBasicColorFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../../src/consts';
const red = { sales: { backgroundColor: 'red', mainArrow: '↓', arrowColor: 'red' } };
const green = {
sales: { backgroundColor: 'green', mainArrow: '↑', arrowColor: 'green' },
};
// Positional array in the original (unsorted) query order: row 0 -> green, row 1 -> red.
const positional = [green, red] as any;
test('uses the formatter attached to the row, not the displayed rowIndex (#105973)', () => {
// After sorting, the row whose original formatter is `red` is displayed first
// (rowIndex 0). The positional lookup would wrongly return `green`.
const rowData: Record<string, unknown> = { sales: 5 };
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: red,
enumerable: false,
});
const node = { data: rowData };
expect(getRowBasicColorFormatter(node, 0, positional)).toBe(red);
expect(
getRowBasicColorFormatter(node, 0, positional)?.sales.backgroundColor,
).toBe('red');
});
test('falls back to positional lookup when no formatter is attached', () => {
const node = { data: { sales: 5 } };
expect(getRowBasicColorFormatter(node, 1, positional)).toBe(red);
});
test('returns undefined when nothing matches', () => {
expect(getRowBasicColorFormatter(undefined, null, positional)).toBeUndefined();
expect(
getRowBasicColorFormatter({ data: {} }, null, positional),
).toBeUndefined();
});
test('attached formatter is non-enumerable so it does not leak into the row', () => {
const rowData: Record<string, unknown> = { sales: 5 };
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: green,
enumerable: false,
});
expect(Object.keys(rowData)).toEqual(['sales']);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,277 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,133 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,257 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,209 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,68 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,82 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview 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,6 +254,33 @@ 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,7 +19,6 @@
import type { editors } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider;
@@ -28,8 +27,45 @@ 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.
@@ -47,9 +83,15 @@ class EditorProviders {
*/
private languageToProvider: Map<EditorLanguage, string> = new Map();
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
/**
* Event emitter for provider registration events.
*/
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
/**
* Event emitter for provider unregistration events.
*/
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set();
@@ -184,11 +226,8 @@ class EditorProviders {
* @param listener The listener function.
* @returns A Disposable to unsubscribe.
*/
public onDidRegister(
listener: Listener<EditorRegisteredEvent>,
thisArgs?: unknown,
): Disposable {
return this.registerEmitter.subscribe(listener, thisArgs);
public onDidRegister(listener: Listener<EditorRegisteredEvent>): Disposable {
return this.registerEmitter.subscribe(listener);
}
/**
@@ -198,9 +237,8 @@ class EditorProviders {
*/
public onDidUnregister(
listener: Listener<EditorUnregisteredEvent>,
thisArgs?: unknown,
): Disposable {
return this.unregisterEmitter.subscribe(listener, thisArgs);
return this.unregisterEmitter.subscribe(listener);
}
/**
@@ -210,8 +248,6 @@ class EditorProviders {
this.providers.clear();
this.languageToProvider.clear();
this.syncListeners.clear();
this.registerEmitter = createEventEmitter<EditorRegisteredEvent>();
this.unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
}
}

View File

@@ -18,39 +18,130 @@
*/
/**
* @fileoverview Host implementation of the `editors` contribution type.
* @fileoverview Implementation of the editors API for Superset.
*
* 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.
* This module provides the runtime implementation of the editor registration
* and resolution functions declared in the API types.
*/
import { useSyncExternalStore } from 'react';
import { editors as editorsApi } from '@apache-superset/core';
import { Disposable } from '../models';
import EditorProviders from './EditorProviders';
export type { EditorHostProps } from './EditorHost';
export { default as EditorHost } from './EditorHost';
export { default as AceEditorProvider } from './AceEditorProvider';
type EditorLanguage = editorsApi.EditorLanguage;
type Editor = editorsApi.Editor;
type EditorProvider = editorsApi.EditorProvider;
type EditorComponent = editorsApi.EditorComponent;
type EditorRegisteredEvent = editorsApi.EditorRegisteredEvent;
type EditorUnregisteredEvent = editorsApi.EditorUnregisteredEvent;
const provider = EditorProviders.getInstance();
/**
* Register an editor provider as a module-level side effect.
* Takes the editor descriptor directly rather than looking it up
* from a manifest by ID.
*
* @param editor The editor descriptor.
* @param component The React component implementing the editor.
* @returns A Disposable to unregister the provider.
*/
export const registerEditor = (
editor: Editor,
component: EditorComponent,
): Disposable => {
const providers = EditorProviders.getInstance();
return providers.registerProvider(editor, component);
};
export const useEditor = (language: editorsApi.EditorLanguage) =>
useSyncExternalStore(
provider.subscribe,
() => provider.getProvider(language),
/**
* 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),
() => undefined,
);
export const editors: typeof editorsApi = {
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),
};
/**
* Editors API object for use in the extension system.
*/
export const editors: typeof editorsApi = {
registerEditor,
getEditor,
hasEditor,
getAllEditors,
onDidRegisterEditor,
onDidUnregisterEditor,
};
export { EditorProviders };
// Component exports
export { default as EditorHost } from './EditorHost';
export type { EditorHostProps } from './EditorHost';
export { default as AceEditorProvider } from './AceEditorProvider';

View File

@@ -27,13 +27,11 @@ 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,7 +27,6 @@
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;
@@ -48,19 +47,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
const menuCache = new Map<string, Menu | undefined>();
const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
registerEmitter.fire(event);
registerListeners.forEach(l => l(event));
};
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
unregisterEmitter.fire(event);
unregisterListeners.forEach(l => l(event));
};
const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -118,14 +117,16 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void,
thisArgs?: unknown,
): Disposable => registerEmitter.subscribe(listener, thisArgs);
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
(
listener: (e: MenuItemUnregisteredEvent) => void,
thisArgs?: unknown,
): Disposable => unregisterEmitter.subscribe(listener, thisArgs);
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
export const menus: typeof menusApi = {
registerMenuItem,

View File

@@ -1,124 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// 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

@@ -1,94 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @fileoverview 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 '../storeUtils';
import { createActionListener } from '../utils';
import {
Panel,
Tab,

View File

@@ -1,48 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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,54 +17,33 @@
* 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';
type Listener<T> = (e: T) => unknown;
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;
/** 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 {
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);
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);
}
},
});
return {
dispose: () => {
unsubscribe();
},
subscribe,
getCurrent: () => current,
};
}

View File

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

View File

@@ -481,7 +481,7 @@ const Chart = (props: ChartProps) => {
(formData as JsonObject).dashboardId = dashboardInfo.id;
const exportTable = useCallback(
async (format: string, isFullCSV: boolean, isPivot = false) => {
(format: string, isFullCSV: boolean, isPivot = false) => {
const logAction =
format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
@@ -556,48 +556,24 @@ const Chart = (props: ChartProps) => {
}
: baseOwnState;
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);
}
}
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,
});
},
[
sliceSliceId,
@@ -609,7 +585,6 @@ const Chart = (props: ChartProps) => {
chartState,
props.id,
boundActionCreators.logEvent,
boundActionCreators.addDangerToast,
queriesResponse,
startExport,
resetExport,

View File

@@ -42,7 +42,6 @@ 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 {
@@ -338,7 +337,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
// Update document title when dashboard title changes
useEffect(() => {
if (pageTitle) {
document.title = sanitizeDocumentTitle(pageTitle);
document.title = pageTitle;
}
}, [pageTitle]);

View File

@@ -66,7 +66,6 @@ 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';
@@ -398,7 +397,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
// Update document title when slice name changes
useEffect(() => {
if (props.sliceName) {
document.title = sanitizeDocumentTitle(props.sliceName);
document.title = props.sliceName;
}
}, [props.sliceName]);

View File

@@ -339,34 +339,7 @@ export const useExploreAdditionalActionsMenu = (
}
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
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 () => {
const exportCSV = useCallback(() => {
if (!canDownloadCSV) return null;
// Determine row count for streaming threshold check
@@ -405,31 +378,26 @@ export const useExploreAdditionalActionsMenu = (
filename = `${safeChartName}${timestamp}.csv`;
}
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',
});
}
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',
});
}
: null,
});
} catch (error) {
handleExportError(error);
}
return null;
}
: null,
});
}, [
canDownloadCSV,
latestQueryFormData,
@@ -438,59 +406,46 @@ export const useExploreAdditionalActionsMenu = (
streamingThreshold,
slice,
startExport,
handleExportError,
]);
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 exportCSVPivoted = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'post_processed',
resultFormat: 'csv',
})
: 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 exportJson = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'json',
})
: 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 exportExcel = useCallback(
() =>
canDownloadCSV
? exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'xlsx',
})
: null,
[canDownloadCSV, latestQueryFormData, ownState],
);
const copyLink = useCallback(async () => {
try {
@@ -850,7 +805,7 @@ export const useExploreAdditionalActionsMenu = (
label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: async () => {
onClick: () => {
// 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 (
@@ -865,16 +820,12 @@ export const useExploreAdditionalActionsMenu = (
slice?.slice_name || 'current_view',
);
} else {
try {
await exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'csv',
});
} catch (error) {
handleExportError(error);
}
exportChart({
formData: latestQueryFormData as QueryFormData,
ownState,
resultType: 'results',
resultFormat: 'csv',
});
}
setIsDropdownVisible(false);
dispatch(
@@ -1107,7 +1058,6 @@ export const useExploreAdditionalActionsMenu = (
exportCSVPivoted,
exportExcel,
exportJson,
handleExportError,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,

View File

@@ -1,150 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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,11 +18,6 @@
*/
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),
@@ -32,7 +27,6 @@ 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: {} }),
@@ -47,14 +41,6 @@ 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 = {
@@ -127,24 +113,22 @@ 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.postBlob adds appRoot
// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot
// internally via getUrl(), so the URL passed must NOT already be prefixed.
test('exportChart v1 API calls postBlob with unprefixed URL when app root is configured', async () => {
test('exportChart v1 API calls postForm 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.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postBlob.mock.calls[0];
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.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 () => {
@@ -256,10 +240,9 @@ 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 postBlob with relative URL', async () => {
test('exportChart legacy API calls postForm 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' }),
@@ -276,11 +259,10 @@ test('exportChart legacy API calls postBlob with relative URL', async () => {
resultType: 'full',
});
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postBlob.mock.calls[0];
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.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 () => {
@@ -307,187 +289,3 @@ 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,7 +34,6 @@ 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,
@@ -399,54 +398,11 @@ export const exportChart = async ({
exportSource: 'chart',
});
} else {
// Use AJAX blob download instead of form submission to enable error handling.
// SupersetClient.postBlob calls getUrl({ endpoint }) internally, which prepends
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
// appRoot — so the URL must NOT be pre-prefixed here.
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;
}
SupersetClient.postForm(url as string, {
form_data: safeStringify(payload),
});
}
};

View File

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

View File

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

View File

@@ -17,32 +17,41 @@
* 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';
import 'src/extensions/Namespaces';
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;
};
}
}
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
useNavigationTracker();
const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId,
);
@@ -50,19 +59,15 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
useEffect(() => {
if (userId == null) return;
// 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.
// Provide the implementations for @apache-superset/core
window.superset = {
...supersetCore,
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
};

View File

@@ -1,60 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* 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, CACHE_KEY } from '@superset-ui/core';
import { isFeatureEnabled, FeatureFlag } 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,35 +401,17 @@ test('Logs out and clears local storage item redux', async () => {
expect(localStorage.getItem('redux')).not.toBeNull();
expect(sessionStorage.getItem('login_attempted')).not.toBeNull();
// 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;
await userEvent.hover(await screen.findByText(/Settings/i));
try {
await userEvent.hover(await screen.findByText(/Settings/i));
// Simulate user clicking the logout button
const logoutButton = await screen.findByText('Logout');
await userEvent.click(logoutButton);
// 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;
}
}
// Wait for local and session storage to be cleared
await waitFor(() => {
expect(localStorage.getItem('redux')).toBeNull();
expect(sessionStorage.getItem('login_attempted')).toBeNull();
});
});
test('shows logout button when not embedded', async () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -762,7 +762,7 @@ function DashboardList(props: DashboardListProps) {
name: t('Dashboard'),
buttonStyle: 'primary',
onClick: () => {
navigateTo('/dashboard/new/', { assign: true });
navigateTo('/dashboard/new', { assign: true });
},
});
}

View File

@@ -281,141 +281,6 @@ test('clicking export calls handleResourceExport with dataset ID', async () => {
});
});
test('bulk export of only semantic-view rows shows danger toast and skips export', async () => {
const semanticView = {
...mockDatasets[0],
id: 100,
table_name: 'sl_only_view',
kind: 'semantic_view',
};
const addDangerToast = jest.fn();
mockDatasetListEndpoints({ result: [semanticView], count: 1 });
renderDatasetList(mockAdminUser, {
addDangerToast,
addSuccessToast: jest.fn(),
});
await waitFor(() => {
expect(screen.getByText(semanticView.table_name)).toBeInTheDocument();
});
const bulkSelectButton = screen.getByRole('button', {
name: /bulk select/i,
});
await userEvent.click(bulkSelectButton);
const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
const table = screen.getByTestId('listview-table');
await within(table).findAllByRole('checkbox');
const row = (await within(table).findByText(semanticView.table_name)).closest(
'tr',
);
expect(row).toBeInTheDocument();
await userEvent.click(within(row!).getByRole('checkbox'));
await waitFor(() => {
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
/1 Selected/i,
);
});
const bulkExportButton = await within(bulkSelectControls).findByRole(
'button',
{ name: /^export$/i },
);
await userEvent.click(bulkExportButton);
await waitFor(() => {
expect(addDangerToast).toHaveBeenCalled();
});
expect(String(addDangerToast.mock.calls[0][0])).toMatch(/semantic view/i);
expect(mockHandleResourceExport).not.toHaveBeenCalled();
});
test('bulk export of mixed datasets and semantic views warns and exports only datasets', async () => {
const physicalDataset = {
...mockDatasets[0],
id: 1,
table_name: 'physical_table',
kind: 'physical',
};
const semanticView = {
...mockDatasets[1],
id: 100,
table_name: 'sl_mixed_view',
kind: 'semantic_view',
};
const addDangerToast = jest.fn();
mockDatasetListEndpoints({
result: [physicalDataset, semanticView],
count: 2,
});
renderDatasetList(mockAdminUser, {
addDangerToast,
addSuccessToast: jest.fn(),
});
await waitFor(() => {
expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument();
expect(screen.getByText(semanticView.table_name)).toBeInTheDocument();
});
const bulkSelectButton = screen.getByRole('button', {
name: /bulk select/i,
});
await userEvent.click(bulkSelectButton);
const bulkSelectControls = await screen.findByTestId('bulk-select-controls');
const table = screen.getByTestId('listview-table');
await within(table).findAllByRole('checkbox');
const physicalRow = (
await within(table).findByText(physicalDataset.table_name)
).closest('tr');
await userEvent.click(within(physicalRow!).getByRole('checkbox'));
await waitFor(() => {
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
/1 Selected/i,
);
});
const semanticRow = (
await within(table).findByText(semanticView.table_name)
).closest('tr');
await userEvent.click(within(semanticRow!).getByRole('checkbox'));
await waitFor(() => {
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
/2 Selected/i,
);
});
const bulkExportButton = await within(bulkSelectControls).findByRole(
'button',
{ name: /^export$/i },
);
await userEvent.click(bulkExportButton);
await waitFor(() => {
expect(addDangerToast).toHaveBeenCalled();
});
expect(String(addDangerToast.mock.calls[0][0])).toMatch(/skipped/i);
await waitFor(() => {
expect(mockHandleResourceExport).toHaveBeenCalledWith(
'dataset',
[physicalDataset.id],
expect.any(Function),
);
});
});
test('clicking duplicate opens modal and submits duplicate request', async () => {
const datasetToDuplicate = {
...mockDatasets[1],

View File

@@ -610,39 +610,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const handleBulkDatasetExport = useCallback(
async (datasetsToExport: Dataset[]) => {
// The combined Datasources list mixes regular datasets (SqlaTable) with
// semantic views, which live in their own table with an independent id
// sequence. The dataset export endpoint looks rows up by bare numeric
// id against ``tables`` only — passing a semantic-view id silently
// returns whatever SqlaTable happens to share that id. Until a proper
// semantic-view export path exists, partition the selection and only
// ship dataset ids over to ``/api/v1/dataset/export/``.
const datasetRows = datasetsToExport.filter(
({ kind }) => kind !== 'semantic_view',
);
const semanticViewCount = datasetsToExport.length - datasetRows.length;
if (datasetRows.length === 0) {
addDangerToast(
t(
'Exporting semantic views is not supported yet. ' +
'Deselect the semantic-view rows and try again.',
),
);
return;
}
if (semanticViewCount > 0) {
addDangerToast(
t(
'Exporting semantic views is not supported yet — ' +
'%s semantic-view row(s) were skipped.',
semanticViewCount,
),
);
}
const ids = datasetRows.map(({ id }) => id);
const ids = datasetsToExport.map(({ id }) => id);
setPreparingExport(true);
try {
await handleResourceExport('dataset', ids, () => {

View File

@@ -19,7 +19,7 @@
import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { parse as parseContentDisposition } from 'content-disposition';
import handleResourceExport, { getFilenameFromResponse } from './export';
import handleResourceExport from './export';
// Mock dependencies
jest.mock('@superset-ui/core', () => ({
@@ -454,59 +454,3 @@ test.each(doublePrefixTestCases)(
(ensureAppRoot as jest.Mock).mockImplementation((path: string) => path);
},
);
test('getFilenameFromResponse returns filename from Content-Disposition', () => {
(parseContentDisposition as jest.Mock).mockReturnValueOnce({
parameters: { filename: 'server_export.csv' },
});
const response = {
headers: new Headers({
'Content-Disposition': 'attachment; filename="server_export.csv"',
}),
} as Response;
expect(getFilenameFromResponse(response, 'fallback.csv')).toBe(
'server_export.csv',
);
});
test('getFilenameFromResponse uses zip extension when Content-Type is zip', () => {
const response = {
headers: new Headers({
'Content-Type': 'application/zip',
}),
} as Response;
expect(getFilenameFromResponse(response, 'chart_export_2025.csv')).toBe(
'chart_export_2025.zip',
);
});
test('getFilenameFromResponse returns fallback when no headers match', () => {
const response = {
headers: new Headers(),
} as Response;
expect(getFilenameFromResponse(response, 'chart_export_2025.csv')).toBe(
'chart_export_2025.csv',
);
});
test('getFilenameFromResponse falls back when Content-Disposition parsing fails', () => {
(parseContentDisposition as jest.Mock).mockImplementationOnce(() => {
throw new Error('Parse error');
});
const response = {
headers: new Headers({
'Content-Disposition': 'invalid',
}),
} as Response;
expect(getFilenameFromResponse(response, 'fallback.csv')).toBe(
'fallback.csv',
);
expect(logging.warn).toHaveBeenCalledWith(
'Failed to parse Content-Disposition header:',
expect.any(Error),
);
});

View File

@@ -29,35 +29,7 @@ const MAX_BLOB_SIZE = 100 * 1024 * 1024;
* @param blob - The blob to download
* @param fileName - The filename to use for the download
*/
/**
* Derives a download filename from response headers, falling back when absent.
*/
export function getFilenameFromResponse(
response: Response,
fallback: string,
): string {
const disposition = response.headers.get('Content-Disposition');
if (disposition) {
try {
const parsed = parseContentDisposition(disposition);
if (parsed?.parameters?.filename) {
return parsed.parameters.filename;
}
} catch (error) {
logging.warn('Failed to parse Content-Disposition header:', error);
}
}
const contentType = response.headers.get('Content-Type') ?? '';
if (contentType.includes('zip')) {
const base = fallback.replace(/\.[^.]+$/, '');
return `${base}.zip`;
}
return fallback;
}
export function downloadBlob(blob: Blob, fileName: string): void {
function downloadBlob(blob: Blob, fileName: string): void {
const url = window.URL.createObjectURL(blob);
try {
const a = document.createElement('a');

View File

@@ -57,7 +57,6 @@ export enum LocalStorageKeys {
DashboardExploreContext = 'dashboard__explore_context',
DashboardEditorShowOnlyMyCharts = 'dashboard__editor_show_only_my_charts',
CommonResizableSidebarWidths = 'common__resizable_sidebar_widths',
ChatState = 'chat__state',
}
export type LocalStorageValues = {
@@ -79,7 +78,6 @@ export type LocalStorageValues = {
dashboard__explore_context: Record<string, DashboardContextForExplore>;
dashboard__editor_show_only_my_charts: boolean;
common__resizable_sidebar_widths: Record<string, number>;
chat__state: { open: boolean; mode: string };
};
/*

View File

@@ -1,35 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { sanitizeDocumentTitle } from './sanitizeDocumentTitle';
test('removes all C0 control characters including tab/LF/CR', () => {
expect(sanitizeDocumentTitle('a\x08b')).toBe('ab');
expect(sanitizeDocumentTitle('x\x09y')).toBe('xy');
expect(sanitizeDocumentTitle('x\ny')).toBe('xy');
expect(sanitizeDocumentTitle('x\ry')).toBe('xy');
});
test('removes DEL and C1 controls', () => {
expect(sanitizeDocumentTitle('a\x7Fb')).toBe('ab');
expect(sanitizeDocumentTitle('a\x9Fb')).toBe('ab');
});
test('leaves normal text unchanged', () => {
expect(sanitizeDocumentTitle('Dashboard 你好')).toBe('Dashboard 你好');
});

View File

@@ -1,27 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Strip all C0/C1 control characters (U+0000U+001F, U+007FU+009F).
* Headless browsers (Playwright/Chromium) can hang or crash when document.title
* contains characters such as U+0008 (backspace).
*/
export function sanitizeDocumentTitle(title: string): string {
return title.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
}

View File

@@ -25,8 +25,8 @@ import {
useLocation,
} from 'react-router-dom';
import { bindActionCreators } from 'redux';
import { css, useTheme } from '@apache-superset/core/theme';
import { Flex, Layout, Loading } from '@superset-ui/core/components';
import { css } from '@apache-superset/core/theme';
import { Layout, Loading } from '@superset-ui/core/components';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { ErrorBoundary } from 'src/components';
import Menu from 'src/features/home/Menu';
@@ -39,12 +39,7 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { isUser } from 'src/types/bootstrapTypes';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
import { Splitter } from 'src/components/Splitter';
import { ChatFloatingHost, ChatPanelHost, useChat } from 'src/core/chat';
import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -84,139 +79,42 @@ const LocationPathnameLogger = () => {
return <></>;
};
const CHAT_PANEL_DEFAULT_WIDTH = 400;
const CHAT_PANEL_MIN_WIDTH = 280;
const RouteSwitch = () => {
const theme = useTheme();
return (
<Switch>
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<ErrorBoundary
css={css`
margin: ${theme.sizeUnit * 4}px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</Suspense>
</Route>
))}
<Redirect from="/" to="/superset/welcome/" exact />
</Switch>
);
};
const layoutCss = css`
flex: 1;
min-height: 0;
overflow: hidden;
`;
const contentCss = css`
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
position: relative;
`;
/**
* Renders the main content area. When the chat panel is open in panel mode,
* wraps <Layout> and <ChatPanelContent> in a Splitter so they sit side-by-side
* with a lazy drag bar (blue preview line, resize committed on mouseup).
* The full <Layout> tree lives inside the first panel so its internal flex
* context is preserved — SQL Lab, Explore, and other pages are unaffected.
*/
const AppContent = () => {
const isAuthenticated =
isUser(bootstrapData.user) && !bootstrapData.user.isAnonymous;
const chatExtensionsEnabled =
isFeatureEnabled(FeatureFlag.EnableExtensions) && isAuthenticated;
const { open: panelOpen, mode, chat } = useChat();
const hasChatExtension = chatExtensionsEnabled && !!chat;
const isPanelOpen = hasChatExtension && mode === 'panel' && panelOpen;
const [storedWidth, setStoredWidth] = useStoredSidebarWidth(
'chat:panel',
CHAT_PANEL_DEFAULT_WIDTH,
);
const layoutContent = (
<Layout css={layoutCss}>
<Layout.Content css={contentCss}>
<RouteSwitch />
</Layout.Content>
</Layout>
);
if (!isPanelOpen) {
return (
<>
{layoutContent}
{hasChatExtension && <ChatFloatingHost />}
</>
);
}
return (
<Splitter
lazy
onResizeEnd={sizes => {
const chatWidth = sizes[sizes.length - 1];
if (
typeof chatWidth === 'number' &&
chatWidth >= CHAT_PANEL_MIN_WIDTH
) {
setStoredWidth(chatWidth);
}
}}
css={css`
flex: 1;
min-height: 0;
overflow: hidden;
/*
* Splitter.Panel is not a flex container by default, so flex:1 on
* children (Layout, ChatPanelHost) has no height effect and
* panels collapse. Making them flex columns lets children fill them.
*/
& > .ant-splitter-panel {
display: flex !important;
flex-direction: column;
}
`}
>
<Splitter.Panel>{layoutContent}</Splitter.Panel>
<Splitter.Panel size={storedWidth} min={CHAT_PANEL_MIN_WIDTH}>
<ChatPanelHost />
</Splitter.Panel>
</Splitter>
);
};
const App = () => (
<Router basename={applicationRoot()}>
<ScrollToTop />
<LocationPathnameLogger />
<RootContextProviders>
<Flex
vertical
css={css`
height: 100vh;
overflow: hidden;
`}
>
<Menu
data={bootstrapData.common.menu_data}
isFrontendRoute={isFrontendRoute}
/>
<ExtensionsStartup>
<AppContent />
</ExtensionsStartup>
</Flex>
<Menu
data={bootstrapData.common.menu_data}
isFrontendRoute={isFrontendRoute}
/>
<ExtensionsStartup>
<Switch>
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<Layout>
<Layout.Content
css={css`
display: flex;
flex-direction: column;
`}
>
<ErrorBoundary
css={css`
margin: 16px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</Layout.Content>
</Layout>
</Suspense>
</Route>
))}
<Redirect from="/" to="/superset/welcome/" exact />
</Switch>
</ExtensionsStartup>
<ToastContainer />
</RootContextProviders>
</Router>

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