mirror of
https://github.com/apache/superset.git
synced 2026-06-27 18:35:32 +00:00
Compare commits
67 Commits
fix/105973
...
enxdev/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503f46836e | ||
|
|
9a11c15a33 | ||
|
|
615eaa110b | ||
|
|
a90c8e0347 | ||
|
|
fe2424ec14 | ||
|
|
b4f43bd7e0 | ||
|
|
2b25345ed9 | ||
|
|
e0f3f93cd4 | ||
|
|
0667ba6097 | ||
|
|
81f7e42f4e | ||
|
|
0fd244b5c6 | ||
|
|
1f16d10cbf | ||
|
|
4f4663418f | ||
|
|
4519a5c52d | ||
|
|
da9fbadaf6 | ||
|
|
f40abbbefd | ||
|
|
6166af3c3c | ||
|
|
076d8c1508 | ||
|
|
518cadd907 | ||
|
|
b955c90de4 | ||
|
|
7363774869 | ||
|
|
6f12d17313 | ||
|
|
09c7ba14df | ||
|
|
3ec4bd23c4 | ||
|
|
f6ce105450 | ||
|
|
7bb4e82a82 | ||
|
|
2d78a8733c | ||
|
|
3261d10270 | ||
|
|
556b336431 | ||
|
|
7136fb22ba | ||
|
|
36126d9e2f | ||
|
|
82189e06e3 | ||
|
|
f35638967c | ||
|
|
cba0b99d20 | ||
|
|
5abdd4bceb | ||
|
|
af961ce252 | ||
|
|
baf701c022 | ||
|
|
ee3f8107f9 | ||
|
|
9f7624cdc6 | ||
|
|
f846e106b8 | ||
|
|
3e26f0218f | ||
|
|
864b27d0e3 | ||
|
|
86b3d8922c | ||
|
|
9651d0e673 | ||
|
|
15ec3bbb87 | ||
|
|
e4f8472e2e | ||
|
|
3a52d609c9 | ||
|
|
edc8e4b3ab | ||
|
|
f6ac345ef3 | ||
|
|
d036ef4455 | ||
|
|
6c69cc23ea | ||
|
|
15b28631bf | ||
|
|
e7c9cf0d04 | ||
|
|
2f980320b6 | ||
|
|
d1ec3ebb40 | ||
|
|
13ed9b5bad | ||
|
|
0bfaf3c50e | ||
|
|
055fa360bb | ||
|
|
7d53e4d708 | ||
|
|
c0be0485b3 | ||
|
|
84c228e28b | ||
|
|
899ecf8214 | ||
|
|
4e156dc41e | ||
|
|
2df224370e | ||
|
|
a44b8a6cf0 | ||
|
|
ad92ec683b | ||
|
|
6aef573304 |
@@ -24,6 +24,14 @@ 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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -161,6 +161,7 @@ Here's the documentation section how how to set up Talisman: https://superset.ap
|
||||
|
||||
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
|
||||
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
|
||||
- [ ] Rotate the other security-critical secrets (guest-token and async-query JWT secrets, SMTP and database credentials) on the cadence in Appendix C, and after any potential security incident.
|
||||
- [ ] Conduct quarterly access reviews for all users.
|
||||
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
|
||||
|
||||
@@ -173,6 +174,24 @@ Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is manda
|
||||
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
|
||||
https://superset.apache.org/admin-docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
|
||||
|
||||
### **Appendix C: Secrets Register and Rotation Schedule**
|
||||
|
||||
`SUPERSET_SECRET_KEY` is not the only security-critical secret in a Superset deployment. Maintain an inventory of all such secrets, store each in a secrets manager (not in `superset_config.py` or version control), assign an owner, and rotate them on a defined cadence as well as after any suspected compromise.
|
||||
|
||||
| Secret | Purpose | Risk if leaked | Suggested rotation |
|
||||
|---|---|---|---|
|
||||
| `SUPERSET_SECRET_KEY` | Signs session cookies; key material for encrypting stored DB credentials (Fernet/AES) | Forged sessions (auth bypass / privilege escalation); decryption of exfiltrated metadata-DB secrets | Quarterly + post-incident |
|
||||
| `GUEST_TOKEN_JWT_SECRET` | Signs embedded-dashboard guest tokens | Forged guest tokens → unauthorized dashboard/data access | Quarterly + post-incident |
|
||||
| `GLOBAL_ASYNC_QUERIES_JWT_SECRET` | Signs the async-query channel JWT | Forged async-query tokens | Quarterly + post-incident |
|
||||
| SMTP password | Outbound email for alerts & reports | Email relay abuse / spoofing | Per organizational policy + post-incident |
|
||||
| Database connection passwords | Access to analytical databases and the metadata DB | Direct database access | Per organizational policy + post-incident |
|
||||
|
||||
Notes:
|
||||
|
||||
- Rotating `GUEST_TOKEN_JWT_SECRET` or `GLOBAL_ASYNC_QUERIES_JWT_SECRET` invalidates outstanding tokens of that type; schedule rotations accordingly.
|
||||
- After a suspected compromise, rotate **all** of the above, not only `SUPERSET_SECRET_KEY`.
|
||||
- Keep the register under change control so new secrets introduced by future features are added to the rotation schedule.
|
||||
|
||||
:::resources
|
||||
- [Blog: Running Apache Superset on the Open Internet](https://preset.io/blog/running-apache-superset-on-the-open-internet-a-report-from-the-fireline/)
|
||||
- [Blog: How Security Vulnerabilities are Reported & Handled in Apache Superset](https://preset.io/blog/how-security-vulnerabilities-are-reported-and-handled-in-apache-superset/)
|
||||
|
||||
@@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface
|
||||
|
||||
Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application.
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
```typescript
|
||||
import { views } from '@apache-superset/core';
|
||||
import MyPanel from './MyPanel';
|
||||
|
||||
views.registerView(
|
||||
{ id: 'my-extension.main', name: 'My Panel Name' },
|
||||
'sqllab.panels',
|
||||
() => <MyPanel />,
|
||||
MyPanel,
|
||||
);
|
||||
```
|
||||
|
||||
@@ -112,6 +111,24 @@ editors.registerEditor(
|
||||
|
||||
See [Editors Extension Point](./extension-points/editors.md) for implementation details.
|
||||
|
||||
### Chat
|
||||
|
||||
Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads.
|
||||
|
||||
```tsx
|
||||
import { chat } from '@apache-superset/core';
|
||||
import ChatTrigger from './ChatTrigger';
|
||||
import ChatPanel from './ChatPanel';
|
||||
|
||||
chat.registerChat(
|
||||
{ id: 'my-org.my-chat', name: 'My Chat' },
|
||||
ChatTrigger,
|
||||
ChatPanel,
|
||||
);
|
||||
```
|
||||
|
||||
See [Chat](./extension-points/chat.md) for implementation details.
|
||||
|
||||
## Backend
|
||||
|
||||
Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file.
|
||||
|
||||
141
docs/developer_docs/extensions/extension-points/chat.md
Normal file
141
docs/developer_docs/extensions/extension-points/chat.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Chat
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Chat Contributions
|
||||
|
||||
Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components.
|
||||
|
||||
## Overview
|
||||
|
||||
A chat registration consists of two React components:
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. |
|
||||
| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. |
|
||||
|
||||
## Display Modes
|
||||
|
||||
The host supports two display modes, switchable by the user or the extension at runtime:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `floating` | Panel floats above page content, anchored to the bottom-right corner. |
|
||||
| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. |
|
||||
|
||||
The user's last selected mode and open/closed state are persisted across page reloads.
|
||||
|
||||
## Registering a Chat
|
||||
|
||||
Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory:
|
||||
|
||||
```tsx
|
||||
import { chat } from '@apache-superset/core';
|
||||
import ChatTrigger from './ChatTrigger';
|
||||
import ChatPanel from './ChatPanel';
|
||||
|
||||
chat.registerChat(
|
||||
{ id: 'my-org.my-chat', name: 'My Chat' },
|
||||
ChatTrigger,
|
||||
ChatPanel,
|
||||
);
|
||||
```
|
||||
|
||||
Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged.
|
||||
|
||||
## Opening and Closing the Chat
|
||||
|
||||
The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility:
|
||||
|
||||
```tsx
|
||||
import { chat } from '@apache-superset/core';
|
||||
|
||||
export default function ChatTrigger() {
|
||||
return (
|
||||
<button onClick={() => (chat.isOpen() ? chat.close() : chat.open())}>
|
||||
💬
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can also subscribe to open/close events from any component:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const { dispose } = chat.onDidOpen(() => console.log('chat opened'));
|
||||
return dispose;
|
||||
}, []);
|
||||
```
|
||||
|
||||
## Changing the Display Mode
|
||||
|
||||
Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user):
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { chat } from '@apache-superset/core';
|
||||
|
||||
export default function ChatPanel() {
|
||||
const [mode, setMode] = useState(chat.getDisplayMode());
|
||||
|
||||
useEffect(() => {
|
||||
const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m));
|
||||
return dispose;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ height: mode === 'panel' ? '100%' : '80vh' }}>
|
||||
<button onClick={() => chat.setDisplayMode(mode === 'panel' ? 'floating' : 'panel')}>
|
||||
{mode === 'panel' ? 'Float' : 'Dock'}
|
||||
</button>
|
||||
{/* message list and input */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Chat API Reference
|
||||
|
||||
All methods are available on the `chat` namespace from `@apache-superset/core`:
|
||||
|
||||
| Method / Event | Description |
|
||||
|----------------|-------------|
|
||||
| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. |
|
||||
| `open()` | Open the chat panel. No-op if already open or no registration. |
|
||||
| `close()` | Close the chat panel. |
|
||||
| `isOpen()` | Returns `true` if the panel is currently open. |
|
||||
| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). |
|
||||
| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. |
|
||||
| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. |
|
||||
| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. |
|
||||
| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. |
|
||||
| `onDidRegisterChat(listener)` | Subscribe to registration events. |
|
||||
| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. |
|
||||
| `onDidResizePanel(listener)` | Subscribe to panel resize events (panel mode only). Not all hosts provide a resizer — do not rely on this firing. Returns a `Disposable`. |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Contribution Types](../contribution-types.md)** — Explore other contribution types
|
||||
- **[Development](../development.md)** — Set up your development environment
|
||||
@@ -47,6 +47,8 @@ module.exports = {
|
||||
collapsed: true,
|
||||
items: [
|
||||
'extensions/extension-points/sqllab',
|
||||
'extensions/extension-points/editors',
|
||||
'extensions/extension-points/chat',
|
||||
],
|
||||
},
|
||||
'extensions/development',
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.41",
|
||||
"antd": "^6.4.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
|
||||
@@ -5698,10 +5698,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.37, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.37"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz#3e636475b6b293244e2b23e2c71a2ab9d9e6ba7d"
|
||||
integrity sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==
|
||||
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.38"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
|
||||
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
|
||||
@@ -315,7 +315,7 @@ pygeohash==3.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
pygments==2.20.0
|
||||
# via rich
|
||||
pyjwt==2.12.0
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -769,7 +769,7 @@ pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==5.1.2
|
||||
# via apache-superset
|
||||
pyjwt==2.12.0
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -63,56 +63,57 @@ describe('Add database', () => {
|
||||
it('show error alerts on dynamic form for bad host', () => {
|
||||
cy.get('.preferred > :nth-child(1)').click();
|
||||
|
||||
cy.get('input[name="host"]').type('badhost', { force: true });
|
||||
cy.get('input[name="port"]').type('5432', { force: true });
|
||||
cy.get('input[name="username"]').type('testusername', { force: true });
|
||||
cy.get('input[name="database"]').type('testdb', { force: true });
|
||||
cy.get('input[name="password"]').type('testpass', { force: true });
|
||||
|
||||
cy.get('body').click(0, 0);
|
||||
|
||||
cy.get('input[name="host"]').type('badhost', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="port"]').type('5432', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="username"]')
|
||||
.type('testusername', { force: true })
|
||||
.blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
|
||||
cy.getBySel('btn-submit-connection').should('not.be.disabled');
|
||||
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
|
||||
'not.be.disabled',
|
||||
);
|
||||
cy.getBySel('btn-submit-connection').click({ force: true });
|
||||
|
||||
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
|
||||
cy.wait('@createDb', { timeout: 60000 }).then(() => {
|
||||
cy.contains(
|
||||
'.ant-form-item-explain-error',
|
||||
"The hostname provided can't be resolved",
|
||||
).should('exist');
|
||||
});
|
||||
cy.wait('@createDb', { timeout: 60000 }).then(() => {
|
||||
cy.contains(
|
||||
'.ant-form-item-explain-error',
|
||||
"The hostname provided can't be resolved",
|
||||
).should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('show error alerts on dynamic form for bad port', () => {
|
||||
cy.get('.preferred > :nth-child(1)').click();
|
||||
|
||||
cy.get('input[name="host"]').type('localhost', { force: true });
|
||||
cy.get('body').click(0, 0);
|
||||
cy.get('input[name="host"]').type('localhost', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="port"]').type('5430', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="database"]').type('testdb', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="username"]')
|
||||
.type('testusername', { force: true })
|
||||
.blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
cy.get('input[name="password"]').type('testpass', { force: true }).blur();
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
|
||||
cy.get('input[name="port"]').type('5430', { force: true });
|
||||
cy.get('input[name="database"]').type('testdb', { force: true });
|
||||
cy.get('input[name="username"]').type('testusername', { force: true });
|
||||
|
||||
cy.wait('@validateParams', { timeout: 30000 });
|
||||
|
||||
cy.get('input[name="password"]').type('testpass', { force: true });
|
||||
cy.wait('@validateParams');
|
||||
|
||||
cy.getBySel('btn-submit-connection').should('not.be.disabled');
|
||||
cy.getBySel('btn-submit-connection', { timeout: 60000 }).should(
|
||||
'not.be.disabled',
|
||||
);
|
||||
cy.getBySel('btn-submit-connection').click({ force: true });
|
||||
cy.wait('@validateParams', { timeout: 30000 }).then(() => {
|
||||
cy.get('body').click(0, 0);
|
||||
cy.getBySel('btn-submit-connection').click({ force: true });
|
||||
cy.wait('@createDb', { timeout: 60000 }).then(() => {
|
||||
cy.contains(
|
||||
'.ant-form-item-explain-error',
|
||||
'The port is closed',
|
||||
).should('exist');
|
||||
});
|
||||
|
||||
cy.wait('@createDb', { timeout: 60000 }).then(() => {
|
||||
cy.contains('.ant-form-item-explain-error', 'The port is closed').should(
|
||||
'exist',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
192
superset-frontend/package-lock.json
generated
192
superset-frontend/package-lock.json
generated
@@ -109,7 +109,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -220,7 +220,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -6326,12 +6326,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-supported": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
|
||||
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/martini": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz",
|
||||
@@ -11470,15 +11464,6 @@
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson-vt": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
|
||||
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/glob-to-regexp": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/glob-to-regexp/-/glob-to-regexp-0.4.4.tgz",
|
||||
@@ -11776,12 +11761,6 @@
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pbf": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
||||
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
|
||||
@@ -14961,9 +14940,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.37",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
|
||||
"version": "2.10.38",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||
"integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -15954,12 +15933,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cheap-ruler": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
|
||||
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
@@ -17283,12 +17256,6 @@
|
||||
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csscolorparser": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
|
||||
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -18617,11 +18584,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -21450,12 +21416,6 @@
|
||||
"integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/geojson-vt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/geolib": {
|
||||
"version": "3.3.14",
|
||||
"resolved": "https://registry.npmjs.org/geolib/-/geolib-3.3.14.tgz",
|
||||
@@ -23260,9 +23220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -28361,9 +28321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.24.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.24.1.tgz",
|
||||
"integrity": "sha512-e9Wj1TtGGOjzE/jtWaUvdFN7RYL3H0keEzH7gwzHbEdFAsmi03RaDVhnATmtFtIRXQUYf944CIQN0jQv+obeNg==",
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz",
|
||||
"integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -28371,66 +28331,7 @@
|
||||
"test/build/vite",
|
||||
"test/build/webpack",
|
||||
"test/build/typings"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-supported": "^3.0.0",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/geojson-vt": "^3.2.5",
|
||||
"@types/pbf": "^3.0.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"cheap-ruler": "^4.0.0",
|
||||
"csscolorparser": "~1.0.3",
|
||||
"earcut": "^3.0.1",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"martinez-polygon-clipping": "^0.8.1",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.0.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
|
||||
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/pbf": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
|
||||
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
@@ -28548,23 +28449,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/martinez-polygon-clipping": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
|
||||
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^2.0.4",
|
||||
"splaytree": "^0.1.4",
|
||||
"tinyqueue": "3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/martinez-polygon-clipping/node_modules/robust-predicates": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
||||
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/match-sorter": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz",
|
||||
@@ -39137,12 +39021,6 @@
|
||||
"webpack": "^1 || ^2 || ^3 || ^4 || ^5"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
|
||||
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/split": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
|
||||
@@ -45044,15 +44922,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
@@ -45120,6 +44989,26 @@
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
|
||||
"integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"highlight.js": "^10.4.1",
|
||||
"highlightjs-vue": "^1.0.0",
|
||||
"lowlight": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"refractor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.20.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/timezone-mock": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/timezone-mock/-/timezone-mock-1.4.2.tgz",
|
||||
@@ -45423,15 +45312,6 @@
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-ag-grid-table": {
|
||||
"name": "@superset-ui/plugin-chart-ag-grid-table",
|
||||
"version": "0.20.3",
|
||||
@@ -45611,7 +45491,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"json-bigint": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"markdown-to-jsx": "^9.8.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -303,7 +303,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.37",
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
"types": "./lib/authentication/index.d.ts",
|
||||
"default": "./lib/authentication/index.js"
|
||||
},
|
||||
"./chat": {
|
||||
"types": "./lib/chat/index.d.ts",
|
||||
"default": "./lib/chat/index.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./lib/navigation/index.d.ts",
|
||||
"default": "./lib/navigation/index.js"
|
||||
},
|
||||
"./commands": {
|
||||
"types": "./lib/commands/index.d.ts",
|
||||
"default": "./lib/commands/index.js"
|
||||
|
||||
156
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
156
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Chat contribution API for Superset extensions.
|
||||
*
|
||||
* Chat is a dedicated contribution type: an extension registers
|
||||
* a chat via {@link registerChat} and the host owns where and how it is
|
||||
* mounted. The host applies singleton resolution — multiple chat extensions
|
||||
* may register, but exactly one is active at a time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { chat } from '@apache-superset/core';
|
||||
*
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* AcmeTrigger,
|
||||
* AcmePanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import type { Disposable, Event } from '../common';
|
||||
|
||||
export interface Chat {
|
||||
/** The unique identifier for the chat. */
|
||||
id: string;
|
||||
/** The display name of the chat. */
|
||||
name: string;
|
||||
/** Optional description of the chat. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type DisplayMode = 'floating' | 'panel';
|
||||
|
||||
/**
|
||||
* Registers a chat provider. Only one chat is active at a time; the most
|
||||
* recently registered chat wins. Disposing the returned Disposable unregisters
|
||||
* the chat.
|
||||
*
|
||||
* @param chat The chat descriptor (id, name).
|
||||
* @param trigger The trigger component — the collapsed bubble entry point.
|
||||
* Owns dynamic state such as unread counts.
|
||||
* @param panel The panel component, rendered in either display mode. In
|
||||
* 'floating' mode it appears as an overlay; in 'panel' mode it is docked
|
||||
* alongside the main content.
|
||||
* @returns A Disposable that unregisters the chat when disposed.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* AcmeTrigger,
|
||||
* AcmePanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerChat(
|
||||
chat: Chat,
|
||||
trigger: ComponentType,
|
||||
panel: ComponentType,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Returns the active chat descriptor, or undefined if none is registered.
|
||||
*/
|
||||
export declare function getChat(): Chat | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is registered.
|
||||
*/
|
||||
export declare const onDidRegisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Opens the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when no chat is registered or the panel is already open.
|
||||
*/
|
||||
export declare function open(): void;
|
||||
|
||||
/**
|
||||
* Closes the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when the panel is not open.
|
||||
*/
|
||||
export declare function close(): void;
|
||||
|
||||
/**
|
||||
* Returns whether the active chat's panel is currently open.
|
||||
*/
|
||||
export declare function isOpen(): boolean;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel opens. Also fired by the host's own
|
||||
* controls, not only by an extension's open() call.
|
||||
*/
|
||||
export declare const onDidOpen: Event<void>;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel closes, whether triggered by an extension
|
||||
* or by the host.
|
||||
*/
|
||||
export declare const onDidClose: Event<void>;
|
||||
|
||||
/**
|
||||
* Returns the current display mode.
|
||||
*/
|
||||
export declare function getDisplayMode(): DisplayMode;
|
||||
|
||||
/**
|
||||
* Sets the display mode. The mode is host-global and applies to whichever
|
||||
* chat is active. Use {@link onDidChangeDisplayMode} to observe all changes,
|
||||
* including those triggered by the host.
|
||||
*/
|
||||
export declare function setDisplayMode(displayMode: DisplayMode): void;
|
||||
|
||||
/**
|
||||
* Event fired when the display mode changes, whether triggered by an
|
||||
* extension via setDisplayMode() or by host-provided controls.
|
||||
*/
|
||||
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
|
||||
|
||||
/**
|
||||
* Event fired when the panel is resized in panel mode. Not all hosts provide
|
||||
* a resizer — do not rely on this event firing.
|
||||
*/
|
||||
export declare const onDidResizePanel: Event<{ width: number }>;
|
||||
|
||||
// TODO: client actions API — tool availability functions will be added here
|
||||
// once the client_actions SIP is finalized. The chat namespace is the
|
||||
// intended integration point between the two SIPs.
|
||||
@@ -223,8 +223,6 @@ export interface Extension {
|
||||
dependencies: string[];
|
||||
/** Human-readable description of the extension */
|
||||
description: string;
|
||||
/** List of other extensions that this extension depends on */
|
||||
extensionDependencies: string[];
|
||||
/** Unique identifier for the extension */
|
||||
id: string;
|
||||
/** Human-readable name of the extension */
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
* menus, editors, chat) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
@@ -71,7 +72,8 @@ export interface MenuContributions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
* Aggregates all contributions (commands, menus, views, editors, and chat)
|
||||
* provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
@@ -82,4 +84,10 @@ export interface Contributions {
|
||||
views: ViewContributions;
|
||||
/** List of editors. */
|
||||
editors?: Editor[];
|
||||
/**
|
||||
* The chat contributed by the extension — at most one per extension, since
|
||||
* the host applies singleton resolution and renders exactly one active
|
||||
* chat at a time.
|
||||
*/
|
||||
chat?: Chat;
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
*/
|
||||
export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as chat from './chat';
|
||||
export * as commands from './commands';
|
||||
export * as editors from './editors';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
export * as navigation from './navigation';
|
||||
export * as sqlLab from './sqlLab';
|
||||
export * as views from './views';
|
||||
export * as contributions from './contributions';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Navigation namespace for Superset extensions.
|
||||
*
|
||||
* Exposes the current application surface so extensions can react to route
|
||||
* changes without polling. Entity-level context (chart, dashboard, dataset)
|
||||
* is intentionally not included here — surface-specific namespaces that
|
||||
* resolve entity payloads are introduced in later phases.
|
||||
*/
|
||||
|
||||
import { Event } from '../common';
|
||||
|
||||
/**
|
||||
* The set of top-level application surfaces.
|
||||
*
|
||||
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
|
||||
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
|
||||
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
|
||||
* single entity is active. `'sqllab'` is the SQL editor where
|
||||
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
|
||||
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
|
||||
* the welcome surface and the fallback for any route not explicitly enumerated.
|
||||
*/
|
||||
export type Page =
|
||||
| 'dashboard'
|
||||
| 'dashboard_list'
|
||||
| 'explore'
|
||||
| 'chart_list'
|
||||
| 'sqllab'
|
||||
| 'query_history'
|
||||
| 'saved_queries'
|
||||
| 'dataset'
|
||||
| 'dataset_list'
|
||||
| 'home';
|
||||
|
||||
/**
|
||||
* Returns the current page surface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const page = navigation.getPage();
|
||||
* if (page === 'dashboard') {
|
||||
* // react to being on a dashboard surface
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function getPage(): Page;
|
||||
|
||||
/**
|
||||
* Event fired whenever the user navigates to a different surface.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sub = navigation.onDidChangePage(page => {
|
||||
* if (page === 'dashboard') {
|
||||
* // react to navigating onto a dashboard surface
|
||||
* }
|
||||
* });
|
||||
* // later:
|
||||
* sub.dispose();
|
||||
* ```
|
||||
*/
|
||||
export declare const onDidChangePage: Event<Page>;
|
||||
@@ -30,12 +30,12 @@
|
||||
*
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
||||
* () => <ResultStatsPanel />,
|
||||
* ResultStatsPanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { ComponentType } from 'react';
|
||||
import { Disposable, Event } from '../common';
|
||||
|
||||
/**
|
||||
@@ -58,7 +58,7 @@ export interface View {
|
||||
*
|
||||
* @param view The view descriptor (id and name).
|
||||
* @param location The location where this view should appear (e.g. "sqllab.panels").
|
||||
* @param provider A function that returns the React element to render.
|
||||
* @param component The React component to render at that location.
|
||||
* @returns A Disposable that unregisters the view when disposed.
|
||||
*
|
||||
* @example
|
||||
@@ -66,14 +66,14 @@ export interface View {
|
||||
* views.registerView(
|
||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||
* 'sqllab.panels',
|
||||
* () => <ResultStatsPanel />,
|
||||
* ResultStatsPanel,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerView(
|
||||
view: View,
|
||||
location: string,
|
||||
provider: () => ReactElement,
|
||||
component: ComponentType,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
|
||||
@@ -132,6 +132,26 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'time_compare_full_range',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show full range for time shift'),
|
||||
default: false,
|
||||
description: t(
|
||||
'Plot each time-shifted series across its full time range instead ' +
|
||||
'of truncating it to the main series. Useful for comparing a ' +
|
||||
'partial current period (e.g. today so far) against complete ' +
|
||||
'prior periods (e.g. all of yesterday).',
|
||||
),
|
||||
visibility: ({ controls }) =>
|
||||
Boolean(controls?.time_compare?.value) &&
|
||||
(!Array.isArray(controls?.time_compare?.value) ||
|
||||
controls.time_compare.value.length > 0),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'comparison_type',
|
||||
|
||||
@@ -79,7 +79,7 @@ export const LabeledErrorBoundInput = ({
|
||||
isValidating ? 'validating' : hasError ? 'error' : 'success'
|
||||
}
|
||||
help={errorMessage || helpText}
|
||||
hasFeedback={!!hasError}
|
||||
hasFeedback={isValidating || !!hasError}
|
||||
>
|
||||
{visibilityToggle || props.name === 'password' ? (
|
||||
<StyledInputPassword
|
||||
|
||||
@@ -31,5 +31,6 @@ export interface LabeledErrorBoundInputProps {
|
||||
id?: string;
|
||||
classname?: string;
|
||||
visibilityToggle?: boolean;
|
||||
isValidating?: boolean;
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
|
||||
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { mergeWith } from 'lodash';
|
||||
import { cloneDeep, mergeWith } from 'lodash';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils';
|
||||
|
||||
interface SafeMarkdownProps {
|
||||
@@ -85,8 +85,15 @@ export function getOverrideHtmlSchema(
|
||||
originalSchema: typeof defaultSchema,
|
||||
htmlSchemaOverrides: SafeMarkdownProps['htmlSchemaOverrides'],
|
||||
) {
|
||||
return mergeWith(originalSchema, htmlSchemaOverrides, (objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
// Merge into a fresh clone: mergeWith mutates its first argument, and the
|
||||
// array customizer concatenates, so merging into the shared defaultSchema
|
||||
// import would progressively widen the sanitization allowlist for every
|
||||
// SafeMarkdown instance app-wide.
|
||||
return mergeWith(
|
||||
cloneDeep(originalSchema),
|
||||
htmlSchemaOverrides,
|
||||
(objValue, srcValue) =>
|
||||
Array.isArray(objValue) ? objValue.concat(srcValue) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ const SupersetClient: SupersetClientInterface = {
|
||||
request: request => getInstance().request(request),
|
||||
getCSRFToken: () => getInstance().getCSRFToken(),
|
||||
getUrl: (...args) => getInstance().getUrl(...args),
|
||||
postBlob: (endpoint, payload) => getInstance().postBlob(endpoint, payload),
|
||||
get guestTokenHeaderName() {
|
||||
try {
|
||||
return getInstance().guestTokenHeaderName;
|
||||
|
||||
@@ -150,6 +150,26 @@ export default class SupersetClientClass {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request that returns a blob for file downloads.
|
||||
* Unlike postForm, this uses AJAX so errors can be caught and handled.
|
||||
* @param endpoint - API endpoint
|
||||
* @param payload - Request payload
|
||||
* @returns Promise resolving to Response with blob
|
||||
*/
|
||||
async postBlob(
|
||||
endpoint: string,
|
||||
payload: Record<string, any>,
|
||||
): Promise<Response> {
|
||||
await this.ensureAuth();
|
||||
return this.post({
|
||||
endpoint,
|
||||
postPayload: payload,
|
||||
parseMethod: 'raw',
|
||||
stringify: false,
|
||||
});
|
||||
}
|
||||
|
||||
async reAuthenticate() {
|
||||
return this.init(true);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface SupersetClientInterface extends Pick<
|
||||
| 'get'
|
||||
| 'post'
|
||||
| 'postForm'
|
||||
| 'postBlob'
|
||||
| 'put'
|
||||
| 'request'
|
||||
| 'init'
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { defaultSchema } from 'rehype-sanitize';
|
||||
import {
|
||||
getOverrideHtmlSchema,
|
||||
SafeMarkdown,
|
||||
@@ -51,6 +53,36 @@ describe('getOverrideHtmlSchema', () => {
|
||||
expect(result.attributes).toEqual({ '*': ['size', 'src'], h1: ['style'] });
|
||||
expect(result.tagNames).toEqual(['h1', 'h2', 'h3', 'iframe']);
|
||||
});
|
||||
|
||||
test('should not mutate the original schema', () => {
|
||||
const original = {
|
||||
attributes: { '*': ['size'] },
|
||||
tagNames: ['h1'],
|
||||
};
|
||||
getOverrideHtmlSchema(original, {
|
||||
attributes: { '*': ['src'] },
|
||||
tagNames: ['iframe'],
|
||||
});
|
||||
// The original passed in is left untouched.
|
||||
expect(original.attributes).toEqual({ '*': ['size'] });
|
||||
expect(original.tagNames).toEqual(['h1']);
|
||||
});
|
||||
|
||||
test('should not mutate the shared defaultSchema import or accumulate across calls', () => {
|
||||
const snapshot = cloneDeep(defaultSchema);
|
||||
const overrides = { tagNames: ['iframe'] };
|
||||
|
||||
const first = getOverrideHtmlSchema(defaultSchema, overrides);
|
||||
const second = getOverrideHtmlSchema(defaultSchema, overrides);
|
||||
|
||||
// The shared singleton is never modified...
|
||||
expect(defaultSchema).toEqual(snapshot);
|
||||
// ...and repeated calls do not accumulate the override (no growing arrays).
|
||||
expect(first.tagNames).toEqual(second.tagNames);
|
||||
expect(
|
||||
(second.tagNames ?? []).filter(name => name === 'iframe'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformLinkUri', () => {
|
||||
|
||||
@@ -36,12 +36,13 @@ describe('SupersetClient', () => {
|
||||
getUrl: (...args: unknown[]) => string;
|
||||
};
|
||||
|
||||
test('exposes configure, init, get, post, postForm, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||
test('exposes configure, init, get, post, postForm, postBlob, delete, put, request, reset, getGuestToken, getCSRFToken, getUrl, isAuthenticated, and reAuthenticate methods', () => {
|
||||
expect(typeof SupersetClient.configure).toBe('function');
|
||||
expect(typeof SupersetClient.init).toBe('function');
|
||||
expect(typeof SupersetClient.get).toBe('function');
|
||||
expect(typeof SupersetClient.post).toBe('function');
|
||||
expect(typeof SupersetClient.postForm).toBe('function');
|
||||
expect(typeof SupersetClient.postBlob).toBe('function');
|
||||
expect(typeof SupersetClient.delete).toBe('function');
|
||||
expect(typeof SupersetClient.put).toBe('function');
|
||||
expect(typeof SupersetClient.request).toBe('function');
|
||||
@@ -53,11 +54,12 @@ describe('SupersetClient', () => {
|
||||
expect(typeof SupersetClient.reAuthenticate).toBe('function');
|
||||
});
|
||||
|
||||
test('throws if you call init, get, post, postForm, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||
test('throws if you call init, get, post, postForm, postBlob, delete, put, request, getGuestToken, getCSRFToken, getUrl, isAuthenticated, or reAuthenticate before configure', () => {
|
||||
expect(SupersetClient.init).toThrow();
|
||||
expect(SupersetClient.get).toThrow();
|
||||
expect(SupersetClient.post).toThrow();
|
||||
expect(SupersetClient.postForm).toThrow();
|
||||
expect(SupersetClient.postBlob).toThrow();
|
||||
expect(SupersetClient.delete).toThrow();
|
||||
expect(SupersetClient.put).toThrow();
|
||||
expect(SupersetClient.request).toThrow();
|
||||
|
||||
@@ -780,4 +780,75 @@ describe('SupersetClientClass', () => {
|
||||
expect(authSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.postBlob()', () => {
|
||||
const protocol = 'https:';
|
||||
const host = 'host';
|
||||
const mockPostBlobEndpoint = '/api/v1/chart/data';
|
||||
const mockPostBlobUrl = `${protocol}//${host}${mockPostBlobEndpoint}`;
|
||||
const postBlobPayload = { form_data: '{"viz_type":"table"}' };
|
||||
|
||||
let authSpy: jest.SpyInstance;
|
||||
let client: SupersetClientClass;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.removeRoute(LOGIN_GLOB);
|
||||
fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB });
|
||||
|
||||
client = new SupersetClientClass({ protocol, host });
|
||||
await client.init();
|
||||
authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('calls ensureAuth and delegates to post with raw parseMethod', async () => {
|
||||
const mockResponse = new Response('csv data', { status: 200 });
|
||||
const postSpy = jest
|
||||
.spyOn(client, 'post')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await client.postBlob(
|
||||
mockPostBlobEndpoint,
|
||||
postBlobPayload,
|
||||
);
|
||||
|
||||
expect(authSpy).toHaveBeenCalledTimes(1);
|
||||
expect(postSpy).toHaveBeenCalledWith({
|
||||
endpoint: mockPostBlobEndpoint,
|
||||
postPayload: postBlobPayload,
|
||||
parseMethod: 'raw',
|
||||
stringify: false,
|
||||
});
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
test('passes payload in request body', async () => {
|
||||
fetchMock.post(mockPostBlobUrl, {
|
||||
status: 200,
|
||||
body: 'csv data',
|
||||
});
|
||||
|
||||
await client.postBlob(mockPostBlobEndpoint, postBlobPayload);
|
||||
|
||||
const fetchRequest = fetchMock.callHistory.calls(mockPostBlobUrl)[0]
|
||||
.options as CallApi;
|
||||
const formData = fetchRequest.body as FormData;
|
||||
|
||||
expect(formData.get('form_data')).toBe(postBlobPayload.form_data);
|
||||
});
|
||||
|
||||
test('rejects when response is not ok', async () => {
|
||||
fetchMock.post(mockPostBlobUrl, {
|
||||
status: 413,
|
||||
body: 'Payload Too Large',
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.postBlob(mockPostBlobEndpoint, postBlobPayload),
|
||||
).rejects.toMatchObject({ status: 413 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
||||
const { extras = {} } = baseQueryObject;
|
||||
let postProcessing: PostProcessingRule[] = [];
|
||||
// Capture the percent-metric `contribution` rule so it can be reused for
|
||||
// the totals query below. The totals query must rename percent-metric
|
||||
// columns the same way (`metric` -> `%metric`) so the footer can look them
|
||||
// up; without it the totals row renders 0.000%. We deliberately reuse only
|
||||
// this rule and not the full `postProcessing` array, which may also contain
|
||||
// a time-comparison operator that must not run on the single totals row.
|
||||
let contributionPostProcessing: PostProcessingRule | undefined;
|
||||
const nonCustomNorInheritShifts = ensureIsArray(
|
||||
formData.time_compare,
|
||||
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
||||
@@ -157,15 +164,14 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
metrics.concat(percentMetrics),
|
||||
getMetricLabel,
|
||||
);
|
||||
postProcessing = [
|
||||
{
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
||||
},
|
||||
contributionPostProcessing = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
||||
},
|
||||
];
|
||||
};
|
||||
postProcessing = [contributionPostProcessing];
|
||||
}
|
||||
// Add the operator for the time comparison if some is selected
|
||||
if (!isEmpty(timeOffsets)) {
|
||||
@@ -658,7 +664,13 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
// Reapply only the percent-metric contribution rule so the totals row
|
||||
// exposes `%metric` keys (value/value = 100% on the single aggregated
|
||||
// row). The time-comparison operator from the main query is omitted on
|
||||
// purpose; it must not run against the single-row totals query.
|
||||
post_processing: contributionPostProcessing
|
||||
? [contributionPostProcessing]
|
||||
: [],
|
||||
order_desc: undefined, // we don't need orderby stuff here,
|
||||
orderby: undefined, // because this query will be used for get total aggregation.
|
||||
});
|
||||
|
||||
@@ -44,8 +44,3 @@ 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__';
|
||||
|
||||
@@ -24,7 +24,6 @@ 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`
|
||||
${() => `
|
||||
@@ -164,13 +163,13 @@ export const NumericCellRenderer = (
|
||||
let arrow = '';
|
||||
let arrowColor = '';
|
||||
if (hasBasicColorFormatters && col?.metricName) {
|
||||
const rowFormatter = getRowBasicColorFormatter(
|
||||
node,
|
||||
node?.rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName];
|
||||
arrow = rowFormatter?.mainArrow;
|
||||
arrowColor = rowFormatter?.arrowColor?.toLowerCase();
|
||||
arrow =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
|
||||
?.mainArrow;
|
||||
arrowColor =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[
|
||||
col.metricName
|
||||
]?.arrowColor?.toLowerCase();
|
||||
}
|
||||
|
||||
const alignment =
|
||||
|
||||
@@ -46,7 +46,6 @@ 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,
|
||||
@@ -704,23 +703,6 @@ 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) ?? [];
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ 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;
|
||||
@@ -85,11 +84,8 @@ const getCellStyle = (params: CellStyleParams) => {
|
||||
col?.metricName &&
|
||||
node?.rowPinned !== 'bottom'
|
||||
) {
|
||||
backgroundColor = getRowBasicColorFormatter(
|
||||
node,
|
||||
rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName]?.backgroundColor;
|
||||
backgroundColor =
|
||||
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
|
||||
}
|
||||
|
||||
const textAlign =
|
||||
|
||||
@@ -1,51 +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 { 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];
|
||||
}
|
||||
@@ -852,6 +852,75 @@ describe('plugin-chart-ag-grid-table', () => {
|
||||
expect(totalsQuery.columns).toEqual([]);
|
||||
expect(totalsQuery.row_limit).toBe(0);
|
||||
});
|
||||
|
||||
test('should reapply percent-metric contribution op to totals query', () => {
|
||||
// Regression test for #37627: when a percent metric is configured and
|
||||
// Show Summary (show_totals) is enabled, the totals query must rename
|
||||
// percent-metric columns (`metric` -> `%metric`) so the footer can
|
||||
// look them up. Otherwise the totals row renders 0.000%.
|
||||
const { queries } = buildQuery({
|
||||
...basicFormData,
|
||||
metrics: ['count'],
|
||||
percent_metrics: ['count'],
|
||||
show_totals: true,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
});
|
||||
|
||||
// No server pagination -> queries[1] is the totals query.
|
||||
const totalsQuery = queries[1];
|
||||
const contributionRule = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: ['count'],
|
||||
rename_columns: ['%count'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(queries[0].post_processing).toContainEqual(contributionRule);
|
||||
expect(totalsQuery.post_processing).toEqual([contributionRule]);
|
||||
});
|
||||
|
||||
test('should omit time-comparison op from totals post_processing', () => {
|
||||
// The totals query must reuse ONLY the contribution rule; the
|
||||
// time-comparison operator from the main query must not run against
|
||||
// the single-row totals query.
|
||||
const { queries } = buildQuery({
|
||||
...basicFormData,
|
||||
metrics: ['count'],
|
||||
percent_metrics: ['count'],
|
||||
show_totals: true,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
time_compare: ['1 year ago'],
|
||||
comparison_type: 'values',
|
||||
});
|
||||
|
||||
const totalsQuery = queries[1];
|
||||
|
||||
// Exactly one op (contribution) — the time-comparison operator from the
|
||||
// main query must not be carried over to the single-row totals query.
|
||||
expect(totalsQuery.post_processing).toHaveLength(1);
|
||||
expect(totalsQuery.post_processing?.[0]).toMatchObject({
|
||||
operation: 'contribution',
|
||||
});
|
||||
// The reused rule matches the main query's contribution rule verbatim.
|
||||
expect(totalsQuery.post_processing?.[0]).toEqual(
|
||||
queries[0].post_processing?.find(
|
||||
op => op?.operation === 'contribution',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('should leave totals post_processing empty without percent metrics', () => {
|
||||
const { queries } = buildQuery({
|
||||
...basicFormData,
|
||||
metrics: ['count'],
|
||||
show_totals: true,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
});
|
||||
|
||||
const totalsQuery = queries[1];
|
||||
expect(totalsQuery.post_processing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration - all filter types together', () => {
|
||||
|
||||
@@ -1,65 +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 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']);
|
||||
});
|
||||
@@ -318,14 +318,25 @@ function createAdvancedAnalyticsSection(
|
||||
): ControlPanelSectionConfig {
|
||||
const aaWithSuffix = cloneDeep(sections.advancedAnalyticsControls);
|
||||
aaWithSuffix.label = label;
|
||||
// `time_compare_full_range` is only wired into the regular timeseries query
|
||||
// builder, not the mixed-timeseries one, so drop it here to avoid showing a
|
||||
// control that has no effect.
|
||||
aaWithSuffix.controlSetRows = aaWithSuffix.controlSetRows
|
||||
.map(row =>
|
||||
row.filter(
|
||||
control =>
|
||||
(control as CustomControlItem)?.name !== 'time_compare_full_range',
|
||||
),
|
||||
)
|
||||
.filter(row => row.length > 0);
|
||||
if (!controlSuffix) {
|
||||
return aaWithSuffix;
|
||||
}
|
||||
aaWithSuffix.controlSetRows.forEach(row =>
|
||||
row.forEach((control: CustomControlItem) => {
|
||||
if (control?.name) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
control.name = `${control.name}${controlSuffix}`;
|
||||
row.forEach(control => {
|
||||
const item = control as CustomControlItem;
|
||||
if (item?.name) {
|
||||
item.name = `${item.name}${controlSuffix}`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -82,6 +82,11 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
? formData.time_compare
|
||||
: [];
|
||||
|
||||
// When comparing against prior periods, optionally keep each shifted series at
|
||||
// its full time range instead of truncating it to the main series' range.
|
||||
const time_compare_full_range =
|
||||
time_offsets.length > 0 && Boolean(formData.time_compare_full_range);
|
||||
|
||||
return [
|
||||
{
|
||||
...baseQueryObject,
|
||||
@@ -92,6 +97,7 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
// todo: move `normalizeOrderBy to extractQueryFields`
|
||||
orderby: normalizeOrderBy(baseQueryObject).orderby,
|
||||
time_offsets,
|
||||
time_compare_full_range,
|
||||
/* Note that:
|
||||
1. The resample, rolling, cum, timeCompare operators should be after pivot.
|
||||
2. Resample must come before rolling so that imputed values are
|
||||
|
||||
@@ -381,6 +381,15 @@ export default function transformProps(
|
||||
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
|
||||
const inverted = invert(verboseMap);
|
||||
|
||||
// With the "full range" time-shift option, offset series are outer-joined onto
|
||||
// the main series, which inserts null rows into the main series wherever the
|
||||
// comparison period has data the current period lacks. Connect nulls so the
|
||||
// main line stays continuous (matching the default left-join appearance) rather
|
||||
// than fragmenting at every inserted gap.
|
||||
const timeCompareFullRange = Boolean(
|
||||
chartProps.rawFormData?.time_compare_full_range,
|
||||
);
|
||||
|
||||
const offsetLineWidths: { [key: string]: number } = {};
|
||||
|
||||
// For horizontal bar charts, calculate min/max from data to avoid cutting off labels
|
||||
@@ -478,7 +487,7 @@ export default function transformProps(
|
||||
colorScaleKey,
|
||||
{
|
||||
area,
|
||||
connectNulls: derivedSeries,
|
||||
connectNulls: derivedSeries || timeCompareFullRange,
|
||||
filterState,
|
||||
seriesContexts,
|
||||
markerEnabled,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.24.1",
|
||||
"mapbox-gl": "^3.25.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -86,6 +86,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
||||
const { extras = {} } = baseQueryObject;
|
||||
const postProcessing: PostProcessingRule[] = [];
|
||||
// Capture the percent-metric `contribution` rule so it can be reused for
|
||||
// the totals query below. Without it the totals row's percent-metric
|
||||
// columns are keyed `metric` instead of `%metric`, so the footer renders
|
||||
// 0.000%. We reuse only this rule and not the full `postProcessing` array,
|
||||
// which may also contain a time-comparison operator that must not run on
|
||||
// the single totals row.
|
||||
let contributionPostProcessing: PostProcessingRule | undefined;
|
||||
const nonCustomNorInheritShifts = ensureIsArray(
|
||||
formData.time_compare,
|
||||
).filter((shift: string) => shift !== 'custom' && shift !== 'inherit');
|
||||
@@ -137,12 +144,6 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
orderby = [[metrics[0], false]];
|
||||
}
|
||||
// add postprocessing for percent metrics only when in aggregation mode
|
||||
type PercentMetricCalculationMode = 'row_limit' | 'all_records';
|
||||
|
||||
const calculationMode: PercentMetricCalculationMode =
|
||||
(formData.percent_metric_calculation as PercentMetricCalculationMode) ||
|
||||
'row_limit';
|
||||
|
||||
if (percentMetrics && percentMetrics.length > 0) {
|
||||
const percentMetricsLabelsWithTimeComparison = isTimeComparison(
|
||||
formData,
|
||||
@@ -162,23 +163,14 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
getMetricLabel,
|
||||
);
|
||||
|
||||
if (calculationMode === 'all_records') {
|
||||
postProcessing.push({
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
postProcessing.push({
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||
},
|
||||
});
|
||||
}
|
||||
contributionPostProcessing = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: percentMetricLabels,
|
||||
rename_columns: percentMetricLabels.map(m => `%${m}`),
|
||||
},
|
||||
};
|
||||
postProcessing.push(contributionPostProcessing);
|
||||
}
|
||||
|
||||
// Add the operator for the time comparison if some is selected
|
||||
@@ -357,7 +349,13 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
columns: [],
|
||||
row_limit: 0,
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
// Reapply only the percent-metric contribution rule so the totals row
|
||||
// exposes `%metric` keys (value/value = 100% on the single aggregated
|
||||
// row). The time-comparison operator from the main query is omitted on
|
||||
// purpose; it must not run against the single-row totals query.
|
||||
post_processing: contributionPostProcessing
|
||||
? [contributionPostProcessing]
|
||||
: [],
|
||||
order_desc: undefined,
|
||||
orderby: undefined,
|
||||
});
|
||||
|
||||
@@ -236,6 +236,83 @@ describe('plugin-chart-table', () => {
|
||||
expect(queries).toHaveLength(1);
|
||||
expect(queries[0].post_processing).toEqual([]);
|
||||
});
|
||||
|
||||
test('should reapply contribution op to totals query in row_limit mode', () => {
|
||||
// Regression test for #37627: with a percent metric and Show Summary
|
||||
// (show_totals) enabled, the totals query must rename percent-metric
|
||||
// columns (`metric` -> `%metric`) so the footer can look them up.
|
||||
// Otherwise the totals row renders 0.000%.
|
||||
const formData = {
|
||||
...baseFormDataWithPercents,
|
||||
show_totals: true,
|
||||
};
|
||||
|
||||
const { queries } = buildQuery(formData);
|
||||
|
||||
// row_limit mode + show_totals -> [main, totals].
|
||||
expect(queries).toHaveLength(2);
|
||||
|
||||
const contributionRule = {
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: ['sum_sales'],
|
||||
rename_columns: ['%sum_sales'],
|
||||
},
|
||||
};
|
||||
|
||||
expect(queries[1]).toMatchObject({
|
||||
columns: [],
|
||||
post_processing: [contributionRule],
|
||||
});
|
||||
});
|
||||
|
||||
test('should omit time-comparison op from totals post_processing', () => {
|
||||
// The totals query must reuse ONLY the contribution rule; the
|
||||
// time-comparison operator from the main query must not run against
|
||||
// the single-row totals query.
|
||||
const formData = {
|
||||
...baseFormDataWithPercents,
|
||||
show_totals: true,
|
||||
time_compare: ['1 year ago'],
|
||||
comparison_type: 'values',
|
||||
};
|
||||
|
||||
const { queries } = buildQuery(formData);
|
||||
|
||||
// row_limit mode + show_totals -> [main, totals].
|
||||
expect(queries).toHaveLength(2);
|
||||
|
||||
const totalsQuery = queries[1];
|
||||
|
||||
// Exactly one op (contribution) — the time-comparison operator from the
|
||||
// main query must not be carried over to the single-row totals query.
|
||||
expect(totalsQuery.post_processing).toHaveLength(1);
|
||||
expect(totalsQuery.post_processing?.[0]).toMatchObject({
|
||||
operation: 'contribution',
|
||||
});
|
||||
// The reused rule matches the main query's contribution rule verbatim.
|
||||
expect(totalsQuery.post_processing?.[0]).toEqual(
|
||||
queries[0].post_processing?.find(
|
||||
op => op?.operation === 'contribution',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('should leave totals post_processing empty without percent metrics', () => {
|
||||
const formData = {
|
||||
...basicFormData,
|
||||
query_mode: QueryMode.Aggregate,
|
||||
metrics: ['count'],
|
||||
percent_metrics: [],
|
||||
groupby: ['category'],
|
||||
show_totals: true,
|
||||
};
|
||||
|
||||
const { queries } = buildQuery(formData);
|
||||
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries[1].post_processing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing for server pagination with search filter', () => {
|
||||
|
||||
@@ -632,6 +632,35 @@ function processFile(filepath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application source trees that must be authored in TypeScript. Matches the
|
||||
* top-level `src/` directory as well as each package/plugin `src/` directory.
|
||||
*/
|
||||
const TS_ONLY_SOURCE_PATTERN =
|
||||
/^(src|packages\/[^/]+\/src|plugins\/[^/]+\/src)\//;
|
||||
|
||||
/**
|
||||
* Enforce the TypeScript-only frontend convention: no `.js`/`.jsx` files may be
|
||||
* added under the application source trees (including test files). Build
|
||||
* artifacts and root-level config files (e.g. `.storybook/preview.jsx`,
|
||||
* `webpack.config.js`) live outside these trees and are intentionally allowed.
|
||||
*
|
||||
* @param {string[]} candidateFiles paths relative to `superset-frontend/`
|
||||
*/
|
||||
function checkTypeScriptOnlySource(candidateFiles) {
|
||||
candidateFiles.forEach(file => {
|
||||
if (TS_ONLY_SOURCE_PATTERN.test(file) && /\.(js|jsx)$/.test(file)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${RED}✗${RESET} ${file}: frontend source must be TypeScript. ` +
|
||||
`Rename to .ts/.tsx (the codebase is mid-migration to full ` +
|
||||
`TypeScript; no new .js/.jsx files in src/).`,
|
||||
);
|
||||
errorCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
@@ -666,6 +695,22 @@ function main() {
|
||||
/packages\/superset-ui-core\/src\/color\/index\.ts/, // Core brand color constants
|
||||
];
|
||||
|
||||
// Enforce TypeScript-only source. Run this on the raw file list (before the
|
||||
// ignore patterns below strip out tests/stories) so that e.g. a new
|
||||
// `*.test.jsx` is still rejected.
|
||||
const tsOnlyCandidates =
|
||||
args.length === 0
|
||||
? glob.sync('{src,packages/*/src,plugins/*/src}/**/*.{js,jsx}', {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/esm/**',
|
||||
'**/lib/**',
|
||||
'**/dist/**',
|
||||
],
|
||||
})
|
||||
: args.map(f => f.replace(/^superset-frontend\//, ''));
|
||||
checkTypeScriptOnlySource(tsOnlyCandidates);
|
||||
|
||||
// If no files specified, check all
|
||||
if (files.length === 0) {
|
||||
files = glob.sync('src/**/*.{ts,tsx,js,jsx}', {
|
||||
@@ -706,22 +751,23 @@ function main() {
|
||||
if (files.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('No files to check.');
|
||||
return;
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Checking ${files.length} files for Superset custom rules...\n`,
|
||||
);
|
||||
|
||||
files.forEach(file => {
|
||||
// Resolve the file path
|
||||
const resolvedPath = path.resolve(file);
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
processFile(resolvedPath);
|
||||
} else if (fs.existsSync(file)) {
|
||||
processFile(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Checking ${files.length} files for Superset custom rules...\n`);
|
||||
|
||||
files.forEach(file => {
|
||||
// Resolve the file path
|
||||
const resolvedPath = path.resolve(file);
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
processFile(resolvedPath);
|
||||
} else if (fs.existsSync(file)) {
|
||||
processFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n${errorCount} errors, ${warningCount} warnings`);
|
||||
|
||||
@@ -740,4 +786,5 @@ module.exports = {
|
||||
checkNoFaIcons,
|
||||
checkI18nTemplates,
|
||||
checkUntranslatedStrings,
|
||||
checkTypeScriptOnlySource,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
import type { Column, GridApi, IHeaderParams } from 'ag-grid-community';
|
||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { Header } from './Header';
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
@@ -38,9 +38,70 @@ jest.mock('@superset-ui/core/components/Icons', () => {
|
||||
};
|
||||
});
|
||||
|
||||
class MockApi extends EventTarget {
|
||||
class MockColumn {
|
||||
private colListeners = new Map<string, Set<Function>>();
|
||||
|
||||
sortValue: string | null = 'asc';
|
||||
|
||||
sortIndexValue: number | null = null;
|
||||
|
||||
getColId() {
|
||||
return '123';
|
||||
}
|
||||
|
||||
isPinnedLeft() {
|
||||
return true;
|
||||
}
|
||||
|
||||
isPinnedRight() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getSort() {
|
||||
return this.sortValue;
|
||||
}
|
||||
|
||||
getSortIndex() {
|
||||
return this.sortIndexValue;
|
||||
}
|
||||
|
||||
addEventListener(eventType: string, listener: Function) {
|
||||
if (!this.colListeners.has(eventType)) {
|
||||
this.colListeners.set(eventType, new Set());
|
||||
}
|
||||
this.colListeners.get(eventType)!.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(eventType: string, listener: Function) {
|
||||
this.colListeners.get(eventType)?.delete(listener);
|
||||
}
|
||||
|
||||
triggerEvent(eventType: string) {
|
||||
this.colListeners.get(eventType)?.forEach(listener => listener({}));
|
||||
}
|
||||
}
|
||||
|
||||
class MockOtherColumn extends MockColumn {
|
||||
getColId() {
|
||||
return 'other-col';
|
||||
}
|
||||
}
|
||||
|
||||
class MockApi {
|
||||
mockColumn = new MockColumn();
|
||||
|
||||
otherColumn = new MockOtherColumn();
|
||||
|
||||
getAllDisplayedColumns() {
|
||||
return [];
|
||||
return [this.mockColumn, this.otherColumn];
|
||||
}
|
||||
|
||||
getColumns() {
|
||||
return [this.mockColumn, this.otherColumn];
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
@@ -48,48 +109,76 @@ class MockApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
const mockApi = new MockApi();
|
||||
|
||||
const mockedProps = {
|
||||
displayName: 'test column',
|
||||
setSort: jest.fn(),
|
||||
progressSort: jest.fn(),
|
||||
enableSorting: true,
|
||||
column: {
|
||||
getColId: () => '123',
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSort: () => 'asc',
|
||||
getSortIndex: () => null,
|
||||
} as any as Column,
|
||||
api: new MockApi() as any as GridApi,
|
||||
};
|
||||
column: mockApi.mockColumn as any as Column,
|
||||
api: mockApi as any as GridApi,
|
||||
} as unknown as IHeaderParams;
|
||||
|
||||
test('renders display name for the column', () => {
|
||||
const { queryByText } = render(<Header {...mockedProps} />);
|
||||
expect(queryByText(mockedProps.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sorts by clicking a column header', () => {
|
||||
const { getByText, queryByTestId } = render(<Header {...mockedProps} />);
|
||||
test('calls progressSort without shiftKey on click', () => {
|
||||
const { getByText } = render(<Header {...mockedProps} />);
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith('asc', false);
|
||||
expect(queryByTestId('mock-sort-asc')).toBeInTheDocument();
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith('desc', false);
|
||||
expect(queryByTestId('mock-sort-desc')).toBeInTheDocument();
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(mockedProps.setSort).toHaveBeenCalledWith(null, false);
|
||||
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
|
||||
expect(mockedProps.progressSort).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('synchronizes the current sort when sortChanged event occurred', async () => {
|
||||
const { findByTestId } = render(<Header {...mockedProps} />);
|
||||
test('calls progressSort with shiftKey on shift-click', () => {
|
||||
const { getByText } = render(<Header {...mockedProps} />);
|
||||
fireEvent.click(getByText(mockedProps.displayName), { shiftKey: true });
|
||||
expect(mockedProps.progressSort).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test('synchronizes sort icon when columnStateUpdated fires on column', async () => {
|
||||
const { findByTestId, queryByTestId } = render(<Header {...mockedProps} />);
|
||||
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
mockedProps.api.dispatchEvent(new Event('sortChanged'));
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
const sortAsc = await findByTestId('mock-sort-asc');
|
||||
expect(sortAsc).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows sortIndex label when multi-sort is active', async () => {
|
||||
const { findByText } = render(<Header {...mockedProps} />);
|
||||
|
||||
act(() => {
|
||||
mockApi.mockColumn.sortIndexValue = 1;
|
||||
mockApi.otherColumn.sortValue = 'desc';
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
const label = await findByText('2');
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides sortIndex label when multi-sort is cleared', async () => {
|
||||
const { queryByText } = render(<Header {...mockedProps} />);
|
||||
|
||||
act(() => {
|
||||
mockApi.mockColumn.sortIndexValue = 1;
|
||||
mockApi.otherColumn.sortValue = 'desc';
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
mockApi.mockColumn.sortIndexValue = null;
|
||||
mockApi.otherColumn.sortValue = null;
|
||||
mockApi.mockColumn.triggerEvent('columnStateUpdated');
|
||||
});
|
||||
|
||||
expect(queryByText('2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('disable menu when enableFilterButton is false', () => {
|
||||
const { queryByText, queryByTestId } = render(
|
||||
<Header {...mockedProps} enableFilterButton={false} />,
|
||||
@@ -99,18 +188,39 @@ test('disable menu when enableFilterButton is false', () => {
|
||||
});
|
||||
|
||||
test('hide display name for PIVOT_COL_ID', () => {
|
||||
const pivotColumn = new MockColumn();
|
||||
(pivotColumn as any).getColId = () => PIVOT_COL_ID;
|
||||
|
||||
const { queryByText } = render(
|
||||
<Header
|
||||
{...mockedProps}
|
||||
column={
|
||||
{
|
||||
getColId: () => PIVOT_COL_ID,
|
||||
isPinnedLeft: () => true,
|
||||
isPinnedRight: () => false,
|
||||
getSortIndex: () => null,
|
||||
} as any as Column
|
||||
}
|
||||
/>,
|
||||
<Header {...mockedProps} column={pivotColumn as any as Column} />,
|
||||
);
|
||||
expect(queryByText(mockedProps.displayName)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not attach click handler when enableSorting is false', () => {
|
||||
const { getByText } = render(
|
||||
<Header {...mockedProps} enableSorting={false} />,
|
||||
);
|
||||
const cell = getByText(mockedProps.displayName).closest(
|
||||
'.ag-header-cell-label',
|
||||
);
|
||||
expect(cell).not.toHaveAttribute('role', 'button');
|
||||
});
|
||||
|
||||
test('does not call progressSort on click when enableSorting is false', () => {
|
||||
const progressSort = jest.fn();
|
||||
const { getByText } = render(
|
||||
<Header {...mockedProps} enableSorting={false} progressSort={progressSort} />,
|
||||
);
|
||||
fireEvent.click(getByText(mockedProps.displayName));
|
||||
expect(progressSort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not render sort icons when enableSorting is false', () => {
|
||||
const { queryByTestId } = render(
|
||||
<Header {...mockedProps} enableSorting={false} />,
|
||||
);
|
||||
expect(queryByTestId('mock-sort')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('mock-sort-asc')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('mock-sort-desc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,32 +16,16 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
type MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { IHeaderParams, Column, SortDirection } from 'ag-grid-community';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||
import type { Column, GridApi } from 'ag-grid-community';
|
||||
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
|
||||
import { PIVOT_COL_ID } from './constants';
|
||||
import { HeaderMenu } from './HeaderMenu';
|
||||
|
||||
interface Params {
|
||||
enableFilterButton?: boolean;
|
||||
enableSorting?: boolean;
|
||||
displayName: string;
|
||||
column: Column;
|
||||
api: GridApi;
|
||||
setSort: (sort: string | null, multiSort: boolean) => void;
|
||||
}
|
||||
|
||||
const SORT_DIRECTION = [null, 'asc', 'desc'];
|
||||
|
||||
const HeaderCell = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -87,30 +71,26 @@ const IconPlaceholder = styled.div`
|
||||
top: 0;
|
||||
`;
|
||||
|
||||
export const Header: React.FC<Params> = ({
|
||||
export const Header: React.FC<IHeaderParams> = ({
|
||||
enableFilterButton,
|
||||
enableSorting,
|
||||
displayName,
|
||||
setSort,
|
||||
progressSort,
|
||||
column,
|
||||
api,
|
||||
}: Params) => {
|
||||
}: IHeaderParams) => {
|
||||
const theme = useTheme();
|
||||
const colId = column.getColId();
|
||||
const pinnedLeft = column.isPinnedLeft();
|
||||
const pinnedRight = column.isPinnedRight();
|
||||
const sortOption = useRef<number>(0);
|
||||
const [invisibleColumns, setInvisibleColumns] = useState<Column[]>([]);
|
||||
const [currentSort, setCurrentSort] = useState<string | null>(null);
|
||||
const [currentSort, setCurrentSort] = useState<SortDirection>(null);
|
||||
const [sortIndex, setSortIndex] = useState<number | null>();
|
||||
const onSort = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
sortOption.current = (sortOption.current + 1) % SORT_DIRECTION.length;
|
||||
const sort = SORT_DIRECTION[sortOption.current];
|
||||
setSort(sort, event.shiftKey);
|
||||
setCurrentSort(sort);
|
||||
(event: React.MouseEvent) => {
|
||||
progressSort(event.shiftKey);
|
||||
},
|
||||
[setSort],
|
||||
[progressSort],
|
||||
);
|
||||
const onVisibleChange = useCallback(
|
||||
(isVisible: boolean) => {
|
||||
@@ -123,24 +103,22 @@ export const Header: React.FC<Params> = ({
|
||||
[api],
|
||||
);
|
||||
|
||||
const onSortChanged = useCallback(() => {
|
||||
const syncSortState = useCallback(() => {
|
||||
const hasMultiSort = api
|
||||
.getAllDisplayedColumns()
|
||||
.some(c => c.getSortIndex());
|
||||
const updatedSortIndex = column.getSortIndex();
|
||||
sortOption.current = SORT_DIRECTION.indexOf(column.getSort() ?? null);
|
||||
.some(c => c.getColId() !== colId && c.getSort() !== null);
|
||||
setCurrentSort(column.getSort() ?? null);
|
||||
setSortIndex(hasMultiSort ? updatedSortIndex : null);
|
||||
}, [api, column]);
|
||||
setSortIndex(hasMultiSort ? column.getSortIndex() : null);
|
||||
}, [api, column, colId]);
|
||||
|
||||
useEffect(() => {
|
||||
api.addEventListener('sortChanged', onSortChanged);
|
||||
column.addEventListener('columnStateUpdated', syncSortState);
|
||||
|
||||
return () => {
|
||||
if (api.isDestroyed()) return;
|
||||
api.removeEventListener('sortChanged', onSortChanged);
|
||||
column.removeEventListener('columnStateUpdated', syncSortState);
|
||||
};
|
||||
}, [api, onSortChanged]);
|
||||
}, [column, syncSortState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
277
superset-frontend/src/core/chat/ChatHost.test.tsx
Normal file
277
superset-frontend/src/core/chat/ChatHost.test.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { act, render, screen } from 'spec/helpers/testing-library';
|
||||
import { chat } from 'src/core/chat';
|
||||
import ChatProvider from './ChatProvider';
|
||||
import { ChatFloatingHost as ChatHost, ChatPanelHost } from './ChatHost';
|
||||
|
||||
beforeEach(() => {
|
||||
ChatProvider.getInstance().reset();
|
||||
});
|
||||
|
||||
test('renders nothing when no chat extension is registered', () => {
|
||||
render(<ChatHost />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the trigger bubble of the registered chat', () => {
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
|
||||
render(<ChatHost />);
|
||||
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
// The panel stays unmounted until the chat is opened.
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('mounts the panel when the chat opens and unmounts it on close', () => {
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
|
||||
render(<ChatHost />);
|
||||
|
||||
act(() => chat.open());
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
// In floating mode the trigger stays mounted alongside the open panel.
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the last-registered chat when several are installed', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
);
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
);
|
||||
|
||||
jest.restoreAllMocks();
|
||||
render(<ChatHost />);
|
||||
|
||||
// Last-loaded wins: the second registration takes over the singleton slot.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reacts to a chat registering after the initial render', () => {
|
||||
render(<ChatHost />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a takeover mounts the incoming chat closed', () => {
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
);
|
||||
|
||||
render(<ChatHost />);
|
||||
act(() => chat.open());
|
||||
expect(screen.getByText('First Panel')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// The displaced chat's open state must not leak into the winner.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('ChatPanelHost renders the panel when open in panel mode', () => {
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
|
||||
render(<ChatPanelHost />);
|
||||
|
||||
act(() => {
|
||||
chat.setDisplayMode('panel');
|
||||
chat.open();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('ChatFloatingHost suppresses the floating panel in panel mode but keeps the trigger', () => {
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
|
||||
render(<ChatHost />);
|
||||
|
||||
act(() => {
|
||||
chat.setDisplayMode('panel');
|
||||
chat.open();
|
||||
});
|
||||
|
||||
// In panel mode the floating panel is suppressed (ChatPanelHost owns that slot).
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
// The trigger stays rendered so the user can reopen after collapsing.
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
// Trigger remains visible even when closed — it's the user's only way back.
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a crashing panel does not take the trigger down with it', () => {
|
||||
const FailingPanel = () => {
|
||||
throw new Error('panel blew up');
|
||||
};
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <FailingPanel />,
|
||||
);
|
||||
|
||||
render(<ChatHost />);
|
||||
act(() => chat.open());
|
||||
|
||||
// The panel's boundary contains the crash; the trigger keeps rendering so
|
||||
// the user is not stranded without a way back.
|
||||
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing trigger so it does not crash the host', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('chat blew up');
|
||||
};
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatHost />)).not.toThrow();
|
||||
// The mount slot still renders (the boundary lives inside it), confirming
|
||||
// the provider was actually exercised and contained.
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a component that throws during render', () => {
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => {
|
||||
throw new Error('provider blew up');
|
||||
},
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
|
||||
expect(() => render(<ChatHost />)).not.toThrow();
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers from a crashed chat when a different chat takes over', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('first chat blew up');
|
||||
};
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>First Panel</div>,
|
||||
);
|
||||
|
||||
render(<ChatHost />);
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// The boundary is keyed per registration, so the latched crash from the
|
||||
// first chat does not blank the second one.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers from a crashed chat when a different id takes over', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('broken release');
|
||||
};
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
);
|
||||
|
||||
render(<ChatHost />);
|
||||
|
||||
act(() => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
chat.registerChat(
|
||||
{ id: 'fixed.chat', name: 'Fixed Chat' },
|
||||
() => <div>Fixed Bubble</div>,
|
||||
() => <div>Fixed Panel</div>,
|
||||
);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Different id: boundary key changes, latch resets, fix renders.
|
||||
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
|
||||
});
|
||||
133
superset-frontend/src/core/chat/ChatHost.tsx
Normal file
133
superset-frontend/src/core/chat/ChatHost.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { type ComponentType, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { useChat } from '.';
|
||||
|
||||
const CHAT_EDGE_MARGIN = 24;
|
||||
|
||||
/**
|
||||
* Returns an onError handler that shows a toast on crash, once per chat id.
|
||||
*/
|
||||
function useCrashNotifier(chatId: string | undefined) {
|
||||
const notifiedFor = useRef<string | undefined>(undefined);
|
||||
return (error: Error) => {
|
||||
if (!chatId) return;
|
||||
logging.error('[chat] provider crashed', error);
|
||||
if (notifiedFor.current !== chatId) {
|
||||
notifiedFor.current = chatId;
|
||||
store.dispatch(addDangerToast(t('The chat failed to load.')));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a component in an ErrorBoundary, keyed by chat id so the boundary
|
||||
* resets when a different chat takes over.
|
||||
*/
|
||||
const ChatBoundary = ({
|
||||
component: Component,
|
||||
onError,
|
||||
}: {
|
||||
component: ComponentType;
|
||||
onError: (error: Error) => void;
|
||||
}) => (
|
||||
<ErrorBoundary showMessage={false} onError={onError}>
|
||||
<Component />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders the chat panel content in panel mode. Fills its container height.
|
||||
*/
|
||||
export const ChatPanelHost = () => {
|
||||
const { chat, panel } = useChat();
|
||||
const onError = useCrashNotifier(chat?.id);
|
||||
|
||||
if (!chat || !panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
`}
|
||||
>
|
||||
<ChatBoundary key={chat.id} component={panel} onError={onError} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the chat trigger and, when the panel is open in floating mode, the
|
||||
* floating panel overlay. The trigger is always visible when a chat is
|
||||
* registered; the panel overlay is suppressed in panel mode.
|
||||
*/
|
||||
export const ChatFloatingHost = () => {
|
||||
const theme = useTheme();
|
||||
const { open: panelOpen, mode, chat, trigger, panel } = useChat();
|
||||
const onError = useCrashNotifier(chat?.id);
|
||||
|
||||
if (!chat || !trigger || !panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHAT_EDGE_MARGIN}px;
|
||||
bottom: ${CHAT_EDGE_MARGIN}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
{/*
|
||||
Separate boundaries so a crashing panel cannot take the trigger down
|
||||
with it — the trigger is the user's only way back.
|
||||
*/}
|
||||
{panelOpen && mode !== 'panel' && (
|
||||
<ChatBoundary
|
||||
key={`panel-${chat.id}`}
|
||||
component={panel}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
<ChatBoundary
|
||||
key={`trigger-${chat.id}`}
|
||||
component={trigger}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
257
superset-frontend/src/core/chat/ChatProvider.test.ts
Normal file
257
superset-frontend/src/core/chat/ChatProvider.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createElement } from 'react';
|
||||
import ChatProvider from './ChatProvider';
|
||||
|
||||
const trigger = () => createElement('button', null, 'Bubble');
|
||||
const panel = () => createElement('div', null, 'Panel');
|
||||
|
||||
beforeEach(() => {
|
||||
ChatProvider.getInstance().reset();
|
||||
});
|
||||
|
||||
test('returns the singleton instance', () => {
|
||||
expect(ChatProvider.getInstance()).toBe(ChatProvider.getInstance());
|
||||
});
|
||||
|
||||
test('getChat returns undefined when no chat is registered', () => {
|
||||
expect(ChatProvider.getInstance().getChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('registerChat sets the registration and returns the descriptor copy', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
|
||||
const disposable = provider.registerChat(descriptor, trigger, panel);
|
||||
|
||||
expect(provider.getChat()).toEqual(descriptor);
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('the last-registered chat wins and logs a warning', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||
|
||||
expect(provider.getChat()?.id).toBe('second.chat');
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toContain('second.chat');
|
||||
expect(warn.mock.calls[0][0]).toContain('first.chat');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('re-registering with a different id replaces the active chat', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||
expect(provider.getChat()?.id).toBe('first.chat');
|
||||
|
||||
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||
expect(provider.getChat()?.id).toBe('second.chat');
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('disposing the registration clears it', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const disposable = provider.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(provider.getChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('disposing twice fires unregister only once', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const unregistered = jest.fn();
|
||||
provider.onDidUnregisterChat(unregistered);
|
||||
|
||||
const disposable = provider.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
disposable.dispose();
|
||||
disposable.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const registered = jest.fn();
|
||||
const unregistered = jest.fn();
|
||||
provider.onDidRegisterChat(registered);
|
||||
provider.onDidUnregisterChat(unregistered);
|
||||
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||
const disposable = provider.registerChat(descriptor, trigger, panel);
|
||||
|
||||
expect(registered).toHaveBeenCalledWith(descriptor);
|
||||
expect(unregistered).not.toHaveBeenCalled();
|
||||
|
||||
disposable.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledWith(descriptor);
|
||||
});
|
||||
|
||||
test('open and close toggle the panel state', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
|
||||
provider.open();
|
||||
expect(provider.isOpen()).toBe(true);
|
||||
|
||||
provider.close();
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('open fires once; duplicate open is a no-op', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const opened = jest.fn();
|
||||
provider.onDidOpen(opened);
|
||||
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||
|
||||
provider.open();
|
||||
provider.open();
|
||||
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('close fires once; duplicate close is a no-op', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const closed = jest.fn();
|
||||
provider.onDidClose(closed);
|
||||
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||
|
||||
provider.open();
|
||||
provider.close();
|
||||
provider.close();
|
||||
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('open is a no-op when no chat is registered', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const opened = jest.fn();
|
||||
provider.onDidOpen(opened);
|
||||
|
||||
provider.open();
|
||||
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('registering a second chat while open closes the panel', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const closed = jest.fn();
|
||||
provider.onDidClose(closed);
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||
provider.open();
|
||||
provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('disposing the active chat while open closes the panel', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const closed = jest.fn();
|
||||
provider.onDidClose(closed);
|
||||
|
||||
const disposable = provider.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
provider.open();
|
||||
disposable.dispose();
|
||||
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a late registration does not inherit a stale open state', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const disposable = provider.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
provider.open();
|
||||
disposable.dispose();
|
||||
|
||||
provider.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel);
|
||||
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('getDisplayMode defaults to floating', () => {
|
||||
expect(ChatProvider.getInstance().getDisplayMode()).toBe('floating');
|
||||
});
|
||||
|
||||
test('setDisplayMode updates mode and fires event only on change', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
const modeChanged = jest.fn();
|
||||
provider.onDidChangeDisplayMode(modeChanged);
|
||||
|
||||
provider.setDisplayMode('floating');
|
||||
expect(modeChanged).not.toHaveBeenCalled();
|
||||
|
||||
provider.setDisplayMode('panel');
|
||||
expect(provider.getDisplayMode()).toBe('panel');
|
||||
expect(modeChanged).toHaveBeenCalledWith('panel');
|
||||
});
|
||||
|
||||
test('state reflects changes after registration and open', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
|
||||
expect(provider.getChat()).toBeUndefined();
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
|
||||
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||
provider.open();
|
||||
|
||||
expect(provider.isOpen()).toBe(true);
|
||||
expect(provider.getChat()?.id).toBe('acme.chat');
|
||||
});
|
||||
|
||||
test('reset clears all state', () => {
|
||||
const provider = ChatProvider.getInstance();
|
||||
provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||
provider.open();
|
||||
provider.setDisplayMode('panel');
|
||||
|
||||
provider.reset();
|
||||
|
||||
expect(provider.getChat()).toBeUndefined();
|
||||
expect(provider.isOpen()).toBe(false);
|
||||
expect(provider.getDisplayMode()).toBe('floating');
|
||||
});
|
||||
209
superset-frontend/src/core/chat/ChatProvider.ts
Normal file
209
superset-frontend/src/core/chat/ChatProvider.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import type { chat as chatApi } from '@apache-superset/core';
|
||||
import {
|
||||
LocalStorageKeys,
|
||||
getItem,
|
||||
setItem,
|
||||
} from 'src/utils/localStorageHelpers';
|
||||
import { Disposable } from '../models';
|
||||
import { createValueEventEmitter, createEventEmitter } from '../utils';
|
||||
|
||||
type Chat = chatApi.Chat;
|
||||
type DisplayMode = chatApi.DisplayMode;
|
||||
|
||||
/**
|
||||
* Singleton manager for the chat provider.
|
||||
* Handles registration, open/close state, and display mode.
|
||||
*/
|
||||
class ChatProvider {
|
||||
private static instance: ChatProvider;
|
||||
|
||||
private chat: Chat | undefined;
|
||||
|
||||
private trigger: ComponentType | undefined;
|
||||
|
||||
private panel: ComponentType | undefined;
|
||||
|
||||
private opened: boolean;
|
||||
|
||||
private stateSubscribers = new Set<() => void>();
|
||||
|
||||
private registerEmitter = createEventEmitter<Chat>();
|
||||
|
||||
private unregisterEmitter = createEventEmitter<Chat>();
|
||||
|
||||
private openEmitter = createEventEmitter<void>();
|
||||
|
||||
private closeEmitter = createEventEmitter<void>();
|
||||
|
||||
private resizePanelEmitter = createEventEmitter<{ width: number }>();
|
||||
|
||||
private modeEmitter: ReturnType<typeof createValueEventEmitter<DisplayMode>>;
|
||||
|
||||
private constructor() {
|
||||
const persisted = getItem(LocalStorageKeys.ChatState, {
|
||||
open: false,
|
||||
mode: 'floating',
|
||||
});
|
||||
const mode = (
|
||||
persisted.mode === 'panel' ? 'panel' : 'floating'
|
||||
) as DisplayMode;
|
||||
this.opened = persisted.open === true;
|
||||
this.modeEmitter = createValueEventEmitter<DisplayMode>(mode);
|
||||
}
|
||||
|
||||
public static getInstance(): ChatProvider {
|
||||
if (!ChatProvider.instance) {
|
||||
ChatProvider.instance = new ChatProvider();
|
||||
}
|
||||
return ChatProvider.instance;
|
||||
}
|
||||
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
this.stateSubscribers.add(listener);
|
||||
return () => this.stateSubscribers.delete(listener);
|
||||
};
|
||||
|
||||
private notifyState(): void {
|
||||
setItem(LocalStorageKeys.ChatState, {
|
||||
open: this.opened,
|
||||
mode: this.modeEmitter.getCurrent(),
|
||||
});
|
||||
this.stateSubscribers.forEach(fn => fn());
|
||||
}
|
||||
|
||||
private closePanel(): void {
|
||||
this.opened = false;
|
||||
this.closeEmitter.fire();
|
||||
}
|
||||
|
||||
public registerChat(
|
||||
chat: Chat,
|
||||
trigger: ComponentType,
|
||||
panel: ComponentType,
|
||||
): Disposable {
|
||||
if (this.chat) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`,
|
||||
);
|
||||
this.unregisterEmitter.fire(this.chat);
|
||||
if (this.opened) this.closePanel();
|
||||
}
|
||||
|
||||
this.chat = chat;
|
||||
this.trigger = trigger;
|
||||
this.panel = panel;
|
||||
this.registerEmitter.fire(chat);
|
||||
this.notifyState();
|
||||
|
||||
return new Disposable(() => {
|
||||
if (this.chat !== chat) return;
|
||||
this.chat = undefined;
|
||||
this.trigger = undefined;
|
||||
this.panel = undefined;
|
||||
this.unregisterEmitter.fire(chat);
|
||||
if (this.opened) this.closePanel();
|
||||
this.notifyState();
|
||||
});
|
||||
}
|
||||
|
||||
public getChat(): Chat | undefined {
|
||||
return this.chat;
|
||||
}
|
||||
|
||||
public getTrigger(): ComponentType | undefined {
|
||||
return this.trigger;
|
||||
}
|
||||
|
||||
public getPanel(): ComponentType | undefined {
|
||||
return this.panel;
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
if (this.opened || !this.chat) return;
|
||||
this.opened = true;
|
||||
this.openEmitter.fire();
|
||||
this.notifyState();
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (!this.opened || !this.chat) return;
|
||||
this.closePanel();
|
||||
this.notifyState();
|
||||
}
|
||||
|
||||
public isOpen(): boolean {
|
||||
return this.opened;
|
||||
}
|
||||
|
||||
public getDisplayMode(): DisplayMode {
|
||||
return this.modeEmitter.getCurrent();
|
||||
}
|
||||
|
||||
public setDisplayMode(displayMode: DisplayMode): void {
|
||||
if (displayMode === this.modeEmitter.getCurrent()) return;
|
||||
this.modeEmitter.fire(displayMode);
|
||||
this.notifyState();
|
||||
}
|
||||
|
||||
public get onDidRegisterChat() {
|
||||
return this.registerEmitter.subscribe;
|
||||
}
|
||||
|
||||
public get onDidUnregisterChat() {
|
||||
return this.unregisterEmitter.subscribe;
|
||||
}
|
||||
|
||||
public get onDidOpen() {
|
||||
return this.openEmitter.subscribe;
|
||||
}
|
||||
|
||||
public get onDidClose() {
|
||||
return this.closeEmitter.subscribe;
|
||||
}
|
||||
|
||||
public get onDidChangeDisplayMode() {
|
||||
return this.modeEmitter.subscribe;
|
||||
}
|
||||
|
||||
public get onDidResizePanel() {
|
||||
return this.resizePanelEmitter.subscribe;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.chat = undefined;
|
||||
this.trigger = undefined;
|
||||
this.panel = undefined;
|
||||
this.opened = false;
|
||||
this.registerEmitter = createEventEmitter<Chat>();
|
||||
this.unregisterEmitter = createEventEmitter<Chat>();
|
||||
this.openEmitter = createEventEmitter<void>();
|
||||
this.closeEmitter = createEventEmitter<void>();
|
||||
this.resizePanelEmitter = createEventEmitter<{ width: number }>();
|
||||
this.modeEmitter = createValueEventEmitter<DisplayMode>('floating');
|
||||
this.stateSubscribers.clear();
|
||||
setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' });
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatProvider;
|
||||
68
superset-frontend/src/core/chat/index.test.ts
Normal file
68
superset-frontend/src/core/chat/index.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createElement } from 'react';
|
||||
import { chat } from './index';
|
||||
import ChatProvider from './ChatProvider';
|
||||
|
||||
const trigger = () => createElement('button', null, 'Bubble');
|
||||
const panel = () => createElement('div', null, 'Panel');
|
||||
|
||||
beforeEach(() => {
|
||||
ChatProvider.getInstance().reset();
|
||||
});
|
||||
|
||||
test('getChat returns undefined when no chat is registered', () => {
|
||||
expect(chat.getChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('registerChat makes the chat retrievable via getChat', () => {
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
|
||||
chat.registerChat(descriptor, trigger, panel);
|
||||
|
||||
expect(chat.getChat()).toEqual(descriptor);
|
||||
});
|
||||
|
||||
test('the last-registered chat wins when multiple are registered', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel);
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel);
|
||||
|
||||
expect(chat.getChat()?.id).toBe('second.chat');
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('open and close toggle isOpen', () => {
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel);
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
chat.open();
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
chat.close();
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('getDisplayMode defaults to floating', () => {
|
||||
expect(chat.getDisplayMode()).toBe('floating');
|
||||
});
|
||||
|
||||
test('setDisplayMode updates the display mode', () => {
|
||||
chat.setDisplayMode('panel');
|
||||
expect(chat.getDisplayMode()).toBe('panel');
|
||||
});
|
||||
82
superset-frontend/src/core/chat/index.ts
Normal file
82
superset-frontend/src/core/chat/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Host implementation of the `chat` contribution type.
|
||||
*
|
||||
* Extensions register via the public `chat.registerChat()` and the host owns
|
||||
* mounting, open/close state, and the display mode. Only the last-registered
|
||||
* chat is active at a time.
|
||||
*
|
||||
* The public namespace (`chat`) is exposed to extensions on `window.superset`.
|
||||
* `useChat` is host-internal and NOT part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import type { chat as chatApi } from '@apache-superset/core';
|
||||
import ChatProvider from './ChatProvider';
|
||||
|
||||
export { ChatFloatingHost, ChatPanelHost } from './ChatHost';
|
||||
|
||||
const provider = ChatProvider.getInstance();
|
||||
|
||||
const buildSnapshot = memoizeOne(
|
||||
(
|
||||
open: boolean,
|
||||
mode: chatApi.DisplayMode,
|
||||
chat: chatApi.Chat | undefined,
|
||||
trigger: ReturnType<typeof provider.getTrigger>,
|
||||
panel: ReturnType<typeof provider.getPanel>,
|
||||
) => ({ open, mode, chat, trigger, panel }),
|
||||
);
|
||||
|
||||
const getSnapshot = () =>
|
||||
buildSnapshot(
|
||||
provider.isOpen(),
|
||||
provider.getDisplayMode(),
|
||||
provider.getChat(),
|
||||
provider.getTrigger(),
|
||||
provider.getPanel(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Host-internal hook. Returns the current open/mode state and the active chat
|
||||
* (trigger, panel, descriptor).
|
||||
*/
|
||||
export const useChat = () =>
|
||||
useSyncExternalStore(provider.subscribe, getSnapshot);
|
||||
|
||||
export const chat: typeof chatApi = {
|
||||
registerChat: provider.registerChat.bind(provider),
|
||||
getChat: provider.getChat.bind(provider),
|
||||
onDidRegisterChat: provider.onDidRegisterChat,
|
||||
onDidUnregisterChat: provider.onDidUnregisterChat,
|
||||
open: provider.open.bind(provider),
|
||||
close: provider.close.bind(provider),
|
||||
isOpen: provider.isOpen.bind(provider),
|
||||
onDidOpen: provider.onDidOpen,
|
||||
onDidClose: provider.onDidClose,
|
||||
getDisplayMode: provider.getDisplayMode.bind(provider),
|
||||
setDisplayMode: provider.setDisplayMode.bind(provider),
|
||||
onDidChangeDisplayMode: provider.onDidChangeDisplayMode,
|
||||
// The host fires this from its panel resizer; until that chrome exists the
|
||||
// event is exposed but never fires.
|
||||
onDidResizePanel: provider.onDidResizePanel,
|
||||
};
|
||||
@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
|
||||
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { editors } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type EditorLanguage = editors.EditorLanguage;
|
||||
type EditorProvider = editors.EditorProvider;
|
||||
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
|
||||
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
|
||||
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
|
||||
|
||||
/**
|
||||
* Listener function type for events.
|
||||
*/
|
||||
type Listener<T> = (e: T) => void;
|
||||
|
||||
/**
|
||||
* Simple event emitter for editor provider lifecycle events.
|
||||
*/
|
||||
class EventEmitter<T> {
|
||||
private listeners: Set<Listener<T>> = new Set();
|
||||
|
||||
/**
|
||||
* Subscribe to this event.
|
||||
* @param listener The listener function to call when the event is fired.
|
||||
* @returns A Disposable to unsubscribe from the event.
|
||||
*/
|
||||
subscribe(listener: Listener<T>): Disposable {
|
||||
this.listeners.add(listener);
|
||||
return new Disposable(() => {
|
||||
this.listeners.delete(listener);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the event with the given data.
|
||||
* @param data The event data to pass to listeners.
|
||||
*/
|
||||
fire(data: T): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(data);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error in event listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton manager for editor providers.
|
||||
* Handles registration, resolution, and lifecycle of custom editor implementations.
|
||||
@@ -83,15 +47,9 @@ class EditorProviders {
|
||||
*/
|
||||
private languageToProvider: Map<EditorLanguage, string> = new Map();
|
||||
|
||||
/**
|
||||
* Event emitter for provider registration events.
|
||||
*/
|
||||
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
|
||||
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
|
||||
|
||||
/**
|
||||
* Event emitter for provider unregistration events.
|
||||
*/
|
||||
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
|
||||
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
|
||||
|
||||
private syncListeners: Set<() => void> = new Set();
|
||||
|
||||
@@ -226,8 +184,11 @@ class EditorProviders {
|
||||
* @param listener The listener function.
|
||||
* @returns A Disposable to unsubscribe.
|
||||
*/
|
||||
public onDidRegister(listener: Listener<EditorRegisteredEvent>): Disposable {
|
||||
return this.registerEmitter.subscribe(listener);
|
||||
public onDidRegister(
|
||||
listener: Listener<EditorRegisteredEvent>,
|
||||
thisArgs?: unknown,
|
||||
): Disposable {
|
||||
return this.registerEmitter.subscribe(listener, thisArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,8 +198,9 @@ class EditorProviders {
|
||||
*/
|
||||
public onDidUnregister(
|
||||
listener: Listener<EditorUnregisteredEvent>,
|
||||
thisArgs?: unknown,
|
||||
): Disposable {
|
||||
return this.unregisterEmitter.subscribe(listener);
|
||||
return this.unregisterEmitter.subscribe(listener, thisArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,6 +210,8 @@ class EditorProviders {
|
||||
this.providers.clear();
|
||||
this.languageToProvider.clear();
|
||||
this.syncListeners.clear();
|
||||
this.registerEmitter = createEventEmitter<EditorRegisteredEvent>();
|
||||
this.unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,130 +18,39 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Implementation of the editors API for Superset.
|
||||
* @fileoverview Host implementation of the `editors` contribution type.
|
||||
*
|
||||
* This module provides the runtime implementation of the editor registration
|
||||
* and resolution functions declared in the API types.
|
||||
* Extensions register via the public `editors.registerEditor()` and the host
|
||||
* resolves the appropriate provider per language, falling back to the built-in
|
||||
* AceEditorProvider when no extension is registered.
|
||||
*
|
||||
* The public namespace (`editors`) is exposed to extensions on `window.superset`.
|
||||
* `EditorHost` is the host-internal component for rendering editors and is NOT
|
||||
* part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { editors as editorsApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import EditorProviders from './EditorProviders';
|
||||
|
||||
type EditorLanguage = editorsApi.EditorLanguage;
|
||||
type Editor = editorsApi.Editor;
|
||||
type EditorProvider = editorsApi.EditorProvider;
|
||||
type EditorComponent = editorsApi.EditorComponent;
|
||||
type EditorRegisteredEvent = editorsApi.EditorRegisteredEvent;
|
||||
type EditorUnregisteredEvent = editorsApi.EditorUnregisteredEvent;
|
||||
export type { EditorHostProps } from './EditorHost';
|
||||
export { default as EditorHost } from './EditorHost';
|
||||
export { default as AceEditorProvider } from './AceEditorProvider';
|
||||
|
||||
/**
|
||||
* Register an editor provider as a module-level side effect.
|
||||
* Takes the editor descriptor directly rather than looking it up
|
||||
* from a manifest by ID.
|
||||
*
|
||||
* @param editor The editor descriptor.
|
||||
* @param component The React component implementing the editor.
|
||||
* @returns A Disposable to unregister the provider.
|
||||
*/
|
||||
export const registerEditor = (
|
||||
editor: Editor,
|
||||
component: EditorComponent,
|
||||
): Disposable => {
|
||||
const providers = EditorProviders.getInstance();
|
||||
return providers.registerProvider(editor, component);
|
||||
};
|
||||
const provider = EditorProviders.getInstance();
|
||||
|
||||
/**
|
||||
* Get the editor provider for a specific language.
|
||||
* Returns the extension's editor if registered, otherwise undefined.
|
||||
*
|
||||
* @param language The language to get an editor for
|
||||
* @returns The editor provider or undefined if no extension provides one
|
||||
*/
|
||||
export const getEditor = (
|
||||
language: EditorLanguage,
|
||||
): EditorProvider | undefined => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return manager.getProvider(language);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an extension has registered an editor for a language.
|
||||
*
|
||||
* @param language The language to check
|
||||
* @returns True if an extension provides an editor for this language
|
||||
*/
|
||||
export const hasEditor = (language: EditorLanguage): boolean => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return manager.hasProvider(language);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all registered editor providers.
|
||||
*
|
||||
* @returns Array of all registered editor providers
|
||||
*/
|
||||
export const getAllEditors = (): EditorProvider[] => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return manager.getAllProviders();
|
||||
};
|
||||
|
||||
/**
|
||||
* Event fired when an editor is registered.
|
||||
* Subscribe to this event to react when extensions register new editors.
|
||||
*/
|
||||
export const onDidRegisterEditor = (
|
||||
listener: (e: EditorRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return manager.onDidRegister(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Event fired when an editor is unregistered.
|
||||
* Subscribe to this event to react when extensions unregister editors.
|
||||
*/
|
||||
export const onDidUnregisterEditor = (
|
||||
listener: (e: EditorUnregisteredEvent) => void,
|
||||
): Disposable => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return manager.onDidUnregister(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that returns the editor provider for a specific language and re-renders when it changes.
|
||||
*
|
||||
* @param language The language to get an editor for
|
||||
* @returns The editor provider or undefined if no extension provides one
|
||||
*/
|
||||
export const useEditor = (
|
||||
language: EditorLanguage,
|
||||
): EditorProvider | undefined => {
|
||||
const manager = EditorProviders.getInstance();
|
||||
return useSyncExternalStore(
|
||||
manager.subscribe,
|
||||
() => manager.getProvider(language),
|
||||
export const useEditor = (language: editorsApi.EditorLanguage) =>
|
||||
useSyncExternalStore(
|
||||
provider.subscribe,
|
||||
() => provider.getProvider(language),
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Editors API object for use in the extension system.
|
||||
*/
|
||||
export const editors: typeof editorsApi = {
|
||||
registerEditor,
|
||||
getEditor,
|
||||
hasEditor,
|
||||
getAllEditors,
|
||||
onDidRegisterEditor,
|
||||
onDidUnregisterEditor,
|
||||
registerEditor: provider.registerProvider.bind(provider),
|
||||
getEditor: provider.getProvider.bind(provider),
|
||||
hasEditor: provider.hasProvider.bind(provider),
|
||||
getAllEditors: provider.getAllProviders.bind(provider),
|
||||
onDidRegisterEditor: provider.onDidRegister.bind(provider),
|
||||
onDidUnregisterEditor: provider.onDidUnregister.bind(provider),
|
||||
};
|
||||
|
||||
export { EditorProviders };
|
||||
|
||||
// Component exports
|
||||
export { default as EditorHost } from './EditorHost';
|
||||
export type { EditorHostProps } from './EditorHost';
|
||||
export { default as AceEditorProvider } from './AceEditorProvider';
|
||||
|
||||
@@ -27,11 +27,13 @@ export const core: typeof coreType = {
|
||||
};
|
||||
|
||||
export * from './authentication';
|
||||
export * from './chat';
|
||||
export * from './commands';
|
||||
export * from './editors';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
export * from './models';
|
||||
export * from './navigation';
|
||||
export * from './sqlLab';
|
||||
export * from './utils';
|
||||
export * from './views';
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { menus as menusApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type MenuItem = menusApi.MenuItem;
|
||||
type Menu = menusApi.Menu;
|
||||
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
|
||||
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
|
||||
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
|
||||
|
||||
const menuCache = new Map<string, Menu | undefined>();
|
||||
const notifyRegister = (event: MenuItemRegisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
registerEmitter.fire(event);
|
||||
};
|
||||
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
|
||||
menuCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
unregisterEmitter.fire(event);
|
||||
};
|
||||
|
||||
const registerMenuItem: typeof menusApi.registerMenuItem = (
|
||||
@@ -117,16 +118,14 @@ export const useMenu = (location: string): Menu | undefined =>
|
||||
|
||||
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
|
||||
listener: (e: MenuItemRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
thisArgs?: unknown,
|
||||
): Disposable => registerEmitter.subscribe(listener, thisArgs);
|
||||
|
||||
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
|
||||
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
(
|
||||
listener: (e: MenuItemUnregisteredEvent) => void,
|
||||
thisArgs?: unknown,
|
||||
): Disposable => unregisterEmitter.subscribe(listener, thisArgs);
|
||||
|
||||
export const menus: typeof menusApi = {
|
||||
registerMenuItem,
|
||||
|
||||
124
superset-frontend/src/core/navigation/index.test.ts
Normal file
124
superset-frontend/src/core/navigation/index.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Reset module state between tests so currentPage is re-initialized.
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/' },
|
||||
});
|
||||
});
|
||||
|
||||
async function importNavigation() {
|
||||
const mod = await import('./index');
|
||||
return mod;
|
||||
}
|
||||
|
||||
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
|
||||
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||
// The default pathname ('/') is not enumerated and falls back to home.
|
||||
expect(navigation.getPage()).toBe('home');
|
||||
notifyLocationChanged('/superset/welcome/');
|
||||
expect(navigation.getPage()).toBe('home');
|
||||
});
|
||||
|
||||
test('getPage derives the page from window.location.pathname', async () => {
|
||||
window.location.pathname = '/superset/dashboard/42/';
|
||||
const { navigation } = await importNavigation();
|
||||
expect(navigation.getPage()).toBe('dashboard');
|
||||
});
|
||||
|
||||
test('notifyLocationChanged updates the current page type', async () => {
|
||||
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||
notifyLocationChanged('/explore/?form_data={}');
|
||||
expect(navigation.getPage()).toBe('explore');
|
||||
});
|
||||
|
||||
test('notifyLocationChanged fires listeners on page type change', async () => {
|
||||
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
notifyLocationChanged('/superset/dashboard/1/');
|
||||
expect(listener).toHaveBeenCalledWith('dashboard');
|
||||
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => {
|
||||
window.location.pathname = '/superset/dashboard/1/';
|
||||
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
navigation.onDidChangePage(listener);
|
||||
|
||||
notifyLocationChanged('/superset/dashboard/2/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onDidChangePage listener is removed after dispose', async () => {
|
||||
const { navigation, notifyLocationChanged } = await importNavigation();
|
||||
const listener = jest.fn();
|
||||
const disposable = navigation.onDidChangePage(listener);
|
||||
|
||||
disposable.dispose();
|
||||
notifyLocationChanged('/superset/dashboard/1/');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sqllab path is matched with and without trailing slash', async () => {
|
||||
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||
notifyLocationChanged('/sqllab');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
notifyLocationChanged('/explore/');
|
||||
notifyLocationChanged('/sqllab/');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
});
|
||||
|
||||
test('chart and dashboard list pages get their own page types', async () => {
|
||||
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||
notifyLocationChanged('/chart/list/');
|
||||
expect(navigation.getPage()).toBe('chart_list');
|
||||
notifyLocationChanged('/dashboard/list/');
|
||||
expect(navigation.getPage()).toBe('dashboard_list');
|
||||
});
|
||||
|
||||
test('dataset list and single-dataset pages get distinct page types', async () => {
|
||||
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||
notifyLocationChanged('/tablemodelview/list/');
|
||||
expect(navigation.getPage()).toBe('dataset_list');
|
||||
notifyLocationChanged('/dataset/42');
|
||||
expect(navigation.getPage()).toBe('dataset');
|
||||
});
|
||||
|
||||
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
|
||||
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||
notifyLocationChanged('/sqllab/');
|
||||
expect(navigation.getPage()).toBe('sqllab');
|
||||
notifyLocationChanged('/sqllab/history/');
|
||||
expect(navigation.getPage()).toBe('query_history');
|
||||
notifyLocationChanged('/savedqueryview/list/');
|
||||
expect(navigation.getPage()).toBe('saved_queries');
|
||||
});
|
||||
|
||||
test('chart/add resolves to explore, not chart_list', async () => {
|
||||
const { notifyLocationChanged, navigation } = await importNavigation();
|
||||
notifyLocationChanged('/chart/add');
|
||||
expect(navigation.getPage()).toBe('explore');
|
||||
});
|
||||
94
superset-frontend/src/core/navigation/index.ts
Normal file
94
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Host-internal implementation of the `navigation` namespace.
|
||||
*
|
||||
* Derives the current {@link Page} from the browser location by matching
|
||||
* against {@link RoutePaths}. Call {@link useNavigationTracker} once in the
|
||||
* app shell to keep the page in sync with React Router.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation, matchPath } from 'react-router-dom';
|
||||
import type { navigation as navigationApi } from '@apache-superset/core';
|
||||
import { RoutePaths } from '../../views/routePaths';
|
||||
import { Disposable } from '../models';
|
||||
import { createValueEventEmitter } from '../utils';
|
||||
|
||||
type Page = navigationApi.Page;
|
||||
|
||||
/** Maps route path patterns to their corresponding Page type. */
|
||||
const PAGE_ROUTES: { path: string; page: Page }[] = [
|
||||
{ path: RoutePaths.DASHBOARD, page: 'dashboard' },
|
||||
{ path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' },
|
||||
{ path: RoutePaths.QUERY_HISTORY, page: 'query_history' },
|
||||
{ path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' },
|
||||
{ path: RoutePaths.SQLLAB, page: 'sqllab' },
|
||||
{ path: RoutePaths.CHART_ADD, page: 'explore' },
|
||||
{ path: RoutePaths.CHART_LIST, page: 'chart_list' },
|
||||
{ path: RoutePaths.EXPLORE, page: 'explore' },
|
||||
{ path: RoutePaths.EXPLORE_PERMALINK, page: 'explore' },
|
||||
{ path: RoutePaths.DATASET_LIST, page: 'dataset_list' },
|
||||
{ path: RoutePaths.DATASET_ADD, page: 'dataset' },
|
||||
{ path: RoutePaths.DATASET, page: 'dataset' },
|
||||
];
|
||||
|
||||
function derivePage(pathname: string): Page {
|
||||
for (const { path, page } of PAGE_ROUTES) {
|
||||
if (matchPath(pathname, { path, exact: false })) return page;
|
||||
}
|
||||
return 'home';
|
||||
}
|
||||
|
||||
const pageEmitter = createValueEventEmitter<Page>(
|
||||
derivePage(window.location.pathname),
|
||||
);
|
||||
|
||||
/** Updates the current page from a pathname. No-op when the page is unchanged. */
|
||||
export const notifyLocationChanged = (pathname: string): void => {
|
||||
const next = derivePage(pathname);
|
||||
if (next === pageEmitter.getCurrent()) return;
|
||||
pageEmitter.fire(next);
|
||||
};
|
||||
|
||||
const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent();
|
||||
|
||||
const onDidChangePage: typeof navigationApi.onDidChangePage = (
|
||||
listener: (page: Page) => void,
|
||||
thisArgs?: unknown,
|
||||
): Disposable => pageEmitter.subscribe(listener, thisArgs);
|
||||
|
||||
/** Synchronizes the navigation module with React Router. Call once in the app shell. */
|
||||
export const useNavigationTracker = () => {
|
||||
const location = useLocation();
|
||||
const prevPathname = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevPathname.current !== location.pathname) {
|
||||
prevPathname.current = location.pathname;
|
||||
notifyLocationChanged(location.pathname);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
};
|
||||
|
||||
export const navigation: typeof navigationApi = {
|
||||
getPage,
|
||||
onDidChangePage,
|
||||
};
|
||||
@@ -48,7 +48,7 @@ import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
|
||||
import { Database, Disposable } from '../models';
|
||||
import { createActionListener } from '../utils';
|
||||
import { createActionListener } from '../storeUtils';
|
||||
import {
|
||||
Panel,
|
||||
Tab,
|
||||
|
||||
48
superset-frontend/src/core/storeUtils.ts
Normal file
48
superset-frontend/src/core/storeUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
|
||||
export function createActionListener<V, A = unknown>(
|
||||
predicate: AnyListenerPredicate<RootState>,
|
||||
listener: (v: V) => void,
|
||||
valueParser: (action: A, state: RootState) => V | null | undefined,
|
||||
thisArgs?: unknown,
|
||||
): core.Disposable {
|
||||
const boundListener = thisArgs ? listener.bind(thisArgs as object) : listener;
|
||||
|
||||
const unsubscribe = listenerMiddleware.startListening({
|
||||
predicate,
|
||||
effect: action => {
|
||||
const state = store.getState();
|
||||
// The predicate already ensures the action matches type A at runtime.
|
||||
const value = valueParser(action as unknown as A, state);
|
||||
if (value != null) {
|
||||
boundListener(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
unsubscribe();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,33 +17,54 @@
|
||||
* under the License.
|
||||
*/
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import { AnyAction } from 'redux';
|
||||
import { listenerMiddleware, RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
|
||||
export function createActionListener<V>(
|
||||
predicate: AnyListenerPredicate<RootState>,
|
||||
listener: (v: V) => void,
|
||||
valueParser: (action: AnyAction, state: RootState) => V | null | undefined,
|
||||
thisArgs?: any,
|
||||
): core.Disposable {
|
||||
const boundListener = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
type Listener<T> = (e: T) => unknown;
|
||||
|
||||
const unsubscribe = listenerMiddleware.startListening({
|
||||
predicate,
|
||||
effect: (action: AnyAction) => {
|
||||
const state = store.getState();
|
||||
const value = valueParser(action, state);
|
||||
// Skip calling listener if valueParser returns null/undefined
|
||||
if (value != null) {
|
||||
boundListener(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
|
||||
export interface EventEmitter<T> {
|
||||
/** Notifies every current subscriber with `value`. */
|
||||
fire(value: T): void;
|
||||
/** Registers a listener; returns a Disposable that removes it. */
|
||||
subscribe: core.Event<T>;
|
||||
}
|
||||
|
||||
/** An event emitter that also retains the last fired value. */
|
||||
export interface ValueEventEmitter<T> extends EventEmitter<T> {
|
||||
/** Returns the value last passed to {@link fire} (or the initial value). */
|
||||
getCurrent(): T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stateless event emitter. Listeners registered via `event` receive
|
||||
* every subsequent `fire`; a returned Disposable removes the listener.
|
||||
*/
|
||||
export function createEventEmitter<T>(): EventEmitter<T> {
|
||||
const listeners = new Set<Listener<T>>();
|
||||
const subscribe: core.Event<T> = (listener, thisArgs) => {
|
||||
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||
listeners.add(bound);
|
||||
return { dispose: () => listeners.delete(bound) };
|
||||
};
|
||||
return {
|
||||
dispose: () => {
|
||||
unsubscribe();
|
||||
},
|
||||
fire: value => listeners.forEach(fn => fn(value)),
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a value event emitter seeded with `initial`. Behaves like
|
||||
* {@link createEventEmitter} but also tracks the last fired value, readable
|
||||
* via `getCurrent` — useful for state that is both observed and queried.
|
||||
*/
|
||||
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
|
||||
const { fire, subscribe } = createEventEmitter<T>();
|
||||
let current = initial;
|
||||
return {
|
||||
fire: value => {
|
||||
current = value;
|
||||
fire(value);
|
||||
},
|
||||
subscribe,
|
||||
getCurrent: () => current,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,11 +24,12 @@
|
||||
* Extensions register views as side effects at import time.
|
||||
*/
|
||||
|
||||
import React, { ReactElement, useSyncExternalStore } from 'react';
|
||||
import React, { ComponentType, useSyncExternalStore } from 'react';
|
||||
import type { views as viewsApi } from '@apache-superset/core';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
|
||||
import { Disposable } from '../models';
|
||||
import { createEventEmitter } from '../utils';
|
||||
|
||||
type View = viewsApi.View;
|
||||
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
|
||||
@@ -36,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent;
|
||||
|
||||
const viewRegistry: Map<
|
||||
string,
|
||||
{ view: View; location: string; provider: () => ReactElement }
|
||||
{ view: View; location: string; component: ComponentType }
|
||||
> = new Map();
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
@@ -47,29 +48,29 @@ const subscribe = (listener: () => void) => {
|
||||
return () => syncListeners.delete(listener);
|
||||
};
|
||||
|
||||
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
|
||||
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
|
||||
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
|
||||
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
|
||||
|
||||
const viewsCache = new Map<string, View[] | undefined>();
|
||||
const notifyRegister = (event: ViewRegisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
registerListeners.forEach(l => l(event));
|
||||
registerEmitter.fire(event);
|
||||
};
|
||||
const notifyUnregister = (event: ViewUnregisteredEvent) => {
|
||||
viewsCache.clear();
|
||||
syncListeners.forEach(l => l());
|
||||
unregisterListeners.forEach(l => l(event));
|
||||
unregisterEmitter.fire(event);
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
provider: () => ReactElement,
|
||||
component: ComponentType,
|
||||
): Disposable => {
|
||||
const { id } = view;
|
||||
|
||||
viewRegistry.set(id, { view, location, provider });
|
||||
viewRegistry.set(id, { view, location, component });
|
||||
|
||||
const ids = locationIndex.get(location) ?? new Set();
|
||||
ids.add(id);
|
||||
@@ -83,12 +84,16 @@ const registerView: typeof viewsApi.registerView = (
|
||||
});
|
||||
};
|
||||
|
||||
export const resolveView = (id: string): ReactElement => {
|
||||
const provider = viewRegistry.get(id)?.provider;
|
||||
if (!provider) {
|
||||
export const resolveView = (id: string): React.ReactElement => {
|
||||
const entry = viewRegistry.get(id);
|
||||
if (!entry) {
|
||||
return React.createElement(ExtensionPlaceholder, { id });
|
||||
}
|
||||
return React.createElement(ErrorBoundary, null, provider());
|
||||
return React.createElement(
|
||||
ErrorBoundary,
|
||||
null,
|
||||
React.createElement(entry.component),
|
||||
);
|
||||
};
|
||||
|
||||
const getViews: typeof viewsApi.getViews = (
|
||||
@@ -116,17 +121,11 @@ export const useViews = (location: string): View[] | undefined =>
|
||||
|
||||
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
|
||||
listener: (e: ViewRegisteredEvent) => void,
|
||||
): Disposable => {
|
||||
registerListeners.add(listener);
|
||||
return new Disposable(() => registerListeners.delete(listener));
|
||||
};
|
||||
): Disposable => registerEmitter.subscribe(listener);
|
||||
|
||||
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
|
||||
listener: (e: ViewUnregisteredEvent) => void,
|
||||
): Disposable => {
|
||||
unregisterListeners.add(listener);
|
||||
return new Disposable(() => unregisterListeners.delete(listener));
|
||||
};
|
||||
): Disposable => unregisterEmitter.subscribe(listener);
|
||||
|
||||
export const views: typeof viewsApi = {
|
||||
registerView,
|
||||
|
||||
@@ -481,7 +481,7 @@ const Chart = (props: ChartProps) => {
|
||||
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
||||
|
||||
const exportTable = useCallback(
|
||||
(format: string, isFullCSV: boolean, isPivot = false) => {
|
||||
async (format: string, isFullCSV: boolean, isPivot = false) => {
|
||||
const logAction =
|
||||
format === 'csv'
|
||||
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
|
||||
@@ -556,24 +556,48 @@ const Chart = (props: ChartProps) => {
|
||||
}
|
||||
: baseOwnState;
|
||||
|
||||
exportChart({
|
||||
formData:
|
||||
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
|
||||
resultType,
|
||||
resultFormat: format,
|
||||
force: true,
|
||||
ownState: exportOwnState,
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? (exportParams: JsonObject) => {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...(exportParams as Record<string, unknown>),
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
} as Parameters<typeof startExport>[0]);
|
||||
}
|
||||
: null,
|
||||
});
|
||||
try {
|
||||
await exportChart({
|
||||
formData:
|
||||
exportFormData as unknown as import('@superset-ui/core').QueryFormData,
|
||||
resultType,
|
||||
resultFormat: format,
|
||||
force: true,
|
||||
ownState: exportOwnState,
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? (exportParams: JsonObject) => {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...(exportParams as Record<string, unknown>),
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
} as Parameters<typeof startExport>[0]);
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
if (status === 413) {
|
||||
boundActionCreators.addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
boundActionCreators.addDangerToast(errorMessage);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
sliceSliceId,
|
||||
@@ -585,6 +609,7 @@ const Chart = (props: ChartProps) => {
|
||||
chartState,
|
||||
props.id,
|
||||
boundActionCreators.logEvent,
|
||||
boundActionCreators.addDangerToast,
|
||||
queriesResponse,
|
||||
startExport,
|
||||
resetExport,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { LocalStorageKeys, setItem } from 'src/utils/localStorageHelpers';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
|
||||
import { setDatasetsStatus } from 'src/dashboard/actions/dashboardState';
|
||||
import { DASHBOARD_HEADER_ID } from 'src/dashboard/util/constants';
|
||||
import {
|
||||
@@ -337,7 +338,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
// Update document title when dashboard title changes
|
||||
useEffect(() => {
|
||||
if (pageTitle) {
|
||||
document.title = pageTitle;
|
||||
document.title = sanitizeDocumentTitle(pageTitle);
|
||||
}
|
||||
}, [pageTitle]);
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
|
||||
} from 'src/logger/LogUtils';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { sanitizeDocumentTitle } from 'src/utils/sanitizeDocumentTitle';
|
||||
import cx from 'classnames';
|
||||
import * as chartActions from 'src/components/Chart/chartAction';
|
||||
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
|
||||
@@ -397,7 +398,7 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
|
||||
// Update document title when slice name changes
|
||||
useEffect(() => {
|
||||
if (props.sliceName) {
|
||||
document.title = props.sliceName;
|
||||
document.title = sanitizeDocumentTitle(props.sliceName);
|
||||
}
|
||||
}, [props.sliceName]);
|
||||
|
||||
|
||||
@@ -339,7 +339,34 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
|
||||
|
||||
const exportCSV = useCallback(() => {
|
||||
const handleExportError = useCallback(
|
||||
(error: unknown) => {
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const status = exportError.status || exportError.response?.status;
|
||||
if (status === 413) {
|
||||
addDangerToast(
|
||||
t(
|
||||
'The chart data is too large to download. Please try reducing the date range, limiting rows, or using fewer columns.',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
exportError.message ||
|
||||
exportError.statusText ||
|
||||
t(
|
||||
'Failed to export chart data. Please try again or contact your administrator.',
|
||||
);
|
||||
addDangerToast(errorMessage);
|
||||
}
|
||||
},
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
const exportCSV = useCallback(async () => {
|
||||
if (!canDownloadCSV) return null;
|
||||
|
||||
// Determine row count for streaming threshold check
|
||||
@@ -378,26 +405,31 @@ export const useExploreAdditionalActionsMenu = (
|
||||
filename = `${safeChartName}${timestamp}.csv`;
|
||||
}
|
||||
|
||||
return exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
canDownloadCSV,
|
||||
latestQueryFormData,
|
||||
@@ -406,46 +438,59 @@ export const useExploreAdditionalActionsMenu = (
|
||||
streamingThreshold,
|
||||
slice,
|
||||
startExport,
|
||||
handleExportError,
|
||||
]);
|
||||
|
||||
const exportCSVPivoted = useCallback(
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
})
|
||||
: null,
|
||||
[canDownloadCSV, latestQueryFormData, ownState],
|
||||
);
|
||||
const exportCSVPivoted = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportJson = useCallback(
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
})
|
||||
: null,
|
||||
[canDownloadCSV, latestQueryFormData, ownState],
|
||||
);
|
||||
const exportJson = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const exportExcel = useCallback(
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
})
|
||||
: null,
|
||||
[canDownloadCSV, latestQueryFormData, ownState],
|
||||
);
|
||||
const exportExcel = useCallback(async () => {
|
||||
if (!canDownloadCSV) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
return null;
|
||||
}, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
try {
|
||||
@@ -805,7 +850,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
label: dataExportLabel(t('Export to .CSV')),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
// Use 'results' to export the *current view* (as opposed to 'full').
|
||||
// Pass ownState so client/UI state (e.g., filters) can be respected when supported.
|
||||
if (
|
||||
@@ -820,12 +865,16 @@ export const useExploreAdditionalActionsMenu = (
|
||||
slice?.slice_name || 'current_view',
|
||||
);
|
||||
} else {
|
||||
exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
try {
|
||||
await exportChart({
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
} catch (error) {
|
||||
handleExportError(error);
|
||||
}
|
||||
}
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -1058,6 +1107,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
exportCSVPivoted,
|
||||
exportExcel,
|
||||
exportJson,
|
||||
handleExportError,
|
||||
latestQueryFormData,
|
||||
onOpenInEditor,
|
||||
onOpenPropertiesModal,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ComponentType } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useExploreAdditionalActionsMenu } from './index';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('src/explore/exploreUtils'),
|
||||
exportChart: jest.fn(),
|
||||
getChartKey: jest.fn(() => 'test_chart_key'),
|
||||
}));
|
||||
|
||||
const mockExportChart = exploreUtils.exportChart as jest.Mock;
|
||||
|
||||
const mockAddDangerToast = jest.fn();
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
__esModule: true,
|
||||
default: (component: ComponentType) => component,
|
||||
useToasts: () => ({
|
||||
addDangerToast: mockAddDangerToast,
|
||||
addSuccessToast: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('src/logger/actions', () => ({
|
||||
logEvent: jest.fn(() => ({ type: 'LOG_EVENT' })),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
getChartMetadataRegistry: jest.fn(() => ({
|
||||
get: jest.fn(() => ({ behaviors: ['EXPORT_CURRENT_VIEW'] })),
|
||||
})),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
latestQueryFormData: {
|
||||
datasource: '1__table',
|
||||
viz_type: 'pivot_table_v2',
|
||||
},
|
||||
canDownloadCSV: true,
|
||||
slice: { slice_id: 1, slice_name: 'Test Chart' },
|
||||
ownState: {},
|
||||
dashboards: [],
|
||||
onOpenInEditor: jest.fn(),
|
||||
onOpenPropertiesModal: jest.fn(),
|
||||
showReportModal: jest.fn(),
|
||||
setCurrentReportDeleting: jest.fn(),
|
||||
};
|
||||
|
||||
type TestComponentProps = typeof defaultProps;
|
||||
type HookParams = Parameters<typeof useExploreAdditionalActionsMenu>;
|
||||
|
||||
const TestComponent = (props: TestComponentProps) => {
|
||||
const [menu] = useExploreAdditionalActionsMenu(
|
||||
props.latestQueryFormData as HookParams[0],
|
||||
props.canDownloadCSV,
|
||||
props.slice as HookParams[2],
|
||||
props.onOpenInEditor,
|
||||
props.onOpenPropertiesModal,
|
||||
props.ownState as HookParams[5],
|
||||
props.dashboards as HookParams[6],
|
||||
props.showReportModal,
|
||||
props.setCurrentReportDeleting,
|
||||
);
|
||||
|
||||
return <div>{menu}</div>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockExportChart.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('shows 413 error toast when exportCSV fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
render(<TestComponent {...defaultProps} />, { useRedux: true });
|
||||
|
||||
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||
userEvent.hover(await screen.findByText('Export All Data'));
|
||||
userEvent.click(await screen.findByText('Export to original .CSV'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/The chart data is too large to download/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows 413 error toast when exportCSVPivoted fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
render(<TestComponent {...defaultProps} />, { useRedux: true });
|
||||
|
||||
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||
userEvent.hover(await screen.findByText('Export All Data'));
|
||||
userEvent.click(await screen.findByText('Export to pivoted .CSV'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/The chart data is too large to download/),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows 413 error toast when Export Current View CSV server path fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
latestQueryFormData={{
|
||||
datasource: '1__table',
|
||||
viz_type: 'table',
|
||||
}}
|
||||
ownState={{}}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.hover(await screen.findByText('Data Export Options'));
|
||||
userEvent.hover(await screen.findByText('Export Current View'));
|
||||
userEvent.click(await screen.findByText('Export to .CSV'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/The chart data is too large to download/),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,11 @@
|
||||
*/
|
||||
import { exportChart } from '.';
|
||||
|
||||
jest.mock('src/utils/export', () => ({
|
||||
...jest.requireActual('src/utils/export'),
|
||||
downloadBlob: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock pathUtils to control app root prefix
|
||||
jest.mock('src/utils/pathUtils', () => ({
|
||||
ensureAppRoot: jest.fn((path: string) => path),
|
||||
@@ -27,6 +32,7 @@ jest.mock('src/utils/pathUtils', () => ({
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
SupersetClient: {
|
||||
postBlob: jest.fn(),
|
||||
postForm: jest.fn(),
|
||||
get: jest.fn().mockResolvedValue({ json: {} }),
|
||||
post: jest.fn().mockResolvedValue({ json: {} }),
|
||||
@@ -41,6 +47,14 @@ jest.mock('@superset-ui/core', () => ({
|
||||
|
||||
const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils');
|
||||
const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core');
|
||||
const { downloadBlob } = jest.requireMock('src/utils/export');
|
||||
|
||||
const mockBlob = new Blob(['test data'], { type: 'text/csv' });
|
||||
|
||||
const createMockExportResponse = (headers: Headers = new Headers()) => ({
|
||||
headers,
|
||||
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||
});
|
||||
|
||||
// Minimal formData that won't trigger legacy API (useLegacyApi = false)
|
||||
const baseFormData = {
|
||||
@@ -113,22 +127,24 @@ test('exportChart v1 API passes nested prefix for deeply nested deployments', as
|
||||
expect(callArgs.exportType).toBe('xlsx');
|
||||
});
|
||||
|
||||
// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot
|
||||
// Regression test for the double-prefix bug: SupersetClient.postBlob adds appRoot
|
||||
// internally via getUrl(), so the URL passed must NOT already be prefixed.
|
||||
test('exportChart v1 API calls postForm with unprefixed URL when app root is configured', async () => {
|
||||
test('exportChart v1 API calls postBlob with unprefixed URL when app root is configured', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const appRoot = '/analytics';
|
||||
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
|
||||
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postForm.mock.calls[0];
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postBlob.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/chart/data');
|
||||
expect(url).not.toContain(appRoot);
|
||||
expect(downloadBlob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart passes csv exportType for CSV exports', async () => {
|
||||
@@ -240,9 +256,10 @@ test('exportChart legacy API builds relative URL for xlsx export', async () => {
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
|
||||
});
|
||||
|
||||
test('exportChart legacy API calls postForm with relative URL', async () => {
|
||||
test('exportChart legacy API calls postBlob with relative URL', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
ensureAppRoot.mockImplementation((path: string) => path);
|
||||
SupersetClient.postBlob.mockResolvedValue(createMockExportResponse());
|
||||
|
||||
getChartMetadataRegistry.mockReturnValue({
|
||||
get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
|
||||
@@ -259,10 +276,11 @@ test('exportChart legacy API calls postForm with relative URL', async () => {
|
||||
resultType: 'full',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postForm.mock.calls[0];
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
const [url] = SupersetClient.postBlob.mock.calls[0];
|
||||
expect(url).toBe('/superset/explore_json/?csv=true');
|
||||
expect(url).not.toMatch(/^https?:\/\//);
|
||||
expect(downloadBlob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart legacy API includes force param when force=true', async () => {
|
||||
@@ -289,3 +307,187 @@ test('exportChart legacy API includes force param when force=true', async () =>
|
||||
const callArgs = onStartStreamingExport.mock.calls[0][0];
|
||||
expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
|
||||
});
|
||||
|
||||
test('exportChart successfully exports chart as CSV', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse();
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
resultType: 'full',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.blob).toHaveBeenCalled();
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
expect.stringContaining('.csv'),
|
||||
);
|
||||
});
|
||||
|
||||
test('exportChart successfully exports chart as Excel', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse();
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'xlsx',
|
||||
resultType: 'results',
|
||||
});
|
||||
|
||||
expect(SupersetClient.postBlob).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.blob).toHaveBeenCalled();
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
expect.stringContaining('.xlsx'),
|
||||
);
|
||||
});
|
||||
|
||||
test('exportChart throws error with status 413 when payload is too large', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockErrorResponse = new Response('Payload Too Large', {
|
||||
status: 413,
|
||||
statusText: 'Payload Too Large',
|
||||
});
|
||||
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
|
||||
|
||||
await expect(
|
||||
exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 413,
|
||||
message: expect.stringContaining('413'),
|
||||
});
|
||||
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart throws error with status 500 for server errors', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockErrorResponse = new Response('Internal Server Error', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
SupersetClient.postBlob.mockRejectedValue(mockErrorResponse);
|
||||
|
||||
await expect(
|
||||
exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'json',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: expect.stringContaining('500'),
|
||||
});
|
||||
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart enhances errors without status property', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const genericError = new Error('Network error');
|
||||
SupersetClient.postBlob.mockRejectedValue(genericError);
|
||||
|
||||
await expect(
|
||||
exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: expect.stringContaining('Network error'),
|
||||
});
|
||||
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart uses streaming export when onStartStreamingExport is provided', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockStreamingHandler = jest.fn();
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: mockStreamingHandler as unknown as null,
|
||||
});
|
||||
|
||||
expect(mockStreamingHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockStreamingHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/api/v1/chart/data',
|
||||
exportType: 'csv',
|
||||
}),
|
||||
);
|
||||
expect(SupersetClient.postBlob).not.toHaveBeenCalled();
|
||||
expect(downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('exportChart generates correct filename with timestamp', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse();
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
const mockDate = new Date('2025-01-14T12:34:56.789Z');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
expect.stringMatching(
|
||||
/^chart_export_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.csv$/,
|
||||
),
|
||||
);
|
||||
|
||||
jest.spyOn(global, 'Date').mockRestore();
|
||||
});
|
||||
|
||||
test('exportChart uses filename from Content-Disposition header', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockResponse = createMockExportResponse(
|
||||
new Headers({
|
||||
'Content-Disposition': 'attachment; filename="export.zip"',
|
||||
}),
|
||||
);
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(downloadBlob).toHaveBeenCalledWith(mockBlob, 'export.zip');
|
||||
});
|
||||
|
||||
test('exportChart uses zip extension when Content-Type is application/zip', async () => {
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
const mockDate = new Date('2025-01-14T12:34:56.789Z');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
|
||||
const mockResponse = createMockExportResponse(
|
||||
new Headers({
|
||||
'Content-Type': 'application/zip',
|
||||
}),
|
||||
);
|
||||
SupersetClient.postBlob.mockResolvedValue(mockResponse);
|
||||
|
||||
await exportChart({
|
||||
formData: baseFormData,
|
||||
resultFormat: 'csv',
|
||||
});
|
||||
|
||||
expect(downloadBlob).toHaveBeenCalledWith(
|
||||
mockBlob,
|
||||
'chart_export_2025-01-14T12-34-56.zip',
|
||||
);
|
||||
|
||||
jest.spyOn(global, 'Date').mockRestore();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import { availableDomains } from 'src/utils/hostNamesConfig';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import { optionLabel } from 'src/utils/common';
|
||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
||||
import { downloadBlob, getFilenameFromResponse } from 'src/utils/export';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import {
|
||||
DISABLE_INPUT_OPERATORS,
|
||||
@@ -398,11 +399,54 @@ export const exportChart = async ({
|
||||
exportSource: 'chart',
|
||||
});
|
||||
} else {
|
||||
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
|
||||
// Use AJAX blob download instead of form submission to enable error handling.
|
||||
// SupersetClient.postBlob calls getUrl({ endpoint }) internally, which prepends
|
||||
// appRoot — so the URL must NOT be pre-prefixed here.
|
||||
SupersetClient.postForm(url as string, {
|
||||
form_data: safeStringify(payload),
|
||||
});
|
||||
try {
|
||||
const response = await SupersetClient.postBlob(url as string, {
|
||||
form_data: safeStringify(payload),
|
||||
});
|
||||
|
||||
const extension = resultFormat === 'xlsx' ? 'xlsx' : resultFormat;
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, '-')
|
||||
.slice(0, -5);
|
||||
const fallbackFilename = `chart_export_${timestamp}.${extension}`;
|
||||
const filename = getFilenameFromResponse(response, fallbackFilename);
|
||||
|
||||
const blob = await response.blob();
|
||||
downloadBlob(blob, filename);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
const responseError = new Error(
|
||||
`HTTP ${error.status} ${error.statusText}`,
|
||||
) as Error & {
|
||||
status: number;
|
||||
statusText: string;
|
||||
response: Response;
|
||||
};
|
||||
responseError.status = error.status;
|
||||
responseError.statusText = error.statusText;
|
||||
responseError.response = error;
|
||||
throw responseError;
|
||||
}
|
||||
|
||||
const exportError = error as Error & {
|
||||
status?: number;
|
||||
originalError?: unknown;
|
||||
};
|
||||
if (!exportError.status) {
|
||||
const enhancedError = new Error(
|
||||
exportError.message || 'Export failed',
|
||||
) as Error & { status: number; originalError: unknown };
|
||||
enhancedError.status = 500;
|
||||
enhancedError.originalError = error;
|
||||
throw enhancedError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
remoteEntry: '',
|
||||
extensionDependencies: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ afterEach(() => {
|
||||
test('renders without crashing', () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
// Verify the global superset object is set up
|
||||
expect((window as any).superset).toBeDefined();
|
||||
expect((window as any).superset.authentication).toBeDefined();
|
||||
expect((window as any).superset.chat).toBeDefined();
|
||||
expect((window as any).superset.core).toBeDefined();
|
||||
expect((window as any).superset.commands).toBeDefined();
|
||||
expect((window as any).superset.extensions).toBeDefined();
|
||||
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
test('does not set up global superset object when user is not logged in', async () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
|
||||
|
||||
const { rerender } = render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
|
||||
</ExtensionsStartup>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -17,41 +17,32 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
useNavigationTracker,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from 'src/views/store';
|
||||
import ExtensionsLoader from './ExtensionsLoader';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
commands: typeof commands;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
};
|
||||
}
|
||||
}
|
||||
import 'src/extensions/Namespaces';
|
||||
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
useNavigationTracker();
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
);
|
||||
@@ -59,15 +50,19 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
useEffect(() => {
|
||||
if (userId == null) return;
|
||||
|
||||
// Provide the implementations for @apache-superset/core
|
||||
// Provide the implementations for @apache-superset/core.
|
||||
// Namespaces are listed explicitly — do not spread the core package here,
|
||||
// as that would leak un-contracted symbols onto window.superset.
|
||||
window.superset = {
|
||||
...supersetCore,
|
||||
authentication,
|
||||
chat,
|
||||
core,
|
||||
commands,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
};
|
||||
|
||||
60
superset-frontend/src/extensions/Namespaces.ts
Normal file
60
superset-frontend/src/extensions/Namespaces.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Global `window.superset` type augmentation.
|
||||
*
|
||||
* Lives in its own module (rather than inline in ExtensionsStartup) so every
|
||||
* file that reads or writes `window.superset` — notably ExtensionsLoader —
|
||||
* sees the type regardless of how files are batched during compilation. Both
|
||||
* the startup component and the loader import this module for its side effect.
|
||||
*/
|
||||
|
||||
import type {
|
||||
authentication,
|
||||
chat,
|
||||
commands,
|
||||
core,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
navigation,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
|
||||
/** The host namespaces exposed to extensions on `window.superset`. */
|
||||
export interface Namespaces {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
chat: typeof chat;
|
||||
commands: typeof commands;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
navigation: typeof navigation;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: Namespaces;
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,7 @@ export const accessTokenField = ({
|
||||
validationErrors,
|
||||
db,
|
||||
isEditMode,
|
||||
isValidating,
|
||||
default_value,
|
||||
description,
|
||||
}: FieldPropTypes) => (
|
||||
@@ -250,6 +251,7 @@ export const accessTokenField = ({
|
||||
id="access_token"
|
||||
name="access_token"
|
||||
required={required}
|
||||
isValidating={isValidating}
|
||||
visibilityToggle={!isEditMode}
|
||||
value={db?.parameters?.access_token}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const TableCatalog = ({
|
||||
getValidation,
|
||||
validationErrors,
|
||||
db,
|
||||
isValidating,
|
||||
isPublic = true,
|
||||
}: FieldPropTypes) => {
|
||||
const tableCatalog = db?.catalog || [];
|
||||
@@ -53,6 +54,7 @@ export const TableCatalog = ({
|
||||
<ValidatedInput
|
||||
className="catalog-name-input"
|
||||
required={required}
|
||||
isValidating={isValidating}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={catalogError[idx]?.name}
|
||||
placeholder={t('Enter a name for this sheet')}
|
||||
@@ -86,6 +88,7 @@ export const TableCatalog = ({
|
||||
<ValidatedInput
|
||||
className="catalog-name-url"
|
||||
required={required}
|
||||
isValidating={isValidating}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={catalogError[idx]?.url}
|
||||
placeholder={t('Paste the shareable Google Sheet URL here')}
|
||||
|
||||
@@ -49,11 +49,13 @@ export const validatedInputField = ({
|
||||
validationErrors,
|
||||
db,
|
||||
field,
|
||||
isValidating,
|
||||
}: FieldPropTypes) => (
|
||||
<ValidatedInput
|
||||
id={field}
|
||||
name={field}
|
||||
required={required}
|
||||
isValidating={isValidating}
|
||||
value={db?.parameters?.[field as keyof DatabaseParameters]}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={validationErrors?.[field]}
|
||||
|
||||
@@ -18,18 +18,21 @@
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { JsonObject } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import {
|
||||
Form,
|
||||
FormLabel,
|
||||
Col,
|
||||
Row,
|
||||
LabeledErrorBoundInput,
|
||||
Icons,
|
||||
Tooltip,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Input } from '@superset-ui/core/components/Input';
|
||||
import { Radio } from '@superset-ui/core/components/Radio';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { DatabaseObject, FieldPropTypes } from '../types';
|
||||
import { DatabaseObject, CustomEventHandlerType } from '../types';
|
||||
import { AuthType } from '.';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
@@ -48,50 +51,73 @@ const StyledFormItem = styled(Form.Item)`
|
||||
margin-bottom: 0 !important;
|
||||
`;
|
||||
|
||||
const StyledInputPassword = styled(Input.Password)`
|
||||
margin: ${({ theme }) => `${theme.sizeUnit}px 0 ${theme.sizeUnit * 2}px`};
|
||||
`;
|
||||
interface SSHTunnelFormProps {
|
||||
db: DatabaseObject | null;
|
||||
onSSHTunnelParametersChange: CustomEventHandlerType;
|
||||
setSSHTunnelLoginMethod: (method: AuthType) => void;
|
||||
isValidating?: boolean;
|
||||
validationErrors?: JsonObject | null;
|
||||
getValidation: () => void;
|
||||
}
|
||||
|
||||
const SSHTunnelForm = ({
|
||||
db,
|
||||
onSSHTunnelParametersChange,
|
||||
setSSHTunnelLoginMethod,
|
||||
}: {
|
||||
db: DatabaseObject | null;
|
||||
onSSHTunnelParametersChange: FieldPropTypes['changeMethods']['onSSHTunnelParametersChange'];
|
||||
setSSHTunnelLoginMethod: (method: AuthType) => void;
|
||||
}) => {
|
||||
isValidating = false,
|
||||
validationErrors,
|
||||
getValidation,
|
||||
}: SSHTunnelFormProps) => {
|
||||
const [usePassword, setUsePassword] = useState<AuthType>(AuthType.Password);
|
||||
const sshErrors = validationErrors?.ssh_tunnel || {};
|
||||
const sshSectionError = sshErrors?._error;
|
||||
|
||||
return (
|
||||
<Form>
|
||||
{sshSectionError && (
|
||||
<StyledRow gutter={16}>
|
||||
<Col xs={24}>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={sshSectionError}
|
||||
data-test="ssh-tunnel-section-error"
|
||||
/>
|
||||
</Col>
|
||||
</StyledRow>
|
||||
)}
|
||||
<StyledRow gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledDiv>
|
||||
<FormLabel htmlFor="server_address" required>
|
||||
{t('SSH Host')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
<LabeledErrorBoundInput
|
||||
id="server_address"
|
||||
name="server_address"
|
||||
type="text"
|
||||
label={t('SSH Host')}
|
||||
required
|
||||
placeholder={t('e.g. 127.0.0.1')}
|
||||
value={db?.ssh_tunnel?.server_address || ''}
|
||||
onChange={onSSHTunnelParametersChange}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={sshErrors?.server_address}
|
||||
isValidating={isValidating}
|
||||
data-test="ssh-tunnel-server_address-input"
|
||||
/>
|
||||
</StyledDiv>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledDiv>
|
||||
<FormLabel htmlFor="server_port" required>
|
||||
{t('SSH Port')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
<LabeledErrorBoundInput
|
||||
id="server_port"
|
||||
name="server_port"
|
||||
label={t('SSH Port')}
|
||||
required
|
||||
placeholder={t('22')}
|
||||
type="number"
|
||||
value={db?.ssh_tunnel?.server_port}
|
||||
onChange={onSSHTunnelParametersChange}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={sshErrors?.server_port}
|
||||
isValidating={isValidating}
|
||||
data-test="ssh-tunnel-server_port-input"
|
||||
/>
|
||||
</StyledDiv>
|
||||
@@ -100,15 +126,17 @@ const SSHTunnelForm = ({
|
||||
<StyledRow gutter={16}>
|
||||
<Col xs={24}>
|
||||
<StyledDiv>
|
||||
<FormLabel htmlFor="username" required>
|
||||
{t('Username')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
<LabeledErrorBoundInput
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
label={t('Username')}
|
||||
required
|
||||
placeholder={t('e.g. Analytics')}
|
||||
value={db?.ssh_tunnel?.username || ''}
|
||||
onChange={onSSHTunnelParametersChange}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={sshErrors?.username}
|
||||
isValidating={isValidating}
|
||||
data-test="ssh-tunnel-username-input"
|
||||
/>
|
||||
</StyledDiv>
|
||||
@@ -148,16 +176,20 @@ const SSHTunnelForm = ({
|
||||
<StyledRow gutter={16}>
|
||||
<Col xs={24}>
|
||||
<StyledDiv>
|
||||
<FormLabel htmlFor="password" required>
|
||||
{t('SSH Password')}
|
||||
</FormLabel>
|
||||
<StyledInputPassword
|
||||
<LabeledErrorBoundInput
|
||||
id="password"
|
||||
name="password"
|
||||
label={t('SSH Password')}
|
||||
required
|
||||
visibilityToggle
|
||||
placeholder={t('e.g. ********')}
|
||||
value={db?.ssh_tunnel?.password || ''}
|
||||
onChange={onSSHTunnelParametersChange}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={sshErrors?.password}
|
||||
isValidating={isValidating}
|
||||
data-test="ssh-tunnel-password-input"
|
||||
iconRender={visible =>
|
||||
iconRender={(visible: boolean) =>
|
||||
visible ? (
|
||||
<Tooltip title={t('Hide password.')}>
|
||||
<Icons.EyeInvisibleOutlined />
|
||||
@@ -182,30 +214,47 @@ const SSHTunnelForm = ({
|
||||
<FormLabel htmlFor="private_key" required>
|
||||
{t('Private Key')}
|
||||
</FormLabel>
|
||||
<Input.TextArea
|
||||
name="private_key"
|
||||
placeholder={t('Paste Private Key here')}
|
||||
value={db?.ssh_tunnel?.private_key || ''}
|
||||
onChange={onSSHTunnelParametersChange}
|
||||
data-test="ssh-tunnel-private_key-input"
|
||||
rows={4}
|
||||
/>
|
||||
<StyledFormItem
|
||||
validateStatus={
|
||||
isValidating
|
||||
? 'validating'
|
||||
: sshErrors?.private_key
|
||||
? 'error'
|
||||
: 'success'
|
||||
}
|
||||
help={sshErrors?.private_key}
|
||||
hasFeedback={isValidating || !!sshErrors?.private_key}
|
||||
>
|
||||
<Input.TextArea
|
||||
name="private_key"
|
||||
placeholder={t('Paste Private Key here')}
|
||||
value={db?.ssh_tunnel?.private_key || ''}
|
||||
onChange={onSSHTunnelParametersChange}
|
||||
onBlur={getValidation}
|
||||
data-test="ssh-tunnel-private_key-input"
|
||||
rows={4}
|
||||
/>
|
||||
</StyledFormItem>
|
||||
</StyledDiv>
|
||||
</Col>
|
||||
</StyledRow>
|
||||
<StyledRow gutter={16}>
|
||||
<Col xs={24}>
|
||||
<StyledDiv>
|
||||
<FormLabel htmlFor="private_key_password" required>
|
||||
{t('Private Key Password')}
|
||||
</FormLabel>
|
||||
<StyledInputPassword
|
||||
<LabeledErrorBoundInput
|
||||
id="private_key_password"
|
||||
name="private_key_password"
|
||||
label={t('Private Key Password')}
|
||||
required
|
||||
visibilityToggle
|
||||
placeholder={t('e.g. ********')}
|
||||
value={db?.ssh_tunnel?.private_key_password || ''}
|
||||
onChange={onSSHTunnelParametersChange}
|
||||
validationMethods={{ onBlur: getValidation }}
|
||||
errorMessage={sshErrors?.private_key_password}
|
||||
isValidating={isValidating}
|
||||
data-test="ssh-tunnel-private_key_password-input"
|
||||
iconRender={visible =>
|
||||
iconRender={(visible: boolean) =>
|
||||
visible ? (
|
||||
<Tooltip title={t('Hide password.')}>
|
||||
<Icons.EyeInvisibleOutlined />
|
||||
|
||||
@@ -1212,26 +1212,40 @@ describe('DatabaseModal', () => {
|
||||
'ssh-tunnel-server_address-input',
|
||||
);
|
||||
expect(SSHTunnelServerAddressInput).toHaveValue('');
|
||||
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
|
||||
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
|
||||
fireEvent.change(SSHTunnelServerAddressInput, {
|
||||
target: { value: 'localhost' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
|
||||
);
|
||||
const SSHTunnelServerPortInput = screen.getByTestId(
|
||||
'ssh-tunnel-server_port-input',
|
||||
);
|
||||
expect(SSHTunnelServerPortInput).toHaveValue(null);
|
||||
userEvent.type(SSHTunnelServerPortInput, '22');
|
||||
expect(SSHTunnelServerPortInput).toHaveValue(22);
|
||||
fireEvent.change(SSHTunnelServerPortInput, {
|
||||
target: { value: '22' },
|
||||
});
|
||||
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
|
||||
const SSHTunnelUsernameInput = screen.getByTestId(
|
||||
'ssh-tunnel-username-input',
|
||||
);
|
||||
expect(SSHTunnelUsernameInput).toHaveValue('');
|
||||
userEvent.type(SSHTunnelUsernameInput, 'test');
|
||||
expect(SSHTunnelUsernameInput).toHaveValue('test');
|
||||
fireEvent.change(SSHTunnelUsernameInput, {
|
||||
target: { value: 'test' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(SSHTunnelUsernameInput).toHaveValue('test'),
|
||||
);
|
||||
const SSHTunnelPasswordInput = screen.getByTestId(
|
||||
'ssh-tunnel-password-input',
|
||||
);
|
||||
expect(SSHTunnelPasswordInput).toHaveValue('');
|
||||
userEvent.type(SSHTunnelPasswordInput, 'pass');
|
||||
expect(SSHTunnelPasswordInput).toHaveValue('pass');
|
||||
fireEvent.change(SSHTunnelPasswordInput, {
|
||||
target: { value: 'pass' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
|
||||
);
|
||||
});
|
||||
|
||||
test('properly interacts with SSH Tunnel form textboxes', async () => {
|
||||
@@ -1250,26 +1264,40 @@ describe('DatabaseModal', () => {
|
||||
'ssh-tunnel-server_address-input',
|
||||
);
|
||||
expect(SSHTunnelServerAddressInput).toHaveValue('');
|
||||
userEvent.type(SSHTunnelServerAddressInput, 'localhost');
|
||||
expect(SSHTunnelServerAddressInput).toHaveValue('localhost');
|
||||
fireEvent.change(SSHTunnelServerAddressInput, {
|
||||
target: { value: 'localhost' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(SSHTunnelServerAddressInput).toHaveValue('localhost'),
|
||||
);
|
||||
const SSHTunnelServerPortInput = screen.getByTestId(
|
||||
'ssh-tunnel-server_port-input',
|
||||
);
|
||||
expect(SSHTunnelServerPortInput).toHaveValue(null);
|
||||
userEvent.type(SSHTunnelServerPortInput, '22');
|
||||
expect(SSHTunnelServerPortInput).toHaveValue(22);
|
||||
fireEvent.change(SSHTunnelServerPortInput, {
|
||||
target: { value: '22' },
|
||||
});
|
||||
await waitFor(() => expect(SSHTunnelServerPortInput).toHaveValue(22));
|
||||
const SSHTunnelUsernameInput = screen.getByTestId(
|
||||
'ssh-tunnel-username-input',
|
||||
);
|
||||
expect(SSHTunnelUsernameInput).toHaveValue('');
|
||||
userEvent.type(SSHTunnelUsernameInput, 'test');
|
||||
expect(SSHTunnelUsernameInput).toHaveValue('test');
|
||||
fireEvent.change(SSHTunnelUsernameInput, {
|
||||
target: { value: 'test' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(SSHTunnelUsernameInput).toHaveValue('test'),
|
||||
);
|
||||
const SSHTunnelPasswordInput = screen.getByTestId(
|
||||
'ssh-tunnel-password-input',
|
||||
);
|
||||
expect(SSHTunnelPasswordInput).toHaveValue('');
|
||||
userEvent.type(SSHTunnelPasswordInput, 'pass');
|
||||
expect(SSHTunnelPasswordInput).toHaveValue('pass');
|
||||
fireEvent.change(SSHTunnelPasswordInput, {
|
||||
target: { value: 'pass' },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(SSHTunnelPasswordInput).toHaveValue('pass'),
|
||||
);
|
||||
});
|
||||
|
||||
test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => {
|
||||
@@ -1364,7 +1392,10 @@ describe('DatabaseModal', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const textboxes = screen.getAllByRole('textbox');
|
||||
// Wait for step 2 to render
|
||||
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
|
||||
|
||||
const textboxes = await screen.findAllByRole('textbox');
|
||||
const hostField = textboxes[0];
|
||||
const portField = screen.getByRole('spinbutton');
|
||||
const databaseNameField = textboxes[1];
|
||||
@@ -1380,15 +1411,20 @@ describe('DatabaseModal', () => {
|
||||
|
||||
expect(connectButton).toBeDisabled();
|
||||
|
||||
userEvent.type(hostField, 'localhost');
|
||||
userEvent.type(portField, '5432');
|
||||
userEvent.type(databaseNameField, 'postgres');
|
||||
userEvent.type(usernameField, 'testdb');
|
||||
userEvent.type(passwordField, 'demoPassword');
|
||||
fireEvent.change(hostField, { target: { value: 'localhost' } });
|
||||
fireEvent.blur(hostField);
|
||||
fireEvent.change(portField, { target: { value: '5432' } });
|
||||
fireEvent.blur(portField);
|
||||
fireEvent.change(databaseNameField, { target: { value: 'postgres' } });
|
||||
fireEvent.blur(databaseNameField);
|
||||
fireEvent.change(usernameField, { target: { value: 'testdb' } });
|
||||
fireEvent.blur(usernameField);
|
||||
fireEvent.change(passwordField, { target: { value: 'demoPassword' } });
|
||||
fireEvent.blur(passwordField);
|
||||
|
||||
await waitFor(() => expect(connectButton).toBeEnabled());
|
||||
|
||||
expect(await screen.findByDisplayValue(/5432/i)).toBeInTheDocument();
|
||||
await waitFor(() => expect(portField).toHaveValue(5432));
|
||||
expect(hostField).toHaveValue('localhost');
|
||||
expect(portField).toHaveValue(5432);
|
||||
expect(databaseNameField).toHaveValue('postgres');
|
||||
@@ -1397,10 +1433,48 @@ describe('DatabaseModal', () => {
|
||||
|
||||
expect(connectButton).toBeEnabled();
|
||||
userEvent.click(connectButton);
|
||||
// Verify that validation was called during the form interaction
|
||||
// Note: With the optimized validation, redundant calls on the same db state are skipped
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
|
||||
).toEqual(5);
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not fire redundant validation on blur when db has not changed', async () => {
|
||||
setup();
|
||||
|
||||
userEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /postgresql/i,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
|
||||
|
||||
const textboxes = await screen.findAllByRole('textbox');
|
||||
const hostField = textboxes[0];
|
||||
|
||||
// Type a value and blur - should trigger validation
|
||||
fireEvent.change(hostField, { target: { value: 'localhost' } });
|
||||
fireEvent.blur(hostField);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
// Blur again without changing the value - should NOT trigger another validation
|
||||
fireEvent.focus(hostField);
|
||||
fireEvent.blur(hostField);
|
||||
|
||||
// Wait a tick to ensure no additional calls are made
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(VALIDATE_PARAMS_ENDPOINT).length,
|
||||
).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -668,6 +668,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
hasValidated,
|
||||
setHasValidated,
|
||||
] = useDatabaseValidation();
|
||||
const lastValidatedDbSnapshotRef = useRef<string | null>(null);
|
||||
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
|
||||
const [showCTAbtns, setShowCTAbtns] = useState(false);
|
||||
const [dbName, setDbName] = useState('');
|
||||
@@ -775,6 +776,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
const handleClearValidationErrors = useCallback(() => {
|
||||
setValidationErrors(null);
|
||||
setHasValidated(false);
|
||||
lastValidatedDbSnapshotRef.current = null;
|
||||
clearError();
|
||||
}, [setValidationErrors, setHasValidated, clearError]);
|
||||
|
||||
@@ -851,6 +853,16 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
({ target }: { target: HTMLInputElement }) => {
|
||||
onChange(ActionType.TextChange, {
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleChangeWithValidation = useCallback(
|
||||
(
|
||||
actionType: ActionType,
|
||||
@@ -862,6 +874,21 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
[onChange, handleClearValidationErrors],
|
||||
);
|
||||
|
||||
const getBlurValidation = useCallback(async () => {
|
||||
const currentDbSnapshot = JSON.stringify(db);
|
||||
if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) {
|
||||
return [];
|
||||
}
|
||||
const result = await getValidation(db);
|
||||
// Only cache after a request that produced a usable response. ``null``
|
||||
// signals an unexpected/network failure, in which case we leave the
|
||||
// snapshot untouched so the next blur retries.
|
||||
if (result !== null) {
|
||||
lastValidatedDbSnapshotRef.current = currentDbSnapshot;
|
||||
}
|
||||
return result;
|
||||
}, [db, getValidation]);
|
||||
|
||||
const onClose = () => {
|
||||
setDB({ type: ActionType.Reset });
|
||||
setHasConnectedDb(false);
|
||||
@@ -940,7 +967,15 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
}
|
||||
|
||||
const errors = await getValidation(dbToUpdate, true);
|
||||
if (!isEmpty(validationErrors) || errors?.length) {
|
||||
// ``getValidation`` returns ``[]`` on success, a field-keyed object
|
||||
// for blocking errors (e.g. the duplicate ``database_name`` check),
|
||||
// and ``null`` for stale or unexpected responses. During save we
|
||||
// cannot proceed without a usable result, so treat ``null`` as
|
||||
// blocking too — only ``[]`` is a clean pass.
|
||||
const hasReturnedErrors =
|
||||
errors === null ||
|
||||
(Array.isArray(errors) ? errors.length > 0 : !isEmpty(errors));
|
||||
if (!isEmpty(validationErrors) || hasReturnedErrors) {
|
||||
addDangerToast(
|
||||
t('Connection failed, please check your connection settings.'),
|
||||
);
|
||||
@@ -1845,7 +1880,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
});
|
||||
handleClearValidationErrors();
|
||||
}}
|
||||
setSSHTunnelLoginMethod={(method: AuthType) =>
|
||||
setDB({
|
||||
@@ -1853,6 +1887,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
payload: { login_method: method },
|
||||
})
|
||||
}
|
||||
isValidating={isValidating}
|
||||
validationErrors={validationErrors}
|
||||
getValidation={getBlurValidation}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1928,13 +1965,8 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
});
|
||||
}}
|
||||
onParametersChange={handleParametersChange}
|
||||
onChange={({ target }: { target: HTMLInputElement }) =>
|
||||
handleChangeWithValidation(ActionType.TextChange, {
|
||||
name: target.name,
|
||||
value: target.value,
|
||||
})
|
||||
}
|
||||
getValidation={() => getValidation(db)}
|
||||
onChange={handleTextChange}
|
||||
getValidation={getBlurValidation}
|
||||
validationErrors={validationErrors}
|
||||
getPlaceholder={getPlaceholder}
|
||||
clearValidationErrors={handleClearValidationErrors}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ function DashboardTable({
|
||||
name: t('Dashboard'),
|
||||
buttonStyle: 'secondary',
|
||||
onClick: () => {
|
||||
navigateTo('/dashboard/new', { assign: true });
|
||||
navigateTo('/dashboard/new/', { assign: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -89,7 +89,7 @@ const dropdownItems = [
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import { isFeatureEnabled, FeatureFlag, CACHE_KEY } from '@superset-ui/core';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import RightMenu from './RightMenu';
|
||||
import { GlobalMenuDataOptions, RightMenuProps } from './types';
|
||||
@@ -105,7 +105,7 @@ const dropdownItems = [
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: 'fa-fw fa-dashboard',
|
||||
perm: 'can_write',
|
||||
view: 'Dashboard',
|
||||
@@ -401,17 +401,35 @@ test('Logs out and clears local storage item redux', async () => {
|
||||
expect(localStorage.getItem('redux')).not.toBeNull();
|
||||
expect(sessionStorage.getItem('login_attempted')).not.toBeNull();
|
||||
|
||||
await userEvent.hover(await screen.findByText(/Settings/i));
|
||||
// Mock the Cache API so we can assert the namespaced store is purged.
|
||||
const cacheGlobal = global as unknown as { caches?: CacheStorage };
|
||||
const priorCaches = cacheGlobal.caches;
|
||||
const deleteMock = jest.fn().mockResolvedValue(true);
|
||||
cacheGlobal.caches = { delete: deleteMock } as unknown as CacheStorage;
|
||||
|
||||
// Simulate user clicking the logout button
|
||||
const logoutButton = await screen.findByText('Logout');
|
||||
await userEvent.click(logoutButton);
|
||||
try {
|
||||
await userEvent.hover(await screen.findByText(/Settings/i));
|
||||
|
||||
// Wait for local and session storage to be cleared
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('redux')).toBeNull();
|
||||
expect(sessionStorage.getItem('login_attempted')).toBeNull();
|
||||
});
|
||||
// Simulate user clicking the logout button
|
||||
const logoutButton = await screen.findByText('Logout');
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
// Wait for local and session storage to be cleared
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem('redux')).toBeNull();
|
||||
expect(sessionStorage.getItem('login_attempted')).toBeNull();
|
||||
});
|
||||
// The namespaced Cache API store is purged on logout.
|
||||
expect(deleteMock).toHaveBeenCalledWith(CACHE_KEY);
|
||||
} finally {
|
||||
// Restore the global so an early assertion failure cannot leak the mock
|
||||
// into other tests.
|
||||
if (priorCaches === undefined) {
|
||||
delete cacheGlobal.caches;
|
||||
} else {
|
||||
cacheGlobal.caches = priorCaches;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('shows logout button when not embedded', async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getExtensionsRegistry,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
CACHE_KEY,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
styled,
|
||||
@@ -232,7 +233,7 @@ const RightMenu = ({
|
||||
},
|
||||
{
|
||||
label: t('Dashboard'),
|
||||
url: '/dashboard/new',
|
||||
url: '/dashboard/new/',
|
||||
icon: (
|
||||
<Icons.DashboardOutlined data-test={`menu-item-${t('Dashboard')}`} />
|
||||
),
|
||||
@@ -353,6 +354,14 @@ const RightMenu = ({
|
||||
try {
|
||||
window.localStorage.removeItem('redux');
|
||||
window.sessionStorage.removeItem('login_attempted');
|
||||
// Purge the namespaced Cache API store so cached GET responses are not
|
||||
// retained on the device after the session ends. Best-effort: the
|
||||
// returned promise is not awaited since logout navigates away.
|
||||
if (typeof caches !== 'undefined') {
|
||||
caches.delete(CACHE_KEY).catch(() => {
|
||||
/* best-effort: ignore cache deletion failures */
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear storage on logout:', error);
|
||||
}
|
||||
|
||||
@@ -252,9 +252,7 @@ describe('RoleListEditModal', () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
|
||||
) {
|
||||
// Only return permission id=10, not id=20
|
||||
return Promise.resolve({
|
||||
@@ -298,9 +296,7 @@ describe('RoleListEditModal', () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
|
||||
) {
|
||||
return Promise.reject(new Error('network error'));
|
||||
}
|
||||
@@ -371,7 +367,9 @@ describe('RoleListEditModal', () => {
|
||||
};
|
||||
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
|
||||
if (
|
||||
endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)
|
||||
) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleA.permission_ids.map(pid => ({
|
||||
@@ -382,7 +380,9 @@ describe('RoleListEditModal', () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
|
||||
if (
|
||||
endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)
|
||||
) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleB.permission_ids.map(pid => ({
|
||||
|
||||
@@ -33,7 +33,12 @@ import { ensureAppRoot } from '../utils/pathUtils';
|
||||
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
|
||||
import type { QueryEditor } from '../SqlLab/types';
|
||||
|
||||
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
|
||||
type LogEventSource =
|
||||
| 'dashboard'
|
||||
| 'embedded_dashboard'
|
||||
| 'explore'
|
||||
| 'sqlLab'
|
||||
| 'slice';
|
||||
|
||||
interface LogEventData {
|
||||
source?: LogEventSource;
|
||||
|
||||
@@ -762,7 +762,7 @@ function DashboardList(props: DashboardListProps) {
|
||||
name: t('Dashboard'),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => {
|
||||
navigateTo('/dashboard/new', { assign: true });
|
||||
navigateTo('/dashboard/new/', { assign: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 from './export';
|
||||
import handleResourceExport, { getFilenameFromResponse } from './export';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -454,3 +454,59 @@ 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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,35 @@ const MAX_BLOB_SIZE = 100 * 1024 * 1024;
|
||||
* @param blob - The blob to download
|
||||
* @param fileName - The filename to use for the download
|
||||
*/
|
||||
function downloadBlob(blob: Blob, fileName: string): void {
|
||||
/**
|
||||
* 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 {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -57,6 +57,7 @@ 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 = {
|
||||
@@ -78,6 +79,7 @@ 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 };
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
35
superset-frontend/src/utils/sanitizeDocumentTitle.test.ts
Normal file
35
superset-frontend/src/utils/sanitizeDocumentTitle.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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 你好');
|
||||
});
|
||||
27
superset-frontend/src/utils/sanitizeDocumentTitle.ts
Normal file
27
superset-frontend/src/utils/sanitizeDocumentTitle.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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+0000–U+001F, U+007F–U+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, '');
|
||||
}
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { Layout, Loading } from '@superset-ui/core/components';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Flex, 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,7 +39,12 @@ 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';
|
||||
|
||||
@@ -79,42 +84,139 @@ 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>
|
||||
<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>
|
||||
<Flex
|
||||
vertical
|
||||
css={css`
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
`}
|
||||
>
|
||||
<Menu
|
||||
data={bootstrapData.common.menu_data}
|
||||
isFrontendRoute={isFrontendRoute}
|
||||
/>
|
||||
<ExtensionsStartup>
|
||||
<AppContent />
|
||||
</ExtensionsStartup>
|
||||
</Flex>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
</Router>
|
||||
|
||||
@@ -819,16 +819,13 @@ export function useDatabaseValidation() {
|
||||
);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [hasValidated, setHasValidated] = useState(false);
|
||||
const latestRequestIdRef = useRef(0);
|
||||
|
||||
const getValidation = useCallback(
|
||||
async (database: Partial<DatabaseObject> | null, onCreate = false) => {
|
||||
if (database?.parameters?.ssh) {
|
||||
setValidationErrors(null);
|
||||
setIsValidating(false);
|
||||
setHasValidated(true);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const requestId = latestRequestIdRef.current + 1;
|
||||
latestRequestIdRef.current = requestId;
|
||||
const isLatest = () => latestRequestIdRef.current === requestId;
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
@@ -837,6 +834,9 @@ export function useDatabaseValidation() {
|
||||
body: JSON.stringify(transformDB(database)),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
// Stale responses return ``null`` so callers can tell the result
|
||||
// apart from a real, current outcome and skip caching it.
|
||||
if (!isLatest()) return null;
|
||||
setValidationErrors(null);
|
||||
setIsValidating(false);
|
||||
setHasValidated(true);
|
||||
@@ -845,12 +845,18 @@ export function useDatabaseValidation() {
|
||||
if (typeof error.json === 'function') {
|
||||
return error.json().then(({ errors = [] }) => {
|
||||
const parsedErrors = errors
|
||||
.filter((err: { error_type: string }) => {
|
||||
.filter((err: { error_type: string; extra?: JsonObject }) => {
|
||||
const allowed = [
|
||||
'CONNECTION_MISSING_PARAMETERS_ERROR',
|
||||
'CONNECTION_ACCESS_DENIED_ERROR',
|
||||
'INVALID_PAYLOAD_SCHEMA_ERROR',
|
||||
];
|
||||
// SSH-tunnel section errors carry their own ``ssh_tunnel``
|
||||
// marker and need to surface during blur validation too,
|
||||
// otherwise feature-gate failures become invisible
|
||||
// blockers (the save guard would still trip but with no
|
||||
// hint about why).
|
||||
if (err.extra?.ssh_tunnel) return true;
|
||||
return allowed.includes(err.error_type) || onCreate;
|
||||
})
|
||||
.reduce((acc: JsonObject, err2: any) => {
|
||||
@@ -866,6 +872,28 @@ export function useDatabaseValidation() {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (extra?.ssh_tunnel) {
|
||||
// Field-level errors come in via ``extra.missing``;
|
||||
// section-level errors (e.g. feature flag disabled) do
|
||||
// not name a specific field, so preserve the server
|
||||
// message under a reserved ``_error`` key so the SSH
|
||||
// form can render it instead of silently dropping it.
|
||||
const missingFields = extra.missing ?? [];
|
||||
acc.ssh_tunnel = {
|
||||
...acc.ssh_tunnel,
|
||||
...Object.fromEntries(
|
||||
missingFields.map((field: string) => [
|
||||
field,
|
||||
'This is a required field',
|
||||
]),
|
||||
),
|
||||
...(missingFields.length === 0 && message
|
||||
? { _error: message }
|
||||
: {}),
|
||||
};
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (extra?.invalid) {
|
||||
extra.invalid.forEach((field: string) => {
|
||||
acc[field] = message;
|
||||
@@ -885,6 +913,7 @@ export function useDatabaseValidation() {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (!isLatest()) return null;
|
||||
setValidationErrors(parsedErrors);
|
||||
setIsValidating(false);
|
||||
setHasValidated(true);
|
||||
@@ -893,9 +922,11 @@ export function useDatabaseValidation() {
|
||||
}
|
||||
|
||||
console.error('Unexpected error during validation:', error);
|
||||
setIsValidating(false);
|
||||
setHasValidated(true);
|
||||
return {};
|
||||
if (isLatest()) {
|
||||
setIsValidating(false);
|
||||
setHasValidated(true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[setValidationErrors],
|
||||
|
||||
60
superset-frontend/src/views/routePaths.ts
Normal file
60
superset-frontend/src/views/routePaths.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export const RoutePaths = {
|
||||
REDIRECT: '/redirect/',
|
||||
LOGIN: '/login/',
|
||||
REGISTER_ACTIVATION: '/register/activation/:activationHash',
|
||||
REGISTER: '/register/',
|
||||
LOGOUT: '/logout/',
|
||||
HOME: '/superset/welcome/',
|
||||
FILE_HANDLER: '/superset/file-handler',
|
||||
DASHBOARD: '/superset/dashboard/:idOrSlug/',
|
||||
DASHBOARD_LIST: '/dashboard/list/',
|
||||
CHART_ADD: '/chart/add',
|
||||
CHART_LIST: '/chart/list/',
|
||||
DATASET_LIST: '/tablemodelview/list/',
|
||||
DATABASE_LIST: '/databaseview/list/',
|
||||
SAVED_QUERIES: '/savedqueryview/list/',
|
||||
CSS_TEMPLATES: '/csstemplatemodelview/list/',
|
||||
THEMES: '/theme/list/',
|
||||
ANNOTATION_LAYERS: '/annotationlayer/list/',
|
||||
ANNOTATION_LIST: '/annotationlayer/:annotationLayerId/annotation/',
|
||||
QUERY_HISTORY: '/sqllab/history/',
|
||||
ALERTS: '/alert/list/',
|
||||
REPORTS: '/report/list/',
|
||||
ALERT_LOG: '/alert/:alertId/log/',
|
||||
REPORT_LOG: '/report/:alertId/log/',
|
||||
EXPLORE: '/explore/',
|
||||
EXPLORE_PERMALINK: '/superset/explore/p',
|
||||
DATASET_ADD: '/dataset/add/',
|
||||
DATASET: '/dataset/:datasetId',
|
||||
ROW_LEVEL_SECURITY: '/rowlevelsecurity/list',
|
||||
TASKS: '/tasks/list/',
|
||||
SQLLAB: '/sqllab/',
|
||||
USER_INFO: '/user_info/',
|
||||
ACTION_LOG: '/actionlog/list',
|
||||
REGISTRATIONS: '/registrations/',
|
||||
ALL_ENTITIES: '/superset/all_entities/',
|
||||
TAGS: '/superset/tags/',
|
||||
ROLES: '/roles/',
|
||||
USERS: '/users/',
|
||||
GROUPS: '/list_groups/',
|
||||
EXTENSIONS: '/extensions/list/',
|
||||
} as const;
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from 'react';
|
||||
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { RoutePaths } from './routePaths';
|
||||
|
||||
// not lazy loaded since this is the home page.
|
||||
import Home from 'src/pages/Home';
|
||||
@@ -189,158 +190,58 @@ const RedirectWarning = lazy(
|
||||
|
||||
type Routes = {
|
||||
path: string;
|
||||
Component: ComponentType;
|
||||
Fallback?: ComponentType;
|
||||
Component: ComponentType<any>;
|
||||
Fallback?: ComponentType<any>;
|
||||
props?: ComponentProps<any>;
|
||||
}[];
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: RoutePaths.REDIRECT, Component: RedirectWarning },
|
||||
{ path: RoutePaths.LOGIN, Component: Login },
|
||||
{ path: RoutePaths.REGISTER_ACTIVATION, Component: Register },
|
||||
{ path: RoutePaths.REGISTER, Component: Register },
|
||||
{ path: RoutePaths.LOGOUT, Component: Login },
|
||||
{ path: RoutePaths.HOME, Component: Home },
|
||||
{ path: RoutePaths.FILE_HANDLER, Component: FileHandler },
|
||||
{ path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
|
||||
{ path: RoutePaths.DASHBOARD, Component: Dashboard },
|
||||
{ path: RoutePaths.CHART_ADD, Component: ChartCreation },
|
||||
{ path: RoutePaths.CHART_LIST, Component: ChartList },
|
||||
{ path: RoutePaths.DATASET_LIST, Component: DatasetList },
|
||||
{ path: RoutePaths.DATABASE_LIST, Component: DatabaseList },
|
||||
{ path: RoutePaths.SAVED_QUERIES, Component: SavedQueryList },
|
||||
{ path: RoutePaths.CSS_TEMPLATES, Component: CssTemplateList },
|
||||
{ path: RoutePaths.THEMES, Component: ThemeList },
|
||||
{ path: RoutePaths.ANNOTATION_LAYERS, Component: AnnotationLayerList },
|
||||
{ path: RoutePaths.ANNOTATION_LIST, Component: AnnotationList },
|
||||
{ path: RoutePaths.QUERY_HISTORY, Component: QueryHistoryList },
|
||||
{ path: RoutePaths.ALERTS, Component: AlertReportList },
|
||||
{
|
||||
path: '/redirect/',
|
||||
Component: RedirectWarning,
|
||||
},
|
||||
{
|
||||
path: '/login/',
|
||||
Component: Login,
|
||||
},
|
||||
{
|
||||
path: '/register/activation/:activationHash',
|
||||
Component: Register,
|
||||
},
|
||||
{
|
||||
path: '/register/',
|
||||
Component: Register,
|
||||
},
|
||||
{
|
||||
path: '/logout/',
|
||||
Component: Login,
|
||||
},
|
||||
{
|
||||
path: '/superset/welcome/',
|
||||
Component: Home,
|
||||
},
|
||||
{
|
||||
path: '/superset/file-handler',
|
||||
Component: FileHandler,
|
||||
},
|
||||
{
|
||||
path: '/dashboard/list/',
|
||||
Component: DashboardList,
|
||||
},
|
||||
{
|
||||
path: '/superset/dashboard/:idOrSlug/',
|
||||
Component: Dashboard,
|
||||
},
|
||||
{
|
||||
path: '/chart/add',
|
||||
Component: ChartCreation,
|
||||
},
|
||||
{
|
||||
path: '/chart/list/',
|
||||
Component: ChartList,
|
||||
},
|
||||
{
|
||||
path: '/tablemodelview/list/',
|
||||
Component: DatasetList,
|
||||
},
|
||||
{
|
||||
path: '/databaseview/list/',
|
||||
Component: DatabaseList,
|
||||
},
|
||||
{
|
||||
path: '/savedqueryview/list/',
|
||||
Component: SavedQueryList,
|
||||
},
|
||||
{
|
||||
path: '/csstemplatemodelview/list/',
|
||||
Component: CssTemplateList,
|
||||
},
|
||||
{
|
||||
path: '/theme/list/',
|
||||
Component: ThemeList,
|
||||
},
|
||||
{
|
||||
path: '/annotationlayer/list/',
|
||||
Component: AnnotationLayerList,
|
||||
},
|
||||
{
|
||||
path: '/annotationlayer/:annotationLayerId/annotation/',
|
||||
Component: AnnotationList,
|
||||
},
|
||||
{
|
||||
path: '/sqllab/history/',
|
||||
Component: QueryHistoryList,
|
||||
},
|
||||
{
|
||||
path: '/alert/list/',
|
||||
path: RoutePaths.REPORTS,
|
||||
Component: AlertReportList,
|
||||
props: { isReportEnabled: true },
|
||||
},
|
||||
{ path: RoutePaths.ALERT_LOG, Component: ExecutionLogList },
|
||||
{
|
||||
path: '/report/list/',
|
||||
Component: AlertReportList,
|
||||
props: {
|
||||
isReportEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/alert/:alertId/log/',
|
||||
path: RoutePaths.REPORT_LOG,
|
||||
Component: ExecutionLogList,
|
||||
props: { isReportEnabled: true },
|
||||
},
|
||||
{
|
||||
path: '/report/:alertId/log/',
|
||||
Component: ExecutionLogList,
|
||||
props: {
|
||||
isReportEnabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/explore/',
|
||||
Component: Chart,
|
||||
},
|
||||
{
|
||||
path: '/superset/explore/p',
|
||||
Component: Chart,
|
||||
},
|
||||
{
|
||||
path: '/dataset/add/',
|
||||
Component: DatasetCreation,
|
||||
},
|
||||
{
|
||||
path: '/dataset/:datasetId',
|
||||
Component: DatasetCreation,
|
||||
},
|
||||
{
|
||||
path: '/rowlevelsecurity/list',
|
||||
Component: RowLevelSecurityList,
|
||||
},
|
||||
{
|
||||
path: '/tasks/list/',
|
||||
Component: TaskList,
|
||||
},
|
||||
{
|
||||
path: '/sqllab/',
|
||||
Component: SqlLab,
|
||||
},
|
||||
{ path: '/user_info/', Component: UserInfo },
|
||||
{
|
||||
path: '/actionlog/list',
|
||||
Component: ActionLogList,
|
||||
},
|
||||
{
|
||||
path: '/registrations/',
|
||||
Component: UserRegistrations,
|
||||
},
|
||||
{ path: RoutePaths.EXPLORE, Component: Chart },
|
||||
{ path: RoutePaths.EXPLORE_PERMALINK, Component: Chart },
|
||||
{ path: RoutePaths.DATASET_ADD, Component: DatasetCreation },
|
||||
{ path: RoutePaths.DATASET, Component: DatasetCreation },
|
||||
{ path: RoutePaths.ROW_LEVEL_SECURITY, Component: RowLevelSecurityList },
|
||||
{ path: RoutePaths.TASKS, Component: TaskList },
|
||||
{ path: RoutePaths.SQLLAB, Component: SqlLab },
|
||||
{ path: RoutePaths.USER_INFO, Component: UserInfo },
|
||||
{ path: RoutePaths.ACTION_LOG, Component: ActionLogList },
|
||||
{ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations },
|
||||
];
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
|
||||
routes.push({
|
||||
path: '/superset/all_entities/',
|
||||
Component: AllEntities,
|
||||
});
|
||||
routes.push({
|
||||
path: '/superset/tags/',
|
||||
Component: Tags,
|
||||
});
|
||||
routes.push({ path: RoutePaths.ALL_ENTITIES, Component: AllEntities });
|
||||
routes.push({ path: RoutePaths.TAGS, Component: Tags });
|
||||
}
|
||||
|
||||
const user = getBootstrapData()?.user;
|
||||
@@ -350,33 +251,18 @@ const isAdmin = isUserAdmin(user);
|
||||
|
||||
if (isAdmin) {
|
||||
routes.push(
|
||||
{
|
||||
path: '/roles/',
|
||||
Component: RolesList,
|
||||
},
|
||||
{
|
||||
path: '/users/',
|
||||
Component: UsersList,
|
||||
},
|
||||
{
|
||||
path: '/list_groups/',
|
||||
Component: GroupsList,
|
||||
},
|
||||
{ path: RoutePaths.ROLES, Component: RolesList },
|
||||
{ path: RoutePaths.USERS, Component: UsersList },
|
||||
{ path: RoutePaths.GROUPS, Component: GroupsList },
|
||||
);
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
routes.push({
|
||||
path: '/extensions/list/',
|
||||
Component: Extensions,
|
||||
});
|
||||
routes.push({ path: RoutePaths.EXTENSIONS, Component: Extensions });
|
||||
}
|
||||
}
|
||||
|
||||
if (authRegistrationEnabled) {
|
||||
routes.push({
|
||||
path: '/registrations/',
|
||||
Component: UserRegistrations,
|
||||
});
|
||||
routes.push({ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations });
|
||||
}
|
||||
|
||||
const frontEndRoutes: Record<string, boolean> = routes
|
||||
|
||||
@@ -1455,6 +1455,18 @@ class ChartDataQueryObjectSchema(Schema):
|
||||
fields.String(),
|
||||
allow_none=True,
|
||||
)
|
||||
time_compare_full_range = fields.Boolean(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
metadata={
|
||||
"description": (
|
||||
"When using a time comparison (time_offsets), plot each shifted "
|
||||
"series across its full time range instead of truncating it to the "
|
||||
"main series' range. Useful for comparing a partial current period "
|
||||
"against complete prior periods."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@post_load
|
||||
def rename_deprecated_fields(
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any, Optional
|
||||
|
||||
from flask_babel import gettext as __
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import (
|
||||
DatabaseOfflineError,
|
||||
@@ -33,6 +34,7 @@ from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.extensions import event_logger
|
||||
from superset.models.core import Database
|
||||
from superset.utils import json
|
||||
from superset.utils.ssh_tunnel import unmask_password_info
|
||||
|
||||
BYPASS_VALIDATION_ENGINES = {"bigquery", "datastore", "snowflake"}
|
||||
|
||||
@@ -42,14 +44,23 @@ class ValidateDatabaseParametersCommand(BaseCommand):
|
||||
self._properties = properties.copy()
|
||||
self._model: Optional[Database] = None
|
||||
|
||||
def run(self) -> None:
|
||||
def run(self) -> None: # noqa: C901
|
||||
self.validate()
|
||||
|
||||
engine = self._properties["engine"]
|
||||
driver = self._properties.get("driver")
|
||||
|
||||
if engine in BYPASS_VALIDATION_ENGINES:
|
||||
# Skip engines that are only validated onCreate
|
||||
# Skip engines that are only validated onCreate, but still surface
|
||||
# database_name uniqueness and SSH tunnel field errors so the
|
||||
# progressive validation flow stays consistent across engines.
|
||||
errors: list[SupersetError] = []
|
||||
if database_name_error := self._get_database_name_error():
|
||||
errors.append(database_name_error)
|
||||
errors.extend(self._get_ssh_tunnel_errors())
|
||||
if errors:
|
||||
event_logger.log_with_context(action="validation_error", engine=engine)
|
||||
raise InvalidParametersError(errors)
|
||||
return
|
||||
|
||||
engine_spec = get_engine_spec(engine, driver)
|
||||
@@ -65,8 +76,17 @@ class ValidateDatabaseParametersCommand(BaseCommand):
|
||||
),
|
||||
)
|
||||
|
||||
# perform initial validation
|
||||
# perform initial validation (host, port, database, username)
|
||||
errors = engine_spec.validate_parameters(self._properties) # type: ignore
|
||||
|
||||
# Collect database_name errors along with parameter errors
|
||||
if database_name_error := self._get_database_name_error():
|
||||
errors.append(database_name_error)
|
||||
|
||||
# Collect SSH tunnel errors
|
||||
ssh_tunnel_errors = self._get_ssh_tunnel_errors()
|
||||
errors.extend(ssh_tunnel_errors)
|
||||
|
||||
if errors:
|
||||
event_logger.log_with_context(action="validation_error", engine=engine)
|
||||
raise InvalidParametersError(errors)
|
||||
@@ -92,11 +112,23 @@ class ValidateDatabaseParametersCommand(BaseCommand):
|
||||
)
|
||||
if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
|
||||
sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
|
||||
|
||||
# Forward the SSH tunnel into the connection test so that
|
||||
# tunnel-only databases are reached through the tunnel rather
|
||||
# than directly, mirroring the existing test_connection flow.
|
||||
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
|
||||
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
|
||||
ssh_tunnel_properties = unmask_password_info(
|
||||
ssh_tunnel_properties,
|
||||
self._model.ssh_tunnel,
|
||||
)
|
||||
|
||||
database = DatabaseDAO.build_db_for_connection_test(
|
||||
server_cert=self._properties.get("server_cert", ""),
|
||||
extra=self._properties.get("extra", "{}"),
|
||||
impersonate_user=self._properties.get("impersonate_user", False),
|
||||
encrypted_extra=json.dumps(encrypted_extra),
|
||||
ssh_tunnel=ssh_tunnel_properties,
|
||||
)
|
||||
database.set_sqlalchemy_uri(sqlalchemy_uri)
|
||||
database.db_engine_spec.mutate_db_for_connection_test(database)
|
||||
@@ -138,6 +170,116 @@ class ValidateDatabaseParametersCommand(BaseCommand):
|
||||
),
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
def _load_model(self) -> None:
|
||||
"""Load the existing database model if updating."""
|
||||
if (database_id := self._properties.get("id")) is not None:
|
||||
self._model = DatabaseDAO.find_by_id(database_id)
|
||||
|
||||
def _get_database_name_error(self) -> Optional[SupersetError]:
|
||||
"""Check for duplicate database name and return error if found."""
|
||||
database_id = self._properties.get("id")
|
||||
|
||||
if database_name := self._properties.get("database_name"):
|
||||
is_unique = (
|
||||
DatabaseDAO.validate_update_uniqueness(database_id, database_name)
|
||||
if database_id is not None
|
||||
else DatabaseDAO.validate_uniqueness(database_name)
|
||||
)
|
||||
if not is_unique:
|
||||
return SupersetError(
|
||||
message=__("A database with the same name already exists."),
|
||||
error_type=SupersetErrorType.INVALID_PAYLOAD_SCHEMA_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
extra={"invalid": ["database_name"]},
|
||||
)
|
||||
return None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Load the model in preparation for run()."""
|
||||
self._load_model()
|
||||
|
||||
def _get_ssh_tunnel_errors(self) -> list[SupersetError]:
|
||||
"""Validate SSH tunnel fields and return list of errors."""
|
||||
errors: list[SupersetError] = []
|
||||
ssh_tunnel = self._properties.get("ssh_tunnel") or {}
|
||||
parameters = self._properties.get("parameters") or {}
|
||||
|
||||
# ``parameters.ssh`` is the UI toggle. When it's explicitly false the
|
||||
# user has turned SSH off and any stale ``ssh_tunnel`` payload should
|
||||
# be ignored — otherwise toggling off after partially filling the
|
||||
# tunnel form would still trip validation. When the toggle is absent
|
||||
# (older callers / save-time payloads) fall back to inferring from
|
||||
# the presence of an ``ssh_tunnel`` object.
|
||||
if "ssh" in parameters:
|
||||
if not parameters.get("ssh"):
|
||||
return errors
|
||||
ssh_enabled = True
|
||||
else:
|
||||
ssh_enabled = bool(ssh_tunnel)
|
||||
if not ssh_enabled:
|
||||
return errors
|
||||
|
||||
if not is_feature_enabled("SSH_TUNNELING"):
|
||||
errors.append(
|
||||
SupersetError(
|
||||
message=__("SSH Tunneling is not enabled"),
|
||||
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
extra={"ssh_tunnel": True},
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
if not parameters.get("port"):
|
||||
# ``port`` is a database parameter (not a tunnel field), so it
|
||||
# surfaces via the top-level ``missing`` path and lands on the
|
||||
# database port input rather than the SSH tunnel section.
|
||||
errors.append(
|
||||
SupersetError(
|
||||
message=__(
|
||||
"A database port is required when connecting via SSH Tunnel."
|
||||
),
|
||||
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
|
||||
level=ErrorLevel.WARNING,
|
||||
extra={"missing": ["port"]},
|
||||
)
|
||||
)
|
||||
|
||||
# Required fields on the tunnel itself
|
||||
required_fields = ["server_address", "server_port", "username"]
|
||||
missing = [f for f in required_fields if not ssh_tunnel.get(f)]
|
||||
|
||||
if missing:
|
||||
errors.append(
|
||||
SupersetError(
|
||||
message=__(
|
||||
"One or more parameters are missing: %(missing)s",
|
||||
missing=", ".join(missing),
|
||||
),
|
||||
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
|
||||
level=ErrorLevel.WARNING,
|
||||
extra={"missing": missing, "ssh_tunnel": True},
|
||||
)
|
||||
)
|
||||
|
||||
# Either password or private_key is required. The active login
|
||||
# method is a client-side toggle, so flag both fields as missing —
|
||||
# the modal renders only one pane at a time and will surface the
|
||||
# error on whichever field the user can see.
|
||||
has_password = bool(ssh_tunnel.get("password"))
|
||||
has_private_key = bool(ssh_tunnel.get("private_key"))
|
||||
|
||||
if not has_password and not has_private_key:
|
||||
errors.append(
|
||||
SupersetError(
|
||||
message=__("Must provide credentials for the SSH Tunnel"),
|
||||
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
|
||||
level=ErrorLevel.WARNING,
|
||||
extra={
|
||||
"missing": ["password", "private_key"],
|
||||
"ssh_tunnel": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user