mirror of
https://github.com/apache/superset.git
synced 2026-07-29 10:02:32 +00:00
Compare commits
113 Commits
fix/big-nu
...
loader-exp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8013b32f0e | ||
|
|
adeed60fe0 | ||
|
|
546945e7a6 | ||
|
|
5b2f1bbf9e | ||
|
|
875f538d54 | ||
|
|
b7d3ff1e85 | ||
|
|
c03964dc5f | ||
|
|
950a3313d8 | ||
|
|
e2a22d481c | ||
|
|
b4e2406385 | ||
|
|
ca9e74edd8 | ||
|
|
39b3de6b5d | ||
|
|
26563bb330 | ||
|
|
0653e123cc | ||
|
|
76358ed64e | ||
|
|
217f11a8f7 | ||
|
|
af21ef2497 | ||
|
|
51c25831e8 | ||
|
|
be41e0526a | ||
|
|
0f240ea1b2 | ||
|
|
e520538af6 | ||
|
|
e03d840d06 | ||
|
|
1921ba993e | ||
|
|
b050897ebd | ||
|
|
0bdd8a223d | ||
|
|
d12f86363f | ||
|
|
9f680a63f8 | ||
|
|
928a052440 | ||
|
|
fbc84a1f9a | ||
|
|
fa1693dc5f | ||
|
|
8a8fb49617 | ||
|
|
dc4474889d | ||
|
|
29ac507d56 | ||
|
|
7f14e434c8 | ||
|
|
21ca26acd7 | ||
|
|
33e48146b0 | ||
|
|
73701b7295 | ||
|
|
22475e787e | ||
|
|
9e38a0cc29 | ||
|
|
a391ebecca | ||
|
|
72cd9dffa3 | ||
|
|
4ed05f4ff1 | ||
|
|
871cfe0c78 | ||
|
|
a928f8cd9e | ||
|
|
afaaf64f52 | ||
|
|
dc0d542054 | ||
|
|
0cd3a12daa | ||
|
|
35b30480f0 | ||
|
|
6d1f17bd46 | ||
|
|
ab899e71e7 | ||
|
|
6b9d8708d3 | ||
|
|
bc1e8e07cf | ||
|
|
82526865d2 | ||
|
|
02c8c9c752 | ||
|
|
6475188e6a | ||
|
|
6e485c9f70 | ||
|
|
b49e5857c9 | ||
|
|
13ced58261 | ||
|
|
ed36674a99 | ||
|
|
99aa3a6507 | ||
|
|
f045a73e2d | ||
|
|
7791674f24 | ||
|
|
9f0ae77341 | ||
|
|
5a9e366c0a | ||
|
|
c22c532a5c | ||
|
|
6db3a4d9d2 | ||
|
|
17d7b72f3b | ||
|
|
fee33dd0cf | ||
|
|
65605b4a54 | ||
|
|
e304f2d5ad | ||
|
|
4e0c261c9d | ||
|
|
22de26cd77 | ||
|
|
339ba96600 | ||
|
|
3c6091144b | ||
|
|
ef14b529b8 | ||
|
|
2a97a6ec1f | ||
|
|
fa6548939e | ||
|
|
418c673699 | ||
|
|
13f77a7416 | ||
|
|
303a80a316 | ||
|
|
2392ac6827 | ||
|
|
01ce4b987e | ||
|
|
2f308a85d8 | ||
|
|
e8d60509a0 | ||
|
|
d6f80eaae7 | ||
|
|
a5f986fec5 | ||
|
|
141d0252f2 | ||
|
|
c029b532d4 | ||
|
|
13816443ba | ||
|
|
2c4e22e598 | ||
|
|
aea776a131 | ||
|
|
d2360b533b | ||
|
|
de84a534ac | ||
|
|
ac636c73ae | ||
|
|
6a586fe4fd | ||
|
|
fbd8ae2888 | ||
|
|
7e4fde7a14 | ||
|
|
150b9a0168 | ||
|
|
f7b7aace38 | ||
|
|
f78c94c988 | ||
|
|
74ff8dc724 | ||
|
|
8aa127eac2 | ||
|
|
3729016a0d | ||
|
|
b6628cdfd2 | ||
|
|
ae48dba3e1 | ||
|
|
09364d182c | ||
|
|
99ed968289 | ||
|
|
8fa3b8d7e3 | ||
|
|
7530487760 | ||
|
|
79afc2b545 | ||
|
|
8c94f9c435 | ||
|
|
b589d44dfb | ||
|
|
4140261797 |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -27,6 +27,8 @@ updates:
|
||||
- package-ecosystem: "uv"
|
||||
directory: "requirements/"
|
||||
open-pull-requests-limit: 10
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
labels:
|
||||
- uv
|
||||
- dependabot
|
||||
|
||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -127,6 +127,11 @@
|
||||
- any-glob-to-any-file:
|
||||
- 'superset/translations/es/**'
|
||||
|
||||
"i18n:persian":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'superset/translations/fa/**'
|
||||
|
||||
############################################
|
||||
# Sub-projects and monorepo packages
|
||||
############################################
|
||||
|
||||
3
.github/workflows/check-python-deps.yml
vendored
3
.github/workflows/check-python-deps.yml
vendored
@@ -17,13 +17,12 @@ jobs:
|
||||
check-python-deps:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
depth: 1
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Python
|
||||
if: steps.check.outputs.python
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -48,6 +48,8 @@ jobs:
|
||||
allow-dependencies-licenses: pkg:npm/store2@2.14.2, pkg:npm/applitools/core, pkg:npm/applitools/core-base, pkg:npm/applitools/css-tree, pkg:npm/applitools/ec-client, pkg:npm/applitools/eg-socks5-proxy-server, pkg:npm/applitools/eyes, pkg:npm/applitools/eyes-cypress, pkg:npm/applitools/nml-client, pkg:npm/applitools/tunnel-client, pkg:npm/applitools/utils, pkg:npm/node-forge@1.3.1, pkg:npm/rgbcolor, pkg:npm/jszip@3.10.1
|
||||
|
||||
python-dependency-liccheck:
|
||||
# NOTE: Configuration for liccheck lives in our pyproject.yml.
|
||||
# You cannot use a liccheck.ini file in this workflow.
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
|
||||
@@ -43,6 +43,7 @@ Join our growing community!
|
||||
- [Cape Crypto](https://capecrypto.com)
|
||||
- [Capital Service S.A.](https://capitalservice.pl) [@pkonarzewski]
|
||||
- [Clark.de](https://clark.de/)
|
||||
- [Europace](https://europace.de)
|
||||
- [KarrotPay](https://www.daangnpay.com/)
|
||||
- [Remita](https://remita.net) [@mujibishola]
|
||||
- [Taveo](https://www.taveo.com) [@codek]
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"Israel",
|
||||
"Italy",
|
||||
"Italy (regions)",
|
||||
"Ivory Coast",
|
||||
"Japan",
|
||||
"Jordan",
|
||||
"Kazakhstan",
|
||||
@@ -143,6 +144,7 @@
|
||||
"Poland",
|
||||
"Portugal",
|
||||
"Qatar",
|
||||
"Republic Of Serbia",
|
||||
"Romania",
|
||||
"Russia",
|
||||
"Rwanda",
|
||||
|
||||
@@ -302,6 +302,15 @@ AUTH_USER_REGISTRATION = True
|
||||
AUTH_USER_REGISTRATION_ROLE = "Public"
|
||||
```
|
||||
|
||||
In case you want to assign the `Admin` role on new user registration, it can be assigned as follows:
|
||||
```python
|
||||
AUTH_USER_REGISTRATION_ROLE = "Admin"
|
||||
```
|
||||
If you encounter the [issue](https://github.com/apache/superset/issues/13243) of not being able to list users from the Superset main page settings, although a newly registered user has an `Admin` role, please re-run `superset init` to sync the required permissions. Below is the command to re-run `superset init` using docker compose.
|
||||
```
|
||||
docker-compose exec superset superset init
|
||||
```
|
||||
|
||||
Then, create a `CustomSsoSecurityManager` that extends `SupersetSecurityManager` and overrides
|
||||
`oauth_user_info`:
|
||||
|
||||
|
||||
@@ -1293,6 +1293,13 @@ The connection string for SQL Server looks like this:
|
||||
mssql+pyodbc:///?odbc_connect=Driver%3D%7BODBC+Driver+17+for+SQL+Server%7D%3BServer%3Dtcp%3A%3Cmy_server%3E%2C1433%3BDatabase%3Dmy_database%3BUid%3Dmy_user_name%3BPwd%3Dmy_password%3BEncrypt%3Dyes%3BConnection+Timeout%3D30
|
||||
```
|
||||
|
||||
:::note
|
||||
You might have noticed that some special charecters are used in the above connection string. For example see the `odbc_connect` parameter. The value is `Driver%3D%7BODBC+Driver+17+for+SQL+Server%7D%3B` which is a URL-encoded form of `Driver={ODBC+Driver+17+for+SQL+Server};`. It's important to give the connection string is URL encoded.
|
||||
|
||||
For more information about this check the [sqlalchemy documentation](https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords). Which says `When constructing a fully formed URL string to pass to create_engine(), special characters such as those that may be used in the user and password need to be URL encoded to be parsed correctly. This includes the @ sign.`
|
||||
:::
|
||||
|
||||
|
||||
#### StarRocks
|
||||
|
||||
The [sqlalchemy-starrocks](https://pypi.org/project/starrocks/) library is the recommended
|
||||
|
||||
@@ -4,9 +4,95 @@ version: 1
|
||||
---
|
||||
|
||||
import InteractiveSVG from '../../src/components/InteractiveERDSVG';
|
||||
import Mermaid from '@theme/Mermaid';
|
||||
|
||||
# Resources
|
||||
|
||||
## High Level Architecture
|
||||
<div style={{ maxWidth: "600px", margin: "0 auto", marginLeft: 0, marginRight: "auto" }}>
|
||||
```mermaid
|
||||
flowchart TD
|
||||
|
||||
%% Top Level
|
||||
LB["<b>Load Balancer(s)</b><br/>(optional)"]
|
||||
LB -.-> WebServers
|
||||
|
||||
%% Web Servers
|
||||
subgraph WebServers ["<b>Web Server(s)</b>"]
|
||||
WS1["<b>Frontend</b><br/>(React, AntD, ECharts, AGGrid)"]
|
||||
WS2["<b>Backend</b><br/>(Python, Flask, SQLAlchemy, Pandas, ...)"]
|
||||
end
|
||||
|
||||
%% Infra
|
||||
subgraph InfraServices ["<b>Infra</b>"]
|
||||
DB[("<b>Metadata Database</b><br/>(Postgres / MySQL)")]
|
||||
|
||||
subgraph Caching ["<b>Caching Subservices<br/></b>(Redis, memcache, S3, ...)"]
|
||||
direction LR
|
||||
DummySpace[" "]:::invisible
|
||||
QueryCache["<b>Query Results Cache</b><br/>(Accelerated Dashboards)"]
|
||||
CsvCache["<b>CSV Exports Cache</b>"]
|
||||
ThumbnailCache["<b>Thumbnails Cache</b>"]
|
||||
AlertImageCache["<b>Alert/Report Images Cache</b>"]
|
||||
QueryCache -- " " --> CsvCache
|
||||
linkStyle 1 stroke:transparent;
|
||||
ThumbnailCache -- " " --> AlertImageCache
|
||||
linkStyle 2 stroke:transparent;
|
||||
end
|
||||
|
||||
Broker(("<b>Message Queue</b><br/>(Redis / RabbitMQ / SQS)"))
|
||||
end
|
||||
|
||||
AsyncBackend["<b>Async Workers (Celery)</b><br>required for Alerts & Reports, thumbnails, CSV exports, long-running workloads, ..."]
|
||||
|
||||
%% External DBs
|
||||
subgraph ExternalDatabases ["<b>Analytics Databases</b>"]
|
||||
direction LR
|
||||
BigQuery[(BigQuery)]
|
||||
Snowflake[(Snowflake)]
|
||||
Redshift[(Redshift)]
|
||||
Postgres[(Postgres)]
|
||||
Postgres[(... any ...)]
|
||||
end
|
||||
|
||||
%% Connections
|
||||
LB -.-> WebServers
|
||||
WebServers --> DB
|
||||
WebServers -.-> Caching
|
||||
WebServers -.-> Broker
|
||||
WebServers -.-> ExternalDatabases
|
||||
|
||||
Broker -.-> AsyncBackend
|
||||
|
||||
AsyncBackend -.-> ExternalDatabases
|
||||
AsyncBackend -.-> Caching
|
||||
|
||||
|
||||
|
||||
%% Legend styling
|
||||
classDef requiredNode stroke-width:2px,stroke:black;
|
||||
class Required requiredNode;
|
||||
class Optional optionalNode;
|
||||
|
||||
%% Hide real arrow
|
||||
linkStyle 0 stroke:transparent;
|
||||
|
||||
%% Styling
|
||||
classDef optionalNode stroke-dasharray: 5 5, opacity:0.9;
|
||||
class LB optionalNode;
|
||||
class Caching optionalNode;
|
||||
class AsyncBackend optionalNode;
|
||||
class Broker optionalNode;
|
||||
class QueryCache optionalNode;
|
||||
class CsvCache optionalNode;
|
||||
class ThumbnailCache optionalNode;
|
||||
class AlertImageCache optionalNode;
|
||||
class Celery optionalNode;
|
||||
|
||||
classDef invisible fill:transparent,stroke:transparent;
|
||||
```
|
||||
</div>
|
||||
|
||||
## Entity-Relationship Diagram
|
||||
|
||||
Here is our interactive ERD:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Docker Builds
|
||||
hide_title: true
|
||||
sidebar_position: 6
|
||||
sidebar_position: 7
|
||||
version: 1
|
||||
---
|
||||
|
||||
@@ -44,7 +44,7 @@ Here are the build presets that are exposed through the `supersetbot docker` uti
|
||||
- `py311`, e.g., Py311: Similar to lean but with a different Python version (in this example, 3.11).
|
||||
- `ci`: For certain CI workloads.
|
||||
- `websocket`: For Superset clusters supporting advanced features.
|
||||
- `dockerize`: Used by Helm.
|
||||
- `dockerize`: Used by Helm in initContainers to wait for database dependencies to be available.
|
||||
|
||||
## Key tags examples
|
||||
|
||||
@@ -64,6 +64,51 @@ check the [supersetbot docker](https://github.com/apache-superset/supersetbot)
|
||||
subcommand and the [docker.yml](https://github.com/apache/superset/blob/master/.github/workflows/docker.yml)
|
||||
GitHub action.
|
||||
|
||||
## Building your own production Docker image
|
||||
|
||||
Every Superset deployment will require its own set of drivers depending on the data warehouse(s),
|
||||
etc. so we recommend that users build their own Docker image by extending the `lean` image.
|
||||
|
||||
Here's an example Dockerfile that does this. Follow the in-line comments to customize it for
|
||||
your desired Superset version and database drivers. The comments also note that a certain feature flag will
|
||||
have to be enabled in your config file.
|
||||
|
||||
You would build the image with `docker build -t mysuperset:latest .` or `docker build -t ourcompanysuperset:4.1.2 .`
|
||||
|
||||
```Dockerfile
|
||||
# change this to apache/superset:4.1.2 or whatever version you want to build from;
|
||||
# otherwise the default is the latest commit on GitHub master branch
|
||||
FROM apache/superset:master
|
||||
|
||||
USER root
|
||||
|
||||
# Set environment variable for Playwright
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers
|
||||
|
||||
# Install packages using uv into the virtual environment
|
||||
# Superset started using uv after the 4.1 branch; if you are building from apache/superset:4.1.x,
|
||||
# replace the first two lines with RUN pip install \
|
||||
RUN . /app/.venv/bin/activate && \
|
||||
uv pip install \
|
||||
# install psycopg2 for using PostgreSQL metadata store - could be a MySQL package if using that backend:
|
||||
psycopg2-binary \
|
||||
# add the driver(s) for your data warehouse(s), in this example we're showing for Microsoft SQL Server:
|
||||
pymssql \
|
||||
# package needed for using single-sign on authentication:
|
||||
Authlib \
|
||||
# install Playwright for taking screenshots for Alerts & Reports. This assumes the feature flag PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled
|
||||
# That feature flag will default to True starting in 6.0.0
|
||||
# Playwright works only with Chrome.
|
||||
# If you are still using Selenium instead of Playwright, you would instead install here the selenium package and a headless browser & webdriver
|
||||
playwright \
|
||||
&& PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/playwright-browsers playwright install chromium
|
||||
|
||||
# Switch back to the superset user
|
||||
USER superset
|
||||
|
||||
CMD ["/app/docker/entrypoints/run-server.sh"]
|
||||
```
|
||||
|
||||
## Key ARGs in Dockerfile
|
||||
|
||||
- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Docker Compose
|
||||
hide_title: true
|
||||
sidebar_position: 4
|
||||
sidebar_position: 5
|
||||
version: 1
|
||||
---
|
||||
|
||||
|
||||
56
docs/docs/installation/installation-methods.mdx
Normal file
56
docs/docs/installation/installation-methods.mdx
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Installation Methods
|
||||
hide_title: true
|
||||
sidebar_position: 2
|
||||
version: 1
|
||||
---
|
||||
|
||||
import useBaseUrl from "@docusaurus/useBaseUrl";
|
||||
|
||||
# Installation Methods
|
||||
|
||||
How should you install Superset? Here's a comparison of the different options. It will help if you've first read the [Architecture](/docs/installation/architecture.mdx) page to understand Superset's different components.
|
||||
|
||||
The fundamental trade-off is between you needing to do more of the detail work yourself vs. using a more complex deployment route that handles those details.
|
||||
|
||||
## [Docker Compose](/docs/installation/docker-compose.mdx)
|
||||
|
||||
**Summary:** This takes advantage of containerization while remaining simpler than Kubernetes. This is the best way to try out Superset; it's also useful for developing & contributing back to Superset.
|
||||
|
||||
If you're not just demoing the software, you'll need a moderate understanding of Docker to customize your deployment and avoid a few risks. Even when fully-optimized this is not as robust a method as Kubernetes when it comes to large-scale production deployments.
|
||||
|
||||
You manage a superset-config.py file and a docker-compose.yml file. Docker Compose brings up all the needed services - the Superset application, a Postgres metadata DB, Redis cache, Celery worker and beat. They are automatically connected to each other.
|
||||
|
||||
**Responsibilities**
|
||||
|
||||
You will need to back up your metadata DB. That could mean backing up the service running as a Docker container and its volume; ideally you are running Postgres as a service outside of that container and backing up that service.
|
||||
|
||||
You will also need to extend the Superset docker image. The default `lean` images do not contain drivers needed to access your metadata database (Postgres or MySQL), nor to access your data warehouse, nor the headless browser needed for Alerts & Reports. You could run a `-dev` image while demoing Superset, which has some of this, but you'll still need to install the driver for your data warehouse. The `-dev` images run as root, which is not recommended for production.
|
||||
|
||||
Ideally you will build your own image of Superset that extends `lean`, adding what your deployment needs. See [Building your own production Docker image](/docs/installation/docker-builds/#building-your-own-production-docker-image).
|
||||
|
||||
## [Kubernetes (K8s)](/docs/installation/kubernetes.mdx)
|
||||
|
||||
**Summary:** This is the best-practice way to deploy a production instance of Superset, but has the steepest skill requirement - someone who knows Kubernetes.
|
||||
|
||||
You will deploy Superset into a K8s cluster. The most common method is using the community-maintained Helm chart, though work is now underway to implement [SIP-149 - a Kubernetes Operator for Superset](https://github.com/apache/superset/issues/31408).
|
||||
|
||||
A K8s deployment can scale up and down based on usage and deploy rolling updates with zero downtime - features that big deployments appreciate.
|
||||
|
||||
**Responsibilities**
|
||||
|
||||
You will need to build your own Docker image, and back up your metadata DB, both as described in Docker Compose above. You'll also need to customize your Helm chart values and deploy and maintain your Kubernetes cluster.
|
||||
|
||||
## [PyPI (Python)](/docs/installation/pypi.mdx)
|
||||
|
||||
**Summary:** This is the only method that requires no knowledge of containers. It requires the most hands-on work to deploy, connect, and maintain each component.
|
||||
|
||||
You install Superset as a Python package and run it that way, providing your own metadata database. Superset has documentation on how to install this way, but it is updated infrequently.
|
||||
|
||||
If you want caching, you'll set up Redis or RabbitMQ. If you want Alerts & Reports, you'll set up Celery.
|
||||
|
||||
**Responsibilities**
|
||||
|
||||
You will need to get the component services running and communicating with each other. You'll need to arrange backups of your metadata database.
|
||||
|
||||
When upgrading, you'll need to manage the system environment and packages and ensure all components have functional dependencies.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Kubernetes
|
||||
hide_title: true
|
||||
sidebar_position: 2
|
||||
sidebar_position: 3
|
||||
version: 1
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: PyPI
|
||||
hide_title: true
|
||||
sidebar_position: 3
|
||||
sidebar_position: 4
|
||||
version: 1
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Upgrading Superset
|
||||
hide_title: true
|
||||
sidebar_position: 5
|
||||
sidebar_position: 6
|
||||
version: 1
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
title: CVEs fixed by release
|
||||
sidebar_position: 2
|
||||
---
|
||||
#### Version 4.1.2
|
||||
|
||||
| CVE | Title | Affected |
|
||||
|:---------------|:-----------------------------------------------------------------------------------|---------:|
|
||||
| CVE-2025-27696 | Improper authorization leading to resource ownership takeover | < 4.1.2 |
|
||||
|
||||
#### Version 4.1.0
|
||||
|
||||
| CVE | Title | Affected |
|
||||
|
||||
@@ -64,6 +64,26 @@ tables in the **Permissions** dropdown. To select the data sources you want to a
|
||||
You can then confirm with users assigned to the **Gamma** role that they see the
|
||||
objects (dashboards and slices) associated with the tables you just extended them.
|
||||
|
||||
### SQL Execution Security Considerations
|
||||
|
||||
Apache Superset includes features designed to provide safeguards when interacting with connected databases, such as the `DISALLOWED_SQL_FUNCTIONS` configuration setting. This aims to prevent the execution of potentially harmful database functions or system variables directly from Superset interfaces like SQL Lab.
|
||||
|
||||
However, it is crucial to understand the following:
|
||||
|
||||
**Superset is Not a Database Firewall**: Superset's built-in checks, like `DISALLOWED_SQL_FUNCTIONS`, provide a layer of protection but cannot guarantee complete security against all database-level threats or advanced bypass techniques (like specific comment injection methods). They should be viewed as a supplement to, not a replacement for, robust database security.
|
||||
|
||||
**Configuration is Key**: The effectiveness of Superset's safeguards heavily depends on proper configuration by the Superset administrator. This includes maintaining the `DISALLOWED_SQL_FUNCTIONS` list, carefully managing feature flags (like `ENABLE_TEMPLATE_PROCESSING`), and configuring other security settings appropriately.
|
||||
|
||||
**Database Security is Paramount**: The ultimate responsibility for securing database access, controlling permissions, and preventing unauthorized function execution lies with the database administrators (DBAs) and security teams managing the underlying database instance.
|
||||
|
||||
**Recommended Database Practices**: We strongly recommend implementing security best practices at the database level, including:
|
||||
* **Least Privilege**: Connecting Superset using dedicated database user accounts with the minimum permissions required for Superset's operation (typically read-only access to necessary schemas/tables).
|
||||
* **Database Roles & Permissions**: Utilizing database-native roles and permissions to restrict access to sensitive functions, system variables (like `@@hostname`), schemas, or tables.
|
||||
* **Network Security**: Employing network-level controls like database firewalls or proxies to restrict connections.
|
||||
* **Auditing**: Enabling database-level auditing to monitor executed queries and access patterns.
|
||||
|
||||
By combining Superset's configurable safeguards with strong database-level security practices, you can achieve a more robust and layered security posture.
|
||||
|
||||
### REST API for user & role management
|
||||
|
||||
Flask-AppBuilder supports a REST API for user CRUD,
|
||||
|
||||
@@ -31,10 +31,13 @@ const config: Config = {
|
||||
baseUrl: '/',
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'throw',
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
},
|
||||
favicon: '/img/favicon.ico',
|
||||
organizationName: 'apache',
|
||||
projectName: 'superset',
|
||||
themes: ['@saucelabs/theme-github-codeblock'],
|
||||
themes: ['@saucelabs/theme-github-codeblock', '@docusaurus/theme-mermaid'],
|
||||
plugins: [
|
||||
[
|
||||
'docusaurus-plugin-less',
|
||||
|
||||
@@ -19,22 +19,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.2",
|
||||
"@docusaurus/core": "^3.5.2",
|
||||
"@docusaurus/plugin-client-redirects": "^3.5.2",
|
||||
"@docusaurus/preset-classic": "^3.5.2",
|
||||
"@docusaurus/core": "3.7.0",
|
||||
"@docusaurus/plugin-client-redirects": "3.7.0",
|
||||
"@docusaurus/preset-classic": "3.7.0",
|
||||
"@docusaurus/theme-mermaid": "3.7.0",
|
||||
"@emotion/styled": "^10.0.27",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@superset-ui/style": "^0.14.23",
|
||||
"antd": "^5.24.5",
|
||||
"antd": "^5.25.1",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"less": "^4.2.2",
|
||||
"less": "^4.3.0",
|
||||
"less-loader": "^11.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-github-btn": "^1.4.0",
|
||||
"react-svg-pan-zoom": "^3.13.1",
|
||||
"swagger-ui-react": "^5.20.2"
|
||||
"swagger-ui-react": "^5.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.7.0",
|
||||
@@ -43,12 +44,12 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prettier": "^2.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"webpack": "^5.98.0"
|
||||
"typescript": "~5.8.3",
|
||||
"webpack": "^5.99.8"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -111,7 +111,7 @@ const StyledTitleContainer = styled('div')`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Link)`
|
||||
const StyledButton = styled(Link as React.ComponentType<any>)`
|
||||
border-radius: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
|
||||
6289
docs/static/resources/openapi.json
vendored
6289
docs/static/resources/openapi.json
vendored
File diff suppressed because it is too large
Load Diff
7084
docs/yarn.lock
7084
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,7 @@ dependencies = [
|
||||
"cryptography>=42.0.4, <45.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
"flask>=2.2.5, <3.0.0",
|
||||
"flask-appbuilder>=4.6.1, <5.0.0",
|
||||
"flask-appbuilder>=4.6.4, <5.0.0",
|
||||
"flask-caching>=2.1.0, <3",
|
||||
"flask-compress>=1.13, <2.0",
|
||||
"flask-talisman>=1.0.0, <2.0",
|
||||
@@ -81,7 +81,7 @@ dependencies = [
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
"python-geohash",
|
||||
"pyarrow>=14.0.1, <15",
|
||||
"pyarrow>=18.1.0, <19",
|
||||
"pyyaml>=6.0.0, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=4.6.0, <5.0",
|
||||
@@ -240,6 +240,12 @@ disallow_untyped_calls = false
|
||||
disallow_untyped_defs = false
|
||||
disable_error_code = "annotation-unchecked"
|
||||
|
||||
# TODO: remove this once cryptography is fixed, introduced in cryptography 44.0.3
|
||||
[[tool.mypy.overrides]]
|
||||
module = "cryptography.*"
|
||||
ignore_errors = true
|
||||
follow_imports = "skip"
|
||||
|
||||
[tool.ruff]
|
||||
# Exclude a variety of commonly ignored directories.
|
||||
exclude = [
|
||||
@@ -272,7 +278,6 @@ exclude = [
|
||||
"venv",
|
||||
]
|
||||
|
||||
|
||||
# Same as Black.
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
@@ -367,17 +372,21 @@ docstring-code-line-length = "dynamic"
|
||||
requirement_txt_file = "requirements/base.txt"
|
||||
authorized_licenses = [
|
||||
"academic free license (afl)",
|
||||
"any-osi",
|
||||
"apache license 2.0",
|
||||
"apache software",
|
||||
"apache software, bsd",
|
||||
"bsd",
|
||||
"bsd-3-clause",
|
||||
"isc license (iscl)",
|
||||
"isc license",
|
||||
"mit",
|
||||
"mozilla public license 2.0 (mpl 2.0)",
|
||||
"osi approved",
|
||||
"osi approved",
|
||||
"psf-2.0",
|
||||
"python software foundation",
|
||||
"simplified bsd",
|
||||
"the unlicense (unlicense)",
|
||||
"the unlicense",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv pip compile pyproject.toml requirements/base.in -o requirements/base.txt
|
||||
alembic==1.15.1
|
||||
alembic==1.15.2
|
||||
# via flask-migrate
|
||||
amqp==5.3.1
|
||||
# via kombu
|
||||
@@ -8,7 +8,7 @@ apispec==6.6.1
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# flask-appbuilder
|
||||
apsw==3.49.1.0
|
||||
apsw==3.49.2.0
|
||||
# via shillelagh
|
||||
async-timeout==4.0.3
|
||||
# via
|
||||
@@ -32,7 +32,7 @@ billiard==4.2.1
|
||||
# via celery
|
||||
blinker==1.9.0
|
||||
# via flask
|
||||
bottleneck==1.4.2
|
||||
bottleneck==1.5.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
brotli==1.1.0
|
||||
# via flask-compress
|
||||
@@ -42,11 +42,11 @@ cachelib==0.13.0
|
||||
# flask-session
|
||||
cachetools==5.5.2
|
||||
# via google-auth
|
||||
cattrs==24.1.2
|
||||
cattrs==24.1.3
|
||||
# via requests-cache
|
||||
celery==5.4.0
|
||||
celery==5.5.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
certifi==2025.1.31
|
||||
certifi==2025.4.26
|
||||
# via
|
||||
# requests
|
||||
# selenium
|
||||
@@ -54,9 +54,9 @@ cffi==1.17.1
|
||||
# via
|
||||
# cryptography
|
||||
# pynacl
|
||||
charset-normalizer==3.4.1
|
||||
charset-normalizer==3.4.2
|
||||
# via requests
|
||||
click==8.1.8
|
||||
click==8.2.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# celery
|
||||
@@ -82,7 +82,7 @@ cron-descriptor==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
croniter==6.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
cryptography==44.0.2
|
||||
cryptography==44.0.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# paramiko
|
||||
@@ -99,7 +99,7 @@ email-validator==2.2.0
|
||||
# via flask-appbuilder
|
||||
et-xmlfile==2.0.0
|
||||
# via openpyxl
|
||||
exceptiongroup==1.2.2
|
||||
exceptiongroup==1.3.0
|
||||
# via
|
||||
# cattrs
|
||||
# trio
|
||||
@@ -118,7 +118,7 @@ flask==2.3.3
|
||||
# flask-session
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
flask-appbuilder==4.6.1
|
||||
flask-appbuilder==4.6.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-babel==2.0.0
|
||||
# via flask-appbuilder
|
||||
@@ -152,7 +152,7 @@ geographiclib==2.0
|
||||
# via geopy
|
||||
geopy==2.4.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
google-auth==2.38.0
|
||||
google-auth==2.40.1
|
||||
# via shillelagh
|
||||
greenlet==3.1.1
|
||||
# via
|
||||
@@ -160,20 +160,21 @@ greenlet==3.1.1
|
||||
# shillelagh
|
||||
gunicorn==23.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.14.0
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
hashids==1.3.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
holidays==0.25
|
||||
# via apache-superset (pyproject.toml)
|
||||
humanize==4.12.2
|
||||
humanize==4.12.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
idna==3.10
|
||||
# via
|
||||
# email-validator
|
||||
# requests
|
||||
# trio
|
||||
importlib-metadata==8.6.1
|
||||
# url-normalize
|
||||
importlib-metadata==8.7.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
isodate==0.7.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -189,19 +190,19 @@ jsonpath-ng==1.7.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
jsonschema==4.23.0
|
||||
# via flask-appbuilder
|
||||
jsonschema-specifications==2024.10.1
|
||||
jsonschema-specifications==2025.4.1
|
||||
# via jsonschema
|
||||
kombu==5.5.0
|
||||
kombu==5.5.3
|
||||
# via celery
|
||||
korean-lunar-calendar==0.3.1
|
||||
# via holidays
|
||||
limits==4.4.1
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.3.9
|
||||
mako==1.3.10
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
markdown==3.7
|
||||
markdown==3.8
|
||||
# via apache-superset (pyproject.toml)
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
@@ -235,7 +236,6 @@ numpy==1.26.4
|
||||
# bottleneck
|
||||
# numexpr
|
||||
# pandas
|
||||
# pyarrow
|
||||
odfpy==1.4.1
|
||||
# via pandas
|
||||
openpyxl==3.1.5
|
||||
@@ -243,8 +243,10 @@ openpyxl==3.1.5
|
||||
ordered-set==4.1.0
|
||||
# via flask-limiter
|
||||
outcome==1.3.0.post0
|
||||
# via trio
|
||||
packaging==24.2
|
||||
# via
|
||||
# trio
|
||||
# trio-websocket
|
||||
packaging==25.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apispec
|
||||
@@ -263,7 +265,7 @@ parsedatetime==2.6
|
||||
# via apache-superset (pyproject.toml)
|
||||
pgsanity==0.2.9
|
||||
# via apache-superset (pyproject.toml)
|
||||
platformdirs==4.3.7
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
ply==3.11
|
||||
# via jsonpath-ng
|
||||
@@ -271,15 +273,15 @@ polyline==2.0.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
prison==0.2.1
|
||||
# via flask-appbuilder
|
||||
prompt-toolkit==3.0.50
|
||||
prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==14.0.2
|
||||
pyarrow==18.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
pyasn1==0.6.1
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# rsa
|
||||
pyasn1-modules==0.4.1
|
||||
pyasn1-modules==0.4.2
|
||||
# via google-auth
|
||||
pycparser==2.22
|
||||
# via cffi
|
||||
@@ -294,7 +296,7 @@ pynacl==1.5.0
|
||||
# via paramiko
|
||||
pyopenssl==25.0.0
|
||||
# via shillelagh
|
||||
pyparsing==3.2.2
|
||||
pyparsing==3.2.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
pysocks==1.7.1
|
||||
# via urllib3
|
||||
@@ -307,11 +309,11 @@ python-dateutil==2.9.0.post0
|
||||
# holidays
|
||||
# pandas
|
||||
# shillelagh
|
||||
python-dotenv==1.0.1
|
||||
python-dotenv==1.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
python-geohash==0.8.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
pytz==2025.1
|
||||
pytz==2025.2
|
||||
# via
|
||||
# croniter
|
||||
# flask-babel
|
||||
@@ -336,13 +338,13 @@ requests-cache==1.2.1
|
||||
# via shillelagh
|
||||
rich==13.9.4
|
||||
# via flask-limiter
|
||||
rpds-py==0.23.1
|
||||
rpds-py==0.25.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
rsa==4.9
|
||||
rsa==4.9.1
|
||||
# via google-auth
|
||||
selenium==4.27.1
|
||||
selenium==4.32.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
shillelagh==1.3.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -352,7 +354,6 @@ six==1.17.0
|
||||
# via
|
||||
# prison
|
||||
# python-dateutil
|
||||
# url-normalize
|
||||
# wtforms-json
|
||||
slack-sdk==3.35.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -373,7 +374,7 @@ sqlalchemy-utils==0.38.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
sqlglot==26.11.1
|
||||
sqlglot==26.17.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlparse==0.5.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -381,29 +382,29 @@ sshtunnel==0.4.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
tabulate==0.8.10
|
||||
# via apache-superset (pyproject.toml)
|
||||
trio==0.28.0
|
||||
trio==0.30.0
|
||||
# via
|
||||
# selenium
|
||||
# trio-websocket
|
||||
trio-websocket==0.11.1
|
||||
trio-websocket==0.12.2
|
||||
# via selenium
|
||||
typing-extensions==4.12.2
|
||||
typing-extensions==4.13.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.1
|
||||
tzdata==2025.2
|
||||
# via
|
||||
# celery
|
||||
# kombu
|
||||
# pandas
|
||||
url-normalize==1.4.3
|
||||
url-normalize==2.2.1
|
||||
# via requests-cache
|
||||
urllib3==1.26.20
|
||||
# via
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt
|
||||
-e .
|
||||
# via -r requirements/development.in
|
||||
alembic==1.15.1
|
||||
alembic==1.15.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-migrate
|
||||
@@ -14,7 +14,7 @@ apispec==6.6.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-appbuilder
|
||||
apsw==3.49.1.0
|
||||
apsw==3.49.2.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# shillelagh
|
||||
@@ -51,7 +51,7 @@ blinker==1.9.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask
|
||||
bottleneck==1.4.2
|
||||
bottleneck==1.5.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -68,15 +68,15 @@ cachetools==5.5.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# google-auth
|
||||
cattrs==24.1.2
|
||||
cattrs==24.1.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# requests-cache
|
||||
celery==5.4.0
|
||||
celery==5.5.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
certifi==2025.1.31
|
||||
certifi==2025.4.26
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# requests
|
||||
@@ -88,11 +88,11 @@ cffi==1.17.1
|
||||
# pynacl
|
||||
cfgv==3.4.0
|
||||
# via pre-commit
|
||||
charset-normalizer==3.4.1
|
||||
charset-normalizer==3.4.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# requests
|
||||
click==8.1.8
|
||||
click==8.2.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -138,7 +138,7 @@ croniter==6.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
cryptography==44.0.2
|
||||
cryptography==44.0.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -176,7 +176,7 @@ et-xmlfile==2.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# openpyxl
|
||||
exceptiongroup==1.2.2
|
||||
exceptiongroup==1.3.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# cattrs
|
||||
@@ -202,7 +202,7 @@ flask==2.3.3
|
||||
# flask-sqlalchemy
|
||||
# flask-testing
|
||||
# flask-wtf
|
||||
flask-appbuilder==4.6.1
|
||||
flask-appbuilder==4.6.4
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -280,7 +280,7 @@ google-api-core==2.23.0
|
||||
# google-cloud-core
|
||||
# pandas-gbq
|
||||
# sqlalchemy-bigquery
|
||||
google-auth==2.38.0
|
||||
google-auth==2.40.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# google-api-core
|
||||
@@ -329,7 +329,7 @@ gunicorn==23.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
h11==0.14.0
|
||||
h11==0.16.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# wsproto
|
||||
@@ -342,7 +342,7 @@ holidays==0.25
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# prophet
|
||||
humanize==4.12.2
|
||||
humanize==4.12.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -354,7 +354,8 @@ idna==3.10
|
||||
# email-validator
|
||||
# requests
|
||||
# trio
|
||||
importlib-metadata==8.6.1
|
||||
# url-normalize
|
||||
importlib-metadata==8.7.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -388,14 +389,14 @@ jsonschema==4.23.0
|
||||
# openapi-spec-validator
|
||||
jsonschema-path==0.3.4
|
||||
# via openapi-spec-validator
|
||||
jsonschema-specifications==2024.10.1
|
||||
jsonschema-specifications==2025.4.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# jsonschema
|
||||
# openapi-schema-validator
|
||||
kiwisolver==1.4.7
|
||||
# via matplotlib
|
||||
kombu==5.5.0
|
||||
kombu==5.5.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# celery
|
||||
@@ -405,16 +406,16 @@ korean-lunar-calendar==0.3.1
|
||||
# holidays
|
||||
lazy-object-proxy==1.10.0
|
||||
# via openapi-spec-validator
|
||||
limits==4.4.1
|
||||
limits==5.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-limiter
|
||||
mako==1.3.9
|
||||
mako==1.3.10
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
markdown==3.7
|
||||
markdown==3.8
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -472,7 +473,6 @@ numpy==1.26.4
|
||||
# pandas
|
||||
# pandas-gbq
|
||||
# prophet
|
||||
# pyarrow
|
||||
oauthlib==3.2.2
|
||||
# via requests-oauthlib
|
||||
odfpy==1.4.1
|
||||
@@ -495,7 +495,8 @@ outcome==1.3.0.post0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# trio
|
||||
packaging==24.2
|
||||
# trio-websocket
|
||||
packaging==25.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -542,7 +543,7 @@ pillow==10.3.0
|
||||
# via
|
||||
# apache-superset
|
||||
# matplotlib
|
||||
platformdirs==4.3.7
|
||||
platformdirs==4.3.8
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# requests-cache
|
||||
@@ -565,7 +566,7 @@ prison==0.2.1
|
||||
# flask-appbuilder
|
||||
progress==1.6
|
||||
# via apache-superset
|
||||
prompt-toolkit==3.0.50
|
||||
prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# click-repl
|
||||
@@ -586,7 +587,7 @@ psutil==6.1.0
|
||||
# via apache-superset
|
||||
psycopg2-binary==2.9.6
|
||||
# via apache-superset
|
||||
pyarrow==14.0.2
|
||||
pyarrow==18.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -598,7 +599,7 @@ pyasn1==0.6.1
|
||||
# pyasn1-modules
|
||||
# python-ldap
|
||||
# rsa
|
||||
pyasn1-modules==0.4.1
|
||||
pyasn1-modules==0.4.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# google-auth
|
||||
@@ -635,7 +636,7 @@ pyopenssl==25.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# shillelagh
|
||||
pyparsing==3.2.2
|
||||
pyparsing==3.2.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -668,7 +669,7 @@ python-dateutil==2.9.0.post0
|
||||
# pyhive
|
||||
# shillelagh
|
||||
# trino
|
||||
python-dotenv==1.0.1
|
||||
python-dotenv==1.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -678,7 +679,7 @@ python-geohash==0.8.5
|
||||
# apache-superset
|
||||
python-ldap==3.4.4
|
||||
# via apache-superset
|
||||
pytz==2025.1
|
||||
pytz==2025.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# croniter
|
||||
@@ -731,22 +732,22 @@ rich==13.9.4
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-limiter
|
||||
rpds-py==0.23.1
|
||||
rpds-py==0.25.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# jsonschema
|
||||
# referencing
|
||||
rsa==4.9
|
||||
rsa==4.9.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# google-auth
|
||||
ruff==0.8.0
|
||||
# via apache-superset
|
||||
selenium==4.27.1
|
||||
selenium==4.32.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
setuptools==75.6.0
|
||||
setuptools==80.7.1
|
||||
# via
|
||||
# nodeenv
|
||||
# pandas-gbq
|
||||
@@ -767,7 +768,6 @@ six==1.17.0
|
||||
# prison
|
||||
# python-dateutil
|
||||
# rfc3339-validator
|
||||
# url-normalize
|
||||
# wtforms-json
|
||||
slack-sdk==3.35.0
|
||||
# via
|
||||
@@ -799,7 +799,7 @@ sqlalchemy-utils==0.38.3
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# flask-appbuilder
|
||||
sqlglot==26.11.1
|
||||
sqlglot==26.17.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -829,36 +829,36 @@ tqdm==4.67.1
|
||||
# prophet
|
||||
trino==0.330.0
|
||||
# via apache-superset
|
||||
trio==0.28.0
|
||||
trio==0.30.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# selenium
|
||||
# trio-websocket
|
||||
trio-websocket==0.11.1
|
||||
trio-websocket==0.12.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# selenium
|
||||
typing-extensions==4.12.2
|
||||
typing-extensions==4.13.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.1
|
||||
tzdata==2025.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# celery
|
||||
# kombu
|
||||
# pandas
|
||||
tzlocal==5.2
|
||||
# via trino
|
||||
url-normalize==1.4.3
|
||||
url-normalize==2.2.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# requests-cache
|
||||
|
||||
@@ -56,7 +56,6 @@ describe('Visualization > Big Number with Trendline', () => {
|
||||
it('should work', () => {
|
||||
verify(BIG_NUMBER_FORM_DATA);
|
||||
cy.get('.chart-container .header-line');
|
||||
cy.get('.chart-container .subheader-line');
|
||||
cy.get('.chart-container canvas');
|
||||
});
|
||||
|
||||
@@ -66,7 +65,7 @@ describe('Visualization > Big Number with Trendline', () => {
|
||||
compare_lag: null,
|
||||
});
|
||||
cy.get('.chart-container .header-line');
|
||||
cy.get('.chart-container .subheader-line').should('not.exist');
|
||||
cy.get('.chart-container .subtitle-line').should('not.exist');
|
||||
cy.get('.chart-container canvas');
|
||||
});
|
||||
|
||||
@@ -76,7 +75,6 @@ describe('Visualization > Big Number with Trendline', () => {
|
||||
show_trend_line: false,
|
||||
});
|
||||
cy.get('[data-test="chart-container"] .header-line');
|
||||
cy.get('[data-test="chart-container"] .subheader-line');
|
||||
cy.get('[data-test="chart-container"] canvas').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,4 +252,215 @@ describe('Visualization > Table', () => {
|
||||
});
|
||||
cy.get('td').contains(/\d*%/);
|
||||
});
|
||||
|
||||
it('Test row limit with server pagination toggle', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
row_limit: 100,
|
||||
});
|
||||
|
||||
// Enable server pagination
|
||||
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
|
||||
|
||||
// Click row limit control and select high value (200k)
|
||||
cy.get('div[aria-label="Row limit"]').click();
|
||||
|
||||
// Type 200000 and press enter to select the option
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('200000{enter}');
|
||||
|
||||
// Verify that there is no error tooltip when server pagination is enabled
|
||||
cy.get('[data-test="error-tooltip"]').should('not.exist');
|
||||
|
||||
// Disable server pagination
|
||||
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
|
||||
|
||||
// Verify error tooltip appears
|
||||
cy.get('[data-test="error-tooltip"]').should('be.visible');
|
||||
|
||||
// Trigger mouseover and verify tooltip text
|
||||
cy.get('[data-test="error-tooltip"]').trigger('mouseover');
|
||||
|
||||
// Verify tooltip content
|
||||
cy.get('.antd5-tooltip-inner').should('be.visible');
|
||||
cy.get('.antd5-tooltip-inner').should(
|
||||
'contain',
|
||||
'Server pagination needs to be enabled for values over',
|
||||
);
|
||||
|
||||
// Hide the tooltip by adding display:none style
|
||||
cy.get('.antd5-tooltip').invoke('attr', 'style', 'display: none');
|
||||
|
||||
// Enable server pagination again
|
||||
cy.get('[data-test="server_pagination-header"] div.pull-left').click();
|
||||
|
||||
cy.get('[data-test="error-tooltip"]').should('not.exist');
|
||||
|
||||
cy.get('div[aria-label="Row limit"]').click();
|
||||
|
||||
// Type 1000000
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('1000000');
|
||||
|
||||
// Wait for 1 second
|
||||
cy.wait(1000);
|
||||
|
||||
// Press enter
|
||||
cy.get('div[aria-label="Row limit"]')
|
||||
.find('.ant-select-selection-search-input:visible')
|
||||
.type('{enter}');
|
||||
|
||||
// Wait for error tooltip to appear and verify its content
|
||||
cy.get('[data-test="error-tooltip"]')
|
||||
.should('be.visible')
|
||||
.trigger('mouseover');
|
||||
|
||||
// Wait for tooltip content and verify
|
||||
cy.get('.antd5-tooltip-inner').should('exist');
|
||||
cy.get('.antd5-tooltip-inner').should('be.visible');
|
||||
|
||||
// Verify tooltip content separately
|
||||
cy.get('.antd5-tooltip-inner').should('contain', 'Value cannot exceed');
|
||||
});
|
||||
|
||||
it('Test sorting with server pagination enabled', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
groupby: ['name'],
|
||||
row_limit: 100000,
|
||||
server_pagination: true, // Enable server pagination
|
||||
});
|
||||
|
||||
// Wait for the initial data load
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get the first column header (name)
|
||||
cy.get('.chart-container th').contains('name').as('nameHeader');
|
||||
|
||||
// Click to sort ascending
|
||||
cy.get('@nameHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Verify first row starts with 'A'
|
||||
cy.get('.chart-container td:first').invoke('text').should('match', /^[Aa]/);
|
||||
|
||||
// Click again to sort descending
|
||||
cy.get('@nameHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Verify first row starts with 'Z'
|
||||
cy.get('.chart-container td:first').invoke('text').should('match', /^[Zz]/);
|
||||
|
||||
// Test numeric sorting
|
||||
cy.get('.chart-container th').contains('COUNT').as('countHeader');
|
||||
|
||||
// Click to sort ascending by count
|
||||
cy.get('@countHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get first two count values and verify ascending order
|
||||
cy.get('.chart-container td:nth-child(2)').then($cells => {
|
||||
const first = parseFloat($cells[0].textContent || '0');
|
||||
const second = parseFloat($cells[1].textContent || '0');
|
||||
expect(first).to.be.at.most(second);
|
||||
});
|
||||
|
||||
// Click again to sort descending
|
||||
cy.get('@countHeader').click();
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Get first two count values and verify descending order
|
||||
cy.get('.chart-container td:nth-child(2)').then($cells => {
|
||||
const first = parseFloat($cells[0].textContent || '0');
|
||||
const second = parseFloat($cells[1].textContent || '0');
|
||||
expect(first).to.be.at.least(second);
|
||||
});
|
||||
});
|
||||
|
||||
it('Test search with server pagination enabled', () => {
|
||||
cy.visitChartByParams({
|
||||
...VIZ_DEFAULTS,
|
||||
metrics: ['count'],
|
||||
groupby: ['name', 'state'],
|
||||
row_limit: 100000,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
});
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
// Basic search test
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').should(
|
||||
'be.visible',
|
||||
);
|
||||
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').type('John');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/John/i);
|
||||
});
|
||||
|
||||
// Clear and test case-insensitive search
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').type('mary');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/Mary/i);
|
||||
});
|
||||
|
||||
// Test special characters
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
|
||||
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').type('Nicole');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container tbody tr').each($row => {
|
||||
cy.wrap($row).contains(/Nicole/i);
|
||||
});
|
||||
|
||||
// Test no results
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
|
||||
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').type('XYZ123');
|
||||
|
||||
cy.wait('@chartData');
|
||||
|
||||
cy.get('.chart-container').contains('No records found');
|
||||
|
||||
// Test column-specific search
|
||||
cy.get('.search-select').should('be.visible');
|
||||
|
||||
cy.get('.search-select').click();
|
||||
|
||||
cy.get('.ant-select-dropdown').should('be.visible');
|
||||
|
||||
cy.get('.ant-select-item-option').contains('state').should('be.visible');
|
||||
|
||||
cy.get('.ant-select-item-option').contains('state').click();
|
||||
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').clear();
|
||||
|
||||
cy.get('span.dt-global-filter input.form-control.input-sm').type('CA');
|
||||
|
||||
cy.wait('@chartData');
|
||||
cy.wait(1000);
|
||||
|
||||
cy.get('td[aria-labelledby="header-state"]').should('be.visible');
|
||||
|
||||
cy.get('td[aria-labelledby="header-state"]')
|
||||
.first()
|
||||
.should('contain', 'CA');
|
||||
});
|
||||
});
|
||||
|
||||
966
superset-frontend/package-lock.json
generated
966
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -90,7 +90,7 @@
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^5.21.1",
|
||||
"@rjsf/utils": "^5.24.3",
|
||||
"@rjsf/validator-ajv8": "^5.22.3",
|
||||
"@rjsf/validator-ajv8": "^5.24.9",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -122,7 +122,7 @@
|
||||
"@visx/tooltip": "^3.0.0",
|
||||
"@visx/xychart": "^3.5.1",
|
||||
"abortcontroller-polyfill": "^1.7.8",
|
||||
"ace-builds": "^1.36.3",
|
||||
"ace-builds": "^1.41.0",
|
||||
"ag-grid-community": "33.1.1",
|
||||
"ag-grid-react": "33.1.1",
|
||||
"antd": "4.10.3",
|
||||
@@ -137,6 +137,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dom-to-image-more": "^3.2.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"echarts": "^5.6.0",
|
||||
"emotion-rgba": "0.0.12",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
@@ -182,7 +183,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-hot-loader": "^4.13.1",
|
||||
"react-intersection-observer": "^9.10.2",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-js-cron": "^2.1.2",
|
||||
"react-json-tree": "^0.17.0",
|
||||
"react-lines-ellipsis": "^0.15.4",
|
||||
@@ -229,8 +230,8 @@
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
|
||||
"@babel/plugin-transform-runtime": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.7",
|
||||
"@babel/plugin-transform-runtime": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@babel/register": "^7.23.7",
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ColumnMeta, SortSeriesData, SortSeriesType } from './types';
|
||||
|
||||
export const DEFAULT_MAX_ROW = 100000;
|
||||
export const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const TIME_FILTER_LABELS = {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Dataset } from './types';
|
||||
|
||||
export const TestDataset: Dataset = {
|
||||
column_formats: {},
|
||||
currency_formats: {},
|
||||
columns: [
|
||||
{
|
||||
advanced_data_type: undefined,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { PostProcessingFactory } from './types';
|
||||
import { getMetricOffsetsMap, isTimeComparison } from './utils';
|
||||
import { TIME_COMPARISON_SEPARATOR } from './utils/constants';
|
||||
|
||||
export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
|
||||
formData,
|
||||
@@ -37,50 +38,60 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
|
||||
);
|
||||
const { truncate_metric } = formData;
|
||||
const xAxisLabel = getXAxisLabel(formData);
|
||||
const isTimeComparisonValue = isTimeComparison(formData, queryObject);
|
||||
|
||||
// remove or rename top level of column name(metric name) in the MultiIndex when
|
||||
// 1) only 1 metric
|
||||
// 1) at least 1 metric
|
||||
// 2) dimension exist
|
||||
// 3) xAxis exist
|
||||
// 4) time comparison exist, and comparison type is "actual values"
|
||||
// 5) truncate_metric in form_data and truncate_metric is true
|
||||
// 4) truncate_metric in form_data and truncate_metric is true
|
||||
if (
|
||||
metrics.length === 1 &&
|
||||
metrics.length > 0 &&
|
||||
columns.length > 0 &&
|
||||
xAxisLabel &&
|
||||
!(
|
||||
// todo: we should provide an approach to handle derived metrics
|
||||
(
|
||||
isTimeComparison(formData, queryObject) &&
|
||||
[
|
||||
ComparisonType.Difference,
|
||||
ComparisonType.Ratio,
|
||||
ComparisonType.Percentage,
|
||||
].includes(formData.comparison_type)
|
||||
)
|
||||
) &&
|
||||
truncate_metric !== undefined &&
|
||||
!!truncate_metric
|
||||
) {
|
||||
const renamePairs: [string, string | null][] = [];
|
||||
|
||||
if (
|
||||
// "actual values" will add derived metric.
|
||||
// we will rename the "metric" from the metricWithOffset label
|
||||
// for example: "count__1 year ago" => "1 year ago"
|
||||
isTimeComparison(formData, queryObject) &&
|
||||
formData.comparison_type === ComparisonType.Values
|
||||
isTimeComparisonValue
|
||||
) {
|
||||
const metricOffsetMap = getMetricOffsetsMap(formData, queryObject);
|
||||
const timeOffsets = ensureIsArray(formData.time_compare);
|
||||
[...metricOffsetMap.keys()].forEach(metricWithOffset => {
|
||||
const offsetLabel = timeOffsets.find(offset =>
|
||||
metricWithOffset.includes(offset),
|
||||
);
|
||||
renamePairs.push([metricWithOffset, offsetLabel]);
|
||||
});
|
||||
[...metricOffsetMap.entries()].forEach(
|
||||
([metricWithOffset, metricOnly]) => {
|
||||
const offsetLabel = timeOffsets.find(offset =>
|
||||
metricWithOffset.includes(offset),
|
||||
);
|
||||
renamePairs.push([
|
||||
formData.comparison_type === ComparisonType.Values
|
||||
? metricWithOffset
|
||||
: [formData.comparison_type, metricOnly, metricWithOffset].join(
|
||||
TIME_COMPARISON_SEPARATOR,
|
||||
),
|
||||
metrics.length > 1 ? `${metricOnly}, ${offsetLabel}` : offsetLabel,
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
renamePairs.push([getMetricLabel(metrics[0]), null]);
|
||||
if (
|
||||
![
|
||||
ComparisonType.Difference,
|
||||
ComparisonType.Percentage,
|
||||
ComparisonType.Ratio,
|
||||
].includes(formData.comparison_type) &&
|
||||
metrics.length === 1
|
||||
) {
|
||||
renamePairs.push([getMetricLabel(metrics[0]), null]);
|
||||
}
|
||||
|
||||
if (renamePairs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
operation: 'rename',
|
||||
|
||||
@@ -69,7 +69,6 @@ export interface Dataset {
|
||||
columns: ColumnMeta[];
|
||||
metrics: Metric[];
|
||||
column_formats: Record<string, string>;
|
||||
currency_formats: Record<string, Currency>;
|
||||
verbose_map: Record<string, string>;
|
||||
main_dttm_col: string;
|
||||
// eg. ['["ds", true]', 'ds [asc]']
|
||||
|
||||
@@ -43,12 +43,12 @@ const queryObject: QueryObject = {
|
||||
post_processing: [],
|
||||
};
|
||||
|
||||
test('should skip renameOperator if exists multiple metrics', () => {
|
||||
test('should skip renameOperator for empty metrics', () => {
|
||||
expect(
|
||||
renameOperator(formData, {
|
||||
...queryObject,
|
||||
...{
|
||||
metrics: ['count(*)', 'sum(sales)'],
|
||||
metrics: [],
|
||||
},
|
||||
}),
|
||||
).toEqual(undefined);
|
||||
@@ -77,7 +77,23 @@ test('should skip renameOperator if does not exist x_axis and is_timeseries', ()
|
||||
).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should skip renameOperator if exists derived metrics', () => {
|
||||
test('should skip renameOperator if not is_timeseries and multi metrics', () => {
|
||||
expect(
|
||||
renameOperator(formData, {
|
||||
...queryObject,
|
||||
...{ is_timeseries: false, metrics: ['count(*)', 'sum(val)'] },
|
||||
}),
|
||||
).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should add renameOperator', () => {
|
||||
expect(renameOperator(formData, queryObject)).toEqual({
|
||||
operation: 'rename',
|
||||
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator if exists derived metrics', () => {
|
||||
[
|
||||
ComparisonType.Difference,
|
||||
ComparisonType.Ratio,
|
||||
@@ -99,14 +115,14 @@ test('should skip renameOperator if exists derived metrics', () => {
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator', () => {
|
||||
expect(renameOperator(formData, queryObject)).toEqual({
|
||||
operation: 'rename',
|
||||
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
|
||||
).toEqual({
|
||||
operation: 'rename',
|
||||
options: {
|
||||
columns: { [`${type}__count(*)__count(*)__1 year ago`]: '1 year ago' },
|
||||
inplace: true,
|
||||
level: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -170,6 +186,61 @@ test('should add renameOperator if exist "actual value" time comparison', () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator if derived time comparison exists', () => {
|
||||
expect(
|
||||
renameOperator(
|
||||
{
|
||||
...formData,
|
||||
...{
|
||||
comparison_type: ComparisonType.Ratio,
|
||||
time_compare: ['1 year ago', '1 year later'],
|
||||
},
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'rename',
|
||||
options: {
|
||||
columns: {
|
||||
'ratio__count(*)__count(*)__1 year ago': '1 year ago',
|
||||
'ratio__count(*)__count(*)__1 year later': '1 year later',
|
||||
},
|
||||
inplace: true,
|
||||
level: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator if multiple metrics exist', () => {
|
||||
expect(
|
||||
renameOperator(
|
||||
{
|
||||
...formData,
|
||||
...{
|
||||
comparison_type: ComparisonType.Values,
|
||||
time_compare: ['1 year ago'],
|
||||
},
|
||||
},
|
||||
{
|
||||
...queryObject,
|
||||
...{
|
||||
metrics: ['count(*)', 'sum(sales)'],
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'rename',
|
||||
options: {
|
||||
columns: {
|
||||
'count(*)__1 year ago': 'count(*), 1 year ago',
|
||||
'sum(sales)__1 year ago': 'sum(sales), 1 year ago',
|
||||
},
|
||||
inplace: true,
|
||||
level: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should remove renameOperator', () => {
|
||||
expect(
|
||||
renameOperator(
|
||||
|
||||
@@ -53,7 +53,6 @@ describe('columnChoices()', () => {
|
||||
],
|
||||
verbose_map: {},
|
||||
column_formats: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' },
|
||||
currency_formats: {},
|
||||
datasource_name: 'my_datasource',
|
||||
description: 'this is my datasource',
|
||||
}),
|
||||
@@ -105,7 +104,6 @@ describe('columnChoices()', () => {
|
||||
],
|
||||
verbose_map: {},
|
||||
column_formats: { fiz: 'NUMERIC', about: 'STRING', foo: 'DATE' },
|
||||
currency_formats: {},
|
||||
datasource_name: 'my_datasource',
|
||||
description: 'this is my datasource',
|
||||
}),
|
||||
|
||||
@@ -41,7 +41,6 @@ describe('defineSavedMetrics', () => {
|
||||
columns: [],
|
||||
verbose_map: {},
|
||||
column_formats: {},
|
||||
currency_formats: {},
|
||||
datasource_name: 'my_datasource',
|
||||
description: 'this is my datasource',
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ChartMetadataConfig {
|
||||
label?: ChartLabel | null;
|
||||
labelExplanation?: string | null;
|
||||
queryObjectCount?: number;
|
||||
dynamicQueryObjectCount?: boolean;
|
||||
parseMethod?: ParseMethod;
|
||||
// suppressContextMenu: true hides the default context menu for the chart.
|
||||
// This is useful for viz plugins that define their own context menu.
|
||||
@@ -92,6 +93,8 @@ export default class ChartMetadata {
|
||||
|
||||
queryObjectCount: number;
|
||||
|
||||
dynamicQueryObjectCount: boolean;
|
||||
|
||||
parseMethod: ParseMethod;
|
||||
|
||||
suppressContextMenu?: boolean;
|
||||
@@ -115,6 +118,7 @@ export default class ChartMetadata {
|
||||
label = null,
|
||||
labelExplanation = null,
|
||||
queryObjectCount = 1,
|
||||
dynamicQueryObjectCount = false,
|
||||
parseMethod = 'json-bigint',
|
||||
suppressContextMenu = false,
|
||||
} = config;
|
||||
@@ -145,6 +149,7 @@ export default class ChartMetadata {
|
||||
this.label = label;
|
||||
this.labelExplanation = labelExplanation;
|
||||
this.queryObjectCount = queryObjectCount;
|
||||
this.dynamicQueryObjectCount = dynamicQueryObjectCount;
|
||||
this.parseMethod = parseMethod;
|
||||
this.suppressContextMenu = suppressContextMenu;
|
||||
}
|
||||
|
||||
@@ -58,17 +58,18 @@ export default async function parseResponse<T extends ParseMethod = 'json'>(
|
||||
const result: JsonResponse = {
|
||||
response,
|
||||
json: cloneDeepWith(json, (value: any) => {
|
||||
// `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// TODO: clean up after json-bigint>1.0.1 is released
|
||||
if (value?.isInteger?.() === false) {
|
||||
return Number(value);
|
||||
}
|
||||
if (
|
||||
value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER)
|
||||
value?.isInteger?.() === true &&
|
||||
(value?.isGreaterThan?.(Number.MAX_SAFE_INTEGER) ||
|
||||
value?.isLessThan?.(Number.MIN_SAFE_INTEGER))
|
||||
) {
|
||||
return BigInt(value);
|
||||
}
|
||||
// // `json-bigint` could not handle floats well, see sidorares/json-bigint#62
|
||||
// // TODO: clean up after json-bigint>1.0.1 is released
|
||||
if (value?.isNaN?.() === false) {
|
||||
return value?.toNumber?.();
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -35,7 +35,8 @@ export type Locale =
|
||||
| 'pt'
|
||||
| 'pt_BR'
|
||||
| 'ru'
|
||||
| 'zh'; // supported locales in Superset
|
||||
| 'zh'
|
||||
| 'zh_TW'; // supported locales in Superset
|
||||
|
||||
/**
|
||||
* Language pack provided to `jed`.
|
||||
|
||||
@@ -25,3 +25,4 @@ export { default as validateNonEmpty } from './validateNonEmpty';
|
||||
export { default as validateMaxValue } from './validateMaxValue';
|
||||
export { default as validateMapboxStylesUrl } from './validateMapboxStylesUrl';
|
||||
export { default as validateTimeComparisonRangeValues } from './validateTimeComparisonRangeValues';
|
||||
export { default as validateServerPagination } from './validateServerPagination';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 { t } from '../translation';
|
||||
|
||||
export default function validateServerPagination(
|
||||
v: unknown,
|
||||
serverPagination: boolean,
|
||||
maxValueWithoutServerPagination: number,
|
||||
maxServer: number,
|
||||
) {
|
||||
if (
|
||||
Number(v) > +maxValueWithoutServerPagination &&
|
||||
Number(v) <= maxServer &&
|
||||
!serverPagination
|
||||
) {
|
||||
return t(
|
||||
'Server pagination needs to be enabled for values over %s',
|
||||
maxValueWithoutServerPagination,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ describe('parseResponse()', () => {
|
||||
const mockBigIntUrl = '/mock/get/bigInt';
|
||||
const mockGetBigIntPayload = `{
|
||||
"value": 9223372036854775807, "minus": { "value": -483729382918228373892, "str": "something" },
|
||||
"number": 1234, "floatValue": { "plus": 0.3452211361231223, "minus": -0.3452211361231223 },
|
||||
"number": 1234, "floatValue": { "plus": 0.3452211361231223, "minus": -0.3452211361231223, "even": 1234567890123456.0000000 },
|
||||
"string.constructor": "data.constructor",
|
||||
"constructor": "constructor"
|
||||
}`;
|
||||
@@ -161,6 +161,7 @@ describe('parseResponse()', () => {
|
||||
expect(responseBigNumber.json.floatValue.minus).toEqual(
|
||||
-0.3452211361231223,
|
||||
);
|
||||
expect(responseBigNumber.json.floatValue.even).toEqual(1234567890123456);
|
||||
expect(
|
||||
responseBigNumber.json.floatValue.plus +
|
||||
responseBigNumber.json.floatValue.minus,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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 { validateServerPagination } from '@superset-ui/core';
|
||||
import './setup';
|
||||
|
||||
const DEFAULT_MAX_ROW = 100000;
|
||||
const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
|
||||
|
||||
test('validateServerPagination returns warning message only when value is between max thresholds and server pagination is disabled', () => {
|
||||
// Should show warning - value between thresholds and server pagination disabled
|
||||
expect(
|
||||
validateServerPagination(
|
||||
200000,
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
300000,
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Should not show warning - value above max server threshold
|
||||
expect(
|
||||
validateServerPagination(
|
||||
600000,
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
|
||||
// Should not show warning - value below max without server threshold
|
||||
expect(
|
||||
validateServerPagination(
|
||||
50000,
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test('validateServerPagination returns false when server pagination is enabled regardless of value', () => {
|
||||
expect(
|
||||
validateServerPagination(
|
||||
200000,
|
||||
true,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
300000,
|
||||
true,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
600000,
|
||||
true,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test('validateServerPagination handles string inputs correctly', () => {
|
||||
expect(
|
||||
validateServerPagination(
|
||||
'200000',
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
'600000',
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
'50000',
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test('validateServerPagination handles edge cases', () => {
|
||||
expect(
|
||||
validateServerPagination(
|
||||
undefined,
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
null,
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
NaN,
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
validateServerPagination(
|
||||
'invalid',
|
||||
false,
|
||||
DEFAULT_MAX_ROW,
|
||||
DEFAULT_MAX_ROW_TABLE_SERVER,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
@@ -54,13 +54,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.7",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@storybook/react-webpack5": "8.2.9",
|
||||
"babel-loader": "^9.1.3",
|
||||
"fork-ts-checker-webpack-plugin": "^9.0.2",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -103,6 +103,7 @@ import iran from './countries/iran.geojson';
|
||||
import israel from './countries/israel.geojson';
|
||||
import italy from './countries/italy.geojson';
|
||||
import italy_regions from './countries/italy_regions.geojson';
|
||||
import ivory_coast from './countries/ivory_coast.geojson';
|
||||
import japan from './countries/japan.geojson';
|
||||
import jordan from './countries/jordan.geojson';
|
||||
import kazakhstan from './countries/kazakhstan.geojson';
|
||||
@@ -160,6 +161,7 @@ import philippines_regions from './countries/philippines_regions.geojson';
|
||||
import poland from './countries/poland.geojson';
|
||||
import portugal from './countries/portugal.geojson';
|
||||
import qatar from './countries/qatar.geojson';
|
||||
import republic_of_serbia from './countries/republic_of_serbia.geojson';
|
||||
import romania from './countries/romania.geojson';
|
||||
import russia from './countries/russia.geojson';
|
||||
import rwanda from './countries/rwanda.geojson';
|
||||
@@ -304,6 +306,7 @@ export const countries = {
|
||||
israel,
|
||||
italy,
|
||||
italy_regions,
|
||||
ivory_coast,
|
||||
japan,
|
||||
jordan,
|
||||
kazakhstan,
|
||||
@@ -361,6 +364,7 @@ export const countries = {
|
||||
poland,
|
||||
portugal,
|
||||
qatar,
|
||||
republic_of_serbia,
|
||||
romania,
|
||||
russia,
|
||||
rwanda,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -38,9 +38,20 @@ import {
|
||||
} from '../DeckGLContainer';
|
||||
import { getExploreLongUrl } from '../utils/explore';
|
||||
import layerGenerators from '../layers';
|
||||
import { Viewport } from '../utils/fitViewport';
|
||||
import fitViewport, { Viewport } from '../utils/fitViewport';
|
||||
import { TooltipProps } from '../components/Tooltip';
|
||||
|
||||
import { getPoints as getPointsArc } from '../layers/Arc/Arc';
|
||||
import { getPoints as getPointsPath } from '../layers/Path/Path';
|
||||
import { getPoints as getPointsPolygon } from '../layers/Polygon/Polygon';
|
||||
import { getPoints as getPointsGrid } from '../layers/Grid/Grid';
|
||||
import { getPoints as getPointsScatter } from '../layers/Scatter/Scatter';
|
||||
import { getPoints as getPointsContour } from '../layers/Contour/Contour';
|
||||
import { getPoints as getPointsHeatmap } from '../layers/Heatmap/Heatmap';
|
||||
import { getPoints as getPointsHex } from '../layers/Hex/Hex';
|
||||
import { getPoints as getPointsGeojson } from '../layers/Geojson/Geojson';
|
||||
import { getPoints as getPointsScreengrid } from '../layers/Screengrid/Screengrid';
|
||||
|
||||
export type DeckMultiProps = {
|
||||
formData: QueryFormData;
|
||||
payload: JsonObject;
|
||||
@@ -56,7 +67,35 @@ export type DeckMultiProps = {
|
||||
const DeckMulti = (props: DeckMultiProps) => {
|
||||
const containerRef = useRef<DeckGLContainerHandle>();
|
||||
|
||||
const [viewport, setViewport] = useState<Viewport>();
|
||||
const getAdjustedViewport = useCallback(() => {
|
||||
let viewport = { ...props.viewport };
|
||||
const points = [
|
||||
...getPointsPolygon(props.payload.data.features.deck_polygon || []),
|
||||
...getPointsPath(props.payload.data.features.deck_path || []),
|
||||
...getPointsGrid(props.payload.data.features.deck_grid || []),
|
||||
...getPointsScatter(props.payload.data.features.deck_scatter || []),
|
||||
...getPointsContour(props.payload.data.features.deck_contour || []),
|
||||
...getPointsHeatmap(props.payload.data.features.deck_heatmap || []),
|
||||
...getPointsHex(props.payload.data.features.deck_hex || []),
|
||||
...getPointsArc(props.payload.data.features.deck_arc || []),
|
||||
...getPointsGeojson(props.payload.data.features.deck_geojson || []),
|
||||
...getPointsScreengrid(props.payload.data.features.deck_screengrid || []),
|
||||
];
|
||||
|
||||
if (props.formData) {
|
||||
viewport = fitViewport(viewport, {
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
points,
|
||||
});
|
||||
}
|
||||
if (viewport.zoom < 0) {
|
||||
viewport.zoom = 0;
|
||||
}
|
||||
return viewport;
|
||||
}, [props]);
|
||||
|
||||
const [viewport, setViewport] = useState<Viewport>(getAdjustedViewport());
|
||||
const [subSlicesLayers, setSubSlicesLayers] = useState<Record<number, Layer>>(
|
||||
{},
|
||||
);
|
||||
@@ -70,23 +109,31 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
|
||||
const loadLayers = useCallback(
|
||||
(formData: QueryFormData, payload: JsonObject, viewport?: Viewport) => {
|
||||
setViewport(viewport);
|
||||
setViewport(getAdjustedViewport());
|
||||
setSubSlicesLayers({});
|
||||
payload.data.slices.forEach(
|
||||
(subslice: { slice_id: number } & JsonObject) => {
|
||||
// Filters applied to multi_deck are passed down to underlying charts
|
||||
// note that dashboard contextual information (filter_immune_slices and such) aren't
|
||||
// taken into consideration here
|
||||
const filters = [
|
||||
...(subslice.form_data.filters || []),
|
||||
...(formData.filters || []),
|
||||
const extra_filters = [
|
||||
...(subslice.form_data.extra_filters || []),
|
||||
...(formData.extra_filters || []),
|
||||
...(formData.extra_form_data?.filters || []),
|
||||
];
|
||||
|
||||
const adhoc_filters = [
|
||||
...(formData.adhoc_filters || []),
|
||||
...(subslice.formData?.adhoc_filters || []),
|
||||
...(formData.extra_form_data?.adhoc_filters || []),
|
||||
];
|
||||
|
||||
const subsliceCopy = {
|
||||
...subslice,
|
||||
form_data: {
|
||||
...subslice.form_data,
|
||||
filters,
|
||||
extra_filters,
|
||||
adhoc_filters,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -117,7 +164,13 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
},
|
||||
);
|
||||
},
|
||||
[props.datasource, props.onAddFilter, props.onSelect, setTooltip],
|
||||
[
|
||||
props.datasource,
|
||||
props.onAddFilter,
|
||||
props.onSelect,
|
||||
setTooltip,
|
||||
getAdjustedViewport,
|
||||
],
|
||||
);
|
||||
|
||||
const prevDeckSlices = usePrevious(props.formData.deck_slices);
|
||||
@@ -136,7 +189,7 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
<DeckGLContainerStyledWrapper
|
||||
ref={containerRef}
|
||||
mapboxApiAccessToken={payload.data.mapboxApiKey}
|
||||
viewport={viewport || props.viewport}
|
||||
viewport={viewport}
|
||||
layers={layers}
|
||||
mapStyle={formData.mapbox_style}
|
||||
setControlValue={setControlValue}
|
||||
|
||||
@@ -29,7 +29,7 @@ import TooltipRow from '../../TooltipRow';
|
||||
import { TooltipProps } from '../../components/Tooltip';
|
||||
import { Point } from '../../types';
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
const points: Point[] = [];
|
||||
data.forEach(d => {
|
||||
points.push(d.sourcePosition);
|
||||
@@ -77,7 +77,7 @@ export function getLayer(
|
||||
getTargetColor: (d: any) =>
|
||||
d.targetColor || d.color || [tc.r, tc.g, tc.b, 255 * tc.a],
|
||||
id: `path-layer-${fd.slice_id}` as const,
|
||||
strokeWidth: fd.stroke_width ? fd.stroke_width : 3,
|
||||
getWidth: fd.stroke_width ? fd.stroke_width : 3,
|
||||
...commonLayerProps(fd, setTooltip, setTooltipContent(fd)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const getLayer: getLayerType<unknown> = function (
|
||||
});
|
||||
};
|
||||
|
||||
function getPoints(data: any[]) {
|
||||
export function getPoints(data: any[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import { commonLayerProps } from '../common';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import fitViewport, { Viewport } from '../../utils/fitViewport';
|
||||
import { TooltipProps } from '../../components/Tooltip';
|
||||
import { Point } from '../../types';
|
||||
|
||||
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
|
||||
properties: JsonObject;
|
||||
@@ -172,6 +173,17 @@ export type DeckGLGeoJsonProps = {
|
||||
width: number;
|
||||
};
|
||||
|
||||
export function getPoints(data: Point[]) {
|
||||
return data.reduce((acc: Array<any>, feature: any) => {
|
||||
const bounds = geojsonExtent(feature);
|
||||
if (bounds) {
|
||||
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
|
||||
const containerRef = useRef<DeckGLContainerHandle>();
|
||||
const setTooltip = useCallback((tooltip: TooltipProps['tooltip']) => {
|
||||
@@ -186,24 +198,13 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
|
||||
|
||||
const viewport: Viewport = useMemo(() => {
|
||||
if (formData.autozoom) {
|
||||
const points =
|
||||
payload?.data?.features?.reduce?.(
|
||||
(acc: [number, number, number, number][], feature: any) => {
|
||||
const bounds = geojsonExtent(feature);
|
||||
if (bounds) {
|
||||
return [...acc, [bounds[0], bounds[1]], [bounds[2], bounds[3]]];
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
) || [];
|
||||
const points = getPoints(payload.data.features) || [];
|
||||
|
||||
if (points.length) {
|
||||
return fitViewport(props.viewport, {
|
||||
width,
|
||||
height,
|
||||
points,
|
||||
points: getPoints(payload.data.features) || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function getLayer(
|
||||
});
|
||||
}
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const getLayer: getLayerType<unknown> = (
|
||||
});
|
||||
};
|
||||
|
||||
function getPoints(data: any[]) {
|
||||
export function getPoints(data: any[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export function getLayer(
|
||||
});
|
||||
}
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function getLayer(
|
||||
});
|
||||
}
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
let points: Point[] = [];
|
||||
data.forEach(d => {
|
||||
points = points.concat(d.path);
|
||||
|
||||
@@ -173,6 +173,10 @@ export type DeckGLPolygonProps = {
|
||||
height: number;
|
||||
};
|
||||
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.flatMap(getPointsFromPolygon);
|
||||
}
|
||||
|
||||
const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
const containerRef = useRef<DeckGLContainerHandle>();
|
||||
|
||||
@@ -183,7 +187,7 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
viewport = fitViewport(viewport, {
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
points: features.flatMap(getPointsFromPolygon),
|
||||
points: getPoints(features),
|
||||
});
|
||||
}
|
||||
if (viewport.zoom < 0) {
|
||||
|
||||
@@ -30,7 +30,7 @@ import TooltipRow from '../../TooltipRow';
|
||||
import { unitToRadius } from '../../utils/geo';
|
||||
import { TooltipProps } from '../../components/Tooltip';
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from '../../DeckGLContainer';
|
||||
import { TooltipProps } from '../../components/Tooltip';
|
||||
|
||||
function getPoints(data: JsonObject[]) {
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"dayjs": "^1.11.13",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.2.4",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -36,13 +36,25 @@ import {
|
||||
} from './types';
|
||||
import { useOverflowDetection } from './useOverflowDetection';
|
||||
|
||||
const MetricNameText = styled.div<{ metricNameFontSize?: number }>`
|
||||
${({ theme, metricNameFontSize }) => `
|
||||
font-family: ${theme.typography.families.sansSerif};
|
||||
font-weight: ${theme.typography.weights.normal};
|
||||
font-size: ${metricNameFontSize || theme.typography.sizes.s * 2}px;
|
||||
text-align: center;
|
||||
margin-bottom: ${theme.gridUnit * 3}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const NumbersContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
`;
|
||||
|
||||
const ComparisonValue = styled.div<PopKPIComparisonValueStyleProps>`
|
||||
@@ -73,6 +85,8 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
prevNumber,
|
||||
valueDifference,
|
||||
percentDifferenceFormattedString,
|
||||
metricName,
|
||||
metricNameFontSize,
|
||||
headerFontSize,
|
||||
subheaderFontSize,
|
||||
comparisonColorEnabled,
|
||||
@@ -84,8 +98,8 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
subtitle,
|
||||
subtitleFontSize,
|
||||
dashboardTimeRange,
|
||||
showMetricName,
|
||||
} = props;
|
||||
|
||||
const [comparisonRange, setComparisonRange] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,9 +274,16 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
align-items: flex-start;
|
||||
overflow: auto;
|
||||
`
|
||||
}
|
||||
>
|
||||
{showMetricName && metricName && (
|
||||
<MetricNameText metricNameFontSize={metricNameFontSize}>
|
||||
{metricName}
|
||||
</MetricNameText>
|
||||
)}
|
||||
|
||||
<div css={bigValueContainerStyles}>
|
||||
{bigNumber}
|
||||
{percentDifferenceNumber !== 0 && (
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
subheaderFontSize,
|
||||
subtitleControl,
|
||||
subtitleFontSize,
|
||||
showMetricNameControl,
|
||||
metricNameFontSizeWithVisibility,
|
||||
} from '../sharedControls';
|
||||
import { ColorSchemeEnum } from './types';
|
||||
|
||||
@@ -70,6 +72,8 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
[subtitleControl],
|
||||
[subtitleFontSize],
|
||||
[showMetricNameControl],
|
||||
[metricNameFontSizeWithVisibility],
|
||||
[
|
||||
{
|
||||
...subheaderFontSize,
|
||||
|
||||
@@ -32,6 +32,7 @@ export default class PopKPIPlugin extends ChartPlugin {
|
||||
tags: [
|
||||
t('Comparison'),
|
||||
t('Business'),
|
||||
t('ECharts'),
|
||||
t('Percentages'),
|
||||
t('Report'),
|
||||
t('Advanced-Analytics'),
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
SimpleAdhocFilter,
|
||||
ensureIsArray,
|
||||
} from '@superset-ui/core';
|
||||
import { getComparisonFontSize, getHeaderFontSize } from './utils';
|
||||
import {
|
||||
getComparisonFontSize,
|
||||
getHeaderFontSize,
|
||||
getMetricNameFontSize,
|
||||
} from './utils';
|
||||
|
||||
import { getOriginalLabel } from '../utils';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
@@ -83,6 +89,7 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
headerFontSize,
|
||||
headerText,
|
||||
metric,
|
||||
metricNameFontSize,
|
||||
yAxisFormat,
|
||||
currencyFormat,
|
||||
subheaderFontSize,
|
||||
@@ -91,11 +98,14 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
percentDifferenceFormat,
|
||||
subtitle = '',
|
||||
subtitleFontSize,
|
||||
columnConfig,
|
||||
columnConfig = {},
|
||||
} = formData;
|
||||
const { data: dataA = [] } = queriesData[0];
|
||||
const data = dataA;
|
||||
const metricName = metric ? getMetricLabel(metric) : '';
|
||||
const metrics = chartProps.datasource?.metrics || [];
|
||||
const originalLabel = getOriginalLabel(metric, metrics);
|
||||
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
|
||||
const timeComparison = ensureIsArray(chartProps.rawFormData?.time_compare)[0];
|
||||
const startDateOffset = chartProps.rawFormData?.start_date_offset;
|
||||
const currentTimeRangeFilter = chartProps.rawFormData?.adhoc_filters?.filter(
|
||||
@@ -179,7 +189,7 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
metricName,
|
||||
metricName: originalLabel,
|
||||
bigNumber,
|
||||
prevNumber,
|
||||
valueDifference,
|
||||
@@ -187,6 +197,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
boldText,
|
||||
subtitle,
|
||||
subtitleFontSize,
|
||||
showMetricName,
|
||||
metricNameFontSize: getMetricNameFontSize(metricNameFontSize),
|
||||
headerFontSize: getHeaderFontSize(headerFontSize),
|
||||
subheaderFontSize: getComparisonFontSize(subheaderFontSize),
|
||||
headerText,
|
||||
|
||||
@@ -61,6 +61,8 @@ export type PopKPIProps = PopKPIStylesProps &
|
||||
data: TimeseriesDataRecord[];
|
||||
metrics: Metric[];
|
||||
metricName: string;
|
||||
metricNameFontSize?: number;
|
||||
showMetricName: boolean;
|
||||
bigNumber: string;
|
||||
prevNumber: string;
|
||||
subtitle?: string;
|
||||
|
||||
@@ -16,10 +16,19 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { headerFontSize, subheaderFontSize } from '../sharedControls';
|
||||
import {
|
||||
headerFontSize,
|
||||
subheaderFontSize,
|
||||
metricNameFontSize,
|
||||
} from '../sharedControls';
|
||||
|
||||
const headerFontSizes = [16, 20, 30, 48, 60];
|
||||
const comparisonFontSizes = [16, 20, 26, 32, 40];
|
||||
const sharedFontSizes = [16, 20, 26, 32, 40];
|
||||
|
||||
const metricNameProportionValues =
|
||||
metricNameFontSize.config.options.map(
|
||||
(option: { label: string; value: number }) => option.value,
|
||||
) ?? [];
|
||||
|
||||
const headerProportionValues =
|
||||
headerFontSize.config.options.map(
|
||||
@@ -40,6 +49,10 @@ const getFontSizeMapping = (
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const metricNameFontSizesMapping = getFontSizeMapping(
|
||||
metricNameProportionValues,
|
||||
sharedFontSizes,
|
||||
);
|
||||
const headerFontSizesMapping = getFontSizeMapping(
|
||||
headerProportionValues,
|
||||
headerFontSizes,
|
||||
@@ -47,13 +60,17 @@ const headerFontSizesMapping = getFontSizeMapping(
|
||||
|
||||
const comparisonFontSizesMapping = getFontSizeMapping(
|
||||
subheaderProportionValues,
|
||||
comparisonFontSizes,
|
||||
sharedFontSizes,
|
||||
);
|
||||
|
||||
export const getMetricNameFontSize = (proportionValue: number) =>
|
||||
metricNameFontSizesMapping[proportionValue] ??
|
||||
sharedFontSizes[sharedFontSizes.length - 1];
|
||||
|
||||
export const getHeaderFontSize = (proportionValue: number) =>
|
||||
headerFontSizesMapping[proportionValue] ??
|
||||
headerFontSizes[headerFontSizes.length - 1];
|
||||
|
||||
export const getComparisonFontSize = (proportionValue: number) =>
|
||||
comparisonFontSizesMapping[proportionValue] ??
|
||||
comparisonFontSizes[comparisonFontSizes.length - 1];
|
||||
sharedFontSizes[sharedFontSizes.length - 1];
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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 { SqlaFormData } from '@superset-ui/core';
|
||||
import * as ChartControls from '@superset-ui/chart-controls';
|
||||
import controlPanel from './controlPanel';
|
||||
|
||||
const { __mockShiftMetric } = ChartControls as any;
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
GenericDataType: { Numeric: 'numeric' },
|
||||
SMART_DATE_ID: 'SMART_DATE_ID',
|
||||
t: (str: string) => str,
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/chart-controls', () => {
|
||||
// Define the mock function inside the factory
|
||||
const mockShiftMetric = jest.fn(() => 'shiftedMetric');
|
||||
return {
|
||||
ControlPanelConfig: {},
|
||||
D3_FORMAT_DOCS: 'Format docs',
|
||||
D3_TIME_FORMAT_OPTIONS: [['', 'default']],
|
||||
getStandardizedControls: () => ({
|
||||
shiftMetric: mockShiftMetric,
|
||||
}),
|
||||
// Optional export to let tests access the mock
|
||||
__mockShiftMetric: mockShiftMetric,
|
||||
};
|
||||
});
|
||||
|
||||
describe('BigNumber Total Control Panel Config', () => {
|
||||
it('should have the required control panel sections', () => {
|
||||
expect(controlPanel).toHaveProperty('controlPanelSections');
|
||||
const sections = controlPanel.controlPanelSections;
|
||||
expect(Array.isArray(sections)).toBe(true);
|
||||
expect(sections.length).toBe(2);
|
||||
|
||||
// First section should have label 'Query' and contain rows with metric and adhoc_filters
|
||||
expect(sections[0]!.label).toBe('Query');
|
||||
expect(Array.isArray(sections[0]!.controlSetRows)).toBe(true);
|
||||
expect(sections[0]!.controlSetRows[0]).toEqual(['metric']);
|
||||
expect(sections[0]!.controlSetRows[1]).toEqual(['adhoc_filters']);
|
||||
|
||||
// Second section should contain a control named subtitle
|
||||
const secondSectionRow = sections[1]!.controlSetRows[1];
|
||||
expect(secondSectionRow[0]).toHaveProperty('name', 'subtitle');
|
||||
|
||||
// Second section should include controls for time_format and conditional_formatting
|
||||
const thirdSection = sections[1]!.controlSetRows;
|
||||
// Check time_format control exists in one of the rows
|
||||
const timeFormatRow = thirdSection.find(row =>
|
||||
row.some((control: any) => control.name === 'time_format'),
|
||||
);
|
||||
expect(timeFormatRow).toBeTruthy();
|
||||
// Check conditional_formatting control exists in one of the rows
|
||||
const conditionalFormattingRow = thirdSection.find(row =>
|
||||
row.some((control: any) => control.name === 'conditional_formatting'),
|
||||
);
|
||||
expect(conditionalFormattingRow).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have y_axis_format override with correct label', () => {
|
||||
expect(controlPanel).toHaveProperty('controlOverrides');
|
||||
expect(controlPanel.controlOverrides).toHaveProperty('y_axis_format');
|
||||
expect(controlPanel.controlOverrides!.y_axis_format!.label).toBe(
|
||||
'Number format',
|
||||
);
|
||||
});
|
||||
|
||||
it('should override formData metric using getStandardizedControls', () => {
|
||||
const dummyFormData = { someProp: 'test' } as unknown as SqlaFormData;
|
||||
const newFormData = controlPanel.formDataOverrides!(dummyFormData);
|
||||
|
||||
// The original properties are spread correctly.
|
||||
expect(newFormData.someProp).toBe('test');
|
||||
// The metric property should be replaced by the output of shiftMetric.
|
||||
expect(newFormData.metric).toBe('shiftedMetric');
|
||||
|
||||
// Ensure that the mockShiftMetric function was called.
|
||||
expect(__mockShiftMetric).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
headerFontSize,
|
||||
subtitleFontSize,
|
||||
subtitleControl,
|
||||
showMetricNameControl,
|
||||
metricNameFontSizeWithVisibility,
|
||||
} from '../sharedControls';
|
||||
|
||||
export default {
|
||||
@@ -44,6 +46,8 @@ export default {
|
||||
[headerFontSize],
|
||||
[subtitleControl],
|
||||
[subtitleFontSize],
|
||||
[showMetricNameControl],
|
||||
[metricNameFontSizeWithVisibility],
|
||||
['y_axis_format'],
|
||||
['currency_format'],
|
||||
[
|
||||
|
||||
@@ -39,6 +39,7 @@ const metadata = {
|
||||
tags: [
|
||||
t('Additive'),
|
||||
t('Business'),
|
||||
t('ECharts'),
|
||||
t('Legacy'),
|
||||
t('Percentages'),
|
||||
t('Featured'),
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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 { GenericDataType } from '@superset-ui/core';
|
||||
import { getColorFormatters } from '@superset-ui/chart-controls';
|
||||
import { BigNumberTotalChartProps } from '../types';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
jest.mock('@superset-ui/chart-controls', () => ({
|
||||
getColorFormatters: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
GenericDataType: { Temporal: 2, String: 1 },
|
||||
getMetricLabel: jest.fn(metric => metric),
|
||||
extractTimegrain: jest.fn(() => 'P1D'),
|
||||
getValueFormatter: jest.fn(() => (v: any) => `$${v}`),
|
||||
}));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
|
||||
parseMetricValue: jest.fn(val => Number(val)),
|
||||
getOriginalLabel: jest.fn((metric, metrics) => metric),
|
||||
}));
|
||||
|
||||
describe('BigNumberTotal transformProps', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const baseFormData = {
|
||||
headerFontSize: 20,
|
||||
metric: 'value',
|
||||
subheader: 'sub header text',
|
||||
subheaderFontSize: 14,
|
||||
forceTimestampFormatting: false,
|
||||
timeFormat: 'YYYY-MM-DD',
|
||||
yAxisFormat: 'SMART_NUMBER',
|
||||
conditionalFormatting: [{ color: 'red', op: '>', value: 0 }],
|
||||
currencyFormat: { symbol: '$', symbolPosition: 'prefix' },
|
||||
};
|
||||
|
||||
const baseDatasource = {
|
||||
currencyFormats: { value: '$0,0.00' },
|
||||
columnFormats: { value: '$0,0.00' },
|
||||
metrics: [{ metric_name: 'value', d3format: '.2f' }],
|
||||
};
|
||||
|
||||
const baseHooks = { onContextMenu };
|
||||
|
||||
const baseRawFormData = { dummy: 'raw' };
|
||||
|
||||
it('should return null bigNumber when no data is provided', () => {
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [{ data: [], coltypes: [] }],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBeNull();
|
||||
expect(result.width).toBe(400);
|
||||
expect(result.height).toBe(300);
|
||||
expect(result.subtitle).toBe(baseFormData.subheader);
|
||||
expect(result.onContextMenu).toBe(onContextMenu);
|
||||
expect(result.refs).toEqual({});
|
||||
// headerFormatter should be set even if there's no data
|
||||
expect(typeof result.headerFormatter).toBe('function');
|
||||
// colorThresholdFormatters fallback to empty array when getColorFormatters returns falsy
|
||||
expect(result.colorThresholdFormatters).toEqual([]);
|
||||
});
|
||||
it('should convert subheader to subtitle', () => {
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [{ data: [], coltypes: [] }],
|
||||
formData: { ...baseFormData, subheader: 'test' },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.subtitle).toBe('test');
|
||||
});
|
||||
|
||||
const baseChartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [{ data: [], coltypes: [] }],
|
||||
rawFormData: { dummy: 'raw' },
|
||||
hooks: { onContextMenu: jest.fn() },
|
||||
datasource: {
|
||||
currencyFormats: { value: '$0,0.00' },
|
||||
columnFormats: { value: '$0,0.00' },
|
||||
metrics: [{ metric_name: 'value', d3format: '.2f' }],
|
||||
},
|
||||
};
|
||||
|
||||
it('uses subtitle font size when subtitle is provided', () => {
|
||||
const result = transformProps({
|
||||
...baseChartProps,
|
||||
formData: {
|
||||
subtitle: 'Subtitle wins',
|
||||
subheader: 'Fallback subheader',
|
||||
subtitleFontSize: 0.4,
|
||||
subheaderFontSize: 0.99,
|
||||
metric: 'value',
|
||||
headerFontSize: 0.3,
|
||||
yAxisFormat: 'SMART_NUMBER',
|
||||
timeFormat: 'smart_date',
|
||||
},
|
||||
} as unknown as BigNumberTotalChartProps);
|
||||
|
||||
expect(result.subtitle).toBe('Subtitle wins');
|
||||
expect(result.subtitleFontSize).toBe(0.4);
|
||||
});
|
||||
|
||||
it('should compute bigNumber using parseMetricValue when data exists', () => {
|
||||
const chartProps = {
|
||||
width: 500,
|
||||
height: 400,
|
||||
queriesData: [
|
||||
{ data: [{ value: '456' }], coltypes: [GenericDataType.String] },
|
||||
],
|
||||
formData: { ...baseFormData, forceTimestampFormatting: false },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
sortBy: 'value',
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
// parseMetricValue converts '456' to number 456 by our mock
|
||||
expect(result.bigNumber).toEqual(456);
|
||||
});
|
||||
|
||||
it('should use formatTime as headerFormatter for Temporal or String types or forced formatting', () => {
|
||||
// Case 1: Temporal type
|
||||
const chartPropsTemporal = {
|
||||
width: 600,
|
||||
height: 450,
|
||||
queriesData: [
|
||||
{ data: [{ value: '789' }], coltypes: [GenericDataType.Temporal] },
|
||||
],
|
||||
formData: { ...baseFormData, forceTimestampFormatting: false },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const resultTemporal = transformProps(
|
||||
chartPropsTemporal as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(resultTemporal.headerFormatter(5)).toBe('5pm');
|
||||
|
||||
// Case 2: String type regardless of forcing formatting
|
||||
const chartPropsString = {
|
||||
width: 600,
|
||||
height: 450,
|
||||
queriesData: [
|
||||
{ data: [{ value: '789' }], coltypes: [GenericDataType.String] },
|
||||
],
|
||||
formData: { ...baseFormData, forceTimestampFormatting: false },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const resultString = transformProps(
|
||||
chartPropsString as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(resultString.headerFormatter(5)).toBe('5pm');
|
||||
|
||||
// Case 3: Forced timestamp formatting
|
||||
const chartPropsForced = {
|
||||
width: 600,
|
||||
height: 450,
|
||||
queriesData: [{ data: [{ value: '789' }], coltypes: [0] }], // non-temporal/non-string
|
||||
formData: { ...baseFormData, forceTimestampFormatting: true },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const resultForced = transformProps(
|
||||
chartPropsForced as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(resultForced.headerFormatter(5)).toBe('5pm');
|
||||
});
|
||||
|
||||
it('should use numberFormatter as headerFormatter when not Temporal/String and no forced formatting', () => {
|
||||
const chartProps = {
|
||||
width: 700,
|
||||
height: 500,
|
||||
queriesData: [{ data: [{ value: '321' }], coltypes: [0] }], // non-temporal/non-string
|
||||
formData: { ...baseFormData, forceTimestampFormatting: false },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.headerFormatter(500)).toBe('$500');
|
||||
});
|
||||
|
||||
it('should propagate colorThresholdFormatters from getColorFormatters', () => {
|
||||
// Override the getColorFormatters mock to return specific value
|
||||
const mockFormatters = [{ formatter: 'red' }];
|
||||
(getColorFormatters as jest.Mock).mockReturnValueOnce(mockFormatters);
|
||||
|
||||
const chartProps = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{ data: [{ value: '100' }], coltypes: [GenericDataType.Temporal] },
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberTotalChartProps,
|
||||
);
|
||||
expect(result.colorThresholdFormatters).toEqual(mockFormatters);
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
getValueFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { BigNumberTotalChartProps, BigNumberVizProps } from '../types';
|
||||
import { getDateFormatter, parseMetricValue } from '../utils';
|
||||
import { getDateFormatter, getOriginalLabel, parseMetricValue } from '../utils';
|
||||
import { Refs } from '../../types';
|
||||
|
||||
export default function transformProps(
|
||||
@@ -45,21 +45,30 @@ export default function transformProps(
|
||||
datasource: { currencyFormats = {}, columnFormats = {} },
|
||||
} = chartProps;
|
||||
const {
|
||||
metricNameFontSize,
|
||||
headerFontSize,
|
||||
metric = 'value',
|
||||
subtitle = '',
|
||||
subtitle,
|
||||
subtitleFontSize,
|
||||
forceTimestampFormatting,
|
||||
timeFormat,
|
||||
yAxisFormat,
|
||||
conditionalFormatting,
|
||||
currencyFormat,
|
||||
subheader,
|
||||
subheaderFontSize,
|
||||
} = formData;
|
||||
const refs: Refs = {};
|
||||
const { data = [], coltypes = [] } = queriesData[0];
|
||||
const { data = [], coltypes = [] } = queriesData[0] || {};
|
||||
const granularity = extractTimegrain(rawFormData as QueryFormData);
|
||||
const metrics = chartProps.datasource?.metrics || [];
|
||||
const originalLabel = getOriginalLabel(metric, metrics);
|
||||
const metricName = getMetricLabel(metric);
|
||||
const formattedSubtitle = subtitle;
|
||||
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
|
||||
const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || '';
|
||||
const formattedSubtitleFontSize = subtitle?.trim()
|
||||
? (subtitleFontSize ?? 1)
|
||||
: (subheaderFontSize ?? 1);
|
||||
const bigNumber =
|
||||
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
|
||||
|
||||
@@ -98,19 +107,20 @@ export default function transformProps(
|
||||
const colorThresholdFormatters =
|
||||
getColorFormatters(conditionalFormatting, data, false) ??
|
||||
defaultColorFormatters;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
bigNumber,
|
||||
headerFormatter,
|
||||
headerFontSize,
|
||||
subtitleFontSize,
|
||||
subheaderFontSize,
|
||||
subtitleFontSize: formattedSubtitleFontSize,
|
||||
subtitle: formattedSubtitle,
|
||||
subheader: '',
|
||||
subheaderFontSize: subtitleFontSize,
|
||||
onContextMenu,
|
||||
refs,
|
||||
colorThresholdFormatters,
|
||||
metricName: originalLabel,
|
||||
showMetricName,
|
||||
metricNameFontSize,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent, MouseEvent } from 'react';
|
||||
import { PureComponent, MouseEvent, createRef } from 'react';
|
||||
import {
|
||||
t,
|
||||
getNumberFormatter,
|
||||
@@ -35,6 +35,7 @@ const defaultNumberFormatter = getNumberFormatter();
|
||||
|
||||
const PROPORTION = {
|
||||
// text size: proportion of the chart container sans trendline
|
||||
METRIC_NAME: 0.125,
|
||||
KICKER: 0.1,
|
||||
HEADER: 0.3,
|
||||
SUBHEADER: 0.125,
|
||||
@@ -42,13 +43,20 @@ const PROPORTION = {
|
||||
TRENDLINE: 0.3,
|
||||
};
|
||||
|
||||
class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
type BigNumberVisState = {
|
||||
elementsRendered: boolean;
|
||||
recalculateTrigger: boolean;
|
||||
};
|
||||
|
||||
class BigNumberVis extends PureComponent<BigNumberVizProps, BigNumberVisState> {
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
headerFormatter: defaultNumberFormatter,
|
||||
formatTime: getTimeFormatter(SMART_DATE_VERBOSE_ID),
|
||||
headerFontSize: PROPORTION.HEADER,
|
||||
kickerFontSize: PROPORTION.KICKER,
|
||||
metricNameFontSize: PROPORTION.METRIC_NAME,
|
||||
showMetricName: true,
|
||||
mainColor: BRAND_COLOR,
|
||||
showTimestamp: false,
|
||||
showTrendLine: false,
|
||||
@@ -58,6 +66,40 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
timeRangeFixed: false,
|
||||
};
|
||||
|
||||
// Create refs for each component to measure heights
|
||||
metricNameRef = createRef<HTMLDivElement>();
|
||||
|
||||
kickerRef = createRef<HTMLDivElement>();
|
||||
|
||||
headerRef = createRef<HTMLDivElement>();
|
||||
|
||||
subheaderRef = createRef<HTMLDivElement>();
|
||||
|
||||
subtitleRef = createRef<HTMLDivElement>();
|
||||
|
||||
state = {
|
||||
elementsRendered: false,
|
||||
recalculateTrigger: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
// Wait for elements to render and then calculate heights
|
||||
setTimeout(() => {
|
||||
this.setState({ elementsRendered: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: BigNumberVizProps) {
|
||||
if (
|
||||
prevProps.height !== this.props.height ||
|
||||
prevProps.showTrendLine !== this.props.showTrendLine
|
||||
) {
|
||||
this.setState(prevState => ({
|
||||
recalculateTrigger: !prevState.recalculateTrigger,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getClassName() {
|
||||
const { className, showTrendLine, bigNumberFallback } = this.props;
|
||||
const names = `superset-legacy-chart-big-number ${className} ${
|
||||
@@ -92,6 +134,37 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
);
|
||||
}
|
||||
|
||||
renderMetricName(maxHeight: number) {
|
||||
const { metricName, width, showMetricName } = this.props;
|
||||
if (!showMetricName || !metricName) return null;
|
||||
|
||||
const text = metricName;
|
||||
|
||||
const container = this.createTemporaryContainer();
|
||||
document.body.append(container);
|
||||
const fontSize = computeMaxFontSize({
|
||||
text,
|
||||
maxWidth: width,
|
||||
maxHeight,
|
||||
className: 'metric-name',
|
||||
container,
|
||||
});
|
||||
container.remove();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.metricNameRef}
|
||||
className="metric-name"
|
||||
style={{
|
||||
fontSize,
|
||||
height: 'auto',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderKicker(maxHeight: number) {
|
||||
const { timestamp, showTimestamp, formatTime, width } = this.props;
|
||||
if (
|
||||
@@ -118,6 +191,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.kickerRef}
|
||||
className="kicker"
|
||||
style={{
|
||||
fontSize,
|
||||
@@ -173,6 +247,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.headerRef}
|
||||
className="header-line"
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -188,34 +263,30 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
);
|
||||
}
|
||||
|
||||
renderSubheader(maxHeight: number) {
|
||||
const { bigNumber, subheader, width, bigNumberFallback } = this.props;
|
||||
rendermetricComparisonSummary(maxHeight: number) {
|
||||
const { subheader, width } = this.props;
|
||||
let fontSize = 0;
|
||||
|
||||
const NO_DATA_OR_HASNT_LANDED = t(
|
||||
'No data after filtering or data is NULL for the latest time record',
|
||||
);
|
||||
const NO_DATA = t(
|
||||
'Try applying different filters or ensuring your datasource has data',
|
||||
);
|
||||
let text = subheader;
|
||||
if (bigNumber === null) {
|
||||
text = bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED;
|
||||
}
|
||||
const text = subheader;
|
||||
|
||||
if (text) {
|
||||
const container = this.createTemporaryContainer();
|
||||
document.body.append(container);
|
||||
fontSize = computeMaxFontSize({
|
||||
text,
|
||||
maxWidth: width * 0.9, // max width reduced
|
||||
maxHeight,
|
||||
className: 'subheader-line',
|
||||
container,
|
||||
});
|
||||
container.remove();
|
||||
try {
|
||||
fontSize = computeMaxFontSize({
|
||||
text,
|
||||
maxWidth: width * 0.9,
|
||||
maxHeight,
|
||||
className: 'subheader-line',
|
||||
container,
|
||||
});
|
||||
} finally {
|
||||
container.remove();
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this.subheaderRef}
|
||||
className="subheader-line"
|
||||
style={{
|
||||
fontSize,
|
||||
@@ -230,34 +301,47 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
}
|
||||
|
||||
renderSubtitle(maxHeight: number) {
|
||||
const { subtitle, width } = this.props;
|
||||
const { subtitle, width, bigNumber, bigNumberFallback } = this.props;
|
||||
let fontSize = 0;
|
||||
|
||||
if (subtitle) {
|
||||
const NO_DATA_OR_HASNT_LANDED = t(
|
||||
'No data after filtering or data is NULL for the latest time record',
|
||||
);
|
||||
const NO_DATA = t(
|
||||
'Try applying different filters or ensuring your datasource has data',
|
||||
);
|
||||
|
||||
let text = subtitle;
|
||||
if (bigNumber === null) {
|
||||
text =
|
||||
subtitle || (bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED);
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const container = this.createTemporaryContainer();
|
||||
document.body.append(container);
|
||||
try {
|
||||
fontSize = computeMaxFontSize({
|
||||
text: subtitle,
|
||||
maxWidth: width * 0.9,
|
||||
maxHeight,
|
||||
className: 'subtitle-line',
|
||||
container,
|
||||
});
|
||||
} finally {
|
||||
container.remove();
|
||||
}
|
||||
fontSize = computeMaxFontSize({
|
||||
text,
|
||||
maxWidth: width * 0.9,
|
||||
maxHeight,
|
||||
className: 'subtitle-line',
|
||||
container,
|
||||
});
|
||||
container.remove();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="subtitle-line"
|
||||
style={{
|
||||
fontSize,
|
||||
height: maxHeight,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
ref={this.subtitleRef}
|
||||
className="subtitle-line subheader-line"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: maxHeight,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -309,25 +393,75 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
);
|
||||
}
|
||||
|
||||
getTotalElementsHeight() {
|
||||
const marginPerElement = 8; // theme.gridUnit = 4, so margin-bottom = 8px
|
||||
|
||||
const refs = [
|
||||
this.metricNameRef,
|
||||
this.kickerRef,
|
||||
this.headerRef,
|
||||
this.subheaderRef,
|
||||
this.subtitleRef,
|
||||
];
|
||||
|
||||
// Filter refs to only those with a current element
|
||||
const visibleRefs = refs.filter(ref => ref.current);
|
||||
|
||||
const totalHeight = visibleRefs.reduce((sum, ref, index) => {
|
||||
const height = ref.current?.offsetHeight || 0;
|
||||
const margin = index < visibleRefs.length - 1 ? marginPerElement : 0;
|
||||
return sum + height + margin;
|
||||
}, 0);
|
||||
|
||||
return totalHeight;
|
||||
}
|
||||
|
||||
shouldApplyOverflow(availableHeight: number) {
|
||||
if (!this.state.elementsRendered) return false;
|
||||
const totalHeight = this.getTotalElementsHeight();
|
||||
return totalHeight > availableHeight;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
showTrendLine,
|
||||
height,
|
||||
kickerFontSize,
|
||||
headerFontSize,
|
||||
subheaderFontSize,
|
||||
subtitleFontSize,
|
||||
metricNameFontSize,
|
||||
subheaderFontSize,
|
||||
} = this.props;
|
||||
const className = this.getClassName();
|
||||
|
||||
if (showTrendLine) {
|
||||
const chartHeight = Math.floor(PROPORTION.TRENDLINE * height);
|
||||
const allTextHeight = height - chartHeight;
|
||||
const shouldApplyOverflow = this.shouldApplyOverflow(allTextHeight);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="text-container" style={{ height: allTextHeight }}>
|
||||
<div
|
||||
className="text-container"
|
||||
style={{
|
||||
height: allTextHeight,
|
||||
...(shouldApplyOverflow
|
||||
? {
|
||||
display: 'block',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{this.renderFallbackWarning()}
|
||||
{this.renderMetricName(
|
||||
Math.ceil(
|
||||
(metricNameFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
|
||||
),
|
||||
)}
|
||||
{this.renderKicker(
|
||||
Math.ceil(
|
||||
(kickerFontSize || 0) * (1 - PROPORTION.TRENDLINE) * height,
|
||||
@@ -336,7 +470,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
{this.renderHeader(
|
||||
Math.ceil(headerFontSize * (1 - PROPORTION.TRENDLINE) * height),
|
||||
)}
|
||||
{this.renderSubheader(
|
||||
{this.rendermetricComparisonSummary(
|
||||
Math.ceil(
|
||||
subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height,
|
||||
),
|
||||
@@ -349,14 +483,33 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldApplyOverflow = this.shouldApplyOverflow(height);
|
||||
return (
|
||||
<div className={className} style={{ height }}>
|
||||
{this.renderFallbackWarning()}
|
||||
{this.renderKicker((kickerFontSize || 0) * height)}
|
||||
{this.renderHeader(Math.ceil(headerFontSize * height))}
|
||||
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
|
||||
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
height,
|
||||
...(shouldApplyOverflow
|
||||
? {
|
||||
display: 'block',
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<div className="text-container">
|
||||
{this.renderFallbackWarning()}
|
||||
{this.renderMetricName((metricNameFontSize || 0) * height)}
|
||||
{this.renderKicker((kickerFontSize || 0) * height)}
|
||||
{this.renderHeader(Math.ceil(headerFontSize * height))}
|
||||
{this.rendermetricComparisonSummary(
|
||||
Math.ceil(subheaderFontSize * height),
|
||||
)}
|
||||
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -391,7 +544,12 @@ export default styled(BigNumberVis)`
|
||||
|
||||
.kicker {
|
||||
line-height: 1em;
|
||||
padding-bottom: 2em;
|
||||
margin-bottom: ${theme.gridUnit * 2}px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
line-height: 1em;
|
||||
margin-bottom: ${theme.gridUnit * 2}px;
|
||||
}
|
||||
|
||||
.header-line {
|
||||
@@ -407,12 +565,12 @@ export default styled(BigNumberVis)`
|
||||
|
||||
.subheader-line {
|
||||
line-height: 1em;
|
||||
padding-bottom: 0.3em;
|
||||
margin-bottom: ${theme.gridUnit * 2}px;
|
||||
}
|
||||
|
||||
.subtitle-line {
|
||||
line-height: 1em;
|
||||
padding-top: 0.3em;
|
||||
margin-bottom: ${theme.gridUnit * 2}px;
|
||||
}
|
||||
|
||||
&.is-fallback-value {
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
subheaderFontSize,
|
||||
subtitleFontSize,
|
||||
subtitleControl,
|
||||
showMetricNameControl,
|
||||
metricNameFontSizeWithVisibility,
|
||||
} from '../sharedControls';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
@@ -141,6 +143,8 @@ const config: ControlPanelConfig = {
|
||||
[subheaderFontSize],
|
||||
[subtitleControl],
|
||||
[subtitleFontSize],
|
||||
[showMetricNameControl],
|
||||
[metricNameFontSizeWithVisibility],
|
||||
['y_axis_format'],
|
||||
['currency_format'],
|
||||
[
|
||||
|
||||
@@ -37,6 +37,7 @@ const metadata = {
|
||||
name: t('Big Number with Trendline'),
|
||||
tags: [
|
||||
t('Advanced-Analytics'),
|
||||
t('ECharts'),
|
||||
t('Line'),
|
||||
t('Percentages'),
|
||||
t('Featured'),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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 { GenericDataType } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
GenericDataType: { Temporal: 2, String: 1 },
|
||||
extractTimegrain: jest.fn(() => 'P1D'),
|
||||
getMetricLabel: jest.fn(metric => metric),
|
||||
getXAxisLabel: jest.fn(() => '__timestamp'),
|
||||
getValueFormatter: jest.fn(() => ({
|
||||
format: (v: number) => `$${v}`,
|
||||
})),
|
||||
getNumberFormatter: jest.fn(() => (v: number) => `${(v * 100).toFixed(1)}%`),
|
||||
t: jest.fn(v => v),
|
||||
tooltipHtml: jest.fn(() => '<div>tooltip</div>'),
|
||||
NumberFormats: {
|
||||
PERCENT_SIGNED_1_POINT: '.1%',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
|
||||
parseMetricValue: jest.fn(val => Number(val)),
|
||||
getOriginalLabel: jest.fn((metric, metrics) => metric),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/tooltip', () => ({
|
||||
getDefaultTooltip: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
describe('BigNumberWithTrendline transformProps', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const baseFormData = {
|
||||
headerFontSize: 20,
|
||||
metric: 'value',
|
||||
subtitle: 'subtitle message',
|
||||
subtitleFontSize: 14,
|
||||
forceTimestampFormatting: false,
|
||||
timeFormat: 'YYYY-MM-DD',
|
||||
yAxisFormat: 'SMART_NUMBER',
|
||||
compareLag: 1,
|
||||
compareSuffix: 'WoW',
|
||||
colorPicker: { r: 0, g: 0, b: 0 },
|
||||
currencyFormat: { symbol: '$', symbolPosition: 'prefix' },
|
||||
};
|
||||
|
||||
const baseDatasource = {
|
||||
currencyFormats: { value: '$0,0.00' },
|
||||
columnFormats: { value: '$0,0.00' },
|
||||
metrics: [{ metric_name: 'value', d3format: '.2f' }],
|
||||
};
|
||||
|
||||
const baseHooks = { onContextMenu };
|
||||
const baseRawFormData = { dummy: 'raw' };
|
||||
|
||||
it('should return null bigNumber when no data is provided', () => {
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [{ data: [] as unknown as BigNumberDatum[], coltypes: [] }],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toBeNull();
|
||||
expect(result.subtitle).toBe('subtitle message');
|
||||
});
|
||||
|
||||
it('should calculate subheader as percent change with suffix', () => {
|
||||
const chartProps = {
|
||||
width: 500,
|
||||
height: 400,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ __timestamp: 2, value: 110 },
|
||||
{ __timestamp: 1, value: 100 },
|
||||
] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: ['TEMPORAL', 'NUMERIC'],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.subheader).toBe('10.0% WoW');
|
||||
});
|
||||
|
||||
it('should compute bigNumber from parseMetricValue', () => {
|
||||
const chartProps = {
|
||||
width: 600,
|
||||
height: 450,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ __timestamp: 2, value: '456' },
|
||||
] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData: baseFormData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.bigNumber).toEqual(456);
|
||||
});
|
||||
|
||||
it('should use formatTime as headerFormatter for Temporal/String or forced', () => {
|
||||
const formData = { ...baseFormData, forceTimestampFormatting: true };
|
||||
const chartProps = {
|
||||
width: 600,
|
||||
height: 450,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ __timestamp: 2, value: '123' },
|
||||
] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: [0, GenericDataType.String],
|
||||
},
|
||||
],
|
||||
formData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.headerFormatter(5)).toBe('5pm');
|
||||
});
|
||||
|
||||
it('should use numberFormatter when not Temporal/String and not forced', () => {
|
||||
const formData = { ...baseFormData, forceTimestampFormatting: false };
|
||||
const chartProps = {
|
||||
width: 600,
|
||||
height: 450,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ __timestamp: 2, value: 500 }] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: [0, 0],
|
||||
},
|
||||
],
|
||||
formData,
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.headerFormatter.format(500)).toBe('$500');
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
BigNumberWithTrendlineChartProps,
|
||||
TimeSeriesDatum,
|
||||
} from '../types';
|
||||
import { getDateFormatter, parseMetricValue } from '../utils';
|
||||
import { getDateFormatter, parseMetricValue, getOriginalLabel } from '../utils';
|
||||
import { getDefaultTooltip } from '../../utils/tooltip';
|
||||
import { Refs } from '../../types';
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function transformProps(
|
||||
compareLag: compareLag_,
|
||||
compareSuffix = '',
|
||||
timeFormat,
|
||||
metricNameFontSize,
|
||||
headerFontSize,
|
||||
metric = 'value',
|
||||
showTimestamp,
|
||||
@@ -96,6 +97,9 @@ export default function transformProps(
|
||||
const aggregatedData = hasAggregatedData ? aggregatedQueryData.data[0] : null;
|
||||
const refs: Refs = {};
|
||||
const metricName = getMetricLabel(metric);
|
||||
const metrics = chartProps.datasource?.metrics || [];
|
||||
const originalLabel = getOriginalLabel(metric, metrics);
|
||||
const showMetricName = chartProps.rawFormData?.show_metric_name ?? false;
|
||||
const compareLag = Number(compareLag_) || 0;
|
||||
let formattedSubheader = subheader;
|
||||
|
||||
@@ -303,6 +307,9 @@ export default function transformProps(
|
||||
headerFormatter,
|
||||
formatTime,
|
||||
formData,
|
||||
metricName: originalLabel,
|
||||
showMetricName,
|
||||
metricNameFontSize,
|
||||
headerFontSize,
|
||||
subtitleFontSize,
|
||||
subtitle,
|
||||
|
||||
@@ -21,106 +21,68 @@
|
||||
import { t } from '@superset-ui/core';
|
||||
import { CustomControlItem } from '@superset-ui/chart-controls';
|
||||
|
||||
export const headerFontSize: CustomControlItem = {
|
||||
name: 'header_font_size',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Big Number Font Size'),
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
default: 0.4,
|
||||
// Values represent the percentage of space a header should take
|
||||
options: [
|
||||
{
|
||||
label: t('Tiny'),
|
||||
value: 0.2,
|
||||
},
|
||||
{
|
||||
label: t('Small'),
|
||||
value: 0.3,
|
||||
},
|
||||
{
|
||||
label: t('Normal'),
|
||||
value: 0.4,
|
||||
},
|
||||
{
|
||||
label: t('Large'),
|
||||
value: 0.5,
|
||||
},
|
||||
{
|
||||
label: t('Huge'),
|
||||
value: 0.6,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const FONT_SIZE_OPTIONS_SMALL = [
|
||||
{ label: t('Tiny'), value: 0.125 },
|
||||
{ label: t('Small'), value: 0.15 },
|
||||
{ label: t('Normal'), value: 0.2 },
|
||||
{ label: t('Large'), value: 0.3 },
|
||||
{ label: t('Huge'), value: 0.4 },
|
||||
];
|
||||
|
||||
export const subtitleFontSize: CustomControlItem = {
|
||||
name: 'subtitle_font_size',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Subtitle Font Size'),
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
default: 0.15,
|
||||
// Values represent the percentage of space a subtitle should take
|
||||
options: [
|
||||
{
|
||||
label: t('Tiny'),
|
||||
value: 0.125,
|
||||
},
|
||||
{
|
||||
label: t('Small'),
|
||||
value: 0.15,
|
||||
},
|
||||
{
|
||||
label: t('Normal'),
|
||||
value: 0.2,
|
||||
},
|
||||
{
|
||||
label: t('Large'),
|
||||
value: 0.3,
|
||||
},
|
||||
{
|
||||
label: t('Huge'),
|
||||
value: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
export const subheaderFontSize: CustomControlItem = {
|
||||
name: 'subheader_font_size',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Subheader Font Size'),
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
default: 0.15,
|
||||
// Values represent the percentage of space a subheader should take
|
||||
options: [
|
||||
{
|
||||
label: t('Tiny'),
|
||||
value: 0.125,
|
||||
},
|
||||
{
|
||||
label: t('Small'),
|
||||
value: 0.15,
|
||||
},
|
||||
{
|
||||
label: t('Normal'),
|
||||
value: 0.2,
|
||||
},
|
||||
{
|
||||
label: t('Large'),
|
||||
value: 0.3,
|
||||
},
|
||||
{
|
||||
label: t('Huge'),
|
||||
value: 0.4,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const FONT_SIZE_OPTIONS_LARGE = [
|
||||
{ label: t('Tiny'), value: 0.2 },
|
||||
{ label: t('Small'), value: 0.3 },
|
||||
{ label: t('Normal'), value: 0.4 },
|
||||
{ label: t('Large'), value: 0.5 },
|
||||
{ label: t('Huge'), value: 0.6 },
|
||||
];
|
||||
|
||||
function makeFontSizeControl(
|
||||
name: string,
|
||||
label: string,
|
||||
defaultValue: number,
|
||||
options: { label: string; value: number }[],
|
||||
): CustomControlItem {
|
||||
return {
|
||||
name,
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t(label),
|
||||
renderTrigger: true,
|
||||
clearable: false,
|
||||
default: defaultValue,
|
||||
options,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const headerFontSize = makeFontSizeControl(
|
||||
'header_font_size',
|
||||
'Big Number Font Size',
|
||||
0.4,
|
||||
FONT_SIZE_OPTIONS_LARGE,
|
||||
);
|
||||
|
||||
export const subtitleFontSize = makeFontSizeControl(
|
||||
'subtitle_font_size',
|
||||
'Subtitle Font Size',
|
||||
0.15,
|
||||
FONT_SIZE_OPTIONS_SMALL,
|
||||
);
|
||||
|
||||
export const subheaderFontSize = makeFontSizeControl(
|
||||
'subheader_font_size',
|
||||
'Subheader Font Size',
|
||||
0.15,
|
||||
FONT_SIZE_OPTIONS_SMALL,
|
||||
);
|
||||
|
||||
export const metricNameFontSize = makeFontSizeControl(
|
||||
'metric_name_font_size',
|
||||
'Metric Name Font Size',
|
||||
0.15,
|
||||
FONT_SIZE_OPTIONS_SMALL,
|
||||
);
|
||||
|
||||
export const subtitleControl: CustomControlItem = {
|
||||
name: 'subtitle',
|
||||
@@ -131,3 +93,23 @@ export const subtitleControl: CustomControlItem = {
|
||||
description: t('Description text that shows up below your Big Number'),
|
||||
},
|
||||
};
|
||||
|
||||
export const showMetricNameControl: CustomControlItem = {
|
||||
name: 'show_metric_name',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show Metric Name'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t('Whether to display the metric name'),
|
||||
},
|
||||
};
|
||||
|
||||
export const metricNameFontSizeWithVisibility: CustomControlItem = {
|
||||
...metricNameFontSize,
|
||||
config: {
|
||||
...metricNameFontSize.config,
|
||||
visibility: ({ controls }) => controls?.show_metric_name?.value === true,
|
||||
resetOnHide: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,9 +75,13 @@ export type BigNumberVizProps = {
|
||||
bigNumberFallback?: TimeSeriesDatum;
|
||||
headerFormatter: ValueFormatter | TimeFormatter;
|
||||
formatTime?: TimeFormatter;
|
||||
metricName?: string;
|
||||
friendlyMetricName?: string;
|
||||
metricNameFontSize?: number;
|
||||
showMetricName?: boolean;
|
||||
headerFontSize: number;
|
||||
kickerFontSize?: number;
|
||||
subheader: string;
|
||||
subheader?: string;
|
||||
subtitle: string;
|
||||
subheaderFontSize: number;
|
||||
subtitleFontSize: number;
|
||||
|
||||
@@ -22,6 +22,10 @@ import utc from 'dayjs/plugin/utc';
|
||||
import {
|
||||
getTimeFormatter,
|
||||
getTimeFormatterForGranularity,
|
||||
isAdhocMetricSimple,
|
||||
isSavedMetric,
|
||||
Metric,
|
||||
QueryFormMetric,
|
||||
SMART_DATE_ID,
|
||||
TimeGranularity,
|
||||
} from '@superset-ui/core';
|
||||
@@ -47,3 +51,43 @@ export const getDateFormatter = (
|
||||
timeFormat === SMART_DATE_ID
|
||||
? getTimeFormatterForGranularity(granularity)
|
||||
: getTimeFormatter(timeFormat ?? fallbackFormat);
|
||||
|
||||
export function getOriginalLabel(
|
||||
metric: QueryFormMetric,
|
||||
metrics: Metric[] = [],
|
||||
): string {
|
||||
const metricLabel = typeof metric === 'string' ? metric : metric.label || '';
|
||||
|
||||
if (isSavedMetric(metric)) {
|
||||
const metricEntry = metrics.find(m => m.metric_name === metric);
|
||||
return (
|
||||
metricEntry?.verbose_name ||
|
||||
metricEntry?.metric_name ||
|
||||
metric ||
|
||||
'Unknown Metric'
|
||||
);
|
||||
}
|
||||
|
||||
if (isAdhocMetricSimple(metric)) {
|
||||
const column = metric.column || {};
|
||||
const columnName = column.column_name || 'unknown_column';
|
||||
const verboseName = column.verbose_name || columnName;
|
||||
const aggregate = metric.aggregate || 'UNKNOWN';
|
||||
return metric.hasCustomLabel && metric.label
|
||||
? metric.label
|
||||
: `${aggregate}(${verboseName})`;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof metric === 'object' &&
|
||||
'expressionType' in metric &&
|
||||
metric.expressionType === 'SQL' &&
|
||||
'sqlExpression' in metric
|
||||
) {
|
||||
return metric.hasCustomLabel && metric.label
|
||||
? metric.label
|
||||
: metricLabel || 'Custom Metric';
|
||||
}
|
||||
|
||||
return metricLabel || 'Unknown Metric';
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function transformProps(
|
||||
data: data.map(row =>
|
||||
colnames.map(col => {
|
||||
const value = row[col];
|
||||
if (!value) {
|
||||
if (value === null || value === undefined) {
|
||||
return NULL_STRING;
|
||||
}
|
||||
if (typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function transformProps(
|
||||
.filter(key => !groupbySet.has(key))
|
||||
.map(key => {
|
||||
const array = key.split(' - ').map(value => parseFloat(value));
|
||||
return `${xAxisFormatter(array[0])} '-' ${xAxisFormatter(array[1])}`;
|
||||
return `${xAxisFormatter(array[0])} - ${xAxisFormatter(array[1])}`;
|
||||
});
|
||||
const barSeries: BarSeriesOption[] = data.map(datum => {
|
||||
const seriesName =
|
||||
|
||||
@@ -16,14 +16,30 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { buildQueryContext, QueryFormData } from '@superset-ui/core';
|
||||
import {
|
||||
buildQueryContext,
|
||||
getMetricLabel,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { getContributionLabel } from './utils';
|
||||
|
||||
export default function buildQuery(formData: QueryFormData) {
|
||||
const { metric, sort_by_metric } = formData;
|
||||
const metricLabel = getMetricLabel(metric);
|
||||
|
||||
return buildQueryContext(formData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
...(sort_by_metric && { orderby: [[metric, false]] }),
|
||||
post_processing: [
|
||||
{
|
||||
operation: 'contribution',
|
||||
options: {
|
||||
columns: [metricLabel],
|
||||
rename_columns: [getContributionLabel(metricLabel)],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* 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 CONTRIBUTION_SUFFIX = '__contribution' as const;
|
||||
@@ -84,6 +84,23 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'threshold_for_other',
|
||||
config: {
|
||||
type: 'NumberControl',
|
||||
label: t('Threshold for Other'),
|
||||
min: 0,
|
||||
step: 0.5,
|
||||
max: 100,
|
||||
default: 0,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Values less than this percentage will be grouped into the Other category.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'roseType',
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ValueFormatter,
|
||||
getValueFormatter,
|
||||
tooltipHtml,
|
||||
DataRecord,
|
||||
} from '@superset-ui/core';
|
||||
import type { CallbackDataParams } from 'echarts/types/src/util/types';
|
||||
import type { EChartsCoreOption } from 'echarts/core';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
EchartsPieChartProps,
|
||||
EchartsPieFormData,
|
||||
EchartsPieLabelType,
|
||||
PieChartDataItem,
|
||||
PieChartTransformedProps,
|
||||
} from './types';
|
||||
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
|
||||
@@ -50,6 +52,7 @@ import { defaultGrid } from '../defaults';
|
||||
import { convertInteger } from '../utils/convertInteger';
|
||||
import { getDefaultTooltip } from '../utils/tooltip';
|
||||
import { Refs } from '../types';
|
||||
import { getContributionLabel } from './utils';
|
||||
|
||||
const percentFormatter = getNumberFormatter(NumberFormats.PERCENT_2_POINT);
|
||||
|
||||
@@ -133,7 +136,7 @@ export default function transformProps(
|
||||
datasource,
|
||||
} = chartProps;
|
||||
const { columnFormats = {}, currencyFormats = {} } = datasource;
|
||||
const { data = [] } = queriesData[0];
|
||||
const { data: rawData = [] } = queriesData[0];
|
||||
const coltypeMapping = getColtypesMapping(queriesData[0]);
|
||||
|
||||
const {
|
||||
@@ -159,6 +162,7 @@ export default function transformProps(
|
||||
sliceId,
|
||||
showTotal,
|
||||
roseType,
|
||||
thresholdForOther,
|
||||
}: EchartsPieFormData = {
|
||||
...DEFAULT_LEGEND_FORM_DATA,
|
||||
...DEFAULT_PIE_FORM_DATA,
|
||||
@@ -166,17 +170,68 @@ export default function transformProps(
|
||||
};
|
||||
const refs: Refs = {};
|
||||
const metricLabel = getMetricLabel(metric);
|
||||
const contributionLabel = getContributionLabel(metricLabel);
|
||||
const groupbyLabels = groupby.map(getColumnLabel);
|
||||
const minShowLabelAngle = (showLabelsThreshold || 0) * 3.6;
|
||||
|
||||
const keys = data.map(datum =>
|
||||
extractGroupbyLabel({
|
||||
datum,
|
||||
groupby: groupbyLabels,
|
||||
coltypeMapping,
|
||||
timeFormatter: getTimeFormatter(dateFormat),
|
||||
}),
|
||||
const numberFormatter = getValueFormatter(
|
||||
metric,
|
||||
currencyFormats,
|
||||
columnFormats,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
);
|
||||
|
||||
let data = rawData;
|
||||
const otherRows: DataRecord[] = [];
|
||||
const otherTooltipData: string[][] = [];
|
||||
let otherDatum: PieChartDataItem | null = null;
|
||||
let otherSum = 0;
|
||||
if (thresholdForOther) {
|
||||
let contributionSum = 0;
|
||||
data = data.filter(datum => {
|
||||
const contribution = datum[contributionLabel] as number;
|
||||
if (!contribution || contribution * 100 >= thresholdForOther) {
|
||||
return true;
|
||||
}
|
||||
otherSum += datum[metricLabel] as number;
|
||||
contributionSum += contribution;
|
||||
otherRows.push(datum);
|
||||
otherTooltipData.push([
|
||||
extractGroupbyLabel({
|
||||
datum,
|
||||
groupby: groupbyLabels,
|
||||
coltypeMapping,
|
||||
timeFormatter: getTimeFormatter(dateFormat),
|
||||
}),
|
||||
numberFormatter(datum[metricLabel] as number),
|
||||
percentFormatter(contribution),
|
||||
]);
|
||||
return false;
|
||||
});
|
||||
const otherName = t('Other');
|
||||
otherTooltipData.push([
|
||||
t('Total'),
|
||||
numberFormatter(otherSum),
|
||||
percentFormatter(contributionSum),
|
||||
]);
|
||||
if (otherSum) {
|
||||
otherDatum = {
|
||||
name: otherName,
|
||||
value: otherSum,
|
||||
itemStyle: {
|
||||
color: theme.colors.grayscale.dark1,
|
||||
opacity:
|
||||
filterState.selectedValues &&
|
||||
!filterState.selectedValues.includes(otherName)
|
||||
? OpacityEnum.SemiTransparent
|
||||
: OpacityEnum.NonTransparent,
|
||||
},
|
||||
isOther: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const labelMap = data.reduce((acc: Record<string, string[]>, datum) => {
|
||||
const label = extractGroupbyLabel({
|
||||
datum,
|
||||
@@ -192,13 +247,6 @@ export default function transformProps(
|
||||
|
||||
const { setDataMask = () => {}, onContextMenu } = hooks;
|
||||
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
|
||||
const numberFormatter = getValueFormatter(
|
||||
metric,
|
||||
currencyFormats,
|
||||
columnFormats,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
);
|
||||
|
||||
let totalValue = 0;
|
||||
|
||||
@@ -229,6 +277,10 @@ export default function transformProps(
|
||||
},
|
||||
};
|
||||
});
|
||||
if (otherDatum) {
|
||||
transformedData.push(otherDatum);
|
||||
totalValue += otherSum;
|
||||
}
|
||||
|
||||
const selectedValues = (filterState.selectedValues || []).reduce(
|
||||
(acc: Record<string, number>, selectedValue: string) => {
|
||||
@@ -372,6 +424,9 @@ export default function transformProps(
|
||||
numberFormatter,
|
||||
sanitizeName: true,
|
||||
});
|
||||
if (params?.data?.isOther) {
|
||||
return tooltipHtml(otherTooltipData, name);
|
||||
}
|
||||
return tooltipHtml(
|
||||
[[metricLabel, formattedValue, formattedPercent]],
|
||||
name,
|
||||
@@ -380,7 +435,7 @@ export default function transformProps(
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(legendType, legendOrientation, showLegend, theme),
|
||||
data: keys,
|
||||
data: transformedData.map(datum => datum.name),
|
||||
},
|
||||
graphic: showTotal
|
||||
? {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type EchartsPieFormData = QueryFormData &
|
||||
dateFormat: string;
|
||||
showLabelsThreshold: number;
|
||||
roseType: 'radius' | 'area' | null;
|
||||
thresholdForOther: number;
|
||||
};
|
||||
|
||||
export enum EchartsPieLabelType {
|
||||
@@ -82,9 +83,20 @@ export const DEFAULT_FORM_DATA: EchartsPieFormData = {
|
||||
showLabelsThreshold: 5,
|
||||
dateFormat: 'smart_date',
|
||||
roseType: null,
|
||||
thresholdForOther: 0,
|
||||
};
|
||||
|
||||
export type PieChartTransformedProps =
|
||||
BaseTransformedProps<EchartsPieFormData> &
|
||||
ContextMenuTransformedProps &
|
||||
CrossFilterTransformedProps;
|
||||
|
||||
export interface PieChartDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
itemStyle: {
|
||||
color: string;
|
||||
opacity: number;
|
||||
};
|
||||
isOther?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 { CONTRIBUTION_SUFFIX } from './constants';
|
||||
|
||||
export const getContributionLabel = (metricLabel: string) =>
|
||||
`${metricLabel}${CONTRIBUTION_SUFFIX}`;
|
||||
@@ -57,7 +57,7 @@ export default class EchartsSankeyChartPlugin extends ChartPlugin<
|
||||
),
|
||||
exampleGallery: [{ url: example1 }, { url: example2 }],
|
||||
name: t('Sankey Chart'),
|
||||
tags: [t('Directional'), t('Distribution'), t('Flow')],
|
||||
tags: [t('Directional'), t('ECharts'), t('Distribution'), t('Flow')],
|
||||
thumbnail,
|
||||
}),
|
||||
transformProps,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@superset-ui/core';
|
||||
import { JsonArray, t } from '@superset-ui/core';
|
||||
import {
|
||||
ControlPanelConfig,
|
||||
ControlPanelsContainerProps,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
DEFAULT_FORM_DATA,
|
||||
TIME_SERIES_DESCRIPTION_TEXT,
|
||||
} from '../../constants';
|
||||
import { StackControlsValue } from '../../../constants';
|
||||
|
||||
const {
|
||||
logAxis,
|
||||
@@ -321,6 +322,38 @@ const config: ControlPanelConfig = {
|
||||
['color_scheme'],
|
||||
['time_shift_color'],
|
||||
...showValueSection,
|
||||
[
|
||||
{
|
||||
name: 'stackDimension',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('Split stack by'),
|
||||
visibility: ({ controls }) =>
|
||||
controls?.stack?.value === StackControlsValue.Stack,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Stack in groups, where each group corresponds to a dimension',
|
||||
),
|
||||
shouldMapStateToProps: (
|
||||
prevState,
|
||||
state,
|
||||
controlState,
|
||||
chartState,
|
||||
) => true,
|
||||
mapStateToProps: (state, controlState, chartState) => {
|
||||
const value: JsonArray = state.controls.groupby
|
||||
.value as JsonArray;
|
||||
const valueAsStringArr: string[][] = value.map(v => {
|
||||
if (v) return [v.toString(), v.toString()];
|
||||
return ['', ''];
|
||||
});
|
||||
return {
|
||||
choices: valueAsStringArr,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[minorTicks],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -58,6 +58,7 @@ export default class EchartsTimeseriesChartPlugin extends EchartsChartPlugin<
|
||||
name: t('Generic Chart'),
|
||||
tags: [
|
||||
t('Advanced-Analytics'),
|
||||
t('ECharts'),
|
||||
t('Line'),
|
||||
t('Predictive'),
|
||||
t('Time'),
|
||||
|
||||
@@ -191,6 +191,7 @@ export default function transformProps(
|
||||
yAxisTitleMargin,
|
||||
yAxisTitlePosition,
|
||||
zoomable,
|
||||
stackDimension,
|
||||
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
const refs: Refs = {};
|
||||
const groupBy = ensureIsArray(groupby);
|
||||
@@ -418,6 +419,23 @@ export default function transformProps(
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
stack === StackControlsValue.Stack &&
|
||||
stackDimension &&
|
||||
chartProps.rawFormData.groupby
|
||||
) {
|
||||
const idxSelectedDimension =
|
||||
formData.metrics.length > 1
|
||||
? 1
|
||||
: 0 + chartProps.rawFormData.groupby.indexOf(stackDimension);
|
||||
for (const s of series) {
|
||||
if (s.id) {
|
||||
const columnsArr = labelMap[s.id];
|
||||
(s as any).stack = columnsArr[idxSelectedDimension];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// axis bounds need to be parsed to replace incompatible values with undefined
|
||||
const [xAxisMin, xAxisMax] = (xAxisBounds || []).map(parseAxisBound);
|
||||
let [yAxisMin, yAxisMax] = (yAxisBounds || []).map(parseAxisBound);
|
||||
|
||||
@@ -74,6 +74,7 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
rowLimit: number;
|
||||
seriesType: EchartsTimeseriesSeriesType;
|
||||
stack: StackType;
|
||||
stackDimension: string;
|
||||
timeCompare?: string[];
|
||||
tooltipTimeFormat?: string;
|
||||
showTooltipTotal?: boolean;
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
CallbackDataParams,
|
||||
} from 'echarts/types/src/util/types';
|
||||
import transformProps, { parseParams } from '../../src/Pie/transformProps';
|
||||
import { EchartsPieChartProps } from '../../src/Pie/types';
|
||||
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
|
||||
|
||||
describe('Pie transformProps', () => {
|
||||
const formData: SqlaFormData = {
|
||||
@@ -46,8 +46,13 @@ describe('Pie transformProps', () => {
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ foo: 'Sylvester', bar: 1, sum__num: 10 },
|
||||
{ foo: 'Arnold', bar: 2, sum__num: 2.5 },
|
||||
{
|
||||
foo: 'Sylvester',
|
||||
bar: 1,
|
||||
sum__num: 10,
|
||||
sum__num__contribution: 0.8,
|
||||
},
|
||||
{ foo: 'Arnold', bar: 2, sum__num: 2.5, sum__num__contribution: 0.2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -215,3 +220,77 @@ describe('Pie label string template', () => {
|
||||
).toEqual('Tablet:123456\n55.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other category', () => {
|
||||
const defaultFormData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'metric',
|
||||
groupby: ['foo', 'bar'],
|
||||
viz_type: 'my_viz',
|
||||
};
|
||||
|
||||
const getChartProps = (formData: Partial<SqlaFormData>) =>
|
||||
new ChartProps({
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
...formData,
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
foo: 'foo 1',
|
||||
bar: 'bar 1',
|
||||
metric: 1,
|
||||
metric__contribution: 1 / 15, // 6.7%
|
||||
},
|
||||
{
|
||||
foo: 'foo 2',
|
||||
bar: 'bar 2',
|
||||
metric: 2,
|
||||
metric__contribution: 2 / 15, // 13.3%
|
||||
},
|
||||
{
|
||||
foo: 'foo 3',
|
||||
bar: 'bar 3',
|
||||
metric: 3,
|
||||
metric__contribution: 3 / 15, // 20%
|
||||
},
|
||||
{
|
||||
foo: 'foo 4',
|
||||
bar: 'bar 4',
|
||||
metric: 4,
|
||||
metric__contribution: 4 / 15, // 26.7%
|
||||
},
|
||||
{
|
||||
foo: 'foo 5',
|
||||
bar: 'bar 5',
|
||||
metric: 5,
|
||||
metric__contribution: 5 / 15, // 33.3%
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
it('generates Other category', () => {
|
||||
const chartProps = getChartProps({
|
||||
threshold_for_other: 20,
|
||||
});
|
||||
const transformed = transformProps(chartProps as EchartsPieChartProps);
|
||||
const series = transformed.echartOptions.series as PieSeriesOption[];
|
||||
const data = series[0].data as PieChartDataItem[];
|
||||
expect(data).toHaveLength(4);
|
||||
expect(data[0].value).toBe(3);
|
||||
expect(data[1].value).toBe(4);
|
||||
expect(data[2].value).toBe(5);
|
||||
expect(data[3].value).toBe(1 + 2);
|
||||
expect(data[3].name).toBe('Other');
|
||||
expect(data[3].isOther).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
MutableRefObject,
|
||||
CSSProperties,
|
||||
DragEvent,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -39,8 +41,9 @@ import {
|
||||
Row,
|
||||
} from 'react-table';
|
||||
import { matchSorter, rankings } from 'match-sorter';
|
||||
import { typedMemo, usePrevious } from '@superset-ui/core';
|
||||
import { styled, typedMemo, usePrevious } from '@superset-ui/core';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Space } from 'antd';
|
||||
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
|
||||
import SelectPageSize, {
|
||||
SelectPageSizeProps,
|
||||
@@ -50,6 +53,8 @@ import SimplePagination from './components/Pagination';
|
||||
import useSticky from './hooks/useSticky';
|
||||
import { PAGE_SIZE_OPTIONS } from '../consts';
|
||||
import { sortAlphanumericCaseInsensitive } from './utils/sortAlphanumericCaseInsensitive';
|
||||
import { SearchOption, SortByItem } from '../types';
|
||||
import SearchSelectDropdown from './components/SearchSelectDropdown';
|
||||
|
||||
export interface DataTableProps<D extends object> extends TableOptions<D> {
|
||||
tableClassName?: string;
|
||||
@@ -62,7 +67,12 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
|
||||
height?: string | number;
|
||||
serverPagination?: boolean;
|
||||
onServerPaginationChange: (pageNumber: number, pageSize: number) => void;
|
||||
serverPaginationData: { pageSize?: number; currentPage?: number };
|
||||
serverPaginationData: {
|
||||
pageSize?: number;
|
||||
currentPage?: number;
|
||||
sortBy?: SortByItem[];
|
||||
searchColumn?: string;
|
||||
};
|
||||
pageSize?: number;
|
||||
noResults?: string | ((filterString: string) => ReactNode);
|
||||
sticky?: boolean;
|
||||
@@ -71,6 +81,14 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
|
||||
onColumnOrderChange: () => void;
|
||||
renderGroupingHeaders?: () => JSX.Element;
|
||||
renderTimeComparisonDropdown?: () => JSX.Element;
|
||||
handleSortByChange: (sortBy: SortByItem[]) => void;
|
||||
sortByFromParent: SortByItem[];
|
||||
manualSearch?: boolean;
|
||||
onSearchChange?: (searchText: string) => void;
|
||||
initialSearchText?: string;
|
||||
searchInputId?: string;
|
||||
onSearchColChange: (searchCol: string) => void;
|
||||
searchOptions: SearchOption[];
|
||||
}
|
||||
|
||||
export interface RenderHTMLCellProps extends HTMLProps<HTMLTableCellElement> {
|
||||
@@ -81,6 +99,24 @@ const sortTypes = {
|
||||
alphanumeric: sortAlphanumericCaseInsensitive,
|
||||
};
|
||||
|
||||
const StyledSpace = styled(Space)`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.search-select-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-by-label {
|
||||
align-self: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledRow = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
// Be sure to pass our updateMyData and the skipReset option
|
||||
export default typedMemo(function DataTable<D extends object>({
|
||||
tableClassName,
|
||||
@@ -105,6 +141,14 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
onColumnOrderChange,
|
||||
renderGroupingHeaders,
|
||||
renderTimeComparisonDropdown,
|
||||
handleSortByChange,
|
||||
sortByFromParent = [],
|
||||
manualSearch = false,
|
||||
onSearchChange,
|
||||
initialSearchText,
|
||||
searchInputId,
|
||||
onSearchColChange,
|
||||
searchOptions,
|
||||
...moreUseTableOptions
|
||||
}: DataTableProps<D>): JSX.Element {
|
||||
const tableHooks: PluginHook<D>[] = [
|
||||
@@ -115,6 +159,7 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
doSticky ? useSticky : [],
|
||||
hooks || [],
|
||||
].flat();
|
||||
|
||||
const columnNames = Object.keys(data?.[0] || {});
|
||||
const previousColumnNames = usePrevious(columnNames);
|
||||
const resultsSize = serverPagination ? rowCount : data.length;
|
||||
@@ -127,7 +172,8 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
...initialState_,
|
||||
// zero length means all pages, the `usePagination` plugin does not
|
||||
// understand pageSize = 0
|
||||
sortBy: sortByRef.current,
|
||||
// sortBy: sortByRef.current,
|
||||
sortBy: serverPagination ? sortByFromParent : sortByRef.current,
|
||||
pageSize: initialPageSize > 0 ? initialPageSize : resultsSize || 10,
|
||||
};
|
||||
const defaultWrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -188,7 +234,13 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
wrapStickyTable,
|
||||
setColumnOrder,
|
||||
allColumns,
|
||||
state: { pageIndex, pageSize, globalFilter: filterValue, sticky = {} },
|
||||
state: {
|
||||
pageIndex,
|
||||
pageSize,
|
||||
globalFilter: filterValue,
|
||||
sticky = {},
|
||||
sortBy,
|
||||
},
|
||||
} = useTable<D>(
|
||||
{
|
||||
columns,
|
||||
@@ -198,10 +250,46 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
globalFilter: defaultGlobalFilter,
|
||||
sortTypes,
|
||||
autoResetSortBy: !isEqual(columnNames, previousColumnNames),
|
||||
manualSortBy: !!serverPagination,
|
||||
...moreUseTableOptions,
|
||||
},
|
||||
...tableHooks,
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(query: string) => {
|
||||
if (manualSearch && onSearchChange) {
|
||||
onSearchChange(query);
|
||||
} else {
|
||||
setGlobalFilter(query);
|
||||
}
|
||||
},
|
||||
[manualSearch, onSearchChange, setGlobalFilter],
|
||||
);
|
||||
|
||||
// updating the sort by to the own State of table viz
|
||||
useEffect(() => {
|
||||
const serverSortBy = serverPaginationData?.sortBy || [];
|
||||
|
||||
if (serverPagination && !isEqual(sortBy, serverSortBy)) {
|
||||
if (Array.isArray(sortBy) && sortBy.length > 0) {
|
||||
const [sortByItem] = sortBy;
|
||||
const matchingColumn = columns.find(col => col?.id === sortByItem?.id);
|
||||
|
||||
if (matchingColumn && 'columnKey' in matchingColumn) {
|
||||
const sortByWithColumnKey: SortByItem = {
|
||||
...sortByItem,
|
||||
key: (matchingColumn as { columnKey: string }).columnKey,
|
||||
};
|
||||
|
||||
handleSortByChange([sortByWithColumnKey]);
|
||||
}
|
||||
} else {
|
||||
handleSortByChange([]);
|
||||
}
|
||||
}
|
||||
}, [sortBy]);
|
||||
|
||||
// make setPageSize accept 0
|
||||
const setPageSize = (size: number) => {
|
||||
if (serverPagination) {
|
||||
@@ -355,6 +443,7 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
resultOnPageChange = (pageNumber: number) =>
|
||||
onServerPaginationChange(pageNumber, serverPageSize);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
@@ -362,9 +451,9 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
>
|
||||
{hasGlobalControl ? (
|
||||
<div ref={globalControlRef} className="form-inline dt-controls">
|
||||
<div className="row">
|
||||
<StyledRow className="row">
|
||||
<div
|
||||
className={renderTimeComparisonDropdown ? 'col-sm-5' : 'col-sm-6'}
|
||||
className={renderTimeComparisonDropdown ? 'col-sm-4' : 'col-sm-5'}
|
||||
>
|
||||
{hasPagination ? (
|
||||
<SelectPageSize
|
||||
@@ -381,16 +470,35 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
) : null}
|
||||
</div>
|
||||
{searchInput ? (
|
||||
<div className="col-sm-6">
|
||||
<StyledSpace
|
||||
className={
|
||||
renderTimeComparisonDropdown ? 'col-sm-7' : 'col-sm-8'
|
||||
}
|
||||
>
|
||||
{serverPagination && (
|
||||
<div className="search-select-container">
|
||||
<span className="search-by-label">Search by: </span>
|
||||
<SearchSelectDropdown
|
||||
searchOptions={searchOptions}
|
||||
value={serverPaginationData?.searchColumn || ''}
|
||||
onChange={onSearchColChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<GlobalFilter<D>
|
||||
searchInput={
|
||||
typeof searchInput === 'boolean' ? undefined : searchInput
|
||||
}
|
||||
preGlobalFilteredRows={preGlobalFilteredRows}
|
||||
setGlobalFilter={setGlobalFilter}
|
||||
filterValue={filterValue}
|
||||
setGlobalFilter={
|
||||
manualSearch ? handleSearchChange : setGlobalFilter
|
||||
}
|
||||
filterValue={manualSearch ? initialSearchText : filterValue}
|
||||
id={searchInputId}
|
||||
serverPagination={!!serverPagination}
|
||||
rowCount={rowCount}
|
||||
/>
|
||||
</div>
|
||||
</StyledSpace>
|
||||
) : null}
|
||||
{renderTimeComparisonDropdown ? (
|
||||
<div
|
||||
@@ -400,7 +508,7 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
{renderTimeComparisonDropdown()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</StyledRow>
|
||||
</div>
|
||||
) : null}
|
||||
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { memo, ComponentType, ChangeEventHandler } from 'react';
|
||||
import {
|
||||
memo,
|
||||
ComponentType,
|
||||
ChangeEventHandler,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { Row, FilterValue } from 'react-table';
|
||||
import useAsyncState from '../utils/useAsyncState';
|
||||
|
||||
@@ -24,8 +30,12 @@ export interface SearchInputProps {
|
||||
count: number;
|
||||
value: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onBlur?: () => void;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const isSearchFocused = new Map();
|
||||
|
||||
export interface GlobalFilterProps<D extends object> {
|
||||
preGlobalFilteredRows: Row<D>[];
|
||||
// filter value cannot be `undefined` otherwise React will report component
|
||||
@@ -33,17 +43,28 @@ export interface GlobalFilterProps<D extends object> {
|
||||
filterValue: string;
|
||||
setGlobalFilter: (filterValue: FilterValue) => void;
|
||||
searchInput?: ComponentType<SearchInputProps>;
|
||||
id?: string;
|
||||
serverPagination: boolean;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
function DefaultSearchInput({ count, value, onChange }: SearchInputProps) {
|
||||
function DefaultSearchInput({
|
||||
count,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
inputRef,
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<span className="dt-global-filter">
|
||||
Search{' '}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="form-control input-sm"
|
||||
placeholder={`${count} records...`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
@@ -56,8 +77,13 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
filterValue = '',
|
||||
searchInput,
|
||||
setGlobalFilter,
|
||||
id = '',
|
||||
serverPagination,
|
||||
rowCount,
|
||||
}: GlobalFilterProps<D>) {
|
||||
const count = preGlobalFilteredRows.length;
|
||||
const count = serverPagination ? rowCount : preGlobalFilteredRows.length;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [value, setValue] = useAsyncState(
|
||||
filterValue,
|
||||
(newValue: string) => {
|
||||
@@ -66,17 +92,37 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
200,
|
||||
);
|
||||
|
||||
// Preserve focus during server-side filtering to maintain a better user experience
|
||||
useEffect(() => {
|
||||
if (
|
||||
serverPagination &&
|
||||
isSearchFocused.get(id) &&
|
||||
document.activeElement !== inputRef.current
|
||||
) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [value, serverPagination]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
e.preventDefault();
|
||||
isSearchFocused.set(id, true);
|
||||
setValue(target.value);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isSearchFocused.set(id, false);
|
||||
};
|
||||
|
||||
const SearchInput = searchInput || DefaultSearchInput;
|
||||
|
||||
return (
|
||||
<SearchInput
|
||||
count={count}
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
e.preventDefault();
|
||||
setValue(target.value);
|
||||
}}
|
||||
inputRef={inputRef}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { Select } from 'antd';
|
||||
import { SearchOption } from '../../types';
|
||||
|
||||
const StyledSelect = styled(Select)`
|
||||
width: 120px;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
interface SearchSelectDropdownProps {
|
||||
/** The currently selected search column value */
|
||||
value?: string;
|
||||
/** Callback triggered when a new search column is selected */
|
||||
onChange: (searchCol: string) => void;
|
||||
/** Available search column options to populate the dropdown */
|
||||
searchOptions: SearchOption[];
|
||||
}
|
||||
|
||||
function SearchSelectDropdown({
|
||||
value,
|
||||
onChange,
|
||||
searchOptions,
|
||||
}: SearchSelectDropdownProps) {
|
||||
return (
|
||||
<StyledSelect
|
||||
className="search-select"
|
||||
value={value || (searchOptions?.[0]?.value ?? '')}
|
||||
options={searchOptions}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchSelectDropdown;
|
||||
@@ -115,3 +115,11 @@ declare module 'react-table' {
|
||||
extends UseTableHooks<D>,
|
||||
UseSortByHooks<D> {}
|
||||
}
|
||||
|
||||
interface TableOwnState {
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
sortColumn?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
searchText?: string;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SetDataMaskHook } from '@superset-ui/core';
|
||||
import { TableOwnState } from '../types/react-table';
|
||||
|
||||
export const updateExternalFormData = (
|
||||
setDataMask: SetDataMaskHook = () => {},
|
||||
@@ -30,3 +31,11 @@ export const updateExternalFormData = (
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
export const updateTableOwnState = (
|
||||
setDataMask: SetDataMaskHook = () => {},
|
||||
modifiedOwnState: TableOwnState,
|
||||
) =>
|
||||
setDataMask({
|
||||
ownState: modifiedOwnState,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
useState,
|
||||
MouseEvent,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -61,10 +62,12 @@ import {
|
||||
PlusCircleOutlined,
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { debounce, isEmpty, isEqual } from 'lodash';
|
||||
import {
|
||||
ColorSchemeEnum,
|
||||
DataColumnMeta,
|
||||
SearchOption,
|
||||
SortByItem,
|
||||
TableChartTransformedProps,
|
||||
} from './types';
|
||||
import DataTable, {
|
||||
@@ -76,8 +79,8 @@ import DataTable, {
|
||||
|
||||
import Styles from './Styles';
|
||||
import { formatColumnValue } from './utils/formatValue';
|
||||
import { PAGE_SIZE_OPTIONS } from './consts';
|
||||
import { updateExternalFormData } from './DataTable/utils/externalAPIs';
|
||||
import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
|
||||
import { updateTableOwnState } from './DataTable/utils/externalAPIs';
|
||||
import getScrollBarSize from './DataTable/utils/getScrollBarSize';
|
||||
|
||||
type ValueRange = [number, number];
|
||||
@@ -176,20 +179,26 @@ function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
|
||||
return sortIcon;
|
||||
}
|
||||
|
||||
function SearchInput({ count, value, onChange }: SearchInputProps) {
|
||||
return (
|
||||
<span className="dt-global-filter">
|
||||
{t('Search')}{' '}
|
||||
<input
|
||||
aria-label={t('Search %s records', count)}
|
||||
className="form-control input-sm"
|
||||
placeholder={tn('search.num_records', count)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const SearchInput = ({
|
||||
count,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
inputRef,
|
||||
}: SearchInputProps) => (
|
||||
<span className="dt-global-filter">
|
||||
{t('Search')}{' '}
|
||||
<input
|
||||
ref={inputRef}
|
||||
aria-label={t('Search %s records', count)}
|
||||
className="form-control input-sm"
|
||||
placeholder={tn('search.num_records', count)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
function SelectPageSize({
|
||||
options,
|
||||
@@ -267,6 +276,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
isUsingTimeComparison,
|
||||
basicColorFormatters,
|
||||
basicColorColumnFormatters,
|
||||
hasServerPageLengthChanged,
|
||||
serverPageLength,
|
||||
slice_id,
|
||||
} = props;
|
||||
const comparisonColumns = [
|
||||
{ key: 'all', label: t('Display all') },
|
||||
@@ -294,7 +306,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
// only take relevant page size options
|
||||
const pageSizeOptions = useMemo(() => {
|
||||
const getServerPagination = (n: number) => n <= rowCount;
|
||||
return PAGE_SIZE_OPTIONS.filter(([n]) =>
|
||||
return (
|
||||
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
|
||||
).filter(([n]) =>
|
||||
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
|
||||
) as SizeOption[];
|
||||
}, [data.length, rowCount, serverPagination]);
|
||||
@@ -679,7 +693,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
);
|
||||
|
||||
const getColumnConfigs = useCallback(
|
||||
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
|
||||
(
|
||||
column: DataColumnMeta,
|
||||
i: number,
|
||||
): ColumnWithLooseAccessor<D> & {
|
||||
columnKey: string;
|
||||
} => {
|
||||
const {
|
||||
key,
|
||||
label: originalLabel,
|
||||
@@ -766,6 +785,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
// must use custom accessor to allow `.` in column names
|
||||
// typing is incorrect in current version of `@types/react-table`
|
||||
// so we ask TS not to check.
|
||||
columnKey: key,
|
||||
accessor: ((datum: D) => datum[key]) as never,
|
||||
Cell: ({ value, row }: { value: DataRecordValue; row: Row<D> }) => {
|
||||
const [isHtml, text] = formatColumnValue(column, value);
|
||||
@@ -1058,13 +1078,50 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[visibleColumnsMeta, getColumnConfigs],
|
||||
);
|
||||
|
||||
const [searchOptions, setSearchOptions] = useState<SearchOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const options = (
|
||||
columns as unknown as ColumnWithLooseAccessor &
|
||||
{
|
||||
columnKey: string;
|
||||
sortType?: string;
|
||||
}[]
|
||||
)
|
||||
.filter(col => col?.sortType === 'alphanumeric')
|
||||
.map(column => ({
|
||||
value: column.columnKey,
|
||||
label: column.columnKey,
|
||||
}));
|
||||
|
||||
if (!isEqual(options, searchOptions)) {
|
||||
setSearchOptions(options || []);
|
||||
}
|
||||
}, [columns]);
|
||||
|
||||
const handleServerPaginationChange = useCallback(
|
||||
(pageNumber: number, pageSize: number) => {
|
||||
updateExternalFormData(setDataMask, pageNumber, pageSize);
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
currentPage: pageNumber,
|
||||
pageSize,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
[setDataMask],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasServerPageLengthChanged) {
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
currentPage: 0,
|
||||
pageSize: serverPageLength,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSizeChange = useCallback(
|
||||
({ width, height }: { width: number; height: number }) => {
|
||||
setTableSize({ width, height });
|
||||
@@ -1100,6 +1157,42 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
|
||||
const { width: widthFromState, height: heightFromState } = tableSize;
|
||||
|
||||
const handleSortByChange = useCallback(
|
||||
(sortBy: SortByItem[]) => {
|
||||
if (!serverPagination) return;
|
||||
const modifiedOwnState = {
|
||||
...serverPaginationData,
|
||||
sortBy,
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
[setDataMask, serverPagination],
|
||||
);
|
||||
|
||||
const handleSearch = (searchText: string) => {
|
||||
const modifiedOwnState = {
|
||||
...(serverPaginationData || {}),
|
||||
searchColumn:
|
||||
serverPaginationData?.searchColumn || searchOptions[0]?.value,
|
||||
searchText,
|
||||
currentPage: 0, // Reset to first page when searching
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(handleSearch, 800);
|
||||
|
||||
const handleChangeSearchCol = (searchCol: string) => {
|
||||
if (!isEqual(searchCol, serverPaginationData?.searchColumn)) {
|
||||
const modifiedOwnState = {
|
||||
...(serverPaginationData || {}),
|
||||
searchColumn: searchCol,
|
||||
searchText: '',
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Styles>
|
||||
<DataTable<D>
|
||||
@@ -1115,6 +1208,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
serverPagination={serverPagination}
|
||||
onServerPaginationChange={handleServerPaginationChange}
|
||||
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}
|
||||
initialSearchText={serverPaginationData?.searchText || ''}
|
||||
sortByFromParent={serverPaginationData?.sortBy || []}
|
||||
searchInputId={`${slice_id}-search`}
|
||||
// 9 page items in > 340px works well even for 100+ pages
|
||||
maxPageItemCount={width > 340 ? 9 : 7}
|
||||
noResults={getNoResultsMessage}
|
||||
@@ -1128,6 +1224,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
renderTimeComparisonDropdown={
|
||||
isUsingTimeComparison ? renderTimeComparisonDropdown : undefined
|
||||
}
|
||||
handleSortByChange={handleSortByChange}
|
||||
onSearchColChange={handleChangeSearchCol}
|
||||
manualSearch={serverPagination}
|
||||
onSearchChange={debouncedSearch}
|
||||
searchOptions={searchOptions}
|
||||
/>
|
||||
</Styles>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user