mirror of
https://github.com/apache/superset.git
synced 2026-06-19 22:49:18 +00:00
Compare commits
81 Commits
elizabeth/
...
improve-fu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503933756e | ||
|
|
60261a5dc6 | ||
|
|
456512c508 | ||
|
|
c1d9b06649 | ||
|
|
7a64a82cd9 | ||
|
|
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 | ||
|
|
00f1fdb3c4 | ||
|
|
172e5dd095 | ||
|
|
a53907a646 | ||
|
|
be1b8d6751 | ||
|
|
26ff734ef9 | ||
|
|
0e18246999 | ||
|
|
7333ffd41e | ||
|
|
7dc5019b9d | ||
|
|
93fa39a14f | ||
|
|
342e6f3ab0 | ||
|
|
013379eb86 | ||
|
|
bc0ffe0d10 | ||
|
|
5f62deaa36 | ||
|
|
ff8605b723 | ||
|
|
45c77a1976 | ||
|
|
8cb71b8d3b | ||
|
|
2233c02720 | ||
|
|
839215148a | ||
|
|
c1eeb63d89 | ||
|
|
7b9ebbe735 | ||
|
|
a5a91d5e48 | ||
|
|
e1f5c49df7 | ||
|
|
3c1fc0b722 | ||
|
|
05faf2f352 | ||
|
|
347c174099 | ||
|
|
5656d69c04 | ||
|
|
ac4df8d06b | ||
|
|
bcd136cee1 | ||
|
|
7ab8534ef6 | ||
|
|
014b39290b | ||
|
|
4f97b739b1 | ||
|
|
d88cba92c0 | ||
|
|
5304bed4ed | ||
|
|
37194a41ec | ||
|
|
d75ff9e784 | ||
|
|
164a07e2be | ||
|
|
44bd200885 |
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
|
||||
|
||||
4
.github/workflows/superset-e2e.yml
vendored
4
.github/workflows/superset-e2e.yml
vendored
@@ -50,8 +50,8 @@ jobs:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
REDIS_PORT: 16379
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# use the dashboard feature when running manually OR merging to master
|
||||
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true'|| (github.ref == 'refs/heads/master' && 'true') || 'false' }}
|
||||
# Only use dashboard when explicitly requested via workflow_dispatch
|
||||
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
SUPERSET_TESTENV: true
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
run: |
|
||||
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear
|
||||
pytest --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
@@ -101,6 +101,7 @@ Join our growing community!
|
||||
- [ELMO Cloud HR & Payroll](https://elmosoftware.com.au/)
|
||||
- [Endress+Hauser](https://www.endress.com/) [@rumbin]
|
||||
- [FBK - ICT center](https://ict.fbk.eu)
|
||||
- [Formbricks](https://formbricks.com)
|
||||
- [Gavagai](https://gavagai.io) [@gavagai-corp]
|
||||
- [GfK Data Lab](https://www.gfk.com/home) [@mherr]
|
||||
- [Hydrolix](https://www.hydrolix.io/)
|
||||
@@ -216,6 +217,7 @@ Join our growing community!
|
||||
- [Increff](https://www.increff.com/) [@ishansinghania]
|
||||
- [komoot](https://www.komoot.com/) [@christophlingg]
|
||||
- [Let's Roam](https://www.letsroam.com/)
|
||||
- [Machrent SA](https://www.machrent.com/)
|
||||
- [Onebeat](https://1beat.com/) [@GuyAttia]
|
||||
- [X](https://x.com/)
|
||||
- [VLMedia](https://www.vlmedia.com.tr/) [@ibotheperfect]
|
||||
|
||||
@@ -23,7 +23,8 @@ This file documents any backwards-incompatible changes in Superset and
|
||||
assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- [33116](https://github.com/apache/superset/pull/33116) In Echarts Series charts (e.g. Line, Area, Bar, etc.) charts, the `x_axis_sort_series` and `x_axis_sort_series_ascending` form data items have been renamed with `x_axis_sort` and `x_axis_sort_asc`.
|
||||
There's a migration added that can potentially affect a significant number of existing charts.
|
||||
- [32317](https://github.com/apache/superset/pull/32317) The horizontal filter bar feature is now out of testing/beta development and its feature flag `HORIZONTAL_FILTER_BAR` has been removed.
|
||||
- [31976](https://github.com/apache/superset/pull/31976) Removed the `DISABLE_LEGACY_DATASOURCE_EDITOR` feature flag. The previous value of the feature flag was `True` and now the feature is permanently removed.
|
||||
- [31959](https://github.com/apache/superset/pull/32000) Removes CSV_UPLOAD_MAX_SIZE config, use your web server to control file upload size.
|
||||
|
||||
@@ -50,7 +50,11 @@ fi
|
||||
#
|
||||
if [ -f "${REQUIREMENTS_LOCAL}" ]; then
|
||||
echo "Installing local overrides at ${REQUIREMENTS_LOCAL}"
|
||||
uv pip install --no-cache-dir -r "${REQUIREMENTS_LOCAL}"
|
||||
if command -v uv > /dev/null 2>&1; then
|
||||
uv pip install --no-cache-dir -r "${REQUIREMENTS_LOCAL}"
|
||||
else
|
||||
pip install --no-cache-dir -r "${REQUIREMENTS_LOCAL}"
|
||||
fi
|
||||
else
|
||||
echo "Skipping local overrides"
|
||||
fi
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Docker Compose
|
||||
hide_title: true
|
||||
sidebar_position: 4
|
||||
sidebar_position: 5
|
||||
version: 1
|
||||
---
|
||||
|
||||
|
||||
58
docs/docs/installation/installation-methods.mdx
Normal file
58
docs/docs/installation/installation-methods.mdx
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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 [Docker Build Presets](/docs/installation/docker-builds/#build-presets) for more information about the different image versions you can extend.
|
||||
|
||||
## [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
|
||||
---
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ git clone https://github.com/apache/superset
|
||||
$ cd superset
|
||||
|
||||
# Set the repo to the state associated with the latest official version
|
||||
$ git checkout tags/4.1.1
|
||||
$ git checkout tags/4.1.2
|
||||
|
||||
# Fire up Superset using Docker Compose
|
||||
$ docker compose -f docker-compose-image-tag.yml up
|
||||
|
||||
@@ -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,9 +19,10 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
6922
docs/yarn.lock
6922
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -125,7 +125,7 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb-engine>=0.10", "duckdb>=1.1.0"]
|
||||
duckdb = ["duckdb-engine>=0.12.1, <0.13"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
|
||||
@@ -146,6 +146,7 @@ hive = [
|
||||
impala = ["impyla>0.16.2, <0.17"]
|
||||
kusto = ["sqlalchemy-kusto>=3.0.0, <4"]
|
||||
kylin = ["kylinpy>=2.8.1, <2.9"]
|
||||
motherduck = ["duckdb==0.10.2", "duckdb-engine>=0.12.1, <0.13"]
|
||||
mssql = ["pymssql>=2.2.8, <3"]
|
||||
mysql = ["mysqlclient>=2.1.0, <3"]
|
||||
ocient = [
|
||||
|
||||
@@ -44,7 +44,7 @@ cachetools==5.5.2
|
||||
# via google-auth
|
||||
cattrs==24.1.2
|
||||
# via requests-cache
|
||||
celery==5.4.0
|
||||
celery==5.5.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
certifi==2025.1.31
|
||||
# via
|
||||
@@ -158,6 +158,7 @@ greenlet==3.1.1
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
gunicorn==23.0.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
h11==0.14.0
|
||||
@@ -173,7 +174,7 @@ idna==3.10
|
||||
# email-validator
|
||||
# requests
|
||||
# trio
|
||||
importlib-metadata==8.6.1
|
||||
importlib-metadata==8.7.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
isodate==0.7.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -191,13 +192,13 @@ jsonschema==4.23.0
|
||||
# via flask-appbuilder
|
||||
jsonschema-specifications==2024.10.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
|
||||
@@ -244,7 +245,7 @@ ordered-set==4.1.0
|
||||
# via flask-limiter
|
||||
outcome==1.3.0.post0
|
||||
# via trio
|
||||
packaging==24.2
|
||||
packaging==25.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apispec
|
||||
@@ -271,7 +272,7 @@ 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
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -294,7 +295,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 +308,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
|
||||
@@ -373,7 +374,7 @@ sqlalchemy-utils==0.38.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
sqlglot==26.11.1
|
||||
sqlglot==26.16.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlparse==0.5.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -398,9 +399,8 @@ typing-extensions==4.12.2
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.1
|
||||
tzdata==2025.2
|
||||
# via
|
||||
# celery
|
||||
# kombu
|
||||
# pandas
|
||||
url-normalize==1.4.3
|
||||
|
||||
@@ -72,7 +72,7 @@ cattrs==24.1.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# requests-cache
|
||||
celery==5.4.0
|
||||
celery==5.5.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -318,6 +318,7 @@ greenlet==3.1.1
|
||||
# apache-superset
|
||||
# gevent
|
||||
# shillelagh
|
||||
# sqlalchemy
|
||||
grpcio==1.71.0
|
||||
# via
|
||||
# apache-superset
|
||||
@@ -354,7 +355,7 @@ idna==3.10
|
||||
# email-validator
|
||||
# requests
|
||||
# trio
|
||||
importlib-metadata==8.6.1
|
||||
importlib-metadata==8.7.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -395,7 +396,7 @@ jsonschema-specifications==2024.10.1
|
||||
# openapi-schema-validator
|
||||
kiwisolver==1.4.7
|
||||
# via matplotlib
|
||||
kombu==5.5.0
|
||||
kombu==5.5.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# celery
|
||||
@@ -405,11 +406,11 @@ 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
|
||||
@@ -495,7 +496,7 @@ outcome==1.3.0.post0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# trio
|
||||
packaging==24.2
|
||||
packaging==25.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -799,7 +800,7 @@ sqlalchemy-utils==0.38.3
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# flask-appbuilder
|
||||
sqlglot==26.11.1
|
||||
sqlglot==26.16.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
@@ -850,10 +851,9 @@ typing-extensions==4.12.2
|
||||
# rich
|
||||
# selenium
|
||||
# shillelagh
|
||||
tzdata==2025.1
|
||||
tzdata==2025.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# celery
|
||||
# kombu
|
||||
# pandas
|
||||
tzlocal==5.2
|
||||
|
||||
@@ -63,7 +63,10 @@ def fetch_files_github_api(url: str): # type: ignore
|
||||
|
||||
def fetch_changed_files_pr(repo: str, pr_number: str) -> List[str]:
|
||||
"""Fetches files changed in a PR using the GitHub API."""
|
||||
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files"
|
||||
|
||||
# NOTE: limited to 100 files ideally should page-through but instead resorting
|
||||
# to assuming we should trigger when 100 files have been touched
|
||||
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files?per_page=100"
|
||||
files = fetch_files_github_api(url)
|
||||
return [file_info["filename"] for file_info in files]
|
||||
|
||||
@@ -103,7 +106,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
|
||||
"""Main function to check for file changes based on event context."""
|
||||
print("SHA:", sha)
|
||||
print("EVENT_TYPE", event_type)
|
||||
files = None
|
||||
files = []
|
||||
if event_type == "pull_request":
|
||||
pr_number = os.getenv("GITHUB_REF", "").split("/")[-2]
|
||||
if is_int(pr_number):
|
||||
@@ -133,8 +136,11 @@ def main(event_type: str, sha: str, repo: str) -> None:
|
||||
output_path = os.getenv("GITHUB_OUTPUT") or "/tmp/GITHUB_OUTPUT.txt" # noqa: S108
|
||||
with open(output_path, "a") as f:
|
||||
for check, changed in changes_detected.items():
|
||||
if changed:
|
||||
print(f"{check}={str(changed).lower()}", file=f)
|
||||
# NOTE: as noted above, we assume that if 100 files are touched, we should
|
||||
# trigger all checks. This is a workaround for the GitHub API limit of 100
|
||||
# files. Using >= 99 because off-by-one errors are not uncommon
|
||||
if changed or len(files) >= 99:
|
||||
print(f"{check}=true", file=f)
|
||||
print(f"Triggering group: {check}")
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export function prepareDashboardFilters(
|
||||
controlValues: {
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
creatable: true,
|
||||
multiSelect: true,
|
||||
searchAllOptions: false,
|
||||
inverseSelection: false,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
30
superset-frontend/cypress-base/package-lock.json
generated
30
superset-frontend/cypress-base/package-lock.json
generated
@@ -2581,11 +2581,12 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
|
||||
"integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -9244,9 +9245,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.7",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regenerator-transform": {
|
||||
"version": "0.15.1",
|
||||
@@ -12806,11 +12808,11 @@
|
||||
"peer": true
|
||||
},
|
||||
"@babel/runtime": {
|
||||
"version": "7.17.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
|
||||
"integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
}
|
||||
},
|
||||
"@babel/template": {
|
||||
@@ -17857,9 +17859,9 @@
|
||||
}
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.7",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
||||
},
|
||||
"regenerator-transform": {
|
||||
"version": "0.15.1",
|
||||
|
||||
9
superset-frontend/package-lock.json
generated
9
superset-frontend/package-lock.json
generated
@@ -26682,9 +26682,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
|
||||
"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -48975,6 +48975,7 @@
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/tinycolor2": "*",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^17.0.2",
|
||||
"react-loadable": "^5.5.0",
|
||||
"tinycolor2": "*"
|
||||
@@ -50863,7 +50864,7 @@
|
||||
"version": "0.20.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"d3-array": "^1.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"lodash": "^4.17.21"
|
||||
|
||||
@@ -42,7 +42,7 @@ const FlexRowContainer = styled.div`
|
||||
`;
|
||||
|
||||
export interface MetricOptionProps {
|
||||
metric: Omit<Metric, 'id'> & { label?: string };
|
||||
metric: Omit<Metric, 'id' | 'uuid'> & { label?: string };
|
||||
openInNewWindow?: boolean;
|
||||
showFormula?: boolean;
|
||||
showType?: boolean;
|
||||
|
||||
@@ -97,7 +97,7 @@ export const getColumnTooltipNode = (
|
||||
);
|
||||
};
|
||||
|
||||
type MetricType = Omit<Metric, 'id'> & { label?: string };
|
||||
type MetricType = Omit<Metric, 'id' | 'uuid'> & { label?: string };
|
||||
|
||||
export const getMetricTooltipNode = (
|
||||
metric: MetricType,
|
||||
|
||||
@@ -121,6 +121,7 @@ export const TestDataset: Dataset = {
|
||||
main_dttm_col: 'ds',
|
||||
metrics: [
|
||||
{
|
||||
uuid: '123',
|
||||
certification_details: null,
|
||||
certified_by: null,
|
||||
d3format: null,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
xAxisForceCategoricalControl,
|
||||
xAxisSortAscControl,
|
||||
xAxisSortControl,
|
||||
xAxisSortSeriesAscendingControl,
|
||||
xAxisSortSeriesControl,
|
||||
} from '../shared-controls';
|
||||
|
||||
const controlsWithoutXAxis: ControlSetRow[] = [
|
||||
@@ -55,8 +53,6 @@ export const echartsTimeSeriesQueryWithXAxisSort: ControlPanelSectionConfig = {
|
||||
[xAxisForceCategoricalControl],
|
||||
[xAxisSortControl],
|
||||
[xAxisSortAscControl],
|
||||
[xAxisSortSeriesControl],
|
||||
[xAxisSortSeriesAscendingControl],
|
||||
...controlsWithoutXAxis,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -57,9 +57,7 @@ export const contributionModeControl = {
|
||||
};
|
||||
|
||||
const xAxisSortVisibility = ({ controls }: { controls: ControlStateMapping }) =>
|
||||
isSortable(controls) &&
|
||||
ensureIsArray(controls?.groupby?.value).length === 0 &&
|
||||
ensureIsArray(controls?.metrics?.value).length === 1;
|
||||
isSortable(controls);
|
||||
|
||||
// TODO: Expand this aggregation options list to include all backend-supported aggregations.
|
||||
// TODO: Migrate existing chart types (Pivot Table, etc.) to use this shared control.
|
||||
@@ -87,15 +85,6 @@ export const aggregationControl = {
|
||||
},
|
||||
};
|
||||
|
||||
const xAxisMultiSortVisibility = ({
|
||||
controls,
|
||||
}: {
|
||||
controls: ControlStateMapping;
|
||||
}) =>
|
||||
isSortable(controls) &&
|
||||
(!!ensureIsArray(controls?.groupby?.value).length ||
|
||||
ensureIsArray(controls?.metrics?.value).length > 1);
|
||||
|
||||
export const xAxisSortControl = {
|
||||
name: 'x_axis_sort',
|
||||
config: {
|
||||
@@ -104,7 +93,7 @@ export const xAxisSortControl = {
|
||||
state.form_data?.orientation === 'horizontal'
|
||||
? t('Y-Axis Sort By')
|
||||
: t('X-Axis Sort By'),
|
||||
description: t('Decides which column to sort the base axis by.'),
|
||||
description: t('Decides which column or measure to sort the base axis by.'),
|
||||
shouldMapStateToProps: () => true,
|
||||
mapStateToProps: (state: ControlPanelState, controlState: ControlState) => {
|
||||
const { controls, datasource } = state;
|
||||
@@ -112,23 +101,35 @@ export const xAxisSortControl = {
|
||||
const columns = [controls?.x_axis?.value as QueryFormColumn].filter(
|
||||
Boolean,
|
||||
);
|
||||
const isSingleSortAvailable =
|
||||
ensureIsArray(controls?.groupby?.value).length === 0;
|
||||
const isMultiSortAvailable =
|
||||
!!ensureIsArray(controls?.groupby?.value).length ||
|
||||
ensureIsArray(controls?.metrics?.value).length > 1;
|
||||
const metrics = [
|
||||
...ensureIsArray(controls?.metrics?.value as QueryFormMetric),
|
||||
controls?.timeseries_limit_metric?.value as QueryFormMetric,
|
||||
].filter(Boolean);
|
||||
const metricLabels = [...new Set(metrics.map(getMetricLabel))];
|
||||
const options = [
|
||||
...columns.map(column => {
|
||||
const value = getColumnLabel(column);
|
||||
return {
|
||||
value,
|
||||
label: dataset?.verbose_map?.[value] || value,
|
||||
};
|
||||
}),
|
||||
...metricLabels.map(value => ({
|
||||
value,
|
||||
label: dataset?.verbose_map?.[value] || value,
|
||||
})),
|
||||
...(isSingleSortAvailable
|
||||
? [
|
||||
...columns.map(column => {
|
||||
const value = getColumnLabel(column);
|
||||
return { value, label: dataset?.verbose_map?.[value] || value };
|
||||
}),
|
||||
...metricLabels.map(value => ({
|
||||
value,
|
||||
label: dataset?.verbose_map?.[value] || value,
|
||||
})),
|
||||
]
|
||||
: []),
|
||||
...(isMultiSortAvailable
|
||||
? SORT_SERIES_CHOICES.map(choice => ({
|
||||
value: choice[0],
|
||||
label: choice[1],
|
||||
}))
|
||||
: []),
|
||||
];
|
||||
|
||||
const shouldReset = !(
|
||||
@@ -157,7 +158,7 @@ export const xAxisSortAscControl = {
|
||||
state.form_data?.orientation === 'horizontal'
|
||||
? t('Y-Axis Sort Ascending')
|
||||
: t('X-Axis Sort Ascending'),
|
||||
default: true,
|
||||
default: DEFAULT_XAXIS_SORT_SERIES_DATA.sort_series_ascending,
|
||||
description: t('Whether to sort ascending or descending on the base Axis.'),
|
||||
visibility: ({ controls }: { controls: ControlStateMapping }) =>
|
||||
controls?.x_axis_sort?.value !== undefined &&
|
||||
@@ -184,37 +185,3 @@ export const xAxisForceCategoricalControl = {
|
||||
shouldMapStateToProps: () => true,
|
||||
},
|
||||
};
|
||||
|
||||
export const xAxisSortSeriesControl = {
|
||||
name: 'x_axis_sort_series',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: false,
|
||||
label: (state: ControlPanelState) =>
|
||||
state.form_data?.orientation === 'horizontal'
|
||||
? t('Y-Axis Sort By')
|
||||
: t('X-Axis Sort By'),
|
||||
choices: SORT_SERIES_CHOICES,
|
||||
default: DEFAULT_XAXIS_SORT_SERIES_DATA.sort_series_type,
|
||||
renderTrigger: true,
|
||||
description: t('Decides which measure to sort the base axis by.'),
|
||||
visibility: xAxisMultiSortVisibility,
|
||||
},
|
||||
};
|
||||
|
||||
export const xAxisSortSeriesAscendingControl = {
|
||||
name: 'x_axis_sort_series_ascending',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: (state: ControlPanelState) =>
|
||||
state.form_data?.orientation === 'horizontal'
|
||||
? t('Y-Axis Sort Ascending')
|
||||
: t('X-Axis Sort Ascending'),
|
||||
default: DEFAULT_XAXIS_SORT_SERIES_DATA.sort_series_ascending,
|
||||
description: t('Whether to sort ascending or descending on the base Axis.'),
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }: { controls: ControlStateMapping }) =>
|
||||
controls?.x_axis_sort_series?.value !== undefined &&
|
||||
xAxisMultiSortVisibility({ controls }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('defineSavedMetrics', () => {
|
||||
{
|
||||
metric_name: 'COUNT(*) non-default-dataset-metric',
|
||||
expression: 'COUNT(*) non-default-dataset-metric',
|
||||
uuid: '1',
|
||||
},
|
||||
],
|
||||
type: DatasourceType.Table,
|
||||
@@ -48,6 +49,7 @@ describe('defineSavedMetrics', () => {
|
||||
{
|
||||
metric_name: 'COUNT(*) non-default-dataset-metric',
|
||||
expression: 'COUNT(*) non-default-dataset-metric',
|
||||
uuid: '1',
|
||||
},
|
||||
]);
|
||||
// @ts-ignore
|
||||
|
||||
@@ -24,15 +24,24 @@ describe('mainMetric', () => {
|
||||
expect(mainMetric(null)).toBeUndefined();
|
||||
});
|
||||
it('prefers the "count" metric when first', () => {
|
||||
const metrics = [{ metric_name: 'count' }, { metric_name: 'foo' }];
|
||||
const metrics = [
|
||||
{ metric_name: 'count', uuid: '1' },
|
||||
{ metric_name: 'foo', uuid: '2' },
|
||||
];
|
||||
expect(mainMetric(metrics)).toBe('count');
|
||||
});
|
||||
it('prefers the "count" metric when not first', () => {
|
||||
const metrics = [{ metric_name: 'foo' }, { metric_name: 'count' }];
|
||||
const metrics = [
|
||||
{ metric_name: 'foo', uuid: '1' },
|
||||
{ metric_name: 'count', uuid: '2' },
|
||||
];
|
||||
expect(mainMetric(metrics)).toBe('count');
|
||||
});
|
||||
it('selects the first metric when "count" is not an option', () => {
|
||||
const metrics = [{ metric_name: 'foo' }, { metric_name: 'not_count' }];
|
||||
const metrics = [
|
||||
{ metric_name: 'foo', uuid: '2' },
|
||||
{ metric_name: 'not_count', uuid: '2' },
|
||||
];
|
||||
expect(mainMetric(metrics)).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/tinycolor2": "*",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^17.0.2",
|
||||
"react-loadable": "^5.5.0",
|
||||
"tinycolor2": "*"
|
||||
|
||||
@@ -59,3 +59,37 @@ test('should truncate', () => {
|
||||
|
||||
expect(isTruncated).toBe(true);
|
||||
});
|
||||
|
||||
test('should not truncate with vertical orientation', () => {
|
||||
const ref = { current: document.createElement('p') };
|
||||
Object.defineProperty(ref.current, 'offsetHeight', { get: () => 100 });
|
||||
Object.defineProperty(ref.current, 'scrollHeight', { get: () => 50 });
|
||||
jest.spyOn(global.React, 'useRef').mockReturnValue({ current: ref.current });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCSSTextTruncation<HTMLParagraphElement>({
|
||||
isVertical: true,
|
||||
isHorizontal: false,
|
||||
}),
|
||||
);
|
||||
const [, isTruncated] = result.current;
|
||||
|
||||
expect(isTruncated).toBe(false);
|
||||
});
|
||||
|
||||
test('should truncate with vertical orientation', () => {
|
||||
const ref = { current: document.createElement('p') };
|
||||
Object.defineProperty(ref.current, 'offsetHeight', { get: () => 50 });
|
||||
Object.defineProperty(ref.current, 'scrollHeight', { get: () => 100 });
|
||||
jest.spyOn(global.React, 'useRef').mockReturnValue({ current: ref.current });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCSSTextTruncation<HTMLParagraphElement>({
|
||||
isVertical: true,
|
||||
isHorizontal: false,
|
||||
}),
|
||||
);
|
||||
const [, isTruncated] = result.current;
|
||||
|
||||
expect(isTruncated).toBe(true);
|
||||
});
|
||||
|
||||
@@ -36,24 +36,37 @@ export const truncationCSS = css`
|
||||
* to be displayed, this hook returns a ref to attach to the text
|
||||
* element and a boolean for whether that element is currently truncated.
|
||||
*/
|
||||
const useCSSTextTruncation = <T extends HTMLElement>(): [
|
||||
RefObject<T>,
|
||||
boolean,
|
||||
] => {
|
||||
const useCSSTextTruncation = <T extends HTMLElement>(
|
||||
{ isVertical, isHorizontal } = { isVertical: false, isHorizontal: true },
|
||||
): [RefObject<T>, boolean] => {
|
||||
const [isTruncated, setIsTruncated] = useState(true);
|
||||
const ref = useRef<T>(null);
|
||||
const [offsetWidth, setOffsetWidth] = useState(0);
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
const [offsetHeight, setOffsetHeight] = useState(0);
|
||||
const [scrollHeight, setScrollHeight] = useState(0);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
setOffsetWidth(ref.current?.offsetWidth ?? 0);
|
||||
setScrollWidth(ref.current?.scrollWidth ?? 0);
|
||||
setOffsetHeight(ref.current?.offsetHeight ?? 0);
|
||||
setScrollHeight(ref.current?.scrollHeight ?? 0);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsTruncated(offsetWidth < scrollWidth);
|
||||
}, [offsetWidth, scrollWidth]);
|
||||
setIsTruncated(
|
||||
(isVertical && offsetHeight < scrollHeight) ||
|
||||
(isHorizontal && offsetWidth < scrollWidth),
|
||||
);
|
||||
}, [
|
||||
offsetWidth,
|
||||
scrollWidth,
|
||||
offsetHeight,
|
||||
scrollHeight,
|
||||
isVertical,
|
||||
isHorizontal,
|
||||
]);
|
||||
|
||||
return [ref, isTruncated];
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Column } from './Column';
|
||||
import { Metric } from './Metric';
|
||||
|
||||
@@ -58,6 +59,7 @@ export const DEFAULT_METRICS: Metric[] = [
|
||||
{
|
||||
metric_name: 'COUNT(*)',
|
||||
expression: 'COUNT(*)',
|
||||
uuid: nanoid(),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export type SavedMetric = string;
|
||||
*/
|
||||
export interface Metric {
|
||||
id?: number;
|
||||
uuid: string;
|
||||
metric_name: string;
|
||||
expression?: Maybe<string>;
|
||||
certification_details?: Maybe<string>;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -20,10 +20,10 @@ import { DatasourceType, DEFAULT_METRICS } from '@superset-ui/core';
|
||||
|
||||
test('DEFAULT_METRICS', () => {
|
||||
expect(DEFAULT_METRICS).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
metric_name: 'COUNT(*)',
|
||||
expression: 'COUNT(*)',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
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
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
currentTimeRangeFilter,
|
||||
startDateOffset,
|
||||
shift,
|
||||
subtitle,
|
||||
subtitleFontSize,
|
||||
dashboardTimeRange,
|
||||
} = props;
|
||||
|
||||
@@ -140,6 +142,16 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
margin-bottom: ${theme.gridUnit * 4}px;
|
||||
`;
|
||||
|
||||
const SubtitleText = styled.div`
|
||||
${({ theme }) => `
|
||||
font-family: ${theme.typography.families.sansSerif};
|
||||
font-weight: ${theme.typography.weights.medium};
|
||||
text-align: center;
|
||||
margin-top: -10px;
|
||||
margin-bottom: ${theme.gridUnit * 4}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const getArrowIndicatorColor = () => {
|
||||
if (!comparisonColorEnabled || percentDifferenceNumber === 0) {
|
||||
return theme.colors.grayscale.base;
|
||||
@@ -195,31 +207,40 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
]);
|
||||
|
||||
const SYMBOLS_WITH_VALUES = useMemo(
|
||||
() => [
|
||||
{
|
||||
symbol: '#',
|
||||
value: prevNumber,
|
||||
tooltipText: t('Data for %s', comparisonRange || 'previous range'),
|
||||
columnKey: 'Previous value',
|
||||
},
|
||||
{
|
||||
symbol: '△',
|
||||
value: valueDifference,
|
||||
tooltipText: t('Value difference between the time periods'),
|
||||
columnKey: 'Delta',
|
||||
},
|
||||
{
|
||||
symbol: '%',
|
||||
value: percentDifferenceFormattedString,
|
||||
tooltipText: t('Percentage difference between the time periods'),
|
||||
columnKey: 'Percent change',
|
||||
},
|
||||
],
|
||||
() =>
|
||||
[
|
||||
{
|
||||
defaultSymbol: '#',
|
||||
value: prevNumber,
|
||||
tooltipText: t('Data for %s', comparisonRange || 'previous range'),
|
||||
columnKey: 'Previous value',
|
||||
},
|
||||
{
|
||||
defaultSymbol: '△',
|
||||
value: valueDifference,
|
||||
tooltipText: t('Value difference between the time periods'),
|
||||
columnKey: 'Delta',
|
||||
},
|
||||
{
|
||||
defaultSymbol: '%',
|
||||
value: percentDifferenceFormattedString,
|
||||
tooltipText: t('Percentage difference between the time periods'),
|
||||
columnKey: 'Percent change',
|
||||
},
|
||||
].map(item => {
|
||||
const config = props.columnConfig?.[item.columnKey];
|
||||
return {
|
||||
...item,
|
||||
symbol: config?.displayTypeIcon === false ? '' : item.defaultSymbol,
|
||||
label: config?.customColumnName || item.columnKey,
|
||||
};
|
||||
}),
|
||||
[
|
||||
comparisonRange,
|
||||
prevNumber,
|
||||
valueDifference,
|
||||
percentDifferenceFormattedString,
|
||||
props.columnConfig,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -250,6 +271,15 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<SubtitleText
|
||||
style={{
|
||||
fontSize: `${subtitleFontSize * height * 0.4}px`,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</SubtitleText>
|
||||
)}
|
||||
|
||||
{visibleSymbols.length > 0 && (
|
||||
<div
|
||||
@@ -276,7 +306,7 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
>
|
||||
{visibleSymbols.map((symbol_with_value, index) => (
|
||||
<ComparisonValue
|
||||
key={`comparison-symbol-${symbol_with_value.symbol}`}
|
||||
key={`comparison-symbol-${symbol_with_value.columnKey}`}
|
||||
subheaderFontSize={subheaderFontSize}
|
||||
>
|
||||
<Tooltip
|
||||
@@ -284,15 +314,19 @@ export default function PopKPI(props: PopKPIProps) {
|
||||
placement="top"
|
||||
title={symbol_with_value.tooltipText}
|
||||
>
|
||||
<SymbolWrapper
|
||||
backgroundColor={
|
||||
index > 0 ? backgroundColor : defaultBackgroundColor
|
||||
}
|
||||
textColor={index > 0 ? textColor : defaultTextColor}
|
||||
>
|
||||
{symbol_with_value.symbol}
|
||||
</SymbolWrapper>
|
||||
{symbol_with_value.value}
|
||||
{symbol_with_value.symbol && (
|
||||
<SymbolWrapper
|
||||
backgroundColor={
|
||||
index > 0 ? backgroundColor : defaultBackgroundColor
|
||||
}
|
||||
textColor={index > 0 ? textColor : defaultTextColor}
|
||||
>
|
||||
{symbol_with_value.symbol}
|
||||
</SymbolWrapper>
|
||||
)}
|
||||
{symbol_with_value.value}{' '}
|
||||
{props.columnConfig?.[symbol_with_value.columnKey]
|
||||
?.customColumnName || ''}
|
||||
</Tooltip>
|
||||
</ComparisonValue>
|
||||
))}
|
||||
|
||||
@@ -23,7 +23,12 @@ import {
|
||||
sharedControls,
|
||||
sections,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { headerFontSize, subheaderFontSize } from '../sharedControls';
|
||||
import {
|
||||
headerFontSize,
|
||||
subheaderFontSize,
|
||||
subtitleControl,
|
||||
subtitleFontSize,
|
||||
} from '../sharedControls';
|
||||
import { ColorSchemeEnum } from './types';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
@@ -63,6 +68,8 @@ const config: ControlPanelConfig = {
|
||||
config: { ...headerFontSize.config, default: 0.2 },
|
||||
},
|
||||
],
|
||||
[subtitleControl],
|
||||
[subtitleFontSize],
|
||||
[
|
||||
{
|
||||
...subheaderFontSize,
|
||||
@@ -120,7 +127,11 @@ const config: ControlPanelConfig = {
|
||||
[GenericDataType.Numeric]: [
|
||||
{
|
||||
tab: t('General'),
|
||||
children: [['visible']],
|
||||
children: [
|
||||
['customColumnName'],
|
||||
['displayTypeIcon'],
|
||||
['visible'],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -89,6 +89,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
comparisonColorScheme,
|
||||
comparisonColorEnabled,
|
||||
percentDifferenceFormat,
|
||||
subtitle = '',
|
||||
subtitleFontSize,
|
||||
columnConfig,
|
||||
} = formData;
|
||||
const { data: dataA = [] } = queriesData[0];
|
||||
@@ -183,6 +185,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
valueDifference,
|
||||
percentDifferenceFormattedString: percentDifference,
|
||||
boldText,
|
||||
subtitle,
|
||||
subtitleFontSize,
|
||||
headerFontSize: getHeaderFontSize(headerFontSize),
|
||||
subheaderFontSize: getComparisonFontSize(subheaderFontSize),
|
||||
headerText,
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface PopKPIStylesProps {
|
||||
|
||||
export type TableColumnConfig = {
|
||||
visible?: boolean;
|
||||
customColumnName?: string;
|
||||
displayTypeIcon?: boolean;
|
||||
};
|
||||
|
||||
interface PopKPICustomizeProps {
|
||||
@@ -61,6 +63,8 @@ export type PopKPIProps = PopKPIStylesProps &
|
||||
metricName: string;
|
||||
bigNumber: string;
|
||||
prevNumber: string;
|
||||
subtitle?: string;
|
||||
subtitleFontSize: number;
|
||||
valueDifference: string;
|
||||
percentDifferenceFormattedString: string;
|
||||
compType: string;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
Dataset,
|
||||
getStandardizedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { headerFontSize, subheaderFontSize } from '../sharedControls';
|
||||
import {
|
||||
headerFontSize,
|
||||
subtitleFontSize,
|
||||
subtitleControl,
|
||||
} from '../sharedControls';
|
||||
|
||||
export default {
|
||||
controlPanelSections: [
|
||||
@@ -33,32 +37,13 @@ export default {
|
||||
expanded: true,
|
||||
controlSetRows: [['metric'], ['adhoc_filters']],
|
||||
},
|
||||
{
|
||||
label: t('Display settings'),
|
||||
expanded: true,
|
||||
tabOverride: 'data',
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'subheader',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Subheader'),
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Description text that shows up below your Big Number',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Chart Options'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[headerFontSize],
|
||||
[subheaderFontSize],
|
||||
[subtitleControl],
|
||||
[subtitleFontSize],
|
||||
['y_axis_format'],
|
||||
['currency_format'],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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)),
|
||||
}));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -47,19 +47,24 @@ export default function transformProps(
|
||||
const {
|
||||
headerFontSize,
|
||||
metric = 'value',
|
||||
subheader = '',
|
||||
subheaderFontSize,
|
||||
subtitle,
|
||||
subtitleFontSize,
|
||||
forceTimestampFormatting,
|
||||
timeFormat,
|
||||
yAxisFormat,
|
||||
conditionalFormatting,
|
||||
currencyFormat,
|
||||
subheader,
|
||||
subheaderFontSize,
|
||||
} = formData;
|
||||
const refs: Refs = {};
|
||||
const { data = [], coltypes = [] } = queriesData[0];
|
||||
const granularity = extractTimegrain(rawFormData as QueryFormData);
|
||||
const metricName = getMetricLabel(metric);
|
||||
const formattedSubheader = subheader;
|
||||
const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || '';
|
||||
const formattedSubtitleFontSize = subtitle?.trim()
|
||||
? (subtitleFontSize ?? 1)
|
||||
: (subheaderFontSize ?? 1);
|
||||
const bigNumber =
|
||||
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
|
||||
|
||||
@@ -106,7 +111,8 @@ export default function transformProps(
|
||||
headerFormatter,
|
||||
headerFontSize,
|
||||
subheaderFontSize,
|
||||
subheader: formattedSubheader,
|
||||
subtitleFontSize: formattedSubtitleFontSize,
|
||||
subtitle: formattedSubtitle,
|
||||
onContextMenu,
|
||||
refs,
|
||||
colorThresholdFormatters,
|
||||
|
||||
@@ -188,31 +188,26 @@ 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
|
||||
@@ -229,6 +224,52 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
return null;
|
||||
}
|
||||
|
||||
renderSubtitle(maxHeight: number) {
|
||||
const { subtitle, width, bigNumber, bigNumberFallback } = 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 = subtitle;
|
||||
if (bigNumber === null) {
|
||||
text =
|
||||
subtitle || (bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED);
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const container = this.createTemporaryContainer();
|
||||
document.body.append(container);
|
||||
fontSize = computeMaxFontSize({
|
||||
text,
|
||||
maxWidth: width * 0.9,
|
||||
maxHeight,
|
||||
className: 'subtitle-line',
|
||||
container,
|
||||
});
|
||||
container.remove();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="subtitle-line subheader-line"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: maxHeight,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderTrendline(maxHeight: number) {
|
||||
const { width, trendLineData, echartOptions, refs } = this.props;
|
||||
|
||||
@@ -281,6 +322,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
height,
|
||||
kickerFontSize,
|
||||
headerFontSize,
|
||||
subtitleFontSize,
|
||||
subheaderFontSize,
|
||||
} = this.props;
|
||||
const className = this.getClassName();
|
||||
@@ -301,11 +343,14 @@ 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,
|
||||
),
|
||||
)}
|
||||
{this.renderSubtitle(
|
||||
Math.ceil(subtitleFontSize * (1 - PROPORTION.TRENDLINE) * height),
|
||||
)}
|
||||
</div>
|
||||
{this.renderTrendline(chartHeight)}
|
||||
</div>
|
||||
@@ -317,7 +362,10 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
|
||||
{this.renderFallbackWarning()}
|
||||
{this.renderKicker((kickerFontSize || 0) * height)}
|
||||
{this.renderHeader(Math.ceil(headerFontSize * height))}
|
||||
{this.renderSubheader(Math.ceil(subheaderFontSize * height))}
|
||||
{this.rendermetricComparisonSummary(
|
||||
Math.ceil(subheaderFontSize * height),
|
||||
)}
|
||||
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -371,6 +419,11 @@ export default styled(BigNumberVis)`
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.subtitle-line {
|
||||
line-height: 1em;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&.is-fallback-value {
|
||||
.kicker,
|
||||
.header-line,
|
||||
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
getStandardizedControls,
|
||||
temporalColumnMixin,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { headerFontSize, subheaderFontSize } from '../sharedControls';
|
||||
import {
|
||||
headerFontSize,
|
||||
subheaderFontSize,
|
||||
subtitleFontSize,
|
||||
subtitleControl,
|
||||
} from '../sharedControls';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
@@ -134,6 +139,8 @@ const config: ControlPanelConfig = {
|
||||
['color_picker', null],
|
||||
[headerFontSize],
|
||||
[subheaderFontSize],
|
||||
[subtitleControl],
|
||||
[subtitleFontSize],
|
||||
['y_axis_format'],
|
||||
['currency_format'],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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)),
|
||||
}));
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,8 @@ export default function transformProps(
|
||||
metric = 'value',
|
||||
showTimestamp,
|
||||
showTrendLine,
|
||||
subtitle = '',
|
||||
subtitleFontSize,
|
||||
aggregation,
|
||||
startYAxisAtZero,
|
||||
subheader = '',
|
||||
@@ -302,6 +304,8 @@ export default function transformProps(
|
||||
formatTime,
|
||||
formData,
|
||||
headerFontSize,
|
||||
subtitleFontSize,
|
||||
subtitle,
|
||||
subheaderFontSize,
|
||||
mainColor,
|
||||
showTimestamp,
|
||||
|
||||
@@ -55,6 +55,39 @@ export const headerFontSize: CustomControlItem = {
|
||||
},
|
||||
};
|
||||
|
||||
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: {
|
||||
@@ -88,3 +121,13 @@ export const subheaderFontSize: CustomControlItem = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const subtitleControl: CustomControlItem = {
|
||||
name: 'subtitle',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Subtitle'),
|
||||
renderTrigger: true,
|
||||
description: t('Description text that shows up below your Big Number'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -77,8 +77,10 @@ export type BigNumberVizProps = {
|
||||
formatTime?: TimeFormatter;
|
||||
headerFontSize: number;
|
||||
kickerFontSize?: number;
|
||||
subheader: string;
|
||||
subheader?: string;
|
||||
subtitle: string;
|
||||
subheaderFontSize: number;
|
||||
subtitleFontSize: number;
|
||||
showTimestamp?: boolean;
|
||||
showTrendLine?: boolean;
|
||||
startYAxisAtZero?: boolean;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -179,8 +179,8 @@ export default function transformProps(
|
||||
xAxisBounds,
|
||||
xAxisForceCategorical,
|
||||
xAxisLabelRotation,
|
||||
xAxisSortSeries,
|
||||
xAxisSortSeriesAscending,
|
||||
xAxisSort,
|
||||
xAxisSortAsc,
|
||||
xAxisTimeFormat,
|
||||
xAxisTitle,
|
||||
xAxisTitleMargin,
|
||||
@@ -242,10 +242,8 @@ export default function transformProps(
|
||||
isHorizontal,
|
||||
sortSeriesType,
|
||||
sortSeriesAscending,
|
||||
xAxisSortSeries: isMultiSeries ? xAxisSortSeries : undefined,
|
||||
xAxisSortSeriesAscending: isMultiSeries
|
||||
? xAxisSortSeriesAscending
|
||||
: undefined,
|
||||
xAxisSortSeries: isMultiSeries ? xAxisSort : undefined,
|
||||
xAxisSortSeriesAscending: isMultiSeries ? xAxisSortAsc : undefined,
|
||||
},
|
||||
);
|
||||
const showValueIndexes = extractShowValueIndexes(rawSeries, {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useCallback,
|
||||
Ref,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -106,6 +107,16 @@ use([
|
||||
LabelLayout,
|
||||
]);
|
||||
|
||||
const loadLocale = async (locale: string) => {
|
||||
let lang;
|
||||
try {
|
||||
lang = await import(`echarts/lib/i18n/lang${locale}`);
|
||||
} catch (e) {
|
||||
console.error(`Locale ${locale} not supported in ECharts`, e);
|
||||
}
|
||||
return lang?.default;
|
||||
};
|
||||
|
||||
function Echart(
|
||||
{
|
||||
width,
|
||||
@@ -123,6 +134,7 @@ function Echart(
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
refs.divRef = divRef;
|
||||
}
|
||||
const [didMount, setDidMount] = useState(false);
|
||||
const chartRef = useRef<EChartsType>();
|
||||
const currentSelection = useMemo(
|
||||
() => Object.keys(selectedValues) || [],
|
||||
@@ -148,20 +160,20 @@ function Echart(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadLocaleAndInitChart = async () => {
|
||||
if (!divRef.current) return;
|
||||
|
||||
const lang = await import(`echarts/lib/i18n/lang${locale}`).catch(e => {
|
||||
console.error(`Locale ${locale} not supported in ECharts`, e);
|
||||
});
|
||||
if (lang?.default) {
|
||||
registerLocale(locale, lang.default);
|
||||
loadLocale(locale).then(localeObj => {
|
||||
if (localeObj) {
|
||||
registerLocale(locale, localeObj);
|
||||
}
|
||||
|
||||
if (!divRef.current) return;
|
||||
if (!chartRef.current) {
|
||||
chartRef.current = init(divRef.current, null, { locale });
|
||||
}
|
||||
setDidMount(true);
|
||||
});
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didMount) {
|
||||
Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
|
||||
chartRef.current?.off(name);
|
||||
chartRef.current?.on(name, handler);
|
||||
@@ -172,14 +184,14 @@ function Echart(
|
||||
chartRef.current?.getZr().on(name, handler);
|
||||
});
|
||||
|
||||
chartRef.current.setOption(echartOptions, true);
|
||||
chartRef.current?.setOption(echartOptions, true);
|
||||
|
||||
// did mount
|
||||
handleSizeChange({ width, height });
|
||||
};
|
||||
}
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers]);
|
||||
|
||||
loadLocaleAndInitChart();
|
||||
}, [echartOptions, eventHandlers, zrEventHandlers, locale]);
|
||||
useEffect(() => () => chartRef.current?.dispose(), []);
|
||||
|
||||
// highlighting
|
||||
useEffect(() => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export function getTooltipTimeFormatter(
|
||||
format?: string,
|
||||
): TimeFormatter | StringConstructor {
|
||||
if (format === SMART_DATE_ID) {
|
||||
return getSmartDateDetailedFormatter();
|
||||
return getSmartDateVerboseFormatter();
|
||||
}
|
||||
if (format) {
|
||||
return getTimeFormatter(format);
|
||||
|
||||
@@ -149,6 +149,7 @@ describe('BigNumberWithTrendline', () => {
|
||||
label: 'value',
|
||||
metric_name: 'value',
|
||||
d3format: '.2f',
|
||||
uuid: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -174,6 +175,7 @@ describe('BigNumberWithTrendline', () => {
|
||||
metric_name: 'value',
|
||||
d3format: '.2f',
|
||||
currency: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
uuid: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -682,13 +682,33 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
(column: DataColumnMeta, i: number): ColumnWithLooseAccessor<D> => {
|
||||
const {
|
||||
key,
|
||||
label,
|
||||
label: originalLabel,
|
||||
isNumeric,
|
||||
dataType,
|
||||
isMetric,
|
||||
isPercentMetric,
|
||||
config = {},
|
||||
} = column;
|
||||
const label = config.customColumnName || originalLabel;
|
||||
let displayLabel = label;
|
||||
|
||||
const isComparisonColumn = ['#', '△', '%', t('Main')].includes(
|
||||
column.label,
|
||||
);
|
||||
|
||||
if (isComparisonColumn) {
|
||||
if (column.label === t('Main')) {
|
||||
displayLabel = config.customColumnName || column.originalLabel || '';
|
||||
} else if (config.customColumnName) {
|
||||
displayLabel =
|
||||
config.displayTypeIcon !== false
|
||||
? `${column.label} ${config.customColumnName}`
|
||||
: config.customColumnName;
|
||||
} else if (config.displayTypeIcon === false) {
|
||||
displayLabel = '';
|
||||
}
|
||||
}
|
||||
|
||||
const columnWidth = Number.isNaN(Number(config.columnWidth))
|
||||
? config.columnWidth
|
||||
: Number(config.columnWidth);
|
||||
@@ -795,6 +815,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
white-space: ${value instanceof Date ? 'nowrap' : undefined};
|
||||
position: relative;
|
||||
background: ${backgroundColor || undefined};
|
||||
padding-left: ${column.isChildColumn
|
||||
? `${theme.gridUnit * 5}px`
|
||||
: `${theme.gridUnit}px`};
|
||||
`;
|
||||
|
||||
const cellBarStyles = css`
|
||||
@@ -970,11 +993,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<span data-column-name={col.id}>{label}</span>
|
||||
<span data-column-name={col.id}>{displayLabel}</span>
|
||||
<SortIcon column={col} />
|
||||
</div>
|
||||
</th>
|
||||
),
|
||||
|
||||
Footer: totals ? (
|
||||
i === 0 ? (
|
||||
<th key={`footer-summary-${i}`}>
|
||||
@@ -1024,9 +1048,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
],
|
||||
);
|
||||
|
||||
const visibleColumnsMeta = useMemo(
|
||||
() => filteredColumnsMeta.filter(col => col.config?.visible !== false),
|
||||
[filteredColumnsMeta],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => filteredColumnsMeta.map(getColumnConfigs),
|
||||
[filteredColumnsMeta, getColumnConfigs],
|
||||
() => visibleColumnsMeta.map(getColumnConfigs),
|
||||
[visibleColumnsMeta, getColumnConfigs],
|
||||
);
|
||||
|
||||
const handleServerPaginationChange = useCallback(
|
||||
|
||||
@@ -37,15 +37,17 @@ import {
|
||||
import {
|
||||
ensureIsArray,
|
||||
GenericDataType,
|
||||
getMetricLabel,
|
||||
isAdhocColumn,
|
||||
isPhysicalColumn,
|
||||
QueryFormColumn,
|
||||
QueryFormMetric,
|
||||
QueryMode,
|
||||
SMART_DATE_ID,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
import { isEmpty, last } from 'lodash';
|
||||
import { PAGE_SIZE_OPTIONS } from './consts';
|
||||
import { ColorSchemeEnum } from './types';
|
||||
|
||||
@@ -486,38 +488,69 @@ const config: ControlPanelConfig = {
|
||||
return true;
|
||||
},
|
||||
mapStateToProps(explore, _, chart) {
|
||||
const timeComparisonStatus = !isEmpty(
|
||||
explore?.controls?.time_compare?.value,
|
||||
);
|
||||
|
||||
const timeComparisonValue =
|
||||
explore?.controls?.time_compare?.value;
|
||||
const { colnames: _colnames, coltypes: _coltypes } =
|
||||
chart?.queriesResponse?.[0] ?? {};
|
||||
let colnames: string[] = _colnames || [];
|
||||
let coltypes: GenericDataType[] = _coltypes || [];
|
||||
const childColumnMap: Record<string, boolean> = {};
|
||||
const timeComparisonColumnMap: Record<string, boolean> = {};
|
||||
|
||||
if (timeComparisonStatus) {
|
||||
if (!isEmpty(timeComparisonValue)) {
|
||||
/**
|
||||
* Replace numeric columns with sets of comparison columns.
|
||||
*/
|
||||
const updatedColnames: string[] = [];
|
||||
const updatedColtypes: GenericDataType[] = [];
|
||||
colnames.forEach((colname, index) => {
|
||||
if (coltypes[index] === GenericDataType.Numeric) {
|
||||
updatedColnames.push(
|
||||
...generateComparisonColumns(colname),
|
||||
);
|
||||
updatedColtypes.push(...generateComparisonColumnTypes(4));
|
||||
} else {
|
||||
updatedColnames.push(colname);
|
||||
updatedColtypes.push(coltypes[index]);
|
||||
}
|
||||
});
|
||||
|
||||
colnames
|
||||
.filter(
|
||||
colname =>
|
||||
last(colname.split('__')) !== timeComparisonValue,
|
||||
)
|
||||
.forEach((colname, index) => {
|
||||
if (
|
||||
explore.form_data.metrics?.some(
|
||||
metric => getMetricLabel(metric) === colname,
|
||||
) ||
|
||||
explore.form_data.percent_metrics?.some(
|
||||
(metric: QueryFormMetric) =>
|
||||
getMetricLabel(metric) === colname,
|
||||
)
|
||||
) {
|
||||
const comparisonColumns =
|
||||
generateComparisonColumns(colname);
|
||||
comparisonColumns.forEach((name, idx) => {
|
||||
updatedColnames.push(name);
|
||||
updatedColtypes.push(
|
||||
...generateComparisonColumnTypes(4),
|
||||
);
|
||||
timeComparisonColumnMap[name] = true;
|
||||
if (idx === 0 && name.startsWith('Main ')) {
|
||||
childColumnMap[name] = false;
|
||||
} else {
|
||||
childColumnMap[name] = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
updatedColnames.push(colname);
|
||||
updatedColtypes.push(coltypes[index]);
|
||||
childColumnMap[colname] = false;
|
||||
timeComparisonColumnMap[colname] = false;
|
||||
}
|
||||
});
|
||||
|
||||
colnames = updatedColnames;
|
||||
coltypes = updatedColtypes;
|
||||
}
|
||||
return {
|
||||
columnsPropsObject: { colnames, coltypes },
|
||||
columnsPropsObject: {
|
||||
colnames,
|
||||
coltypes,
|
||||
childColumnMap,
|
||||
timeComparisonColumnMap,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -49,6 +49,9 @@ export type TableColumnConfig = {
|
||||
colorPositiveNegative?: boolean;
|
||||
truncateLongCells?: boolean;
|
||||
currencyFormat?: Currency;
|
||||
visible?: boolean;
|
||||
customColumnName?: string;
|
||||
displayTypeIcon?: boolean;
|
||||
};
|
||||
|
||||
export interface DataColumnMeta {
|
||||
@@ -68,6 +71,7 @@ export interface DataColumnMeta {
|
||||
isPercentMetric?: boolean;
|
||||
isNumeric?: boolean;
|
||||
config?: TableColumnConfig;
|
||||
isChildColumn?: boolean;
|
||||
}
|
||||
|
||||
export interface TableChartData {
|
||||
|
||||
@@ -67,18 +67,21 @@ describe('plugin-chart-table', () => {
|
||||
});
|
||||
it('should process comparison columns when time_compare and comparison_type are set', () => {
|
||||
const transformedProps = transformProps(testData.comparison);
|
||||
|
||||
// Check if comparison columns are processed
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
col =>
|
||||
col.label === 'Main' ||
|
||||
col.originalLabel === 'metric_1' ||
|
||||
col.originalLabel === 'metric_2' ||
|
||||
col.label === '#' ||
|
||||
col.label === '△' ||
|
||||
col.label === '%',
|
||||
);
|
||||
|
||||
expect(comparisonColumns.length).toBeGreaterThan(0);
|
||||
expect(comparisonColumns.some(col => col.label === 'Main')).toBe(true);
|
||||
expect(
|
||||
comparisonColumns.some(col => col.originalLabel === 'metric_1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
comparisonColumns.some(col => col.originalLabel === 'metric_2'),
|
||||
).toBe(true);
|
||||
expect(comparisonColumns.some(col => col.label === '#')).toBe(true);
|
||||
expect(comparisonColumns.some(col => col.label === '△')).toBe(true);
|
||||
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
|
||||
@@ -180,26 +183,37 @@ describe('plugin-chart-table', () => {
|
||||
const transformedProps = transformProps(testData.comparison);
|
||||
|
||||
// Check if comparison columns are processed
|
||||
// Now we're looking for columns with metric names as labels
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
col =>
|
||||
col.label === 'Main' ||
|
||||
col.originalLabel === 'metric_1' ||
|
||||
col.originalLabel === 'metric_2' ||
|
||||
col.label === '#' ||
|
||||
col.label === '△' ||
|
||||
col.label === '%',
|
||||
);
|
||||
|
||||
expect(comparisonColumns.length).toBeGreaterThan(0);
|
||||
expect(comparisonColumns.some(col => col.label === 'Main')).toBe(true);
|
||||
expect(
|
||||
comparisonColumns.some(col => col.originalLabel === 'metric_1'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
comparisonColumns.some(col => col.originalLabel === 'metric_2'),
|
||||
).toBe(true);
|
||||
expect(comparisonColumns.some(col => col.label === '#')).toBe(true);
|
||||
expect(comparisonColumns.some(col => col.label === '△')).toBe(true);
|
||||
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
|
||||
|
||||
// Verify originalLabel for metric_1 comparison columns
|
||||
const mainMetric1 = transformedProps.columns.find(
|
||||
col => col.key === 'Main metric_1',
|
||||
const metric1Column = transformedProps.columns.find(
|
||||
col =>
|
||||
col.originalLabel === 'metric_1' &&
|
||||
!col.key.startsWith('#') &&
|
||||
!col.key.startsWith('△') &&
|
||||
!col.key.startsWith('%'),
|
||||
);
|
||||
expect(mainMetric1).toBeDefined();
|
||||
expect(mainMetric1?.originalLabel).toBe('metric_1');
|
||||
expect(metric1Column).toBeDefined();
|
||||
expect(metric1Column?.originalLabel).toBe('metric_1');
|
||||
expect(metric1Column?.label).toBe('Main');
|
||||
|
||||
const hashMetric1 = transformedProps.columns.find(
|
||||
col => col.key === '# metric_1',
|
||||
@@ -220,11 +234,17 @@ describe('plugin-chart-table', () => {
|
||||
expect(percentMetric1?.originalLabel).toBe('metric_1');
|
||||
|
||||
// Verify originalLabel for metric_2 comparison columns
|
||||
const mainMetric2 = transformedProps.columns.find(
|
||||
col => col.key === 'Main metric_2',
|
||||
const metric2Column = transformedProps.columns.find(
|
||||
col =>
|
||||
col.originalLabel === 'metric_2' &&
|
||||
!col.key.startsWith('#') &&
|
||||
!col.key.startsWith('△') &&
|
||||
!col.key.startsWith('%'),
|
||||
);
|
||||
expect(mainMetric2).toBeDefined();
|
||||
expect(mainMetric2?.originalLabel).toBe('metric_2');
|
||||
expect(metric2Column).toBeDefined();
|
||||
expect(metric2Column?.originalLabel).toBe('metric_2');
|
||||
|
||||
expect(metric2Column?.label).toBe('Main');
|
||||
|
||||
const hashMetric2 = transformedProps.columns.find(
|
||||
col => col.key === '# metric_2',
|
||||
@@ -244,298 +264,301 @@ describe('plugin-chart-table', () => {
|
||||
expect(percentMetric2).toBeDefined();
|
||||
expect(percentMetric2?.originalLabel).toBe('metric_2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TableChart', () => {
|
||||
it('render basic data', () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...transformProps(testData.basic)} sticky={false} />,
|
||||
</ThemeProvider>,
|
||||
);
|
||||
describe('TableChart', () => {
|
||||
it('render basic data', () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...transformProps(testData.basic)} sticky={false} />,
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const firstDataRow = screen.getAllByRole('rowgroup')[1];
|
||||
const cells = firstDataRow.querySelectorAll('td');
|
||||
expect(cells).toHaveLength(12);
|
||||
expect(cells[0]).toHaveTextContent('2020-01-01 12:34:56');
|
||||
expect(cells[1]).toHaveTextContent('Michael');
|
||||
// number is not in `metrics` list, so it should output raw value
|
||||
// (in real world Superset, this would mean the column is used in GROUP BY)
|
||||
expect(cells[2]).toHaveTextContent('2467063');
|
||||
// should not render column with `.` in name as `undefined`
|
||||
expect(cells[3]).toHaveTextContent('foo');
|
||||
expect(cells[6]).toHaveTextContent('2467');
|
||||
expect(cells[8]).toHaveTextContent('N/A');
|
||||
});
|
||||
|
||||
it('render advanced data', () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...transformProps(testData.advanced)} sticky={false} />,
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const secondColumnHeader = screen.getByText('Sum of Num');
|
||||
expect(secondColumnHeader).toBeInTheDocument();
|
||||
expect(secondColumnHeader?.getAttribute('data-column-name')).toEqual('1');
|
||||
|
||||
const firstDataRow = screen.getAllByRole('rowgroup')[1];
|
||||
const cells = firstDataRow.querySelectorAll('td');
|
||||
expect(cells[0]).toHaveTextContent('Michael');
|
||||
expect(cells[2]).toHaveTextContent('12.346%');
|
||||
expect(cells[4]).toHaveTextContent('2.47k');
|
||||
});
|
||||
|
||||
it('render advanced data with currencies', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart
|
||||
{...transformProps(testData.advancedWithCurrency)}
|
||||
sticky={false}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
expect(document.querySelectorAll('th')[1]).toHaveTextContent(
|
||||
'Sum of Num',
|
||||
);
|
||||
expect(cells[0]).toHaveTextContent('Michael');
|
||||
expect(cells[2]).toHaveTextContent('12.346%');
|
||||
expect(cells[4]).toHaveTextContent('$ 2.47k');
|
||||
});
|
||||
|
||||
it('render raw data', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
const firstDataRow = screen.getAllByRole('rowgroup')[1];
|
||||
const cells = firstDataRow.querySelectorAll('td');
|
||||
expect(cells).toHaveLength(12);
|
||||
expect(cells[0]).toHaveTextContent('2020-01-01 12:34:56');
|
||||
expect(cells[1]).toHaveTextContent('Michael');
|
||||
// number is not in `metrics` list, so it should output raw value
|
||||
// (in real world Superset, this would mean the column is used in GROUP BY)
|
||||
expect(cells[2]).toHaveTextContent('2467063');
|
||||
// should not render column with `.` in name as `undefined`
|
||||
expect(cells[3]).toHaveTextContent('foo');
|
||||
expect(cells[6]).toHaveTextContent('2467');
|
||||
expect(cells[8]).toHaveTextContent('N/A');
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
|
||||
expect(cells[0]).toHaveTextContent('1234');
|
||||
expect(cells[1]).toHaveTextContent('10000');
|
||||
expect(cells[1]).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('render raw data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
column_config: {
|
||||
num: {
|
||||
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
it('render advanced data', () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...transformProps(testData.advanced)} sticky={false} />
|
||||
,
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const secondColumnHeader = screen.getByText('Sum of Num');
|
||||
expect(secondColumnHeader).toBeInTheDocument();
|
||||
expect(secondColumnHeader?.getAttribute('data-column-name')).toEqual(
|
||||
'1',
|
||||
);
|
||||
|
||||
const firstDataRow = screen.getAllByRole('rowgroup')[1];
|
||||
const cells = firstDataRow.querySelectorAll('td');
|
||||
expect(cells[0]).toHaveTextContent('Michael');
|
||||
expect(cells[2]).toHaveTextContent('12.346%');
|
||||
expect(cells[4]).toHaveTextContent('2.47k');
|
||||
});
|
||||
|
||||
it('render advanced data with currencies', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart
|
||||
{...transformProps(testData.advancedWithCurrency)}
|
||||
sticky={false}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
expect(document.querySelectorAll('th')[1]).toHaveTextContent(
|
||||
'Sum of Num',
|
||||
);
|
||||
expect(cells[0]).toHaveTextContent('Michael');
|
||||
expect(cells[2]).toHaveTextContent('12.346%');
|
||||
expect(cells[4]).toHaveTextContent('$ 2.47k');
|
||||
});
|
||||
|
||||
it('render raw data', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
|
||||
expect(cells[0]).toHaveTextContent('1234');
|
||||
expect(cells[1]).toHaveTextContent('10000');
|
||||
expect(cells[1]).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('render raw data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
column_config: {
|
||||
num: {
|
||||
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
|
||||
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
|
||||
expect(cells[0]).toHaveTextContent('$ 1.23k');
|
||||
expect(cells[1]).toHaveTextContent('$ 10k');
|
||||
expect(cells[2]).toHaveTextContent('$ 0');
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
|
||||
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
|
||||
expect(cells[0]).toHaveTextContent('$ 1.23k');
|
||||
expect(cells[1]).toHaveTextContent('$ 10k');
|
||||
expect(cells[2]).toHaveTextContent('$ 0');
|
||||
});
|
||||
|
||||
it('render small formatted data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
column_config: {
|
||||
num: {
|
||||
d3SmallNumberFormat: '.2r',
|
||||
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
it('render small formatted data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
column_config: {
|
||||
num: {
|
||||
d3SmallNumberFormat: '.2r',
|
||||
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
data: [
|
||||
{
|
||||
num: 1234,
|
||||
},
|
||||
{
|
||||
num: 0.5,
|
||||
},
|
||||
{
|
||||
num: 0.61234,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
|
||||
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
|
||||
expect(cells[0]).toHaveTextContent('$ 1.23k');
|
||||
expect(cells[1]).toHaveTextContent('$ 0.50');
|
||||
expect(cells[2]).toHaveTextContent('$ 0.61');
|
||||
});
|
||||
|
||||
it('render empty data', () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...transformProps(testData.empty)} sticky={false} />,
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('No records found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render color with column color formatter', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart
|
||||
{...transformProps({
|
||||
...testData.advanced,
|
||||
rawFormData: {
|
||||
...testData.advanced.rawFormData,
|
||||
conditional_formatting: [
|
||||
{
|
||||
colorScheme: '#ACE1C4',
|
||||
column: 'sum__num',
|
||||
operator: '>',
|
||||
targetValue: 2467,
|
||||
},
|
||||
],
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
data: [
|
||||
{
|
||||
num: 1234,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
{
|
||||
num: 0.5,
|
||||
},
|
||||
{
|
||||
num: 0.61234,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
const cells = document.querySelectorAll('td');
|
||||
|
||||
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
|
||||
'rgba(172, 225, 196, 1)',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
|
||||
});
|
||||
|
||||
it('render cell without color', () => {
|
||||
const dataWithEmptyCell = testData.advanced.queriesData[0];
|
||||
dataWithEmptyCell.data.push({
|
||||
__timestamp: null,
|
||||
name: 'Noah',
|
||||
sum__num: null,
|
||||
'%pct_nice': 0.643,
|
||||
'abc.com': 'bazzinga',
|
||||
expect(document.querySelectorAll('th')[0]).toHaveTextContent('num');
|
||||
expect(cells[0]).toHaveTextContent('$ 1.23k');
|
||||
expect(cells[1]).toHaveTextContent('$ 0.50');
|
||||
expect(cells[2]).toHaveTextContent('$ 0.61');
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart
|
||||
{...transformProps({
|
||||
...testData.advanced,
|
||||
queriesData: [dataWithEmptyCell],
|
||||
rawFormData: {
|
||||
...testData.advanced.rawFormData,
|
||||
conditional_formatting: [
|
||||
{
|
||||
colorScheme: '#ACE1C4',
|
||||
column: 'sum__num',
|
||||
operator: '<',
|
||||
targetValue: 12342,
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe(
|
||||
'rgba(172, 225, 196, 0.812)',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
|
||||
'',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
|
||||
});
|
||||
it('should display originalLabel in grouped headers', () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...transformProps(testData.comparison)} sticky={false} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
it('render empty data', () => {
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...transformProps(testData.empty)} sticky={false} />,
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('No records found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const groupHeaders = screen.getAllByRole('columnheader');
|
||||
expect(groupHeaders[0]).toHaveTextContent('metric_1');
|
||||
expect(groupHeaders[1]).toHaveTextContent('metric_2');
|
||||
});
|
||||
});
|
||||
it('render color with column color formatter', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart
|
||||
{...transformProps({
|
||||
...testData.advanced,
|
||||
rawFormData: {
|
||||
...testData.advanced.rawFormData,
|
||||
conditional_formatting: [
|
||||
{
|
||||
colorScheme: '#ACE1C4',
|
||||
column: 'sum__num',
|
||||
operator: '>',
|
||||
targetValue: 2467,
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
});
|
||||
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
|
||||
'rgba(172, 225, 196, 1)',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
|
||||
});
|
||||
|
||||
props.columns[0].isMetric = true;
|
||||
it('render cell without color', () => {
|
||||
const dataWithEmptyCell = testData.advanced.queriesData[0];
|
||||
dataWithEmptyCell.data.push({
|
||||
__timestamp: null,
|
||||
name: 'Noah',
|
||||
sum__num: null,
|
||||
'%pct_nice': 0.643,
|
||||
'abc.com': 'bazzinga',
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
let cells = document.querySelectorAll('div.cell-bar');
|
||||
cells.forEach(cell => {
|
||||
expect(cell).toHaveClass('positive');
|
||||
});
|
||||
props.columns[0].isMetric = false;
|
||||
props.columns[0].isPercentMetric = true;
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart
|
||||
{...transformProps({
|
||||
...testData.advanced,
|
||||
queriesData: [dataWithEmptyCell],
|
||||
rawFormData: {
|
||||
...testData.advanced.rawFormData,
|
||||
conditional_formatting: [
|
||||
{
|
||||
colorScheme: '#ACE1C4',
|
||||
column: 'sum__num',
|
||||
operator: '<',
|
||||
targetValue: 12342,
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe(
|
||||
'rgba(172, 225, 196, 0.812)',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
|
||||
'',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
|
||||
});
|
||||
it('should display originalLabel in grouped headers', () => {
|
||||
const props = transformProps(testData.comparison);
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
cells = document.querySelectorAll('div.cell-bar');
|
||||
cells.forEach(cell => {
|
||||
expect(cell).toHaveClass('positive');
|
||||
});
|
||||
render(
|
||||
<ThemeProvider theme={supersetTheme}>
|
||||
<TableChart {...props} sticky={false} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const groupHeaders = screen.getAllByRole('columnheader');
|
||||
expect(groupHeaders.length).toBeGreaterThan(0);
|
||||
const hasMetricHeaders = groupHeaders.some(
|
||||
header =>
|
||||
header.textContent &&
|
||||
(header.textContent.includes('metric') ||
|
||||
header.textContent.includes('Metric')),
|
||||
);
|
||||
expect(hasMetricHeaders).toBe(true);
|
||||
});
|
||||
|
||||
props.showCellBars = false;
|
||||
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
cells = document.querySelectorAll('td');
|
||||
props.columns[0].isMetric = true;
|
||||
|
||||
cells.forEach(cell => {
|
||||
expect(cell).toHaveClass('test-c7w8t3');
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
let cells = document.querySelectorAll('div.cell-bar');
|
||||
cells.forEach(cell => {
|
||||
expect(cell).toHaveClass('positive');
|
||||
});
|
||||
props.columns[0].isMetric = false;
|
||||
props.columns[0].isPercentMetric = true;
|
||||
|
||||
props.columns[0].isPercentMetric = false;
|
||||
props.columns[0].isMetric = true;
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
cells = document.querySelectorAll('div.cell-bar');
|
||||
cells.forEach(cell => {
|
||||
expect(cell).toHaveClass('positive');
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
cells = document.querySelectorAll('td');
|
||||
cells.forEach(cell => {
|
||||
expect(cell).toHaveClass('test-c7w8t3');
|
||||
props.showCellBars = false;
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
cells = document.querySelectorAll('td');
|
||||
|
||||
props.columns[0].isPercentMetric = false;
|
||||
props.columns[0].isMetric = true;
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
cells = document.querySelectorAll('td');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ export const nativeFilters: NativeFiltersState = {
|
||||
excluded: [],
|
||||
},
|
||||
controlValues: {
|
||||
creatable: false,
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
inverseSelection: false,
|
||||
@@ -79,6 +80,7 @@ export const nativeFilters: NativeFiltersState = {
|
||||
excluded: [],
|
||||
},
|
||||
controlValues: {
|
||||
creatable: false,
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
inverseSelection: false,
|
||||
@@ -463,6 +465,7 @@ export const buildNativeFilter = (
|
||||
) => ({
|
||||
id,
|
||||
controlValues: {
|
||||
creatable: true,
|
||||
multiSelect: true,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
|
||||
@@ -212,12 +212,12 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
<TabTitle>{qe.name}</TabTitle>
|
||||
<TabTitle>{qe.name}</TabTitle>{' '}
|
||||
<StatusIcon
|
||||
className="status-icon"
|
||||
iconSize="xs"
|
||||
iconColor={getStatusColor(queryState, theme)}
|
||||
/>
|
||||
/>{' '}
|
||||
</TabTitleWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -396,7 +396,7 @@ test('context menu for supported chart, dimensions, filter B', async () => {
|
||||
await expectDrillToDetailByDimension(filterB);
|
||||
});
|
||||
|
||||
test('context menu for supported chart, dimensions, all filters', async () => {
|
||||
test.skip('context menu for supported chart, dimensions, all filters', async () => {
|
||||
const filters = [filterA, filterB];
|
||||
setupMenu(filters);
|
||||
await expectDrillToDetailByAll(filters);
|
||||
|
||||
@@ -21,7 +21,7 @@ import { styled } from '@superset-ui/core';
|
||||
import { CheckboxChecked, CheckboxUnchecked } from 'src/components/Checkbox';
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean;
|
||||
checked?: boolean;
|
||||
onChange: (val?: boolean) => void;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
@@ -35,7 +35,7 @@ const Styles = styled.span`
|
||||
`;
|
||||
|
||||
export default function Checkbox({
|
||||
checked,
|
||||
checked = false,
|
||||
onChange,
|
||||
style,
|
||||
className,
|
||||
|
||||
@@ -328,12 +328,16 @@ test('Should schema select display options', async () => {
|
||||
});
|
||||
expect(select).toBeInTheDocument();
|
||||
userEvent.click(select);
|
||||
expect(
|
||||
await screen.findByRole('option', { name: 'public' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByRole('option', { name: 'information_schema' }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
const publicOption = await screen.findByRole('option', { name: 'public' });
|
||||
expect(publicOption).toBeInTheDocument();
|
||||
|
||||
const infoSchemaOption = await screen.findByRole('option', {
|
||||
name: 'information_schema',
|
||||
});
|
||||
expect(infoSchemaOption).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Sends the correct db when changing the database', async () => {
|
||||
|
||||
@@ -179,6 +179,42 @@ const DropdownContainer = forwardRef(
|
||||
[items, overflowingIndex],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = current?.children.item(0);
|
||||
if (!container) return;
|
||||
|
||||
const childrenArray = Array.from(container.children);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
recalculateItemWidths();
|
||||
});
|
||||
|
||||
childrenArray.map(child => resizeObserver.observe(child));
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
childrenArray.map(child => resizeObserver.unobserve(child));
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [items.length]);
|
||||
|
||||
// callback to update item widths so that the useLayoutEffect runs whenever
|
||||
// width of any of the child changes
|
||||
const recalculateItemWidths = () => {
|
||||
const container = current?.children.item(0);
|
||||
if (container) {
|
||||
const { children } = container;
|
||||
const childrenArray = Array.from(children);
|
||||
|
||||
const currentWidths = childrenArray.map(
|
||||
child => child.getBoundingClientRect().width,
|
||||
);
|
||||
|
||||
// Update state with new widths
|
||||
setItemsWidth(currentWidths);
|
||||
}
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
return;
|
||||
|
||||
@@ -36,6 +36,21 @@ const defaultProps = {
|
||||
subtitle: 'Test subtitle',
|
||||
};
|
||||
|
||||
const missingExtraProps = {
|
||||
...defaultProps,
|
||||
error: {
|
||||
error_type: ErrorTypeEnum.INVALID_SQL_ERROR,
|
||||
message: 'SQLStatement should have exactly one statement',
|
||||
level: 'error' as ErrorLevel,
|
||||
extra: {
|
||||
sql: null,
|
||||
line: null,
|
||||
column: null,
|
||||
engine: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = (overrides = {}) =>
|
||||
render(<InvalidSQLErrorMessage {...defaultProps} {...overrides} />);
|
||||
|
||||
@@ -60,6 +75,12 @@ describe('InvalidSQLErrorMessage', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders the error message with the empty extra properties', () => {
|
||||
const { getByText } = renderComponent(missingExtraProps);
|
||||
expect(getByText('Unable to parse SQL')).toBeInTheDocument();
|
||||
expect(getByText(missingExtraProps.error.message)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the SQL error line and column indicator', async () => {
|
||||
const { getByText, container, unmount } = renderComponent();
|
||||
|
||||
|
||||
@@ -36,20 +36,22 @@ function InvalidSQLErrorMessage({
|
||||
source,
|
||||
subtitle,
|
||||
}: ErrorMessageComponentProps<SupersetParseErrorExtra>) {
|
||||
const { extra, level } = error;
|
||||
const { extra, level, message } = error;
|
||||
|
||||
const { sql, line, column } = extra;
|
||||
const lines = sql.split('\n');
|
||||
const lines = sql?.split('\n');
|
||||
let errorLine;
|
||||
if (line !== null) errorLine = lines[line - 1];
|
||||
else if (lines.length > 0) {
|
||||
if (line !== null && Number.isInteger(line)) errorLine = lines[line - 1];
|
||||
else if (lines?.length > 0) {
|
||||
errorLine = lines[0];
|
||||
}
|
||||
const body = errorLine && (
|
||||
const body = errorLine ? (
|
||||
<>
|
||||
<pre>{errorLine}</pre>
|
||||
{column !== null && <pre>{' '.repeat(column - 1)}^</pre>}
|
||||
</>
|
||||
) : (
|
||||
message
|
||||
);
|
||||
return (
|
||||
<ErrorAlert
|
||||
|
||||
@@ -35,11 +35,12 @@ import { useLocale } from 'src/hooks/useLocale';
|
||||
import { BaseFilter, FilterHandler } from './Base';
|
||||
|
||||
interface DateRangeFilterProps extends BaseFilter {
|
||||
onSubmit: (val: number[]) => void;
|
||||
onSubmit: (val: number[] | string[]) => void;
|
||||
name: string;
|
||||
dateFilterValueType?: 'unix' | 'iso';
|
||||
}
|
||||
|
||||
type ValueState = [number, number];
|
||||
type ValueState = [number, number] | [string, string] | null;
|
||||
|
||||
const RangeFilterContainer = styled.div`
|
||||
display: inline-flex;
|
||||
@@ -50,7 +51,12 @@ const RangeFilterContainer = styled.div`
|
||||
`;
|
||||
|
||||
function DateRangeFilter(
|
||||
{ Header, initialValue, onSubmit }: DateRangeFilterProps,
|
||||
{
|
||||
Header,
|
||||
initialValue,
|
||||
onSubmit,
|
||||
dateFilterValueType = 'unix',
|
||||
}: DateRangeFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
|
||||
@@ -85,11 +91,14 @@ function DateRangeFilter(
|
||||
onSubmit([]);
|
||||
return;
|
||||
}
|
||||
const changeValue = [
|
||||
dayjsRange[0]?.valueOf() ?? 0,
|
||||
dayjsRange[1]?.valueOf() ?? 0,
|
||||
] as ValueState;
|
||||
setValue(changeValue);
|
||||
const changeValue =
|
||||
dateFilterValueType === 'iso'
|
||||
? [dayjsRange[0].toISOString(), dayjsRange[1].toISOString()]
|
||||
: [
|
||||
dayjsRange[0]?.valueOf() ?? 0,
|
||||
dayjsRange[1]?.valueOf() ?? 0,
|
||||
];
|
||||
setValue(changeValue as ValueState);
|
||||
onSubmit(changeValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 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 { useState, forwardRef, useImperativeHandle, RefObject } from 'react';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { InputNumber } from 'src/components/Input';
|
||||
import { FormLabel } from 'src/components/Form';
|
||||
import { BaseFilter, FilterHandler } from './Base';
|
||||
|
||||
const RangeFilterContainer = styled.div`
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 360px;
|
||||
`;
|
||||
|
||||
const InputContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledDivider = styled.span`
|
||||
margin: 0 ${({ theme }) => theme.gridUnit * 2}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
font-weight: ${({ theme }) => theme.typography.weights.bold};
|
||||
font-size: ${({ theme }) => theme.typography.sizes.m}px;
|
||||
`;
|
||||
|
||||
const ErrorMessage = styled.div`
|
||||
color: ${({ theme }) => theme.colors.error.base};
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
font-weight: ${({ theme }) => theme.typography.weights.bold};
|
||||
position: absolute;
|
||||
bottom: -50%;
|
||||
left: 0;
|
||||
`;
|
||||
|
||||
interface NumericalRangeFilterProps extends BaseFilter {
|
||||
onSubmit: (val: [number | null, number | null]) => void;
|
||||
name: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
function NumericalRangeFilter(
|
||||
{ Header, initialValue, onSubmit }: NumericalRangeFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const [value, setValue] = useState<[number | null, number | null]>(
|
||||
initialValue ?? [null, null],
|
||||
);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const handleMinChange = (newMin: number | null) => {
|
||||
const newValue: [number | null, number | null] = [newMin, value[1]];
|
||||
setValue(newValue);
|
||||
|
||||
if (newMin !== null && value[1] !== null && newMin >= value[1]) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasError(false);
|
||||
onSubmit(newValue);
|
||||
};
|
||||
const handleMaxChange = (newMax: number | null) => {
|
||||
const newValue: [number | null, number | null] = [value[0], newMax];
|
||||
setValue(newValue);
|
||||
|
||||
if (value[0] !== null && newMax !== null && value[0] >= newMax) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasError(false);
|
||||
onSubmit(newValue);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setValue([null, null]);
|
||||
setHasError(false);
|
||||
onSubmit([null, null]);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<RangeFilterContainer>
|
||||
<FormLabel>{Header}</FormLabel>
|
||||
<InputContainer>
|
||||
<InputNumber
|
||||
value={value[0]}
|
||||
onChange={handleMinChange}
|
||||
placeholder={t('Value greater than')}
|
||||
style={{ width: '100%' }}
|
||||
data-test="numerical-filter-min-input"
|
||||
/>
|
||||
<StyledDivider>-</StyledDivider>
|
||||
<InputNumber
|
||||
value={value[1]}
|
||||
onChange={handleMaxChange}
|
||||
placeholder={t('Value less than')}
|
||||
style={{ width: '100%' }}
|
||||
data-test="numerical-filter-max-input"
|
||||
/>
|
||||
{hasError && (
|
||||
<ErrorMessage>
|
||||
{t('Minimum must be strictly less than maximum')}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
</InputContainer>
|
||||
</RangeFilterContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(NumericalRangeFilter);
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import SearchFilter from './Search';
|
||||
import SelectFilter from './Select';
|
||||
import DateRangeFilter from './DateRange';
|
||||
import NumericalRangeFilter from './NumericalRange';
|
||||
import { FilterHandler } from './Base';
|
||||
|
||||
interface UIFiltersProps {
|
||||
@@ -76,6 +77,9 @@ function UIFilters(
|
||||
toolTipDescription,
|
||||
onFilterUpdate,
|
||||
loading,
|
||||
dateFilterValueType,
|
||||
min,
|
||||
max,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
@@ -136,6 +140,21 @@ function UIFilters(
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
dateFilterValueType={dateFilterValueType || 'unix'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'numerical_range') {
|
||||
return (
|
||||
<NumericalRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
min={min}
|
||||
max={max}
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export interface Filter {
|
||||
| 'select'
|
||||
| 'checkbox'
|
||||
| 'search'
|
||||
| 'datetime_range';
|
||||
| 'datetime_range'
|
||||
| 'numerical_range';
|
||||
unfilteredLabel?: string;
|
||||
selects?: SelectOption[];
|
||||
onFilterOpen?: () => void;
|
||||
@@ -60,6 +61,9 @@ export interface Filter {
|
||||
) => Promise<{ data: SelectOption[]; totalCount: number }>;
|
||||
paginate?: boolean;
|
||||
loading?: boolean;
|
||||
dateFilterValueType?: 'unix' | 'iso';
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export type Filters = Filter[];
|
||||
@@ -74,7 +78,8 @@ export type InnerFilterValue =
|
||||
| undefined
|
||||
| string[]
|
||||
| number[]
|
||||
| { label: string; value: string | number };
|
||||
| { label: string; value: string | number }
|
||||
| [number | null, number | null];
|
||||
|
||||
export interface FilterValue {
|
||||
id: string;
|
||||
|
||||
@@ -23,8 +23,6 @@ import { styled, useCSSTextTruncation } from '@superset-ui/core';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
import { CustomCloseIcon } from '../Tags/Tag';
|
||||
import { CustomTagProps } from './types';
|
||||
import { SELECT_ALL_VALUE } from './utils';
|
||||
import { NoElement } from './styles';
|
||||
|
||||
const StyledTag = styled(AntdTag)`
|
||||
& .ant-tag-close-icon {
|
||||
@@ -61,7 +59,7 @@ const Tag = (props: any) => {
|
||||
* Custom tag renderer
|
||||
*/
|
||||
export const customTagRender = (props: CustomTagProps) => {
|
||||
const { label, value } = props;
|
||||
const { label } = props;
|
||||
|
||||
const onPreventMouseDown = (event: MouseEvent<HTMLElement>) => {
|
||||
// if close icon is clicked, stop propagation to avoid opening the dropdown
|
||||
@@ -76,12 +74,9 @@ export const customTagRender = (props: CustomTagProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (value !== SELECT_ALL_VALUE) {
|
||||
return (
|
||||
<Tag onMouseDown={onPreventMouseDown} {...(props as object)}>
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return <NoElement />;
|
||||
return (
|
||||
<Tag onMouseDown={onPreventMouseDown} {...(props as object)}>
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import Select from 'src/components/Select/Select';
|
||||
import { SELECT_ALL_VALUE } from './utils';
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
@@ -77,9 +76,6 @@ const defaultProps = {
|
||||
showSearch: true,
|
||||
};
|
||||
|
||||
const selectAllOptionLabel = (numOptions: number) =>
|
||||
`${String(SELECT_ALL_VALUE)} (${numOptions})`;
|
||||
|
||||
const getElementByClassName = (className: string) =>
|
||||
document.querySelector(className)! as HTMLElement;
|
||||
|
||||
@@ -88,6 +84,9 @@ const getElementsByClassName = (className: string) =>
|
||||
|
||||
const getSelect = () => screen.getByRole('combobox', { name: ARIA_LABEL });
|
||||
|
||||
const selectAllButtonText = (length: number) => `Select all (${length})`;
|
||||
const deselectAllButtonText = (length: number) => `Deselect all (${length})`;
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
@@ -110,11 +109,6 @@ const findSelectValue = () =>
|
||||
const findAllSelectValues = () =>
|
||||
waitFor(() => [...getElementsByClassName('.ant-select-selection-item')]);
|
||||
|
||||
const findAllCheckedValues = () =>
|
||||
waitFor(() => [
|
||||
...getElementsByClassName('.ant-select-item-option-selected'),
|
||||
]);
|
||||
|
||||
const clearAll = () => userEvent.click(screen.getByLabelText('close-circle'));
|
||||
|
||||
const matchOrder = async (expectedLabels: string[]) => {
|
||||
@@ -255,33 +249,22 @@ test('should sort selected to the top when in multi mode', async () => {
|
||||
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(labels[2]));
|
||||
expect(
|
||||
await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
|
||||
).toBe(true);
|
||||
expect(await matchOrder(labels)).toBe(true);
|
||||
|
||||
await reopen();
|
||||
labels = labels.splice(2, 1).concat(labels);
|
||||
expect(
|
||||
await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
|
||||
).toBe(true);
|
||||
expect(await matchOrder(labels)).toBe(true);
|
||||
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(labels[5]));
|
||||
await reopen();
|
||||
labels = [labels.splice(0, 1)[0], labels.splice(4, 1)[0]].concat(labels);
|
||||
expect(
|
||||
await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
|
||||
).toBe(true);
|
||||
expect(await matchOrder(labels)).toBe(true);
|
||||
|
||||
// should revert to original order
|
||||
clearAll();
|
||||
await reopen();
|
||||
expect(
|
||||
await matchOrder([
|
||||
selectAllOptionLabel(originalLabels.length),
|
||||
...originalLabels,
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(await matchOrder(originalLabels)).toBe(true);
|
||||
});
|
||||
|
||||
test('searches for label or value', async () => {
|
||||
@@ -634,15 +617,19 @@ test('finds an element with a numeric value and does not duplicate the options',
|
||||
test('render "Select all" for multi select', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" options={OPTIONS} />);
|
||||
await open();
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent(selectAllOptionLabel(OPTIONS.length));
|
||||
expect(
|
||||
await screen.findByText(selectAllButtonText(OPTIONS.length)),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render "Select all" for single select', async () => {
|
||||
render(<Select {...defaultProps} options={OPTIONS} mode="single" />);
|
||||
await open();
|
||||
expect(
|
||||
screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
|
||||
screen.queryByText(selectAllButtonText(OPTIONS.length)),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(selectAllButtonText(OPTIONS.length)),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -650,45 +637,21 @@ test('does not render "Select all" for an empty multiple select', async () => {
|
||||
render(<Select {...defaultProps} options={[]} mode="multiple" />);
|
||||
await open();
|
||||
expect(
|
||||
screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
|
||||
screen.queryByText(selectAllButtonText(OPTIONS.length)),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render "Select all" when searching', async () => {
|
||||
test('Renders "Select all" when searching', async () => {
|
||||
render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
|
||||
await open();
|
||||
await type('Select');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
|
||||
screen.queryByText(selectAllButtonText(OPTIONS.length)),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not render "Select all" as one of the tags after selection', async () => {
|
||||
render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
|
||||
const values = await findAllSelectValues();
|
||||
expect(values[0]).not.toHaveTextContent(selectAllOptionLabel(OPTIONS.length));
|
||||
});
|
||||
|
||||
test('keeps "Select all" at the top after a selection', async () => {
|
||||
const selected = OPTIONS[2];
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={OPTIONS.slice(0, 10)}
|
||||
mode="multiple"
|
||||
value={[selected]}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent(selectAllOptionLabel(10));
|
||||
expect(options[1]).toHaveTextContent(selected.label);
|
||||
});
|
||||
|
||||
test('selects all values', async () => {
|
||||
render(
|
||||
<Select
|
||||
@@ -699,7 +662,7 @@ test('selects all values', async () => {
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
|
||||
userEvent.click(await screen.findByText(selectAllButtonText(OPTIONS.length)));
|
||||
const values = await findAllSelectValues();
|
||||
expect(values.length).toBe(1);
|
||||
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
|
||||
@@ -715,33 +678,17 @@ test('unselects all values', async () => {
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
|
||||
userEvent.click(await screen.findByText(selectAllButtonText(OPTIONS.length)));
|
||||
let values = await findAllSelectValues();
|
||||
expect(values.length).toBe(1);
|
||||
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
|
||||
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
|
||||
userEvent.click(
|
||||
await screen.findByText(deselectAllButtonText(OPTIONS.length)),
|
||||
);
|
||||
values = await findAllSelectValues();
|
||||
expect(values.length).toBe(0);
|
||||
});
|
||||
|
||||
test('deselecting a value also deselects "Select all"', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={OPTIONS.slice(0, 10)}
|
||||
mode="multiple"
|
||||
maxTagCount={0}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
|
||||
let values = await findAllCheckedValues();
|
||||
expect(values[0]).toHaveTextContent(selectAllOptionLabel(10));
|
||||
userEvent.click(await findSelectOption(OPTIONS[0].label));
|
||||
values = await findAllCheckedValues();
|
||||
expect(values[0]).not.toHaveTextContent(selectAllOptionLabel(10));
|
||||
});
|
||||
|
||||
test('deselecting a new value also removes it from the options', async () => {
|
||||
render(
|
||||
<Select
|
||||
@@ -760,27 +707,6 @@ test('deselecting a new value also removes it from the options', async () => {
|
||||
expect(await querySelectOption(NEW_OPTION)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('selecting all values also selects "Select all"', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={OPTIONS.slice(0, 10)}
|
||||
mode="multiple"
|
||||
maxTagCount={0}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
const options = await findAllSelectOptions();
|
||||
options.forEach((option, index) => {
|
||||
// skip select all
|
||||
if (index > 0) {
|
||||
userEvent.click(option);
|
||||
}
|
||||
});
|
||||
const values = await findAllSelectValues();
|
||||
expect(values[0]).toHaveTextContent(`+ 10 ...`);
|
||||
});
|
||||
|
||||
test('Renders only 1 tag and an overflow tag in oneLine mode', () => {
|
||||
render(
|
||||
<Select
|
||||
@@ -829,56 +755,12 @@ test('Renders only an overflow tag if dropdown is open in oneLine mode', async (
|
||||
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
|
||||
});
|
||||
|
||||
test('+N tag does not count the "Select All" option', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={OPTIONS.slice(0, 10)}
|
||||
mode="multiple"
|
||||
maxTagCount={0}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
|
||||
const values = await findAllSelectValues();
|
||||
// maxTagCount is 0 so the +N tag should be + 10 ...
|
||||
expect(values[0]).toHaveTextContent('+ 10 ...');
|
||||
});
|
||||
|
||||
test('"Select All" is checked when unchecking a newly added option and all the other options are still selected', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={OPTIONS.slice(0, 10)}
|
||||
mode="multiple"
|
||||
allowNewOptions
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
|
||||
expect(await findSelectOption(selectAllOptionLabel(10))).toBeInTheDocument();
|
||||
// add a new option
|
||||
await type(NEW_OPTION);
|
||||
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
|
||||
clearTypedText();
|
||||
expect(await findSelectOption(selectAllOptionLabel(11))).toBeInTheDocument();
|
||||
// select all should be selected
|
||||
let values = await findAllCheckedValues();
|
||||
expect(values[0]).toHaveTextContent(selectAllOptionLabel(11));
|
||||
// remove new option
|
||||
userEvent.click(await findSelectOption(NEW_OPTION));
|
||||
// select all should still be selected
|
||||
values = await findAllCheckedValues();
|
||||
expect(values[0]).toHaveTextContent(selectAllOptionLabel(10));
|
||||
expect(await findSelectOption(selectAllOptionLabel(10))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render "Select All" when there are 0 or 1 options', async () => {
|
||||
test('does not render "Select all" when there are 0 or 1 options', async () => {
|
||||
const { rerender } = render(
|
||||
<Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,
|
||||
);
|
||||
await open();
|
||||
expect(screen.queryByText(selectAllOptionLabel(0))).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(selectAllButtonText(0))).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
@@ -888,7 +770,7 @@ test('does not render "Select All" when there are 0 or 1 options', async () => {
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
expect(screen.queryByText(selectAllOptionLabel(1))).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(selectAllButtonText(1))).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
@@ -898,10 +780,10 @@ test('does not render "Select All" when there are 0 or 1 options', async () => {
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
expect(screen.getByText(selectAllOptionLabel(2))).toBeInTheDocument();
|
||||
expect(screen.getByText(selectAllButtonText(2))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('do not count unselected disabled options in "Select All"', async () => {
|
||||
test('do not count unselected disabled options in "Select all"', async () => {
|
||||
const options = [...OPTIONS];
|
||||
options[0].disabled = true;
|
||||
options[1].disabled = true;
|
||||
@@ -915,13 +797,39 @@ test('do not count unselected disabled options in "Select All"', async () => {
|
||||
);
|
||||
await open();
|
||||
// We have 2 options disabled but one is selected initially
|
||||
// Select All should count one and ignore the other
|
||||
// Select all should count one and ignore the other
|
||||
expect(
|
||||
screen.getByText(selectAllOptionLabel(OPTIONS.length - 1)),
|
||||
screen.getByText(selectAllButtonText(OPTIONS.length - 1)),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Select All" does not affect disabled options', async () => {
|
||||
test('"Deselect all" counts all selected options', async () => {
|
||||
render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption('Ava'));
|
||||
expect(await screen.findByText(deselectAllButtonText(1))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Deselect all" counts new selected options', async () => {
|
||||
render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
userEvent.click(await findSelectOption(NEW_OPTION));
|
||||
clearTypedText();
|
||||
await open();
|
||||
userEvent.click(await findSelectOption('Ava'));
|
||||
expect(await screen.findByText(deselectAllButtonText(2))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Select all" does not count unselected new options', async () => {
|
||||
render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
|
||||
await open();
|
||||
await type('er');
|
||||
// We have 5 options matching the search
|
||||
expect(await screen.findByText(selectAllButtonText(5))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Select all" does not affect disabled options', async () => {
|
||||
const options = [...OPTIONS];
|
||||
options[0].disabled = true;
|
||||
options[1].disabled = true;
|
||||
@@ -939,14 +847,14 @@ test('"Select All" does not affect disabled options', async () => {
|
||||
expect(await findSelectValue()).toHaveTextContent(options[0].label);
|
||||
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
|
||||
|
||||
// Checking Select All shouldn't affect the disabled options
|
||||
const selectAll = selectAllOptionLabel(OPTIONS.length - 1);
|
||||
userEvent.click(await findSelectOption(selectAll));
|
||||
// Checking Select all shouldn't affect the disabled options
|
||||
const selectAll = selectAllButtonText(OPTIONS.length - 1);
|
||||
userEvent.click(await screen.findByText(selectAll));
|
||||
expect(await findSelectValue()).toHaveTextContent(options[0].label);
|
||||
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
|
||||
|
||||
// Unchecking Select All shouldn't affect the disabled options
|
||||
userEvent.click(await findSelectOption(selectAll));
|
||||
// Unchecking Select all shouldn't affect the disabled options
|
||||
userEvent.click(await screen.findByText(selectAll));
|
||||
expect(await findSelectValue()).toHaveTextContent(options[0].label);
|
||||
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
|
||||
});
|
||||
|
||||
@@ -28,15 +28,9 @@ import {
|
||||
ClipboardEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
ensureIsArray,
|
||||
formatNumber,
|
||||
NumberFormats,
|
||||
t,
|
||||
usePrevious,
|
||||
} from '@superset-ui/core';
|
||||
import { ensureIsArray, t, usePrevious } from '@superset-ui/core';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import AntdSelect, { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; // TODO: Remove antd
|
||||
import { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; // TODO: Remove antd
|
||||
import { debounce, isEqual, uniq } from 'lodash';
|
||||
import { FAST_DEBOUNCE } from 'src/constants';
|
||||
import {
|
||||
@@ -49,8 +43,6 @@ import {
|
||||
handleFilterOptionHelper,
|
||||
dropDownRenderHelper,
|
||||
getSuffixIcon,
|
||||
SELECT_ALL_VALUE,
|
||||
selectAllOption,
|
||||
mapValues,
|
||||
mapOptions,
|
||||
hasCustomLabels,
|
||||
@@ -60,6 +52,7 @@ import {
|
||||
} from './utils';
|
||||
import { RawValue, SelectOptionsType, SelectProps } from './types';
|
||||
import {
|
||||
StyledBulkActionsContainer,
|
||||
StyledCheckOutlined,
|
||||
StyledContainer,
|
||||
StyledHeader,
|
||||
@@ -73,6 +66,7 @@ import {
|
||||
DEFAULT_SORT_COMPARATOR,
|
||||
} from './constants';
|
||||
import { customTagRender } from './CustomTag';
|
||||
import Button from '../Button';
|
||||
|
||||
/**
|
||||
* This component is a customized version of the Antdesign 4.X Select component
|
||||
@@ -89,6 +83,7 @@ import { customTagRender } from './CustomTag';
|
||||
const Select = forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
allowClear,
|
||||
allowNewOptions = false,
|
||||
allowSelectAll = true,
|
||||
@@ -121,6 +116,7 @@ const Select = forwardRef(
|
||||
getPopupContainer,
|
||||
oneLine,
|
||||
maxTagCount: propsMaxTagCount,
|
||||
|
||||
...props
|
||||
}: SelectProps,
|
||||
ref: RefObject<HTMLInputElement>,
|
||||
@@ -131,12 +127,13 @@ const Select = forwardRef(
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(loading);
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [visibleOptions, setVisibleOptions] = useState<SelectOptionsType>([]);
|
||||
const [maxTagCount, setMaxTagCount] = useState(
|
||||
propsMaxTagCount ?? MAX_TAG_COUNT,
|
||||
);
|
||||
const [onChangeCount, setOnChangeCount] = useState(0);
|
||||
const previousChangeCount = usePrevious(onChangeCount, 0);
|
||||
|
||||
const fireOnChange = useCallback(
|
||||
() => setOnChangeCount(onChangeCount + 1),
|
||||
[onChangeCount],
|
||||
@@ -150,8 +147,6 @@ const Select = forwardRef(
|
||||
|
||||
const mappedMode = isSingleMode ? undefined : 'multiple';
|
||||
|
||||
const { Option } = AntdSelect;
|
||||
|
||||
const sortSelectedFirst = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
sortSelectedFirstHelper(a, b, selectValue),
|
||||
@@ -202,20 +197,22 @@ const Select = forwardRef(
|
||||
missingValues.length > 0
|
||||
? missingValues.concat(selectOptions)
|
||||
: selectOptions;
|
||||
return result.filter(opt => opt.value !== SELECT_ALL_VALUE);
|
||||
}, [selectOptions, selectValue]);
|
||||
return result.slice().sort(sortSelectedFirst);
|
||||
}, [selectOptions, selectValue, sortSelectedFirst]);
|
||||
|
||||
const enabledOptions = useMemo(
|
||||
() => fullSelectOptions.filter(option => !option.disabled),
|
||||
[fullSelectOptions],
|
||||
() => visibleOptions.filter(option => !option.disabled),
|
||||
[visibleOptions],
|
||||
);
|
||||
|
||||
const selectAllEligible = useMemo(
|
||||
() =>
|
||||
fullSelectOptions.filter(
|
||||
option => hasOption(option.value, selectValue) || !option.disabled,
|
||||
visibleOptions.filter(
|
||||
option =>
|
||||
(hasOption(option.value, selectValue) || !option.disabled) &&
|
||||
!option.isNewOption,
|
||||
),
|
||||
[fullSelectOptions, selectValue],
|
||||
[visibleOptions, selectValue],
|
||||
);
|
||||
|
||||
const selectAllEnabled = useMemo(
|
||||
@@ -223,14 +220,12 @@ const Select = forwardRef(
|
||||
!isSingleMode &&
|
||||
allowSelectAll &&
|
||||
selectOptions.length > 0 &&
|
||||
enabledOptions.length > 1 &&
|
||||
!inputValue,
|
||||
enabledOptions.length > 1,
|
||||
[
|
||||
isSingleMode,
|
||||
allowSelectAll,
|
||||
selectOptions.length,
|
||||
enabledOptions.length,
|
||||
inputValue,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -239,6 +234,31 @@ const Select = forwardRef(
|
||||
[selectValue, selectAllEligible],
|
||||
);
|
||||
|
||||
const bulkSelectCounts = useMemo(() => {
|
||||
const selectedValuesSet = new Set(
|
||||
ensureIsArray(selectValue).map(getValue),
|
||||
);
|
||||
return visibleOptions.reduce(
|
||||
(acc, option) => {
|
||||
const isSelected = selectedValuesSet.has(option.value);
|
||||
const isDisabled = option.disabled;
|
||||
const isNew = option.isNewOption;
|
||||
|
||||
if (
|
||||
(!isDisabled || isSelected) &&
|
||||
((isNew && isSelected) || !isNew)
|
||||
) {
|
||||
acc.selectable += 1;
|
||||
}
|
||||
if (isSelected && !isDisabled) {
|
||||
acc.deselectable += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ selectable: 0, deselectable: 0 },
|
||||
);
|
||||
}, [visibleOptions, selectValue]);
|
||||
|
||||
const handleOnSelect: SelectProps['onSelect'] = (selectedItem, option) => {
|
||||
if (isSingleMode) {
|
||||
// on select is fired in single value mode if the same value is selected
|
||||
@@ -255,19 +275,6 @@ const Select = forwardRef(
|
||||
setSelectValue(previousState => {
|
||||
const array = ensureIsArray(previousState);
|
||||
const value = getValue(selectedItem);
|
||||
// Tokenized values can contain duplicated values
|
||||
if (value === getValue(SELECT_ALL_VALUE)) {
|
||||
if (isLabeledValue(selectedItem)) {
|
||||
return [
|
||||
...selectAllEligible,
|
||||
selectAllOption,
|
||||
] as AntdLabeledValue[];
|
||||
}
|
||||
return [
|
||||
SELECT_ALL_VALUE,
|
||||
...selectAllEligible.map(opt => opt.value),
|
||||
] as AntdLabeledValue[];
|
||||
}
|
||||
if (!hasOption(value, array)) {
|
||||
const result = [...array, selectedItem];
|
||||
if (
|
||||
@@ -275,8 +282,8 @@ const Select = forwardRef(
|
||||
selectAllEnabled
|
||||
) {
|
||||
return isLabeledValue(selectedItem)
|
||||
? ([...result, selectAllOption] as AntdLabeledValue[])
|
||||
: ([...result, SELECT_ALL_VALUE] as (string | number)[]);
|
||||
? ([...result] as AntdLabeledValue[])
|
||||
: ([...result] as (string | number)[]);
|
||||
}
|
||||
return result as AntdLabeledValue[];
|
||||
}
|
||||
@@ -308,37 +315,33 @@ const Select = forwardRef(
|
||||
|
||||
const handleOnDeselect: SelectProps['onDeselect'] = (value, option) => {
|
||||
if (Array.isArray(selectValue)) {
|
||||
if (getValue(value) === getValue(SELECT_ALL_VALUE)) {
|
||||
clear();
|
||||
} else {
|
||||
let array = selectValue as AntdLabeledValue[];
|
||||
array = array.filter(
|
||||
element => getValue(element) !== getValue(value),
|
||||
);
|
||||
// if this was not a new item, deselect select all option
|
||||
if (selectAllMode && !option.isNewOption) {
|
||||
array = array.filter(
|
||||
element => getValue(element) !== SELECT_ALL_VALUE,
|
||||
);
|
||||
}
|
||||
setSelectValue(array);
|
||||
const array = (selectValue as AntdLabeledValue[]).filter(
|
||||
element => getValue(element) !== getValue(value),
|
||||
);
|
||||
setSelectValue(array);
|
||||
|
||||
// removes new option
|
||||
if (option.isNewOption) {
|
||||
setSelectOptions(
|
||||
fullSelectOptions.filter(
|
||||
option => getValue(option.value) !== getValue(value),
|
||||
),
|
||||
);
|
||||
}
|
||||
// removes new option
|
||||
if (option.isNewOption) {
|
||||
const updatedOptions = fullSelectOptions.filter(
|
||||
option => getValue(option.value) !== getValue(value),
|
||||
);
|
||||
setSelectOptions(updatedOptions);
|
||||
setVisibleOptions(updatedOptions);
|
||||
}
|
||||
}
|
||||
fireOnChange();
|
||||
onDeselect?.(value, option);
|
||||
};
|
||||
|
||||
const handleFilterOption = (search: string, option: AntdLabeledValue) =>
|
||||
handleFilterOptionHelper(search, option, optionFilterProps, filterOption);
|
||||
|
||||
const handleOnSearch = debounce((search: string) => {
|
||||
const searchValue = search.trim();
|
||||
setIsSearching(!!searchValue);
|
||||
|
||||
let updatedOptions = selectOptions;
|
||||
|
||||
if (allowNewOptions) {
|
||||
const newOption = searchValue &&
|
||||
!hasOption(searchValue, fullSelectOptions, true) && {
|
||||
@@ -349,23 +352,27 @@ const Select = forwardRef(
|
||||
const cleanSelectOptions = ensureIsArray(fullSelectOptions).filter(
|
||||
opt => !opt.isNewOption || hasOption(opt.value, selectValue),
|
||||
);
|
||||
const newOptions = newOption
|
||||
updatedOptions = newOption
|
||||
? [newOption, ...cleanSelectOptions]
|
||||
: cleanSelectOptions;
|
||||
setSelectOptions(newOptions);
|
||||
setSelectOptions(updatedOptions);
|
||||
}
|
||||
|
||||
const filteredOptions = updatedOptions.filter(
|
||||
(option: AntdLabeledValue) => handleFilterOption(search, option),
|
||||
);
|
||||
|
||||
setVisibleOptions(filteredOptions);
|
||||
setInputValue(searchValue);
|
||||
onSearch?.(searchValue);
|
||||
}, FAST_DEBOUNCE);
|
||||
|
||||
useEffect(() => () => handleOnSearch.cancel(), [handleOnSearch]);
|
||||
|
||||
const handleFilterOption = (search: string, option: AntdLabeledValue) =>
|
||||
handleFilterOptionHelper(search, option, optionFilterProps, filterOption);
|
||||
|
||||
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
||||
setIsDropdownVisible(isDropdownVisible);
|
||||
|
||||
setVisibleOptions(fullSelectOptions);
|
||||
// if no search input value, force sort options because it won't be sorted by
|
||||
// `filterSort`.
|
||||
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
|
||||
@@ -373,11 +380,101 @@ const Select = forwardRef(
|
||||
setSelectOptions(initialOptionsSorted);
|
||||
}
|
||||
}
|
||||
if (!isDropdownVisible) {
|
||||
setSelectOptions(initialOptionsSorted);
|
||||
}
|
||||
if (onDropdownVisibleChange) {
|
||||
onDropdownVisibleChange(isDropdownVisible);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (isSingleMode) return;
|
||||
|
||||
const optionsToSelect = isSearching
|
||||
? visibleOptions.filter(option => !option.isNewOption)
|
||||
: enabledOptions;
|
||||
|
||||
const currentValues = ensureIsArray(selectValue);
|
||||
const currentValuesSet = new Set(currentValues.map(getValue));
|
||||
|
||||
const newValues = [...currentValues] as AntdLabeledValue[];
|
||||
optionsToSelect.forEach(option => {
|
||||
if (!option.disabled && !currentValuesSet.has(option.value)) {
|
||||
if (labelInValue) {
|
||||
newValues.push({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
});
|
||||
} else {
|
||||
newValues.push(option.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setSelectValue(newValues);
|
||||
fireOnChange();
|
||||
}, [
|
||||
isSingleMode,
|
||||
isSearching,
|
||||
visibleOptions,
|
||||
enabledOptions,
|
||||
selectValue,
|
||||
labelInValue,
|
||||
fireOnChange,
|
||||
]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (isSingleMode) return;
|
||||
|
||||
const deselectionValues = new Set(enabledOptions.map(opt => opt.value));
|
||||
|
||||
const newValues = ensureIsArray(selectValue).filter(item => {
|
||||
const itemValue = getValue(item);
|
||||
return !deselectionValues.has(itemValue);
|
||||
}) as AntdLabeledValue[];
|
||||
|
||||
setSelectValue(newValues);
|
||||
fireOnChange();
|
||||
}, [isSingleMode, enabledOptions, selectValue, fireOnChange]);
|
||||
|
||||
const bulkSelectComponent = useMemo(
|
||||
() => (
|
||||
<StyledBulkActionsContainer size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
buttonSize="xsmall"
|
||||
disabled={bulkSelectCounts.selectable === 0}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{`${t('Select all')} (${bulkSelectCounts.selectable})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
buttonSize="xsmall"
|
||||
disabled={bulkSelectCounts.deselectable === 0}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{`${t('Deselect all')} (${bulkSelectCounts.deselectable})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
[
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
bulkSelectCounts.selectable,
|
||||
bulkSelectCounts.deselectable,
|
||||
],
|
||||
);
|
||||
|
||||
const dropdownRender = (
|
||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||
) =>
|
||||
@@ -387,6 +484,8 @@ const Select = forwardRef(
|
||||
isLoading,
|
||||
fullSelectOptions.length,
|
||||
helperText,
|
||||
undefined,
|
||||
selectAllEnabled ? bulkSelectComponent : undefined,
|
||||
);
|
||||
|
||||
const handleClear = () => {
|
||||
@@ -399,6 +498,7 @@ const Select = forwardRef(
|
||||
useEffect(() => {
|
||||
// when `options` list is updated from component prop, reset states
|
||||
setSelectOptions(initialOptions);
|
||||
setVisibleOptions(initialOptions);
|
||||
}, [initialOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -411,50 +511,6 @@ const Select = forwardRef(
|
||||
setSelectValue(value);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
// if all values are selected, add select all to value
|
||||
if (
|
||||
selectAllEnabled &&
|
||||
ensureIsArray(value).length === selectAllEligible.length
|
||||
) {
|
||||
setSelectValue(
|
||||
labelInValue
|
||||
? ([...ensureIsArray(value), selectAllOption] as AntdLabeledValue[])
|
||||
: ([...ensureIsArray(value), SELECT_ALL_VALUE] as RawValue[]),
|
||||
);
|
||||
}
|
||||
}, [labelInValue, selectAllEligible.length, selectAllEnabled, value]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectAll = ensureIsArray(selectValue).some(
|
||||
v => getValue(v) === SELECT_ALL_VALUE,
|
||||
);
|
||||
if (checkSelectAll && !selectAllMode) {
|
||||
const optionsToSelect = selectAllEligible.map(option =>
|
||||
labelInValue ? option : option.value,
|
||||
);
|
||||
optionsToSelect.push(labelInValue ? selectAllOption : SELECT_ALL_VALUE);
|
||||
setSelectValue(optionsToSelect);
|
||||
fireOnChange();
|
||||
}
|
||||
}, [
|
||||
selectValue,
|
||||
selectAllMode,
|
||||
labelInValue,
|
||||
selectAllEligible,
|
||||
fireOnChange,
|
||||
]);
|
||||
|
||||
const selectAllLabel = useMemo(
|
||||
() => () =>
|
||||
// TODO: localize
|
||||
`${SELECT_ALL_VALUE} (${formatNumber(
|
||||
NumberFormats.INTEGER,
|
||||
selectAllEligible.length,
|
||||
)})`,
|
||||
[selectAllEligible],
|
||||
);
|
||||
|
||||
const handleOnBlur = (event: FocusEvent<HTMLElement>) => {
|
||||
setInputValue('');
|
||||
onBlur?.(event);
|
||||
@@ -469,20 +525,6 @@ const Select = forwardRef(
|
||||
let newOptions = options;
|
||||
if (!isSingleMode) {
|
||||
if (
|
||||
ensureIsArray(newValues).some(
|
||||
val => getValue(val) === SELECT_ALL_VALUE,
|
||||
)
|
||||
) {
|
||||
// send all options to onchange if all are not currently there
|
||||
if (!selectAllMode) {
|
||||
newValues = mapValues(selectAllEligible, labelInValue);
|
||||
newOptions = mapOptions(selectAllEligible);
|
||||
} else {
|
||||
newValues = ensureIsArray(values).filter(
|
||||
(val: any) => getValue(val) !== SELECT_ALL_VALUE,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
ensureIsArray(values).length === selectAllEligible.length &&
|
||||
selectAllMode
|
||||
) {
|
||||
@@ -585,9 +627,29 @@ const Select = forwardRef(
|
||||
} else {
|
||||
const token = tokenSeparators.find(token => pastedText.includes(token));
|
||||
const array = token ? uniq(pastedText.split(token)) : [pastedText];
|
||||
|
||||
const newOptions: SelectOptionsType = [];
|
||||
|
||||
const values = array
|
||||
.map(item => getPastedTextValue(item))
|
||||
.map(item => {
|
||||
const option = getOption(item, fullSelectOptions, true);
|
||||
if (!option && allowNewOptions) {
|
||||
const newOption = {
|
||||
label: item,
|
||||
value: item,
|
||||
isNewOption: true,
|
||||
};
|
||||
newOptions.push(newOption);
|
||||
}
|
||||
return getPastedTextValue(item);
|
||||
})
|
||||
.filter(item => item !== undefined);
|
||||
|
||||
if (newOptions.length > 0) {
|
||||
const updatedOptions = [...fullSelectOptions, ...newOptions];
|
||||
setSelectOptions(updatedOptions);
|
||||
setVisibleOptions(updatedOptions);
|
||||
}
|
||||
if (labelInValue) {
|
||||
setSelectValue(previous => [
|
||||
...((previous || []) as AntdLabeledValue[]),
|
||||
@@ -604,7 +666,7 @@ const Select = forwardRef(
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer headerPosition={headerPosition}>
|
||||
<StyledContainer className={className} headerPosition={headerPosition}>
|
||||
{header && (
|
||||
<StyledHeader headerPosition={headerPosition}>{header}</StyledHeader>
|
||||
)}
|
||||
@@ -651,24 +713,13 @@ const Select = forwardRef(
|
||||
<StyledCheckOutlined iconSize="m" aria-label="check" />
|
||||
)
|
||||
}
|
||||
options={shouldRenderChildrenOptions ? undefined : fullSelectOptions}
|
||||
options={shouldRenderChildrenOptions ? undefined : visibleOptions}
|
||||
oneLine={oneLine}
|
||||
tagRender={customTagRender}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{selectAllEnabled && (
|
||||
<Option
|
||||
id="select-all"
|
||||
className="select-all"
|
||||
key={SELECT_ALL_VALUE}
|
||||
value={SELECT_ALL_VALUE}
|
||||
>
|
||||
{selectAllLabel()}
|
||||
</Option>
|
||||
)}
|
||||
{shouldRenderChildrenOptions &&
|
||||
renderSelectOptions(fullSelectOptions)}
|
||||
{shouldRenderChildrenOptions && renderSelectOptions(visibleOptions)}
|
||||
</StyledSelect>
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Icons } from 'src/components/Icons';
|
||||
import { Spin, Tag } from 'antd'; // TODO: Remove antd
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import AntdSelect from 'antd/lib/select'; // TODO: Remove antd
|
||||
import { Space } from '../Space';
|
||||
|
||||
export const StyledHeader = styled.span<{ headerPosition: string }>`
|
||||
${({ theme, headerPosition }) => `
|
||||
@@ -54,9 +55,6 @@ export const StyledSelect = styled(AntdSelect, {
|
||||
.ant-select-arrow .anticon:not(.ant-select-suffix) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.select-all {
|
||||
border-bottom: 1px solid ${theme.colors.grayscale.light3};
|
||||
}
|
||||
${
|
||||
oneLine &&
|
||||
`
|
||||
@@ -138,3 +136,19 @@ export const StyledErrorMessage = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
export const StyledBulkActionsContainer = styled(Space)`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.gridUnit}px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid ${theme.colors.grayscale.light3};
|
||||
.superset-button {
|
||||
color: ${theme.colors.primary.dark1};
|
||||
font-weight: ${theme.typography.weights.normal};
|
||||
}
|
||||
.superset-button:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -72,6 +72,10 @@ export type AntdExposedProps = Pick<
|
||||
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
|
||||
|
||||
export interface BaseSelectProps extends AntdExposedProps {
|
||||
/**
|
||||
* Optional CSS class name to apply to the select container
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* It enables the user to create new options.
|
||||
* Can be used with standard or async select types.
|
||||
|
||||
@@ -26,12 +26,6 @@ import { LabeledValue, RawValue, SelectOptionsType, V } from './types';
|
||||
|
||||
const { Option } = AntdSelect;
|
||||
|
||||
export const SELECT_ALL_VALUE: RawValue = 'Select All';
|
||||
export const selectAllOption = {
|
||||
value: SELECT_ALL_VALUE,
|
||||
label: String(SELECT_ALL_VALUE),
|
||||
};
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
value !== null &&
|
||||
@@ -158,6 +152,7 @@ export const dropDownRenderHelper = (
|
||||
optionsLength: number,
|
||||
helperText: string | undefined,
|
||||
errorComponent?: JSX.Element,
|
||||
bulkSelectComponents?: JSX.Element,
|
||||
) => {
|
||||
if (!isDropdownVisible) {
|
||||
originNode.ref?.current?.scrollTo({ top: 0 });
|
||||
@@ -174,6 +169,7 @@ export const dropDownRenderHelper = (
|
||||
<StyledHelperText role="note">{helperText}</StyledHelperText>
|
||||
)}
|
||||
{originNode}
|
||||
{bulkSelectComponents}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -657,8 +657,61 @@ export function setDirectPathToChild(path) {
|
||||
}
|
||||
|
||||
export const SET_ACTIVE_TAB = 'SET_ACTIVE_TAB';
|
||||
|
||||
function findTabsToRestore(tabId, prevTabId, dashboardState, dashboardLayout) {
|
||||
const { activeTabs: prevActiveTabs, inactiveTabs: prevInactiveTabs } =
|
||||
dashboardState;
|
||||
const { present: currentLayout } = dashboardLayout;
|
||||
const restoredTabs = [];
|
||||
const queue = [tabId];
|
||||
const visited = new Set();
|
||||
while (queue.length > 0) {
|
||||
const seek = queue.shift();
|
||||
if (!visited.has(seek)) {
|
||||
visited.add(seek);
|
||||
const found =
|
||||
prevInactiveTabs?.filter(inactiveTabId =>
|
||||
currentLayout[inactiveTabId]?.parents
|
||||
.filter(id => id.startsWith('TAB-'))
|
||||
.slice(-1)
|
||||
.includes(seek),
|
||||
) ?? [];
|
||||
restoredTabs.push(...found);
|
||||
queue.push(...found);
|
||||
}
|
||||
}
|
||||
const activeTabs = restoredTabs ? [tabId].concat(restoredTabs) : [tabId];
|
||||
const tabChanged = Boolean(prevTabId) && tabId !== prevTabId;
|
||||
const inactiveTabs = tabChanged
|
||||
? prevActiveTabs.filter(
|
||||
activeTabId =>
|
||||
activeTabId !== prevTabId &&
|
||||
currentLayout[activeTabId]?.parents.includes(prevTabId),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
activeTabs,
|
||||
inactiveTabs,
|
||||
};
|
||||
}
|
||||
|
||||
export function setActiveTab(tabId, prevTabId) {
|
||||
return { type: SET_ACTIVE_TAB, tabId, prevTabId };
|
||||
return (dispatch, getState) => {
|
||||
const { dashboardLayout, dashboardState } = getState();
|
||||
const { activeTabs, inactiveTabs } = findTabsToRestore(
|
||||
tabId,
|
||||
prevTabId,
|
||||
dashboardState,
|
||||
dashboardLayout,
|
||||
);
|
||||
|
||||
return dispatch({
|
||||
type: SET_ACTIVE_TAB,
|
||||
activeTabs,
|
||||
prevTabId,
|
||||
inactiveTabs,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Even though SET_ACTIVE_TABS is not being called from Superset's codebase,
|
||||
|
||||
@@ -150,6 +150,9 @@ const Chart = props => {
|
||||
const emitCrossFilters = useSelector(
|
||||
state => !!state.dashboardInfo.crossFiltersEnabled,
|
||||
);
|
||||
const maxRows = useSelector(
|
||||
state => state.dashboardInfo.common.conf.SQL_MAX_ROW,
|
||||
);
|
||||
const datasource = useSelector(
|
||||
state =>
|
||||
(chart &&
|
||||
@@ -360,9 +363,7 @@ const Chart = props => {
|
||||
is_cached: props.isCached,
|
||||
});
|
||||
exportChart({
|
||||
formData: isFullCSV
|
||||
? { ...formData, row_limit: props.maxRows }
|
||||
: formData,
|
||||
formData: isFullCSV ? { ...formData, row_limit: maxRows } : formData,
|
||||
resultType: isPivot ? 'post_processed' : 'full',
|
||||
resultFormat: format,
|
||||
force: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ const props = {
|
||||
height: 100,
|
||||
updateSliceName() {},
|
||||
// from redux
|
||||
maxRows: 666,
|
||||
maxRows: 500, // will be overwritten with SQL_MAX_ROW from conf
|
||||
formData: chartQueries[queryId].form_data,
|
||||
datasource: mockDatasource[sliceEntities.slices[queryId].datasource],
|
||||
sliceName: sliceEntities.slices[queryId].slice_name,
|
||||
@@ -78,7 +78,7 @@ const defaultState = {
|
||||
superset_can_explore: false,
|
||||
superset_can_share: false,
|
||||
superset_can_csv: false,
|
||||
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0 } },
|
||||
common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0, SQL_MAX_ROW: 666 } },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -109,7 +109,9 @@ const HorizontalOverflowFilterControlContainer = styled(
|
||||
}
|
||||
`;
|
||||
|
||||
const VerticalFormItem = styled(StyledFormItem)`
|
||||
const VerticalFormItem = styled(StyledFormItem)<{
|
||||
inverseSelection: boolean;
|
||||
}>`
|
||||
.ant-form-item-label {
|
||||
overflow: visible;
|
||||
label.ant-form-item-required:not(.ant-form-item-required-mark-optional) {
|
||||
@@ -118,9 +120,19 @@ const VerticalFormItem = styled(StyledFormItem)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-container {
|
||||
${({ inverseSelection }) =>
|
||||
inverseSelection &&
|
||||
`
|
||||
width: 140px;
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
const HorizontalFormItem = styled(StyledFormItem)`
|
||||
const HorizontalFormItem = styled(StyledFormItem)<{
|
||||
inverseSelection: boolean;
|
||||
}>`
|
||||
&& {
|
||||
margin-bottom: 0;
|
||||
align-items: center;
|
||||
@@ -142,7 +154,15 @@ const HorizontalFormItem = styled(StyledFormItem)`
|
||||
}
|
||||
|
||||
.ant-form-item-control {
|
||||
width: ${({ theme }) => theme.gridUnit * 41}px;
|
||||
width: ${({ inverseSelection }) => (inverseSelection ? 252 : 164)}px;
|
||||
}
|
||||
|
||||
.select-container {
|
||||
${({ inverseSelection, theme }) =>
|
||||
inverseSelection &&
|
||||
`
|
||||
width: 164px;
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -151,31 +171,41 @@ const HorizontalOverflowFormItem = VerticalFormItem;
|
||||
const useFilterControlDisplay = (
|
||||
orientation: FilterBarOrientation,
|
||||
overflow: boolean,
|
||||
inverseSelection: boolean,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (orientation === FilterBarOrientation.Horizontal) {
|
||||
if (overflow) {
|
||||
return {
|
||||
FilterControlContainer: HorizontalOverflowFilterControlContainer,
|
||||
FormItem: HorizontalOverflowFormItem,
|
||||
FormItem: (props: any) => (
|
||||
<HorizontalOverflowFormItem
|
||||
{...props}
|
||||
inverseSelection={inverseSelection}
|
||||
/>
|
||||
),
|
||||
FilterControlTitleBox: HorizontalOverflowFilterControlTitleBox,
|
||||
FilterControlTitle: HorizontalOverflowFilterControlTitle,
|
||||
};
|
||||
}
|
||||
return {
|
||||
FilterControlContainer: HorizontalFilterControlContainer,
|
||||
FormItem: HorizontalFormItem,
|
||||
FormItem: (props: any) => (
|
||||
<HorizontalFormItem {...props} inverseSelection={inverseSelection} />
|
||||
),
|
||||
FilterControlTitleBox: HorizontalFilterControlTitleBox,
|
||||
FilterControlTitle: HorizontalFilterControlTitle,
|
||||
};
|
||||
}
|
||||
return {
|
||||
FilterControlContainer: VerticalFilterControlContainer,
|
||||
FormItem: VerticalFormItem,
|
||||
FormItem: (props: any) => (
|
||||
<VerticalFormItem {...props} inverseSelection={inverseSelection} />
|
||||
),
|
||||
FilterControlTitleBox: VerticalFilterControlTitleBox,
|
||||
FilterControlTitle: VerticalFilterControlTitle,
|
||||
};
|
||||
}, [orientation, overflow]);
|
||||
}, [orientation, overflow, inverseSelection]);
|
||||
|
||||
const ToolTipContainer = styled.div`
|
||||
font-size: ${({ theme }) => theme.typography.sizes.m}px;
|
||||
@@ -243,13 +273,14 @@ const FilterControl = ({
|
||||
checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
|
||||
const validateStatus = isMissingRequiredValue ? 'error' : undefined;
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
const inverseSelection = !!filter.controlValues?.inverseSelection;
|
||||
|
||||
const {
|
||||
FilterControlContainer,
|
||||
FormItem,
|
||||
FilterControlTitleBox,
|
||||
FilterControlTitle,
|
||||
} = useFilterControlDisplay(orientation, overflow);
|
||||
} = useFilterControlDisplay(orientation, overflow, inverseSelection);
|
||||
|
||||
const label = useMemo(
|
||||
() => (
|
||||
|
||||
@@ -46,6 +46,7 @@ export const FilterTitle = styled.div`
|
||||
}
|
||||
}
|
||||
&.errored div, &.errored .warning {
|
||||
align-items: center;
|
||||
color: ${theme.colors.error.base};
|
||||
}
|
||||
`}
|
||||
@@ -120,7 +121,7 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
|
||||
{isRemoved ? t('(Removed)') : getFilterTitle(id)}
|
||||
</div>
|
||||
{!removedFilters[id] && isErrored && (
|
||||
<StyledWarning className="warning" />
|
||||
<StyledWarning className="warning" iconSize="s" />
|
||||
)}
|
||||
{isRemoved && (
|
||||
<span
|
||||
|
||||
@@ -99,6 +99,7 @@ export function ColumnSelect({
|
||||
'columns.column_name',
|
||||
'columns.is_dttm',
|
||||
'columns.type_generic',
|
||||
'columns.filterable',
|
||||
],
|
||||
})}`,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user