mirror of
https://github.com/apache/superset.git
synced 2026-06-14 20:19:24 +00:00
Compare commits
94 Commits
csp-frame
...
playwright
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
482bef1507 | ||
|
|
20c04a4663 | ||
|
|
782f5eab16 | ||
|
|
1234533c67 | ||
|
|
7f0c0aea94 | ||
|
|
d9dcbb68b7 | ||
|
|
98fba1eefe | ||
|
|
bad03b1e76 | ||
|
|
fcfafebb29 | ||
|
|
47e82b02ed | ||
|
|
a463d66c80 | ||
|
|
337da13ba7 | ||
|
|
4a3453999a | ||
|
|
58758de93d | ||
|
|
b4a8acc584 | ||
|
|
08f89690e9 | ||
|
|
f02899d38d | ||
|
|
86583f1121 | ||
|
|
26cbd71099 | ||
|
|
500ce7a02a | ||
|
|
6d8ceed10e | ||
|
|
68d65f727f | ||
|
|
f165785003 | ||
|
|
8e31c93119 | ||
|
|
4974c08f7d | ||
|
|
fa90ba976c | ||
|
|
35c3d8dfbc | ||
|
|
ee23815aff | ||
|
|
7c946ae3db | ||
|
|
3926f5c55c | ||
|
|
fdc03d4bf3 | ||
|
|
24f0aed8a7 | ||
|
|
00d2f577df | ||
|
|
c35fc71bc5 | ||
|
|
1b6d57c3f3 | ||
|
|
d089a96163 | ||
|
|
0b3fe3d60c | ||
|
|
0eeb184b6a | ||
|
|
8e7edce616 | ||
|
|
754201b3d0 | ||
|
|
925401b4e1 | ||
|
|
8368ea4094 | ||
|
|
e8a6fb24ae | ||
|
|
311b7a72dc | ||
|
|
aa496def53 | ||
|
|
aea4375255 | ||
|
|
9ab0a0179d | ||
|
|
3db613dab5 | ||
|
|
de5ca79805 | ||
|
|
aede3bb5ba | ||
|
|
408f84aea6 | ||
|
|
92c07aaf54 | ||
|
|
f405174fcf | ||
|
|
8c125d2553 | ||
|
|
fb8fca4c64 | ||
|
|
dc0c055518 | ||
|
|
09349cb1e7 | ||
|
|
ca29adb0cb | ||
|
|
1617bbbe71 | ||
|
|
de1dd53186 | ||
|
|
58672dfab6 | ||
|
|
4b5629d1c8 | ||
|
|
4ddc3f14ed | ||
|
|
400a8aec89 | ||
|
|
51489a75ce | ||
|
|
09772eeda0 | ||
|
|
78907d08cd | ||
|
|
d0a0d280a1 | ||
|
|
5d77ed3677 | ||
|
|
f68ee6ba67 | ||
|
|
a01560cfa1 | ||
|
|
7e06ce8eeb | ||
|
|
ccc0e3dbb2 | ||
|
|
bd48e87eeb | ||
|
|
e6bd03fe98 | ||
|
|
9252d835b8 | ||
|
|
35b5f8dcdc | ||
|
|
97518544ee | ||
|
|
1c934b474a | ||
|
|
9d1d396a9b | ||
|
|
c38ba1daa8 | ||
|
|
8727d321f3 | ||
|
|
9918f8868e | ||
|
|
3dcf85caef | ||
|
|
e437ae1f2f | ||
|
|
17ebbdd966 | ||
|
|
de0bd37a66 | ||
|
|
412587ad41 | ||
|
|
941907ed4e | ||
|
|
91fbc64327 | ||
|
|
79ff093b30 | ||
|
|
ff80d4f406 | ||
|
|
c846cd187c | ||
|
|
9a43a47e6a |
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -53,6 +53,6 @@ jobs:
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -25,14 +25,14 @@ little bit helps, and credit will always be given.
|
||||
|
||||
All developer and contribution documentation has moved to the Apache Superset Developer Portal:
|
||||
|
||||
**[📚 View the Developer Portal →](https://superset.apache.org/docs/developer-portal/)**
|
||||
**[📚 View the Developer Portal →](https://superset.apache.org/developer_portal/)**
|
||||
|
||||
The Developer Portal includes comprehensive guides for:
|
||||
- [Contributing Overview](https://superset.apache.org/docs/developer-portal/contributing/overview)
|
||||
- [Development Setup](https://superset.apache.org/docs/developer-portal/contributing/development-setup)
|
||||
- [Submitting Pull Requests](https://superset.apache.org/docs/developer-portal/contributing/submitting-pr)
|
||||
- [Contribution Guidelines](https://superset.apache.org/docs/developer-portal/contributing/guidelines)
|
||||
- [Code Review Process](https://superset.apache.org/docs/developer-portal/contributing/code-review)
|
||||
- [Development How-tos](https://superset.apache.org/docs/developer-portal/contributing/howtos)
|
||||
- [Contributing Overview](https://superset.apache.org/developer_portal/contributing/overview)
|
||||
- [Development Setup](https://superset.apache.org/developer_portal/contributing/development-setup)
|
||||
- [Submitting Pull Requests](https://superset.apache.org/developer_portal/contributing/submitting-pr)
|
||||
- [Contribution Guidelines](https://superset.apache.org/developer_portal/contributing/guidelines)
|
||||
- [Code Review Process](https://superset.apache.org/developer_portal/contributing/code-review)
|
||||
- [Development How-tos](https://superset.apache.org/developer_portal/contributing/howtos)
|
||||
|
||||
Source for the Developer Portal documentation is [located here](https://github.com/apache/superset/tree/master/docs/developer_portal).
|
||||
|
||||
@@ -49,6 +49,7 @@ are compatible with Superset.
|
||||
| [Apache Pinot](/docs/configuration/databases#apache-pinot) | `pip install pinotdb` | `pinot://BROKER:5436/query?server=http://CONTROLLER:5983/` |
|
||||
| [Apache Solr](/docs/configuration/databases#apache-solr) | `pip install sqlalchemy-solr` | `solr://{username}:{password}@{hostname}:{port}/{server_path}/{collection}` |
|
||||
| [Apache Spark SQL](/docs/configuration/databases#apache-spark-sql) | `pip install pyhive` | `hive://hive@{hostname}:{port}/{database}` |
|
||||
| [Arc (Basekick Labs)](/docs/configuration/databases#arc) | `pip install arc-superset-dialect` | `arc://{api_key}@{hostname}:{port}/{database}` |
|
||||
| [Ascend.io](/docs/configuration/databases#ascendio) | `pip install impyla` | `ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true` |
|
||||
| [Azure MS SQL](/docs/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://UserName@presetSQL:TestPassword@presetSQL.database.windows.net:1433/TestSchema` |
|
||||
| [ClickHouse](/docs/configuration/databases#clickhouse) | `pip install clickhouse-connect` | `clickhousedb://{username}:{password}@{hostname}:{port}/{database}` |
|
||||
@@ -1256,6 +1257,20 @@ The expected connection string is formatted as follows:
|
||||
hive://hive@{hostname}:{port}/{database}
|
||||
```
|
||||
|
||||
#### Arc
|
||||
|
||||
The recommended connector library is [arc-superset-dialect](https://pypi.org/project/arc-superset-dialect/). Install with `pip install arc-superset-dialect`.
|
||||
|
||||
The connection string looks like:
|
||||
|
||||
```
|
||||
arc://{api_key}@{hostname}:{port}/{database}
|
||||
```
|
||||
|
||||
##### Multi-Database Support
|
||||
|
||||
Arc supports multiple databases (schemas) within a single instance. In Superset, each Arc database appears as a schema in the SQL Lab, and cross-database queries are supported using `database.table` syntax.
|
||||
|
||||
#### SQL Server
|
||||
|
||||
The recommended connector library for SQL Server is [pymssql](https://github.com/pymssql/pymssql).
|
||||
|
||||
@@ -12,11 +12,13 @@ version: 1
|
||||
SQL Lab and Explore supports [Jinja templating](https://jinja.palletsprojects.com/en/2.11.x/) in queries.
|
||||
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in `superset_config.py`.
|
||||
|
||||
> #### ⚠️ Security Warning
|
||||
>
|
||||
> While powerful, this feature executes template code on the server. Within the Superset security model, this is **intended functionality**, as users with permissions to edit charts and virtual datasets are considered **trusted users**.
|
||||
>
|
||||
> If you grant these permissions to untrusted users, this feature can be exploited as a **Server-Side Template Injection (SSTI)** vulnerability. Do not enable `ENABLE_TEMPLATE_PROCESSING` unless you fully understand and accept the associated security risks.
|
||||
:::warning[Security Warning]
|
||||
|
||||
While powerful, this feature executes template code on the server. Within the Superset security model, this is **intended functionality**, as users with permissions to edit charts and virtual datasets are considered **trusted users**.
|
||||
|
||||
If you grant these permissions to untrusted users, this feature can be exploited as a **Server-Side Template Injection (SSTI)** vulnerability. Do not enable `ENABLE_TEMPLATE_PROCESSING` unless you fully understand and accept the associated security risks.
|
||||
|
||||
:::
|
||||
|
||||
When templating is enabled, python code can be embedded in virtual datasets and
|
||||
in Custom SQL in the filter and metric controls in Explore. By default, the following variables are
|
||||
@@ -182,7 +184,7 @@ The available validators and names can be found in
|
||||
|
||||
In this section, we'll walkthrough the pre-defined Jinja macros in Superset.
|
||||
|
||||
**Current Username**
|
||||
### Current Username
|
||||
|
||||
The `{{ current_username() }}` macro returns the `username` of the currently logged in user.
|
||||
|
||||
@@ -197,7 +199,7 @@ cache key by adding the following parameter to your Jinja code:
|
||||
{{ current_username(add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
**Current User ID**
|
||||
### Current User ID
|
||||
|
||||
The `{{ current_user_id() }}` macro returns the account ID of the currently logged in user.
|
||||
|
||||
@@ -212,7 +214,7 @@ cache key by adding the following parameter to your Jinja code:
|
||||
{{ current_user_id(add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
**Current User Email**
|
||||
### Current User Email
|
||||
|
||||
The `{{ current_user_email() }}` macro returns the email address of the currently logged in user.
|
||||
|
||||
@@ -227,7 +229,7 @@ cache key by adding the following parameter to your Jinja code:
|
||||
{{ current_user_email(add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
**Current User Roles**
|
||||
### Current User Roles
|
||||
|
||||
The `{{ current_user_roles() }}` macro returns an array of roles for the logged in user.
|
||||
|
||||
@@ -257,7 +259,7 @@ Will be rendered as:
|
||||
SELECT * FROM users WHERE role IN ('admin', 'viewer')
|
||||
```
|
||||
|
||||
**Current User RLS Rules**
|
||||
### Current User RLS Rules
|
||||
|
||||
The `{{ current_user_rls_rules() }}` macro returns an array of RLS rules applied to the current dataset for the logged in user.
|
||||
|
||||
@@ -265,7 +267,7 @@ If you have caching enabled in your Superset configuration, then the list of RLS
|
||||
by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a
|
||||
cache hit in the future and Superset can retrieve cached data.
|
||||
|
||||
**Custom URL Parameters**
|
||||
### Custom URL Parameters
|
||||
|
||||
The `{{ url_param('custom_variable') }}` macro lets you define arbitrary URL
|
||||
parameters and reference them in your SQL code.
|
||||
@@ -299,7 +301,7 @@ Here's a concrete example:
|
||||
WHERE country_code = 'US'
|
||||
```
|
||||
|
||||
**Explicitly Including Values in Cache Key**
|
||||
### Explicitly Including Values in Cache Key
|
||||
|
||||
The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the
|
||||
accumulated list of values used in the calculation of the cache key.
|
||||
@@ -311,7 +313,7 @@ in the cache key. You can gain more context
|
||||
Note that this function powers the caching of the `user_id` and `username` values
|
||||
in the `current_user_id()` and `current_username()` function calls (if you have caching enabled).
|
||||
|
||||
**Filter Values**
|
||||
### Filter Values
|
||||
|
||||
You can retrieve the value for a specific filter as a list using `{{ filter_values() }}`.
|
||||
|
||||
@@ -332,7 +334,7 @@ GROUP BY action
|
||||
|
||||
There `where_in` filter converts the list of values from `filter_values('action_type')` into a string suitable for an `IN` expression.
|
||||
|
||||
**Filters for a Specific Column**
|
||||
### Filters for a Specific Column
|
||||
|
||||
The `{{ get_filters() }}` macro returns the filters applied to a given column. In addition to
|
||||
returning the values (similar to how `filter_values()` does), the `get_filters()` macro
|
||||
@@ -394,7 +396,7 @@ Here's a concrete example:
|
||||
order by lineage, level
|
||||
```
|
||||
|
||||
**Time Filter**
|
||||
### Time Filter
|
||||
|
||||
The `{{ get_time_filter() }}` macro returns the time filter applied to a specific column. This is useful if you want
|
||||
to handle time filters inside the virtual dataset, as by default the time filter is placed on the outer query. This can
|
||||
@@ -469,7 +471,7 @@ WHERE
|
||||
AND dttm < {{ time_filter.to_expr }}
|
||||
```
|
||||
|
||||
**Datasets**
|
||||
### Datasets
|
||||
|
||||
It's possible to query physical and virtual datasets using the `dataset` macro. This is useful if you've defined computed columns and metrics on your datasets, and want to reuse the definition in adhoc SQL Lab queries.
|
||||
|
||||
@@ -493,7 +495,7 @@ Since metrics are aggregations, the resulting SQL expression will be grouped by
|
||||
SELECT * FROM {{ dataset(42, include_metrics=True, columns=["ds", "category"]) }} LIMIT 10
|
||||
```
|
||||
|
||||
**Metrics**
|
||||
### Metrics
|
||||
|
||||
The `{{ metric('metric_key', dataset_id) }}` macro can be used to retrieve the metric SQL syntax from a dataset. This can be useful for different purposes:
|
||||
|
||||
@@ -511,7 +513,7 @@ The parameter can be used in SQL Lab, or when fetching a metric from another dat
|
||||
|
||||
Superset supports [builtin filters from the Jinja2 templating package](https://jinja.palletsprojects.com/en/stable/templates/#builtin-filters). Custom filters have also been implemented:
|
||||
|
||||
**Where In**
|
||||
### Where In
|
||||
Parses a list into a SQL-compatible statement. This is useful with macros that return an array (for example the `filter_values` macro):
|
||||
|
||||
```
|
||||
@@ -528,7 +530,7 @@ Dashboard filter without any value applied
|
||||
{{ filter_values('column')|where_in(default_to_none=True) }} => None
|
||||
```
|
||||
|
||||
**To Datetime**
|
||||
### To Datetime
|
||||
|
||||
Loads a string as a `datetime` object. This is useful when performing date operations. For example:
|
||||
```
|
||||
|
||||
174
docs/docs/security/securing_superset.mdx
Normal file
174
docs/docs/security/securing_superset.mdx
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Securing Your Superset Installation for Production
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
> *This guide applies to Apache Superset version 4.0 and later and is an evolving set of best practices that administrators should adapt to their specific deployment architecture.*
|
||||
|
||||
The default Apache Superset configuration is optimized for ease of use and development, not for security. For any production deployment, it is **critical** that you review and apply the following security configurations to harden your instance, protect user data, and prevent unauthorized access.
|
||||
|
||||
This guide provides a comprehensive checklist of essential security configurations and best practices.
|
||||
|
||||
### **Critical Prerequisites: HTTPS/TLS Configuration**
|
||||
|
||||
Running Superset without HTTPS (TLS) is not secure. Without it, all network traffic—including user credentials, session tokens, and sensitive data—is sent in cleartext and can be easily intercepted.
|
||||
|
||||
* **Use a Reverse Proxy:** Your Superset instance should always be deployed behind a reverse proxy (e.g., Nginx, Traefik) or a load balancer (e.g., AWS ALB, Google Cloud Load Balancer) that is configured to handle HTTPS termination.
|
||||
* **Enforce Modern TLS:** Configure your proxy to enforce TLS 1.2 or higher with strong, industry-standard cipher suites.
|
||||
* **Implement HSTS:** Use the HTTP Strict Transport Security (HSTS) header to ensure browsers only connect to your Superset instance over HTTPS. This can be configured in your reverse proxy or within Superset's Talisman settings.
|
||||
|
||||
### **`SUPERSET_SECRET_KEY` Management (CRITICAL)**
|
||||
|
||||
This is the most critical security setting for your Superset instance. It is used to sign all session cookies and encrypt sensitive information in the metadata database, such as database connection credentials.
|
||||
|
||||
* **Generate a Unique, Strong Key:** A unique key must be generated for every Superset instance. Use a cryptographically secure method to create it.
|
||||
```bash
|
||||
# Example using openssl to generate a strong key
|
||||
openssl rand -base64 42
|
||||
```
|
||||
* **Store the Key Securely:** The key must be kept confidential. The recommended approach is to store it as an environment variable or in a secrets management system (e.g., AWS Secrets Manager, HashiCorp Vault). **Do not hardcode the key in `superset_config.py` or commit it to version control.**
|
||||
```python
|
||||
# In superset_config.py
|
||||
import os
|
||||
SECRET_KEY = os.environ.get('SUPERSET_SECRET_KEY')
|
||||
```
|
||||
|
||||
> #### ⚠️ Warning: Your `SUPERSET_SECRET_KEY` Must Be Unique
|
||||
>
|
||||
> **NEVER** reuse the same `SUPERSET_SECRET_KEY` across different environments (e.g., development, staging, production) or different Superset instances. Reusing a key allows cryptographically signed session cookies to be used across those instances, which can lead to a full authentication bypass if a cookie is compromised. Treat this key like a master password.
|
||||
|
||||
### **Session Management Security (CRITICAL)**
|
||||
|
||||
Properly configuring user sessions is essential to prevent session hijacking and ensure that sessions are terminated correctly.
|
||||
|
||||
#### **Use a Server-Side Session Backend (Strongly Recommended for Production)**
|
||||
|
||||
The default stateless cookie-based session handling presents challenges for immediate session invalidation upon logout. For all production deployments, we strongly recommend configuring an optional server-side session backend like Redis, Memcached, or a database. This ensures that session data is stored securely on the server and can be instantly destroyed upon logout, rendering any copied session cookies immediately useless.
|
||||
|
||||
**Example `superset_config.py` for Redis:**
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
from redis import Redis
|
||||
import os
|
||||
|
||||
# 1. Enable server-side sessions
|
||||
SESSION_SERVER_SIDE = True
|
||||
|
||||
# 2. Choose your backend (e.g., 'redis', 'memcached', 'filesystem', 'sqlalchemy')
|
||||
SESSION_TYPE = 'redis'
|
||||
|
||||
# 3. Configure your Redis connection
|
||||
# Use environment variables for sensitive details
|
||||
SESSION_REDIS = Redis(
|
||||
host=os.environ.get('REDIS_HOST', 'localhost'),
|
||||
port=int(os.environ.get('REDIS_PORT', 6379)),
|
||||
password=os.environ.get('REDIS_PASSWORD'),
|
||||
db=int(os.environ.get('REDIS_DB', 0)),
|
||||
ssl=os.environ.get('REDIS_SSL_ENABLED', 'True').lower() == 'true',
|
||||
ssl_cert_reqs='required' # Or another appropriate SSL setting
|
||||
)
|
||||
|
||||
# 4. Ensure the session cookie is signed for integrity
|
||||
SESSION_USE_SIGNER = True
|
||||
```
|
||||
|
||||
#### **Configure Session Lifetime and Cookie Security Flags**
|
||||
|
||||
This is mandatory for *all* deployments, whether stateless or server-side.
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
from datetime import timedelta
|
||||
|
||||
# Set a short absolute session timeout
|
||||
# The default is 31 days, which is NOT recommended for production.
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=8)
|
||||
|
||||
# Enforce secure cookie flags to prevent browser-based attacks
|
||||
SESSION_COOKIE_SECURE = True # Transmit cookie only over HTTPS
|
||||
SESSION_COOKIE_HTTPONLY = True # Prevent client-side JS from accessing the cookie
|
||||
SESSION_COOKIE_SAMESITE = 'Lax' # Provide protection against CSRF attacks
|
||||
```
|
||||
|
||||
> ##### Note on iFrame Embedding and `SESSION_COOKIE_SAMESITE`
|
||||
>The recommended default setting `'Lax'` provides good CSRF protection for most use cases. However, if you need to embed Superset dashboards into other applications using an iFrame, you will need to change this setting to `'None'`.
|
||||
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
|
||||
Setting SameSite to 'None' requires that SESSION_COOKIE_SECURE is also set to True. Be aware that this configuration disables some of the browser's built-in CSRF protections to allow for cross-domain functionality, so it should only be used when iFrame embedding is necessary.
|
||||
|
||||
### **Authentication and Authorization**
|
||||
|
||||
While Superset's built-in database authentication is convenient, for production it's highly recommended to integrate with an enterprise-grade identity provider (IdP).
|
||||
|
||||
* **Use an Enterprise IdP:** Configure authentication via OAuth or LDAP to leverage your organization's existing identity management system. This provides benefits like Single Sign-On (SSO), Multi-Factor Authentication (MFA), and centralized user provisioning/deprovisioning.
|
||||
* **Principle of Least Privilege:** Assign users to the most restrictive roles necessary for their jobs. Avoid over-provisioning users with Admin or Alpha roles, and ensure row-level security is applied where appropriate.
|
||||
* **Admin Accounts:** Delete or disable the default admin user after a new administrative account has been configured.
|
||||
|
||||
### **Content Security Policy (CSP) and Other Headers**
|
||||
|
||||
Superset can use Flask-Talisman to set security headers. However, it must be explicitly enabled.
|
||||
|
||||
> #### ⚠️ Important: Talisman is Disabled by Default
|
||||
>
|
||||
> In Superset 4.0 and later, Talisman is disabled by default (`TALISMAN_ENABLED = False`). You **must** explicitly enable it in your `superset_config.py` for the security headers defined in `TALISMAN_CONFIG` to take effect.
|
||||
|
||||
Here's the documentation section how how to set up Talisman: https://superset.apache.org/docs/security/#content-security-policy-csp
|
||||
|
||||
### **Database Security**
|
||||
|
||||
> #### ❗ Superset is Not a Database Firewall
|
||||
>
|
||||
> It is essential to understand that **Apache Superset is a data visualization and exploration platform, not a database firewall or a comprehensive security solution for your data warehouse.** While Superset provides features to help manage data access, the ultimate responsibility for securing your underlying databases lies with your database administrators (DBAs) and security teams. This includes managing network access, user privileges, and fine-grained permissions directly within the database. The configurations below are an important secondary layer of security but should not be your only line of defense.
|
||||
|
||||
* **Use a Dedicated Database User:** The database connection configured in Superset should use a dedicated, limited-privilege database user. This user should only have the minimum required permissions (e.g., `SELECT` on specific schemas) for the data sources it needs to query. It should **not** have `INSERT`, `UPDATE`, `DELETE`, or administrative privileges.
|
||||
* **Restrict Dangerous SQL Functions:** To mitigate potential SQL injection risks, configure the `DISALLOWED_SQL_FUNCTIONS` list in your `superset_config.py`. Be aware that this is a defense-in-depth measure, not a substitute for proper database permissions.
|
||||
|
||||
### **Additional Security Layers**
|
||||
|
||||
* **Web Application Firewall (WAF):** Deploying Superset behind a WAF (e.g., Cloudflare, AWS WAF) is strongly recommended. A WAF with a standard ruleset (like the OWASP Core Rule Set) provides a critical layer of defense against common attacks like SQL Injection, XSS, and remote code execution.
|
||||
|
||||
### **Monitoring and Logging**
|
||||
|
||||
* **Configure Structured Logging:** Set up a robust logging configuration to capture important security events.
|
||||
* **Centralize Logs:** Ship logs from all Superset components (frontend, worker, etc.) to a centralized SIEM (Security Information and Event Management) system for analysis and alerting.
|
||||
* **Monitor Key Events:** Create alerts for suspicious activities, including:
|
||||
* Multiple failed login attempts for a single user or from a single IP address.
|
||||
* Changes to user roles or permissions.
|
||||
* Creation or deletion of high-privilege users.
|
||||
* Attempts to use disallowed SQL functions.
|
||||
|
||||
-----
|
||||
|
||||
### **Appendix A: Production Deployment Checklist**
|
||||
|
||||
#### **Initial Setup:**
|
||||
|
||||
- [ ] HTTPS/TLS is configured and enforced via a reverse proxy.
|
||||
- [ ] A unique, strong `SUPERSET_SECRET_KEY` is generated and secured in an environment variable or secrets vault.
|
||||
- [ ] Server-side session management is configured (e.g., Redis).
|
||||
- [ ] `PERMANENT_SESSION_LIFETIME` is set to a short duration (e.g., 8 hours).
|
||||
- [ ] All session cookie security flags (`Secure`, `HttpOnly`, `SameSite`) are enabled.
|
||||
- [ ] `DEBUG` mode is set to `False`.
|
||||
- [ ] Talisman is explicitly enabled and configured with a strict Content Security Policy.
|
||||
- [ ] Database connections use dedicated, limited-privilege accounts.
|
||||
- [ ] Authentication is integrated with an enterprise identity provider (OAuth/LDAP).
|
||||
- [ ] A Web Application Firewall (WAF) is deployed in front of Superset.
|
||||
- [ ] Logging is configured and logs are shipped to a central monitoring system.
|
||||
|
||||
#### **Ongoing Maintenance:**
|
||||
|
||||
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
|
||||
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
|
||||
- [ ] Conduct quarterly access reviews for all users.
|
||||
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
|
||||
|
||||
### **Appendix B: `SECRET_KEY` Rotation and Compromise Response**
|
||||
|
||||
**Why and When to Rotate the `SECRET_KEY`**
|
||||
Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is mandatory after a known or suspected compromise and is a best practice when an employee with access to the key departs. While periodic rotation can limit the window of exposure for an unknown leak, it is a high-impact operation that will invalidate all user sessions and requires careful execution to avoid breaking your instance. The principles behind managing this key align with general best practices for cryptographic storage, which are further detailed in the OWASP Cryptographic Storage Cheat Sheet here: https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
|
||||
|
||||
**Procedure for Rotating the Key**
|
||||
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
|
||||
https://superset.apache.org/docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
|
||||
@@ -28,10 +28,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@docusaurus/core": "3.9.1",
|
||||
"@docusaurus/plugin-client-redirects": "3.9.1",
|
||||
"@docusaurus/preset-classic": "3.9.1",
|
||||
"@docusaurus/theme-mermaid": "^3.9.1",
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/plugin-client-redirects": "3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@docusaurus/theme-mermaid": "^3.9.2",
|
||||
"@emotion/core": "^10.0.27",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^10.0.27",
|
||||
@@ -49,8 +49,8 @@
|
||||
"@storybook/preview-api": "^8.6.11",
|
||||
"@storybook/theming": "^8.6.11",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"antd": "^5.27.4",
|
||||
"caniuse-lite": "^1.0.30001749",
|
||||
"antd": "^5.27.6",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"less": "^4.4.2",
|
||||
@@ -63,25 +63,25 @@
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.1.1",
|
||||
"storybook": "^8.6.11",
|
||||
"swagger-ui-react": "^5.29.3",
|
||||
"swagger-ui-react": "^5.29.5",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"ts-loader": "^9.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.9.1",
|
||||
"@docusaurus/tsconfig": "^3.9.1",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@docusaurus/tsconfig": "^3.9.2",
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.46.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.0",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"webpack": "^5.102.1"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
712
docs/yarn.lock
712
docs/yarn.lock
@@ -1593,10 +1593,10 @@
|
||||
marked "^16.3.0"
|
||||
zod "^4.1.8"
|
||||
|
||||
"@docusaurus/babel@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.9.1.tgz#5297195ab34df9e184e3e2fe20de1a2e1b2a22e8"
|
||||
integrity sha512-/uoi3oG+wvbVWNBRfPrzrEslOSeLxrQEyWMywK51TLDFTANqIRivzkMusudh5bdDty8fXzCYUT+tg5t697jYqg==
|
||||
"@docusaurus/babel@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.9.2.tgz#f956c638baeccf2040e482c71a742bc7e35fdb22"
|
||||
integrity sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA==
|
||||
dependencies:
|
||||
"@babel/core" "^7.25.9"
|
||||
"@babel/generator" "^7.25.9"
|
||||
@@ -1608,23 +1608,23 @@
|
||||
"@babel/runtime" "^7.25.9"
|
||||
"@babel/runtime-corejs3" "^7.25.9"
|
||||
"@babel/traverse" "^7.25.9"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
babel-plugin-dynamic-import-node "^2.3.3"
|
||||
fs-extra "^11.1.1"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/bundler@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.9.1.tgz#6b78c152cf364d706249f6978f8e3fedf576b118"
|
||||
integrity sha512-E1c9DgNmAz4NqbNtiJVp4UgjLtr8O01IgtXD/NDQ4PZaK8895cMiTOgb3k7mN0qX8A3lb8vqyrPJ842+yMpuUg==
|
||||
"@docusaurus/bundler@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.9.2.tgz#0ca82cda4acf13a493e3f66061aea351e9d356cf"
|
||||
integrity sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA==
|
||||
dependencies:
|
||||
"@babel/core" "^7.25.9"
|
||||
"@docusaurus/babel" "3.9.1"
|
||||
"@docusaurus/cssnano-preset" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/babel" "3.9.2"
|
||||
"@docusaurus/cssnano-preset" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
babel-loader "^9.2.1"
|
||||
clean-css "^5.3.3"
|
||||
copy-webpack-plugin "^11.0.0"
|
||||
@@ -1644,18 +1644,18 @@
|
||||
webpack "^5.95.0"
|
||||
webpackbar "^6.0.1"
|
||||
|
||||
"@docusaurus/core@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.9.1.tgz#be4d859464fee8889794d8527f884e931d591f2e"
|
||||
integrity sha512-FWDk1LIGD5UR5Zmm9rCrXRoxZUgbwuP6FBA7rc50DVfzqDOMkeMe3NyJhOsA2dF0zBE3VbHEIMmTjKwTZJwbaA==
|
||||
"@docusaurus/core@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.9.2.tgz#cc970f29b85a8926d63c84f8cffdcda43ed266ff"
|
||||
integrity sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==
|
||||
dependencies:
|
||||
"@docusaurus/babel" "3.9.1"
|
||||
"@docusaurus/bundler" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/babel" "3.9.2"
|
||||
"@docusaurus/bundler" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
boxen "^6.2.1"
|
||||
chalk "^4.1.2"
|
||||
chokidar "^3.5.3"
|
||||
@@ -1692,32 +1692,32 @@
|
||||
webpack-dev-server "^5.2.2"
|
||||
webpack-merge "^6.0.1"
|
||||
|
||||
"@docusaurus/cssnano-preset@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.1.tgz#fa57c81a3f41e4d118115f86c85a71aed6b90f49"
|
||||
integrity sha512-2y7+s7RWQMqBg+9ejeKwvZs7Bdw/hHIVJIodwMXbs2kr+S48AhcmAfdOh6Cwm0unJb0hJUshN0ROwRoQMwl3xg==
|
||||
"@docusaurus/cssnano-preset@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.2.tgz#523aab65349db3c51a77f2489048d28527759428"
|
||||
integrity sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ==
|
||||
dependencies:
|
||||
cssnano-preset-advanced "^6.1.2"
|
||||
postcss "^8.5.4"
|
||||
postcss-sort-media-queries "^5.2.0"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/logger@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.9.1.tgz#0209c4c1044ee35d89dbf676e3cbb5dc8b59c82b"
|
||||
integrity sha512-C9iFzXwHzwvGlisE4bZx+XQE0JIqlGAYAd5LzpR7fEDgjctu7yL8bE5U4nTNywXKHURDzMt4RJK8V6+stFHVkA==
|
||||
"@docusaurus/logger@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.9.2.tgz#6ec6364b90f5a618a438cc9fd01ac7376869f92a"
|
||||
integrity sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA==
|
||||
dependencies:
|
||||
chalk "^4.1.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/mdx-loader@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.9.1.tgz#0ef77bee13450c83c18338f8e5f1753ed2e9ee3f"
|
||||
integrity sha512-/1PY8lqry8jCt0qZddJSpc0U2sH6XC27kVJZfpA7o2TiQ3mdBQyH5AVbj/B2m682B1ounE+XjI0LdpOkAQLPoA==
|
||||
"@docusaurus/mdx-loader@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.9.2.tgz#78d238de6c6203fa811cc2a7e90b9b79e111408c"
|
||||
integrity sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ==
|
||||
dependencies:
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@mdx-js/mdx" "^3.0.0"
|
||||
"@slorber/remark-comment" "^1.0.0"
|
||||
escape-html "^1.0.3"
|
||||
@@ -1740,12 +1740,12 @@
|
||||
vfile "^6.0.1"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/module-type-aliases@3.9.1", "@docusaurus/module-type-aliases@^3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.1.tgz#201959a22e7b30881cf879a21d2ae5b26415b705"
|
||||
integrity sha512-YBce3GbJGGcMbJTyHcnEOMvdXqg41pa5HsrMCGA5Rm4z0h0tHS6YtEldj0mlfQRhCG7Y0VD66t2tb87Aom+11g==
|
||||
"@docusaurus/module-type-aliases@3.9.2", "@docusaurus/module-type-aliases@^3.9.1":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.2.tgz#993c7cb0114363dea5ef6855e989b3ad4b843a34"
|
||||
integrity sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew==
|
||||
dependencies:
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
"@types/react-router-config" "*"
|
||||
@@ -1753,34 +1753,34 @@
|
||||
react-helmet-async "npm:@slorber/react-helmet-async@1.3.0"
|
||||
react-loadable "npm:@docusaurus/react-loadable@6.0.0"
|
||||
|
||||
"@docusaurus/plugin-client-redirects@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.9.1.tgz#06e07487f1596c62536d50afac81535d5fe60a20"
|
||||
integrity sha512-+1InCGvAnw46H+TnVqxaYlJC0qy9AY5gTMgTx2ZFryjAsImJNs3i1pEYW/iUTVbOdtWRj3E/87E4ehbBIaA1TA==
|
||||
"@docusaurus/plugin-client-redirects@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.9.2.tgz#9c27025c72aeeedeb783a94720163911567da0e8"
|
||||
integrity sha512-lUgMArI9vyOYMzLRBUILcg9vcPTCyyI2aiuXq/4npcMVqOr6GfmwtmBYWSbNMlIUM0147smm4WhpXD0KFboffw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
eta "^2.2.0"
|
||||
fs-extra "^11.1.1"
|
||||
lodash "^4.17.21"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-content-blog@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.1.tgz#bf6619847065360d52abc5bf1da307f5ce2a19f8"
|
||||
integrity sha512-vT6kIimpJLWvW9iuWzH4u7VpTdsGlmn4yfyhq0/Kb1h4kf9uVouGsTmrD7WgtYBUG1P+TSmQzUUQa+ALBSRTig==
|
||||
"@docusaurus/plugin-content-blog@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.2.tgz#d5ce51eb7757bdab0515e2dd26a793ed4e119df9"
|
||||
integrity sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
cheerio "1.0.0-rc.12"
|
||||
feed "^4.2.2"
|
||||
fs-extra "^11.1.1"
|
||||
@@ -1792,20 +1792,20 @@
|
||||
utility-types "^3.10.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/plugin-content-docs@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.1.tgz#e3e75d4aa310689c262c18e10010788e53f101ec"
|
||||
integrity sha512-DyLk9BIA6I9gPIuia8XIL+XIEbNnExam6AHzRsfrEq4zJr7k/DsWW7oi4aJMepDnL7jMRhpVcdsCxdjb0/A9xg==
|
||||
"@docusaurus/plugin-content-docs@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz#cd8f2d1c06e53c3fa3d24bdfcb48d237bf2d6b2e"
|
||||
integrity sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@types/react-router-config" "^5.0.7"
|
||||
combine-promises "^1.1.0"
|
||||
fs-extra "^11.1.1"
|
||||
@@ -1816,142 +1816,142 @@
|
||||
utility-types "^3.10.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/plugin-content-pages@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.1.tgz#044b8adbd2a673ff22630a74b3e0ce482761655d"
|
||||
integrity sha512-/1wFzRnXYASI+Nv9ck9IVPIMw0O5BGQ8ZVhDzEwhkL+tl44ycvSnY6PIe6rW2HLxsw61Z3WFwAiU8+xMMtMZpg==
|
||||
"@docusaurus/plugin-content-pages@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.2.tgz#22db6c88ade91cec0a9e87a00b8089898051b08d"
|
||||
integrity sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
fs-extra "^11.1.1"
|
||||
tslib "^2.6.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/plugin-css-cascade-layers@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.1.tgz#958a04679279e787d14fd3cc423ad35c580dc6fc"
|
||||
integrity sha512-/QyW2gRCk/XE3ttCK/ERIgle8KJ024dBNKMu6U5SmpJvuT2il1n5jR/48Pp/9wEwut8WVml4imNm6X8JsL5A0Q==
|
||||
"@docusaurus/plugin-css-cascade-layers@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.2.tgz#358c85f63f1c6a11f611f1b8889d9435c11b22f8"
|
||||
integrity sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-debug@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.9.1.tgz#5dbe01771176697f427b89a1ff023a3967c3e674"
|
||||
integrity sha512-qPeAuk0LccC251d7jg2MRhNI+o7niyqa924oEM/AxnZJvIpMa596aAxkRImiAqNN6+gtLE1Hkrz/RHUH2HDGsA==
|
||||
"@docusaurus/plugin-debug@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.9.2.tgz#b5df4db115583f5404a252dbf66f379ff933e53c"
|
||||
integrity sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
fs-extra "^11.1.1"
|
||||
react-json-view-lite "^2.3.0"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-google-analytics@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.1.tgz#226e39ed6d0a5eb3978dc5189bc9676235756446"
|
||||
integrity sha512-k4Qq2HphqOrIU/CevGPdEO1yJnWUI8m0zOJsYt5NfMJwNsIn/gDD6gv/DKD+hxHndQT5pacsfBd4BWHZVNVroQ==
|
||||
"@docusaurus/plugin-google-analytics@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.2.tgz#857fe075fdeccdf6959e62954d9efe39769fa247"
|
||||
integrity sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-google-gtag@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.1.tgz#971b075898d46d1a59482b2873ccb9aa2e679910"
|
||||
integrity sha512-n9BURBiQyJKI/Ecz35IUjXYwXcgNCSq7/eA07+ZYcDiSyH2p/EjPf8q/QcZG3CyEJPZ/SzGkDHePfcVPahY4Gg==
|
||||
"@docusaurus/plugin-google-gtag@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.2.tgz#df75b1a90ae9266b0471909ba0265f46d5dcae62"
|
||||
integrity sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@types/gtag.js" "^0.0.12"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-google-tag-manager@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.1.tgz#b26770d8bb07cedc1e01305cd9d66c2e4ce6d654"
|
||||
integrity sha512-rZAQZ25ZuXaThBajxzLjXieTDUCMmBzfAA6ThElQ3o7Q+LEpOjCIrwGFau0KLY9HeG6x91+FwwsAM8zeApYDrg==
|
||||
"@docusaurus/plugin-google-tag-manager@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.2.tgz#d1a3cf935acb7d31b84685e92d70a1d342946677"
|
||||
integrity sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-sitemap@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.1.tgz#e84717c1e52f3a61f9fea414ef98ebe025e7ffd2"
|
||||
integrity sha512-k/bf5cXDxAJUYTzqatgFJwmZsLUbIgl6S8AdZMKGG2Mv2wcOHt+EQNN9qPyWZ5/9cFj+Q8f8DN+KQheBMYLong==
|
||||
"@docusaurus/plugin-sitemap@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.2.tgz#e1d9f7012942562cc0c6543d3cb2cdc4ae713dc4"
|
||||
integrity sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
fs-extra "^11.1.1"
|
||||
sitemap "^7.1.1"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-svgr@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.1.tgz#394ad2b8da3af587a0f68167252b4bf99fb72351"
|
||||
integrity sha512-TeZOXT2PSdTNR1OpDJMkYqFyX7MMhbd4t16hQByXksgZQCXNyw3Dio+KaDJ2Nj+LA4WkOvsk45bWgYG5MAaXSQ==
|
||||
"@docusaurus/plugin-svgr@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.2.tgz#62857ed79d97c0150d25f7e7380fdee65671163a"
|
||||
integrity sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@svgr/core" "8.1.0"
|
||||
"@svgr/webpack" "^8.1.0"
|
||||
tslib "^2.6.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/preset-classic@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.9.1.tgz#58d86664b5c9779578092556a0e6ae5ccebbd6c0"
|
||||
integrity sha512-ZHga2xsxxsyd0dN1BpLj8S889Eu9eMBuj2suqxdw/vaaXu/FjJ8KEGbcaeo6nHPo8VQcBBnPEdkBtSDm2TfMNw==
|
||||
"@docusaurus/preset-classic@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.9.2.tgz#85cc4f91baf177f8146c9ce896dfa1f0fd377050"
|
||||
integrity sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/plugin-content-blog" "3.9.1"
|
||||
"@docusaurus/plugin-content-docs" "3.9.1"
|
||||
"@docusaurus/plugin-content-pages" "3.9.1"
|
||||
"@docusaurus/plugin-css-cascade-layers" "3.9.1"
|
||||
"@docusaurus/plugin-debug" "3.9.1"
|
||||
"@docusaurus/plugin-google-analytics" "3.9.1"
|
||||
"@docusaurus/plugin-google-gtag" "3.9.1"
|
||||
"@docusaurus/plugin-google-tag-manager" "3.9.1"
|
||||
"@docusaurus/plugin-sitemap" "3.9.1"
|
||||
"@docusaurus/plugin-svgr" "3.9.1"
|
||||
"@docusaurus/theme-classic" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/theme-search-algolia" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/plugin-content-blog" "3.9.2"
|
||||
"@docusaurus/plugin-content-docs" "3.9.2"
|
||||
"@docusaurus/plugin-content-pages" "3.9.2"
|
||||
"@docusaurus/plugin-css-cascade-layers" "3.9.2"
|
||||
"@docusaurus/plugin-debug" "3.9.2"
|
||||
"@docusaurus/plugin-google-analytics" "3.9.2"
|
||||
"@docusaurus/plugin-google-gtag" "3.9.2"
|
||||
"@docusaurus/plugin-google-tag-manager" "3.9.2"
|
||||
"@docusaurus/plugin-sitemap" "3.9.2"
|
||||
"@docusaurus/plugin-svgr" "3.9.2"
|
||||
"@docusaurus/theme-classic" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/theme-search-algolia" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
|
||||
"@docusaurus/theme-classic@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.9.1.tgz#790fb1b8058d0572632211023ead238c1a6450e0"
|
||||
integrity sha512-LrAIu/mQ04nG6s1cssC0TMmICD8twFIIn/hJ5Pd9uIPQvtKnyAKEn12RefopAul5KfMo9kixPaqogV5jIJr26w==
|
||||
"@docusaurus/theme-classic@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.9.2.tgz#6e514f99a0ff42b80afcf42d5e5d042618311ce0"
|
||||
integrity sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/plugin-content-blog" "3.9.1"
|
||||
"@docusaurus/plugin-content-docs" "3.9.1"
|
||||
"@docusaurus/plugin-content-pages" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/theme-translations" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/plugin-content-blog" "3.9.2"
|
||||
"@docusaurus/plugin-content-docs" "3.9.2"
|
||||
"@docusaurus/plugin-content-pages" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/theme-translations" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
clsx "^2.0.0"
|
||||
infima "0.2.0-alpha.45"
|
||||
@@ -1965,15 +1965,15 @@
|
||||
tslib "^2.6.0"
|
||||
utility-types "^3.10.0"
|
||||
|
||||
"@docusaurus/theme-common@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.9.1.tgz#095cbeab489d51380143951508571888f4d2928d"
|
||||
integrity sha512-j9adi961F+6Ps9d0jcb5BokMcbjXAAJqKkV43eo8nh4YgmDj7KUNDX4EnOh/MjTQeO06oPY5cxp3yUXdW/8Ggw==
|
||||
"@docusaurus/theme-common@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.9.2.tgz#487172c6fef9815c2746ef62a71e4f5b326f9ba5"
|
||||
integrity sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==
|
||||
dependencies:
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
"@types/react-router-config" "*"
|
||||
@@ -1983,32 +1983,32 @@
|
||||
tslib "^2.6.0"
|
||||
utility-types "^3.10.0"
|
||||
|
||||
"@docusaurus/theme-mermaid@^3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.1.tgz#92de20580489e05b3da6716503fda17e5a337a4d"
|
||||
integrity sha512-aKMFlQfxueVBPdCdrNSshG12fOkJXSn1sb6EhI/sGn3UpiTEiazJm4QLP6NoF78mqq8O5Ar2Yll+iHWLvCsuZQ==
|
||||
"@docusaurus/theme-mermaid@^3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.2.tgz#f065e4b4b319560ddd8c3be65ce9dd19ce1d5cc8"
|
||||
integrity sha512-5vhShRDq/ntLzdInsQkTdoKWSzw8d1jB17sNPYhA/KvYYFXfuVEGHLM6nrf8MFbV8TruAHDG21Fn3W4lO8GaDw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
mermaid ">=11.6.0"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/theme-search-algolia@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.1.tgz#2f2ad5212201a1bed3acf8527ae6d81a079e654e"
|
||||
integrity sha512-WjM28bzlgfT6nHlEJemkwyGVpvGsZWPireV/w+wZ1Uo64xCZ8lNOb4xwQRukDaLSed3oPBN0gSnu06l5VuCXHg==
|
||||
"@docusaurus/theme-search-algolia@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz#420fd5b27fc1673b48151fdc9fe7167ba135ed50"
|
||||
integrity sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw==
|
||||
dependencies:
|
||||
"@docsearch/react" "^3.9.0 || ^4.1.0"
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/plugin-content-docs" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/theme-translations" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/plugin-content-docs" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/theme-translations" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
algoliasearch "^5.37.0"
|
||||
algoliasearch-helper "^3.26.0"
|
||||
clsx "^2.0.0"
|
||||
@@ -2018,23 +2018,23 @@
|
||||
tslib "^2.6.0"
|
||||
utility-types "^3.10.0"
|
||||
|
||||
"@docusaurus/theme-translations@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.9.1.tgz#189f1942d0178bc0da659db88c07682c7d7191ee"
|
||||
integrity sha512-mUQd49BSGKTiM6vP9+JFgRJL28lMIN3PUvXjF3rzuOHMByUZUBNwCt26Z23GkKiSIOrRkjKoaBNTipR/MHdYSQ==
|
||||
"@docusaurus/theme-translations@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.9.2.tgz#238cd69c2da92d612be3d3b4f95944c1d0f1e041"
|
||||
integrity sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA==
|
||||
dependencies:
|
||||
fs-extra "^11.1.1"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/tsconfig@^3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.9.1.tgz#a39cb74021f16dd4794db9182de812303817528e"
|
||||
integrity sha512-stdzM1dNDgRO0OvxeznXlE3N1igUoeHPNJjiKqyffLizgpVgNXJBAWeG6fuoYiCH4udGUBqy2dyM+1+kG2/UPQ==
|
||||
"@docusaurus/tsconfig@^3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.9.2.tgz#7f440e0ae665b841e1d487749037f26a0275f9c1"
|
||||
integrity sha512-j6/Fp4Rlpxsc632cnRnl5HpOWeb6ZKssDj6/XzzAzVGXXfm9Eptx3rxCC+fDzySn9fHTS+CWJjPineCR1bB5WQ==
|
||||
|
||||
"@docusaurus/types@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.9.1.tgz#e4fdaf0b91ea014a6aae0d8b62d59f3f020117b6"
|
||||
integrity sha512-ElekJ29sk39s5LTEZMByY1c2oH9FMtw7KbWFU3BtuQ1TytfIK39HhUivDEJvm5KCLyEnnfUZlvSNDXeyk0vzAA==
|
||||
"@docusaurus/types@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.9.2.tgz#e482cf18faea0d1fa5ce0e3f1e28e0f32d2593eb"
|
||||
integrity sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q==
|
||||
dependencies:
|
||||
"@mdx-js/mdx" "^3.0.0"
|
||||
"@types/history" "^4.7.11"
|
||||
@@ -2047,36 +2047,36 @@
|
||||
webpack "^5.95.0"
|
||||
webpack-merge "^5.9.0"
|
||||
|
||||
"@docusaurus/utils-common@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.9.1.tgz#202778391caed923c2527a166a3aae3a22b2dcad"
|
||||
integrity sha512-4M1u5Q8Zn2CYL2TJ864M51FV4YlxyGyfC3x+7CLuR6xsyTVNBNU4QMcPgsTHRS9J2+X6Lq7MyH6hiWXyi/sXUQ==
|
||||
"@docusaurus/utils-common@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.9.2.tgz#e89bfcf43d66359f43df45293fcdf22814847460"
|
||||
integrity sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw==
|
||||
dependencies:
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/utils-validation@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.9.1.tgz#8f9816b31ffb647539881f3c153d46f54e6399f7"
|
||||
integrity sha512-5bzab5si3E1udrlZuVGR17857Lfwe8iFPoy5AvMP9PXqDfoyIKT7gDQgAmxdRDMurgHaJlyhXEHHdzDKkOxxZQ==
|
||||
"@docusaurus/utils-validation@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.9.2.tgz#04aec285604790806e2fc5aa90aa950dc7ba75ae"
|
||||
integrity sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==
|
||||
dependencies:
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
fs-extra "^11.2.0"
|
||||
joi "^17.9.2"
|
||||
js-yaml "^4.1.0"
|
||||
lodash "^4.17.21"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/utils@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.9.1.tgz#9b78849a2be5e3023580b800409aae36a0da6dc8"
|
||||
integrity sha512-YAL4yhhWLl9DXuf5MVig260a6INz4MehrBGFU/CZu8yXmRiYEuQvRFWh9ZsjfAOyaG7za1MNmBVZ4VVAi/CiJA==
|
||||
"@docusaurus/utils@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.9.2.tgz#ffab7922631c7e0febcb54e6d499f648bf8a89eb"
|
||||
integrity sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==
|
||||
dependencies:
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
escape-string-regexp "^4.0.0"
|
||||
execa "5.1.1"
|
||||
file-loader "^6.2.0"
|
||||
@@ -2428,19 +2428,19 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
|
||||
integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
|
||||
|
||||
"@eslint/config-array@^0.21.0":
|
||||
version "0.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.0.tgz#abdbcbd16b124c638081766392a4d6b509f72636"
|
||||
integrity sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==
|
||||
"@eslint/config-array@^0.21.1":
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713"
|
||||
integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==
|
||||
dependencies:
|
||||
"@eslint/object-schema" "^2.1.6"
|
||||
"@eslint/object-schema" "^2.1.7"
|
||||
debug "^4.3.1"
|
||||
minimatch "^3.1.2"
|
||||
|
||||
"@eslint/config-helpers@^0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.0.tgz#e9f94ba3b5b875e32205cb83fece18e64486e9e6"
|
||||
integrity sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==
|
||||
"@eslint/config-helpers@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.1.tgz#7d173a1a35fe256f0989a0fdd8d911ebbbf50037"
|
||||
integrity sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==
|
||||
dependencies:
|
||||
"@eslint/core" "^0.16.0"
|
||||
|
||||
@@ -2466,15 +2466,15 @@
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.37.0", "@eslint/js@^9.37.0":
|
||||
version "9.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b"
|
||||
integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==
|
||||
"@eslint/js@9.38.0", "@eslint/js@^9.38.0":
|
||||
version "9.38.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.38.0.tgz#f7aa9c7577577f53302c1d795643589d7709ebd1"
|
||||
integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==
|
||||
|
||||
"@eslint/object-schema@^2.1.6":
|
||||
version "2.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
|
||||
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
|
||||
"@eslint/object-schema@^2.1.7":
|
||||
version "2.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad"
|
||||
integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==
|
||||
|
||||
"@eslint/plugin-kit@^0.4.0":
|
||||
version "0.4.0"
|
||||
@@ -2796,14 +2796,13 @@
|
||||
classnames "^2.3.2"
|
||||
rc-util "^5.24.4"
|
||||
|
||||
"@rc-component/qrcode@~1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/qrcode/-/qrcode-1.0.0.tgz#48a8de5eb11d0e65926f1377c4b1ef4c888997f5"
|
||||
integrity sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==
|
||||
"@rc-component/qrcode@~1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/qrcode/-/qrcode-1.0.1.tgz#98e0a79dc95f26fe211b59d04ef3312bc70dedbe"
|
||||
integrity sha512-g8eeeaMyFXVlq8cZUeaxCDhfIYjpao0l9cvm5gFwKXy/Vm1yDWV7h2sjH5jHYzdFedlVKBpATFB1VKMrHzwaWQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.24.7"
|
||||
classnames "^2.3.2"
|
||||
rc-util "^5.38.0"
|
||||
|
||||
"@rc-component/tour@~1.15.1":
|
||||
version "1.15.1"
|
||||
@@ -4336,79 +4335,79 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.46.0", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz#fc90b35d8025b5eaa66b2f6c3859cd5381a1e751"
|
||||
integrity sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==
|
||||
"@typescript-eslint/eslint-plugin@8.46.2", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
|
||||
integrity sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.10.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.0"
|
||||
"@typescript-eslint/type-utils" "8.46.0"
|
||||
"@typescript-eslint/utils" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.2"
|
||||
"@typescript-eslint/type-utils" "8.46.2"
|
||||
"@typescript-eslint/utils" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^7.0.0"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/parser@8.46.0", "@typescript-eslint/parser@^8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.0.tgz#9186f28c59f6e477ab8919312d2654f4f27d45c1"
|
||||
integrity sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==
|
||||
"@typescript-eslint/parser@8.46.2", "@typescript-eslint/parser@^8.46.0":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf"
|
||||
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.2"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/project-service@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.0.tgz#1190dcc0d3494d46a85773e0c3a2838cbb2b45a7"
|
||||
integrity sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==
|
||||
"@typescript-eslint/project-service@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608"
|
||||
integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.46.0"
|
||||
"@typescript-eslint/types" "^8.46.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.46.2"
|
||||
"@typescript-eslint/types" "^8.46.2"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz#a41833fe387044075cb2d4cfab490a7f1dd19b61"
|
||||
integrity sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==
|
||||
"@typescript-eslint/scope-manager@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88"
|
||||
integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.46.0", "@typescript-eslint/tsconfig-utils@^8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz#3e33019e0b94838d37d7cc61341fbcc5bf791007"
|
||||
integrity sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==
|
||||
"@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c"
|
||||
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
|
||||
|
||||
"@typescript-eslint/type-utils@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz#815efeb11b9533da68fd825628cecf283ac79829"
|
||||
integrity sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==
|
||||
"@typescript-eslint/type-utils@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz#802d027864e6fb752e65425ed09f3e089fb4d384"
|
||||
integrity sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/utils" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
"@typescript-eslint/utils" "8.46.2"
|
||||
debug "^4.3.4"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/types@8.46.0", "@typescript-eslint/types@^8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.0.tgz#20af6b332f9cd55a15fcd862fdb07d47a6131bf4"
|
||||
integrity sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==
|
||||
"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763"
|
||||
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz#f45a0d5f5e99b26f0280e8cff3ed3380658fd720"
|
||||
integrity sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==
|
||||
"@typescript-eslint/typescript-estree@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08"
|
||||
integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.46.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/project-service" "8.46.2"
|
||||
"@typescript-eslint/tsconfig-utils" "8.46.2"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/visitor-keys" "8.46.2"
|
||||
debug "^4.3.4"
|
||||
fast-glob "^3.3.2"
|
||||
is-glob "^4.0.3"
|
||||
@@ -4416,22 +4415,22 @@
|
||||
semver "^7.6.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/utils@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.0.tgz#27025c5ed7cbc928440d6a30edd6ba34cc5b927a"
|
||||
integrity sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==
|
||||
"@typescript-eslint/utils@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.2.tgz#b313d33d67f9918583af205bd7bcebf20f231732"
|
||||
integrity sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.7.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.2"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz#23936809054c511f703713c56ddd2f46dc197845"
|
||||
integrity sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==
|
||||
"@typescript-eslint/visitor-keys@8.46.2":
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738"
|
||||
integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.2"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -4746,10 +4745,10 @@ ansi-styles@^6.1.0:
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
|
||||
antd@^5.27.4:
|
||||
version "5.27.4"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.4.tgz#13c97deb12e6aeb43adecd23f3dbe3139a62e579"
|
||||
integrity sha512-rhArohoAUCxhkPjGI/BXthOrrjaElL4Fb7d4vEHnIR3DpxFXfegd4rN21IgGdiF+Iz4EFuUZu8MdS8NuJHLSVQ==
|
||||
antd@^5.27.6:
|
||||
version "5.27.6"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.6.tgz#6b7c7a87b5c696395d2aab2fdbd8409a342813e1"
|
||||
integrity sha512-70HrjVbzDXvtiUQ5MP1XdNudr/wGAk9Ivaemk6f36yrAeJurJSmZ8KngOIilolLRHdGuNc6/Vk+4T1OZpSjpag==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^7.2.1"
|
||||
"@ant-design/cssinjs" "^1.23.0"
|
||||
@@ -4760,7 +4759,7 @@ antd@^5.27.4:
|
||||
"@babel/runtime" "^7.26.0"
|
||||
"@rc-component/color-picker" "~2.0.1"
|
||||
"@rc-component/mutate-observer" "^1.1.0"
|
||||
"@rc-component/qrcode" "~1.0.0"
|
||||
"@rc-component/qrcode" "~1.0.1"
|
||||
"@rc-component/tour" "~1.15.1"
|
||||
"@rc-component/trigger" "^2.3.0"
|
||||
classnames "^2.5.1"
|
||||
@@ -4790,7 +4789,7 @@ antd@^5.27.4:
|
||||
rc-slider "~11.1.9"
|
||||
rc-steps "~6.0.1"
|
||||
rc-switch "~4.1.0"
|
||||
rc-table "~7.53.0"
|
||||
rc-table "~7.54.0"
|
||||
rc-tabs "~15.7.0"
|
||||
rc-textarea "~1.10.2"
|
||||
rc-tooltip "~6.4.0"
|
||||
@@ -5305,10 +5304,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001749:
|
||||
version "1.0.30001749"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz#21a43b923577932097fe32bcaabb6da7f4677632"
|
||||
integrity sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001751:
|
||||
version "1.0.30001751"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz#dacd5d9f4baeea841641640139d2b2a4df4226ad"
|
||||
integrity sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -6997,24 +6996,23 @@ eslint-visitor-keys@^4.2.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint@^9.37.0:
|
||||
version "9.37.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.37.0.tgz#ac0222127f76b09c0db63036f4fe289562072d74"
|
||||
integrity sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==
|
||||
eslint@^9.38.0:
|
||||
version "9.38.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.38.0.tgz#3957d2af804e5cf6cc503c618f60acc71acb2e7e"
|
||||
integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.1"
|
||||
"@eslint/config-array" "^0.21.0"
|
||||
"@eslint/config-helpers" "^0.4.0"
|
||||
"@eslint/config-array" "^0.21.1"
|
||||
"@eslint/config-helpers" "^0.4.1"
|
||||
"@eslint/core" "^0.16.0"
|
||||
"@eslint/eslintrc" "^3.3.1"
|
||||
"@eslint/js" "9.37.0"
|
||||
"@eslint/js" "9.38.0"
|
||||
"@eslint/plugin-kit" "^0.4.0"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
"@types/estree" "^1.0.6"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
ajv "^6.12.4"
|
||||
chalk "^4.0.0"
|
||||
cross-spawn "^7.0.6"
|
||||
@@ -11729,10 +11727,10 @@ rc-switch@~4.1.0:
|
||||
classnames "^2.2.1"
|
||||
rc-util "^5.30.0"
|
||||
|
||||
rc-table@~7.53.0:
|
||||
version "7.53.1"
|
||||
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.53.1.tgz#b891aa39e9d1d944711f018692d2c52013afc90f"
|
||||
integrity sha512-firAd7Z+liqIDS5TubJ1qqcoBd6YcANLKWQDZhFf3rfoOTt/UNPj4n3O+2vhl+z4QMqwPEUVAil661WHA8H8Aw==
|
||||
rc-table@~7.54.0:
|
||||
version "7.54.0"
|
||||
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.54.0.tgz#dedd4ea18d1189f2acdf90a80f04d8ca0111e16a"
|
||||
integrity sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.1"
|
||||
"@rc-component/context" "^1.4.0"
|
||||
@@ -13259,10 +13257,10 @@ swagger-client@^3.35.7:
|
||||
ramda "^0.30.1"
|
||||
ramda-adjunct "^5.1.0"
|
||||
|
||||
swagger-ui-react@^5.29.3:
|
||||
version "5.29.3"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.3.tgz#a132c3c3c4553c2acd0aca1f02c8484ca4c78183"
|
||||
integrity sha512-cx47SmqrxXCP86+6NHEzXUBEG/MGbNK/H8BQphzUVomxGpG9lZCUo6hIGFNe1i7fP5eaMxpLV/qoqaWVo3TSvw==
|
||||
swagger-ui-react@^5.29.5:
|
||||
version "5.29.5"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.5.tgz#8c6eafebb75972c15a9f3e24627caec10cc32cbe"
|
||||
integrity sha512-D0YbsDhi4F38HsY5p1DjzuNduU/fVQxtqm3v0o2dRTF5BbLJYRSgjMZ79jejG4q3nNw4kuouCKKiq5xqCLjWrQ==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
@@ -13590,15 +13588,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.46.0:
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.0.tgz#fb1c37a90fadf42fe1c8f8b192b974b6d9c439cc"
|
||||
integrity sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==
|
||||
typescript-eslint@^8.46.2:
|
||||
version "8.46.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.2.tgz#da1adec683ba93a1b6c3850a4efb0922ffbc627d"
|
||||
integrity sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.46.0"
|
||||
"@typescript-eslint/parser" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/utils" "8.46.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.46.2"
|
||||
"@typescript-eslint/parser" "8.46.2"
|
||||
"@typescript-eslint/typescript-estree" "8.46.2"
|
||||
"@typescript-eslint/utils" "8.46.2"
|
||||
|
||||
typescript@~5.9.3:
|
||||
version "5.9.3"
|
||||
|
||||
@@ -441,24 +441,42 @@ def init() -> None:
|
||||
(target_dir / "extension.json").write_text(extension_json)
|
||||
click.secho("✅ Created extension.json", fg="green")
|
||||
|
||||
# Copy frontend template
|
||||
# Initialize frontend files
|
||||
if include_frontend:
|
||||
frontend_dir = target_dir / "frontend"
|
||||
frontend_dir.mkdir()
|
||||
frontend_src_dir = frontend_dir / "src"
|
||||
frontend_src_dir.mkdir()
|
||||
|
||||
# package.json
|
||||
# frontend files
|
||||
package_json = env.get_template("frontend/package.json.j2").render(ctx)
|
||||
(frontend_dir / "package.json").write_text(package_json)
|
||||
webpack_config = env.get_template("frontend/webpack.config.js.j2").render(ctx)
|
||||
(frontend_dir / "webpack.config.js").write_text(webpack_config)
|
||||
tsconfig_json = env.get_template("frontend/tsconfig.json.j2").render(ctx)
|
||||
(frontend_dir / "tsconfig.json").write_text(tsconfig_json)
|
||||
index_tsx = env.get_template("frontend/src/index.tsx.j2").render(ctx)
|
||||
(frontend_src_dir / "index.tsx").write_text(index_tsx)
|
||||
click.secho("✅ Created frontend folder structure", fg="green")
|
||||
|
||||
# Copy backend template
|
||||
# Initialize backend files
|
||||
if include_backend:
|
||||
backend_dir = target_dir / "backend"
|
||||
backend_dir.mkdir()
|
||||
backend_src_dir = backend_dir / "src"
|
||||
backend_src_dir.mkdir()
|
||||
backend_src_package_dir = backend_src_dir / id_
|
||||
backend_src_package_dir.mkdir()
|
||||
|
||||
# pyproject.toml
|
||||
# backend files
|
||||
pyproject_toml = env.get_template("backend/pyproject.toml.j2").render(ctx)
|
||||
(backend_dir / "pyproject.toml").write_text(pyproject_toml)
|
||||
init_py = env.get_template("backend/src/package/__init__.py.j2").render(ctx)
|
||||
(backend_src_package_dir / "__init__.py").write_text(init_py)
|
||||
entrypoint_py = env.get_template("backend/src/package/entrypoint.py.j2").render(
|
||||
ctx
|
||||
)
|
||||
(backend_src_package_dir / "entrypoint.py").write_text(entrypoint_py)
|
||||
|
||||
click.secho("✅ Created backend folder structure", fg="green")
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
print("{{ name }} extension registered")
|
||||
@@ -14,7 +14,7 @@
|
||||
"license": "{{ license }}",
|
||||
"description": "",
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "file:../../../superset-frontend/packages/superset-core",
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { core } from "@apache-superset/core";
|
||||
|
||||
export const activate = (context: core.ExtensionContext) => {
|
||||
context.disposables.push(
|
||||
core.registerViewProvider("{{ id }}.example", () => <p>{{ name }}</p>)
|
||||
);
|
||||
console.log("{{ name }} extension activated");
|
||||
};
|
||||
|
||||
export const deactivate = () => {
|
||||
console.log("{{ name }} extension deactivated");
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node10",
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
const path = require("path");
|
||||
const { ModuleFederationPlugin } = require("webpack").container;
|
||||
const packageConfig = require("./package");
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isProd = argv.mode === "production";
|
||||
|
||||
return {
|
||||
entry: isProd ? {} : "./src/index.tsx",
|
||||
mode: isProd ? "production" : "development",
|
||||
devServer: {
|
||||
port: 3000,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
clean: true,
|
||||
filename: isProd ? undefined : "[name].[contenthash].js",
|
||||
chunkFilename: "[name].[contenthash].js",
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
publicPath: `/api/v1/extensions/${packageConfig.name}/`,
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
||||
},
|
||||
externalsType: "window",
|
||||
externals: {
|
||||
"@apache-superset/core": "superset",
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: "ts-loader",
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
name: "{{ id }}",
|
||||
filename: "remoteEntry.[contenthash].js",
|
||||
exposes: {
|
||||
"./index": "./src/index.tsx",
|
||||
},
|
||||
shared: {
|
||||
react: {
|
||||
singleton: true,
|
||||
requiredVersion: packageConfig.peerDependencies.react,
|
||||
import: false,
|
||||
},
|
||||
"react-dom": {
|
||||
singleton: true,
|
||||
requiredVersion: packageConfig.peerDependencies["react-dom"],
|
||||
import: false,
|
||||
},
|
||||
antd: {
|
||||
singleton: true,
|
||||
requiredVersion: packageConfig.peerDependencies["antd"],
|
||||
import: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -87,6 +87,7 @@ export function prepareDashboardFilters(
|
||||
if (dashboardId) {
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: allFilters,
|
||||
chart_customization_config: [],
|
||||
timed_refresh_immune_slices: [],
|
||||
expanded_slices: {},
|
||||
refresh_frequency: 0,
|
||||
|
||||
1268
superset-frontend/package-lock.json
generated
1268
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -135,14 +135,14 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^2.1.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"dom-to-image-more": "^3.6.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"geolib": "^2.0.24",
|
||||
"geostyler": "^14.1.3",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -185,7 +185,7 @@
|
||||
"react-loadable": "^5.5.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-reverse-portal": "^2.1.2",
|
||||
"react-reverse-portal": "^2.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-search-input": "^0.11.3",
|
||||
"react-sortable-hoc": "^2.0.0",
|
||||
@@ -212,15 +212,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@applitools/eyes-storybook": "^3.60.0",
|
||||
"@babel/cli": "^7.27.2",
|
||||
"@babel/compat-data": "^7.28.0",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/compat-data": "^7.28.4",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/eslint-parser": "^7.25.9",
|
||||
"@babel/eslint-parser": "^7.28.4",
|
||||
"@babel/node": "^7.22.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
|
||||
"@babel/plugin-transform-runtime": "^7.27.1",
|
||||
"@babel/plugin-transform-runtime": "^7.28.3",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
@@ -234,7 +234,7 @@
|
||||
"@hot-loader/react-dom": "^17.0.2",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
"@storybook/addon-essentials": "8.1.11",
|
||||
@@ -257,7 +257,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-json-tree": "^0.13.0",
|
||||
@@ -283,7 +283,7 @@
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-typescript-to-proptypes": "^2.0.0",
|
||||
"cheerio": "1.1.0",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"cross-env": "^10.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.2",
|
||||
@@ -301,7 +301,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-no-only-tests": "^3.3.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-prefer-function-component": "^3.3.0",
|
||||
@@ -312,7 +312,7 @@
|
||||
"fetch-mock": "^11.1.5",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
"history": "^5.3.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"html-webpack-plugin": "^5.6.4",
|
||||
"imports-loader": "^5.0.0",
|
||||
"jest": "^30.0.2",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
@@ -324,7 +324,7 @@
|
||||
"open-cli": "^8.0.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-packagejson": "^2.5.3",
|
||||
"prettier-plugin-packagejson": "^2.5.19",
|
||||
"process": "^0.11.10",
|
||||
"react-resizable": "^3.0.5",
|
||||
"redux-mock-store": "^1.5.4",
|
||||
@@ -335,13 +335,13 @@
|
||||
"storybook": "8.1.11",
|
||||
"style-loader": "^4.0.0",
|
||||
"thread-loader": "^4.0.4",
|
||||
"ts-jest": "^29.4.0",
|
||||
"ts-jest": "^29.4.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "5.4.5",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"webpack": "^5.102.0",
|
||||
"webpack": "^5.102.1",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"yeoman-generator": "^7.5.1",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"jest": "^30.0.5",
|
||||
"yeoman-test": "^10.1.1"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@fontsource/fira-code": "^5.2.6",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"ace-builds": "^1.43.3",
|
||||
"ace-builds": "^1.43.4",
|
||||
"ag-grid-community": "34.2.0",
|
||||
"ag-grid-react": "34.2.0",
|
||||
"brace": "^0.11.1",
|
||||
@@ -38,7 +38,7 @@
|
||||
"csstype": "^3.1.3",
|
||||
"core-js": "^3.38.1",
|
||||
"d3-format": "^1.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"math-expression-evaluator": "^2.0.6",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-js-cron": "^5.2.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"xss": "^1.0.14"
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/styled": "^11.14.1",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { ThemeProvider, supersetTheme } from '@superset-ui/core';
|
||||
import { ConfirmModal } from '.';
|
||||
|
||||
const defaultProps = {
|
||||
show: true,
|
||||
onHide: jest.fn(),
|
||||
onConfirm: jest.fn(),
|
||||
title: 'Confirm Action',
|
||||
body: 'Are you sure you want to proceed?',
|
||||
};
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('renders modal with title and body', () => {
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Are you sure you want to proceed?'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders default confirm and cancel buttons', () => {
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders custom button text', () => {
|
||||
renderWithTheme(
|
||||
<ConfirmModal {...defaultProps} confirmText="Delete" cancelText="Keep" />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Keep' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onConfirm when confirm button is clicked', () => {
|
||||
const onConfirm = jest.fn();
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} onConfirm={onConfirm} />);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('calls onHide when cancel button is clicked', () => {
|
||||
const onHide = jest.fn();
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} onHide={onHide} />);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders danger button style', () => {
|
||||
renderWithTheme(
|
||||
<ConfirmModal {...defaultProps} confirmButtonStyle="danger" />,
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows loading state on confirm button', () => {
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} loading />);
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /Confirm/ });
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
expect(confirmButton).toHaveClass('ant-btn-loading');
|
||||
});
|
||||
|
||||
test('disables buttons when loading', () => {
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} loading />);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders custom icon', () => {
|
||||
const CustomIcon = () => <span data-test="custom-icon">!</span>;
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} icon={<CustomIcon />} />);
|
||||
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders ReactNode as body', () => {
|
||||
renderWithTheme(
|
||||
<ConfirmModal
|
||||
{...defaultProps}
|
||||
body={
|
||||
<div>
|
||||
<p>Line 1</p>
|
||||
<p>Line 2</p>
|
||||
</div>
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Line 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Line 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render when show is false', () => {
|
||||
renderWithTheme(<ConfirmModal {...defaultProps} show={false} />);
|
||||
|
||||
expect(screen.queryByText('Confirm Action')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
|
||||
import type { FC, ReactElement, ReactNode } from 'react';
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
const DEFAULT_ICON = <Icons.QuestionCircleOutlined iconSize="m" />;
|
||||
|
||||
export type ConfirmModalProps = {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
body: string | ReactNode;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmButtonStyle?: 'primary' | 'danger' | 'dashed';
|
||||
icon?: ReactNode;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const ConfirmModal: FC<ConfirmModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
onConfirm,
|
||||
title,
|
||||
body,
|
||||
confirmText = t('Confirm'),
|
||||
cancelText = t('Cancel'),
|
||||
confirmButtonStyle = 'primary',
|
||||
icon = DEFAULT_ICON,
|
||||
loading = false,
|
||||
}: ConfirmModalProps): ReactElement => (
|
||||
<Modal
|
||||
centered
|
||||
responsive
|
||||
onHide={onHide}
|
||||
show={show}
|
||||
width="600px"
|
||||
title={
|
||||
<>
|
||||
<IconWrapper>{icon}</IconWrapper>
|
||||
{title}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button buttonStyle="secondary" onClick={onHide} disabled={loading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle={confirmButtonStyle}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{typeof body === 'string' ? (
|
||||
<Typography.Text>{body}</Typography.Text>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -144,6 +144,7 @@ import {
|
||||
GoogleOutlined,
|
||||
DesktopOutlined,
|
||||
FormatPainterOutlined,
|
||||
GroupOutlined,
|
||||
ExportOutlined,
|
||||
CompressOutlined,
|
||||
HistoryOutlined,
|
||||
@@ -221,6 +222,7 @@ const AntdIcons = {
|
||||
FunctionOutlined,
|
||||
GithubOutlined,
|
||||
GoogleOutlined,
|
||||
GroupOutlined,
|
||||
HighlightOutlined,
|
||||
InfoCircleOutlined,
|
||||
InfoCircleFilled,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const StyledHeader = styled.span<{ headerPosition: string }>`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: ${headerPosition === 'left' ? theme.sizeUnit * 2 : 0}px;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -167,6 +167,33 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
|
||||
.ant-table-body {
|
||||
overflow: auto;
|
||||
height: ${height ? `${height}px` : undefined};
|
||||
|
||||
/* Chrome/Safari/Edge webkit scrollbar styling */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${theme.colorFillSecondary};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorFillTertiary};
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
/* Firefox scrollbar styling */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${theme.colorFillSecondary} ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
|
||||
|
||||
@@ -66,6 +66,7 @@ export {
|
||||
type CheckboxProps,
|
||||
type CheckboxChangeEvent,
|
||||
} from './Checkbox';
|
||||
export { ConfirmModal, type ConfirmModalProps } from './ConfirmModal';
|
||||
export {
|
||||
ColorPicker,
|
||||
type ColorPickerProps,
|
||||
|
||||
@@ -324,6 +324,7 @@ export type Query = {
|
||||
schema?: string;
|
||||
sql: string;
|
||||
sqlEditorId: string;
|
||||
sqlEditorImmutableId: string;
|
||||
state: QueryState;
|
||||
tab: string | null;
|
||||
tempSchema: string | null;
|
||||
@@ -373,6 +374,7 @@ export const testQuery: Query = {
|
||||
dbId: 1,
|
||||
sql: 'SELECT * FROM something',
|
||||
sqlEditorId: 'dfsadfs',
|
||||
sqlEditorImmutableId: 'immutableId2353',
|
||||
tab: 'unimportant',
|
||||
tempTable: '',
|
||||
ctas: false,
|
||||
|
||||
@@ -402,7 +402,7 @@ export interface ThemeContextType {
|
||||
setTheme: (config: AnyThemeConfig) => void;
|
||||
setThemeMode: (newMode: ThemeMode) => void;
|
||||
resetTheme: () => void;
|
||||
setTemporaryTheme: (config: AnyThemeConfig) => void;
|
||||
setTemporaryTheme: (config: AnyThemeConfig, themeId?: number | null) => void;
|
||||
clearLocalOverrides: () => void;
|
||||
getCurrentCrudThemeId: () => string | null;
|
||||
hasDevOverride: () => boolean;
|
||||
@@ -410,6 +410,7 @@ export interface ThemeContextType {
|
||||
canSetTheme: () => boolean;
|
||||
canDetectOSPreference: () => boolean;
|
||||
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
|
||||
getAppliedThemeId: () => number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
"@deck.gl/geo-layers": "^9.1.13",
|
||||
"@deck.gl/layers": "^9.1.13",
|
||||
"@deck.gl/react": "^9.1.14",
|
||||
"@luma.gl/constants": "^9.1.9",
|
||||
"@luma.gl/constants": "^9.2.2",
|
||||
"@luma.gl/core": "^9.1.9",
|
||||
"@luma.gl/engine": "^9.1.9",
|
||||
"@luma.gl/shadertools": "^9.1.9",
|
||||
"@luma.gl/webgl": "^9.1.9",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/geojson-extent": "^1.0.1",
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/d3-array": "^2.0.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"d3-array": "^1.2.4",
|
||||
"d3-color": "^1.4.1",
|
||||
"d3-scale": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
|
||||
@@ -156,13 +156,27 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
switch (selectedColorScheme) {
|
||||
case COLOR_SCHEME_TYPES.fixed_color: {
|
||||
color = fd.color_picker || { r: 0, g: 0, b: 0, a: 100 };
|
||||
const colorArray = [color.r, color.g, color.b, color.a * 255];
|
||||
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: [color.r, color.g, color.b, color.a * 255],
|
||||
}));
|
||||
return data.map(d => ({ ...d, color: colorArray }));
|
||||
}
|
||||
case COLOR_SCHEME_TYPES.categorical_palette: {
|
||||
if (!fd.dimension) {
|
||||
const fallbackColor = fd.color_picker || {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 100,
|
||||
};
|
||||
const colorArray = [
|
||||
fallbackColor.r,
|
||||
fallbackColor.g,
|
||||
fallbackColor.b,
|
||||
fallbackColor.a * 255,
|
||||
];
|
||||
return data.map(d => ({ ...d, color: colorArray }));
|
||||
}
|
||||
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: hexToRGB(colorFn(d.cat_color, fd.slice_id)),
|
||||
@@ -190,17 +204,17 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
d.metric <= breakpoint.maxValue,
|
||||
);
|
||||
|
||||
return {
|
||||
...d,
|
||||
color: breakpointForPoint
|
||||
? [
|
||||
breakpointForPoint?.color.r,
|
||||
breakpointForPoint?.color.g,
|
||||
breakpointForPoint?.color.b,
|
||||
breakpointForPoint?.color.a * 255,
|
||||
]
|
||||
: defaultBreakpointColor,
|
||||
};
|
||||
if (breakpointForPoint) {
|
||||
const pointColor = [
|
||||
breakpointForPoint.color.r,
|
||||
breakpointForPoint.color.g,
|
||||
breakpointForPoint.color.b,
|
||||
breakpointForPoint.color.a * 255,
|
||||
];
|
||||
return { ...d, color: pointColor };
|
||||
}
|
||||
|
||||
return { ...d, color: defaultBreakpointColor };
|
||||
});
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -46,14 +46,14 @@ export interface DeckScatterFormData
|
||||
min_radius?: number;
|
||||
max_radius?: number;
|
||||
color_picker?: { r: number; g: number; b: number; a: number };
|
||||
category_name?: string;
|
||||
dimension?: string;
|
||||
}
|
||||
|
||||
export default function buildQuery(formData: DeckScatterFormData) {
|
||||
const {
|
||||
spatial,
|
||||
point_radius_fixed,
|
||||
category_name,
|
||||
dimension,
|
||||
js_columns,
|
||||
tooltip_contents,
|
||||
} = formData;
|
||||
@@ -67,8 +67,8 @@ export default function buildQuery(formData: DeckScatterFormData) {
|
||||
const spatialColumns = getSpatialColumns(spatial);
|
||||
let columns = [...(baseQueryObject.columns || []), ...spatialColumns];
|
||||
|
||||
if (category_name) {
|
||||
columns.push(category_name);
|
||||
if (dimension) {
|
||||
columns.push(dimension);
|
||||
}
|
||||
|
||||
const columnStrings = columns.map(col =>
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
tooltipContents,
|
||||
tooltipTemplate,
|
||||
} from '../../utilities/Shared_DeckGL';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
onInit: controlState => ({
|
||||
@@ -134,9 +133,7 @@ const config: ControlPanelConfig = {
|
||||
controlSetRows: [
|
||||
[legendPosition],
|
||||
[legendFormat],
|
||||
...generateDeckGLColorSchemeControls({
|
||||
defaultSchemeType: COLOR_SCHEME_TYPES.fixed_color,
|
||||
}),
|
||||
...generateDeckGLColorSchemeControls({}),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -95,7 +95,7 @@ function processScatterData(
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { rawFormData: formData } = chartProps;
|
||||
const { spatial, point_radius_fixed, category_name, js_columns } =
|
||||
const { spatial, point_radius_fixed, dimension, js_columns } =
|
||||
formData as DeckScatterFormData;
|
||||
|
||||
const radiusMetricLabel = getMetricLabelFromFormData(point_radius_fixed);
|
||||
@@ -104,7 +104,7 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
records,
|
||||
spatial,
|
||||
radiusMetricLabel,
|
||||
category_name,
|
||||
dimension,
|
||||
js_columns,
|
||||
);
|
||||
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState, memo } from 'react';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { useEffect, useState, memo, useMemo } from 'react';
|
||||
import { styled, t, sanitizeHtml } from '@superset-ui/core';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||
import Handlebars from 'handlebars';
|
||||
import { isPlainObject } from 'lodash';
|
||||
|
||||
@@ -45,8 +44,6 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
|
||||
appContainer?.getAttribute('data-bootstrap') || '{}',
|
||||
);
|
||||
const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
|
||||
const htmlSchemaOverrides =
|
||||
common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -60,6 +57,12 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
|
||||
}
|
||||
}, [templateSource, data]);
|
||||
|
||||
const htmlContent = useMemo(
|
||||
() =>
|
||||
htmlSanitization ? sanitizeHtml(renderedTemplate) : renderedTemplate,
|
||||
[renderedTemplate, htmlSanitization],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorContainer>{error}</ErrorContainer>;
|
||||
}
|
||||
@@ -73,13 +76,9 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
<SafeMarkdown
|
||||
source={renderedTemplate || ''}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
</div>
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
"d3-tip": "^0.9.1",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.2.7",
|
||||
"dompurify": "^3.3.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"d3-array": "^1.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -142,7 +142,7 @@ const config: ControlPanelConfig = {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('X AXIS TITLE MARGIN'),
|
||||
label: t('X axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[1],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
@@ -214,7 +214,7 @@ const config: ControlPanelConfig = {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('Y AXIS TITLE MARGIN'),
|
||||
label: t('Y axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[1],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
|
||||
@@ -81,7 +81,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('AXIS TITLE MARGIN'),
|
||||
label: t('Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[0],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
@@ -114,7 +114,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('AXIS TITLE MARGIN'),
|
||||
label: t('Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[1],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
@@ -132,7 +132,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: false,
|
||||
label: t('AXIS TITLE POSITION'),
|
||||
label: t('Axis title position'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_POSITION_OPTIONS[0][0],
|
||||
choices: sections.TITLE_POSITION_OPTIONS,
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/types": "^7.28.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest": "^30.0.5"
|
||||
"jest": "^30.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,38 @@ const Styles = styled.div<PivotTableStylesProps>`
|
||||
`;
|
||||
|
||||
const PivotTableWrapper = styled.div`
|
||||
height: 100%;
|
||||
max-width: inherit;
|
||||
overflow: auto;
|
||||
${({ theme }) => `
|
||||
height: 100%;
|
||||
max-width: inherit;
|
||||
overflow: auto;
|
||||
|
||||
/* Chrome/Safari/Edge webkit scrollbar styling */
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${theme.colorFillSecondary};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorFillTertiary};
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
/* Firefox scrollbar styling */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${theme.colorFillSecondary} ${theme.colorFillQuaternary};
|
||||
`}
|
||||
`;
|
||||
|
||||
const METRIC_KEY = t('Metric');
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
DragEvent,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { styled, typedMemo, usePrevious } from '@superset-ui/core';
|
||||
import { typedMemo, usePrevious } from '@superset-ui/core';
|
||||
import {
|
||||
useTable,
|
||||
usePagination,
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from 'react-table';
|
||||
import { matchSorter, rankings } from 'match-sorter';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Space } from '@superset-ui/core/components';
|
||||
import { Flex, Space } from '@superset-ui/core/components';
|
||||
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
|
||||
import SelectPageSize, {
|
||||
SelectPageSizeProps,
|
||||
@@ -77,7 +77,7 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
|
||||
sticky?: boolean;
|
||||
rowCount: number;
|
||||
wrapperRef?: MutableRefObject<HTMLDivElement>;
|
||||
onColumnOrderChange: () => void;
|
||||
onColumnOrderChange?: () => void;
|
||||
renderGroupingHeaders?: () => JSX.Element;
|
||||
renderTimeComparisonDropdown?: () => JSX.Element;
|
||||
handleSortByChange: (sortBy: SortByItem[]) => void;
|
||||
@@ -98,24 +98,6 @@ const sortTypes = {
|
||||
alphanumeric: sortAlphanumericCaseInsensitive,
|
||||
};
|
||||
|
||||
const StyledSpace = styled(Space)`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.search-select-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-by-label {
|
||||
align-self: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledRow = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
// Be sure to pass our updateMyData and the skipReset option
|
||||
export default typedMemo(function DataTable<D extends object>({
|
||||
tableClassName,
|
||||
@@ -336,8 +318,7 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
const colToBeMoved = currentCols.splice(columnBeingDragged, 1);
|
||||
currentCols.splice(newPosition, 0, colToBeMoved[0]);
|
||||
setColumnOrder(currentCols);
|
||||
// toggle value in TableChart to trigger column width recalc
|
||||
onColumnOrderChange();
|
||||
onColumnOrderChange?.();
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
@@ -450,30 +431,36 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
>
|
||||
{hasGlobalControl ? (
|
||||
<div ref={globalControlRef} className="form-inline dt-controls">
|
||||
<StyledRow className="row">
|
||||
<StyledSpace size="middle">
|
||||
{hasPagination ? (
|
||||
<SelectPageSize
|
||||
total={resultsSize}
|
||||
current={resultCurrentPageSize}
|
||||
options={pageSizeOptions}
|
||||
selectRenderer={
|
||||
typeof selectPageSize === 'boolean'
|
||||
? undefined
|
||||
: selectPageSize
|
||||
}
|
||||
onChange={setPageSize}
|
||||
/>
|
||||
) : null}
|
||||
<Flex
|
||||
wrap
|
||||
className="row"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
gap="middle"
|
||||
>
|
||||
{hasPagination ? (
|
||||
<SelectPageSize
|
||||
total={resultsSize}
|
||||
current={resultCurrentPageSize}
|
||||
options={pageSizeOptions}
|
||||
selectRenderer={
|
||||
typeof selectPageSize === 'boolean'
|
||||
? undefined
|
||||
: selectPageSize
|
||||
}
|
||||
onChange={setPageSize}
|
||||
/>
|
||||
) : null}
|
||||
<Flex wrap align="center" gap="middle">
|
||||
{serverPagination && (
|
||||
<div className="search-select-container">
|
||||
<span className="search-by-label">Search by: </span>
|
||||
<Space size="small" className="search-select-container">
|
||||
<span className="search-by-label">Search by:</span>
|
||||
<SearchSelectDropdown
|
||||
searchOptions={searchOptions}
|
||||
value={serverPaginationData?.searchColumn || ''}
|
||||
onChange={onSearchColChange}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
{searchInput && (
|
||||
<GlobalFilter<D>
|
||||
@@ -493,8 +480,8 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
{renderTimeComparisonDropdown
|
||||
? renderTimeComparisonDropdown()
|
||||
: null}
|
||||
</StyledSpace>
|
||||
</StyledRow>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
) : null}
|
||||
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
UIEventHandler,
|
||||
} from 'react';
|
||||
import { TableInstance, Hooks } from 'react-table';
|
||||
import { useTheme, css } from '@superset-ui/core';
|
||||
import getScrollBarSize from '../utils/getScrollBarSize';
|
||||
import needScrollBar from '../utils/needScrollBar';
|
||||
import useMountedMemo from '../utils/useMountedMemo';
|
||||
@@ -125,6 +126,8 @@ function StickyWrap({
|
||||
children: Table;
|
||||
sticky?: StickyState; // current sticky element sizes
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!table || table.type !== 'table') {
|
||||
throw new Error('<StickyWrap> must have only one <table> element as child');
|
||||
}
|
||||
@@ -221,6 +224,26 @@ function StickyWrap({
|
||||
let footerTable: ReactElement | undefined;
|
||||
let bodyTable: ReactElement | undefined;
|
||||
|
||||
const scrollBarStyles = css`
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${theme.colorFillSecondary};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
&:hover {
|
||||
background: ${theme.colorFillTertiary};
|
||||
}
|
||||
}
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
`;
|
||||
|
||||
if (needSizer) {
|
||||
const theadWithRef = cloneElement(thead, { ref: theadRef });
|
||||
const tfootWithRef = tfoot && cloneElement(tfoot, { ref: tfootRef });
|
||||
@@ -233,6 +256,7 @@ function StickyWrap({
|
||||
visibility: 'hidden',
|
||||
scrollbarGutter: 'stable',
|
||||
}}
|
||||
css={scrollBarStyles}
|
||||
role="presentation"
|
||||
>
|
||||
{cloneElement(
|
||||
@@ -316,6 +340,7 @@ function StickyWrap({
|
||||
overflow: 'auto',
|
||||
scrollbarGutter: 'stable',
|
||||
}}
|
||||
css={scrollBarStyles}
|
||||
onScroll={sticky.hasHorizontalScroll ? onScroll : undefined}
|
||||
role="presentation"
|
||||
>
|
||||
|
||||
@@ -195,6 +195,21 @@ function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
|
||||
return sortIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label that is visually hidden but accessible
|
||||
*/
|
||||
const VisuallyHidden = styled.label`
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
function SearchInput({
|
||||
count,
|
||||
value,
|
||||
@@ -225,10 +240,10 @@ function SelectPageSize({
|
||||
const { Option } = Select;
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="pageSizeSelect" className="sr-only">
|
||||
<span className="dt-select-page-size">
|
||||
<VisuallyHidden htmlFor="pageSizeSelect">
|
||||
{t('Select page size')}
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{t('Show')}{' '}
|
||||
<Select<number>
|
||||
id="pageSizeSelect"
|
||||
@@ -252,7 +267,7 @@ function SelectPageSize({
|
||||
})}
|
||||
</Select>{' '}
|
||||
{t('entries per page')}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,12 +311,17 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
serverPageLength,
|
||||
slice_id,
|
||||
} = props;
|
||||
const comparisonColumns = [
|
||||
{ key: 'all', label: t('Display all') },
|
||||
{ key: '#', label: '#' },
|
||||
{ key: '△', label: '△' },
|
||||
{ key: '%', label: '%' },
|
||||
];
|
||||
|
||||
const comparisonColumns = useMemo(
|
||||
() => [
|
||||
{ key: 'all', label: t('Display all') },
|
||||
{ key: '#', label: '#' },
|
||||
{ key: '△', label: '△' },
|
||||
{ key: '%', label: '%' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const timestampFormatter = useCallback(
|
||||
value => getTimeFormatterForGranularity(timeGrain)(value),
|
||||
[timeGrain],
|
||||
@@ -353,71 +373,74 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[filters],
|
||||
);
|
||||
|
||||
const getCrossFilterDataMask = (key: string, value: DataRecordValue) => {
|
||||
let updatedFilters = { ...(filters || {}) };
|
||||
if (filters && isActiveFilterValue(key, value)) {
|
||||
updatedFilters = {};
|
||||
} else {
|
||||
updatedFilters = {
|
||||
[key]: [value],
|
||||
};
|
||||
}
|
||||
if (
|
||||
Array.isArray(updatedFilters[key]) &&
|
||||
updatedFilters[key].length === 0
|
||||
) {
|
||||
delete updatedFilters[key];
|
||||
}
|
||||
|
||||
const groupBy = Object.keys(updatedFilters);
|
||||
const groupByValues = Object.values(updatedFilters);
|
||||
const labelElements: string[] = [];
|
||||
groupBy.forEach(col => {
|
||||
const isTimestamp = col === DTTM_ALIAS;
|
||||
const filterValues = ensureIsArray(updatedFilters?.[col]);
|
||||
if (filterValues.length) {
|
||||
const valueLabels = filterValues.map(value =>
|
||||
isTimestamp ? timestampFormatter(value) : value,
|
||||
);
|
||||
labelElements.push(`${valueLabels.join(', ')}`);
|
||||
const getCrossFilterDataMask = useCallback(
|
||||
(key: string, value: DataRecordValue) => {
|
||||
let updatedFilters = { ...(filters || {}) };
|
||||
if (filters && isActiveFilterValue(key, value)) {
|
||||
updatedFilters = {};
|
||||
} else {
|
||||
updatedFilters = {
|
||||
[key]: [value],
|
||||
};
|
||||
}
|
||||
if (
|
||||
Array.isArray(updatedFilters[key]) &&
|
||||
updatedFilters[key].length === 0
|
||||
) {
|
||||
delete updatedFilters[key];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters:
|
||||
groupBy.length === 0
|
||||
? []
|
||||
: groupBy.map(col => {
|
||||
const val = ensureIsArray(updatedFilters?.[col]);
|
||||
if (!val.length)
|
||||
const groupBy = Object.keys(updatedFilters);
|
||||
const groupByValues = Object.values(updatedFilters);
|
||||
const labelElements: string[] = [];
|
||||
groupBy.forEach(col => {
|
||||
const isTimestamp = col === DTTM_ALIAS;
|
||||
const filterValues = ensureIsArray(updatedFilters?.[col]);
|
||||
if (filterValues.length) {
|
||||
const valueLabels = filterValues.map(value =>
|
||||
isTimestamp ? timestampFormatter(value) : value,
|
||||
);
|
||||
labelElements.push(`${valueLabels.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters:
|
||||
groupBy.length === 0
|
||||
? []
|
||||
: groupBy.map(col => {
|
||||
const val = ensureIsArray(updatedFilters?.[col]);
|
||||
if (!val.length)
|
||||
return {
|
||||
col,
|
||||
op: 'IS NULL' as const,
|
||||
};
|
||||
return {
|
||||
col,
|
||||
op: 'IS NULL' as const,
|
||||
op: 'IN' as const,
|
||||
val: val.map(el =>
|
||||
el instanceof Date ? el.getTime() : el!,
|
||||
),
|
||||
grain: col === DTTM_ALIAS ? timeGrain : undefined,
|
||||
};
|
||||
return {
|
||||
col,
|
||||
op: 'IN' as const,
|
||||
val: val.map(el =>
|
||||
el instanceof Date ? el.getTime() : el!,
|
||||
),
|
||||
grain: col === DTTM_ALIAS ? timeGrain : undefined,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
},
|
||||
filterState: {
|
||||
label: labelElements.join(', '),
|
||||
value: groupByValues.length ? groupByValues : null,
|
||||
filters:
|
||||
updatedFilters && Object.keys(updatedFilters).length
|
||||
? updatedFilters
|
||||
: null,
|
||||
},
|
||||
},
|
||||
filterState: {
|
||||
label: labelElements.join(', '),
|
||||
value: groupByValues.length ? groupByValues : null,
|
||||
filters:
|
||||
updatedFilters && Object.keys(updatedFilters).length
|
||||
? updatedFilters
|
||||
: null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: isActiveFilterValue(key, value),
|
||||
};
|
||||
};
|
||||
isCurrentValueSelected: isActiveFilterValue(key, value),
|
||||
};
|
||||
},
|
||||
[filters, isActiveFilterValue, timestampFormatter, timeGrain],
|
||||
);
|
||||
|
||||
const toggleFilter = useCallback(
|
||||
function toggleFilter(key: string, val: DataRecordValue) {
|
||||
@@ -429,17 +452,21 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[emitCrossFilters, getCrossFilterDataMask, setDataMask],
|
||||
);
|
||||
|
||||
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
|
||||
const { isNumeric, config = {} } = column;
|
||||
const textAlign =
|
||||
config.horizontalAlign ||
|
||||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
|
||||
return {
|
||||
textAlign,
|
||||
};
|
||||
};
|
||||
const getSharedStyle = useCallback(
|
||||
(column: DataColumnMeta): CSSProperties => {
|
||||
const { isNumeric, config = {} } = column;
|
||||
const textAlign =
|
||||
config.horizontalAlign ||
|
||||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
|
||||
return {
|
||||
textAlign,
|
||||
};
|
||||
},
|
||||
[isUsingTimeComparison],
|
||||
);
|
||||
|
||||
const comparisonLabels = useMemo(() => [t('Main'), '#', '△', '%'], []);
|
||||
|
||||
const comparisonLabels = [t('Main'), '#', '△', '%'];
|
||||
const filteredColumnsMeta = useMemo(() => {
|
||||
if (!isUsingTimeComparison) {
|
||||
return columnsMeta;
|
||||
@@ -471,79 +498,86 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
selectedComparisonColumns,
|
||||
]);
|
||||
|
||||
const handleContextMenu =
|
||||
onContextMenu && !isRawRecords
|
||||
? (
|
||||
value: D,
|
||||
cellPoint: {
|
||||
key: string;
|
||||
value: DataRecordValue;
|
||||
isMetric?: boolean;
|
||||
},
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
filteredColumnsMeta.forEach(col => {
|
||||
if (!col.isMetric) {
|
||||
const dataRecordValue = value[col.key];
|
||||
drillToDetailFilters.push({
|
||||
col: col.key,
|
||||
op: '==',
|
||||
val: dataRecordValue as string | number | boolean,
|
||||
formattedVal: formatColumnValue(col, dataRecordValue)[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
onContextMenu(clientX, clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
crossFilter: cellPoint.isMetric
|
||||
? undefined
|
||||
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
|
||||
drillBy: cellPoint.isMetric
|
||||
? undefined
|
||||
: {
|
||||
filters: [
|
||||
{
|
||||
col: cellPoint.key,
|
||||
op: '==',
|
||||
val: cellPoint.value as string | number | boolean,
|
||||
},
|
||||
],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const getHeaderColumns = (
|
||||
columnsMeta: DataColumnMeta[],
|
||||
enableTimeComparison?: boolean,
|
||||
) => {
|
||||
const resultMap: Record<string, number[]> = {};
|
||||
|
||||
if (!enableTimeComparison) {
|
||||
return resultMap;
|
||||
const handleContextMenu = useMemo(() => {
|
||||
if (onContextMenu && !isRawRecords) {
|
||||
return (
|
||||
value: D,
|
||||
cellPoint: {
|
||||
key: string;
|
||||
value: DataRecordValue;
|
||||
isMetric?: boolean;
|
||||
},
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
filteredColumnsMeta.forEach(col => {
|
||||
if (!col.isMetric) {
|
||||
const dataRecordValue = value[col.key];
|
||||
drillToDetailFilters.push({
|
||||
col: col.key,
|
||||
op: '==',
|
||||
val: dataRecordValue as string | number | boolean,
|
||||
formattedVal: formatColumnValue(col, dataRecordValue)[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
onContextMenu(clientX, clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
crossFilter: cellPoint.isMetric
|
||||
? undefined
|
||||
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
|
||||
drillBy: cellPoint.isMetric
|
||||
? undefined
|
||||
: {
|
||||
filters: [
|
||||
{
|
||||
col: cellPoint.key,
|
||||
op: '==',
|
||||
val: cellPoint.value as string | number | boolean,
|
||||
},
|
||||
],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [
|
||||
onContextMenu,
|
||||
isRawRecords,
|
||||
filteredColumnsMeta,
|
||||
getCrossFilterDataMask,
|
||||
]);
|
||||
|
||||
columnsMeta.forEach((element, index) => {
|
||||
// Check if element's label is one of the comparison labels
|
||||
if (comparisonLabels.includes(element.label)) {
|
||||
// Extract the key portion after the space, assuming the format is always "label key"
|
||||
const keyPortion = element.key.substring(element.label.length);
|
||||
const getHeaderColumns = useCallback(
|
||||
(columnsMeta: DataColumnMeta[], enableTimeComparison?: boolean) => {
|
||||
const resultMap: Record<string, number[]> = {};
|
||||
|
||||
// If the key portion is not in the map, initialize it with the current index
|
||||
if (!resultMap[keyPortion]) {
|
||||
resultMap[keyPortion] = [index];
|
||||
} else {
|
||||
// Add the index to the existing array
|
||||
resultMap[keyPortion].push(index);
|
||||
}
|
||||
if (!enableTimeComparison) {
|
||||
return resultMap;
|
||||
}
|
||||
});
|
||||
|
||||
return resultMap;
|
||||
};
|
||||
columnsMeta.forEach((element, index) => {
|
||||
// Check if element's label is one of the comparison labels
|
||||
if (comparisonLabels.includes(element.label)) {
|
||||
// Extract the key portion after the space, assuming the format is always "label key"
|
||||
const keyPortion = element.key.substring(element.label.length);
|
||||
|
||||
// If the key portion is not in the map, initialize it with the current index
|
||||
if (!resultMap[keyPortion]) {
|
||||
resultMap[keyPortion] = [index];
|
||||
} else {
|
||||
// Add the index to the existing array
|
||||
resultMap[keyPortion].push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return resultMap;
|
||||
},
|
||||
[comparisonLabels],
|
||||
);
|
||||
|
||||
const renderTimeComparisonDropdown = (): JSX.Element => {
|
||||
const allKey = comparisonColumns[0].key;
|
||||
@@ -638,6 +672,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
);
|
||||
};
|
||||
|
||||
const groupHeaderColumns = useMemo(
|
||||
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
|
||||
[filteredColumnsMeta, getHeaderColumns, isUsingTimeComparison],
|
||||
);
|
||||
|
||||
const renderGroupingHeaders = (): JSX.Element => {
|
||||
// TODO: Make use of ColumnGroup to render the aditional headers
|
||||
const headers: any = [];
|
||||
@@ -719,11 +758,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
);
|
||||
};
|
||||
|
||||
const groupHeaderColumns = useMemo(
|
||||
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
|
||||
[filteredColumnsMeta, isUsingTimeComparison],
|
||||
);
|
||||
|
||||
const getColumnConfigs = useCallback(
|
||||
(
|
||||
column: DataColumnMeta,
|
||||
@@ -1086,19 +1120,27 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
},
|
||||
[
|
||||
getSharedStyle,
|
||||
defaultAlignPN,
|
||||
defaultColorPN,
|
||||
emitCrossFilters,
|
||||
getValueRange,
|
||||
isActiveFilterValue,
|
||||
isRawRecords,
|
||||
showCellBars,
|
||||
sortDesc,
|
||||
toggleFilter,
|
||||
totals,
|
||||
columnColorFormatters,
|
||||
columnOrderToggle,
|
||||
isUsingTimeComparison,
|
||||
basicColorFormatters,
|
||||
showCellBars,
|
||||
isRawRecords,
|
||||
getValueRange,
|
||||
emitCrossFilters,
|
||||
comparisonLabels,
|
||||
totals,
|
||||
theme,
|
||||
sortDesc,
|
||||
groupHeaderColumns,
|
||||
allowRenderHtml,
|
||||
basicColorColumnFormatters,
|
||||
isActiveFilterValue,
|
||||
toggleFilter,
|
||||
handleContextMenu,
|
||||
allowRearrangeColumns,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1131,7 +1173,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
if (!isEqual(options, searchOptions)) {
|
||||
setSearchOptions(options || []);
|
||||
}
|
||||
}, [columns]);
|
||||
}, [columns, searchOptions]);
|
||||
|
||||
const handleServerPaginationChange = useCallback(
|
||||
(pageNumber: number, pageSize: number) => {
|
||||
@@ -1142,7 +1184,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
[setDataMask],
|
||||
[serverPaginationData, setDataMask],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1154,7 +1196,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
}
|
||||
}, []);
|
||||
}, [
|
||||
hasServerPageLengthChanged,
|
||||
serverPageLength,
|
||||
serverPaginationData,
|
||||
setDataMask,
|
||||
]);
|
||||
|
||||
const handleSizeChange = useCallback(
|
||||
({ width, height }: { width: number; height: number }) => {
|
||||
@@ -1200,7 +1247,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
[setDataMask, serverPagination],
|
||||
[serverPagination, serverPaginationData, setDataMask],
|
||||
);
|
||||
|
||||
const handleSearch = (searchText: string) => {
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DatasourceType } from '@superset-ui/core';
|
||||
|
||||
export const id = 7;
|
||||
export const datasourceId = `${id}__table`;
|
||||
|
||||
@@ -40,125 +42,135 @@ export default {
|
||||
},
|
||||
metrics: [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'metric-1-uuid',
|
||||
expression: 'SUM(birth_names.num)',
|
||||
warning_text: null,
|
||||
verbose_name: 'sum__num',
|
||||
metric_name: 'sum__num',
|
||||
description: null,
|
||||
metric_type: 'sum',
|
||||
certified_by: 'someone',
|
||||
certification_details: 'foo',
|
||||
warning_markdown: 'bar',
|
||||
extra:
|
||||
'{"certification":{"details":"foo", "certified_by":"someone"},"warning_markdown":"bar"}',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'metric-2-uuid',
|
||||
expression: 'AVG(birth_names.num)',
|
||||
warning_text: null,
|
||||
verbose_name: 'avg__num',
|
||||
metric_name: 'avg__num',
|
||||
description: null,
|
||||
metric_type: 'avg',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: 'metric-3-uuid',
|
||||
expression: 'SUM(birth_names.num_boys)',
|
||||
warning_text: null,
|
||||
verbose_name: 'sum__num_boys',
|
||||
metric_name: 'sum__num_boys',
|
||||
description: null,
|
||||
metric_type: 'sum',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
uuid: 'metric-4-uuid',
|
||||
expression: 'AVG(birth_names.num_boys)',
|
||||
warning_text: null,
|
||||
verbose_name: 'avg__num_boys',
|
||||
metric_name: 'avg__num_boys',
|
||||
description: null,
|
||||
metric_type: 'avg',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
uuid: 'metric-5-uuid',
|
||||
expression: 'SUM(birth_names.num_girls)',
|
||||
warning_text: null,
|
||||
verbose_name: 'sum__num_girls',
|
||||
metric_name: 'sum__num_girls',
|
||||
description: null,
|
||||
metric_type: 'sum',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
uuid: 'metric-6-uuid',
|
||||
expression: 'AVG(birth_names.num_girls)',
|
||||
warning_text: null,
|
||||
verbose_name: 'avg__num_girls',
|
||||
metric_name: 'avg__num_girls',
|
||||
description: null,
|
||||
metric_type: 'avg',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
uuid: 'metric-7-uuid',
|
||||
expression: 'COUNT(*)',
|
||||
warning_text: null,
|
||||
verbose_name: 'COUNT(*)',
|
||||
metric_name: 'count',
|
||||
description: null,
|
||||
metric_type: 'count',
|
||||
},
|
||||
],
|
||||
column_formats: {},
|
||||
columns: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'DATETIME',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: true,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'ds',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'VARCHAR(16)',
|
||||
description: null,
|
||||
filterable: true,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: true,
|
||||
column_name: 'gender',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'VARCHAR(255)',
|
||||
description: null,
|
||||
filterable: true,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: true,
|
||||
column_name: 'name',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'BIGINT',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'num',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'VARCHAR(10)',
|
||||
description: null,
|
||||
filterable: true,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: true,
|
||||
column_name: 'state',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'BIGINT',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'num_boys',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
type: 'BIGINT',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'num_girls',
|
||||
@@ -169,7 +181,9 @@ export default {
|
||||
granularity_sqla: [['ds', 'ds']],
|
||||
main_dttm_col: 'ds',
|
||||
name: 'birth_names',
|
||||
owners: [{ first_name: 'joe', last_name: 'man', id: 1 }],
|
||||
owners: [
|
||||
{ first_name: 'joe', last_name: 'man', id: 1, username: 'joeman' },
|
||||
],
|
||||
database: {
|
||||
name: 'main',
|
||||
backend: 'sqlite',
|
||||
@@ -198,6 +212,11 @@ export default {
|
||||
['["num_girls", true]', 'num_girls [asc]'],
|
||||
['["num_girls", false]', 'num_girls [desc]'],
|
||||
],
|
||||
type: 'table',
|
||||
type: DatasourceType.Table,
|
||||
description: null,
|
||||
is_managed_externally: false,
|
||||
normalize_columns: false,
|
||||
always_filter_main_dttm: false,
|
||||
datasource_name: null,
|
||||
},
|
||||
};
|
||||
@@ -394,7 +394,7 @@ export function runQueryFromSqlEditor(
|
||||
dbId: qe.dbId,
|
||||
sql: qe.selectedText || qe.sql,
|
||||
sqlEditorId: qe.tabViewId ?? qe.id,
|
||||
immutableId: qe.immutableId,
|
||||
sqlEditorImmutableId: qe.immutableId,
|
||||
tab: qe.name,
|
||||
catalog: qe.catalog,
|
||||
schema: qe.schema,
|
||||
|
||||
@@ -602,4 +602,42 @@ describe('ResultSet', () => {
|
||||
);
|
||||
expect(queryByTestId('copy-to-clipboard-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should include sqlEditorImmutableId in query object when fetching results', async () => {
|
||||
const queryWithResultsKey = {
|
||||
...queries[0],
|
||||
resultsKey: 'test-results-key',
|
||||
sqlEditorImmutableId: 'test-immutable-id-123',
|
||||
};
|
||||
|
||||
const store = mockStore({
|
||||
...initialState,
|
||||
user,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
queries: {
|
||||
[queryWithResultsKey.id]: queryWithResultsKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setup({ ...mockedProps, queryId: queryWithResultsKey.id }, store);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that REQUEST_QUERY_RESULTS action was dispatched
|
||||
const actions = store.getActions();
|
||||
const requestAction = actions.find(
|
||||
action => action.type === 'REQUEST_QUERY_RESULTS',
|
||||
);
|
||||
expect(requestAction).toBeDefined();
|
||||
// Verify sqlEditorImmutableId is present in the query object
|
||||
expect(requestAction?.query?.sqlEditorImmutableId).toBe(
|
||||
'test-immutable-id-123',
|
||||
);
|
||||
});
|
||||
|
||||
// Verify the API was called
|
||||
const resultsCalls = fetchMock.calls('glob:*/api/v1/sqllab/results/*');
|
||||
expect(resultsCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
ButtonGroup,
|
||||
Tooltip,
|
||||
Card,
|
||||
Modal,
|
||||
Input,
|
||||
Label,
|
||||
Loading,
|
||||
@@ -87,6 +86,7 @@ import {
|
||||
} from 'src/logger/LogUtils';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { useConfirmModal } from 'src/hooks/useConfirmModal';
|
||||
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
|
||||
import ExploreResultsButton from '../ExploreResultsButton';
|
||||
import HighlightedSql from '../HighlightedSql';
|
||||
@@ -156,12 +156,14 @@ const ResultSetButtons = styled.div`
|
||||
padding-right: ${({ theme }) => 2 * theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const copyButtonStyles = css`
|
||||
const CopyStyledButton = styled(Button)`
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colorPrimary};
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
span > :first-of-type {
|
||||
margin: 0px;
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -196,6 +198,7 @@ const ResultSet = ({
|
||||
'sql',
|
||||
'executedSql',
|
||||
'sqlEditorId',
|
||||
'sqlEditorImmutableId',
|
||||
'templateParams',
|
||||
'schema',
|
||||
'rows',
|
||||
@@ -226,6 +229,7 @@ const ResultSet = ({
|
||||
const history = useHistory();
|
||||
const dispatch = useDispatch();
|
||||
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
|
||||
const { showConfirm, ConfirmModal } = useConfirmModal();
|
||||
|
||||
const reRunQueryIfSessionTimeoutErrorOnMount = useCallback(() => {
|
||||
if (
|
||||
@@ -302,7 +306,7 @@ const ResultSet = ({
|
||||
|
||||
const renderControls = () => {
|
||||
if (search || visualize || csv) {
|
||||
const { results, queryLimit, limitingFactor, rows } = query;
|
||||
const { limitingFactor, queryLimit, results, rows } = query;
|
||||
const limit = queryLimit || results.query.limit;
|
||||
const rowsCount = Math.min(rows || 0, results?.data?.length || 0);
|
||||
let { data } = query.results;
|
||||
@@ -310,7 +314,6 @@ const ResultSet = ({
|
||||
data = cachedData;
|
||||
}
|
||||
const { columns } = query.results;
|
||||
// Added compute logic to stop user from being able to Save & Explore
|
||||
|
||||
const datasource: ISaveableDatasource = {
|
||||
columns: query.results.columns as ISimpleColumn[],
|
||||
@@ -327,6 +330,27 @@ const ResultSet = ({
|
||||
user?.roles,
|
||||
);
|
||||
|
||||
const handleDownloadCsv = (event: React.MouseEvent<HTMLElement>) => {
|
||||
logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
|
||||
|
||||
if (limitingFactor === LimitingFactor.Dropdown && limit === rowsCount) {
|
||||
event.preventDefault();
|
||||
|
||||
showConfirm({
|
||||
title: t('Download is on the way'),
|
||||
body: t(
|
||||
'Downloading %(rows)s rows based on the LIMIT configuration. If you want the entire result set, you need to adjust the LIMIT.',
|
||||
{ rows: rowsCount.toLocaleString() },
|
||||
),
|
||||
onConfirm: () => {
|
||||
window.location.href = getExportCsvUrl(query.id);
|
||||
},
|
||||
confirmText: t('OK'),
|
||||
cancelText: t('Close'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResultSetControls>
|
||||
<SaveDatasetModal
|
||||
@@ -347,45 +371,28 @@ const ResultSet = ({
|
||||
/>
|
||||
)}
|
||||
{csv && canExportData && (
|
||||
<Button
|
||||
css={copyButtonStyles}
|
||||
<CopyStyledButton
|
||||
buttonSize="small"
|
||||
buttonStyle="secondary"
|
||||
href={getExportCsvUrl(query.id)}
|
||||
data-test="export-csv-button"
|
||||
onClick={() => {
|
||||
logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
|
||||
if (
|
||||
limitingFactor === LimitingFactor.Dropdown &&
|
||||
limit === rowsCount
|
||||
) {
|
||||
Modal.warning({
|
||||
title: t('Download is on the way'),
|
||||
content: t(
|
||||
'Downloading %(rows)s rows based on the LIMIT configuration. If you want the entire result set, you need to adjust the LIMIT.',
|
||||
{ rows: rowsCount.toLocaleString() },
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={handleDownloadCsv}
|
||||
>
|
||||
<Icons.DownloadOutlined iconSize="m" /> {t('Download to CSV')}
|
||||
</Button>
|
||||
</CopyStyledButton>
|
||||
)}
|
||||
|
||||
{canExportData && (
|
||||
<CopyToClipboard
|
||||
text={prepareCopyToClipboardTabularData(data, columns)}
|
||||
wrapped={false}
|
||||
copyNode={
|
||||
<Button
|
||||
css={copyButtonStyles}
|
||||
<CopyStyledButton
|
||||
buttonSize="small"
|
||||
buttonStyle="secondary"
|
||||
data-test="copy-to-clipboard-button"
|
||||
>
|
||||
<Icons.CopyOutlined iconSize="s" /> {t('Copy to Clipboard')}
|
||||
</Button>
|
||||
</CopyStyledButton>
|
||||
}
|
||||
hideTooltip
|
||||
onCopyEnd={() =>
|
||||
@@ -653,68 +660,71 @@ const ResultSet = ({
|
||||
true,
|
||||
);
|
||||
return (
|
||||
<ResultContainer>
|
||||
{renderControls()}
|
||||
{showSql && showSqlInline ? (
|
||||
<>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: ${GAP}px;
|
||||
`}
|
||||
>
|
||||
<Card
|
||||
css={[
|
||||
css`
|
||||
height: 28px;
|
||||
width: calc(100% - ${ROWS_CHIP_WIDTH + GAP}px);
|
||||
code {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap !important;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
]}
|
||||
<>
|
||||
<ResultContainer>
|
||||
{renderControls()}
|
||||
{showSql && showSqlInline ? (
|
||||
<>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: ${GAP}px;
|
||||
`}
|
||||
>
|
||||
{sql}
|
||||
</Card>
|
||||
<Card
|
||||
css={[
|
||||
css`
|
||||
height: 28px;
|
||||
width: calc(100% - ${ROWS_CHIP_WIDTH + GAP}px);
|
||||
code {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap !important;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
]}
|
||||
>
|
||||
{sql}
|
||||
</Card>
|
||||
{renderRowsReturned(false)}
|
||||
</div>
|
||||
{renderRowsReturned(true)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderRowsReturned(false)}
|
||||
</div>
|
||||
{renderRowsReturned(true)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderRowsReturned(false)}
|
||||
{renderRowsReturned(true)}
|
||||
{sql}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
css={css`
|
||||
flex: 1 1 auto;
|
||||
`}
|
||||
>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
<ResultTable
|
||||
data={data}
|
||||
queryId={query.id}
|
||||
orderedColumnKeys={results.columns.map(
|
||||
col => col.column_name,
|
||||
)}
|
||||
height={height}
|
||||
filterText={searchText}
|
||||
expandedColumns={expandedColumns}
|
||||
allowHTML={allowHTML}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</ResultContainer>
|
||||
{renderRowsReturned(true)}
|
||||
{sql}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
css={css`
|
||||
flex: 1 1 auto;
|
||||
`}
|
||||
>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
<ResultTable
|
||||
data={data}
|
||||
queryId={query.id}
|
||||
orderedColumnKeys={results.columns.map(
|
||||
col => col.column_name,
|
||||
)}
|
||||
height={height}
|
||||
filterText={searchText}
|
||||
expandedColumns={expandedColumns}
|
||||
allowHTML={allowHTML}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</ResultContainer>
|
||||
{ConfirmModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (data && data.length === 0) {
|
||||
|
||||
@@ -238,6 +238,7 @@ export const queries = [
|
||||
ctas: false,
|
||||
cached: false,
|
||||
id: 'BkA1CLrJg',
|
||||
sqlEditorImmutableId: 'BkA1CLrJg_immutable',
|
||||
progress: 100,
|
||||
startDttm: 1476910566092.96,
|
||||
state: QueryState.Success,
|
||||
@@ -297,6 +298,7 @@ export const queries = [
|
||||
ctas: false,
|
||||
cached: false,
|
||||
id: 'S1zeAISkx',
|
||||
sqlEditorImmutableId: 'S1zeAISkx_immutable',
|
||||
progress: 100,
|
||||
startDttm: 1476910570802.2,
|
||||
state: QueryState.Success,
|
||||
@@ -331,6 +333,7 @@ export const queryWithNoQueryLimit = {
|
||||
ctas: false,
|
||||
cached: false,
|
||||
id: 'BkA1CLrJg',
|
||||
sqlEditorImmutableId: 'BkA1CLrJg_immutable',
|
||||
progress: 100,
|
||||
startDttm: 1476910566092.96,
|
||||
state: QueryState.Success,
|
||||
@@ -589,6 +592,7 @@ const baseQuery: QueryResponse = {
|
||||
ctas: false,
|
||||
cached: false,
|
||||
id: 'BkA1CLrJg',
|
||||
sqlEditorImmutableId: 'BkA1CLrJg_immutable',
|
||||
progress: 100,
|
||||
startDttm: 1476910566092.96,
|
||||
state: QueryState.Success,
|
||||
@@ -672,6 +676,7 @@ export const runningQuery: QueryResponse = {
|
||||
cached: false,
|
||||
ctas: false,
|
||||
id: 'ryhMUZCGb',
|
||||
sqlEditorImmutableId: 'ryhMUZCGb_immutable',
|
||||
progress: 90,
|
||||
state: QueryState.Running,
|
||||
startDttm: Date.now() - 500,
|
||||
@@ -683,6 +688,7 @@ export const successfulQuery: QueryResponse = {
|
||||
cached: false,
|
||||
ctas: false,
|
||||
id: 'ryhMUZCGb',
|
||||
sqlEditorImmutableId: 'ryhMUZCGb_immutable',
|
||||
progress: 100,
|
||||
state: QueryState.Success,
|
||||
startDttm: Date.now() - 500,
|
||||
|
||||
@@ -1019,10 +1019,16 @@ class DatasourceEditor extends PureComponent {
|
||||
<Field
|
||||
fieldKey="default_endpoint"
|
||||
label={t('Default URL')}
|
||||
description={t(
|
||||
`Default URL to redirect to when accessing from the dataset list page.
|
||||
Accepts relative URLs such as <span style=„white-space: nowrap;”>/superset/dashboard/{id}/</span>`,
|
||||
)}
|
||||
description={
|
||||
<>
|
||||
{t(
|
||||
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
|
||||
)}{' '}
|
||||
<Typography.Text code>
|
||||
/superset/dashboard/{'{id}'}/
|
||||
</Typography.Text>
|
||||
</>
|
||||
}
|
||||
control={<TextControl controlId="default_endpoint" />}
|
||||
/>
|
||||
<Field
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
cleanup,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
|
||||
import type { DatasetObject } from 'src/features/datasets/types';
|
||||
import DatasourceEditor from '..';
|
||||
|
||||
/* eslint-disable jest/no-export */
|
||||
@@ -34,8 +35,17 @@ jest.mock('@superset-ui/core', () => ({
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
interface DatasourceEditorProps {
|
||||
datasource: DatasetObject;
|
||||
addSuccessToast: () => void;
|
||||
addDangerToast: () => void;
|
||||
onChange: jest.Mock;
|
||||
columnLabels?: Record<string, string>;
|
||||
columnLabelTooltips?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Common setup for tests
|
||||
export const props = {
|
||||
export const props: DatasourceEditorProps = {
|
||||
datasource: mockDatasource['7__table'],
|
||||
addSuccessToast: () => {},
|
||||
addDangerToast: () => {},
|
||||
@@ -47,16 +57,19 @@ export const props = {
|
||||
state: 'This is a tooltip for state',
|
||||
},
|
||||
};
|
||||
|
||||
export const DATASOURCE_ENDPOINT =
|
||||
'glob:*/datasource/external_metadata_by_name/*';
|
||||
|
||||
const routeProps = {
|
||||
history: {},
|
||||
location: {},
|
||||
match: {},
|
||||
};
|
||||
export const asyncRender = props =>
|
||||
|
||||
export const asyncRender = (renderProps: DatasourceEditorProps) =>
|
||||
waitFor(() =>
|
||||
render(<DatasourceEditor {...props} {...routeProps} />, {
|
||||
render(<DatasourceEditor {...renderProps} {...routeProps} />, {
|
||||
useRedux: true,
|
||||
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
|
||||
useRouter: true,
|
||||
@@ -87,16 +100,16 @@ describe('DatasourceEditor', () => {
|
||||
|
||||
test('can sync columns from source', async () => {
|
||||
const columnsTab = screen.getByTestId('collection-tab-Columns');
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const syncButton = screen.getByText(/sync columns from source/i);
|
||||
expect(syncButton).toBeInTheDocument();
|
||||
|
||||
// Use a Promise to track when fetchMock is called
|
||||
const fetchPromise = new Promise(resolve => {
|
||||
const fetchPromise = new Promise<string>(resolve => {
|
||||
fetchMock.get(
|
||||
DATASOURCE_ENDPOINT,
|
||||
url => {
|
||||
(url: string) => {
|
||||
resolve(url);
|
||||
return [];
|
||||
},
|
||||
@@ -104,7 +117,7 @@ describe('DatasourceEditor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
userEvent.click(syncButton);
|
||||
await userEvent.click(syncButton);
|
||||
|
||||
// Wait for the fetch to be called
|
||||
const url = await fetchPromise;
|
||||
@@ -114,12 +127,12 @@ describe('DatasourceEditor', () => {
|
||||
// to add, remove and modify columns accordingly
|
||||
test('can modify columns', async () => {
|
||||
const columnsTab = screen.getByTestId('collection-tab-Columns');
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const getToggles = screen.getAllByRole('button', {
|
||||
name: /expand row/i,
|
||||
});
|
||||
userEvent.click(getToggles[0]);
|
||||
await userEvent.click(getToggles[0]);
|
||||
|
||||
const getTextboxes = await screen.findAllByRole('textbox');
|
||||
expect(getTextboxes.length).toBeGreaterThanOrEqual(5);
|
||||
@@ -132,22 +145,39 @@ describe('DatasourceEditor', () => {
|
||||
'Certification details',
|
||||
);
|
||||
|
||||
userEvent.type(inputLabel, 'test_label');
|
||||
userEvent.type(inputDescription, 'test');
|
||||
userEvent.type(inputDtmFormat, 'test');
|
||||
userEvent.type(inputCertifiedBy, 'test');
|
||||
userEvent.type(inputCertDetails, 'test');
|
||||
// Clear onChange mock to track user action callbacks
|
||||
props.onChange.mockClear();
|
||||
|
||||
await userEvent.type(inputLabel, 'test_label');
|
||||
await userEvent.type(inputDescription, 'test');
|
||||
await userEvent.type(inputDtmFormat, 'test');
|
||||
await userEvent.type(inputCertifiedBy, 'test');
|
||||
await userEvent.type(inputCertDetails, 'test');
|
||||
|
||||
// Verify the inputs were updated with the typed values
|
||||
await waitFor(() => {
|
||||
expect(inputLabel).toHaveValue('test_label');
|
||||
expect(inputDescription).toHaveValue('test');
|
||||
expect(inputDtmFormat).toHaveValue('test');
|
||||
expect(inputCertifiedBy).toHaveValue('test');
|
||||
expect(inputCertDetails).toHaveValue('test');
|
||||
});
|
||||
|
||||
// Verify that onChange was triggered by user actions
|
||||
await waitFor(() => {
|
||||
expect(props.onChange).toHaveBeenCalled();
|
||||
});
|
||||
}, 40000);
|
||||
|
||||
test('can delete columns', async () => {
|
||||
const columnsTab = screen.getByTestId('collection-tab-Columns');
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const getToggles = screen.getAllByRole('button', {
|
||||
name: /expand row/i,
|
||||
});
|
||||
|
||||
userEvent.click(getToggles[0]);
|
||||
await userEvent.click(getToggles[0]);
|
||||
|
||||
const deleteButtons = await screen.findAllByRole('button', {
|
||||
name: /delete item/i,
|
||||
@@ -155,7 +185,7 @@ describe('DatasourceEditor', () => {
|
||||
const initialCount = deleteButtons.length;
|
||||
expect(initialCount).toBeGreaterThan(0);
|
||||
|
||||
userEvent.click(deleteButtons[0]);
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const countRows = screen.getAllByRole('button', { name: /delete item/i });
|
||||
@@ -165,14 +195,14 @@ describe('DatasourceEditor', () => {
|
||||
|
||||
test('can add new columns', async () => {
|
||||
const calcColsTab = screen.getByTestId('collection-tab-Calculated columns');
|
||||
userEvent.click(calcColsTab);
|
||||
await userEvent.click(calcColsTab);
|
||||
|
||||
const addBtn = screen.getByRole('button', {
|
||||
name: /add item/i,
|
||||
});
|
||||
expect(addBtn).toBeInTheDocument();
|
||||
|
||||
userEvent.click(addBtn);
|
||||
await userEvent.click(addBtn);
|
||||
|
||||
// newColumn (Column name) is the first textbox in the tab
|
||||
await waitFor(() => {
|
||||
@@ -185,7 +215,7 @@ describe('DatasourceEditor', () => {
|
||||
const columnsTab = screen.getByRole('tab', {
|
||||
name: /settings/i,
|
||||
});
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const extraField = screen.getAllByText(/extra/i);
|
||||
expect(extraField.length).toBeGreaterThan(0);
|
||||
@@ -199,7 +229,7 @@ describe('DatasourceEditor', () => {
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('DatasourceEditor Source Tab', () => {
|
||||
beforeAll(() => {
|
||||
isFeatureEnabled.mockImplementation(() => false);
|
||||
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -215,12 +245,12 @@ describe('DatasourceEditor Source Tab', () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
isFeatureEnabled.mockRestore();
|
||||
(isFeatureEnabled as jest.Mock).mockRestore();
|
||||
});
|
||||
|
||||
test('Source Tab: edit mode', async () => {
|
||||
const getLockBtn = screen.getByRole('img', { name: /lock/i });
|
||||
userEvent.click(getLockBtn);
|
||||
await userEvent.click(getLockBtn);
|
||||
|
||||
const physicalRadioBtn = screen.getByRole('radio', {
|
||||
name: /physical \(table or view\)/i,
|
||||
@@ -259,7 +289,7 @@ describe('DatasourceEditor Source Tab', () => {
|
||||
datasource: {
|
||||
...props.datasource,
|
||||
table_name: 'Vehicle Sales +',
|
||||
datasourceType: 'virtual',
|
||||
type: DatasourceType.Query,
|
||||
sql: 'SELECT * FROM users',
|
||||
},
|
||||
});
|
||||
@@ -19,13 +19,16 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DatasetObject } from 'src/features/datasets/types';
|
||||
import DatasourceEditor from '..';
|
||||
import { props, DATASOURCE_ENDPOINT } from './DatasourceEditor.test';
|
||||
|
||||
type MetricType = DatasetObject['metrics'][number];
|
||||
|
||||
// Optimized render function that doesn't use waitFor initially
|
||||
// This helps prevent one source of the timeout
|
||||
const fastRender = props =>
|
||||
render(<DatasourceEditor {...props} />, {
|
||||
const fastRender = (renderProps: typeof props) =>
|
||||
render(<DatasourceEditor {...renderProps} />, {
|
||||
useRedux: true,
|
||||
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
|
||||
});
|
||||
@@ -66,13 +69,15 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
const metricButton = screen.getByTestId('collection-tab-Metrics');
|
||||
await userEvent.click(metricButton);
|
||||
|
||||
// Find and expand the first metric row
|
||||
// Find and expand the metric row with currency
|
||||
// Metrics are sorted by ID descending, so metric with id=1 (which has currency)
|
||||
// is at position 6 (last). Expand that one.
|
||||
const expandToggles = await screen.findAllByLabelText(
|
||||
/expand row/i,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
await userEvent.click(expandToggles[6]);
|
||||
|
||||
// Check for currency section header
|
||||
const currencyHeader = await screen.findByText(
|
||||
@@ -91,7 +96,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
expect(positionSelector).toBeInTheDocument();
|
||||
|
||||
// Open the dropdown
|
||||
userEvent.click(positionSelector);
|
||||
await userEvent.click(positionSelector);
|
||||
|
||||
// Wait for dropdown to open and find the suffix option
|
||||
const suffixOption = await waitFor(
|
||||
@@ -99,7 +104,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// Look for 'suffix' option in the dropdown
|
||||
const options = document.querySelectorAll('.ant-select-item-option');
|
||||
const suffixOpt = Array.from(options).find(opt =>
|
||||
opt.textContent.toLowerCase().includes('suffix'),
|
||||
opt.textContent?.toLowerCase().includes('suffix'),
|
||||
);
|
||||
|
||||
if (!suffixOpt) throw new Error('Suffix option not found');
|
||||
@@ -112,7 +117,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
propsWithCurrency.onChange.mockClear();
|
||||
|
||||
// Click the suffix option
|
||||
userEvent.click(suffixOption);
|
||||
await userEvent.click(suffixOption);
|
||||
|
||||
// Check if onChange was called with the expected parameters
|
||||
await waitFor(
|
||||
@@ -123,11 +128,12 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// More robust check for the metrics array
|
||||
const metrics = callArg.metrics || [];
|
||||
const updatedMetric = metrics.find(
|
||||
m => m.currency && m.currency.symbolPosition === 'suffix',
|
||||
(m: MetricType) =>
|
||||
m.currency && m.currency.symbolPosition === 'suffix',
|
||||
);
|
||||
|
||||
expect(updatedMetric).toBeDefined();
|
||||
expect(updatedMetric.currency.symbol).toBe('USD');
|
||||
expect(updatedMetric?.currency?.symbol).toBe('USD');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
@@ -142,7 +148,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
);
|
||||
|
||||
// Open the currency dropdown
|
||||
userEvent.click(currencySymbol);
|
||||
await userEvent.click(currencySymbol);
|
||||
|
||||
// Wait for dropdown to open and find the GBP option
|
||||
const gbpOption = await waitFor(
|
||||
@@ -150,7 +156,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// Look for 'GBP' option in the dropdown
|
||||
const options = document.querySelectorAll('.ant-select-item-option');
|
||||
const gbpOpt = Array.from(options).find(opt =>
|
||||
opt.textContent.includes('GBP'),
|
||||
opt.textContent?.includes('GBP'),
|
||||
);
|
||||
|
||||
if (!gbpOpt) throw new Error('GBP option not found');
|
||||
@@ -163,7 +169,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
propsWithCurrency.onChange.mockClear();
|
||||
|
||||
// Click the GBP option
|
||||
userEvent.click(gbpOption);
|
||||
await userEvent.click(gbpOption);
|
||||
|
||||
// Verify the onChange with GBP was called
|
||||
await waitFor(
|
||||
@@ -174,11 +180,11 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// More robust check
|
||||
const metrics = callArg.metrics || [];
|
||||
const updatedMetric = metrics.find(
|
||||
m => m.currency && m.currency.symbol === 'GBP',
|
||||
(m: MetricType) => m.currency && m.currency.symbol === 'GBP',
|
||||
);
|
||||
|
||||
expect(updatedMetric).toBeDefined();
|
||||
expect(updatedMetric.currency.symbolPosition).toBe('suffix');
|
||||
expect(updatedMetric?.currency?.symbolPosition).toBe('suffix');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
@@ -42,15 +42,18 @@ describe('DatasourceEditor RTL Metrics Tests', () => {
|
||||
await userEvent.click(metricButton);
|
||||
|
||||
const expandToggle = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggle[0]);
|
||||
// Metrics are sorted by ID descending, so metric with id=1 (which has certification)
|
||||
// is at position 6 (last). Expand that one.
|
||||
await userEvent.click(expandToggle[6]);
|
||||
|
||||
// Wait for fields to appear
|
||||
const certificationDetails = await screen.findByPlaceholderText(
|
||||
/certification details/i,
|
||||
);
|
||||
expect(certificationDetails.value).toEqual('foo');
|
||||
const certifiedBy = await screen.findByPlaceholderText(/certified by/i);
|
||||
|
||||
const warningMarkdown = await screen.findByPlaceholderText(/certified by/i);
|
||||
expect(warningMarkdown.value).toEqual('someone');
|
||||
expect(certificationDetails).toHaveValue('foo');
|
||||
expect(certifiedBy).toHaveValue('someone');
|
||||
});
|
||||
|
||||
test('properly updates the metric information', async () => {
|
||||
@@ -71,14 +74,14 @@ describe('DatasourceEditor RTL Metrics Tests', () => {
|
||||
/certification details/i,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(certifiedBy.value).toEqual('I am typing a new name');
|
||||
expect(certifiedBy).toHaveValue('I am typing a new name');
|
||||
});
|
||||
|
||||
await userEvent.clear(certificationDetails);
|
||||
await userEvent.type(certificationDetails, 'I am typing something new');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(certificationDetails.value).toEqual('I am typing something new');
|
||||
expect(certificationDetails).toHaveValue('I am typing something new');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
// Only import components that are directly referenced in tests
|
||||
import { ListView } from './ListView';
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
|
||||
function makeMockLocation(query) {
|
||||
const queryStr = encodeURIComponent(query);
|
||||
return {
|
||||
protocol: 'http:',
|
||||
host: 'localhost',
|
||||
pathname: '/',
|
||||
search: queryStr.length ? `?${queryStr}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
const fetchSelectsMock = jest.fn(() => []);
|
||||
const mockedProps = {
|
||||
title: 'Data Table',
|
||||
columns: [
|
||||
{
|
||||
accessor: 'id',
|
||||
Header: 'ID',
|
||||
sortable: true,
|
||||
id: 'id',
|
||||
},
|
||||
{
|
||||
accessor: 'age',
|
||||
Header: 'Age',
|
||||
id: 'age',
|
||||
},
|
||||
{
|
||||
accessor: 'name',
|
||||
Header: 'Name',
|
||||
id: 'name',
|
||||
},
|
||||
{
|
||||
accessor: 'time',
|
||||
Header: 'Time',
|
||||
id: 'time',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
Header: 'ID',
|
||||
id: 'id',
|
||||
input: 'select',
|
||||
selects: [{ label: 'foo', value: 'bar' }],
|
||||
operator: 'eq',
|
||||
},
|
||||
{
|
||||
Header: 'Name',
|
||||
id: 'name',
|
||||
input: 'search',
|
||||
operator: 'ct',
|
||||
},
|
||||
{
|
||||
Header: 'Age',
|
||||
id: 'age',
|
||||
input: 'select',
|
||||
fetchSelects: fetchSelectsMock,
|
||||
paginate: true,
|
||||
operator: 'eq',
|
||||
},
|
||||
{
|
||||
Header: 'Time',
|
||||
id: 'time',
|
||||
input: 'datetime_range',
|
||||
operator: 'between',
|
||||
},
|
||||
],
|
||||
data: [
|
||||
{ id: 1, name: 'data 1', age: 10, time: '2020-11-18T07:53:45.354Z' },
|
||||
{ id: 2, name: 'data 2', age: 1, time: '2020-11-18T07:53:45.354Z' },
|
||||
],
|
||||
count: 2,
|
||||
pageSize: 1,
|
||||
fetchData: jest.fn(() => []),
|
||||
loading: false,
|
||||
bulkSelectEnabled: true,
|
||||
disableBulkSelect: jest.fn(),
|
||||
bulkActions: [
|
||||
{
|
||||
key: 'something',
|
||||
name: 'do something',
|
||||
style: 'danger',
|
||||
onSelect: jest.fn(),
|
||||
},
|
||||
],
|
||||
cardSortSelectOptions: [
|
||||
{
|
||||
desc: false,
|
||||
id: 'something',
|
||||
label: 'Alphabetical',
|
||||
value: 'alphabetical',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const factory = (props = mockedProps) =>
|
||||
render(
|
||||
<QueryParamProvider location={makeMockLocation()}>
|
||||
<ListView {...props} />
|
||||
</QueryParamProvider>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('ListView', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
jest.clearAllMocks();
|
||||
factory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
mockedProps.fetchData.mockClear();
|
||||
mockedProps.bulkActions.forEach(ba => {
|
||||
ba.onSelect.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
// Example of converted test:
|
||||
test('calls fetchData on mount', () => {
|
||||
expect(mockedProps.fetchData).toHaveBeenCalledWith({
|
||||
filters: [],
|
||||
pageIndex: 0,
|
||||
pageSize: 1,
|
||||
sortBy: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('calls fetchData on sort', async () => {
|
||||
const sortHeader = screen.getAllByTestId('sort-header')[1];
|
||||
await userEvent.click(sortHeader);
|
||||
|
||||
expect(mockedProps.fetchData).toHaveBeenCalledWith({
|
||||
filters: [],
|
||||
pageIndex: 0,
|
||||
pageSize: 1,
|
||||
sortBy: [
|
||||
{
|
||||
desc: false,
|
||||
id: 'id',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// Update pagination control tests for Ant Design pagination
|
||||
test('renders pagination controls', () => {
|
||||
const paginationList = screen.getByRole('list');
|
||||
expect(paginationList).toBeInTheDocument();
|
||||
|
||||
const pageOneItem = screen.getByRole('listitem', { name: '1' });
|
||||
expect(pageOneItem).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls fetchData on page change', async () => {
|
||||
const pageTwoItem = screen.getByRole('listitem', { name: '2' });
|
||||
await userEvent.click(pageTwoItem);
|
||||
|
||||
await waitFor(() => {
|
||||
const { calls } = mockedProps.fetchData.mock;
|
||||
const pageChangeCall = calls.find(
|
||||
call =>
|
||||
call[0].pageIndex === 1 &&
|
||||
call[0].filters.length === 0 &&
|
||||
call[0].pageSize === 1,
|
||||
);
|
||||
expect(pageChangeCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('handles bulk actions on 1 row', async () => {
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[1]); // Index 1 is the first row checkbox
|
||||
|
||||
const bulkActionButton = within(
|
||||
screen.getByTestId('bulk-select-controls'),
|
||||
).getByTestId('bulk-select-action');
|
||||
await userEvent.click(bulkActionButton);
|
||||
|
||||
expect(mockedProps.bulkActions[0].onSelect).toHaveBeenCalledWith([
|
||||
{
|
||||
age: 10,
|
||||
id: 1,
|
||||
name: 'data 1',
|
||||
time: '2020-11-18T07:53:45.354Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Update UI filters test to use more specific selector
|
||||
test('renders UI filters', () => {
|
||||
const filterControls = screen.getAllByRole('combobox');
|
||||
expect(filterControls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('calls fetchData on filter', async () => {
|
||||
// Handle select filter
|
||||
const selectFilter = screen.getAllByRole('combobox')[0];
|
||||
await userEvent.click(selectFilter);
|
||||
const option = screen.getByText('foo');
|
||||
await userEvent.click(option);
|
||||
|
||||
// Handle search filter
|
||||
const searchFilter = screen.getByPlaceholderText('Type a value');
|
||||
await userEvent.type(searchFilter, 'something');
|
||||
await userEvent.tab();
|
||||
|
||||
expect(mockedProps.fetchData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: [
|
||||
{
|
||||
id: 'id',
|
||||
operator: 'eq',
|
||||
value: { label: 'foo', value: 'bar' },
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
operator: 'ct',
|
||||
value: 'something',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('calls fetchData on card view sort', async () => {
|
||||
factory({
|
||||
...mockedProps,
|
||||
renderCard: jest.fn(),
|
||||
initialSort: [{ id: 'something' }],
|
||||
});
|
||||
|
||||
const sortSelect = screen.getByTestId('card-sort-select');
|
||||
await userEvent.click(sortSelect);
|
||||
|
||||
const sortOption = screen.getByText('Alphabetical');
|
||||
await userEvent.click(sortOption);
|
||||
|
||||
expect(mockedProps.fetchData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,146 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { ListView } from './ListView';
|
||||
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { ReactNode } from 'react';
|
||||
import { ListView, type ListViewProps } from './ListView';
|
||||
import { ListViewFilterOperator, type ListViewFetchDataConfig } from './types';
|
||||
|
||||
const mockedProps = {
|
||||
title: 'Data Table',
|
||||
// Test-specific type that properly represents mocked props
|
||||
type MockedListViewProps = Omit<
|
||||
ListViewProps,
|
||||
| 'fetchData'
|
||||
| 'refreshData'
|
||||
| 'addSuccessToast'
|
||||
| 'addDangerToast'
|
||||
| 'disableBulkSelect'
|
||||
| 'bulkActions'
|
||||
> & {
|
||||
fetchData: jest.Mock<unknown[], [ListViewFetchDataConfig]>;
|
||||
refreshData: jest.Mock;
|
||||
addSuccessToast: jest.Mock;
|
||||
addDangerToast: jest.Mock;
|
||||
disableBulkSelect: jest.Mock;
|
||||
bulkActions: Array<{
|
||||
key: string;
|
||||
name: ReactNode;
|
||||
onSelect: jest.Mock;
|
||||
type?: 'primary' | 'secondary' | 'danger';
|
||||
}>;
|
||||
};
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
|
||||
function makeMockLocation(query?: string) {
|
||||
const queryStr = query ? encodeURIComponent(query) : '';
|
||||
return {
|
||||
protocol: 'http:',
|
||||
host: 'localhost',
|
||||
pathname: '/',
|
||||
search: queryStr.length ? `?${queryStr}` : '',
|
||||
} as Location;
|
||||
}
|
||||
|
||||
const fetchSelectsMock = jest.fn(() =>
|
||||
Promise.resolve({ data: [], totalCount: 0 }),
|
||||
);
|
||||
|
||||
// Create a properly typed mock with all required fields and Jest mock types
|
||||
const mockedPropsComprehensive: MockedListViewProps = {
|
||||
columns: [
|
||||
{
|
||||
accessor: 'id',
|
||||
Header: 'ID',
|
||||
sortable: true,
|
||||
id: 'id',
|
||||
},
|
||||
{
|
||||
accessor: 'age',
|
||||
Header: 'Age',
|
||||
id: 'age',
|
||||
},
|
||||
{
|
||||
accessor: 'name',
|
||||
Header: 'Name',
|
||||
id: 'name',
|
||||
},
|
||||
{
|
||||
accessor: 'time',
|
||||
Header: 'Time',
|
||||
id: 'time',
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'id',
|
||||
Header: 'ID',
|
||||
id: 'id',
|
||||
input: 'select',
|
||||
selects: [{ label: 'foo', value: 'bar' }],
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
Header: 'Name',
|
||||
id: 'name',
|
||||
input: 'search',
|
||||
operator: ListViewFilterOperator.Contains,
|
||||
},
|
||||
{
|
||||
key: 'age',
|
||||
Header: 'Age',
|
||||
id: 'age',
|
||||
input: 'select',
|
||||
fetchSelects: fetchSelectsMock,
|
||||
paginate: true,
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
Header: 'Time',
|
||||
id: 'time',
|
||||
input: 'datetime_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
],
|
||||
data: [
|
||||
{ id: 1, name: 'data 1', age: 10, time: '2020-11-18T07:53:45.354Z' },
|
||||
{ id: 2, name: 'data 2', age: 1, time: '2020-11-18T07:53:45.354Z' },
|
||||
],
|
||||
count: 2,
|
||||
pageSize: 1,
|
||||
fetchData: jest.fn<unknown[], [ListViewFetchDataConfig]>(() => []),
|
||||
loading: false,
|
||||
refreshData: jest.fn(),
|
||||
addSuccessToast: jest.fn(),
|
||||
addDangerToast: jest.fn(),
|
||||
bulkSelectEnabled: true,
|
||||
disableBulkSelect: jest.fn(),
|
||||
bulkActions: [
|
||||
{
|
||||
key: 'something',
|
||||
name: 'do something',
|
||||
type: 'danger',
|
||||
onSelect: jest.fn(),
|
||||
},
|
||||
],
|
||||
cardSortSelectOptions: [
|
||||
{
|
||||
desc: false,
|
||||
id: 'something',
|
||||
label: 'Alphabetical',
|
||||
value: 'alphabetical',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockedPropsSimple = {
|
||||
columns: [
|
||||
{
|
||||
accessor: 'id',
|
||||
@@ -59,7 +194,7 @@ const mockedProps = {
|
||||
test('redirects to first page when page index is invalid', async () => {
|
||||
const fetchData = jest.fn();
|
||||
window.history.pushState({}, '', '/?pageIndex=9');
|
||||
render(<ListView {...mockedProps} fetchData={fetchData} />, {
|
||||
render(<ListView {...mockedPropsSimple} fetchData={fetchData} />, {
|
||||
useRouter: true,
|
||||
useQueryParams: true,
|
||||
});
|
||||
@@ -75,3 +210,152 @@ test('redirects to first page when page index is invalid', async () => {
|
||||
});
|
||||
fetchData.mockClear();
|
||||
});
|
||||
|
||||
// Comprehensive test suite from original JSX file
|
||||
const factory = (overrides?: Partial<ListViewProps>) => {
|
||||
const props = { ...mockedPropsComprehensive, ...overrides };
|
||||
return render(
|
||||
<QueryParamProvider location={makeMockLocation()}>
|
||||
<ListView {...props} />
|
||||
</QueryParamProvider>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('ListView', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
jest.clearAllMocks();
|
||||
factory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
mockedPropsComprehensive.fetchData.mockClear();
|
||||
mockedPropsComprehensive.bulkActions.forEach(ba => {
|
||||
ba.onSelect.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
test('calls fetchData on mount', () => {
|
||||
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalledWith({
|
||||
filters: [],
|
||||
pageIndex: 0,
|
||||
pageSize: 1,
|
||||
sortBy: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('calls fetchData on sort', async () => {
|
||||
const sortHeader = screen.getAllByTestId('sort-header')[1];
|
||||
await userEvent.click(sortHeader);
|
||||
|
||||
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalledWith({
|
||||
filters: [],
|
||||
pageIndex: 0,
|
||||
pageSize: 1,
|
||||
sortBy: [
|
||||
{
|
||||
desc: false,
|
||||
id: 'id',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('renders pagination controls', () => {
|
||||
const paginationList = screen.getByRole('list');
|
||||
expect(paginationList).toBeInTheDocument();
|
||||
|
||||
const pageOneItem = screen.getByRole('listitem', { name: '1' });
|
||||
expect(pageOneItem).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls fetchData on page change', async () => {
|
||||
const pageTwoItem = screen.getByRole('listitem', { name: '2' });
|
||||
await userEvent.click(pageTwoItem);
|
||||
|
||||
await waitFor(() => {
|
||||
const { calls } = mockedPropsComprehensive.fetchData.mock;
|
||||
const pageChangeCall = calls.find(
|
||||
(call: [ListViewFetchDataConfig]) =>
|
||||
call?.[0]?.pageIndex === 1 &&
|
||||
call?.[0]?.filters?.length === 0 &&
|
||||
call?.[0]?.pageSize === 1,
|
||||
);
|
||||
expect(pageChangeCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('handles bulk actions on 1 row', async () => {
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await userEvent.click(checkboxes[1]); // Index 1 is the first row checkbox
|
||||
|
||||
const bulkActionButton = within(
|
||||
screen.getByTestId('bulk-select-controls'),
|
||||
).getByTestId('bulk-select-action');
|
||||
await userEvent.click(bulkActionButton);
|
||||
|
||||
expect(
|
||||
mockedPropsComprehensive.bulkActions[0].onSelect,
|
||||
).toHaveBeenCalledWith([
|
||||
{
|
||||
age: 10,
|
||||
id: 1,
|
||||
name: 'data 1',
|
||||
time: '2020-11-18T07:53:45.354Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('renders UI filters', () => {
|
||||
const filterControls = screen.getAllByRole('combobox');
|
||||
expect(filterControls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('calls fetchData on filter', async () => {
|
||||
// Handle select filter
|
||||
const selectFilter = screen.getAllByRole('combobox')[0];
|
||||
await userEvent.click(selectFilter);
|
||||
const option = screen.getByText('foo');
|
||||
await userEvent.click(option);
|
||||
|
||||
// Handle search filter
|
||||
const searchFilter = screen.getByPlaceholderText('Type a value');
|
||||
await userEvent.type(searchFilter, 'something');
|
||||
await userEvent.tab();
|
||||
|
||||
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filters: [
|
||||
{
|
||||
id: 'id',
|
||||
operator: 'eq',
|
||||
value: { label: 'foo', value: 'bar' },
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
operator: 'ct',
|
||||
value: 'something',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('calls fetchData on card view sort', async () => {
|
||||
factory({
|
||||
renderCard: jest.fn(),
|
||||
initialSort: [{ id: 'something' }],
|
||||
});
|
||||
|
||||
const sortSelect = screen.getByTestId('card-sort-select');
|
||||
await userEvent.click(sortSelect);
|
||||
|
||||
const sortOption = screen.getByText('Alphabetical');
|
||||
await userEvent.click(sortOption);
|
||||
|
||||
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from 'src/SqlLab/actions/sqlLab';
|
||||
import { RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import type { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { Disposable } from '../models';
|
||||
import { createActionListener } from '../utils';
|
||||
@@ -198,13 +197,10 @@ const getActiveEditorImmutableId = () => {
|
||||
return activeEditor?.immutableId;
|
||||
};
|
||||
|
||||
// Memoized version to avoid repeated store lookups when active editor hasn't changed
|
||||
const getActiveEditorId = memoizeOne(getActiveEditorImmutableId);
|
||||
|
||||
const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
|
||||
// Capture the immutable ID of the active editor at the time the listener is created
|
||||
// This ID never changes for a tab, ensuring stable event routing
|
||||
const registrationImmutableId = getActiveEditorId();
|
||||
const registrationImmutableId = getActiveEditorImmutableId();
|
||||
|
||||
return action => {
|
||||
if (action.type !== actionType) return false;
|
||||
@@ -212,14 +208,15 @@ const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
|
||||
// If we don't have a registration ID, don't filter events
|
||||
if (!registrationImmutableId) return true;
|
||||
|
||||
// For query events, use the immutableId directly from the action payload
|
||||
if (action.query?.immutableId) {
|
||||
return action.query.immutableId === registrationImmutableId;
|
||||
// For query events, use the sqlEditorImmutableId directly from the action payload
|
||||
if (action.query?.sqlEditorImmutableId) {
|
||||
return action.query.sqlEditorImmutableId === registrationImmutableId;
|
||||
}
|
||||
|
||||
// For tab events, we need to find the immutable ID of the affected tab
|
||||
if (action.queryEditor?.id) {
|
||||
const queryEditor = findQueryEditor(action.queryEditor.id);
|
||||
const queryEditorId = action.queryEditor?.id || action.query?.sqlEditorId;
|
||||
if (queryEditorId) {
|
||||
const queryEditor = findQueryEditor(queryEditorId);
|
||||
return queryEditor?.immutableId === registrationImmutableId;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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 { AnyAction } from 'redux';
|
||||
import { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
||||
import { makeApi, t, getClientErrorObject, DataMask } from '@superset-ui/core';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { DashboardInfo, RootState } from 'src/dashboard/types';
|
||||
import {
|
||||
ChartCustomizationItem,
|
||||
FilterOption,
|
||||
ColumnOption,
|
||||
} from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
|
||||
import { triggerQuery } from 'src/components/Chart/chartAction';
|
||||
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
|
||||
import { onSave } from './dashboardState';
|
||||
|
||||
const createUpdateDashboardApi = (id: number) =>
|
||||
makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: Partial<DashboardInfo>; last_modified_time: number }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
|
||||
export interface ChartCustomizationSavePayload {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
removed?: boolean;
|
||||
chartId?: number;
|
||||
customization: {
|
||||
name: string;
|
||||
dataset:
|
||||
| string
|
||||
| number
|
||||
| {
|
||||
value: string | number;
|
||||
label?: string;
|
||||
table_name?: string;
|
||||
schema?: string;
|
||||
}
|
||||
| null;
|
||||
datasetInfo?: {
|
||||
label: string;
|
||||
value: number;
|
||||
table_name: string;
|
||||
};
|
||||
column: string | string[] | null;
|
||||
description?: string;
|
||||
sortFilter?: boolean;
|
||||
sortAscending?: boolean;
|
||||
sortMetric?: string;
|
||||
hasDefaultValue?: boolean;
|
||||
defaultValue?: string;
|
||||
isRequired?: boolean;
|
||||
selectFirst?: boolean;
|
||||
defaultDataMask?: DataMask;
|
||||
defaultValueQueriesData?: ColumnOption[] | null;
|
||||
aggregation?: string;
|
||||
canSelectMultiple?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const SAVE_CHART_CUSTOMIZATION_COMPLETE =
|
||||
'SAVE_CHART_CUSTOMIZATION_COMPLETE';
|
||||
|
||||
export function setChartCustomization(
|
||||
chartCustomization: ChartCustomizationItem[],
|
||||
) {
|
||||
return { type: SAVE_CHART_CUSTOMIZATION_COMPLETE, chartCustomization };
|
||||
}
|
||||
|
||||
export function saveChartCustomization(
|
||||
chartCustomizationItems: ChartCustomizationSavePayload[],
|
||||
): ThunkAction<
|
||||
Promise<{ result: Partial<DashboardInfo>; last_modified_time: number }>,
|
||||
RootState,
|
||||
null,
|
||||
AnyAction
|
||||
> {
|
||||
return async function (
|
||||
dispatch: ThunkDispatch<RootState, null, AnyAction>,
|
||||
getState: () => RootState,
|
||||
) {
|
||||
const { id, metadata, json_metadata } = getState().dashboardInfo;
|
||||
|
||||
const currentState = getState();
|
||||
const currentChartCustomizationItems =
|
||||
currentState.dashboardInfo.metadata?.chart_customization_config || [];
|
||||
|
||||
const existingItemsMap = new Map(
|
||||
currentChartCustomizationItems.map(item => [item.id, item]),
|
||||
);
|
||||
|
||||
const updatedItemsMap = new Map(existingItemsMap);
|
||||
|
||||
chartCustomizationItems.forEach(newItem => {
|
||||
if (newItem.removed) {
|
||||
updatedItemsMap.delete(newItem.id);
|
||||
} else {
|
||||
const chartCustomizationItem: ChartCustomizationItem = {
|
||||
id: newItem.id,
|
||||
title: newItem.title,
|
||||
removed: newItem.removed,
|
||||
chartId: newItem.chartId,
|
||||
customization: newItem.customization,
|
||||
};
|
||||
updatedItemsMap.set(newItem.id, chartCustomizationItem);
|
||||
}
|
||||
});
|
||||
|
||||
const simpleItems = Array.from(updatedItemsMap.values());
|
||||
|
||||
dispatch(setChartCustomization(simpleItems));
|
||||
|
||||
const removedItems = currentChartCustomizationItems.filter(
|
||||
existingItem => !updatedItemsMap.has(existingItem.id),
|
||||
);
|
||||
|
||||
removedItems.forEach(removedItem => {
|
||||
const customizationFilterId = `chart_customization_${removedItem.id}`;
|
||||
dispatch(removeDataMask(customizationFilterId));
|
||||
});
|
||||
|
||||
simpleItems.forEach(item => {
|
||||
const customizationFilterId = `chart_customization_${item.id}`;
|
||||
|
||||
if (item.customization?.column) {
|
||||
const existingDataMask = getState().dataMask[customizationFilterId];
|
||||
|
||||
const existingFilterState = existingDataMask?.filterState;
|
||||
|
||||
dispatch(removeDataMask(customizationFilterId));
|
||||
|
||||
const dataMask = {
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value:
|
||||
existingFilterState?.value ||
|
||||
item.customization?.defaultDataMask?.filterState?.value ||
|
||||
[],
|
||||
},
|
||||
ownState: {
|
||||
column: item.customization.column,
|
||||
},
|
||||
};
|
||||
|
||||
dispatch(updateDataMask(customizationFilterId, dataMask));
|
||||
} else {
|
||||
dispatch(removeDataMask(customizationFilterId));
|
||||
}
|
||||
});
|
||||
|
||||
const updateDashboard = createUpdateDashboardApi(id);
|
||||
|
||||
try {
|
||||
let parsedMetadata: any = {};
|
||||
try {
|
||||
parsedMetadata = json_metadata ? JSON.parse(json_metadata) : metadata;
|
||||
} catch (e) {
|
||||
console.error('Error parsing json_metadata:', e);
|
||||
parsedMetadata = metadata || {};
|
||||
}
|
||||
|
||||
const updatedMetadata = {
|
||||
...parsedMetadata,
|
||||
native_filter_configuration: (
|
||||
parsedMetadata.native_filter_configuration || []
|
||||
).filter(
|
||||
(item: any) =>
|
||||
!(
|
||||
item.type === 'CHART_CUSTOMIZATION' &&
|
||||
item.id === 'chart_customization_groupby'
|
||||
),
|
||||
),
|
||||
chart_customization_config: simpleItems,
|
||||
};
|
||||
|
||||
const response = await updateDashboard({
|
||||
json_metadata: JSON.stringify(updatedMetadata),
|
||||
});
|
||||
|
||||
const lastModifiedTime = response.last_modified_time;
|
||||
|
||||
if (lastModifiedTime) {
|
||||
dispatch(onSave(lastModifiedTime));
|
||||
}
|
||||
|
||||
const { dashboardState } = getState();
|
||||
const chartIds = dashboardState.sliceIds || [];
|
||||
if (chartIds.length > 0) {
|
||||
chartIds.forEach(chartId => {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (errorObject) {
|
||||
const { error } = await getClientErrorObject(errorObject);
|
||||
dispatch(
|
||||
addDangerToast(error || t('Failed to save chart customization')),
|
||||
);
|
||||
throw errorObject;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const INITIALIZE_CHART_CUSTOMIZATION = 'INITIALIZE_CHART_CUSTOMIZATION';
|
||||
export interface InitializeChartCustomization {
|
||||
type: typeof INITIALIZE_CHART_CUSTOMIZATION;
|
||||
chartCustomizationItems: ChartCustomizationItem[];
|
||||
}
|
||||
|
||||
export function initializeChartCustomization(
|
||||
chartCustomizationItems: ChartCustomizationItem[],
|
||||
): ThunkAction<void, RootState, null, AnyAction> {
|
||||
return (dispatch: ThunkDispatch<RootState, null, AnyAction>) => {
|
||||
dispatch({
|
||||
type: INITIALIZE_CHART_CUSTOMIZATION,
|
||||
chartCustomizationItems,
|
||||
});
|
||||
|
||||
chartCustomizationItems.forEach(item => {
|
||||
const customizationFilterId = `chart_customization_${item.id}`;
|
||||
|
||||
if (item.customization?.column) {
|
||||
dispatch(removeDataMask(customizationFilterId));
|
||||
|
||||
const dataMask = {
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value:
|
||||
item.customization?.defaultDataMask?.filterState?.value || [],
|
||||
},
|
||||
ownState: {
|
||||
column: item.customization.column,
|
||||
},
|
||||
};
|
||||
dispatch(updateDataMask(customizationFilterId, dataMask));
|
||||
} else {
|
||||
dispatch(removeDataMask(customizationFilterId));
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_CHART_CUSTOMIZATION_DATA_LOADING =
|
||||
'SET_CHART_CUSTOMIZATION_DATA_LOADING';
|
||||
export interface SetChartCustomizationDataLoading {
|
||||
type: typeof SET_CHART_CUSTOMIZATION_DATA_LOADING;
|
||||
itemId: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function setChartCustomizationDataLoading(
|
||||
itemId: string,
|
||||
isLoading: boolean,
|
||||
): SetChartCustomizationDataLoading {
|
||||
return {
|
||||
type: SET_CHART_CUSTOMIZATION_DATA_LOADING,
|
||||
itemId,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_CHART_CUSTOMIZATION_DATA = 'SET_CHART_CUSTOMIZATION_DATA';
|
||||
export interface SetChartCustomizationData {
|
||||
type: typeof SET_CHART_CUSTOMIZATION_DATA;
|
||||
itemId: string;
|
||||
data: FilterOption[];
|
||||
}
|
||||
|
||||
export function setChartCustomizationData(
|
||||
itemId: string,
|
||||
data: FilterOption[],
|
||||
): SetChartCustomizationData {
|
||||
return {
|
||||
type: SET_CHART_CUSTOMIZATION_DATA,
|
||||
itemId,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadChartCustomizationData(
|
||||
itemId: string,
|
||||
datasetId: string,
|
||||
columnName: string | string[],
|
||||
): ThunkAction<Promise<void>, RootState, null, AnyAction> {
|
||||
return async (dispatch: ThunkDispatch<RootState, null, AnyAction>) => {
|
||||
if (!datasetId || !columnName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualColumnName = Array.isArray(columnName)
|
||||
? columnName[0]
|
||||
: columnName;
|
||||
|
||||
if (!actualColumnName) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setChartCustomizationDataLoading(itemId, false));
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_PENDING_CHART_CUSTOMIZATION =
|
||||
'SET_PENDING_CHART_CUSTOMIZATION';
|
||||
export interface SetPendingChartCustomization {
|
||||
type: typeof SET_PENDING_CHART_CUSTOMIZATION;
|
||||
pendingCustomization: ChartCustomizationSavePayload;
|
||||
}
|
||||
|
||||
export function setPendingChartCustomization(
|
||||
pendingCustomization: ChartCustomizationSavePayload,
|
||||
): SetPendingChartCustomization {
|
||||
return {
|
||||
type: SET_PENDING_CHART_CUSTOMIZATION,
|
||||
pendingCustomization,
|
||||
};
|
||||
}
|
||||
|
||||
export const CLEAR_PENDING_CHART_CUSTOMIZATION =
|
||||
'CLEAR_PENDING_CHART_CUSTOMIZATION';
|
||||
export interface ClearPendingChartCustomization {
|
||||
type: typeof CLEAR_PENDING_CHART_CUSTOMIZATION;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export function clearPendingChartCustomization(
|
||||
itemId: string,
|
||||
): ClearPendingChartCustomization {
|
||||
return {
|
||||
type: CLEAR_PENDING_CHART_CUSTOMIZATION,
|
||||
itemId,
|
||||
};
|
||||
}
|
||||
|
||||
export const CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS =
|
||||
'CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS';
|
||||
export interface ClearAllPendingChartCustomizations {
|
||||
type: typeof CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS;
|
||||
}
|
||||
|
||||
export function clearAllPendingChartCustomizations(): ClearAllPendingChartCustomizations {
|
||||
return {
|
||||
type: CLEAR_ALL_PENDING_CHART_CUSTOMIZATIONS,
|
||||
};
|
||||
}
|
||||
|
||||
export const CLEAR_ALL_CHART_CUSTOMIZATIONS = 'CLEAR_ALL_CHART_CUSTOMIZATIONS';
|
||||
export interface ClearAllChartCustomizations {
|
||||
type: typeof CLEAR_ALL_CHART_CUSTOMIZATIONS;
|
||||
}
|
||||
|
||||
export function clearAllChartCustomizations(): ClearAllChartCustomizations {
|
||||
return {
|
||||
type: CLEAR_ALL_CHART_CUSTOMIZATIONS,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearAllChartCustomizationsFromMetadata() {
|
||||
return clearAllChartCustomizations();
|
||||
}
|
||||
|
||||
export type AnyChartCustomizationAction =
|
||||
| ReturnType<typeof setChartCustomization>
|
||||
| InitializeChartCustomization
|
||||
| SetChartCustomizationDataLoading
|
||||
| SetChartCustomizationData
|
||||
| SetPendingChartCustomization
|
||||
| ClearPendingChartCustomization
|
||||
| ClearAllPendingChartCustomizations
|
||||
| ClearAllChartCustomizations;
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { Dispatch } from 'redux';
|
||||
import { makeApi, t, getErrorText } from '@superset-ui/core';
|
||||
import { makeApi, t, getClientErrorObject } from '@superset-ui/core';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
ChartConfiguration,
|
||||
@@ -28,6 +28,15 @@ import {
|
||||
} from 'src/dashboard/types';
|
||||
import { onSave } from './dashboardState';
|
||||
|
||||
const createUpdateDashboardApi = (id: number) =>
|
||||
makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: Partial<DashboardInfo>; last_modified_time: number }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
|
||||
export const DASHBOARD_INFO_UPDATED = 'DASHBOARD_INFO_UPDATED';
|
||||
export const DASHBOARD_INFO_FILTERS_CHANGED = 'DASHBOARD_INFO_FILTERS_CHANGED';
|
||||
|
||||
@@ -60,14 +69,7 @@ export const saveChartConfiguration =
|
||||
});
|
||||
const { id, metadata } = getState().dashboardInfo;
|
||||
|
||||
// TODO extract this out when makeApi supports url parameters
|
||||
const updateDashboard = makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: DashboardInfo }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
const updateDashboard = createUpdateDashboardApi(id);
|
||||
|
||||
try {
|
||||
const response = await updateDashboard({
|
||||
@@ -81,7 +83,7 @@ export const saveChartConfiguration =
|
||||
});
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata: JSON.parse(response.result.json_metadata),
|
||||
metadata: JSON.parse(response.result.json_metadata || '{}'),
|
||||
}),
|
||||
);
|
||||
dispatch({
|
||||
@@ -116,13 +118,7 @@ export function setCrossFiltersEnabled(crossFiltersEnabled: boolean) {
|
||||
export function saveFilterBarOrientation(orientation: FilterBarOrientation) {
|
||||
return async (dispatch: Dispatch, getState: () => RootState) => {
|
||||
const { id, metadata } = getState().dashboardInfo;
|
||||
const updateDashboard = makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: Partial<DashboardInfo>; last_modified_time: number }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
const updateDashboard = createUpdateDashboardApi(id);
|
||||
try {
|
||||
const response = await updateDashboard({
|
||||
json_metadata: JSON.stringify({
|
||||
@@ -142,23 +138,33 @@ export function saveFilterBarOrientation(orientation: FilterBarOrientation) {
|
||||
dispatch(onSave(lastModifiedTime));
|
||||
}
|
||||
} catch (errorObject) {
|
||||
const errorText = await getErrorText(errorObject, 'dashboard');
|
||||
dispatch(addDangerToast(errorText));
|
||||
const { error } = await getClientErrorObject(errorObject);
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t(
|
||||
'Sorry, there was an error saving this dashboard: %s',
|
||||
error || 'Bad Request',
|
||||
),
|
||||
),
|
||||
);
|
||||
throw errorObject;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function saveCrossFiltersSetting(crossFiltersEnabled: boolean) {
|
||||
return async (dispatch: Dispatch, getState: () => RootState) => {
|
||||
return async function saveCrossFiltersSettingThunk(
|
||||
dispatch: Dispatch,
|
||||
getState: () => RootState,
|
||||
) {
|
||||
const { id, metadata } = getState().dashboardInfo;
|
||||
const updateDashboard = makeApi<
|
||||
Partial<DashboardInfo>,
|
||||
{ result: Partial<DashboardInfo>; last_modified_time: number }
|
||||
>({
|
||||
method: 'PUT',
|
||||
endpoint: `/api/v1/dashboard/${id}`,
|
||||
});
|
||||
|
||||
const previousCrossFiltersEnabled =
|
||||
getState().dashboardInfo.crossFiltersEnabled;
|
||||
|
||||
dispatch(setCrossFiltersEnabled(crossFiltersEnabled));
|
||||
const updateDashboard = createUpdateDashboardApi(id);
|
||||
|
||||
try {
|
||||
const response = await updateDashboard({
|
||||
json_metadata: JSON.stringify({
|
||||
@@ -166,19 +172,29 @@ export function saveCrossFiltersSetting(crossFiltersEnabled: boolean) {
|
||||
cross_filters_enabled: crossFiltersEnabled,
|
||||
}),
|
||||
});
|
||||
|
||||
const updatedDashboard = response.result;
|
||||
const lastModifiedTime = response.last_modified_time;
|
||||
|
||||
if (updatedDashboard.json_metadata) {
|
||||
const metadata = JSON.parse(updatedDashboard.json_metadata);
|
||||
dispatch(setCrossFiltersEnabled(metadata.cross_filters_enabled));
|
||||
}
|
||||
|
||||
if (lastModifiedTime) {
|
||||
dispatch(onSave(lastModifiedTime));
|
||||
}
|
||||
} catch (errorObject) {
|
||||
const errorText = await getErrorText(errorObject, 'dashboard');
|
||||
dispatch(addDangerToast(errorText));
|
||||
throw errorObject;
|
||||
|
||||
dispatch(
|
||||
dashboardInfoChanged({
|
||||
metadata: JSON.parse(response.result.json_metadata || '{}'),
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
} catch (err) {
|
||||
dispatch(setCrossFiltersEnabled(previousCrossFiltersEnabled));
|
||||
dispatch(addDangerToast(t('Failed to save cross-filters setting')));
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export function resizeComponent({ id, width, height }) {
|
||||
};
|
||||
}
|
||||
|
||||
// Drag and drop --------------------------------------------------------------
|
||||
// Drag and Drop --------------------------------------------------------------
|
||||
export const MOVE_COMPONENT = 'MOVE_COMPONENT';
|
||||
const moveComponent = setUnsavedChangesAfterAction(dropResult => ({
|
||||
type: MOVE_COMPONENT,
|
||||
|
||||
@@ -244,6 +244,8 @@ export const hydrateDashboard =
|
||||
metadata.cross_filters_enabled,
|
||||
);
|
||||
|
||||
const chartCustomizationItems = metadata?.chart_customization_config || [];
|
||||
|
||||
return dispatch({
|
||||
type: HYDRATE_DASHBOARD,
|
||||
data: {
|
||||
@@ -308,6 +310,7 @@ export const hydrateDashboard =
|
||||
activeTabs: activeTabs || dashboardState?.activeTabs || [],
|
||||
datasetsStatus:
|
||||
dashboardState?.datasetsStatus || ResourceStatus.Loading,
|
||||
chartCustomizationItems,
|
||||
},
|
||||
dashboardLayout,
|
||||
},
|
||||
|
||||
@@ -196,6 +196,32 @@ export function unsetHoveredNativeFilter(): UnsetHoveredNativeFilter {
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_HOVERED_CHART_CUSTOMIZATION =
|
||||
'SET_HOVERED_CHART_CUSTOMIZATION';
|
||||
export interface SetHoveredChartCustomization {
|
||||
type: typeof SET_HOVERED_CHART_CUSTOMIZATION;
|
||||
id: string;
|
||||
}
|
||||
export const UNSET_HOVERED_CHART_CUSTOMIZATION =
|
||||
'UNSET_HOVERED_CHART_CUSTOMIZATION';
|
||||
export interface UnsetHoveredChartCustomization {
|
||||
type: typeof UNSET_HOVERED_CHART_CUSTOMIZATION;
|
||||
}
|
||||
|
||||
export function setHoveredChartCustomization(
|
||||
id: string,
|
||||
): SetHoveredChartCustomization {
|
||||
return {
|
||||
type: SET_HOVERED_CHART_CUSTOMIZATION,
|
||||
id,
|
||||
};
|
||||
}
|
||||
export function unsetHoveredChartCustomization(): UnsetHoveredChartCustomization {
|
||||
return {
|
||||
type: UNSET_HOVERED_CHART_CUSTOMIZATION,
|
||||
};
|
||||
}
|
||||
|
||||
export const UPDATE_CASCADE_PARENT_IDS = 'UPDATE_CASCADE_PARENT_IDS';
|
||||
export interface UpdateCascadeParentIds {
|
||||
type: typeof UPDATE_CASCADE_PARENT_IDS;
|
||||
@@ -223,4 +249,6 @@ export type AnyFilterAction =
|
||||
| UnsetFocusedNativeFilter
|
||||
| SetHoveredNativeFilter
|
||||
| UnsetHoveredNativeFilter
|
||||
| SetHoveredChartCustomization
|
||||
| UnsetHoveredChartCustomization
|
||||
| UpdateCascadeParentIds;
|
||||
|
||||
@@ -152,7 +152,10 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
return;
|
||||
}
|
||||
const scopes = nativeFilterScopes.map(filterScope => {
|
||||
if (filterScope.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX)) {
|
||||
if (
|
||||
filterScope.id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX) ||
|
||||
filterScope.id.startsWith('chart_customization_')
|
||||
) {
|
||||
return {
|
||||
filterId: filterScope.id,
|
||||
tabsInScope: [],
|
||||
@@ -164,6 +167,14 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
item => item?.type === CHART_TYPE,
|
||||
);
|
||||
|
||||
if (!filterScope.scope || !Array.isArray(filterScope.scope.excluded)) {
|
||||
return {
|
||||
filterId: filterScope.id,
|
||||
tabsInScope: [],
|
||||
chartsInScope: [],
|
||||
};
|
||||
}
|
||||
|
||||
const chartsInScope: number[] = getChartIdsInFilterScope(
|
||||
filterScope.scope,
|
||||
chartIds,
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import {
|
||||
@@ -28,9 +27,8 @@ import {
|
||||
getExtensionsRegistry,
|
||||
makeApi,
|
||||
} from '@superset-ui/core';
|
||||
import { Modal } from '@superset-ui/core/components';
|
||||
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
|
||||
import DashboardEmbedModal from './index';
|
||||
import DashboardEmbedModal from '.';
|
||||
|
||||
const defaultResponse = {
|
||||
result: { uuid: 'uuid', dashboard_id: '1', allowed_domains: ['example.com'] },
|
||||
@@ -58,16 +56,16 @@ const setMockApiNotFound = () => {
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
resetMockApi();
|
||||
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetMockApi();
|
||||
getExtensionsRegistry().set('embedded.modal', undefined);
|
||||
});
|
||||
|
||||
test('renders', async () => {
|
||||
test('renders the embed modal', async () => {
|
||||
setup();
|
||||
expect(await screen.findByText('Embed')).toBeInTheDocument();
|
||||
});
|
||||
@@ -79,15 +77,15 @@ test('renders loading state', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders the modal default content', async () => {
|
||||
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
|
||||
test('renders modal content with settings', async () => {
|
||||
setup();
|
||||
expect(await screen.findByText('Settings')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new RegExp(/Allowed Domains/, 'i')),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the correct actions when dashboard is ready to embed', async () => {
|
||||
test('shows Deactivate and Save changes buttons when ready to embed', async () => {
|
||||
setup();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Deactivate' }),
|
||||
@@ -97,7 +95,7 @@ test('renders the correct actions when dashboard is ready to embed', async () =>
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the correct actions when dashboard is not ready to embed', async () => {
|
||||
test('shows Enable embedding button when not ready to embed', async () => {
|
||||
setMockApiNotFound();
|
||||
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
|
||||
expect(
|
||||
@@ -105,7 +103,7 @@ test('renders the correct actions when dashboard is not ready to embed', async (
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('enables embedding', async () => {
|
||||
test('enables embedding when Enable embedding button is clicked', async () => {
|
||||
setMockApiNotFound();
|
||||
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
|
||||
|
||||
@@ -115,33 +113,33 @@ test('enables embedding', async () => {
|
||||
expect(enableEmbed).toBeInTheDocument();
|
||||
|
||||
resetMockApi();
|
||||
fireEvent.click(enableEmbed);
|
||||
await userEvent.click(enableEmbed);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Deactivate' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows and hides the confirmation modal on deactivation', async () => {
|
||||
test('shows and hides confirmation alert when deactivating', async () => {
|
||||
setup();
|
||||
|
||||
const deactivate = await screen.findByRole('button', { name: 'Deactivate' });
|
||||
fireEvent.click(deactivate);
|
||||
await userEvent.click(deactivate);
|
||||
|
||||
expect(await screen.findByText('Disable embedding?')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('This will remove your current embed configuration.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const okBtn = screen.getByRole('button', { name: 'OK' });
|
||||
fireEvent.click(okBtn);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Deactivate' });
|
||||
await userEvent.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Disable embedding?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('enables the "Save Changes" button', async () => {
|
||||
test('enables Save Changes button when allowed domains are modified', async () => {
|
||||
setup();
|
||||
|
||||
const allowedDomainsInput = await screen.findByRole('textbox', {
|
||||
@@ -153,11 +151,12 @@ test('enables the "Save Changes" button', async () => {
|
||||
expect(saveChangesBtn).toBeDisabled();
|
||||
expect(allowedDomainsInput).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(allowedDomainsInput, { target: { value: 'test.com' } });
|
||||
await userEvent.clear(allowedDomainsInput);
|
||||
await userEvent.type(allowedDomainsInput, 'test.com');
|
||||
expect(saveChangesBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('adds extension to DashboardEmbedModal', async () => {
|
||||
test('renders extension component when registered', async () => {
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
extensionsRegistry.set('embedded.modal', () => (
|
||||
@@ -165,7 +164,7 @@ test('adds extension to DashboardEmbedModal', async () => {
|
||||
));
|
||||
|
||||
setupCodeOverrides();
|
||||
render(<DashboardEmbedModal {...defaultProps} />, { useRedux: true });
|
||||
setup();
|
||||
|
||||
expect(
|
||||
await screen.findByText('dashboard.embed.modal.extension component'),
|
||||
@@ -173,86 +172,3 @@ test('adds extension to DashboardEmbedModal', async () => {
|
||||
|
||||
extensionsRegistry.set('embedded.modal', undefined);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('Modal.useModal integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('uses Modal.useModal hook for confirmation dialogs', () => {
|
||||
const useModalSpy = jest.spyOn(Modal, 'useModal');
|
||||
setup();
|
||||
|
||||
// Verify that useModal is called when the component mounts
|
||||
expect(useModalSpy).toHaveBeenCalled();
|
||||
|
||||
useModalSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('renders contextHolder for proper theming', async () => {
|
||||
const { container } = render(<DashboardEmbedModal {...defaultProps} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for component to be rendered
|
||||
await screen.findByText('Embed');
|
||||
|
||||
// The contextHolder is rendered in the component tree
|
||||
// Check that modal root elements exist for theming
|
||||
const modalRootElements = container.querySelectorAll('.ant-modal-root');
|
||||
expect(modalRootElements).toBeDefined();
|
||||
});
|
||||
|
||||
test('confirmation modal inherits theme context', async () => {
|
||||
setup();
|
||||
|
||||
// Click deactivate to trigger the confirmation modal
|
||||
const deactivate = await screen.findByRole('button', {
|
||||
name: 'Deactivate',
|
||||
});
|
||||
fireEvent.click(deactivate);
|
||||
|
||||
// Wait for the modal to appear
|
||||
const modalTitle = await screen.findByText('Disable embedding?');
|
||||
expect(modalTitle).toBeInTheDocument();
|
||||
|
||||
// Check that the modal is rendered within the component tree (not on body directly)
|
||||
const modal = modalTitle.closest('.ant-modal-wrap');
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not use Modal.confirm directly', () => {
|
||||
// Spy on the static Modal.confirm method
|
||||
const confirmSpy = jest.spyOn(Modal, 'confirm');
|
||||
|
||||
setup();
|
||||
|
||||
// The component should not call Modal.confirm directly
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('modal actions work correctly with useModal', async () => {
|
||||
setup();
|
||||
|
||||
// Click deactivate
|
||||
const deactivate = await screen.findByRole('button', {
|
||||
name: 'Deactivate',
|
||||
});
|
||||
fireEvent.click(deactivate);
|
||||
|
||||
// Modal should appear
|
||||
expect(await screen.findByText('Disable embedding?')).toBeInTheDocument();
|
||||
|
||||
// Click Cancel to close modal
|
||||
const cancelBtn = screen.getByRole('button', { name: 'Cancel' });
|
||||
fireEvent.click(cancelBtn);
|
||||
|
||||
// Modal should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Disable embedding?')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
Modal,
|
||||
Loading,
|
||||
Form,
|
||||
Alert,
|
||||
Space,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { EmbeddedDashboard } from 'src/dashboard/types';
|
||||
@@ -64,9 +66,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
const [loading, setLoading] = useState(false); // whether we are currently doing an async thing
|
||||
const [embedded, setEmbedded] = useState<EmbeddedDashboard | null>(null); // the embedded dashboard config
|
||||
const [allowedDomains, setAllowedDomains] = useState<string>('');
|
||||
|
||||
// Use Modal.useModal hook to ensure proper theming
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
|
||||
|
||||
const endpoint = `/api/v1/dashboard/${dashboardId}/embedded`;
|
||||
// whether saveable changes have been made to the config
|
||||
@@ -103,35 +103,33 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
}, [endpoint, allowedDomains]);
|
||||
|
||||
const disableEmbedded = useCallback(() => {
|
||||
modal.confirm({
|
||||
title: t('Disable embedding?'),
|
||||
content: t('This will remove your current embed configuration.'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
setLoading(true);
|
||||
makeApi<{}>({ method: 'DELETE', endpoint })({})
|
||||
.then(
|
||||
() => {
|
||||
setEmbedded(null);
|
||||
setAllowedDomains('');
|
||||
addInfoToast(t('Embedding deactivated.'));
|
||||
onHide();
|
||||
},
|
||||
err => {
|
||||
console.error(err);
|
||||
addDangerToast(
|
||||
t(
|
||||
'Sorry, something went wrong. Embedding could not be deactivated.',
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [endpoint, modal]);
|
||||
setShowDeactivateConfirm(true);
|
||||
}, []);
|
||||
|
||||
const confirmDeactivate = useCallback(() => {
|
||||
setLoading(true);
|
||||
makeApi<{}>({ method: 'DELETE', endpoint })({})
|
||||
.then(
|
||||
() => {
|
||||
setEmbedded(null);
|
||||
setAllowedDomains('');
|
||||
setShowDeactivateConfirm(false);
|
||||
addInfoToast(t('Embedding deactivated.'));
|
||||
onHide();
|
||||
},
|
||||
err => {
|
||||
console.error(err);
|
||||
addDangerToast(
|
||||
t(
|
||||
'Sorry, something went wrong. Embedding could not be deactivated.',
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [endpoint, addInfoToast, addDangerToast, onHide]);
|
||||
|
||||
useEffect(() => {
|
||||
setReady(false);
|
||||
@@ -170,7 +168,6 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
{embedded ? (
|
||||
DocsConfigDetails ? (
|
||||
<DocsConfigDetails embeddedId={embedded.uuid} />
|
||||
@@ -222,39 +219,71 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<ButtonRow
|
||||
css={theme => css`
|
||||
margin-top: ${theme.margin}px;
|
||||
`}
|
||||
>
|
||||
{embedded ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={disableEmbedded}
|
||||
buttonStyle="secondary"
|
||||
loading={loading}
|
||||
>
|
||||
{t('Deactivate')}
|
||||
</Button>
|
||||
{showDeactivateConfirm ? (
|
||||
<Alert
|
||||
closable={false}
|
||||
type="warning"
|
||||
message={t('Disable embedding?')}
|
||||
description={t('This will remove your current embed configuration.')}
|
||||
css={{
|
||||
textAlign: 'left',
|
||||
marginTop: '16px',
|
||||
}}
|
||||
action={
|
||||
<Space>
|
||||
<Button
|
||||
key="cancel"
|
||||
buttonStyle="secondary"
|
||||
onClick={() => setShowDeactivateConfirm(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
key="deactivate"
|
||||
buttonStyle="danger"
|
||||
onClick={confirmDeactivate}
|
||||
loading={loading}
|
||||
>
|
||||
{t('Deactivate')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ButtonRow
|
||||
css={theme => css`
|
||||
margin-top: ${theme.margin}px;
|
||||
`}
|
||||
>
|
||||
{embedded ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={disableEmbedded}
|
||||
buttonStyle="secondary"
|
||||
loading={loading}
|
||||
>
|
||||
{t('Deactivate')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={enableEmbedded}
|
||||
buttonStyle="primary"
|
||||
disabled={!isDirty}
|
||||
loading={loading}
|
||||
>
|
||||
{t('Save changes')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={enableEmbedded}
|
||||
buttonStyle="primary"
|
||||
disabled={!isDirty}
|
||||
loading={loading}
|
||||
>
|
||||
{t('Save changes')}
|
||||
{t('Enable embedding')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={enableEmbedded}
|
||||
buttonStyle="primary"
|
||||
loading={loading}
|
||||
>
|
||||
{t('Enable embedding')}
|
||||
</Button>
|
||||
)}
|
||||
</ButtonRow>
|
||||
)}
|
||||
</ButtonRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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 { memo, useMemo, useState, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { styled, t, useTheme } from '@superset-ui/core';
|
||||
import { Icons, Badge, Tooltip, Tag } from '@superset-ui/core/components';
|
||||
import { getFilterValueForDisplay } from '../nativeFilters/utils';
|
||||
import { ChartCustomizationItem } from '../nativeFilters/ChartCustomization/types';
|
||||
import { RootState } from '../../types';
|
||||
import { isChartWithoutGroupBy } from '../../util/charts/chartTypeLimitations';
|
||||
|
||||
export interface GroupByBadgeProps {
|
||||
chartId: number;
|
||||
}
|
||||
|
||||
const StyledTag = styled(Tag)`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
padding: ${theme.sizeUnit * 0.5}px ${theme.sizeUnit}px;
|
||||
background: ${theme.colorBgContainer};
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
height: auto;
|
||||
min-height: 20px;
|
||||
|
||||
.anticon {
|
||||
vertical-align: middle;
|
||||
color: ${theme.colorTextSecondary};
|
||||
margin-right: ${theme.sizeUnit * 0.5}px;
|
||||
font-size: 12px;
|
||||
&:hover {
|
||||
color: ${theme.colorText};
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorBgTextHover};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledBadge = styled(Badge)`
|
||||
${({ theme }) => `
|
||||
margin-left: ${theme.sizeUnit * 0.5}px;
|
||||
&>sup.ant-badge-count {
|
||||
padding: 0 ${theme.sizeUnit * 0.5}px;
|
||||
min-width: ${theme.sizeUnit * 3}px;
|
||||
height: ${theme.sizeUnit * 3}px;
|
||||
line-height: 1.2;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeSM - 2}px;
|
||||
box-shadow: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const TooltipContent = styled.div`
|
||||
${({ theme }) => `
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
overflow-x: hidden;
|
||||
color: ${theme.colorText};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SectionName = styled.span`
|
||||
${({ theme }) => `
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const GroupByInfo = styled.div`
|
||||
${({ theme }) => `
|
||||
margin-top: ${theme.sizeUnit}px;
|
||||
&:not(:last-child) {
|
||||
padding-bottom: ${theme.sizeUnit * 3}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const GroupByItem = styled.div`
|
||||
${({ theme }) => `
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const GroupByName = styled.span`
|
||||
${({ theme }) => `
|
||||
padding-right: ${theme.sizeUnit}px;
|
||||
font-style: italic;
|
||||
`}
|
||||
`;
|
||||
|
||||
const GroupByValue = styled.span`
|
||||
max-width: 100%;
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
`;
|
||||
|
||||
export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
|
||||
const [tooltipVisible, setTooltipVisible] = useState(false);
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
const chartCustomizationItems = useSelector<
|
||||
RootState,
|
||||
ChartCustomizationItem[]
|
||||
>(
|
||||
({ dashboardInfo }) =>
|
||||
dashboardInfo.metadata?.chart_customization_config || [],
|
||||
);
|
||||
|
||||
const chartDataset = useSelector<RootState, string | null>(state => {
|
||||
const chart = state.charts[chartId];
|
||||
if (!chart?.latestQueryFormData?.datasource) {
|
||||
return null;
|
||||
}
|
||||
const chartDatasetParts = String(
|
||||
chart.latestQueryFormData.datasource,
|
||||
).split('__');
|
||||
return chartDatasetParts[0];
|
||||
});
|
||||
|
||||
const applicableGroupBys = useMemo(() => {
|
||||
if (!chartDataset) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chartCustomizationItems.filter(item => {
|
||||
if (item.removed) return false;
|
||||
|
||||
const targetDataset = item.customization?.dataset;
|
||||
if (!targetDataset) return false;
|
||||
|
||||
const targetDatasetId = String(targetDataset);
|
||||
const matchesDataset = chartDataset === targetDatasetId;
|
||||
|
||||
const hasColumn = item.customization?.column;
|
||||
|
||||
return matchesDataset && hasColumn;
|
||||
});
|
||||
}, [chartCustomizationItems, chartDataset]);
|
||||
|
||||
const chart = useSelector<RootState, any>(state => state.charts[chartId]);
|
||||
const chartType = chart?.latestQueryFormData?.viz_type;
|
||||
|
||||
const effectiveGroupBys = useMemo(() => {
|
||||
if (!chartType || applicableGroupBys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isChartWithoutGroupBy(chartType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chartFormData = chart?.latestQueryFormData;
|
||||
if (!chartFormData) {
|
||||
return applicableGroupBys;
|
||||
}
|
||||
|
||||
const existingColumns = new Set<string>();
|
||||
|
||||
const extractColumnNames = (columns: unknown[]): void => {
|
||||
if (Array.isArray(columns)) {
|
||||
columns.forEach((col: unknown) => {
|
||||
if (typeof col === 'string') {
|
||||
existingColumns.add(col);
|
||||
} else if (col && typeof col === 'object' && 'column_name' in col) {
|
||||
existingColumns.add((col as { column_name: string }).column_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const existingGroupBy = Array.isArray(chartFormData.groupby)
|
||||
? chartFormData.groupby
|
||||
: chartFormData.groupby
|
||||
? [chartFormData.groupby]
|
||||
: [];
|
||||
existingGroupBy.forEach((col: string) => existingColumns.add(col));
|
||||
|
||||
if (chartFormData.x_axis) {
|
||||
existingColumns.add(chartFormData.x_axis);
|
||||
}
|
||||
|
||||
const metrics = chartFormData.metrics || [];
|
||||
metrics.forEach((metric: any) => {
|
||||
if (typeof metric === 'string') {
|
||||
existingColumns.add(metric);
|
||||
} else if (metric && typeof metric === 'object' && 'column' in metric) {
|
||||
const metricColumn = metric.column;
|
||||
if (typeof metricColumn === 'string') {
|
||||
existingColumns.add(metricColumn);
|
||||
} else if (
|
||||
metricColumn &&
|
||||
typeof metricColumn === 'object' &&
|
||||
'column_name' in metricColumn
|
||||
) {
|
||||
existingColumns.add(metricColumn.column_name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (chartFormData.series) {
|
||||
existingColumns.add(chartFormData.series);
|
||||
}
|
||||
if (chartFormData.entity) {
|
||||
existingColumns.add(chartFormData.entity);
|
||||
}
|
||||
if (chartFormData.source) {
|
||||
existingColumns.add(chartFormData.source);
|
||||
}
|
||||
if (chartFormData.target) {
|
||||
existingColumns.add(chartFormData.target);
|
||||
}
|
||||
|
||||
if (chartType === 'pivot_table_v2') {
|
||||
extractColumnNames(chartFormData.groupbyColumns || []);
|
||||
}
|
||||
|
||||
if (chartType === 'box_plot') {
|
||||
extractColumnNames(chartFormData.columns || []);
|
||||
}
|
||||
|
||||
return applicableGroupBys.filter(item => {
|
||||
if (!item.customization?.column) return false;
|
||||
|
||||
let columnNames: string[] = [];
|
||||
if (typeof item.customization.column === 'string') {
|
||||
columnNames = [item.customization.column];
|
||||
} else if (Array.isArray(item.customization.column)) {
|
||||
columnNames = item.customization.column.filter(
|
||||
col => typeof col === 'string' && col.trim() !== '',
|
||||
);
|
||||
} else if (
|
||||
typeof item.customization.column === 'object' &&
|
||||
item.customization.column !== null
|
||||
) {
|
||||
const columnObj = item.customization.column as any;
|
||||
const columnName =
|
||||
columnObj.column_name || columnObj.name || String(columnObj);
|
||||
if (columnName && columnName.trim() !== '') {
|
||||
columnNames = [columnName];
|
||||
}
|
||||
}
|
||||
|
||||
return columnNames.length > 0;
|
||||
});
|
||||
}, [applicableGroupBys, chartType, chart]);
|
||||
|
||||
const groupByCount = effectiveGroupBys.length;
|
||||
|
||||
if (groupByCount === 0) {
|
||||
return null;
|
||||
}
|
||||
const tooltipContent = (
|
||||
<TooltipContent>
|
||||
<div>
|
||||
<SectionName>
|
||||
{t('Chart Customization (%d)', applicableGroupBys.length)}
|
||||
</SectionName>
|
||||
<GroupByInfo>
|
||||
{effectiveGroupBys.map(groupBy => (
|
||||
<GroupByItem key={groupBy.id}>
|
||||
<div>
|
||||
{groupBy.customization?.name &&
|
||||
groupBy.customization?.column ? (
|
||||
<>
|
||||
<GroupByName>{groupBy.customization.name}: </GroupByName>
|
||||
<GroupByValue>
|
||||
{getFilterValueForDisplay(groupBy.customization.column)}
|
||||
</GroupByValue>
|
||||
</>
|
||||
) : (
|
||||
groupBy.customization?.name || t('None')
|
||||
)}
|
||||
</div>
|
||||
</GroupByItem>
|
||||
))}
|
||||
</GroupByInfo>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
visible={tooltipVisible}
|
||||
onVisibleChange={setTooltipVisible}
|
||||
placement="bottom"
|
||||
overlayStyle={{
|
||||
color: theme.colorText,
|
||||
backgroundColor: theme.colorBgContainer,
|
||||
border: `1px solid ${theme.colorBorder}`,
|
||||
boxShadow: theme.boxShadow,
|
||||
}}
|
||||
overlayInnerStyle={{
|
||||
color: theme.colorText,
|
||||
backgroundColor: theme.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
<StyledTag
|
||||
ref={triggerRef}
|
||||
aria-label={t('Group by settings (%s)', groupByCount)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icons.GroupOutlined iconSize="m" />
|
||||
<StyledBadge
|
||||
data-test="applied-groupby-count"
|
||||
count={groupByCount}
|
||||
showZero={false}
|
||||
/>
|
||||
</StyledTag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(GroupByBadge);
|
||||
@@ -21,7 +21,6 @@ import { omit } from 'lodash';
|
||||
import jsonStringify from 'json-stringify-pretty-compact';
|
||||
import {
|
||||
Form,
|
||||
Modal,
|
||||
Collapse,
|
||||
CollapseLabelInModal,
|
||||
JsonEditor,
|
||||
@@ -151,11 +150,7 @@ const PropertiesModal = ({
|
||||
}
|
||||
}
|
||||
|
||||
Modal.error({
|
||||
title: t('Error'),
|
||||
content: errorText,
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
});
|
||||
addDangerToast(errorText);
|
||||
};
|
||||
|
||||
const handleDashboardData = useCallback(
|
||||
@@ -267,11 +262,7 @@ const PropertiesModal = ({
|
||||
|
||||
// only fire if the color_scheme is present and invalid
|
||||
if (colorScheme && !colorChoices.includes(colorScheme)) {
|
||||
Modal.error({
|
||||
title: t('Error'),
|
||||
content: t('A valid color scheme is required'),
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
});
|
||||
addDangerToast(t('A valid color scheme is required'));
|
||||
onHide();
|
||||
throw new Error('A valid color scheme is required');
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useSelector } from 'react-redux';
|
||||
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
|
||||
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
|
||||
import FiltersBadge from 'src/dashboard/components/FiltersBadge';
|
||||
import GroupByBadge from 'src/dashboard/components/GroupByBadge';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
|
||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
||||
@@ -299,6 +300,9 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
<CrossFilterIcon iconSize="m" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{!uiConfig.hideChartControls && (
|
||||
<GroupByBadge chartId={slice.slice_id} />
|
||||
)}
|
||||
|
||||
{!uiConfig.hideChartControls && (
|
||||
<FiltersBadge chartId={slice.slice_id} />
|
||||
|
||||
@@ -267,6 +267,9 @@ const Chart = props => {
|
||||
const chartConfiguration = useSelector(
|
||||
state => state.dashboardInfo.metadata?.chart_configuration,
|
||||
);
|
||||
const chartCustomizationItems = useSelector(
|
||||
state => state.dashboardInfo.metadata?.chart_customization_config || [],
|
||||
);
|
||||
const colorScheme = useSelector(state => state.dashboardState.colorScheme);
|
||||
const colorNamespace = useSelector(
|
||||
state => state.dashboardState.colorNamespace,
|
||||
@@ -294,6 +297,7 @@ const Chart = props => {
|
||||
getFormDataWithExtraFilters({
|
||||
chart,
|
||||
chartConfiguration,
|
||||
chartCustomizationItems,
|
||||
filters: getAppliedFilterValues(props.id),
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
@@ -310,6 +314,7 @@ const Chart = props => {
|
||||
[
|
||||
chart,
|
||||
chartConfiguration,
|
||||
chartCustomizationItems,
|
||||
props.id,
|
||||
props.extraControls,
|
||||
colorScheme,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* 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, useEffect, useMemo, useCallback, memo } from 'react';
|
||||
import { t, useTheme } from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isEmpty, isEqual, sortBy, debounce } from 'lodash';
|
||||
import { Form } from '@superset-ui/core/components';
|
||||
import type {
|
||||
DatasourcesState,
|
||||
ChartsState,
|
||||
RootState,
|
||||
} from 'src/dashboard/types';
|
||||
import { DatasetSelectLabel } from 'src/features/datasets/DatasetSelectLabel';
|
||||
import { mostUsedDataset } from '../FiltersConfigModal/FiltersConfigForm/utils';
|
||||
import ChartCustomizationTitlePane from './ChartCustomizationTitlePane';
|
||||
import ChartCustomizationForm from './ChartCustomizationForm';
|
||||
import { createDefaultChartCustomizationItem } from './utils';
|
||||
import { ChartCustomizationItem } from './types';
|
||||
import RemovedFilter from '../FiltersConfigModal/FiltersConfigForm/RemovedFilter';
|
||||
import { selectChartCustomizationItems } from './selectors';
|
||||
import { BaseConfigModal } from '../ConfigModal/BaseConfigModal';
|
||||
|
||||
export interface ChartCustomizationModalProps {
|
||||
isOpen: boolean;
|
||||
dashboardId: number;
|
||||
chartId?: number;
|
||||
initialItemId?: string;
|
||||
onCancel: () => void;
|
||||
onSave: (dashboardId: number, items: ChartCustomizationItem[]) => void;
|
||||
}
|
||||
|
||||
const ChartCustomizationModal = ({
|
||||
isOpen,
|
||||
dashboardId,
|
||||
chartId,
|
||||
initialItemId,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: ChartCustomizationModalProps) => {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [items, setItems] = useState<ChartCustomizationItem[]>([]);
|
||||
const [currentId, setCurrentId] = useState<string | null>(null);
|
||||
const [saveAlertVisible, setSaveAlertVisible] = useState(false);
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
const [removedItems, setRemovedItems] = useState<
|
||||
Record<string, { isPending: boolean; timerId?: number } | null>
|
||||
>({});
|
||||
|
||||
const [itemChanges, setItemChanges] = useState<{
|
||||
modified: string[];
|
||||
deleted: string[];
|
||||
reordered: string[];
|
||||
}>({
|
||||
modified: [],
|
||||
deleted: [],
|
||||
reordered: [],
|
||||
});
|
||||
|
||||
const resetItemChanges = () => {
|
||||
setItemChanges({
|
||||
modified: [],
|
||||
deleted: [],
|
||||
reordered: [],
|
||||
});
|
||||
};
|
||||
|
||||
const [erroredItems, setErroredItems] = useState<string[]>([]);
|
||||
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
const changed = form.getFieldValue('changed');
|
||||
const isFieldsTouched = form.isFieldsTouched();
|
||||
const hasNewItems = items.some(item => !item.id.startsWith('existing_'));
|
||||
const hasRemovedItems = items.some(item => item.removed);
|
||||
|
||||
return changed || isFieldsTouched || hasNewItems || hasRemovedItems;
|
||||
}, [form, items]);
|
||||
|
||||
const validateForm = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setErroredItems([]);
|
||||
return values;
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'errorFields' in error) {
|
||||
const { errorFields } = error as {
|
||||
errorFields: Array<{ name: (string | number)[] }>;
|
||||
};
|
||||
const errorItemIds = errorFields
|
||||
.map(field => field.name[1])
|
||||
.filter(
|
||||
(id: string | number) =>
|
||||
id && items.some(item => item.id === String(id)),
|
||||
)
|
||||
.map(String);
|
||||
setErroredItems(errorItemIds);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [form, items]);
|
||||
|
||||
const handleErroredItems = useCallback(() => {
|
||||
const formValidationFields = form.getFieldsError();
|
||||
const erroredItemIds: string[] = [];
|
||||
|
||||
formValidationFields.forEach(field => {
|
||||
const itemId = field.name[1] as string;
|
||||
if (
|
||||
field.errors.length > 0 &&
|
||||
!erroredItemIds.includes(itemId) &&
|
||||
!removedItems[itemId]
|
||||
) {
|
||||
erroredItemIds.push(itemId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!erroredItemIds.length && erroredItems.length > 0) {
|
||||
setErroredItems([]);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
erroredItemIds.length > 0 &&
|
||||
!isEqual(sortBy(erroredItems), sortBy(erroredItemIds))
|
||||
) {
|
||||
setErroredItems(erroredItemIds);
|
||||
}
|
||||
}, [form, erroredItems, removedItems]);
|
||||
|
||||
const resetForm = useCallback(
|
||||
(isSaving = false) => {
|
||||
setItems([]);
|
||||
setCurrentId(null);
|
||||
setSaveAlertVisible(false);
|
||||
setInitialLoadComplete(false);
|
||||
setErroredItems([]);
|
||||
|
||||
if (!isSaving) {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ changed: false });
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const values = await validateForm();
|
||||
|
||||
if (values) {
|
||||
const updatedItems = items.map(item => {
|
||||
const formItemValues = values.filters?.[item.id] || {};
|
||||
const isDeleted = itemChanges.deleted.includes(item.id);
|
||||
|
||||
const rawDataset = formItemValues.dataset;
|
||||
|
||||
const datasetId =
|
||||
typeof rawDataset === 'object' && rawDataset?.value
|
||||
? rawDataset.value
|
||||
: rawDataset;
|
||||
|
||||
const formDatasetInfo = formItemValues.datasetInfo;
|
||||
const datasetInfo =
|
||||
formDatasetInfo &&
|
||||
typeof formDatasetInfo === 'object' &&
|
||||
formDatasetInfo.table_name
|
||||
? {
|
||||
value: formDatasetInfo.value,
|
||||
label: formDatasetInfo.label,
|
||||
table_name: formDatasetInfo.table_name,
|
||||
schema: formDatasetInfo.schema,
|
||||
}
|
||||
: item.customization.datasetInfo;
|
||||
|
||||
const updatedItem = {
|
||||
...item,
|
||||
removed: isDeleted,
|
||||
customization: {
|
||||
...item.customization,
|
||||
...formItemValues,
|
||||
dataset: datasetId,
|
||||
datasetInfo,
|
||||
},
|
||||
};
|
||||
|
||||
return updatedItem;
|
||||
});
|
||||
|
||||
onSave(dashboardId, updatedItems);
|
||||
resetForm(true);
|
||||
resetItemChanges();
|
||||
onCancel();
|
||||
} else if (erroredItems.length > 0) {
|
||||
setCurrentId(erroredItems[0]);
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
items,
|
||||
itemChanges,
|
||||
onSave,
|
||||
dashboardId,
|
||||
resetForm,
|
||||
resetItemChanges,
|
||||
onCancel,
|
||||
erroredItems,
|
||||
]);
|
||||
|
||||
const handleConfirmCancel = useCallback(() => {
|
||||
resetForm();
|
||||
onCancel();
|
||||
}, [resetForm, onCancel]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (hasUnsavedChanges()) {
|
||||
setSaveAlertVisible(true);
|
||||
} else {
|
||||
handleConfirmCancel();
|
||||
}
|
||||
}, [hasUnsavedChanges, handleConfirmCancel]);
|
||||
|
||||
const existingItems = useSelector(selectChartCustomizationItems);
|
||||
|
||||
const loadedDatasets = useSelector<RootState, DatasourcesState>(
|
||||
({ datasources }) => datasources,
|
||||
);
|
||||
const charts = useSelector<RootState, ChartsState>(({ charts }) => charts);
|
||||
|
||||
const addItem = useCallback(() => {
|
||||
const usedDatasetIds = new Set<number>();
|
||||
items.forEach(existingItem => {
|
||||
if (existingItem.removed) return;
|
||||
|
||||
const { dataset } = existingItem.customization;
|
||||
if (dataset) {
|
||||
let datasetId: number;
|
||||
if (
|
||||
typeof dataset === 'object' &&
|
||||
dataset !== null &&
|
||||
'value' in dataset
|
||||
) {
|
||||
datasetId = Number((dataset as { value: string | number }).value);
|
||||
} else {
|
||||
datasetId = Number(dataset);
|
||||
}
|
||||
|
||||
if (!Number.isNaN(datasetId)) {
|
||||
usedDatasetIds.add(datasetId);
|
||||
}
|
||||
}
|
||||
});
|
||||
let fallbackDatasetId: number | undefined = mostUsedDataset(
|
||||
loadedDatasets,
|
||||
charts,
|
||||
);
|
||||
|
||||
if (fallbackDatasetId && usedDatasetIds.has(Number(fallbackDatasetId))) {
|
||||
const availableDatasets = Object.values(loadedDatasets).filter(
|
||||
dataset => !usedDatasetIds.has(dataset.id),
|
||||
);
|
||||
|
||||
fallbackDatasetId =
|
||||
availableDatasets.length > 0 ? availableDatasets[0].id : undefined;
|
||||
}
|
||||
|
||||
const item = createDefaultChartCustomizationItem(
|
||||
chartId,
|
||||
fallbackDatasetId,
|
||||
);
|
||||
setItems([...items, item]);
|
||||
setCurrentId(item.id);
|
||||
|
||||
const currentFormValues = form.getFieldsValue();
|
||||
let formattedDataset = null;
|
||||
|
||||
if (fallbackDatasetId) {
|
||||
const datasetInfo = Object.values(loadedDatasets).find(
|
||||
dataset => dataset.id === Number(fallbackDatasetId),
|
||||
);
|
||||
|
||||
formattedDataset = datasetInfo
|
||||
? {
|
||||
value: fallbackDatasetId,
|
||||
label: DatasetSelectLabel({
|
||||
id: Number(fallbackDatasetId),
|
||||
table_name: datasetInfo.table_name || '',
|
||||
schema: datasetInfo.schema || '',
|
||||
database: {
|
||||
database_name:
|
||||
(datasetInfo.database?.database_name as string) ||
|
||||
(datasetInfo.database?.name as string) ||
|
||||
'',
|
||||
},
|
||||
}),
|
||||
table_name: datasetInfo.table_name,
|
||||
schema: datasetInfo.schema,
|
||||
}
|
||||
: {
|
||||
value: fallbackDatasetId,
|
||||
label: `Dataset ${fallbackDatasetId}`,
|
||||
};
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
filters: {
|
||||
...currentFormValues.filters,
|
||||
[item.id]: {
|
||||
name: '',
|
||||
description: '',
|
||||
dataset: formattedDataset,
|
||||
column: null,
|
||||
sortFilter: false,
|
||||
sortAscending: true,
|
||||
sortMetric: null,
|
||||
hasDefaultValue: false,
|
||||
isRequired: false,
|
||||
selectFirst: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [items, chartId, loadedDatasets, charts, form]);
|
||||
|
||||
const restoreItem = useCallback(
|
||||
(id: string) => {
|
||||
const removal = removedItems[id];
|
||||
if (removal?.isPending && removal.timerId) {
|
||||
clearTimeout(removal.timerId);
|
||||
}
|
||||
|
||||
setRemovedItems(current => ({ ...current, [id]: null }));
|
||||
|
||||
setItemChanges(prev => ({
|
||||
...prev,
|
||||
deleted: prev.deleted.filter(deletedId => deletedId !== id),
|
||||
}));
|
||||
},
|
||||
[removedItems],
|
||||
);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(id: string) => {
|
||||
const completeItemRemoval = (itemId: string) => {
|
||||
setRemovedItems(removedItems => ({
|
||||
...removedItems,
|
||||
[itemId]: { isPending: false },
|
||||
}));
|
||||
};
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
completeItemRemoval(id);
|
||||
}, 3000);
|
||||
|
||||
setRemovedItems(removedItems => ({
|
||||
...removedItems,
|
||||
[id]: { isPending: true, timerId },
|
||||
}));
|
||||
|
||||
setItemChanges(prev => {
|
||||
const newChanges = {
|
||||
...prev,
|
||||
deleted: [...prev.deleted, id],
|
||||
};
|
||||
return newChanges;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
handleErroredItems();
|
||||
}, 0);
|
||||
|
||||
if (currentId === id) {
|
||||
const remainingItems = items.filter(item => item.id !== id);
|
||||
if (remainingItems.length > 0) {
|
||||
setCurrentId(remainingItems[0].id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[items, currentId, handleErroredItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentItemRemoved = removedItems[currentId || ''];
|
||||
if (currentItemRemoved && !currentItemRemoved.isPending) {
|
||||
const nextItem = items.find(
|
||||
item => !removedItems[item.id] && item.id !== currentId,
|
||||
);
|
||||
setCurrentId(nextItem?.id || null);
|
||||
}
|
||||
}, [currentId, removedItems, items]);
|
||||
|
||||
const handleValuesChange = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
setSaveAlertVisible(false);
|
||||
handleErroredItems();
|
||||
}, 1000),
|
||||
[handleErroredItems],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
setExpanded(!expanded);
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !initialLoadComplete) {
|
||||
form.resetFields();
|
||||
resetItemChanges();
|
||||
|
||||
if (existingItems && existingItems.length > 0) {
|
||||
setItems(existingItems);
|
||||
|
||||
const initialItem = initialItemId
|
||||
? existingItems.find(item => item.id === initialItemId)
|
||||
: existingItems[0];
|
||||
|
||||
const selectedItemId = initialItem?.id || existingItems[0].id;
|
||||
setCurrentId(selectedItemId);
|
||||
|
||||
const formFilters: Record<string, any> = {};
|
||||
existingItems.forEach(item => {
|
||||
const datasetId = item.customization.dataset;
|
||||
const savedDatasetInfo = item.customization.datasetInfo as
|
||||
| {
|
||||
value: number;
|
||||
label: string;
|
||||
table_name: string;
|
||||
schema?: string;
|
||||
database?: {
|
||||
database_name?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const datasetInfo = datasetId
|
||||
? Object.values(loadedDatasets).find(
|
||||
dataset => dataset.id === Number(datasetId),
|
||||
)
|
||||
: null;
|
||||
|
||||
formFilters[item.id] = {
|
||||
...item.customization,
|
||||
dataset: datasetId
|
||||
? typeof datasetId === 'object' && !Array.isArray(datasetId)
|
||||
? datasetId
|
||||
: savedDatasetInfo && savedDatasetInfo.table_name
|
||||
? {
|
||||
value: datasetId,
|
||||
label: DatasetSelectLabel({
|
||||
id: Number(datasetId),
|
||||
table_name: savedDatasetInfo.table_name || '',
|
||||
schema: savedDatasetInfo.schema || '',
|
||||
database: {
|
||||
database_name:
|
||||
savedDatasetInfo.database?.database_name ||
|
||||
savedDatasetInfo.database?.name ||
|
||||
'',
|
||||
},
|
||||
}),
|
||||
table_name: savedDatasetInfo.table_name,
|
||||
schema: savedDatasetInfo.schema,
|
||||
}
|
||||
: datasetInfo
|
||||
? {
|
||||
value: datasetId,
|
||||
label: DatasetSelectLabel({
|
||||
id: Number(datasetId),
|
||||
table_name: datasetInfo.table_name || '',
|
||||
schema: datasetInfo.schema || '',
|
||||
database: {
|
||||
database_name:
|
||||
(datasetInfo.database?.database_name as string) ||
|
||||
(datasetInfo.database?.name as string) ||
|
||||
'',
|
||||
},
|
||||
}),
|
||||
table_name: datasetInfo.table_name,
|
||||
schema: datasetInfo.schema,
|
||||
}
|
||||
: {
|
||||
value: datasetId,
|
||||
label: `Dataset ${datasetId}`,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
const initialFormValues = {
|
||||
filters: formFilters,
|
||||
changed: false,
|
||||
};
|
||||
|
||||
form.setFieldsValue(initialFormValues);
|
||||
} else {
|
||||
const fallbackDatasetId = mostUsedDataset(loadedDatasets, charts);
|
||||
const newItem = createDefaultChartCustomizationItem(
|
||||
chartId,
|
||||
fallbackDatasetId,
|
||||
);
|
||||
setCurrentId(newItem.id);
|
||||
setItems([newItem]);
|
||||
|
||||
const datasetInfo = fallbackDatasetId
|
||||
? Object.values(loadedDatasets).find(
|
||||
dataset => dataset.id === Number(fallbackDatasetId),
|
||||
)
|
||||
: null;
|
||||
|
||||
const initialFormValues = {
|
||||
filters: {
|
||||
[newItem.id]: {
|
||||
name: '',
|
||||
description: '',
|
||||
dataset: datasetInfo
|
||||
? {
|
||||
value: fallbackDatasetId,
|
||||
label: DatasetSelectLabel({
|
||||
id: Number(fallbackDatasetId),
|
||||
table_name: datasetInfo.table_name || '',
|
||||
schema: datasetInfo.schema || '',
|
||||
database: {
|
||||
database_name:
|
||||
(datasetInfo.database?.database_name as string) ||
|
||||
(datasetInfo.database?.name as string) ||
|
||||
'',
|
||||
},
|
||||
}),
|
||||
table_name: datasetInfo.table_name,
|
||||
schema: datasetInfo.schema,
|
||||
}
|
||||
: fallbackDatasetId
|
||||
? String(fallbackDatasetId)
|
||||
: null,
|
||||
column: null,
|
||||
sortFilter: false,
|
||||
sortAscending: true,
|
||||
sortMetric: null,
|
||||
hasDefaultValue: false,
|
||||
isRequired: false,
|
||||
selectFirst: false,
|
||||
},
|
||||
},
|
||||
changed: false,
|
||||
};
|
||||
|
||||
form.setFieldsValue(initialFormValues);
|
||||
}
|
||||
setInitialLoadComplete(true);
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
initialLoadComplete,
|
||||
existingItems,
|
||||
initialItemId,
|
||||
form,
|
||||
chartId,
|
||||
loadedDatasets,
|
||||
charts,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setInitialLoadComplete(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(items)) {
|
||||
setErroredItems(prevErroredItems =>
|
||||
prevErroredItems.filter(
|
||||
f => !items.find(item => item.id === f)?.removed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const leftPane = (
|
||||
<div
|
||||
css={{
|
||||
minWidth: '290px',
|
||||
maxWidth: '290px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderRight: `1px solid ${theme.colorSplit}`,
|
||||
}}
|
||||
>
|
||||
<ChartCustomizationTitlePane
|
||||
items={items.filter(
|
||||
item => !removedItems[item.id] || removedItems[item.id]?.isPending,
|
||||
)}
|
||||
currentId={currentId}
|
||||
chartId={chartId}
|
||||
setCurrentId={setCurrentId}
|
||||
onChange={setCurrentId}
|
||||
onAdd={addItem}
|
||||
onRemove={handleRemoveItem}
|
||||
restoreItem={restoreItem}
|
||||
removedItems={removedItems}
|
||||
erroredItems={erroredItems}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const rightPane = (
|
||||
<div
|
||||
css={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: `${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px 0`,
|
||||
}}
|
||||
>
|
||||
{items
|
||||
.filter(
|
||||
item => !removedItems[item.id] || removedItems[item.id]?.isPending,
|
||||
)
|
||||
.map(item => {
|
||||
const isRemoved = !!removedItems[item.id];
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
css={{
|
||||
display: item.id === currentId ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
{isRemoved ? (
|
||||
<RemovedFilter onClick={() => restoreItem(item.id)} />
|
||||
) : (
|
||||
<ChartCustomizationForm
|
||||
form={form}
|
||||
item={item}
|
||||
removedItems={removedItems}
|
||||
allItems={items}
|
||||
onUpdate={updatedItem => {
|
||||
setItems(prev =>
|
||||
prev.map(i =>
|
||||
i.id === updatedItem.id ? updatedItem : i,
|
||||
),
|
||||
);
|
||||
|
||||
form.setFieldsValue({
|
||||
changed: true,
|
||||
});
|
||||
|
||||
handleErroredItems();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
css={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{leftPane}
|
||||
{rightPane}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseConfigModal
|
||||
isOpen={isOpen}
|
||||
title={t('Chart customization')}
|
||||
expanded={expanded}
|
||||
onCancel={handleCancel}
|
||||
onSave={handleSave}
|
||||
leftPane={content}
|
||||
rightPane={null}
|
||||
form={form}
|
||||
onValuesChange={handleValuesChange}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
canSave={!erroredItems.length}
|
||||
saveAlertVisible={saveAlertVisible}
|
||||
onDismissSaveAlert={() => setSaveAlertVisible(false)}
|
||||
onConfirmCancel={handleConfirmCancel}
|
||||
testId="chart-customization-modal"
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default memo(ChartCustomizationModal);
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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 { FC, forwardRef, MouseEvent } from 'react';
|
||||
import { styled, t, css, useTheme } from '@superset-ui/core';
|
||||
import { Icons, Flex } from '@superset-ui/core/components';
|
||||
import { ChartCustomizationItem } from './types';
|
||||
|
||||
interface Props {
|
||||
items: ChartCustomizationItem[];
|
||||
currentId: string | null;
|
||||
onChange: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
restoreItem: (id: string) => void;
|
||||
removedItems: Record<string, { isPending: boolean; timerId?: number } | null>;
|
||||
erroredItems?: string[];
|
||||
}
|
||||
|
||||
const FilterTitle = styled.div<{ selected: boolean; errored: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px
|
||||
${({ theme }) => theme.sizeUnit * 3}px;
|
||||
background-color: ${({ theme, selected }) =>
|
||||
selected ? theme.colorPrimaryBg : theme.colorBgTextHover};
|
||||
border: 1px solid
|
||||
${({ theme, selected }) => (selected ? theme.colorPrimary : 'transparent')};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.colorBgTextHover};
|
||||
}
|
||||
|
||||
${({ theme, errored }) =>
|
||||
errored &&
|
||||
`
|
||||
&.errored div, &.errored .warning {
|
||||
color: ${theme.colorErrorText};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const LabelWrapper = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const TitleText = styled.div`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const UndoButton = styled.span`
|
||||
margin-left: auto;
|
||||
color: ${({ theme }) => theme.colorPrimary};
|
||||
cursor: pointer;
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const TrashIcon = styled(Icons.DeleteOutlined)`
|
||||
cursor: pointer;
|
||||
margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colorErrorText};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledWarning = styled(Icons.ExclamationCircleOutlined)`
|
||||
margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
color: ${({ theme }) => theme.colorErrorText};
|
||||
`;
|
||||
|
||||
const ChartCustomizationTitleContainer: FC<Props> = forwardRef(
|
||||
(
|
||||
{
|
||||
items,
|
||||
currentId,
|
||||
onChange,
|
||||
onRemove,
|
||||
restoreItem,
|
||||
removedItems,
|
||||
erroredItems = [],
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Flex
|
||||
css={css`
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
ref={ref as any}
|
||||
>
|
||||
{items.map(item => {
|
||||
const isRemoved = !!removedItems[item.id];
|
||||
const selected = item.id === currentId;
|
||||
const isErrored = erroredItems.includes(item.id);
|
||||
const displayName =
|
||||
item.customization.name?.trim() || t('[untitled]');
|
||||
|
||||
return (
|
||||
<FilterTitle
|
||||
key={`group-by-title-${item.id}`}
|
||||
role="tab"
|
||||
tabIndex={0}
|
||||
selected={selected}
|
||||
errored={isErrored}
|
||||
onClick={() => onChange(item.id)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LabelWrapper>
|
||||
<TitleText>
|
||||
{isRemoved ? t('(Removed)') : displayName}
|
||||
</TitleText>
|
||||
{isRemoved && (
|
||||
<UndoButton
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e: MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
restoreItem(item.id);
|
||||
}}
|
||||
>
|
||||
{t('Undo?')}
|
||||
</UndoButton>
|
||||
)}
|
||||
</LabelWrapper>
|
||||
{!isRemoved && (
|
||||
<>
|
||||
{isErrored && (
|
||||
<StyledWarning className="warning" iconSize="s" />
|
||||
)}
|
||||
<TrashIcon
|
||||
iconSize="m"
|
||||
onClick={(e: MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
onRemove(item.id);
|
||||
}}
|
||||
aria-label={t('Remove group by')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FilterTitle>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ChartCustomizationTitleContainer;
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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 { FC, useRef } from 'react';
|
||||
import { styled, t, useTheme } from '@superset-ui/core';
|
||||
import { Button, Icons } from '@superset-ui/core/components';
|
||||
import ChartCustomizationTitleContainer from './ChartCustomizationTitleContainer';
|
||||
import { ChartCustomizationItem } from './types';
|
||||
import { createDefaultChartCustomizationItem } from './utils';
|
||||
|
||||
interface Props {
|
||||
items: ChartCustomizationItem[];
|
||||
currentId: string | null;
|
||||
chartId?: number;
|
||||
onChange: (id: string) => void;
|
||||
onAdd: (item: ChartCustomizationItem) => void;
|
||||
onRemove: (id: string) => void;
|
||||
restoreItem: (id: string) => void;
|
||||
removedItems: Record<string, { isPending: boolean; timerId?: number } | null>;
|
||||
setCurrentId: (id: string) => void;
|
||||
erroredItems?: string[];
|
||||
}
|
||||
|
||||
const PaneContainer = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
padding-top: 2px;
|
||||
`;
|
||||
|
||||
const ChartCustomizationTitlePane: FC<Props> = ({
|
||||
items,
|
||||
currentId,
|
||||
chartId,
|
||||
onChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
restoreItem,
|
||||
removedItems,
|
||||
setCurrentId,
|
||||
erroredItems = [],
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
const newItem = createDefaultChartCustomizationItem(chartId);
|
||||
|
||||
onAdd(newItem);
|
||||
setCurrentId(newItem.id);
|
||||
|
||||
setTimeout(() => {
|
||||
listRef.current?.scroll({
|
||||
top: listRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
return (
|
||||
<PaneContainer>
|
||||
<div
|
||||
ref={listRef}
|
||||
css={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<ChartCustomizationTitleContainer
|
||||
items={items}
|
||||
currentId={currentId}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
restoreItem={restoreItem}
|
||||
removedItems={removedItems}
|
||||
erroredItems={erroredItems}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
css={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
paddingTop: theme.sizeUnit * 3,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
buttonSize="default"
|
||||
buttonStyle="secondary"
|
||||
aria-label={t('Add dynamic group by')}
|
||||
icon={
|
||||
<Icons.GroupOutlined
|
||||
iconColor={theme.colorPrimaryActive}
|
||||
iconSize="m"
|
||||
/>
|
||||
}
|
||||
onClick={handleAdd}
|
||||
data-test="add-groupby-button"
|
||||
>
|
||||
{t('Add dynamic group by')}
|
||||
</Button>
|
||||
</div>
|
||||
</PaneContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartCustomizationTitlePane;
|
||||
@@ -0,0 +1,647 @@
|
||||
/**
|
||||
* 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 { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
styled,
|
||||
t,
|
||||
css,
|
||||
DataMaskStateWithId,
|
||||
useTheme,
|
||||
useTruncation,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
Typography,
|
||||
Select,
|
||||
Popover,
|
||||
Loading,
|
||||
Icons,
|
||||
Tooltip,
|
||||
FormItem,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import {
|
||||
setPendingChartCustomization,
|
||||
loadChartCustomizationData,
|
||||
} from 'src/dashboard/actions/chartCustomizationActions';
|
||||
import { TooltipWithTruncation } from 'src/dashboard/components/nativeFilters/FilterCard/TooltipWithTruncation';
|
||||
import { dispatchChartCustomizationHoverAction } from '../FilterBar/FilterControls/utils';
|
||||
import { mergeExtraFormData } from '../utils';
|
||||
import { ChartCustomizationItem } from './types';
|
||||
|
||||
interface GroupByFilterCardProps {
|
||||
customizationItem: ChartCustomizationItem;
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
const Row = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: ${({ theme }) => theme.sizeUnit}px 0;
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const RowLabel = styled.span`
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
padding-right: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
margin-right: auto;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const RowValue = styled.div`
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline;
|
||||
`;
|
||||
|
||||
const InternalRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const FilterTitle = styled(Typography.Text)`
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
font-weight: 600;
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: ${({ theme }) => theme.colorPrimary};
|
||||
}
|
||||
`;
|
||||
|
||||
const HorizontalFormItem = styled(FormItem)`
|
||||
&& {
|
||||
margin-bottom: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
padding-bottom: 0;
|
||||
margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
|
||||
& > label {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
font-weight: ${({ theme }) => theme.fontWeightNormal};
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-form-item-control {
|
||||
min-width: 164px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.select-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Allow dropdown to expand beyond form item width */
|
||||
.ant-select-dropdown {
|
||||
min-width: 200px !important;
|
||||
max-width: 400px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const ToolTipContainer = styled.div`
|
||||
font-size: ${({ theme }) => theme.fontSize}px;
|
||||
display: flex;
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
|
||||
`;
|
||||
|
||||
const RequiredFieldIndicator = () => (
|
||||
<span
|
||||
css={(theme: any) => ({
|
||||
color: theme.colorError,
|
||||
fontSize: `${theme.fontSizeSM}px`,
|
||||
paddingLeft: '1px',
|
||||
})}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
);
|
||||
|
||||
const DescriptionTooltip = ({ description }: { description: string }) => (
|
||||
<ToolTipContainer>
|
||||
<Tooltip
|
||||
title={description}
|
||||
placement="right"
|
||||
overlayInnerStyle={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 10,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
>
|
||||
<Icons.InfoCircleOutlined
|
||||
className="text-muted"
|
||||
role="button"
|
||||
css={theme => ({
|
||||
paddingLeft: `${theme.sizeUnit}px`,
|
||||
})}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ToolTipContainer>
|
||||
);
|
||||
|
||||
const GroupByFilterCardContent: FC<{
|
||||
customizationItem: ChartCustomizationItem;
|
||||
hidePopover: () => void;
|
||||
}> = ({ customizationItem }) => {
|
||||
const { description, customization } = customizationItem;
|
||||
const { dataset, name } = customization || {};
|
||||
const [titleRef, , titleTruncated] = useTruncation();
|
||||
const displayName = name?.trim() || t('Dynamic group by');
|
||||
|
||||
const datasetLabel = useMemo(() => {
|
||||
const { datasetInfo, dataset: datasetValue } =
|
||||
customizationItem.customization;
|
||||
|
||||
if (datasetInfo) {
|
||||
if ('table_name' in datasetInfo) {
|
||||
return (datasetInfo as { table_name: string }).table_name;
|
||||
}
|
||||
if ('label' in datasetInfo) {
|
||||
const { label } = datasetInfo as { label: string };
|
||||
const tableNameMatch = label.match(/^([^([]+)/);
|
||||
return tableNameMatch ? tableNameMatch[1].trim() : label;
|
||||
}
|
||||
}
|
||||
|
||||
if (datasetValue) {
|
||||
if (typeof datasetValue === 'object' && 'label' in datasetValue) {
|
||||
const { label } = datasetValue as { label: string };
|
||||
const tableNameMatch = label.match(/^([^([]+)/);
|
||||
return tableNameMatch ? tableNameMatch[1].trim() : label;
|
||||
}
|
||||
if (typeof datasetValue === 'object' && 'table_name' in datasetValue) {
|
||||
return (datasetValue as { table_name: string }).table_name;
|
||||
}
|
||||
return `Dataset ${dataset}`;
|
||||
}
|
||||
return t('Not set');
|
||||
}, [
|
||||
customizationItem.customization.dataset,
|
||||
customizationItem.customization.datasetInfo,
|
||||
dataset,
|
||||
]);
|
||||
|
||||
const aggregationDisplay = useMemo(() => {
|
||||
if (customization.sortMetric) {
|
||||
return customization.sortMetric.toUpperCase();
|
||||
}
|
||||
return t('None');
|
||||
}, [customization.sortMetric]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row
|
||||
css={theme => css`
|
||||
margin-bottom: ${theme.sizeUnit * 3}px;
|
||||
justify-content: flex-start;
|
||||
`}
|
||||
>
|
||||
<InternalRow>
|
||||
<Icons.GroupOutlined
|
||||
iconSize="s"
|
||||
css={theme => css`
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
`}
|
||||
/>
|
||||
<TooltipWithTruncation title={titleTruncated ? displayName : null}>
|
||||
<div ref={titleRef}>
|
||||
<Typography.Text strong>{displayName}</Typography.Text>
|
||||
</div>
|
||||
</TooltipWithTruncation>
|
||||
</InternalRow>
|
||||
</Row>
|
||||
<Row>
|
||||
<RowLabel>{t('Type')}</RowLabel>
|
||||
<RowValue>{t('Dynamic group by')}</RowValue>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<RowLabel>{t('Dataset')}</RowLabel>
|
||||
<RowValue>
|
||||
{typeof datasetLabel === 'string' ? datasetLabel : 'Dataset'}
|
||||
</RowValue>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<RowLabel>{t('Aggregation')}</RowLabel>
|
||||
<RowValue>{aggregationDisplay}</RowValue>
|
||||
</Row>
|
||||
|
||||
{description && (
|
||||
<Row
|
||||
css={theme => css`
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<DescriptionTooltip description={description} />
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
|
||||
customizationItem,
|
||||
orientation = 'vertical',
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { customization } = customizationItem;
|
||||
const { dataset } = customization || {};
|
||||
const [filterTitleRef, , titleElementsTruncated] = useTruncation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isHoverCardVisible, setIsHoverCardVisible] = useState(false);
|
||||
const [columnOptions, setColumnOptions] = useState<
|
||||
{ label: string; value: string }[]
|
||||
>([]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isHorizontalLayout = orientation === 'horizontal';
|
||||
|
||||
const hideHoverCard = useCallback(() => {
|
||||
setIsHoverCardVisible(false);
|
||||
}, []);
|
||||
|
||||
const setHoveredChartCustomization = useCallback(
|
||||
() => dispatchChartCustomizationHoverAction(dispatch, customizationItem.id),
|
||||
[dispatch, customizationItem.id],
|
||||
);
|
||||
|
||||
const unsetHoveredChartCustomization = useCallback(
|
||||
() => dispatchChartCustomizationHoverAction(dispatch),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const isRequired = useMemo(
|
||||
() => !!customizationItem.customization?.controlValues?.enableEmptyFilter,
|
||||
[customizationItem.customization?.controlValues?.enableEmptyFilter],
|
||||
);
|
||||
|
||||
const chartCustomizationLoading = useSelector<RootState, boolean>(
|
||||
state =>
|
||||
state.dashboardInfo.chartCustomizationLoading?.[customizationItem.id] ||
|
||||
false,
|
||||
);
|
||||
|
||||
const datasetId = useMemo(() => {
|
||||
if (!dataset) return null;
|
||||
|
||||
if (typeof dataset === 'string') {
|
||||
return dataset;
|
||||
}
|
||||
|
||||
if (typeof dataset === 'object' && dataset !== null) {
|
||||
if ('value' in dataset) {
|
||||
return String((dataset as { value: string | number }).value);
|
||||
}
|
||||
if ('id' in dataset) {
|
||||
return String((dataset as { id: string | number }).id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [dataset]);
|
||||
|
||||
const columnName = customizationItem.customization?.column;
|
||||
const canSelectMultiple =
|
||||
customizationItem.customization?.canSelectMultiple ?? true;
|
||||
|
||||
const columnDisplayName = useMemo(() => {
|
||||
if (customizationItem.customization?.name) {
|
||||
return customizationItem.customization.name;
|
||||
}
|
||||
if (customizationItem.title) {
|
||||
return customizationItem.title;
|
||||
}
|
||||
if (columnName) {
|
||||
return columnName;
|
||||
}
|
||||
return t('Group By');
|
||||
}, [
|
||||
customizationItem.customization?.name,
|
||||
customizationItem.title,
|
||||
columnName,
|
||||
]);
|
||||
|
||||
const useChartCustomizationDependencies = () => {
|
||||
const dataMask = useSelector<RootState, DataMaskStateWithId>(
|
||||
state => state.dataMask,
|
||||
);
|
||||
const filters = useSelector<RootState, any>(
|
||||
state => state.nativeFilters.filters,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
let dependencies = {};
|
||||
|
||||
Object.entries(filters).forEach(([filterId, filter]: [string, any]) => {
|
||||
if (
|
||||
filter.type === 'DIVIDER' ||
|
||||
!dataMask[filterId]?.filterState?.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterState = dataMask[filterId];
|
||||
dependencies = mergeExtraFormData(
|
||||
dependencies,
|
||||
filterState?.extraFormData,
|
||||
);
|
||||
});
|
||||
|
||||
return dependencies;
|
||||
}, [dataMask, filters]);
|
||||
};
|
||||
|
||||
const dependencies = useChartCustomizationDependencies();
|
||||
|
||||
useEffect(() => {
|
||||
if (datasetId && columnName) {
|
||||
dispatch(
|
||||
loadChartCustomizationData(customizationItem.id, datasetId, columnName),
|
||||
);
|
||||
}
|
||||
}, [datasetId, columnName, dispatch, customizationItem.id, dependencies]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(chartCustomizationLoading);
|
||||
}, [chartCustomizationLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchColumnOptions = async () => {
|
||||
if (!dataset) return;
|
||||
|
||||
try {
|
||||
const datasetId =
|
||||
typeof dataset === 'string'
|
||||
? dataset
|
||||
: typeof dataset === 'object' &&
|
||||
dataset !== null &&
|
||||
'value' in dataset
|
||||
? (dataset as { value: string | number }).value
|
||||
: null;
|
||||
|
||||
if (!datasetId) return;
|
||||
|
||||
const response = await fetch(`/api/v1/dataset/${datasetId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.result?.columns) {
|
||||
const options = data.result.columns
|
||||
.filter((col: any) => col.filterable !== false)
|
||||
.map((col: any) => ({
|
||||
label: col.verbose_name || col.column_name || col.name,
|
||||
value: col.column_name || col.name,
|
||||
}));
|
||||
setColumnOptions(options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch column options:', error);
|
||||
setColumnOptions([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchColumnOptions();
|
||||
}, [dataset]);
|
||||
|
||||
const displayTitle = columnDisplayName;
|
||||
|
||||
const description =
|
||||
customizationItem.description?.trim() ||
|
||||
customizationItem.customization.description?.trim();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isHorizontalLayout && (
|
||||
<Popover
|
||||
placement="right"
|
||||
overlayStyle={{ width: '280px' }}
|
||||
content={
|
||||
<GroupByFilterCardContent
|
||||
customizationItem={customizationItem}
|
||||
hidePopover={hideHoverCard}
|
||||
/>
|
||||
}
|
||||
mouseEnterDelay={0.2}
|
||||
mouseLeaveDelay={0.2}
|
||||
onOpenChange={visible => {
|
||||
setIsHoverCardVisible(visible);
|
||||
}}
|
||||
open={isHoverCardVisible}
|
||||
arrow={false}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
`}
|
||||
>
|
||||
<TooltipWithTruncation
|
||||
title={titleElementsTruncated ? displayTitle : null}
|
||||
>
|
||||
<div ref={filterTitleRef}>
|
||||
<FilterTitle>
|
||||
{displayTitle}
|
||||
{isRequired && <RequiredFieldIndicator />}
|
||||
</FilterTitle>
|
||||
</div>
|
||||
</TooltipWithTruncation>
|
||||
{description && <DescriptionTooltip description={description} />}
|
||||
</div>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{isHorizontalLayout ? (
|
||||
<HorizontalFormItem
|
||||
label={
|
||||
<Popover
|
||||
placement="bottom"
|
||||
overlayStyle={{ width: '240px' }}
|
||||
content={
|
||||
<GroupByFilterCardContent
|
||||
customizationItem={customizationItem}
|
||||
hidePopover={hideHoverCard}
|
||||
/>
|
||||
}
|
||||
mouseEnterDelay={0.2}
|
||||
mouseLeaveDelay={0.2}
|
||||
onOpenChange={visible => {
|
||||
setIsHoverCardVisible(visible);
|
||||
}}
|
||||
open={isHoverCardVisible}
|
||||
arrow={false}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{displayTitle}
|
||||
{isRequired && <RequiredFieldIndicator />}
|
||||
</div>
|
||||
</Popover>
|
||||
}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={setHoveredChartCustomization}
|
||||
onMouseLeave={unsetHoveredChartCustomization}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
autoClearSearchValue
|
||||
placeholder={t('Search columns...')}
|
||||
value={columnName || null}
|
||||
onChange={(value: string | string[]) => {
|
||||
const columnValue = canSelectMultiple
|
||||
? Array.isArray(value)
|
||||
? value.length > 0
|
||||
? value
|
||||
: null
|
||||
: value || null
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: null;
|
||||
|
||||
const updatedCustomization = {
|
||||
...customizationItem.customization,
|
||||
column: columnValue,
|
||||
};
|
||||
|
||||
dispatch(
|
||||
setPendingChartCustomization({
|
||||
id: customizationItem.id,
|
||||
title: customizationItem.title,
|
||||
customization: updatedCustomization,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
options={columnOptions}
|
||||
showSearch
|
||||
mode={canSelectMultiple ? 'multiple' : undefined}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
getPopupContainer={triggerNode => triggerNode.parentNode}
|
||||
oneLine={isHorizontalLayout}
|
||||
className="select-container"
|
||||
/>
|
||||
</div>
|
||||
</HorizontalFormItem>
|
||||
) : (
|
||||
<div
|
||||
css={css`
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
`}
|
||||
onMouseEnter={setHoveredChartCustomization}
|
||||
onMouseLeave={unsetHoveredChartCustomization}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
autoClearSearchValue
|
||||
placeholder={t('Search columns...')}
|
||||
value={columnName || null}
|
||||
onChange={(value: string | string[]) => {
|
||||
const columnValue = canSelectMultiple
|
||||
? Array.isArray(value)
|
||||
? value.length > 0
|
||||
? value
|
||||
: null
|
||||
: value || null
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: null;
|
||||
|
||||
const updatedCustomization = {
|
||||
...customizationItem.customization,
|
||||
column: columnValue,
|
||||
};
|
||||
|
||||
dispatch(
|
||||
setPendingChartCustomization({
|
||||
id: customizationItem.id,
|
||||
title: customizationItem.title,
|
||||
customization: updatedCustomization,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
options={columnOptions}
|
||||
showSearch
|
||||
mode={canSelectMultiple ? 'multiple' : undefined}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<Loading position="inline" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupByFilterCard;
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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 { createSelector } from '@reduxjs/toolkit';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { GroupByState } from 'src/dashboard/reducers/groupByCustomizations';
|
||||
import { ChartCustomizationItem } from './types';
|
||||
|
||||
export const selectGroupByState = (state: RootState): GroupByState =>
|
||||
state.groupByCustomizations?.groupByState || {};
|
||||
|
||||
export const selectGroupByValues: (
|
||||
state: RootState,
|
||||
groupById: string,
|
||||
) => string[] = createSelector(
|
||||
[selectGroupByState, (state: RootState, groupById: string) => groupById],
|
||||
(groupByState, groupById): string[] =>
|
||||
groupByState[groupById]?.selectedValues || [],
|
||||
);
|
||||
|
||||
export const selectGroupByLoading: (
|
||||
state: RootState,
|
||||
groupById: string,
|
||||
) => boolean = createSelector(
|
||||
[selectGroupByState, (state: RootState, groupById: string) => groupById],
|
||||
(groupByState, groupById): boolean =>
|
||||
groupByState[groupById]?.isLoading || false,
|
||||
);
|
||||
|
||||
export const selectGroupByOptions: (
|
||||
state: RootState,
|
||||
groupById: string,
|
||||
) => Array<{ label: string; value: string }> = createSelector(
|
||||
[selectGroupByState, (state: RootState, groupById: string) => groupById],
|
||||
(groupByState, groupById): Array<{ label: string; value: string }> =>
|
||||
groupByState[groupById]?.availableOptions || [],
|
||||
);
|
||||
|
||||
export const selectGroupByFormData: (
|
||||
state: RootState,
|
||||
chartId: number,
|
||||
) => {
|
||||
groupby?: string[];
|
||||
filters?: Array<{ col: string; op: string; val: string[] }>;
|
||||
} = createSelector(
|
||||
[
|
||||
selectGroupByState,
|
||||
(state: RootState) =>
|
||||
state.dashboardInfo.metadata?.chart_customization_config || [],
|
||||
(state: RootState, chartId: number) => chartId,
|
||||
],
|
||||
(
|
||||
groupByState,
|
||||
chartCustomizationItems: ChartCustomizationItem[],
|
||||
chartId,
|
||||
) => {
|
||||
const groupByFormData: {
|
||||
groupby?: string[];
|
||||
filters?: Array<{ col: string; op: string; val: string[] }>;
|
||||
} = {};
|
||||
|
||||
const matchingCustomizations = chartCustomizationItems.filter(item => {
|
||||
if (item.removed) return false;
|
||||
return !item.chartId || item.chartId === chartId;
|
||||
});
|
||||
|
||||
const groupByColumns: string[] = [];
|
||||
const allFilters: Array<{ col: string; op: string; val: string[] }> = [];
|
||||
|
||||
matchingCustomizations.forEach(item => {
|
||||
const groupById = `chart_customization_${item.id}`;
|
||||
const groupByValues = groupByState[groupById]?.selectedValues || [];
|
||||
|
||||
if (item.customization?.column) {
|
||||
let columnNames: string[] = [];
|
||||
|
||||
if (typeof item.customization.column === 'string') {
|
||||
columnNames = [item.customization.column];
|
||||
} else if (Array.isArray(item.customization.column)) {
|
||||
columnNames = item.customization.column.filter(
|
||||
col => typeof col === 'string' && col.trim() !== '',
|
||||
);
|
||||
} else if (
|
||||
typeof item.customization.column === 'object' &&
|
||||
item.customization.column !== null
|
||||
) {
|
||||
const columnObj = item.customization.column as any;
|
||||
const columnName =
|
||||
columnObj.column_name || columnObj.name || String(columnObj);
|
||||
if (columnName && columnName.trim() !== '') {
|
||||
columnNames = [columnName];
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (columnNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
columnNames.forEach(columnName => {
|
||||
if (!groupByColumns.includes(columnName)) {
|
||||
groupByColumns.push(columnName);
|
||||
}
|
||||
});
|
||||
|
||||
columnNames.forEach(columnName => {
|
||||
if (groupByValues.length > 0) {
|
||||
allFilters.push({
|
||||
col: columnName,
|
||||
op: 'IN',
|
||||
val: groupByValues,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (groupByColumns.length > 0) {
|
||||
groupByFormData.groupby = groupByColumns;
|
||||
}
|
||||
|
||||
if (allFilters.length > 0) {
|
||||
groupByFormData.filters = allFilters;
|
||||
}
|
||||
|
||||
return groupByFormData;
|
||||
},
|
||||
);
|
||||
|
||||
export const selectGroupByExtraFormData: (
|
||||
state: RootState,
|
||||
chartId: number,
|
||||
datasetId: string,
|
||||
) => {
|
||||
groupby?: string[];
|
||||
order_by_cols?: string[];
|
||||
filters?: Array<{ col: string; op: string; val: string[] }>;
|
||||
} = createSelector(
|
||||
[
|
||||
selectGroupByState,
|
||||
(state: RootState) =>
|
||||
state.dashboardInfo.metadata?.chart_customization_config || [],
|
||||
(state: RootState, chartId: number, datasetId: string) => ({
|
||||
chartId,
|
||||
datasetId,
|
||||
}),
|
||||
],
|
||||
(
|
||||
groupByState,
|
||||
chartCustomizationItems: ChartCustomizationItem[],
|
||||
{ chartId, datasetId },
|
||||
) => {
|
||||
const extraFormData: {
|
||||
groupby?: string[];
|
||||
order_by_cols?: string[];
|
||||
filters?: Array<{ col: string; op: string; val: string[] }>;
|
||||
} = {};
|
||||
|
||||
const matchingCustomizations = chartCustomizationItems.filter(item => {
|
||||
if (item.removed) return false;
|
||||
|
||||
const targetDatasetId = String(item.customization?.dataset || '');
|
||||
const datasetMatches = targetDatasetId === datasetId;
|
||||
const chartMatches = !item.chartId || item.chartId === chartId;
|
||||
|
||||
return datasetMatches && chartMatches;
|
||||
});
|
||||
|
||||
const groupByColumns: string[] = [];
|
||||
const allFilters: Array<{ col: string; op: string; val: string[] }> = [];
|
||||
let orderByConfig: string[] | undefined;
|
||||
|
||||
matchingCustomizations.forEach(item => {
|
||||
const { customization } = item;
|
||||
const groupById = `chart_customization_${item.id}`;
|
||||
const groupByValues = groupByState[groupById]?.selectedValues || [];
|
||||
|
||||
if (customization?.column) {
|
||||
let columnNames: string[] = [];
|
||||
|
||||
if (typeof customization.column === 'string') {
|
||||
columnNames = [customization.column];
|
||||
} else if (Array.isArray(customization.column)) {
|
||||
columnNames = customization.column.filter(
|
||||
col => typeof col === 'string' && col.trim() !== '',
|
||||
);
|
||||
} else if (
|
||||
typeof customization.column === 'object' &&
|
||||
customization.column !== null
|
||||
) {
|
||||
const columnObj = customization.column as any;
|
||||
const columnName =
|
||||
columnObj.column_name || columnObj.name || String(columnObj);
|
||||
if (columnName && columnName.trim() !== '') {
|
||||
columnNames = [columnName];
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (columnNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
columnNames.forEach(columnName => {
|
||||
if (!groupByColumns.includes(columnName)) {
|
||||
groupByColumns.push(columnName);
|
||||
}
|
||||
});
|
||||
|
||||
columnNames.forEach(columnName => {
|
||||
if (groupByValues.length > 0) {
|
||||
allFilters.push({
|
||||
col: columnName,
|
||||
op: 'IN',
|
||||
val: groupByValues,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (customization.sortFilter && customization.sortMetric) {
|
||||
orderByConfig = [
|
||||
JSON.stringify([
|
||||
customization.sortMetric,
|
||||
!customization.sortAscending,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (groupByColumns.length > 0) {
|
||||
extraFormData.groupby = groupByColumns;
|
||||
}
|
||||
|
||||
if (allFilters.length > 0) {
|
||||
extraFormData.filters = allFilters;
|
||||
}
|
||||
|
||||
if (orderByConfig) {
|
||||
extraFormData.order_by_cols = orderByConfig;
|
||||
}
|
||||
|
||||
return extraFormData;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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 { RootState } from 'src/dashboard/types';
|
||||
import { ChartCustomizationItem } from './types';
|
||||
|
||||
export const selectChartCustomizationItems = (
|
||||
state: RootState,
|
||||
): ChartCustomizationItem[] => {
|
||||
const { metadata } = state.dashboardInfo;
|
||||
|
||||
if (
|
||||
metadata?.chart_customization_config &&
|
||||
metadata.chart_customization_config.length > 0
|
||||
) {
|
||||
return metadata.chart_customization_config;
|
||||
}
|
||||
|
||||
const legacyCustomization = metadata?.native_filter_configuration?.find(
|
||||
(item: any) =>
|
||||
item.type === 'CHART_CUSTOMIZATION' &&
|
||||
item.id === 'chart_customization_groupby',
|
||||
);
|
||||
|
||||
if (legacyCustomization?.chart_customization) {
|
||||
return legacyCustomization.chart_customization;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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 { DataMask } from '@superset-ui/core';
|
||||
|
||||
interface DatasetReference {
|
||||
value: string | number;
|
||||
label?: string;
|
||||
table_name?: string;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
export interface ColumnOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GroupByCustomization {
|
||||
name: string;
|
||||
dataset: string | number | DatasetReference | null;
|
||||
datasetInfo?: {
|
||||
label: string;
|
||||
value: number;
|
||||
table_name: string;
|
||||
};
|
||||
column: string | string[] | null;
|
||||
description?: string;
|
||||
sortFilter?: boolean;
|
||||
sortAscending?: boolean;
|
||||
sortMetric?: string;
|
||||
hasDefaultValue?: boolean;
|
||||
defaultValue?: string;
|
||||
isRequired?: boolean;
|
||||
selectFirst?: boolean;
|
||||
defaultDataMask?: DataMask;
|
||||
defaultValueQueriesData?: ColumnOption[] | null;
|
||||
aggregation?: string;
|
||||
canSelectMultiple?: boolean;
|
||||
controlValues?: {
|
||||
enableEmptyFilter?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ChartCustomizationItem {
|
||||
id: string;
|
||||
title?: string;
|
||||
removed?: boolean;
|
||||
dataset?: string | null;
|
||||
description?: string;
|
||||
removeTimerId?: number;
|
||||
chartId?: number;
|
||||
settings?: {
|
||||
sortFilter: boolean;
|
||||
hasDefaultValue: boolean;
|
||||
isRequired: boolean;
|
||||
selectFirstByDefault: boolean;
|
||||
};
|
||||
customization: GroupByCustomization;
|
||||
}
|
||||
|
||||
export interface ChartCustomizationChangesType {
|
||||
modified: string[];
|
||||
deleted: string[];
|
||||
reordered: string[];
|
||||
}
|
||||
|
||||
export interface ChartCustomizationRemoval {
|
||||
isPending: boolean;
|
||||
timerId: number;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { saveChartCustomization } from 'src/dashboard/actions/chartCustomizationActions';
|
||||
import { ChartCustomizationItem } from './types';
|
||||
|
||||
export const useChartCustomizationModal = (chartId?: number) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dashboardId = useSelector<RootState, number>(
|
||||
state => state.dashboardInfo.id,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const openChartCustomizationModal = useCallback(() => setIsOpen(true), []);
|
||||
const closeChartCustomizationModal = useCallback(() => setIsOpen(false), []);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(dashboardId: number, items: ChartCustomizationItem[]) => {
|
||||
dispatch(saveChartCustomization(items));
|
||||
setIsOpen(false);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
dashboardId,
|
||||
chartId,
|
||||
openChartCustomizationModal,
|
||||
closeChartCustomizationModal,
|
||||
handleSave,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@superset-ui/core';
|
||||
import { ChartCustomizationItem, GroupByCustomization } from './types';
|
||||
|
||||
export function generateGroupById(): string {
|
||||
return `groupby_${Date.now()}`;
|
||||
}
|
||||
|
||||
export const getChartCustomizationIds = (items: ChartCustomizationItem[]) =>
|
||||
items.map(item => item.id);
|
||||
|
||||
export const createDefaultChartCustomizationItem = (
|
||||
chartId?: number,
|
||||
defaultDatasetId?: number,
|
||||
): ChartCustomizationItem => ({
|
||||
id: generateGroupById(),
|
||||
title: t('[untitled]'),
|
||||
dataset: null,
|
||||
description: '',
|
||||
removed: false,
|
||||
chartId,
|
||||
settings: {
|
||||
sortFilter: false,
|
||||
hasDefaultValue: false,
|
||||
isRequired: false,
|
||||
selectFirstByDefault: false,
|
||||
},
|
||||
customization: {
|
||||
name: '',
|
||||
dataset: defaultDatasetId ? String(defaultDatasetId) : null,
|
||||
column: null,
|
||||
sortAscending: true,
|
||||
hasDefaultValue: false,
|
||||
isRequired: false,
|
||||
selectFirst: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const ensureValidCustomization = (
|
||||
customization: Partial<GroupByCustomization> = {},
|
||||
): GroupByCustomization => ({
|
||||
name: customization.name || '',
|
||||
dataset: customization.dataset || null,
|
||||
column: customization.column || null,
|
||||
...customization,
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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 { ReactNode, useState, useCallback } from 'react';
|
||||
import type { FormInstance } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { BaseModalBody, BaseForm, BaseModalWrapper } from './SharedStyles';
|
||||
import { ModalFooter } from './ModalFooter';
|
||||
|
||||
export interface BaseConfigModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
expanded?: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
leftPane: ReactNode;
|
||||
rightPane: ReactNode;
|
||||
footer?: ReactNode;
|
||||
form?: FormInstance;
|
||||
onValuesChange?: (changedValues: any, allValues: any) => void;
|
||||
canSave?: boolean;
|
||||
saveAlertVisible?: boolean;
|
||||
onDismissSaveAlert?: () => void;
|
||||
onConfirmCancel?: () => void;
|
||||
onToggleExpand?: () => void;
|
||||
testId?: string;
|
||||
maskClosable?: boolean;
|
||||
destroyOnClose?: boolean;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
export const BaseConfigModal = ({
|
||||
isOpen,
|
||||
title,
|
||||
expanded = false,
|
||||
onCancel,
|
||||
onSave,
|
||||
leftPane,
|
||||
rightPane,
|
||||
footer,
|
||||
form,
|
||||
onValuesChange,
|
||||
canSave = true,
|
||||
saveAlertVisible = false,
|
||||
onDismissSaveAlert,
|
||||
onConfirmCancel,
|
||||
onToggleExpand,
|
||||
testId = 'base-config-modal',
|
||||
maskClosable = false,
|
||||
destroyOnClose = true,
|
||||
centered = true,
|
||||
}: BaseConfigModalProps) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
|
||||
const isExpandedControlled = onToggleExpand !== undefined;
|
||||
const isExpanded = isExpandedControlled ? expanded : internalExpanded;
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
if (isExpandedControlled && onToggleExpand) {
|
||||
onToggleExpand();
|
||||
} else {
|
||||
setInternalExpanded(!internalExpanded);
|
||||
}
|
||||
}, [isExpandedControlled, onToggleExpand, internalExpanded]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave();
|
||||
}, [onSave]);
|
||||
|
||||
const handleDismissSaveAlert = useCallback(() => {
|
||||
if (onDismissSaveAlert) {
|
||||
onDismissSaveAlert();
|
||||
}
|
||||
}, [onDismissSaveAlert]);
|
||||
|
||||
const handleConfirmCancel = useCallback(() => {
|
||||
if (onConfirmCancel) {
|
||||
onConfirmCancel();
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}, [onConfirmCancel, onCancel]);
|
||||
|
||||
const defaultFooter = (
|
||||
<ModalFooter
|
||||
onCancel={handleCancel}
|
||||
onSave={handleSave}
|
||||
onConfirmCancel={handleConfirmCancel}
|
||||
onDismiss={handleDismissSaveAlert}
|
||||
saveAlertVisible={saveAlertVisible}
|
||||
canSave={canSave}
|
||||
expanded={isExpanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
saveButtonTestId={`${testId}-save-button`}
|
||||
cancelButtonTestId={`${testId}-cancel-button`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseModalWrapper
|
||||
open={isOpen}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleSave}
|
||||
title={title}
|
||||
footer={footer || defaultFooter}
|
||||
centered={centered}
|
||||
destroyOnClose={destroyOnClose}
|
||||
maskClosable={maskClosable}
|
||||
data-test={testId}
|
||||
expanded={isExpanded}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<BaseModalBody expanded={isExpanded}>
|
||||
<BaseForm
|
||||
form={form}
|
||||
onValuesChange={onValuesChange}
|
||||
layout="vertical"
|
||||
css={{ width: '100%' }}
|
||||
>
|
||||
{leftPane}
|
||||
{rightPane}
|
||||
</BaseForm>
|
||||
</BaseModalBody>
|
||||
</ErrorBoundary>
|
||||
</BaseModalWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseConfigModal;
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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 { FC, ReactNode } from 'react';
|
||||
import {
|
||||
Button,
|
||||
type OnClickHandler,
|
||||
Alert,
|
||||
Icons,
|
||||
Flex,
|
||||
} from '@superset-ui/core/components';
|
||||
import { t, useTheme, styled, css } from '@superset-ui/core';
|
||||
import { BaseExpandButtonWrapper } from './SharedStyles';
|
||||
|
||||
const StyledAlert = styled(Alert)`
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
|
||||
& .ant-alert-action {
|
||||
align-self: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledActionButtons = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledFooterContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
`;
|
||||
|
||||
export interface ConfirmationAlertProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onConfirm: OnClickHandler;
|
||||
onDismiss: OnClickHandler;
|
||||
}
|
||||
|
||||
export function ConfirmationAlert({
|
||||
title,
|
||||
onConfirm,
|
||||
onDismiss,
|
||||
children,
|
||||
}: ConfirmationAlertProps) {
|
||||
return (
|
||||
<StyledAlert
|
||||
closable={false}
|
||||
type="warning"
|
||||
key="alert"
|
||||
message={title}
|
||||
description={children}
|
||||
action={
|
||||
<StyledActionButtons>
|
||||
<Button
|
||||
key="cancel"
|
||||
buttonSize="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
{t('Keep editing')}
|
||||
</Button>
|
||||
<Button
|
||||
key="submit"
|
||||
buttonSize="small"
|
||||
buttonStyle="primary"
|
||||
onClick={onConfirm}
|
||||
data-test="modal-confirm-cancel-button"
|
||||
>
|
||||
{t('Yes, cancel')}
|
||||
</Button>
|
||||
</StyledActionButtons>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ModalFooterProps {
|
||||
onCancel: OnClickHandler;
|
||||
onSave: OnClickHandler;
|
||||
onConfirmCancel: OnClickHandler;
|
||||
onDismiss: OnClickHandler;
|
||||
saveAlertVisible: boolean;
|
||||
canSave?: boolean;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
saveButtonTestId?: string;
|
||||
cancelButtonTestId?: string;
|
||||
saveButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
confirmationTitle?: string;
|
||||
confirmationMessage?: string;
|
||||
}
|
||||
|
||||
export const ModalFooter: FC<ModalFooterProps> = ({
|
||||
canSave = true,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDismiss,
|
||||
onConfirmCancel,
|
||||
saveAlertVisible,
|
||||
expanded = false,
|
||||
onToggleExpand,
|
||||
saveButtonTestId = 'modal-save-button',
|
||||
cancelButtonTestId = 'modal-cancel-button',
|
||||
saveButtonText = t('Save'),
|
||||
cancelButtonText = t('Cancel'),
|
||||
confirmationTitle = t('There are unsaved changes.'),
|
||||
confirmationMessage = t('Are you sure you want to cancel?'),
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (saveAlertVisible) {
|
||||
return (
|
||||
<ConfirmationAlert
|
||||
key="cancel-confirm"
|
||||
title={confirmationTitle}
|
||||
onConfirm={onConfirmCancel}
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
{confirmationMessage}
|
||||
</ConfirmationAlert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledFooterContainer>
|
||||
<Flex
|
||||
css={css`
|
||||
gap: 8px;
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
key="cancel"
|
||||
buttonStyle="secondary"
|
||||
data-test={cancelButtonTestId}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelButtonText}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSave}
|
||||
key="submit"
|
||||
buttonStyle="primary"
|
||||
onClick={onSave}
|
||||
data-test={saveButtonTestId}
|
||||
>
|
||||
{saveButtonText}
|
||||
</Button>
|
||||
</Flex>
|
||||
{onToggleExpand && (
|
||||
<BaseExpandButtonWrapper>
|
||||
{(() => {
|
||||
const ToggleIcon = expanded
|
||||
? Icons.FullscreenExitOutlined
|
||||
: Icons.FullscreenOutlined;
|
||||
return (
|
||||
<ToggleIcon
|
||||
iconSize="l"
|
||||
iconColor={theme.colorTextSecondary}
|
||||
onClick={onToggleExpand}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</BaseExpandButtonWrapper>
|
||||
)}
|
||||
</StyledFooterContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalFooter;
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 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 { styled, css } from '@superset-ui/core';
|
||||
import { Form, StyledModal } from '@superset-ui/core/components';
|
||||
|
||||
const MODAL_MARGIN = 16;
|
||||
const MIN_WIDTH = 880;
|
||||
|
||||
export interface BaseModalWrapperProps {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface BaseModalBodyProps {
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export const BaseModalWrapper = styled(StyledModal)<BaseModalWrapperProps>`
|
||||
min-width: ${MIN_WIDTH}px;
|
||||
width: ${({ expanded }) => (expanded ? '100%' : MIN_WIDTH)} !important;
|
||||
|
||||
@media (max-width: ${MIN_WIDTH + MODAL_MARGIN * 2}px) {
|
||||
width: 100% !important;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
${({ expanded }) =>
|
||||
expanded &&
|
||||
css`
|
||||
height: 100%;
|
||||
|
||||
.ant-modal-body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.ant-modal-content {
|
||||
height: 100%;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const BaseModalBody = styled.div<BaseModalBodyProps>`
|
||||
display: flex;
|
||||
height: ${({ expanded }) => (expanded ? '100%' : '700px')};
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
|
||||
.filters-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
export const BaseForm = styled(Form)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const BaseExpandButtonWrapper = styled.div`
|
||||
margin-left: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
export const BaseFormItem = styled(Form.Item)<{ expanded?: boolean }>`
|
||||
width: ${({ expanded }) => (expanded ? '49%' : '260px')};
|
||||
`;
|
||||
|
||||
export const BaseRowFormItem = styled(Form.Item)<{ expanded?: boolean }>`
|
||||
min-width: ${({ expanded }) => (expanded ? '50%' : '260px')};
|
||||
`;
|
||||
|
||||
export const BaseRowSubFormItem = styled(Form.Item)<{ expanded?: boolean }>`
|
||||
min-width: ${({ expanded }) => (expanded ? '50%' : '260px')};
|
||||
`;
|
||||
|
||||
export const BaseLabel = styled.span`
|
||||
${({ theme }) => `
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`}
|
||||
`;
|
||||
|
||||
export const BaseAsterisk = styled.span`
|
||||
${({ theme }) => `
|
||||
color: ${theme.colorError};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
margin-left: ${theme.sizeUnit - 1}px;
|
||||
|
||||
&:before {
|
||||
content: '*';
|
||||
}
|
||||
`}
|
||||
`;
|
||||
@@ -30,6 +30,7 @@ import { Button } from '@superset-ui/core/components';
|
||||
import { OPEN_FILTER_BAR_WIDTH } from 'src/dashboard/constants';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { FilterBarOrientation } from 'src/dashboard/types';
|
||||
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
|
||||
import { getFilterBarTestId } from '../utils';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
@@ -38,6 +39,7 @@ interface ActionButtonsProps {
|
||||
onClearAll: () => void;
|
||||
dataMaskSelected: DataMaskState;
|
||||
dataMaskApplied: DataMaskStateWithId;
|
||||
chartCustomizationItems?: ChartCustomizationItem[];
|
||||
isApplyDisabled: boolean;
|
||||
filterBarOrientation?: FilterBarOrientation;
|
||||
}
|
||||
@@ -106,17 +108,31 @@ const ActionButtons = ({
|
||||
dataMaskSelected,
|
||||
isApplyDisabled,
|
||||
filterBarOrientation = FilterBarOrientation.Vertical,
|
||||
chartCustomizationItems,
|
||||
}: ActionButtonsProps) => {
|
||||
const isClearAllEnabled = useMemo(
|
||||
() =>
|
||||
Object.values(dataMaskApplied).some(
|
||||
filter =>
|
||||
isDefined(dataMaskSelected[filter.id]?.filterState?.value) ||
|
||||
(!dataMaskSelected[filter.id] &&
|
||||
isDefined(filter.filterState?.value)),
|
||||
),
|
||||
[dataMaskApplied, dataMaskSelected],
|
||||
);
|
||||
const isClearAllEnabled = useMemo(() => {
|
||||
const hasSelectedChanges = Object.entries(dataMaskSelected).some(
|
||||
([, mask]) => {
|
||||
const hasValue = isDefined(mask?.filterState?.value);
|
||||
const hasGroupBy = isDefined(mask?.ownState?.column);
|
||||
return hasValue || hasGroupBy;
|
||||
},
|
||||
);
|
||||
|
||||
const hasAppliedChanges = Object.entries(dataMaskApplied).some(
|
||||
([, mask]) => {
|
||||
const hasValue = isDefined(mask?.filterState?.value);
|
||||
const hasGroupBy = isDefined(mask?.ownState?.column);
|
||||
return hasValue || hasGroupBy;
|
||||
},
|
||||
);
|
||||
|
||||
const hasChartCustomizations = chartCustomizationItems?.some(
|
||||
item => item.customization?.column && !item.removed,
|
||||
);
|
||||
|
||||
return hasSelectedChanges || hasAppliedChanges || hasChartCustomizations;
|
||||
}, [dataMaskSelected, dataMaskApplied, chartCustomizationItems]);
|
||||
const isVertical = filterBarOrientation === FilterBarOrientation.Vertical;
|
||||
|
||||
return (
|
||||
|
||||
@@ -86,7 +86,7 @@ const CrossFilter = (props: {
|
||||
/>
|
||||
)}
|
||||
{last && (
|
||||
<span
|
||||
<div
|
||||
data-test="cross-filters-divider"
|
||||
css={css`
|
||||
${orientation === FilterBarOrientation.Horizontal
|
||||
@@ -95,6 +95,7 @@ const CrossFilter = (props: {
|
||||
height: 22px;
|
||||
margin-left: ${theme.sizeUnit * 4}px;
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
: `
|
||||
width: 100%;
|
||||
|
||||
@@ -26,7 +26,11 @@ import crossFiltersSelector from './selectors';
|
||||
import VerticalCollapse from './VerticalCollapse';
|
||||
import { useChartsVerboseMaps } from '../utils';
|
||||
|
||||
const CrossFiltersVertical = () => {
|
||||
const CrossFiltersVertical = ({
|
||||
hideHeader = false,
|
||||
}: {
|
||||
hideHeader?: boolean;
|
||||
}) => {
|
||||
const dataMask = useSelector<RootState, DataMaskStateWithId>(
|
||||
state => state.dataMask,
|
||||
);
|
||||
@@ -40,7 +44,12 @@ const CrossFiltersVertical = () => {
|
||||
verboseMaps,
|
||||
});
|
||||
|
||||
return <VerticalCollapse crossFilters={selectedCrossFilters} />;
|
||||
return (
|
||||
<VerticalCollapse
|
||||
crossFilters={selectedCrossFilters}
|
||||
hideHeader={hideHeader}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrossFiltersVertical;
|
||||
|
||||
@@ -17,17 +17,88 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Collapse, Divider } from '@superset-ui/core/components';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { t, css, useTheme, SupersetTheme } from '@superset-ui/core';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { FilterBarOrientation } from 'src/dashboard/types';
|
||||
import CrossFilter from './CrossFilter';
|
||||
import { CrossFilterIndicator } from '../../selectors';
|
||||
|
||||
const CrossFiltersVerticalCollapse = (props: {
|
||||
crossFilters: CrossFilterIndicator[];
|
||||
hideHeader?: boolean;
|
||||
}) => {
|
||||
const { crossFilters } = props;
|
||||
const { crossFilters, hideHeader = false } = props;
|
||||
const theme = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const toggleSection = useCallback(() => {
|
||||
setIsOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const sectionContainerStyle = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
margin-bottom: ${theme.sizeUnit * 3}px;
|
||||
padding: 0 ${theme.sizeUnit * 4}px;
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const sectionHeaderStyle = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${theme.sizeUnit * 2}px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorBgTextHover};
|
||||
margin: 0 -${theme.sizeUnit * 2}px;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
}
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const sectionTitleStyle = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
margin: 0;
|
||||
font-size: ${theme.fontSize}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorText};
|
||||
line-height: 1.3;
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const sectionContentStyle = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
padding: ${theme.sizeUnit * 2}px 0;
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const dividerStyle = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
height: 1px;
|
||||
background: ${theme.colorSplit};
|
||||
margin: ${theme.sizeUnit * 2}px 0;
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const iconStyle = useCallback(
|
||||
(isOpen: boolean, theme: SupersetTheme) => css`
|
||||
transform: ${isOpen ? 'rotate(0deg)' : 'rotate(180deg)'};
|
||||
transition: transform 0.2s ease;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const crossFiltersIndicators = useMemo(
|
||||
() =>
|
||||
crossFilters.map(filter => (
|
||||
@@ -45,23 +116,27 @@ const CrossFiltersVerticalCollapse = (props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
defaultActiveKey="crossFilters"
|
||||
expandIconPosition="end"
|
||||
items={[
|
||||
{
|
||||
key: 'crossFilters',
|
||||
label: t('Cross-filters'),
|
||||
children: (
|
||||
<>
|
||||
{crossFiltersIndicators}
|
||||
<Divider data-test="cross-filters-divider" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div css={sectionContainerStyle}>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
css={sectionHeaderStyle}
|
||||
onClick={toggleSection}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleSection();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<h4 css={sectionTitleStyle}>{t('Cross-filters')}</h4>
|
||||
<Icons.UpOutlined iconSize="m" css={iconStyle(isOpen, theme)} />
|
||||
</div>
|
||||
)}
|
||||
{isOpen && <div css={sectionContentStyle}>{crossFiltersIndicators}</div>}
|
||||
{isOpen && <div css={dividerStyle} data-test="cross-filters-divider" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ export const crossFiltersSelector = (props: {
|
||||
}): CrossFilterIndicator[] => {
|
||||
const { dataMask, chartIds, chartLayoutItems, verboseMaps } = props;
|
||||
|
||||
if (!chartIds || !Array.isArray(chartIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chartIds
|
||||
.map(chartId => {
|
||||
const filterIndicator = getCrossFilterIndicator(
|
||||
|
||||
@@ -184,9 +184,9 @@ describe('FilterBar', () => {
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the "Filters" heading', () => {
|
||||
test('should render the "Actions" heading', () => {
|
||||
renderWrapper();
|
||||
expect(screen.getByText('Filters')).toBeInTheDocument();
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the "Clear all" option', () => {
|
||||
|
||||
@@ -108,7 +108,12 @@ test('Popover shows cross-filtering option on by default', async () => {
|
||||
|
||||
test('Can enable/disable cross-filtering', async () => {
|
||||
fetchMock.put('glob:*/api/v1/dashboard/1', {
|
||||
result: {},
|
||||
result: {
|
||||
json_metadata: JSON.stringify({
|
||||
...initialState.dashboardInfo.metadata,
|
||||
cross_filters_enabled: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
await setup();
|
||||
const settingsButton = screen.getByRole('button', {
|
||||
@@ -133,8 +138,10 @@ test('Popover opens with "Vertical" selected', async () => {
|
||||
userEvent.hover(screen.getByText('Orientation of filter bar'));
|
||||
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
|
||||
|
||||
const verticalItem = screen.getByText('Vertical (Left)');
|
||||
expect(
|
||||
within(screen.getAllByRole('menuitem')[4]).getByLabelText('check'),
|
||||
within(verticalItem.closest('li')!).getByLabelText('check'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -147,8 +154,10 @@ test('Popover opens with "Horizontal" selected', async () => {
|
||||
userEvent.hover(screen.getByText('Orientation of filter bar'));
|
||||
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
|
||||
|
||||
const horizontalItem = screen.getByText('Horizontal (Top)');
|
||||
expect(
|
||||
within(screen.getAllByRole('menuitem')[5]).getByLabelText('check'),
|
||||
within(horizontalItem.closest('li')!).getByLabelText('check'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -210,7 +219,10 @@ test('On selection change, send request and update checked value', async () => {
|
||||
});
|
||||
|
||||
test('On failed request, restore previous selection', async () => {
|
||||
fetchMock.put('glob:*/api/v1/dashboard/1', 400);
|
||||
fetchMock.put(
|
||||
'glob:*/api/v1/dashboard/1',
|
||||
() => new Response('', { status: 400, statusText: 'Bad Request' }),
|
||||
);
|
||||
|
||||
const dangerToastSpy = jest.spyOn(mockedMessageActions, 'addDangerToast');
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ import { Space } from '@superset-ui/core/components/Space';
|
||||
import { clearDataMaskState } from 'src/dataMask/actions';
|
||||
import { useFilters } from 'src/dashboard/components/nativeFilters/FilterBar/state';
|
||||
import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal';
|
||||
import { useChartCustomizationModal } from '../../ChartCustomization/useChartCustomizationModal';
|
||||
import ChartCustomizationModal from '../../ChartCustomization/ChartCustomizationModal';
|
||||
import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
|
||||
import FilterConfigurationLink from '../FilterConfigurationLink';
|
||||
|
||||
@@ -50,6 +52,7 @@ const StyledMenuLabel = styled.span`
|
||||
const CROSS_FILTERS_MENU_KEY = 'cross-filters-menu-key';
|
||||
const CROSS_FILTERS_SCOPING_MENU_KEY = 'cross-filters-scoping-menu-key';
|
||||
const ADD_EDIT_FILTERS_MENU_KEY = 'add-edit-filters-menu-key';
|
||||
const CHART_CUSTOMIZATION_MENU_KEY = 'chart-customization-menu-key';
|
||||
|
||||
const isOrientation = (o: SelectedKey): o is FilterBarOrientation =>
|
||||
o === FilterBarOrientation.Vertical || o === FilterBarOrientation.Horizontal;
|
||||
@@ -78,6 +81,15 @@ const FilterBarSettings = () => {
|
||||
({ dashboardInfo }) => dashboardInfo.id,
|
||||
);
|
||||
|
||||
const {
|
||||
isOpen: isChartCustomizationModalOpen,
|
||||
dashboardId: chartCustomizationDashboardId,
|
||||
chartId: chartCustomizationChartId,
|
||||
openChartCustomizationModal,
|
||||
closeChartCustomizationModal,
|
||||
handleSave: handleChartCustomizationSave,
|
||||
} = useChartCustomizationModal();
|
||||
|
||||
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal();
|
||||
|
||||
const { openFilterConfigModal, FilterConfigModalComponent } =
|
||||
@@ -134,6 +146,8 @@ const FilterBarSettings = () => {
|
||||
openScopingModal();
|
||||
} else if (selectedKey === ADD_EDIT_FILTERS_MENU_KEY) {
|
||||
openFilterConfigModal();
|
||||
} else if (selectedKey === CHART_CUSTOMIZATION_MENU_KEY) {
|
||||
openChartCustomizationModal();
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -141,6 +155,7 @@ const FilterBarSettings = () => {
|
||||
toggleCrossFiltering,
|
||||
toggleFilterBarOrientation,
|
||||
openFilterConfigModal,
|
||||
openChartCustomizationModal,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -186,6 +201,10 @@ const FilterBarSettings = () => {
|
||||
});
|
||||
items.push({ type: 'divider' });
|
||||
}
|
||||
items.push({
|
||||
key: CHART_CUSTOMIZATION_MENU_KEY,
|
||||
label: t('Chart customization'),
|
||||
});
|
||||
if (canEdit) {
|
||||
items.push({
|
||||
key: 'placement',
|
||||
@@ -202,6 +221,7 @@ const FilterBarSettings = () => {
|
||||
<Icons.CheckOutlined
|
||||
iconColor={theme.colorPrimary}
|
||||
iconSize="m"
|
||||
aria-label="check"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
@@ -219,6 +239,7 @@ const FilterBarSettings = () => {
|
||||
css={css`
|
||||
vertical-align: middle;
|
||||
`}
|
||||
aria-label="check"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
@@ -237,7 +258,7 @@ const FilterBarSettings = () => {
|
||||
filterValues,
|
||||
]);
|
||||
|
||||
if (!menuItems.length) {
|
||||
if (!menuItems.length || !canEdit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -278,6 +299,15 @@ const FilterBarSettings = () => {
|
||||
</Dropdown>
|
||||
{scopingModal}
|
||||
{FilterConfigModalComponent}
|
||||
{isChartCustomizationModalOpen && (
|
||||
<ChartCustomizationModal
|
||||
isOpen={isChartCustomizationModalOpen}
|
||||
dashboardId={chartCustomizationDashboardId}
|
||||
chartId={chartCustomizationChartId}
|
||||
onCancel={closeChartCustomizationModal}
|
||||
onSave={handleChartCustomizationSave}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
SupersetTheme,
|
||||
t,
|
||||
isNativeFilterWithDataMask,
|
||||
useTheme,
|
||||
styled,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
createHtmlPortalNode,
|
||||
@@ -50,10 +52,12 @@ import { FilterBarOrientation, RootState } from 'src/dashboard/types';
|
||||
import {
|
||||
DropdownContainer,
|
||||
type DropdownRef as DropdownContainerRef,
|
||||
Typography,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
|
||||
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
|
||||
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
|
||||
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
|
||||
import { useFilterControlFactory } from '../useFilterControlFactory';
|
||||
import { FiltersDropdownContent } from '../FiltersDropdownContent';
|
||||
@@ -61,20 +65,69 @@ import crossFiltersSelector from '../CrossFilters/selectors';
|
||||
import CrossFilter from '../CrossFilters/CrossFilter';
|
||||
import { useFilterOutlined } from '../useFilterOutlined';
|
||||
import { useChartsVerboseMaps } from '../utils';
|
||||
import GroupByFilterCard from '../../ChartCustomization/GroupByFilterCard';
|
||||
import { selectChartCustomizationItems } from '../../ChartCustomization/selectors';
|
||||
|
||||
type FilterControlsProps = {
|
||||
dataMaskSelected: DataMaskStateWithId;
|
||||
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void;
|
||||
clearAllTriggers?: Record<string, boolean>;
|
||||
onClearAllComplete?: (filterId: string) => void;
|
||||
hideHeader?: boolean;
|
||||
};
|
||||
|
||||
const SectionContainer = styled.div`
|
||||
margin-bottom: ${({ theme }) => theme.sizeUnit * 3}px;
|
||||
`;
|
||||
|
||||
const SectionHeader = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.colorBgTextHover};
|
||||
margin: 0 -${({ theme }) => theme.sizeUnit * 2}px;
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
}
|
||||
`;
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const SectionContent = styled.div`
|
||||
padding: ${({ theme }) => theme.sizeUnit * 2}px 0;
|
||||
`;
|
||||
|
||||
const StyledDivider = styled.div`
|
||||
height: 1px;
|
||||
background: ${({ theme }) => theme.colorSplit};
|
||||
margin: ${({ theme }) => theme.sizeUnit * 2}px 0;
|
||||
`;
|
||||
|
||||
const StyledIcon = styled(Icons.UpOutlined)<{ isOpen: boolean }>`
|
||||
transform: ${({ isOpen }) => (isOpen ? 'rotate(0deg)' : 'rotate(180deg)')};
|
||||
transition: transform 0.2s ease;
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
`;
|
||||
|
||||
const ChartCustomizationContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
`;
|
||||
|
||||
const FilterControls: FC<FilterControlsProps> = ({
|
||||
dataMaskSelected,
|
||||
onFilterSelectionChange,
|
||||
clearAllTriggers,
|
||||
onClearAllComplete,
|
||||
hideHeader = false,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
|
||||
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
|
||||
);
|
||||
@@ -91,6 +144,11 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
const chartLayoutItems = useChartLayoutItems();
|
||||
const verboseMaps = useChartsVerboseMaps();
|
||||
|
||||
const chartCustomizationItems = useSelector<
|
||||
RootState,
|
||||
ChartCustomizationItem[]
|
||||
>(state => selectChartCustomizationItems(state));
|
||||
|
||||
const selectedCrossFilters = useMemo(
|
||||
() =>
|
||||
crossFiltersSelector({
|
||||
@@ -128,6 +186,18 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
const dashboardHasTabs = useDashboardHasTabs();
|
||||
const showCollapsePanel = dashboardHasTabs && filtersWithValues.length > 0;
|
||||
|
||||
const [sectionsOpen, setSectionsOpen] = useState({
|
||||
filters: true,
|
||||
chartCustomization: true,
|
||||
});
|
||||
|
||||
const toggleSection = useCallback((section: keyof typeof sectionsOpen) => {
|
||||
setSectionsOpen(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const renderer = useCallback(
|
||||
({ id }: Filter | Divider, index: number | undefined) => {
|
||||
const filterIndex = filtersWithValues.findIndex(f => f.id === id);
|
||||
@@ -147,14 +217,101 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
const renderVerticalContent = useCallback(
|
||||
() => (
|
||||
<>
|
||||
{filtersInScope.map(renderer)}
|
||||
{showCollapsePanel && (
|
||||
{filtersInScope.length > 0 && (
|
||||
<SectionContainer>
|
||||
{!hideHeader && (
|
||||
<SectionHeader
|
||||
onClick={() => toggleSection('filters')}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleSection('filters');
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Title
|
||||
level={5}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: theme.fontSize,
|
||||
fontWeight: theme.fontWeightNormal,
|
||||
color: theme.colorText,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{t('Filters')}
|
||||
</Title>
|
||||
<StyledIcon iconSize="m" isOpen={sectionsOpen.filters} />
|
||||
</SectionHeader>
|
||||
)}
|
||||
{(hideHeader || sectionsOpen.filters) && (
|
||||
<SectionContent>{filtersInScope.map(renderer)}</SectionContent>
|
||||
)}
|
||||
{(hideHeader || sectionsOpen.filters) && <StyledDivider />}
|
||||
</SectionContainer>
|
||||
)}
|
||||
|
||||
{showCollapsePanel && (hideHeader || sectionsOpen.filters) && (
|
||||
<FiltersOutOfScopeCollapsible
|
||||
filtersOutOfScope={filtersOutOfScope}
|
||||
forceRender={hasRequiredFirst}
|
||||
renderer={renderer}
|
||||
forceRender={hasRequiredFirst}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartCustomizationItems.length > 0 && (
|
||||
<SectionContainer>
|
||||
{!hideHeader && (
|
||||
<SectionHeader
|
||||
onClick={() => toggleSection('chartCustomization')}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleSection('chartCustomization');
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Title
|
||||
level={5}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: theme.fontSize,
|
||||
fontWeight: theme.fontWeightNormal,
|
||||
color: theme.colorText,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{t('Chart Customization')}
|
||||
</Title>
|
||||
<StyledIcon
|
||||
iconSize="m"
|
||||
isOpen={sectionsOpen.chartCustomization}
|
||||
/>
|
||||
</SectionHeader>
|
||||
)}
|
||||
{(hideHeader || sectionsOpen.chartCustomization) && (
|
||||
<SectionContent>
|
||||
<ChartCustomizationContent>
|
||||
{chartCustomizationItems
|
||||
.filter(item => !item.removed)
|
||||
.map(item => (
|
||||
<GroupByFilterCard
|
||||
key={item.id}
|
||||
customizationItem={item}
|
||||
/>
|
||||
))}
|
||||
</ChartCustomizationContent>
|
||||
</SectionContent>
|
||||
)}
|
||||
{(hideHeader || sectionsOpen.chartCustomization) && (
|
||||
<StyledDivider />
|
||||
)}
|
||||
</SectionContainer>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[
|
||||
@@ -163,6 +320,16 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
showCollapsePanel,
|
||||
filtersOutOfScope,
|
||||
hasRequiredFirst,
|
||||
chartCustomizationItems,
|
||||
sectionsOpen,
|
||||
toggleSection,
|
||||
SectionContainer,
|
||||
SectionHeader,
|
||||
SectionContent,
|
||||
StyledDivider,
|
||||
StyledIcon,
|
||||
ChartCustomizationContent,
|
||||
hideHeader,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -225,8 +392,61 @@ const FilterControls: FC<FilterControlsProps> = ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
return [...crossFilters, ...nativeFiltersInScope];
|
||||
}, [filtersInScope, renderer, rendererCrossFilter, selectedCrossFilters]);
|
||||
const dividerItems = [];
|
||||
if (
|
||||
(crossFilters.length > 0 || nativeFiltersInScope.length > 0) &&
|
||||
chartCustomizationItems.length > 0
|
||||
) {
|
||||
dividerItems.push({
|
||||
id: 'chart-customization-divider',
|
||||
element: (
|
||||
<div
|
||||
css={css`
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: ${theme.colorBorder};
|
||||
margin-left: ${theme.sizeUnit * 4}px;
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const chartCustomizations = chartCustomizationItems
|
||||
.filter(item => !item.removed)
|
||||
.map(item => ({
|
||||
id: `chart-customization-${item.id}`,
|
||||
element: (
|
||||
<div
|
||||
className="chart-customization-item-wrapper"
|
||||
css={css`
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
<GroupByFilterCard
|
||||
customizationItem={item}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
return [
|
||||
...chartCustomizations,
|
||||
...dividerItems,
|
||||
...crossFilters,
|
||||
...nativeFiltersInScope,
|
||||
];
|
||||
}, [
|
||||
filtersInScope,
|
||||
renderer,
|
||||
rendererCrossFilter,
|
||||
selectedCrossFilters,
|
||||
chartCustomizationItems,
|
||||
theme,
|
||||
]);
|
||||
|
||||
const renderHorizontalContent = useCallback(
|
||||
() => (
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
unsetFocusedNativeFilter,
|
||||
setHoveredNativeFilter,
|
||||
unsetHoveredNativeFilter,
|
||||
setHoveredChartCustomization,
|
||||
unsetHoveredChartCustomization,
|
||||
} from 'src/dashboard/actions/nativeFilters';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
|
||||
@@ -47,3 +49,14 @@ export const dispatchFocusAction = debounce(
|
||||
},
|
||||
Constants.FAST_DEBOUNCE,
|
||||
);
|
||||
|
||||
export const dispatchChartCustomizationHoverAction = debounce(
|
||||
(dispatch: Dispatch<any>, id?: string) => {
|
||||
if (id) {
|
||||
dispatch(setHoveredChartCustomization(id));
|
||||
} else {
|
||||
dispatch(unsetHoveredChartCustomization());
|
||||
}
|
||||
},
|
||||
Constants.FAST_DEBOUNCE,
|
||||
);
|
||||
|
||||
@@ -29,10 +29,10 @@ test('should render', () => {
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the "Filters" heading', () => {
|
||||
test('should render the "Actions" heading', () => {
|
||||
const mockedProps = createProps();
|
||||
render(<Header {...mockedProps} />, { useRedux: true });
|
||||
expect(screen.getByText('Filters')).toBeInTheDocument();
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the expand button', () => {
|
||||
|
||||
@@ -68,7 +68,7 @@ type HeaderProps = {
|
||||
const Header: FC<HeaderProps> = ({ toggleFiltersBar }) => (
|
||||
<Wrapper>
|
||||
<TitleArea>
|
||||
<span>{t('Filters')}</span>
|
||||
<span>{t('Actions')}</span>
|
||||
<FilterBarSettings />
|
||||
<HeaderButton
|
||||
{...getFilterBarTestId('collapse-button')}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
|
||||
import { HorizontalBarProps } from './types';
|
||||
import FilterBarSettings from './FilterBarSettings';
|
||||
import crossFiltersSelector from './CrossFilters/selectors';
|
||||
import { selectChartCustomizationItems } from '../ChartCustomization/selectors';
|
||||
import { ChartCustomizationItem } from '../ChartCustomization/types';
|
||||
|
||||
const HorizontalBar = styled.div`
|
||||
${({ theme }) => `
|
||||
@@ -90,7 +92,15 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
[chartIds, chartLayoutItems, dataMask, verboseMaps],
|
||||
);
|
||||
|
||||
const hasFilters = filterValues.length > 0 || selectedCrossFilters.length > 0;
|
||||
const chartCustomizationItems = useSelector<
|
||||
RootState,
|
||||
ChartCustomizationItem[]
|
||||
>(state => selectChartCustomizationItems(state));
|
||||
|
||||
const hasFilters =
|
||||
filterValues.length > 0 ||
|
||||
selectedCrossFilters.length > 0 ||
|
||||
chartCustomizationItems.length > 0;
|
||||
|
||||
return (
|
||||
<HorizontalBar {...getFilterBarTestId()}>
|
||||
|
||||
@@ -29,15 +29,28 @@ import {
|
||||
createContext,
|
||||
FC,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import cx from 'classnames';
|
||||
import { styled, t, useTheme } from '@superset-ui/core';
|
||||
import { styled, t, useTheme, DataMaskStateWithId } from '@superset-ui/core';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { getFilterBarTestId } from './utils';
|
||||
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
|
||||
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
|
||||
import { selectChartCustomizationItems } from '../ChartCustomization/selectors';
|
||||
import { getFilterBarTestId, useChartsVerboseMaps } from './utils';
|
||||
import { VerticalBarProps } from './types';
|
||||
import Header from './Header';
|
||||
import FilterControls from './FilterControls/FilterControls';
|
||||
import { ChartCustomizationItem } from '../ChartCustomization/types';
|
||||
import CrossFiltersVertical from './CrossFilters/Vertical';
|
||||
import crossFiltersSelector from './CrossFilters/selectors';
|
||||
|
||||
enum SectionType {
|
||||
Filters = 'filters',
|
||||
ChartCustomization = 'chartCustomization',
|
||||
CrossFilters = 'crossFilters',
|
||||
}
|
||||
|
||||
const BarWrapper = styled.div<{ width: number }>`
|
||||
width: ${({ theme }) => theme.sizeUnit * 8}px;
|
||||
@@ -159,35 +172,84 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
() => ({ overflow: 'auto', height, overscrollBehavior: 'contain' }),
|
||||
[height],
|
||||
);
|
||||
const chartCustomizationItems = useSelector<
|
||||
RootState,
|
||||
ChartCustomizationItem[]
|
||||
>(state => selectChartCustomizationItems(state));
|
||||
|
||||
const filterControls = useMemo(
|
||||
() =>
|
||||
filterValues.length === 0 ? (
|
||||
<FilterBarEmptyStateContainer>
|
||||
<EmptyState
|
||||
size="small"
|
||||
title={t('No global filters are currently added')}
|
||||
image="filter.svg"
|
||||
description={
|
||||
canEdit &&
|
||||
t(
|
||||
'Click on "Add or Edit Filters" option in Settings to create new dashboard filters',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FilterBarEmptyStateContainer>
|
||||
) : (
|
||||
<FilterControlsWrapper>
|
||||
<FilterControls
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
onFilterSelectionChange={onSelectionChange}
|
||||
clearAllTriggers={clearAllTriggers}
|
||||
onClearAllComplete={onClearAllComplete}
|
||||
/>
|
||||
</FilterControlsWrapper>
|
||||
),
|
||||
[canEdit, dataMaskSelected, filterValues.length, onSelectionChange],
|
||||
const dataMask = useSelector<RootState, DataMaskStateWithId>(
|
||||
state => state.dataMask,
|
||||
);
|
||||
const chartIds = useChartIds();
|
||||
const chartLayoutItems = useChartLayoutItems();
|
||||
const verboseMaps = useChartsVerboseMaps();
|
||||
const selectedCrossFilters = crossFiltersSelector({
|
||||
dataMask,
|
||||
chartIds,
|
||||
chartLayoutItems,
|
||||
verboseMaps,
|
||||
});
|
||||
|
||||
// Determine available section types
|
||||
const availableSectionTypes = useMemo(() => {
|
||||
const types: SectionType[] = [];
|
||||
|
||||
if (filterValues.length > 0) {
|
||||
types.push(SectionType.Filters);
|
||||
}
|
||||
|
||||
if (chartCustomizationItems.length > 0) {
|
||||
types.push(SectionType.ChartCustomization);
|
||||
}
|
||||
|
||||
if (selectedCrossFilters.length > 0) {
|
||||
types.push(SectionType.CrossFilters);
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [
|
||||
filterValues.length,
|
||||
chartCustomizationItems.length,
|
||||
selectedCrossFilters.length,
|
||||
]);
|
||||
|
||||
const hasOnlyOneSectionType = availableSectionTypes.length === 1;
|
||||
|
||||
const filterControls = useMemo(() => {
|
||||
const hasFiltersOrCustomizations =
|
||||
filterValues.length > 0 || chartCustomizationItems.length > 0;
|
||||
|
||||
return hasFiltersOrCustomizations ? (
|
||||
<FilterControlsWrapper>
|
||||
<FilterControls
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
onFilterSelectionChange={onSelectionChange}
|
||||
hideHeader={hasOnlyOneSectionType}
|
||||
/>
|
||||
</FilterControlsWrapper>
|
||||
) : (
|
||||
<FilterBarEmptyStateContainer>
|
||||
<EmptyState
|
||||
size="small"
|
||||
title={t('No global filters are currently added')}
|
||||
image="filter.svg"
|
||||
description={
|
||||
canEdit &&
|
||||
t(
|
||||
'Click on "Add or Edit Filters" option in Settings to create new dashboard filters',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FilterBarEmptyStateContainer>
|
||||
);
|
||||
}, [
|
||||
canEdit,
|
||||
dataMaskSelected,
|
||||
filterValues.length,
|
||||
onSelectionChange,
|
||||
chartCustomizationItems.length,
|
||||
hasOnlyOneSectionType,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FilterBarScrollContext.Provider value={isScrolling}>
|
||||
@@ -228,7 +290,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
) : (
|
||||
<div css={tabPaneStyle} onScroll={onScroll}>
|
||||
<>
|
||||
<CrossFiltersVertical />
|
||||
<CrossFiltersVertical hideHeader={hasOnlyOneSectionType} />
|
||||
{filterControls}
|
||||
</>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,16 @@ import {
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { updateDataMask } from 'src/dataMask/actions';
|
||||
import { triggerQuery } from 'src/components/Chart/chartAction';
|
||||
import {
|
||||
saveChartCustomization,
|
||||
setChartCustomization,
|
||||
clearAllPendingChartCustomizations,
|
||||
ChartCustomizationSavePayload,
|
||||
clearAllChartCustomizationsFromMetadata,
|
||||
} from 'src/dashboard/actions/chartCustomizationActions';
|
||||
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
|
||||
|
||||
import { useImmer } from 'use-immer';
|
||||
import { isEmpty, isEqual, debounce } from 'lodash';
|
||||
import { getInitialDataMask } from 'src/dataMask/reducer';
|
||||
@@ -148,6 +158,9 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
const dataMaskApplied: DataMaskStateWithId = useNativeFiltersDataMask();
|
||||
const [dataMaskSelected, setDataMaskSelected] =
|
||||
useImmer<DataMaskStateWithId>(dataMaskApplied);
|
||||
const chartCustomizationItems = useSelector<RootState, any[]>(
|
||||
state => state.dashboardInfo.metadata?.chart_customization_config || [],
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const [updateKey, setUpdateKey] = useState(0);
|
||||
const tabId = useTabId();
|
||||
@@ -162,6 +175,9 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
({ dashboardInfo }) => dashboardInfo?.id,
|
||||
);
|
||||
const previousDashboardId = usePrevious(dashboardId);
|
||||
const chartIds = useSelector<RootState, number[]>(
|
||||
state => state.dashboardState.sliceIds || [],
|
||||
);
|
||||
const canEdit = useSelector<RootState, boolean>(
|
||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||
);
|
||||
@@ -177,6 +193,8 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
const [initializedFilters, setInitializedFilters] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [hasClearedChartCustomizations, setHasClearedChartCustomizations] =
|
||||
useState(false);
|
||||
|
||||
const dataMaskSelectedRef = useRef(dataMaskSelected);
|
||||
dataMaskSelectedRef.current = dataMaskSelected;
|
||||
@@ -215,9 +233,15 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
};
|
||||
|
||||
// Recalculate validation status
|
||||
const hasRequiredValue =
|
||||
filter.controlValues?.enableEmptyFilter &&
|
||||
baseDataMask.filterState?.value == null;
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
const value = baseDataMask.filterState?.value;
|
||||
|
||||
const isEmptyValue =
|
||||
value == null ||
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(typeof value === 'string' && value.trim() === '');
|
||||
|
||||
const hasRequiredValue = isRequired && isEmptyValue;
|
||||
|
||||
draft[filter.id] = {
|
||||
...baseDataMask,
|
||||
@@ -266,7 +290,7 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
setDataMaskSelected(() => dataMaskApplied);
|
||||
}, [dataMaskAppliedText, setDataMaskSelected]);
|
||||
}, [dataMaskAppliedText, setDataMaskSelected, dashboardId]);
|
||||
|
||||
useEffect(() => {
|
||||
// embedded users can't persist filter combinations
|
||||
@@ -276,16 +300,99 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboardId, dataMaskAppliedText, history, updateKey, tabId]);
|
||||
|
||||
const pendingChartCustomizations = useSelector<
|
||||
RootState,
|
||||
Record<string, ChartCustomizationItem> | undefined
|
||||
>(state => state.dashboardInfo.pendingChartCustomizations);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
dispatch(logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {}));
|
||||
setUpdateKey(1);
|
||||
|
||||
// Apply filter changes
|
||||
Object.entries(dataMaskSelected).forEach(([filterId, dataMask]) => {
|
||||
if (dataMask) {
|
||||
dispatch(updateDataMask(filterId, dataMask));
|
||||
}
|
||||
});
|
||||
}, [dataMaskSelected, dispatch]);
|
||||
|
||||
if (
|
||||
pendingChartCustomizations &&
|
||||
Object.keys(pendingChartCustomizations).length > 0
|
||||
) {
|
||||
Object.values(pendingChartCustomizations).forEach(
|
||||
(customization: any) => {
|
||||
if (customization) {
|
||||
const customizationFilterId = `chart_customization_${customization.id}`;
|
||||
const dataMask = {
|
||||
extraFormData: {},
|
||||
filterState: {},
|
||||
ownState: {
|
||||
column: customization.customization?.column || null,
|
||||
},
|
||||
};
|
||||
dispatch(updateDataMask(customizationFilterId, dataMask));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const pendingItems = Object.values(pendingChartCustomizations).filter(
|
||||
Boolean,
|
||||
) as ChartCustomizationSavePayload[];
|
||||
|
||||
if (pendingItems.length > 0) {
|
||||
const newCustomizations: ChartCustomizationItem[] = pendingItems.map(
|
||||
item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
removed: item.removed,
|
||||
chartId: item.chartId,
|
||||
customization: item.customization,
|
||||
}),
|
||||
);
|
||||
|
||||
const existingCustomizations = chartCustomizationItems || [];
|
||||
const existingMap = new Map(
|
||||
existingCustomizations.map(item => [item.id, item]),
|
||||
);
|
||||
|
||||
newCustomizations.forEach(newItem => {
|
||||
existingMap.set(newItem.id, newItem);
|
||||
});
|
||||
|
||||
const mergedCustomizations = Array.from(existingMap.values());
|
||||
|
||||
dispatch(setChartCustomization(mergedCustomizations));
|
||||
|
||||
if (chartIds.length > 0) {
|
||||
chartIds.forEach(chartId => {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
});
|
||||
}
|
||||
}
|
||||
dispatch(clearAllPendingChartCustomizations());
|
||||
} else if (hasClearedChartCustomizations) {
|
||||
const clearedChartCustomizations = chartCustomizationItems.map(item => ({
|
||||
...item,
|
||||
customization: {
|
||||
...item.customization,
|
||||
column: null,
|
||||
},
|
||||
}));
|
||||
|
||||
dispatch(saveChartCustomization(clearedChartCustomizations));
|
||||
}
|
||||
|
||||
setHasClearedChartCustomizations(false);
|
||||
}, [
|
||||
dataMaskSelected,
|
||||
dispatch,
|
||||
pendingChartCustomizations,
|
||||
hasClearedChartCustomizations,
|
||||
chartCustomizationItems,
|
||||
dashboardId,
|
||||
chartIds,
|
||||
]);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
const newClearAllTriggers = { ...clearAllTriggers };
|
||||
@@ -301,8 +408,52 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
newClearAllTriggers[id] = true;
|
||||
}
|
||||
});
|
||||
|
||||
let hasChartCustomizationsToClear = false;
|
||||
|
||||
const allDataMasks = { ...dataMaskSelected, ...dataMaskApplied };
|
||||
|
||||
Object.keys(allDataMasks).forEach(key => {
|
||||
if (key.startsWith('chart_customization_')) {
|
||||
hasChartCustomizationsToClear = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasChartCustomizationsToClear && chartCustomizationItems.length > 0) {
|
||||
chartCustomizationItems.forEach(item => {
|
||||
if (item.customization?.column) {
|
||||
const customizationFilterId = `chart_customization_${item.id}`;
|
||||
const dataMask = {
|
||||
filterState: {
|
||||
value: item.customization.column,
|
||||
},
|
||||
ownState: {
|
||||
column: item.customization.column,
|
||||
},
|
||||
extraFormData: {},
|
||||
};
|
||||
|
||||
dispatch(updateDataMask(customizationFilterId, dataMask));
|
||||
hasChartCustomizationsToClear = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (hasChartCustomizationsToClear) {
|
||||
dispatch(clearAllPendingChartCustomizations());
|
||||
dispatch(clearAllChartCustomizationsFromMetadata());
|
||||
setHasClearedChartCustomizations(true);
|
||||
}
|
||||
|
||||
setClearAllTriggers(newClearAllTriggers);
|
||||
}, [dataMaskSelected, filtersInScope, setDataMaskSelected, clearAllTriggers]);
|
||||
}, [
|
||||
dataMaskSelected,
|
||||
dataMaskApplied,
|
||||
filtersInScope,
|
||||
chartCustomizationItems,
|
||||
clearAllTriggers,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
const handleClearAllComplete = useCallback((filterId: string) => {
|
||||
setClearAllTriggers(prev => {
|
||||
@@ -313,11 +464,46 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
}, []);
|
||||
|
||||
useFilterUpdates(dataMaskSelected, setDataMaskSelected);
|
||||
const isApplyDisabled = checkIsApplyDisabled(
|
||||
dataMaskSelected,
|
||||
dataMaskApplied,
|
||||
filtersInScope.filter(isNativeFilter),
|
||||
);
|
||||
|
||||
const hasPendingChartCustomizations =
|
||||
pendingChartCustomizations &&
|
||||
Object.keys(pendingChartCustomizations).length > 0;
|
||||
|
||||
const hasMissingRequiredChartCustomization =
|
||||
chartCustomizationItems?.some(item => {
|
||||
if (item.removed) return false;
|
||||
|
||||
const required = !!item.customization?.controlValues?.enableEmptyFilter;
|
||||
if (!required) return false;
|
||||
|
||||
const pendingItem = pendingChartCustomizations?.[item.id];
|
||||
const currentCustomization =
|
||||
pendingItem?.customization || item.customization;
|
||||
const columnValue = currentCustomization?.column;
|
||||
|
||||
if (!columnValue) return true;
|
||||
|
||||
if (Array.isArray(columnValue)) {
|
||||
return columnValue.length === 0;
|
||||
}
|
||||
|
||||
if (typeof columnValue === 'string') {
|
||||
return columnValue.trim() === '';
|
||||
}
|
||||
|
||||
return false;
|
||||
}) || false;
|
||||
|
||||
const isApplyDisabled =
|
||||
(checkIsApplyDisabled(
|
||||
dataMaskSelected,
|
||||
dataMaskApplied,
|
||||
filtersInScope.filter(isNativeFilter),
|
||||
) &&
|
||||
!hasPendingChartCustomizations &&
|
||||
!hasClearedChartCustomizations) ||
|
||||
hasMissingRequiredChartCustomization;
|
||||
|
||||
const isInitialized = useInitialization();
|
||||
|
||||
const actions = useMemo(
|
||||
@@ -330,6 +516,7 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
dataMaskApplied={dataMaskApplied}
|
||||
isApplyDisabled={isApplyDisabled}
|
||||
chartCustomizationItems={chartCustomizationItems}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -338,8 +525,9 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
handleApply,
|
||||
handleClearAll,
|
||||
dataMaskSelected,
|
||||
dataMaskAppliedText,
|
||||
dataMaskApplied,
|
||||
isApplyDisabled,
|
||||
chartCustomizationItems,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { areObjectsEqual } from 'src/reduxUtils';
|
||||
import { DataMaskStateWithId, Filter, FilterState } from '@superset-ui/core';
|
||||
import { testWithId } from 'src/utils/testUtils';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { areObjectsEqual } from 'src/reduxUtils';
|
||||
import { testWithId } from 'src/utils/testUtils';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
|
||||
export const getOnlyExtraFormData = (data: DataMaskStateWithId) =>
|
||||
Object.values(data).reduce(
|
||||
@@ -34,6 +34,10 @@ export const checkIsMissingRequiredValue = (
|
||||
filter: Filter,
|
||||
filterState?: FilterState,
|
||||
) => {
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
|
||||
if (!isRequired) return false;
|
||||
|
||||
const value = filterState?.value;
|
||||
// TODO: this property should be unhardcoded
|
||||
return (
|
||||
@@ -55,22 +59,29 @@ export const checkIsApplyDisabled = (
|
||||
if (!checkIsValidateError(dataMaskSelected)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const dataSelectedValues = Object.values(dataMaskSelected);
|
||||
const dataAppliedValues = Object.values(dataMaskApplied);
|
||||
return (
|
||||
areObjectsEqual(
|
||||
getOnlyExtraFormData(dataMaskSelected),
|
||||
getOnlyExtraFormData(dataMaskApplied),
|
||||
{ ignoreUndefined: true },
|
||||
) ||
|
||||
dataSelectedValues.length !== dataAppliedValues.length ||
|
||||
filters.some(filter =>
|
||||
checkIsMissingRequiredValue(
|
||||
filter,
|
||||
dataMaskSelected?.[filter?.id]?.filterState,
|
||||
),
|
||||
)
|
||||
|
||||
const hasMissingRequiredFilter = filters.some(filter =>
|
||||
checkIsMissingRequiredValue(
|
||||
filter,
|
||||
dataMaskSelected?.[filter?.id]?.filterState,
|
||||
),
|
||||
);
|
||||
|
||||
const areEqual = areObjectsEqual(
|
||||
getOnlyExtraFormData(dataMaskSelected),
|
||||
getOnlyExtraFormData(dataMaskApplied),
|
||||
{ ignoreUndefined: true },
|
||||
);
|
||||
|
||||
const result =
|
||||
areEqual ||
|
||||
dataSelectedValues.length !== dataAppliedValues.length ||
|
||||
hasMissingRequiredFilter;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const chartsVerboseMapSelector = createSelector(
|
||||
|
||||
@@ -24,7 +24,7 @@ interface CollapsibleControlProps {
|
||||
initialValue?: boolean;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
title: string;
|
||||
title: ReactNode;
|
||||
tooltip?: string;
|
||||
children: ReactNode;
|
||||
onChange?: (checked: boolean) => void;
|
||||
|
||||
@@ -34,9 +34,14 @@ import {
|
||||
interface DatasetSelectProps {
|
||||
onChange: (value: { label: string; value: number }) => void;
|
||||
value?: { label: string; value: number };
|
||||
excludeDatasetIds?: number[];
|
||||
}
|
||||
|
||||
const DatasetSelect = ({ onChange, value }: DatasetSelectProps) => {
|
||||
const DatasetSelect = ({
|
||||
onChange,
|
||||
value,
|
||||
excludeDatasetIds = [],
|
||||
}: DatasetSelectProps) => {
|
||||
const getErrorMessage = useCallback(
|
||||
({ error, message }: ClientErrorObject) => {
|
||||
let errorText = message || error || t('An error has occurred');
|
||||
@@ -48,40 +53,45 @@ const DatasetSelect = ({ onChange, value }: DatasetSelectProps) => {
|
||||
[],
|
||||
);
|
||||
|
||||
const loadDatasetOptions = async (
|
||||
search: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
) => {
|
||||
const query = rison.encode({
|
||||
columns: ['id', 'table_name', 'database.database_name', 'schema'],
|
||||
filters: [{ col: 'table_name', opr: 'ct', value: search }],
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_column: 'table_name',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
return cachedSupersetGet({
|
||||
endpoint: `/api/v1/dataset/?q=${query}`,
|
||||
})
|
||||
.then((response: JsonResponse) => {
|
||||
const list: {
|
||||
label: string | ReactNode;
|
||||
value: string | number;
|
||||
}[] = response.json.result.map((item: Dataset) => ({
|
||||
label: DatasetSelectLabel(item),
|
||||
value: item.id,
|
||||
}));
|
||||
return {
|
||||
data: list,
|
||||
totalCount: response.json.count,
|
||||
};
|
||||
})
|
||||
.catch(async error => {
|
||||
const errorMessage = getErrorMessage(await getClientErrorObject(error));
|
||||
throw new Error(errorMessage);
|
||||
const loadDatasetOptions = useCallback(
|
||||
async (search: string, page: number, pageSize: number) => {
|
||||
const query = rison.encode({
|
||||
columns: ['id', 'table_name', 'database.database_name', 'schema'],
|
||||
filters: [{ col: 'table_name', opr: 'ct', value: search }],
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_column: 'table_name',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
};
|
||||
return cachedSupersetGet({
|
||||
endpoint: `/api/v1/dataset/?q=${query}`,
|
||||
})
|
||||
.then((response: JsonResponse) => {
|
||||
const filteredResult = response.json.result.filter(
|
||||
(item: Dataset) => !excludeDatasetIds.includes(item.id),
|
||||
);
|
||||
|
||||
const list: {
|
||||
label: string | ReactNode;
|
||||
value: string | number;
|
||||
}[] = filteredResult.map((item: Dataset) => ({
|
||||
label: DatasetSelectLabel(item),
|
||||
value: item.id,
|
||||
}));
|
||||
return {
|
||||
data: list,
|
||||
totalCount: filteredResult.length,
|
||||
};
|
||||
})
|
||||
.catch(async error => {
|
||||
const errorMessage = getErrorMessage(
|
||||
await getClientErrorObject(error),
|
||||
);
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
},
|
||||
[excludeDatasetIds, getErrorMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
|
||||
@@ -28,16 +28,17 @@ import {
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
Constants,
|
||||
Form,
|
||||
Icons,
|
||||
StyledModal,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Constants, Form, Icons } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
import { testWithId } from 'src/utils/testUtils';
|
||||
import { updateCascadeParentIds } from 'src/dashboard/actions/nativeFilters';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import {
|
||||
BaseModalWrapper,
|
||||
BaseModalBody,
|
||||
BaseForm,
|
||||
BaseExpandButtonWrapper,
|
||||
} from 'src/dashboard/components/nativeFilters/ConfigModal/SharedStyles';
|
||||
import { useFilterConfigMap, useFilterConfiguration } from '../state';
|
||||
import FilterConfigurePane from './FilterConfigurePane';
|
||||
import FiltersConfigForm, {
|
||||
@@ -62,56 +63,13 @@ import {
|
||||
} from './utils';
|
||||
import DividerConfigForm from './DividerConfigForm';
|
||||
|
||||
const MODAL_MARGIN = 16;
|
||||
const MIN_WIDTH = 880;
|
||||
|
||||
const StyledModalWrapper = styled(StyledModal)<{ expanded: boolean }>`
|
||||
min-width: ${MIN_WIDTH}px;
|
||||
width: ${({ expanded }) => (expanded ? '100%' : MIN_WIDTH)} !important;
|
||||
|
||||
@media (max-width: ${MIN_WIDTH + MODAL_MARGIN * 2}px) {
|
||||
width: 100% !important;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
${({ expanded }) =>
|
||||
expanded &&
|
||||
css`
|
||||
height: 100%;
|
||||
|
||||
.ant-modal-body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.ant-modal-content {
|
||||
height: 100%;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const StyledModalBody = styled.div<{ expanded: boolean }>`
|
||||
display: flex;
|
||||
height: ${({ expanded }) => (expanded ? '100%' : '700px')};
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
const StyledModalBody = styled(BaseModalBody)`
|
||||
.filters-list {
|
||||
width: ${({ theme }) => theme.sizeUnit * 50}px;
|
||||
overflow: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledForm = styled(Form)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const StyledExpandButtonWrapper = styled.div`
|
||||
margin-left: ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
`;
|
||||
|
||||
export const FILTERS_CONFIG_MODAL_TEST_ID = 'filters-config-modal';
|
||||
export const getFiltersConfigModalTestId = testWithId(
|
||||
FILTERS_CONFIG_MODAL_TEST_ID,
|
||||
@@ -711,7 +669,7 @@ function FiltersConfigModal({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledModalWrapper
|
||||
<BaseModalWrapper
|
||||
open={isOpen}
|
||||
maskClosable={false}
|
||||
title={t('Add and edit filters')}
|
||||
@@ -737,19 +695,19 @@ function FiltersConfigModal({
|
||||
saveAlertVisible={saveAlertVisible}
|
||||
onConfirmCancel={handleConfirmCancel}
|
||||
/>
|
||||
<StyledExpandButtonWrapper>
|
||||
<BaseExpandButtonWrapper>
|
||||
<ToggleIcon
|
||||
iconSize="l"
|
||||
iconColor={theme.colorIcon}
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
</StyledExpandButtonWrapper>
|
||||
</BaseExpandButtonWrapper>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<StyledModalBody expanded={expanded}>
|
||||
<StyledForm
|
||||
<BaseForm
|
||||
form={form}
|
||||
onValuesChange={handleValuesChange}
|
||||
layout="vertical"
|
||||
@@ -768,10 +726,10 @@ function FiltersConfigModal({
|
||||
>
|
||||
{formList}
|
||||
</FilterConfigurePane>
|
||||
</StyledForm>
|
||||
</BaseForm>
|
||||
</StyledModalBody>
|
||||
</ErrorBoundary>
|
||||
</StyledModalWrapper>
|
||||
</BaseModalWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,15 @@ import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
|
||||
const defaultFilterConfiguration: Filter[] = [];
|
||||
|
||||
export function useFilterConfiguration() {
|
||||
return useSelector<any, FilterConfiguration>(
|
||||
state =>
|
||||
return useSelector<any, FilterConfiguration>(state => {
|
||||
const nativeFilterConfig =
|
||||
state.dashboardInfo?.metadata?.native_filter_configuration ||
|
||||
defaultFilterConfiguration,
|
||||
);
|
||||
defaultFilterConfiguration;
|
||||
|
||||
return nativeFilterConfig.filter(
|
||||
(filter: any) => filter.type !== 'CHART_CUSTOMIZATION',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user