mirror of
https://github.com/apache/superset.git
synced 2026-06-23 08:29:18 +00:00
Compare commits
64 Commits
upgrade-sq
...
feat/bette
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe438b375 | ||
|
|
9ab099a807 | ||
|
|
6b80135aa2 | ||
|
|
de079a7b19 | ||
|
|
f54bbdc06b | ||
|
|
33441ccf3d | ||
|
|
9ec56f5f02 | ||
|
|
11a36ff488 | ||
|
|
af3e088233 | ||
|
|
29f499528f | ||
|
|
21481eef4f | ||
|
|
0d2c8fd373 | ||
|
|
7b56fc1714 | ||
|
|
9131739f98 | ||
|
|
a30492f55e | ||
|
|
090eab099c | ||
|
|
cd4cd53726 | ||
|
|
65c460c9d2 | ||
|
|
868e719c60 | ||
|
|
5efc7ea5a5 | ||
|
|
b0f9a73f63 | ||
|
|
746e266e90 | ||
|
|
5a777c0f45 | ||
|
|
aec1f6edce | ||
|
|
f7218e7a19 | ||
|
|
5cd829f13c | ||
|
|
9566e8a9c6 | ||
|
|
604d49f557 | ||
|
|
84f1ee4409 | ||
|
|
3e3c9686de | ||
|
|
7b21979fa3 | ||
|
|
8853ff19d4 | ||
|
|
22ac5e02b6 | ||
|
|
2c9f0c1c2a | ||
|
|
d47a7105df | ||
|
|
c873225308 | ||
|
|
982e2c1ef7 | ||
|
|
eee3af5775 | ||
|
|
232b34d944 | ||
|
|
d748ed19ce | ||
|
|
5300f65a74 | ||
|
|
440602ef34 | ||
|
|
cbf153845e | ||
|
|
097f474f24 | ||
|
|
73adff55ee | ||
|
|
a65f73a532 | ||
|
|
475615e118 | ||
|
|
79f51e2ae7 | ||
|
|
75d6a95ac3 | ||
|
|
ffd7f10320 | ||
|
|
e3e2bece6b | ||
|
|
0c0d915391 | ||
|
|
080f629ea2 | ||
|
|
142b2cc425 | ||
|
|
6328e51620 | ||
|
|
0d5ddb3674 | ||
|
|
58d245c6b0 | ||
|
|
dbf5e1f131 | ||
|
|
88ce1425e2 | ||
|
|
4dfece9ee5 | ||
|
|
3f64c25712 | ||
|
|
afacca350f | ||
|
|
30ccbb2e05 | ||
|
|
19ec7b48a0 |
16
.github/dependabot.yml
vendored
16
.github/dependabot.yml
vendored
@@ -65,6 +65,22 @@ updates:
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
ignore:
|
||||
# TODO: remove below entries until React >= 18.0.0 in superset-frontend
|
||||
- dependency-name: "storybook"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "@storybook*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor"]
|
||||
- dependency-name: "eslint-plugin-storybook"
|
||||
- dependency-name: "react-error-boundary"
|
||||
groups:
|
||||
storybook:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "@storybook*"
|
||||
- "storybook"
|
||||
update-types:
|
||||
- "patch"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 10
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
# Scan for vulnerabilities in built container image after pushes to mainline branch.
|
||||
- name: Run Trivy container image vulnerabity scan
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker) && matrix.build_preset == 'lean'
|
||||
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
|
||||
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # v0.34.0
|
||||
with:
|
||||
image-ref: ${{ env.IMAGE_TAG }}
|
||||
format: 'sarif'
|
||||
|
||||
2
.github/workflows/superset-app-cli.yml
vendored
2
.github/workflows/superset-app-cli.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
4
.github/workflows/superset-docs-deploy.yml
vendored
4
.github/workflows/superset-docs-deploy.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics (if triggered by integration tests)
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
path: docs/src/data/
|
||||
- name: Try to download latest diagnostics (for push/dispatch triggers)
|
||||
if: github.event_name != 'workflow_run'
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
continue-on-error: true
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
|
||||
2
.github/workflows/superset-docs-verify.yml
vendored
2
.github/workflows/superset-docs-verify.yml
vendored
@@ -111,7 +111,7 @@ jobs:
|
||||
run: |
|
||||
yarn install --check-cache
|
||||
- name: Download database diagnostics from integration tests
|
||||
uses: dawidd6/action-download-artifact@v14
|
||||
uses: dawidd6/action-download-artifact@v15
|
||||
with:
|
||||
workflow: superset-python-integrationtest.yml
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
4
.github/workflows/superset-e2e.yml
vendored
4
.github/workflows/superset-e2e.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
2
.github/workflows/superset-playwright.yml
vendored
2
.github/workflows/superset-playwright.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
UPLOAD_FOLDER: /tmp/.superset/uploads/
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
|
||||
36
UPDATING.md
36
UPDATING.md
@@ -24,6 +24,42 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### MCP Tool Observability
|
||||
|
||||
MCP (Model Context Protocol) tools now include enhanced observability instrumentation for monitoring and debugging:
|
||||
|
||||
**Two-layer instrumentation:**
|
||||
1. **Middleware layer** (`LoggingMiddleware`): Automatically logs all MCP tool calls with `duration_ms` and `success` status in the audit log (Action Log UI, logs table)
|
||||
2. **Sub-operation tracking**: All 19 MCP tools include granular `event_logger.log_context()` blocks for tracking individual operations like validation, database writes, and query execution
|
||||
|
||||
**Action naming convention:**
|
||||
- Tool-level logs: `mcp_tool_call` (via middleware)
|
||||
- Sub-operation logs: `mcp.{tool_name}.{operation}` (e.g., `mcp.generate_chart.validation`, `mcp.execute_sql.query_execution`)
|
||||
|
||||
**Querying MCP logs:**
|
||||
```sql
|
||||
-- Top slowest MCP operations
|
||||
SELECT action, COUNT(*) as calls, AVG(duration_ms) as avg_ms
|
||||
FROM logs
|
||||
WHERE action LIKE 'mcp.%'
|
||||
GROUP BY action
|
||||
ORDER BY avg_ms DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- MCP tool success rate
|
||||
SELECT
|
||||
json_extract(curated_payload, '$.tool') as tool,
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) as successful,
|
||||
ROUND(100.0 * SUM(CASE WHEN json_extract(curated_payload, '$.success') = 'true' THEN 1 ELSE 0 END) / COUNT(*), 2) as success_rate
|
||||
FROM logs
|
||||
WHERE action = 'mcp_tool_call'
|
||||
GROUP BY tool
|
||||
ORDER BY total_calls DESC;
|
||||
```
|
||||
|
||||
**Security note:** Sensitive parameters (passwords, API keys, tokens) are automatically redacted in logs as `[REDACTED]`.
|
||||
|
||||
### Signal Cache Backend
|
||||
|
||||
A new `SIGNAL_CACHE_CONFIG` configuration provides a unified Redis-based backend for real-time coordination features in Superset. This backend enables:
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
container_name: superset_db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- db_home_light:/var/lib/postgresql/data
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
container_name: superset_db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -76,7 +76,7 @@ services:
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
image: postgres:16
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${DATABASE_PORT:-5432}:5432"
|
||||
|
||||
@@ -1 +1 @@
|
||||
v20.18.3
|
||||
v20.20.0
|
||||
|
||||
@@ -141,10 +141,10 @@ database engine on a separate host or container.
|
||||
|
||||
Superset supports the following database engines/versions:
|
||||
|
||||
| Database Engine | Supported Versions |
|
||||
| ----------------------------------------- | ---------------------------------------- |
|
||||
| [PostgreSQL](https://www.postgresql.org/) | 10.X, 11.X, 12.X, 13.X, 14.X, 15.X, 16.X |
|
||||
| [MySQL](https://www.mysql.com/) | 5.7, 8.X |
|
||||
| Database Engine | Supported Versions |
|
||||
| ----------------------------------------- | ---------------------------------------------- |
|
||||
| [PostgreSQL](https://www.postgresql.org/) | 10.X, 11.X, 12.X, 13.X, 14.X, 15.X, 16.X, 17.X |
|
||||
| [MySQL](https://www.mysql.com/) | 5.7, 8.X |
|
||||
|
||||
Use the following database drivers and connection strings:
|
||||
|
||||
|
||||
@@ -48,25 +48,26 @@
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^8.6.15",
|
||||
"@storybook/addon-docs": "^8.6.17",
|
||||
"@storybook/blocks": "^8.6.15",
|
||||
"@storybook/channels": "^8.6.15",
|
||||
"@storybook/client-logger": "^8.6.15",
|
||||
"@storybook/components": "^8.6.15",
|
||||
"@storybook/core": "^8.6.15",
|
||||
"@storybook/core-events": "^8.6.15",
|
||||
"@storybook/channels": "^8.6.17",
|
||||
"@storybook/client-logger": "^8.6.17",
|
||||
"@storybook/components": "^8.6.17",
|
||||
"@storybook/core": "^8.6.17",
|
||||
"@storybook/core-events": "^8.6.17",
|
||||
"@storybook/csf": "^0.1.13",
|
||||
"@storybook/docs-tools": "^8.6.15",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/docs-tools": "^8.6.17",
|
||||
"@storybook/preview-api": "^8.6.17",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.11",
|
||||
"antd": "^6.3.0",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001769",
|
||||
"caniuse-lite": "^1.0.30001770",
|
||||
"docusaurus-plugin-openapi-docs": "^4.6.0",
|
||||
"docusaurus-theme-openapi-docs": "^4.6.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -81,8 +82,8 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.1.1",
|
||||
"storybook": "^8.6.15",
|
||||
"swagger-ui-react": "^5.31.0",
|
||||
"storybook": "^8.6.17",
|
||||
"swagger-ui-react": "^5.31.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -102,8 +103,8 @@
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"webpack": "^5.105.1"
|
||||
"typescript-eslint": "^8.56.0",
|
||||
"webpack": "^5.105.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -104,6 +104,10 @@ const DatabasePage: React.FC<DatabasePageProps> = ({ database, name }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Ensure db filename can be obtained regardless of how db doc gets generated
|
||||
// by either Flask app (superset.db_engine_specs.postgres) or fallback mode (postgres)
|
||||
const databaseModuleFilename = `${database.module?.split('.').pop()}.py`;
|
||||
|
||||
// Render driver information
|
||||
const renderDrivers = () => {
|
||||
if (!docs?.drivers?.length) return null;
|
||||
@@ -770,11 +774,11 @@ const DatabasePage: React.FC<DatabasePageProps> = ({ database, name }) => {
|
||||
Help improve this documentation by editing the engine spec:
|
||||
</Text>
|
||||
<a
|
||||
href={`https://github.com/apache/superset/edit/master/superset/db_engine_specs/${database.module}.py`}
|
||||
href={`https://github.com/apache/superset/edit/master/superset/db_engine_specs/${databaseModuleFilename}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<EditOutlined /> Edit {database.module}.py
|
||||
<EditOutlined /> Edit {databaseModuleFilename}
|
||||
</a>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"generated": "2026-01-31T10:47:01.730Z",
|
||||
"generated": "2026-02-16T04:47:37.257Z",
|
||||
"statistics": {
|
||||
"totalDatabases": 70,
|
||||
"withDocumentation": 70,
|
||||
"withConnectionString": 70,
|
||||
"totalDatabases": 72,
|
||||
"withDocumentation": 72,
|
||||
"withConnectionString": 72,
|
||||
"withDrivers": 36,
|
||||
"withAuthMethods": 4,
|
||||
"supportsJoins": 66,
|
||||
"supportsSubqueries": 67,
|
||||
"supportsJoins": 68,
|
||||
"supportsSubqueries": 69,
|
||||
"supportsDynamicSchema": 15,
|
||||
"supportsCatalog": 9,
|
||||
"averageScore": 32,
|
||||
"averageScore": 31,
|
||||
"maxScore": 201,
|
||||
"byCategory": {
|
||||
"Other Databases": [
|
||||
@@ -74,6 +74,7 @@
|
||||
"Apache Kylin",
|
||||
"Azure Synapse",
|
||||
"Ocient",
|
||||
"Apache Phoenix",
|
||||
"Amazon Redshift",
|
||||
"RisingWave",
|
||||
"SingleStore",
|
||||
@@ -151,12 +152,14 @@
|
||||
"Greenplum",
|
||||
"Apache Hive",
|
||||
"Apache Impala",
|
||||
"Apache IoTDB",
|
||||
"Apache Kylin",
|
||||
"MariaDB",
|
||||
"MonetDB",
|
||||
"MySQL",
|
||||
"OceanBase",
|
||||
"Parseable",
|
||||
"Apache Phoenix",
|
||||
"Apache Pinot",
|
||||
"PostgreSQL",
|
||||
"Presto",
|
||||
@@ -187,6 +190,7 @@
|
||||
"Time Series Databases": [
|
||||
"CrateDB",
|
||||
"Apache Druid",
|
||||
"Apache IoTDB",
|
||||
"Apache Pinot",
|
||||
"TDengine"
|
||||
],
|
||||
@@ -197,7 +201,9 @@
|
||||
"Apache Druid",
|
||||
"Apache Hive",
|
||||
"Apache Impala",
|
||||
"Apache IoTDB",
|
||||
"Apache Kylin",
|
||||
"Apache Phoenix",
|
||||
"Apache Pinot",
|
||||
"Apache Solr",
|
||||
"Apache Spark SQL"
|
||||
@@ -2890,6 +2896,47 @@
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Apache IoTDB": {
|
||||
"engine": "apache_iotdb",
|
||||
"engine_name": "Apache IoTDB",
|
||||
"module": "iotdb",
|
||||
"documentation": {
|
||||
"description": "Apache IoTDB is a time series database designed for IoT data, with efficient storage and query capabilities for massive time series data.",
|
||||
"logo": "apache-iotdb.svg",
|
||||
"homepage_url": "https://iotdb.apache.org/",
|
||||
"categories": [
|
||||
"APACHE_PROJECTS",
|
||||
"TIME_SERIES",
|
||||
"OPEN_SOURCE"
|
||||
],
|
||||
"pypi_packages": [
|
||||
"apache-iotdb"
|
||||
],
|
||||
"connection_string": "iotdb://{username}:{password}@{hostname}:{port}",
|
||||
"default_port": 6667,
|
||||
"parameters": {
|
||||
"username": "Database username (default: root)",
|
||||
"password": "Database password (default: root)",
|
||||
"hostname": "IP address or hostname",
|
||||
"port": "Default 6667"
|
||||
},
|
||||
"notes": "The IoTDB SQLAlchemy dialect was written to integrate with Apache Superset. IoTDB uses a hierarchical data model, which is reorganized into a relational model for SQL queries."
|
||||
},
|
||||
"time_grains": {},
|
||||
"score": 0,
|
||||
"max_score": 0,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false,
|
||||
"ssh_tunneling": false,
|
||||
"query_cancelation": false,
|
||||
"supports_file_upload": false,
|
||||
"user_impersonation": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Azure Data Explorer": {
|
||||
"engine": "azure_data_explorer",
|
||||
"engine_name": "Azure Data Explorer",
|
||||
@@ -4039,6 +4086,41 @@
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Apache Phoenix": {
|
||||
"engine": "apache_phoenix",
|
||||
"engine_name": "Apache Phoenix",
|
||||
"module": "phoenix",
|
||||
"documentation": {
|
||||
"description": "Apache Phoenix is a relational database layer over Apache HBase, providing low-latency SQL queries over HBase data.",
|
||||
"logo": "apache-phoenix.png",
|
||||
"homepage_url": "https://phoenix.apache.org/",
|
||||
"categories": [
|
||||
"APACHE_PROJECTS",
|
||||
"ANALYTICAL_DATABASES",
|
||||
"OPEN_SOURCE"
|
||||
],
|
||||
"pypi_packages": [
|
||||
"phoenixdb"
|
||||
],
|
||||
"connection_string": "phoenix://{hostname}:{port}/",
|
||||
"default_port": 8765,
|
||||
"notes": "Phoenix provides a SQL interface to Apache HBase. The phoenixdb driver connects via the Phoenix Query Server and supports a subset of SQLAlchemy."
|
||||
},
|
||||
"time_grains": {},
|
||||
"score": 0,
|
||||
"max_score": 0,
|
||||
"joins": true,
|
||||
"subqueries": true,
|
||||
"supports_dynamic_schema": false,
|
||||
"supports_catalog": false,
|
||||
"supports_dynamic_catalog": false,
|
||||
"ssh_tunneling": false,
|
||||
"query_cancelation": false,
|
||||
"supports_file_upload": false,
|
||||
"user_impersonation": false,
|
||||
"query_cost_estimation": false,
|
||||
"sql_validation": false
|
||||
},
|
||||
"Apache Pinot": {
|
||||
"engine": "apache_pinot",
|
||||
"engine_name": "Apache Pinot",
|
||||
@@ -4207,6 +4289,80 @@
|
||||
"OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Supabase",
|
||||
"description": "Open-source Firebase alternative built on top of PostgreSQL, providing a full backend-as-a-service with a hosted Postgres database.",
|
||||
"logo": "supabase.svg",
|
||||
"homepage_url": "https://supabase.com/",
|
||||
"pypi_packages": [
|
||||
"psycopg2"
|
||||
],
|
||||
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
|
||||
"connection_examples": [
|
||||
{
|
||||
"description": "Supabase project (connection pooler)",
|
||||
"connection_string": "postgresql://{username}.{project_ref}:{password}@aws-0-{region}.pooler.supabase.com:6543/{database}"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"username": "Database user (default: postgres)",
|
||||
"password": "Database password",
|
||||
"host": "Supabase project host (from project settings)",
|
||||
"port": "Default 5432 (direct) or 6543 (pooler)",
|
||||
"database": "Database name (default: postgres)",
|
||||
"project_ref": "Supabase project reference (from project settings)",
|
||||
"region": "Supabase project region (e.g., us-east-1)"
|
||||
},
|
||||
"notes": "Find connection details in your Supabase project dashboard under Settings > Database. Use the connection pooler (port 6543) for better connection management.",
|
||||
"docs_url": "https://supabase.com/docs/guides/database/connecting-to-postgres",
|
||||
"categories": [
|
||||
"HOSTED_OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Google AlloyDB",
|
||||
"description": "Google Cloud's PostgreSQL-compatible database service for demanding transactional and analytical workloads.",
|
||||
"logo": "alloydb.png",
|
||||
"homepage_url": "https://cloud.google.com/alloydb",
|
||||
"pypi_packages": [
|
||||
"psycopg2"
|
||||
],
|
||||
"connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}",
|
||||
"parameters": {
|
||||
"username": "Database user (default: postgres)",
|
||||
"password": "Database password",
|
||||
"host": "AlloyDB instance IP or Auth Proxy address",
|
||||
"port": "Default 5432",
|
||||
"database": "Database name"
|
||||
},
|
||||
"notes": "For public IP connections, use the AlloyDB Auth Proxy for secure access. Private IP connections can connect directly.",
|
||||
"docs_url": "https://cloud.google.com/alloydb/docs",
|
||||
"categories": [
|
||||
"CLOUD_GCP",
|
||||
"HOSTED_OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Neon",
|
||||
"description": "Serverless PostgreSQL with branching, scale-to-zero, and bottomless storage.",
|
||||
"logo": "neon.png",
|
||||
"homepage_url": "https://neon.tech/",
|
||||
"pypi_packages": [
|
||||
"psycopg2"
|
||||
],
|
||||
"connection_string": "postgresql://{username}:{password}@{host}/{database}?sslmode=require",
|
||||
"parameters": {
|
||||
"username": "Neon role name",
|
||||
"password": "Neon role password",
|
||||
"host": "Neon hostname (e.g., ep-cool-name-123456.us-east-2.aws.neon.tech)",
|
||||
"database": "Database name (default: neondb)"
|
||||
},
|
||||
"notes": "SSL is required for all connections. Find connection details in the Neon console under Connection Details.",
|
||||
"docs_url": "https://neon.tech/docs/connect/connect-from-any-app",
|
||||
"categories": [
|
||||
"HOSTED_OPEN_SOURCE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Amazon Aurora PostgreSQL",
|
||||
"description": "Amazon Aurora PostgreSQL is a fully managed, PostgreSQL-compatible relational database with up to 5x the throughput of standard PostgreSQL.",
|
||||
|
||||
820
docs/yarn.lock
820
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 13.4.4
|
||||
version: 16.7.27
|
||||
- name: redis
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
version: 17.9.4
|
||||
digest: sha256:c6290bb7e8ce9c694c06b3f5e9b9d01401943b0943c515d3a7a3a8dc1e6492ea
|
||||
generated: "2025-03-16T00:52:41.47139769+09:00"
|
||||
digest: sha256:fcae507ca24a20b9cc08b8bf0fcb0eba8ffa33126ab6f71cc3a6e1d5e997e9e3
|
||||
generated: "2026-02-08T14:11:58.8058368+01:00"
|
||||
|
||||
@@ -29,10 +29,10 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.15.3 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
version: 0.15.4 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 13.4.4
|
||||
version: 16.7.27
|
||||
repository: oci://registry-1.docker.io/bitnamicharts
|
||||
condition: postgresql.enabled
|
||||
- name: redis
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -50,7 +50,7 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
|
||||
| Repository | Name | Version |
|
||||
|------------|------|---------|
|
||||
| oci://registry-1.docker.io/bitnamicharts | postgresql | 13.4.4 |
|
||||
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.7.27 |
|
||||
| oci://registry-1.docker.io/bitnamicharts | redis | 17.9.4 |
|
||||
|
||||
## Values
|
||||
|
||||
@@ -82,7 +82,7 @@ dependencies = [
|
||||
"parsedatetime",
|
||||
"paramiko>=3.4.0",
|
||||
"pgsanity",
|
||||
"Pillow>=11.0.0, <12",
|
||||
"Pillow>=11.0.0, <13",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pyparsing>=3.0.6, <4",
|
||||
@@ -99,7 +99,7 @@ dependencies = [
|
||||
"simplejson>=3.15.0",
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-utils>=0.42.0, <0.43",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.9.0, <1.0",
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"flask-appbuilder>=5.0.2,<6",
|
||||
"pydantic>=2.8.0",
|
||||
"sqlalchemy>=1.4.0,<2.0",
|
||||
"sqlalchemy-utils>=0.42.0",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=28.10.0, <29",
|
||||
"typing-extensions>=4.0.0",
|
||||
]
|
||||
|
||||
@@ -245,6 +245,16 @@ module.exports = {
|
||||
// Lodash
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
|
||||
// React effect best practices
|
||||
'react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change':
|
||||
'error',
|
||||
'react-you-might-not-need-an-effect/no-chain-state-updates': 'error',
|
||||
'react-you-might-not-need-an-effect/no-event-handler': 'error',
|
||||
'react-you-might-not-need-an-effect/no-derived-state': 'error',
|
||||
|
||||
// Storybook
|
||||
'storybook/prefer-pascal-case': 'error',
|
||||
|
||||
// File progress
|
||||
'file-progress/activate': 1,
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ module.exports = {
|
||||
}),
|
||||
|
||||
typescript: {
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgen: getAbsolutePath('react-docgen-typescript'),
|
||||
},
|
||||
|
||||
framework: {
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"no-unused-vars": "off",
|
||||
"no-undef": "error",
|
||||
"no-prototype-builtins": "off",
|
||||
"no-unsafe-optional-chaining": "off",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-import-assign": "off",
|
||||
"no-promise-executor-return": "off",
|
||||
|
||||
@@ -136,7 +137,7 @@
|
||||
"react/jsx-no-bind": "off",
|
||||
"react/jsx-props-no-spreading": "off",
|
||||
"react/jsx-boolean-value": ["error", "never", { "always": [] }],
|
||||
"react/jsx-no-duplicate-props": ["error", { "ignoreCase": true }],
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-pascal-case": ["error", { "allowAllCaps": true, "ignore": [] }],
|
||||
"react/jsx-uses-vars": "error",
|
||||
@@ -254,6 +255,7 @@
|
||||
// === Unicorn rules (bonus coverage) ===
|
||||
"unicorn/no-new-array": "error",
|
||||
"unicorn/no-invalid-remove-event-listener": "error",
|
||||
"unicorn/no-useless-length-check": "error",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"unicorn/no-null": "off",
|
||||
|
||||
3196
superset-frontend/package-lock.json
generated
3196
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,7 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -159,12 +160,11 @@
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^1.0.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^2.1.2",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"dom-to-image-more": "^3.7.2",
|
||||
"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.3.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
@@ -183,7 +183,7 @@
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"lodash": "^4.17.23",
|
||||
"mapbox-gl": "^3.18.1",
|
||||
"markdown-to-jsx": "^9.7.3",
|
||||
"markdown-to-jsx": "^9.7.4",
|
||||
"match-sorter": "^6.3.4",
|
||||
"memoize-one": "^5.2.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
@@ -257,20 +257,20 @@
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-actions": "^8.6.15",
|
||||
"@storybook/addon-controls": "^8.6.15",
|
||||
"@storybook/addon-essentials": "^8.6.15",
|
||||
"@storybook/addon-links": "^8.6.15",
|
||||
"@storybook/addon-mdx-gfm": "^8.6.15",
|
||||
"@storybook/components": "^8.6.15",
|
||||
"@storybook/preview-api": "^8.6.15",
|
||||
"@storybook/react": "^8.6.15",
|
||||
"@storybook/react-webpack5": "^8.6.15",
|
||||
"@storybook/addon-actions": "^8.6.17",
|
||||
"@storybook/addon-controls": "^8.6.17",
|
||||
"@storybook/addon-essentials": "^8.6.17",
|
||||
"@storybook/addon-links": "^8.6.17",
|
||||
"@storybook/addon-mdx-gfm": "^8.6.17",
|
||||
"@storybook/components": "^8.6.17",
|
||||
"@storybook/preview-api": "^8.6.17",
|
||||
"@storybook/react": "^8.6.17",
|
||||
"@storybook/react-webpack5": "^8.6.17",
|
||||
"@storybook/test": "^8.6.15",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/plugin-emotion": "^14.5.0",
|
||||
"@swc/plugin-emotion": "^14.6.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^8.20.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -309,7 +309,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.3",
|
||||
"css-loader": "^7.1.4",
|
||||
"css-minimizer-webpack-plugin": "^7.0.4",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
@@ -317,6 +317,7 @@
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-cypress": "^3.6.0",
|
||||
"eslint-plugin-file-progress": "^1.5.0",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
@@ -326,9 +327,9 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.8.5",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.9.1",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.15.4",
|
||||
"eslint-plugin-testing-library": "^7.16.0",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"fork-ts-checker-webpack-plugin": "^9.1.0",
|
||||
@@ -341,12 +342,12 @@
|
||||
"jest-html-reporter": "^4.3.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^28.0.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lerna": "^8.2.3",
|
||||
"lightningcss": "^1.31.1",
|
||||
"mini-css-extract-plugin": "^2.10.0",
|
||||
"open-cli": "^8.0.0",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint": "^1.48.0",
|
||||
"po2json": "^0.4.5",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-packagejson": "^3.0.0",
|
||||
@@ -357,7 +358,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.5.0",
|
||||
"storybook": "8.6.15",
|
||||
"storybook": "8.6.17",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
@@ -368,13 +369,13 @@
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.3",
|
||||
"webpack": "^5.105.0",
|
||||
"wait-on": "^9.0.4",
|
||||
"webpack": "^5.105.2",
|
||||
"webpack-bundle-analyzer": "^5.2.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.3",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.3.3",
|
||||
"webpack-sources": "^3.3.4",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -390,7 +391,6 @@
|
||||
},
|
||||
"overrides": {
|
||||
"core-js": "^3.38.1",
|
||||
"d3-color": "^3.1.0",
|
||||
"puppeteer": "^22.4.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"underscore": "^1.13.7",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/fira-code": "^5.2.6",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^17.0.2",
|
||||
|
||||
@@ -23,9 +23,9 @@ import '@fontsource/inter/200.css';
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/fira-code/400.css';
|
||||
import '@fontsource/fira-code/500.css';
|
||||
import '@fontsource/fira-code/600.css';
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
/* eslint-enable import/extensions */
|
||||
|
||||
import { css, useTheme, Global } from '@emotion/react';
|
||||
|
||||
@@ -502,7 +502,7 @@ test('Theme base theme integration works with real-world Superset base theme con
|
||||
colorSuccess: '#5ac189',
|
||||
colorInfo: '#66bcfe',
|
||||
fontFamily: "'Inter', Helvetica, Arial",
|
||||
fontFamilyCode: "'Fira Code', 'Courier New', monospace",
|
||||
fontFamilyCode: "'IBM Plex Mono', 'Courier New', monospace",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface SupersetSpecificTokens {
|
||||
fontWeightNormal: string;
|
||||
fontWeightLight: string;
|
||||
fontWeightStrong: number;
|
||||
fontWeightBold: string;
|
||||
|
||||
// Brand-related
|
||||
brandIconMaxWidth: number;
|
||||
|
||||
@@ -17,4 +17,5 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { default as isBlank } from './isBlank';
|
||||
export { default as logging } from './logging';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 isBlank from './isBlank';
|
||||
|
||||
test('returns true for null', () => {
|
||||
expect(isBlank(null)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for undefined', () => {
|
||||
expect(isBlank(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for empty string', () => {
|
||||
expect(isBlank('')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for whitespace-only strings', () => {
|
||||
expect(isBlank(' ')).toBe(true);
|
||||
expect(isBlank(' ')).toBe(true);
|
||||
expect(isBlank('\t')).toBe(true);
|
||||
expect(isBlank('\n')).toBe(true);
|
||||
expect(isBlank(' \t\n ')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for non-empty strings', () => {
|
||||
expect(isBlank('hello')).toBe(false);
|
||||
expect(isBlank(' hello ')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for NaN', () => {
|
||||
expect(isBlank(NaN)).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for numbers', () => {
|
||||
expect(isBlank(0)).toBe(false);
|
||||
expect(isBlank(50)).toBe(false);
|
||||
expect(isBlank(-1)).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for booleans', () => {
|
||||
expect(isBlank(true)).toBe(false);
|
||||
expect(isBlank(false)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { isEmpty, isNaN, isNil, isString, trim } from 'lodash';
|
||||
|
||||
/**
|
||||
* Checks if a value is null, undefined, NaN, or a whitespace-only string.
|
||||
*/
|
||||
export default function isBlank(value: unknown): boolean {
|
||||
return (
|
||||
isNil(value) || isNaN(value) || (isString(value) && isEmpty(trim(value)))
|
||||
);
|
||||
}
|
||||
@@ -19,14 +19,20 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { t } from '@apache-superset/core';
|
||||
import { JsonValue } from '@superset-ui/core';
|
||||
import { Radio } from '@superset-ui/core/components';
|
||||
import { Radio, Tooltip, TooltipPlacement } from '@superset-ui/core/components';
|
||||
import { ControlHeader } from '../../components/ControlHeader';
|
||||
|
||||
// [value, label]
|
||||
export type RadioButtonOption = [
|
||||
JsonValue,
|
||||
Exclude<ReactNode, null | undefined | boolean>,
|
||||
];
|
||||
export interface RadioButtonOptionObject {
|
||||
value: JsonValue;
|
||||
label: Exclude<ReactNode, null | undefined | boolean>;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
tooltipPlacement?: TooltipPlacement;
|
||||
}
|
||||
|
||||
export type RadioButtonOption =
|
||||
| [JsonValue, Exclude<ReactNode, null | undefined | boolean>]
|
||||
| RadioButtonOptionObject;
|
||||
|
||||
export interface RadioButtonControlProps {
|
||||
label?: ReactNode;
|
||||
@@ -34,7 +40,17 @@ export interface RadioButtonControlProps {
|
||||
options: RadioButtonOption[];
|
||||
hovered?: boolean;
|
||||
value?: JsonValue;
|
||||
onChange: (opt: RadioButtonOption[0]) => void;
|
||||
onChange: (opt: JsonValue) => void;
|
||||
}
|
||||
|
||||
function normalizeOption(option: RadioButtonOption): RadioButtonOptionObject {
|
||||
if (Array.isArray(option)) {
|
||||
return {
|
||||
value: option[0],
|
||||
label: option[1],
|
||||
};
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
export default function RadioButtonControl({
|
||||
@@ -43,7 +59,9 @@ export default function RadioButtonControl({
|
||||
onChange,
|
||||
...props
|
||||
}: RadioButtonControlProps) {
|
||||
const currentValue = initialValue || options[0][0];
|
||||
const normalizedOptions = options.map(normalizeOption);
|
||||
const currentValue = initialValue || normalizedOptions[0].value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -55,29 +73,52 @@ export default function RadioButtonControl({
|
||||
value={currentValue}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
>
|
||||
{options.map(([val, label]) => (
|
||||
<Radio.Button
|
||||
// role="tab"
|
||||
key={JSON.stringify(val)}
|
||||
value={val}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
id={`tab-${val}`}
|
||||
type="button"
|
||||
aria-selected={val === currentValue}
|
||||
className={`btn btn-default ${
|
||||
val === currentValue ? 'active' : ''
|
||||
}`}
|
||||
onClick={e => {
|
||||
e.currentTarget?.focus();
|
||||
onChange(val);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Radio.Button>
|
||||
))}
|
||||
{normalizedOptions.map(
|
||||
({
|
||||
value: val,
|
||||
label,
|
||||
disabled = false,
|
||||
tooltip,
|
||||
tooltipPlacement = 'top',
|
||||
}) => {
|
||||
const button = (
|
||||
<Radio.Button
|
||||
key={JSON.stringify(val)}
|
||||
value={val}
|
||||
disabled={disabled}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
id={`tab-${val}`}
|
||||
type="button"
|
||||
aria-selected={val === currentValue}
|
||||
className={`btn btn-default ${
|
||||
val === currentValue ? 'active' : ''
|
||||
}`}
|
||||
onClick={e => {
|
||||
e.currentTarget?.focus();
|
||||
onChange(val);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Radio.Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={JSON.stringify(val)}
|
||||
title={tooltip}
|
||||
placement={tooltipPlacement}
|
||||
>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
},
|
||||
)}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{/* accessibility begin */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
style={{
|
||||
@@ -90,10 +131,10 @@ export default function RadioButtonControl({
|
||||
>
|
||||
{t(
|
||||
'%s tab selected',
|
||||
options.find(([val]) => val === currentValue)?.[1],
|
||||
normalizedOptions.find(({ value: val }) => val === currentValue)
|
||||
?.label,
|
||||
)}
|
||||
</div>
|
||||
{/* accessibility end */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,18 +62,41 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
||||
// Dynamically add axis-specific controls (rows and columns)
|
||||
(['columns', 'rows'] as const).forEach(axisParam => {
|
||||
const axis: 'rows' | 'columns' = axisParam;
|
||||
const otherAxis: 'rows' | 'columns' = axis === 'rows' ? 'columns' : 'rows';
|
||||
|
||||
matrixifyControls[`matrixify_mode_${axis}`] = {
|
||||
type: 'RadioButtonControl',
|
||||
label: t(`Metrics / Dimensions`),
|
||||
default: 'metrics',
|
||||
options: [
|
||||
['metrics', t('Metrics')],
|
||||
['dimensions', t('Dimension members')],
|
||||
],
|
||||
default: axis === 'columns' ? 'metrics' : 'dimensions',
|
||||
renderTrigger: true,
|
||||
tabOverride: 'matrixify',
|
||||
visibility: ({ controls }) => isMatrixifyVisible(controls, axis),
|
||||
mapStateToProps: ({ controls }) => {
|
||||
const otherAxisControlName = `matrixify_mode_${otherAxis}`;
|
||||
|
||||
const otherAxisValue =
|
||||
controls?.[otherAxisControlName]?.value ??
|
||||
(otherAxis === 'columns' ? 'metrics' : 'dimensions');
|
||||
|
||||
const isMetricsDisabled = otherAxisValue === 'metrics';
|
||||
|
||||
return {
|
||||
options: [
|
||||
{
|
||||
value: 'metrics',
|
||||
label: t('Metrics'),
|
||||
disabled: isMetricsDisabled,
|
||||
tooltip: isMetricsDisabled
|
||||
? t(
|
||||
"Metrics can't be used for both rows and columns at the same time",
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
{ value: 'dimensions', label: t('Dimension members') },
|
||||
],
|
||||
};
|
||||
},
|
||||
rerender: [`matrixify_mode_${otherAxis}`, `matrixify_dimension_${axis}`],
|
||||
};
|
||||
|
||||
matrixifyControls[`matrixify_${axis}`] = {
|
||||
|
||||
@@ -491,12 +491,16 @@ export type ConditionalFormattingConfig = {
|
||||
toAllRow?: boolean;
|
||||
toTextColor?: boolean;
|
||||
useGradient?: boolean;
|
||||
columnFormatting?: string;
|
||||
objectFormatting?: ObjectFormattingEnum;
|
||||
};
|
||||
|
||||
export type ColorFormatters = {
|
||||
column: string;
|
||||
toAllRow?: boolean;
|
||||
toTextColor?: boolean;
|
||||
columnFormatting?: string;
|
||||
objectFormatting?: ObjectFormattingEnum;
|
||||
getColorFromValue: (
|
||||
value: number | string | boolean | null,
|
||||
) => string | undefined;
|
||||
@@ -614,6 +618,13 @@ export type ControlFormItemSpec<T extends ControlType = ControlType> = {
|
||||
}
|
||||
: {});
|
||||
|
||||
export enum ObjectFormattingEnum {
|
||||
BACKGROUND_COLOR = 'BACKGROUND_COLOR',
|
||||
TEXT_COLOR = 'TEXT_COLOR',
|
||||
CELL_BAR = 'CELL_BAR',
|
||||
ENTIRE_ROW = 'ENTIRE_ROW',
|
||||
}
|
||||
|
||||
export enum ColorSchemeEnum {
|
||||
Green = 'Green',
|
||||
Red = 'Red',
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { isString, isBoolean } from 'lodash';
|
||||
import { isBlank } from '@apache-superset/core';
|
||||
import { addAlpha, DataRecord } from '@superset-ui/core';
|
||||
import {
|
||||
ColorFormatters,
|
||||
@@ -254,6 +256,9 @@ export const getColorFunction = (
|
||||
}
|
||||
|
||||
return (value: number | string | boolean | null) => {
|
||||
if (isBlank(value) && operator !== Comparator.IsNull) {
|
||||
return undefined;
|
||||
}
|
||||
const compareResult = comparatorFunction(value, columnValues);
|
||||
if (compareResult === false) return undefined;
|
||||
const { cutoffValue, extremeValue } = compareResult;
|
||||
@@ -306,6 +311,8 @@ export const getColorFormatters = memoizeOne(
|
||||
column: config?.column,
|
||||
toAllRow: config?.toAllRow,
|
||||
toTextColor: config?.toTextColor,
|
||||
columnFormatting: config?.columnFormatting,
|
||||
objectFormatting: config?.objectFormatting,
|
||||
getColorFromValue: getColorFunction(
|
||||
{ ...config, colorScheme: resolvedColorScheme },
|
||||
data.map(row => row[config.column!] as number),
|
||||
@@ -318,11 +325,3 @@ export const getColorFormatters = memoizeOne(
|
||||
[],
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
function isString(value: unknown) {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown) {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RadioButtonControl, {
|
||||
RadioButtonControlProps,
|
||||
RadioButtonOption,
|
||||
} from '../../../src/shared-controls/components/RadioButtonControl';
|
||||
|
||||
const defaultProps: RadioButtonControlProps = {
|
||||
label: 'Test Radio Control',
|
||||
options: [
|
||||
['option1', 'Option 1'],
|
||||
['option2', 'Option 2'],
|
||||
['option3', 'Option 3'],
|
||||
],
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = (props: Partial<RadioButtonControlProps> = {}) =>
|
||||
render(<RadioButtonControl {...defaultProps} {...props} />);
|
||||
|
||||
test('renders with array-based options (legacy format)', () => {
|
||||
const { container } = setup();
|
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with object-based options (new format)', () => {
|
||||
const objectOptions: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Object Option 1' },
|
||||
{ value: 'opt2', label: 'Object Option 2' },
|
||||
{ value: 'opt3', label: 'Object Option 3' },
|
||||
];
|
||||
|
||||
setup({ options: objectOptions });
|
||||
|
||||
expect(screen.getByText('Object Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Object Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Object Option 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders mixed array and object options', () => {
|
||||
const mixedOptions: RadioButtonOption[] = [
|
||||
['array1', 'Array Option'],
|
||||
{ value: 'obj1', label: 'Object Option' },
|
||||
];
|
||||
|
||||
setup({ options: mixedOptions });
|
||||
|
||||
expect(screen.getByText('Array Option')).toBeInTheDocument();
|
||||
expect(screen.getByText('Object Option')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('defaults to first option when no value provided', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const firstButton = container.querySelector('#tab-option1');
|
||||
expect(firstButton).toBeInTheDocument();
|
||||
expect(firstButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('respects initial value prop', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
const secondButton = container.querySelector('#tab-option2');
|
||||
expect(secondButton).toBeInTheDocument();
|
||||
expect(secondButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('calls onChange when radio button is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
setup({ onChange });
|
||||
|
||||
const secondOption = screen.getByText('Option 2');
|
||||
fireEvent.click(secondOption);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('option2');
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles multiple clicks correctly', () => {
|
||||
const onChange = jest.fn();
|
||||
setup({ onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('Option 2'));
|
||||
fireEvent.click(screen.getByText('Option 3'));
|
||||
fireEvent.click(screen.getByText('Option 1'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('option2');
|
||||
expect(onChange).toHaveBeenCalledWith('option3');
|
||||
expect(onChange).toHaveBeenCalledWith('option1');
|
||||
expect(onChange.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('disables specific options when disabled flag is set', () => {
|
||||
const optionsWithDisabled: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Enabled Option' },
|
||||
{ value: 'opt2', label: 'Disabled Option', disabled: true },
|
||||
{ value: 'opt3', label: 'Another Enabled' },
|
||||
];
|
||||
|
||||
const { container } = setup({ options: optionsWithDisabled });
|
||||
|
||||
const disabledButton = container.querySelector('#tab-opt2');
|
||||
const enabledButton = container.querySelector('#tab-opt1');
|
||||
|
||||
expect(disabledButton).toHaveAttribute('disabled');
|
||||
expect(enabledButton).not.toHaveAttribute('disabled');
|
||||
});
|
||||
|
||||
test('disabled options do not trigger onChange when clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
const optionsWithDisabled: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Enabled' },
|
||||
{ value: 'opt2', label: 'Disabled', disabled: true },
|
||||
];
|
||||
|
||||
const { container } = setup({ options: optionsWithDisabled, onChange });
|
||||
|
||||
const disabledButton = container.querySelector('#tab-opt2');
|
||||
if (disabledButton) {
|
||||
fireEvent.click(disabledButton);
|
||||
}
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders ControlHeader with label and description', () => {
|
||||
const { container } = setup({
|
||||
label: 'My Radio Control',
|
||||
description: 'This is a helpful description',
|
||||
});
|
||||
|
||||
const header = container.querySelector('.ControlHeader');
|
||||
expect(header).toBeInTheDocument();
|
||||
expect(screen.getByText('My Radio Control')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('aria-live region updates with current selection', () => {
|
||||
const { container } = setup({ value: 'option1' });
|
||||
|
||||
const ariaLiveRegion = container.querySelector('[aria-live="polite"]');
|
||||
expect(ariaLiveRegion).toBeInTheDocument();
|
||||
expect(ariaLiveRegion?.textContent).toContain('Option 1');
|
||||
});
|
||||
|
||||
test('aria-live region updates when selection changes', () => {
|
||||
const { container, rerender } = setup({ value: 'option1' });
|
||||
|
||||
let ariaLiveRegion = container.querySelector('[aria-live="polite"]');
|
||||
expect(ariaLiveRegion?.textContent).toContain('Option 1');
|
||||
|
||||
rerender(<RadioButtonControl {...defaultProps} value="option2" />);
|
||||
|
||||
ariaLiveRegion = container.querySelector('[aria-live="polite"]');
|
||||
expect(ariaLiveRegion?.textContent).toContain('Option 2');
|
||||
});
|
||||
|
||||
test('aria-live region is visually hidden but accessible', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const ariaLiveRegion = container.querySelector(
|
||||
'[aria-live="polite"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(ariaLiveRegion).toBeInTheDocument();
|
||||
expect(ariaLiveRegion?.style.position).toBe('absolute');
|
||||
expect(ariaLiveRegion?.style.left).toBe('-9999px');
|
||||
expect(ariaLiveRegion?.style.height).toBe('1px');
|
||||
expect(ariaLiveRegion?.style.width).toBe('1px');
|
||||
expect(ariaLiveRegion?.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
test('renders tablist with correct aria-label when label is string', () => {
|
||||
const { container } = setup({ label: 'String Label' });
|
||||
|
||||
const tablist = container.querySelector('[role="tablist"]');
|
||||
expect(tablist).toHaveAttribute('aria-label', 'String Label');
|
||||
});
|
||||
|
||||
test('tablist has no aria-label when label is not string', () => {
|
||||
const { container } = setup({ label: <div>JSX Label</div> });
|
||||
|
||||
const tablist = container.querySelector('[role="tablist"]');
|
||||
expect(tablist).not.toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
test('each radio button has correct aria-selected state', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
expect(container.querySelector('#tab-option1')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
expect(container.querySelector('#tab-option2')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
expect(container.querySelector('#tab-option3')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('radio buttons have correct aria-label when label is string', () => {
|
||||
setup();
|
||||
|
||||
const option1Button = screen.getByLabelText('Option 1');
|
||||
expect(option1Button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('focuses button when clicked', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const button = container.querySelector('#tab-option2') as HTMLElement;
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(document.activeElement).toBe(button);
|
||||
});
|
||||
|
||||
test('handles numeric values in options', () => {
|
||||
const onChange = jest.fn();
|
||||
const numericOptions: RadioButtonOption[] = [
|
||||
[1, 'One'],
|
||||
[2, 'Two'],
|
||||
[3, 'Three'],
|
||||
];
|
||||
|
||||
setup({ options: numericOptions, onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('Two'));
|
||||
expect(onChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
test('handles boolean values in options', () => {
|
||||
const onChange = jest.fn();
|
||||
const booleanOptions: RadioButtonOption[] = [
|
||||
[true, 'True'],
|
||||
[false, 'False'],
|
||||
];
|
||||
|
||||
setup({ options: booleanOptions, onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('False'));
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('handles null values in options', () => {
|
||||
const onChange = jest.fn();
|
||||
const nullOptions: RadioButtonOption[] = [
|
||||
[null, 'None'],
|
||||
['value', 'Value'],
|
||||
];
|
||||
|
||||
setup({ options: nullOptions, onChange });
|
||||
|
||||
fireEvent.click(screen.getByText('None'));
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('generates unique IDs for options', () => {
|
||||
const { container } = setup();
|
||||
|
||||
const button1 = container.querySelector('#tab-option1');
|
||||
const button2 = container.querySelector('#tab-option2');
|
||||
const button3 = container.querySelector('#tab-option3');
|
||||
|
||||
expect(button1).toBeInTheDocument();
|
||||
expect(button2).toBeInTheDocument();
|
||||
expect(button3).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies active class to selected button', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
const activeButton = container.querySelector('#tab-option2');
|
||||
expect(activeButton).toBeInTheDocument();
|
||||
expect(activeButton).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('does not set aria-selected to true for unselected buttons', () => {
|
||||
const { container } = setup({ value: 'option2' });
|
||||
|
||||
const inactiveButton1 = container.querySelector('#tab-option1');
|
||||
const inactiveButton3 = container.querySelector('#tab-option3');
|
||||
|
||||
expect(inactiveButton1).toHaveAttribute('aria-selected', 'false');
|
||||
expect(inactiveButton3).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
test('backward compatibility with legacy array format', () => {
|
||||
const onChange = jest.fn();
|
||||
const legacyOptions: RadioButtonOption[] = [
|
||||
['val1', 'Label 1'],
|
||||
['val2', 'Label 2'],
|
||||
];
|
||||
|
||||
setup({ options: legacyOptions, onChange });
|
||||
|
||||
expect(screen.getByText('Label 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Label 2')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Label 2'));
|
||||
expect(onChange).toHaveBeenCalledWith('val2');
|
||||
});
|
||||
|
||||
test('normalizeOption handles array format correctly', () => {
|
||||
const arrayOption: RadioButtonOption = ['value', 'Label'];
|
||||
const onChange = jest.fn();
|
||||
|
||||
setup({ options: [arrayOption], onChange });
|
||||
|
||||
expect(screen.getByText('Label')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Label'));
|
||||
expect(onChange).toHaveBeenCalledWith('value');
|
||||
});
|
||||
|
||||
test('normalizeOption handles object format correctly', () => {
|
||||
const objectOption: RadioButtonOption = {
|
||||
value: 'value',
|
||||
label: 'Label',
|
||||
disabled: false,
|
||||
};
|
||||
const onChange = jest.fn();
|
||||
|
||||
setup({ options: [objectOption], onChange });
|
||||
|
||||
expect(screen.getByText('Label')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Label'));
|
||||
expect(onChange).toHaveBeenCalledWith('value');
|
||||
});
|
||||
|
||||
test('handles empty options array gracefully', () => {
|
||||
const { container } = setup({ options: [], value: 'default' });
|
||||
|
||||
expect(container.querySelector('[role="tablist"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with hovered prop', () => {
|
||||
const { container } = setup({
|
||||
label: 'Test',
|
||||
description: 'Test description',
|
||||
hovered: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-test="info-tooltip-icon"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders tooltips for options with tooltip property', async () => {
|
||||
const optionsWithTooltips: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Option 1', tooltip: 'Tooltip for option 1' },
|
||||
{ value: 'opt2', label: 'Option 2' },
|
||||
{ value: 'opt3', label: 'Option 3', tooltip: 'Tooltip for option 3' },
|
||||
];
|
||||
|
||||
setup({ options: optionsWithTooltips });
|
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 3')).toBeInTheDocument();
|
||||
|
||||
const option1 = screen.getByText('Option 1');
|
||||
userEvent.hover(option1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tooltip for option 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.unhover(option1);
|
||||
|
||||
const option3 = screen.getByText('Option 3');
|
||||
userEvent.hover(option3);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tooltip for option 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('wraps disabled buttons with tooltip in span', () => {
|
||||
const optionsWithDisabledTooltip: RadioButtonOption[] = [
|
||||
{ value: 'opt1', label: 'Enabled with tooltip', tooltip: 'Tooltip text' },
|
||||
{
|
||||
value: 'opt2',
|
||||
label: 'Disabled with tooltip',
|
||||
disabled: true,
|
||||
tooltip: 'Disabled tooltip',
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = setup({ options: optionsWithDisabledTooltip });
|
||||
|
||||
const disabledButton = container.querySelector('#tab-opt2');
|
||||
expect(disabledButton).toHaveAttribute('disabled');
|
||||
expect(disabledButton?.parentElement?.tagName).toBe('SPAN');
|
||||
});
|
||||
@@ -506,6 +506,117 @@ test('getColorFunction IsNotNull', () => {
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on numeric comparators', () => {
|
||||
const operators = [
|
||||
{ operator: Comparator.LessThan, targetValue: 50 },
|
||||
{ operator: Comparator.LessOrEqual, targetValue: 50 },
|
||||
{ operator: Comparator.GreaterThan, targetValue: 50 },
|
||||
{ operator: Comparator.GreaterOrEqual, targetValue: 50 },
|
||||
{ operator: Comparator.Equal, targetValue: 50 },
|
||||
{ operator: Comparator.NotEqual, targetValue: 50 },
|
||||
];
|
||||
operators.forEach(({ operator, targetValue }) => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator,
|
||||
targetValue,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on Between comparators', () => {
|
||||
const operators = [
|
||||
Comparator.Between,
|
||||
Comparator.BetweenOrEqual,
|
||||
Comparator.BetweenOrLeftEqual,
|
||||
Comparator.BetweenOrRightEqual,
|
||||
];
|
||||
operators.forEach(operator => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator,
|
||||
targetValueLeft: -10,
|
||||
targetValueRight: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on None operator', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for null values on string comparators', () => {
|
||||
const operators = [
|
||||
Comparator.BeginsWith,
|
||||
Comparator.EndsWith,
|
||||
Comparator.Containing,
|
||||
Comparator.NotContaining,
|
||||
];
|
||||
operators.forEach(operator => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator,
|
||||
targetValue: 'test',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction(null)).toBeUndefined();
|
||||
expect(colorFunction(undefined as unknown as null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('getColorFunction returns undefined for empty and whitespace string values', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.LessThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
expect(colorFunction('' as unknown as number)).toBeUndefined();
|
||||
expect(colorFunction(' ' as unknown as number)).toBeUndefined();
|
||||
expect(colorFunction('\t' as unknown as number)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getColorFunction IsNull still matches null values', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.IsNull,
|
||||
targetValue: '',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'isMember',
|
||||
},
|
||||
boolValues,
|
||||
);
|
||||
expect(colorFunction(null)).toEqual('#FF0000FF');
|
||||
expect(colorFunction(true)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('correct column config', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
|
||||
@@ -42,6 +42,21 @@ test('renders SQLEditor', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('SQLEditor uses fontFamilyCode from theme', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(selector)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editorInstance = ref.current?.editor;
|
||||
const fontFamily = editorInstance?.getOption('fontFamily');
|
||||
// Verify font family is set (not undefined) and contains a monospace font
|
||||
expect(fontFamily).toBeDefined();
|
||||
expect(fontFamily).toMatch(/mono|courier|consolas/i);
|
||||
});
|
||||
|
||||
test('renders FullSQLEditor', async () => {
|
||||
const { container } = render(<FullSQLEditor />);
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AsyncAceEditor(
|
||||
defaultMode,
|
||||
defaultTheme,
|
||||
defaultTabSize = 2,
|
||||
fontFamily = 'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
|
||||
fontFamily,
|
||||
placeholder,
|
||||
}: AsyncAceEditorOptions = {},
|
||||
) {
|
||||
@@ -171,6 +171,7 @@ export function AsyncAceEditor(
|
||||
ref,
|
||||
) {
|
||||
const token = useTheme();
|
||||
const editorFontFamily = fontFamily || token.fontFamilyCode;
|
||||
const langTools = acequire('ace/ext/language_tools');
|
||||
|
||||
const setCompleters = useCallback(
|
||||
@@ -436,7 +437,7 @@ export function AsyncAceEditor(
|
||||
theme={theme}
|
||||
tabSize={tabSize}
|
||||
defaultValue={defaultValue}
|
||||
setOptions={{ fontFamily }}
|
||||
setOptions={{ fontFamily: editorFontFamily }}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -34,6 +34,11 @@ test('works with an onClick handler', () => {
|
||||
expect(mockAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders with monospace prop', () => {
|
||||
const { getByText } = render(<Label monospace>monospace text</Label>);
|
||||
expect(getByText('monospace text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// test stories from the storybook!
|
||||
test('renders all the storybook gallery variants', () => {
|
||||
// @ts-expect-error: Suppress TypeScript error for LabelGallery usage
|
||||
|
||||
@@ -47,9 +47,10 @@ describe('Layout Component', () => {
|
||||
});
|
||||
|
||||
test('hides Header when headerVisible is false', () => {
|
||||
const headerVisible = false;
|
||||
render(
|
||||
<Layout>
|
||||
{false && <Layout.Header>Header</Layout.Header>}
|
||||
{headerVisible && <Layout.Header>Header</Layout.Header>}
|
||||
<Layout.Content>Content Area</Layout.Content>
|
||||
<Layout.Footer>Ant Design Layout Footer</Layout.Footer>
|
||||
</Layout>,
|
||||
@@ -59,11 +60,14 @@ describe('Layout Component', () => {
|
||||
});
|
||||
|
||||
test('hides Footer when footerVisible is false', () => {
|
||||
const footerVisible = false;
|
||||
render(
|
||||
<Layout>
|
||||
<Layout.Header>Header</Layout.Header>
|
||||
<Layout.Content>Content Area</Layout.Content>
|
||||
{false && <Layout.Footer>Ant Design Layout Footer</Layout.Footer>}
|
||||
{footerVisible && (
|
||||
<Layout.Footer>Ant Design Layout Footer</Layout.Footer>
|
||||
)}
|
||||
</Layout>,
|
||||
);
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ const CustomModal = ({
|
||||
const shouldShowMask = !(resizable || draggable);
|
||||
|
||||
const onDragStart = (_: DraggableEvent, uiData: DraggableData) => {
|
||||
const { clientWidth, clientHeight } = window?.document?.documentElement;
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
const targetRect = draggableRef?.current?.getBoundingClientRect();
|
||||
|
||||
if (targetRect) {
|
||||
|
||||
@@ -27,8 +27,7 @@ test('Returns null if Selection object is null', () => {
|
||||
test('Returns selection text if Selection object is not null', () => {
|
||||
jest
|
||||
.spyOn(window, 'getSelection')
|
||||
// @ts-expect-error
|
||||
.mockImplementationOnce(() => ({ toString: () => 'test string' }));
|
||||
.mockImplementationOnce(() => ({ toString: () => 'test string' }) as any);
|
||||
expect(getSelectedText()).toEqual('test string');
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -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 { Page } from '@playwright/test';
|
||||
import { Modal } from '../core';
|
||||
|
||||
/**
|
||||
* Chart properties edit modal.
|
||||
* Opened by clicking the edit icon on a chart row in the chart list.
|
||||
* General section is expanded by default (defaultActiveKey="general").
|
||||
*/
|
||||
export class ChartPropertiesModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
NAME_INPUT: '[data-test="properties-modal-name-input"]',
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page, '[data-test="properties-edit-modal"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the chart name input field
|
||||
* @param name - The new chart name
|
||||
*/
|
||||
async fillName(name: string): Promise<void> {
|
||||
const input = this.body.locator(ChartPropertiesModal.SELECTORS.NAME_INPUT);
|
||||
await input.fill(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Save button in the modal footer
|
||||
*/
|
||||
async clickSave(): Promise<void> {
|
||||
await this.clickFooterButton('Save');
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Response, APIResponse } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
import * as unzipper from 'unzipper';
|
||||
|
||||
/**
|
||||
* Common interface for response types with status() method.
|
||||
@@ -59,3 +60,60 @@ export function expectStatusOneOf<T extends ResponseLike>(
|
||||
).toContain(response.status());
|
||||
return response;
|
||||
}
|
||||
|
||||
interface ExportZipOptions {
|
||||
/** Directory name containing resource yaml files (e.g. 'charts', 'datasets') */
|
||||
resourceDir: string;
|
||||
/** Minimum number of resource yaml files expected (default: 1) */
|
||||
minCount?: number;
|
||||
/** Regex to validate Content-Disposition header (skipped if omitted) */
|
||||
contentDispositionPattern?: RegExp;
|
||||
/** Resource names that must each appear in at least one YAML filepath */
|
||||
expectedNames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an export zip response: content-type, zip structure, and resource yaml files.
|
||||
* Shared across chart and dataset export tests.
|
||||
*/
|
||||
export async function expectValidExportZip(
|
||||
response: ResponseLike,
|
||||
options: ExportZipOptions,
|
||||
): Promise<void> {
|
||||
const {
|
||||
resourceDir,
|
||||
minCount = 1,
|
||||
contentDispositionPattern,
|
||||
expectedNames,
|
||||
} = options;
|
||||
|
||||
expect(response.headers()['content-type']).toContain('application/zip');
|
||||
|
||||
if (contentDispositionPattern) {
|
||||
expect(response.headers()['content-disposition']).toMatch(
|
||||
contentDispositionPattern,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.body();
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
|
||||
const entries: string[] = [];
|
||||
const directory = await unzipper.Open.buffer(body);
|
||||
directory.files.forEach(file => entries.push(file.path));
|
||||
|
||||
const resourceYamlFiles = entries.filter(
|
||||
entry => entry.includes(`${resourceDir}/`) && entry.endsWith('.yaml'),
|
||||
);
|
||||
expect(resourceYamlFiles.length).toBeGreaterThanOrEqual(minCount);
|
||||
expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true);
|
||||
|
||||
if (expectedNames) {
|
||||
for (const name of expectedNames) {
|
||||
expect(
|
||||
resourceYamlFiles.some(f => f.includes(name)),
|
||||
`Expected export zip to contain a YAML file matching "${name}"`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
superset-frontend/playwright/helpers/api/chart.ts
Normal file
104
superset-frontend/playwright/helpers/api/chart.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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 { Page, APIResponse } from '@playwright/test';
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiDelete,
|
||||
apiPut,
|
||||
ApiRequestOptions,
|
||||
} from './requests';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
CHART: 'api/v1/chart/',
|
||||
CHART_EXPORT: 'api/v1/chart/export/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* TypeScript interface for chart creation API payload.
|
||||
* Only slice_name, datasource_id, datasource_type are required (ChartPostSchema).
|
||||
*/
|
||||
export interface ChartCreatePayload {
|
||||
slice_name: string;
|
||||
datasource_id: number;
|
||||
datasource_type: string;
|
||||
viz_type?: string;
|
||||
params?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request to create a chart
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param requestBody - Chart configuration object
|
||||
* @returns API response from chart creation
|
||||
*/
|
||||
export async function apiPostChart(
|
||||
page: Page,
|
||||
requestBody: ChartCreatePayload,
|
||||
): Promise<APIResponse> {
|
||||
return apiPost(page, ENDPOINTS.CHART, requestBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request to fetch a chart's details
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param chartId - ID of the chart to fetch
|
||||
* @param options - Optional request options
|
||||
* @returns API response with chart details
|
||||
*/
|
||||
export async function apiGetChart(
|
||||
page: Page,
|
||||
chartId: number,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiGet(page, `${ENDPOINTS.CHART}${chartId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request to remove a chart
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param chartId - ID of the chart to delete
|
||||
* @param options - Optional request options
|
||||
* @returns API response from chart deletion
|
||||
*/
|
||||
export async function apiDeleteChart(
|
||||
page: Page,
|
||||
chartId: number,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiDelete(page, `${ENDPOINTS.CHART}${chartId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request to update a chart
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param chartId - ID of the chart to update
|
||||
* @param data - Partial chart payload (Marshmallow allows optional fields)
|
||||
* @param options - Optional request options
|
||||
* @returns API response from chart update
|
||||
*/
|
||||
export async function apiPutChart(
|
||||
page: Page,
|
||||
chartId: number,
|
||||
data: Record<string, unknown>,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiPut(page, `${ENDPOINTS.CHART}${chartId}`, data, options);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { test as base } from '@playwright/test';
|
||||
import { apiDeleteChart } from '../api/chart';
|
||||
import { apiDeleteDataset } from '../api/dataset';
|
||||
import { apiDeleteDatabase } from '../api/database';
|
||||
|
||||
@@ -26,40 +27,78 @@ import { apiDeleteDatabase } from '../api/database';
|
||||
* Inspired by Cypress's cleanDashboards/cleanCharts pattern.
|
||||
*/
|
||||
export interface TestAssets {
|
||||
trackChart(id: number): void;
|
||||
trackDataset(id: number): void;
|
||||
trackDatabase(id: number): void;
|
||||
}
|
||||
|
||||
const EXPECTED_CLEANUP_STATUSES = new Set([200, 202, 204, 404]);
|
||||
|
||||
export const test = base.extend<{ testAssets: TestAssets }>({
|
||||
testAssets: async ({ page }, use) => {
|
||||
// Use Set to de-dupe IDs (same resource may be tracked multiple times)
|
||||
const chartIds = new Set<number>();
|
||||
const datasetIds = new Set<number>();
|
||||
const databaseIds = new Set<number>();
|
||||
|
||||
await use({
|
||||
trackChart: id => chartIds.add(id),
|
||||
trackDataset: id => datasetIds.add(id),
|
||||
trackDatabase: id => databaseIds.add(id),
|
||||
});
|
||||
|
||||
// Cleanup: Delete datasets FIRST (they reference databases)
|
||||
// Then delete databases. Use failOnStatusCode: false for tolerance.
|
||||
// Cleanup order: charts → datasets → databases (respects FK dependencies)
|
||||
// Use failOnStatusCode: false to avoid throwing on 404 (resource already deleted by test)
|
||||
// Warn on unexpected status codes (401/403/500) that may indicate leaked state
|
||||
await Promise.all(
|
||||
[...chartIds].map(id =>
|
||||
apiDeleteChart(page, id, { failOnStatusCode: false })
|
||||
.then(response => {
|
||||
if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
|
||||
console.warn(
|
||||
`[testAssets] Unexpected status ${response.status()} cleaning up chart ${id}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(`[testAssets] Failed to cleanup chart ${id}:`, error);
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
[...datasetIds].map(id =>
|
||||
apiDeleteDataset(page, id, { failOnStatusCode: false }).catch(error => {
|
||||
console.warn(`[testAssets] Failed to cleanup dataset ${id}:`, error);
|
||||
}),
|
||||
apiDeleteDataset(page, id, { failOnStatusCode: false })
|
||||
.then(response => {
|
||||
if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
|
||||
console.warn(
|
||||
`[testAssets] Unexpected status ${response.status()} cleaning up dataset ${id}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(
|
||||
`[testAssets] Failed to cleanup dataset ${id}:`,
|
||||
error,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
[...databaseIds].map(id =>
|
||||
apiDeleteDatabase(page, id, { failOnStatusCode: false }).catch(
|
||||
error => {
|
||||
apiDeleteDatabase(page, id, { failOnStatusCode: false })
|
||||
.then(response => {
|
||||
if (!EXPECTED_CLEANUP_STATUSES.has(response.status())) {
|
||||
console.warn(
|
||||
`[testAssets] Unexpected status ${response.status()} cleaning up database ${id}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(
|
||||
`[testAssets] Failed to cleanup database ${id}:`,
|
||||
error,
|
||||
);
|
||||
},
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
132
superset-frontend/playwright/pages/ChartListPage.ts
Normal file
132
superset-frontend/playwright/pages/ChartListPage.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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 { Page, Locator } from '@playwright/test';
|
||||
import { Table } from '../components/core';
|
||||
import { BulkSelect } from '../components/ListView';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
/**
|
||||
* Chart List Page object.
|
||||
*/
|
||||
export class ChartListPage {
|
||||
private readonly page: Page;
|
||||
private readonly table: Table;
|
||||
readonly bulkSelect: BulkSelect;
|
||||
|
||||
/**
|
||||
* Action button names for getByRole('button', { name })
|
||||
* Verified: ChartList uses Icons.DeleteOutlined, Icons.UploadOutlined, Icons.EditOutlined
|
||||
*/
|
||||
private static readonly ACTION_BUTTONS = {
|
||||
DELETE: 'delete',
|
||||
EDIT: 'edit',
|
||||
EXPORT: 'upload',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.table = new Table(page);
|
||||
this.bulkSelect = new BulkSelect(page, this.table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the chart list page.
|
||||
* Forces table view via URL parameter to avoid card view default
|
||||
* (ListviewsDefaultCardView feature flag may enable card view).
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(`${URL.CHART_LIST}?viewMode=table`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the table to load
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
|
||||
await this.table.waitForVisible(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a chart row locator by name.
|
||||
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
|
||||
*
|
||||
* @param chartName - The name of the chart
|
||||
* @returns Locator for the chart row
|
||||
*/
|
||||
getChartRow(chartName: string): Locator {
|
||||
return this.table.getRow(chartName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the delete action button for a chart
|
||||
* @param chartName - The name of the chart to delete
|
||||
*/
|
||||
async clickDeleteAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.DELETE })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the edit action button for a chart
|
||||
* @param chartName - The name of the chart to edit
|
||||
*/
|
||||
async clickEditAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EDIT })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the export action button for a chart
|
||||
* @param chartName - The name of the chart to export
|
||||
*/
|
||||
async clickExportAction(chartName: string): Promise<void> {
|
||||
const row = this.table.getRow(chartName);
|
||||
await row
|
||||
.getByRole('button', { name: ChartListPage.ACTION_BUTTONS.EXPORT })
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the "Bulk select" button to enable bulk selection mode
|
||||
*/
|
||||
async clickBulkSelectButton(): Promise<void> {
|
||||
await this.bulkSelect.enable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a chart's checkbox in bulk select mode
|
||||
* @param chartName - The name of the chart to select
|
||||
*/
|
||||
async selectChartCheckbox(chartName: string): Promise<void> {
|
||||
await this.bulkSelect.selectRow(chartName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a bulk action button by name (e.g., "Export", "Delete")
|
||||
* @param actionName - The name of the bulk action to click
|
||||
*/
|
||||
async clickBulkAction(actionName: string): Promise<void> {
|
||||
await this.bulkSelect.clickAction(actionName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* 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 {
|
||||
test as testWithAssets,
|
||||
expect,
|
||||
} from '../../../helpers/fixtures/testAssets';
|
||||
import { ChartListPage } from '../../../pages/ChartListPage';
|
||||
import { ChartPropertiesModal } from '../../../components/modals/ChartPropertiesModal';
|
||||
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
|
||||
import { Toast } from '../../../components/core/Toast';
|
||||
import { apiGetChart, ENDPOINTS } from '../../../helpers/api/chart';
|
||||
import { createTestChart } from './chart-test-helpers';
|
||||
import { waitForGet, waitForPut } from '../../../helpers/api/intercepts';
|
||||
import {
|
||||
expectStatusOneOf,
|
||||
expectValidExportZip,
|
||||
} from '../../../helpers/api/assertions';
|
||||
|
||||
/**
|
||||
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
|
||||
*/
|
||||
const test = testWithAssets.extend<{ chartListPage: ChartListPage }>({
|
||||
chartListPage: async ({ page }, use) => {
|
||||
const chartListPage = new ChartListPage(page);
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
await use(chartListPage);
|
||||
},
|
||||
});
|
||||
|
||||
test('should delete a chart with confirmation', async ({
|
||||
page,
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Create throwaway chart for deletion
|
||||
const { id: chartId, name: chartName } = await createTestChart(
|
||||
page,
|
||||
testAssets,
|
||||
test.info(),
|
||||
{ prefix: 'test_delete' },
|
||||
);
|
||||
|
||||
// Refresh to see the new chart (created via API)
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
|
||||
// Click delete action button
|
||||
await chartListPage.clickDeleteAction(chartName);
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify chart is removed from list
|
||||
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
|
||||
|
||||
// Backend verification: API returns 404
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await apiGetChart(page, chartId, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
return response.status();
|
||||
},
|
||||
{ timeout: 10000, message: `Chart ${chartId} should return 404` },
|
||||
)
|
||||
.toBe(404);
|
||||
});
|
||||
|
||||
test('should edit chart name via properties modal', async ({
|
||||
page,
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Create throwaway chart for editing
|
||||
const { id: chartId, name: chartName } = await createTestChart(
|
||||
page,
|
||||
testAssets,
|
||||
test.info(),
|
||||
{ prefix: 'test_edit' },
|
||||
);
|
||||
|
||||
// Refresh to see the new chart
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
|
||||
// Click edit action to open properties modal
|
||||
await chartListPage.clickEditAction(chartName);
|
||||
|
||||
// Wait for properties modal to be ready
|
||||
const propertiesModal = new ChartPropertiesModal(page);
|
||||
await propertiesModal.waitForReady();
|
||||
|
||||
// Edit the chart name
|
||||
const newName = `renamed_${Date.now()}_${test.info().parallelIndex}`;
|
||||
await propertiesModal.fillName(newName);
|
||||
|
||||
// Set up response intercept for save
|
||||
const saveResponsePromise = waitForPut(page, `${ENDPOINTS.CHART}${chartId}`);
|
||||
|
||||
// Click Save button
|
||||
await propertiesModal.clickSave();
|
||||
|
||||
// Wait for save to complete and verify success
|
||||
expectStatusOneOf(await saveResponsePromise, [200, 201]);
|
||||
|
||||
// Modal should close
|
||||
await propertiesModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Backend verification: API returns updated name
|
||||
const response = await apiGetChart(page, chartId);
|
||||
const chart = (await response.json()).result;
|
||||
expect(chart.slice_name).toBe(newName);
|
||||
});
|
||||
|
||||
test('should export a chart as a zip file', async ({
|
||||
page,
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Create throwaway chart for export
|
||||
const { name: chartName } = await createTestChart(
|
||||
page,
|
||||
testAssets,
|
||||
test.info(),
|
||||
{ prefix: 'test_export' },
|
||||
);
|
||||
|
||||
// Refresh to see the new chart
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify chart is visible in list
|
||||
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
|
||||
// Click export action button
|
||||
await chartListPage.clickExportAction(chartName);
|
||||
|
||||
// Wait for export API response and validate zip contents
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
await expectValidExportZip(exportResponse, {
|
||||
resourceDir: 'charts',
|
||||
expectedNames: [chartName],
|
||||
});
|
||||
});
|
||||
|
||||
test('should bulk delete multiple charts', async ({
|
||||
page,
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
// Create 2 throwaway charts for bulk delete
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
createTestChart(page, testAssets, test.info(), {
|
||||
prefix: 'bulk_delete_1',
|
||||
}),
|
||||
createTestChart(page, testAssets, test.info(), {
|
||||
prefix: 'bulk_delete_2',
|
||||
}),
|
||||
]);
|
||||
|
||||
// Refresh to see new charts
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
|
||||
// Select both charts
|
||||
await chartListPage.selectChartCheckbox(chart1.name);
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Click bulk delete action
|
||||
await chartListPage.clickBulkAction('Delete');
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
const toast = new Toast(page);
|
||||
await expect(toast.getSuccess()).toBeVisible();
|
||||
|
||||
// Verify both charts are removed from list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
|
||||
|
||||
// Backend verification: Both return 404
|
||||
for (const chart of [chart1, chart2]) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await apiGetChart(page, chart.id, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
return response.status();
|
||||
},
|
||||
{ timeout: 10000, message: `Chart ${chart.id} should return 404` },
|
||||
)
|
||||
.toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
test('should bulk export multiple charts', async ({
|
||||
page,
|
||||
chartListPage,
|
||||
testAssets,
|
||||
}) => {
|
||||
// Create 2 throwaway charts for bulk export
|
||||
const [chart1, chart2] = await Promise.all([
|
||||
createTestChart(page, testAssets, test.info(), {
|
||||
prefix: 'bulk_export_1',
|
||||
}),
|
||||
createTestChart(page, testAssets, test.info(), {
|
||||
prefix: 'bulk_export_2',
|
||||
}),
|
||||
]);
|
||||
|
||||
// Refresh to see new charts
|
||||
await chartListPage.goto();
|
||||
await chartListPage.waitForTableLoad();
|
||||
|
||||
// Verify both charts are visible in list
|
||||
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
|
||||
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
|
||||
|
||||
// Enable bulk select mode
|
||||
await chartListPage.clickBulkSelectButton();
|
||||
|
||||
// Select both charts
|
||||
await chartListPage.selectChartCheckbox(chart1.name);
|
||||
await chartListPage.selectChartCheckbox(chart2.name);
|
||||
|
||||
// Set up API response intercept for export endpoint
|
||||
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
|
||||
|
||||
// Click bulk export action
|
||||
await chartListPage.clickBulkAction('Export');
|
||||
|
||||
// Wait for export API response and validate zip contains both charts
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
await expectValidExportZip(exportResponse, {
|
||||
resourceDir: 'charts',
|
||||
minCount: 2,
|
||||
expectedNames: [chart1.name, chart2.name],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
import type { TestAssets } from '../../../helpers/fixtures/testAssets';
|
||||
import { apiPostChart } from '../../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../../helpers/api/dataset';
|
||||
|
||||
interface TestChartResult {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateTestChartOptions {
|
||||
/** Prefix for generated name (default: 'test_chart') */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test chart via the API for E2E testing.
|
||||
* Uses the members_channels_2 dataset (loaded via --load-examples).
|
||||
*
|
||||
* @example
|
||||
* const { id, name } = await createTestChart(page, testAssets, test.info());
|
||||
*
|
||||
* @example
|
||||
* const { id, name } = await createTestChart(page, testAssets, test.info(), {
|
||||
* prefix: 'test_delete',
|
||||
* });
|
||||
*/
|
||||
export async function createTestChart(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
options?: CreateTestChartOptions,
|
||||
): Promise<TestChartResult> {
|
||||
const prefix = options?.prefix ?? 'test_chart';
|
||||
const name = `${prefix}_${Date.now()}_${testInfo.parallelIndex}`;
|
||||
|
||||
// Look up the members_channels_2 dataset for chart creation
|
||||
const dataset = await getDatasetByName(page, 'members_channels_2');
|
||||
if (!dataset) {
|
||||
throw new Error(
|
||||
'members_channels_2 dataset not found — run Superset with --load-examples',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await apiPostChart(page, {
|
||||
slice_name: name,
|
||||
datasource_id: dataset.id,
|
||||
datasource_type: 'table',
|
||||
viz_type: 'table',
|
||||
params: '{}',
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(`Failed to create test chart: ${response.status()}`);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
// Handle both response shapes: { id } or { result: { id } }
|
||||
const id = body.result?.id ?? body.id;
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`Chart creation returned no id. Response: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
testAssets.trackChart(id);
|
||||
|
||||
return { id, name };
|
||||
}
|
||||
@@ -21,9 +21,7 @@ import {
|
||||
test as testWithAssets,
|
||||
expect,
|
||||
} from '../../../helpers/fixtures/testAssets';
|
||||
import type { Response } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import * as unzipper from 'unzipper';
|
||||
import { DatasetListPage } from '../../../pages/DatasetListPage';
|
||||
import { ExplorePage } from '../../../pages/ExplorePage';
|
||||
import { ConfirmDialog } from '../../../components/modals/ConfirmDialog';
|
||||
@@ -45,7 +43,10 @@ import {
|
||||
waitForPost,
|
||||
waitForPut,
|
||||
} from '../../../helpers/api/intercepts';
|
||||
import { expectStatusOneOf } from '../../../helpers/api/assertions';
|
||||
import {
|
||||
expectStatusOneOf,
|
||||
expectValidExportZip,
|
||||
} from '../../../helpers/api/assertions';
|
||||
import { TIMEOUT } from '../../../utils/constants';
|
||||
|
||||
/**
|
||||
@@ -60,40 +61,6 @@ const test = testWithAssets.extend<{ datasetListPage: DatasetListPage }>({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to validate an export zip response.
|
||||
* Verifies headers, parses zip contents, and validates expected structure.
|
||||
*/
|
||||
async function expectValidExportZip(
|
||||
response: Response,
|
||||
options: { minDatasetCount?: number; checkContentDisposition?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { minDatasetCount = 1, checkContentDisposition = false } = options;
|
||||
|
||||
// Verify headers
|
||||
expect(response.headers()['content-type']).toContain('application/zip');
|
||||
if (checkContentDisposition) {
|
||||
expect(response.headers()['content-disposition']).toMatch(
|
||||
/filename=.*dataset_export.*\.zip/,
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate zip contents
|
||||
const body = await response.body();
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
|
||||
const entries: string[] = [];
|
||||
const directory = await unzipper.Open.buffer(body);
|
||||
directory.files.forEach(file => entries.push(file.path));
|
||||
|
||||
// Validate structure
|
||||
const datasetYamlFiles = entries.filter(
|
||||
entry => entry.includes('datasets/') && entry.endsWith('.yaml'),
|
||||
);
|
||||
expect(datasetYamlFiles.length).toBeGreaterThanOrEqual(minDatasetCount);
|
||||
expect(entries.some(entry => entry.endsWith('metadata.yaml'))).toBe(true);
|
||||
}
|
||||
|
||||
test('should navigate to Explore when dataset name is clicked', async ({
|
||||
page,
|
||||
datasetListPage,
|
||||
@@ -286,7 +253,10 @@ test('should export a dataset as a zip file', async ({
|
||||
|
||||
// Wait for export API response and validate zip contents
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
await expectValidExportZip(exportResponse, { checkContentDisposition: true });
|
||||
await expectValidExportZip(exportResponse, {
|
||||
resourceDir: 'datasets',
|
||||
contentDispositionPattern: /filename=.*dataset_export.*\.zip/,
|
||||
});
|
||||
});
|
||||
|
||||
test('should export multiple datasets via bulk select action', async ({
|
||||
@@ -327,7 +297,10 @@ test('should export multiple datasets via bulk select action', async ({
|
||||
|
||||
// Wait for export API response and validate zip contains multiple datasets
|
||||
const exportResponse = expectStatusOneOf(await exportResponsePromise, [200]);
|
||||
await expectValidExportZip(exportResponse, { minDatasetCount: 2 });
|
||||
await expectValidExportZip(exportResponse, {
|
||||
resourceDir: 'datasets',
|
||||
minCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('should edit dataset name via modal', async ({
|
||||
|
||||
@@ -46,7 +46,7 @@ const Calendar = ({ className, ...otherProps }: CalendarWrapperProps) => {
|
||||
line-height: 1;
|
||||
padding: ${theme.sizeUnit * 3}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
color: ${theme.colorTextLightSolid};
|
||||
color: ${theme.colorTextBase};
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-scale": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -233,7 +233,7 @@ class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps>
|
||||
) {
|
||||
ctx.beginPath();
|
||||
if (location.properties.cluster) {
|
||||
let clusterLabel = clusterLabelMap[i];
|
||||
const clusterLabel = clusterLabelMap[i];
|
||||
// Validate clusterLabel is a finite number before using it for radius calculation
|
||||
const numericLabel = Number(clusterLabel);
|
||||
const safeNumericLabel = Number.isFinite(numericLabel)
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"d3": "^3.5.17",
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"datamaps": "^0.5.9",
|
||||
"datamaps": "^0.5.10",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"bootstrap-slider": "^11.0.2",
|
||||
"d3-array": "^3.2.4",
|
||||
"d3-color": "^1.4.1",
|
||||
"d3-scale": "^3.0.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.17.23",
|
||||
"mousetrap": "^1.6.5",
|
||||
|
||||
@@ -606,7 +606,7 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
{
|
||||
...queryObject,
|
||||
time_offsets: [],
|
||||
row_limit: Number(formData?.row_limit) ?? 0,
|
||||
row_limit: Number(formData?.row_limit ?? 0),
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
is_rowcount: true,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/geojson": "^7946.0.10",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"geojson": "^0.5.0",
|
||||
"lodash": "^4.17.23"
|
||||
},
|
||||
|
||||
@@ -360,7 +360,7 @@ export default function transformProps(
|
||||
series.push(
|
||||
transformFormulaAnnotation(
|
||||
layer,
|
||||
data1,
|
||||
rebasedDataA as TimeseriesDataRecord[],
|
||||
xAxisLabel,
|
||||
xAxisType,
|
||||
colorScale,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
isTimeseriesAnnotationLayer,
|
||||
resolveAutoCurrency,
|
||||
TimeseriesChartDataResponseResult,
|
||||
TimeseriesDataRecord,
|
||||
NumberFormats,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/api/core';
|
||||
@@ -463,7 +464,7 @@ export default function transformProps(
|
||||
series.push(
|
||||
transformFormulaAnnotation(
|
||||
layer,
|
||||
data,
|
||||
rebasedData as TimeseriesDataRecord[],
|
||||
xAxisLabel,
|
||||
xAxisType,
|
||||
colorScale,
|
||||
|
||||
@@ -472,66 +472,85 @@ export function transformIntervalAnnotation(
|
||||
): SeriesOption[] {
|
||||
const series: SeriesOption[] = [];
|
||||
const annotations = extractRecordAnnotations(layer, annotationData);
|
||||
if (annotations.length === 0) {
|
||||
return series;
|
||||
}
|
||||
|
||||
const { name, color, opacity, showLabel } = layer;
|
||||
const isHorizontal = orientation === OrientationType.Horizontal;
|
||||
|
||||
const intervalsByStartTime = new Map<string, string[]>();
|
||||
annotations.forEach(annotation => {
|
||||
const { name, color, opacity, showLabel } = layer;
|
||||
const { descriptions, intervalEnd, time, title } = annotation;
|
||||
const { descriptions, time = '', title } = annotation;
|
||||
const label = formatAnnotationLabel(name, title, descriptions);
|
||||
const isHorizontal = orientation === OrientationType.Horizontal;
|
||||
const intervalData: (
|
||||
| MarkArea1DDataItemOption
|
||||
| MarkArea2DDataItemOption
|
||||
)[] = [
|
||||
[
|
||||
{
|
||||
name: label,
|
||||
...(isHorizontal ? { yAxis: time } : { xAxis: time }),
|
||||
},
|
||||
isHorizontal ? { yAxis: intervalEnd } : { xAxis: intervalEnd },
|
||||
],
|
||||
const existing = intervalsByStartTime.get(time);
|
||||
if (existing) {
|
||||
existing.push(label);
|
||||
} else {
|
||||
intervalsByStartTime.set(time, [label]);
|
||||
}
|
||||
});
|
||||
|
||||
const allIntervalData: (
|
||||
| MarkArea1DDataItemOption
|
||||
| MarkArea2DDataItemOption
|
||||
)[] = annotations.map(annotation => {
|
||||
const { intervalEnd, time = '' } = annotation;
|
||||
const combinedLabel = (intervalsByStartTime.get(time) || []).join('\n');
|
||||
return [
|
||||
{
|
||||
name: combinedLabel,
|
||||
...(isHorizontal ? { yAxis: time } : { xAxis: time }),
|
||||
},
|
||||
isHorizontal ? { yAxis: intervalEnd } : { xAxis: intervalEnd },
|
||||
];
|
||||
const intervalLabel: SeriesLabelOption = showLabel
|
||||
? {
|
||||
show: true,
|
||||
color: theme.colorTextLabel,
|
||||
});
|
||||
|
||||
const intervalLabel: SeriesLabelOption = showLabel
|
||||
? {
|
||||
show: true,
|
||||
color: theme.colorTextLabel,
|
||||
position: 'insideTop',
|
||||
verticalAlign: 'top',
|
||||
fontWeight: 'bold',
|
||||
// @ts-expect-error
|
||||
emphasis: {
|
||||
position: 'insideTop',
|
||||
verticalAlign: 'top',
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
}
|
||||
: {
|
||||
show: false,
|
||||
color: theme.colorTextLabel,
|
||||
emphasis: {
|
||||
fontWeight: 'bold',
|
||||
// @ts-expect-error
|
||||
emphasis: {
|
||||
position: 'insideTop',
|
||||
verticalAlign: 'top',
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
}
|
||||
: {
|
||||
show: false,
|
||||
color: theme.colorTextLabel,
|
||||
emphasis: {
|
||||
fontWeight: 'bold',
|
||||
show: true,
|
||||
position: 'insideTop',
|
||||
verticalAlign: 'top',
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
};
|
||||
series.push({
|
||||
id: `Interval - ${label}`,
|
||||
type: 'line',
|
||||
animation: false,
|
||||
markArea: {
|
||||
silent: false,
|
||||
itemStyle: {
|
||||
color: color || colorScale(name, sliceId),
|
||||
opacity: parseAnnotationOpacity(opacity || AnnotationOpacity.Medium),
|
||||
emphasis: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
} as ItemStyleOption,
|
||||
label: intervalLabel,
|
||||
data: intervalData,
|
||||
},
|
||||
});
|
||||
show: true,
|
||||
position: 'insideTop',
|
||||
verticalAlign: 'top',
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
};
|
||||
|
||||
// Push a single series with all intervals in the markArea data
|
||||
series.push({
|
||||
id: `Interval - ${name}`,
|
||||
type: 'line',
|
||||
animation: false,
|
||||
markArea: {
|
||||
silent: false,
|
||||
itemStyle: {
|
||||
color: color || colorScale(name, sliceId),
|
||||
opacity: parseAnnotationOpacity(opacity || AnnotationOpacity.Medium),
|
||||
emphasis: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
} as ItemStyleOption,
|
||||
label: intervalLabel,
|
||||
data: allIntervalData,
|
||||
},
|
||||
});
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
@@ -546,66 +565,82 @@ export function transformEventAnnotation(
|
||||
): SeriesOption[] {
|
||||
const series: SeriesOption[] = [];
|
||||
const annotations = extractRecordAnnotations(layer, annotationData);
|
||||
if (annotations.length === 0) {
|
||||
return series;
|
||||
}
|
||||
|
||||
const { name, color, opacity, style, width, showLabel } = layer;
|
||||
const isHorizontal = orientation === OrientationType.Horizontal;
|
||||
|
||||
const eventsByTime = new Map<string, { time: string; labels: string[] }>();
|
||||
annotations.forEach(annotation => {
|
||||
const { name, color, opacity, style, width, showLabel } = layer;
|
||||
const { descriptions, time, title } = annotation;
|
||||
const { descriptions, time = '', title } = annotation;
|
||||
const label = formatAnnotationLabel(name, title, descriptions);
|
||||
const isHorizontal = orientation === OrientationType.Horizontal;
|
||||
const eventData: MarkLine1DDataItemOption[] = [
|
||||
{
|
||||
name: label,
|
||||
...(isHorizontal ? { yAxis: time } : { xAxis: time }),
|
||||
},
|
||||
];
|
||||
const existing = eventsByTime.get(time);
|
||||
|
||||
const lineStyle: LineStyleOption & DefaultStatesMixin['emphasis'] = {
|
||||
width,
|
||||
type: style as ZRLineType,
|
||||
color: color || colorScale(name, sliceId),
|
||||
opacity: parseAnnotationOpacity(opacity),
|
||||
emphasis: {
|
||||
width: width ? width + 1 : width,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const eventLabel: SeriesLineLabelOption = showLabel
|
||||
? {
|
||||
show: true,
|
||||
color: theme.colorTextLabel,
|
||||
position: 'insideEndTop',
|
||||
fontWeight: 'bold',
|
||||
formatter: (params: CallbackDataParams) => params.name,
|
||||
// @ts-expect-error
|
||||
emphasis: {
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
}
|
||||
: {
|
||||
show: false,
|
||||
color: theme.colorTextLabel,
|
||||
position: 'insideEndTop',
|
||||
emphasis: {
|
||||
formatter: (params: CallbackDataParams) => params.name,
|
||||
fontWeight: 'bold',
|
||||
show: true,
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
};
|
||||
|
||||
series.push({
|
||||
id: `Event - ${label}`,
|
||||
type: 'line',
|
||||
animation: false,
|
||||
markLine: {
|
||||
silent: false,
|
||||
symbol: 'none',
|
||||
lineStyle,
|
||||
label: eventLabel,
|
||||
data: eventData,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
existing.labels.push(label);
|
||||
} else {
|
||||
eventsByTime.set(time, { time, labels: [label] });
|
||||
}
|
||||
});
|
||||
|
||||
const allEventData: MarkLine1DDataItemOption[] = Array.from(
|
||||
eventsByTime.values(),
|
||||
).map(({ time, labels }) => ({
|
||||
name: labels.join('\n'),
|
||||
...(isHorizontal ? { yAxis: time } : { xAxis: time }),
|
||||
}));
|
||||
|
||||
const lineStyle: LineStyleOption & DefaultStatesMixin['emphasis'] = {
|
||||
width,
|
||||
type: style as ZRLineType,
|
||||
color: color || colorScale(name, sliceId),
|
||||
opacity: parseAnnotationOpacity(opacity),
|
||||
emphasis: {
|
||||
width: width ? width + 1 : width,
|
||||
opacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const eventLabel: SeriesLineLabelOption = showLabel
|
||||
? {
|
||||
show: true,
|
||||
color: theme.colorTextLabel,
|
||||
position: 'insideEndTop',
|
||||
fontWeight: 'bold',
|
||||
formatter: (params: CallbackDataParams) => params.name,
|
||||
// @ts-expect-error
|
||||
emphasis: {
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
}
|
||||
: {
|
||||
show: false,
|
||||
color: theme.colorTextLabel,
|
||||
position: 'insideEndTop',
|
||||
emphasis: {
|
||||
formatter: (params: CallbackDataParams) => params.name,
|
||||
fontWeight: 'bold',
|
||||
show: true,
|
||||
backgroundColor: theme.colorPrimaryBgHover,
|
||||
},
|
||||
};
|
||||
|
||||
// Push a single series with all events in the markLine data
|
||||
series.push({
|
||||
id: `Event - ${name}`,
|
||||
type: 'line',
|
||||
animation: false,
|
||||
markLine: {
|
||||
silent: false,
|
||||
symbol: 'none',
|
||||
lineStyle,
|
||||
label: eventLabel,
|
||||
data: allEventData,
|
||||
},
|
||||
});
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { HandlerFunction, JsonValue } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import {
|
||||
RadioButtonOption,
|
||||
sharedControlComponents,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { sharedControlComponents } from '@superset-ui/chart-controls';
|
||||
import { AreaChartStackControlOptions } from '../constants';
|
||||
|
||||
const { RadioButtonControl } = sharedControlComponents;
|
||||
@@ -60,7 +57,7 @@ export function useExtraControl<
|
||||
}, [area]);
|
||||
|
||||
const extraControlsHandler = useCallback(
|
||||
(value: RadioButtonOption[0]) => {
|
||||
(value: JsonValue) => {
|
||||
if (area) {
|
||||
if (setControlValue) {
|
||||
setControlValue('stack', value);
|
||||
|
||||
@@ -196,7 +196,9 @@ describe('BigNumberWithTrendline', () => {
|
||||
showXAxis: true,
|
||||
},
|
||||
});
|
||||
expect((transformed.echartOptions?.xAxis as any).show).toBe(true);
|
||||
expect((transformed.echartOptions!.xAxis as { show: boolean }).show).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('should not show X axis when showXAxis is false', () => {
|
||||
@@ -207,7 +209,9 @@ describe('BigNumberWithTrendline', () => {
|
||||
showXAxis: false,
|
||||
},
|
||||
});
|
||||
expect((transformed.echartOptions?.xAxis as any).show).toBe(false);
|
||||
expect((transformed.echartOptions!.xAxis as { show: boolean }).show).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('should show Y axis when showYAxis is true', () => {
|
||||
@@ -218,7 +222,9 @@ describe('BigNumberWithTrendline', () => {
|
||||
showYAxis: true,
|
||||
},
|
||||
});
|
||||
expect((transformed.echartOptions?.yAxis as any).show).toBe(true);
|
||||
expect((transformed.echartOptions!.yAxis as { show: boolean }).show).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('should not show Y axis when showYAxis is false', () => {
|
||||
@@ -229,7 +235,9 @@ describe('BigNumberWithTrendline', () => {
|
||||
showYAxis: false,
|
||||
},
|
||||
});
|
||||
expect((transformed.echartOptions?.yAxis as any).show).toBe(false);
|
||||
expect((transformed.echartOptions!.yAxis as { show: boolean }).show).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
AnnotationStyle,
|
||||
AnnotationType,
|
||||
DataRecord,
|
||||
FormulaAnnotationLayer,
|
||||
VizType,
|
||||
ChartDataResponseResult,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
LegendOrientation,
|
||||
LegendType,
|
||||
@@ -28,6 +34,48 @@ import {
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps,
|
||||
} from '../../src/MixedTimeseries/types';
|
||||
import { DEFAULT_FORM_DATA } from '../../src/MixedTimeseries/types';
|
||||
import { createEchartsTimeseriesTestChartProps } from '../helpers';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
|
||||
/**
|
||||
* Creates a partial ChartDataResponseResult for testing.
|
||||
* Only includes the fields needed for tests, with sensible defaults for required fields.
|
||||
*/
|
||||
function createTestQueryData(
|
||||
data: unknown[],
|
||||
overrides?: Partial<ChartDataResponseResult> & {
|
||||
label_map?: Record<string, string[]>;
|
||||
},
|
||||
): ChartDataResponseResult {
|
||||
return {
|
||||
annotation_data: null,
|
||||
cache_key: null,
|
||||
cache_timeout: null,
|
||||
cached_dttm: null,
|
||||
queried_dttm: null,
|
||||
data: data as DataRecord[],
|
||||
colnames: [],
|
||||
coltypes: [],
|
||||
error: null,
|
||||
is_cached: false,
|
||||
query: '',
|
||||
rowcount: data.length,
|
||||
sql_rowcount: data.length,
|
||||
stacktrace: null,
|
||||
status: 'success',
|
||||
from_dttm: null,
|
||||
to_dttm: null,
|
||||
label_map: {},
|
||||
...overrides,
|
||||
} as ChartDataResponseResult & { label_map?: Record<string, string[]> };
|
||||
}
|
||||
|
||||
/** Defaults for createEchartsTimeseriesTestChartProps in Mixed Timeseries tests. */
|
||||
const MIXED_TIMESERIES_CHART_PROPS_DEFAULTS = {
|
||||
defaultFormData: DEFAULT_FORM_DATA,
|
||||
defaultVizType: 'mixed_timeseries' as const,
|
||||
};
|
||||
|
||||
const formData: EchartsMixedTimeseriesFormData = {
|
||||
annotationLayers: [],
|
||||
@@ -85,49 +133,28 @@ const formData: EchartsMixedTimeseriesFormData = {
|
||||
legendSort: null,
|
||||
};
|
||||
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{ boy: 1, girl: 2, ds: 599616000000 },
|
||||
{ boy: 3, girl: 4, ds: 599916000000 },
|
||||
],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
boy: ['boy'],
|
||||
girl: ['girl'],
|
||||
},
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ boy: 1, girl: 2, ds: 599616000000 },
|
||||
{ boy: 3, girl: 4, ds: 599916000000 },
|
||||
],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
boy: ['boy'],
|
||||
girl: ['girl'],
|
||||
},
|
||||
},
|
||||
const defaultQueryRows = [
|
||||
{ boy: 1, girl: 2, ds: 599616000000 },
|
||||
{ boy: 3, girl: 4, ds: 599916000000 },
|
||||
];
|
||||
const defaultLabelMap = { ds: ['ds'], boy: ['boy'], girl: ['girl'] };
|
||||
|
||||
const queriesData: ChartDataResponseResult[] = [
|
||||
createTestQueryData(defaultQueryRows, { label_map: defaultLabelMap }),
|
||||
createTestQueryData(defaultQueryRows, { label_map: defaultLabelMap }),
|
||||
];
|
||||
|
||||
const chartPropsConfig = {
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
test('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: false,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, showQueryIdentifiers: false },
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
// Check that series IDs don't include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
@@ -160,15 +187,16 @@ test('should transform chart props for viz with showQueryIdentifiers=false', ()
|
||||
});
|
||||
|
||||
test('should transform chart props for viz with showQueryIdentifiers=true', () => {
|
||||
const chartPropsConfigWithIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: true,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, showQueryIdentifiers: true },
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
// Check that series IDs include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
@@ -202,22 +230,25 @@ test('should transform chart props for viz with showQueryIdentifiers=true', () =
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const getChartProps = (overrides = {}) =>
|
||||
new ChartProps({
|
||||
...chartPropsConfig,
|
||||
createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
...overrides,
|
||||
showQueryIdentifiers: true,
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
test('sort legend by data', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: null,
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsMixedTimeseriesProps,
|
||||
);
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num (Query A), girl',
|
||||
@@ -231,9 +262,7 @@ describe('legend sorting', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'asc',
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsMixedTimeseriesProps,
|
||||
);
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num (Query A), boy',
|
||||
@@ -247,9 +276,7 @@ describe('legend sorting', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'desc',
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsMixedTimeseriesProps,
|
||||
);
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num (Query B), girl',
|
||||
@@ -261,64 +288,148 @@ describe('legend sorting', () => {
|
||||
});
|
||||
|
||||
test('legend margin: top orientation sets grid.top correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 250,
|
||||
showLegend: true,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).top).toEqual(270);
|
||||
});
|
||||
|
||||
test('legend margin: bottom orientation sets grid.bottom correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 250,
|
||||
showLegend: true,
|
||||
legendOrientation: LegendOrientation.Bottom,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).bottom).toEqual(270);
|
||||
});
|
||||
|
||||
test('legend margin: left orientation sets grid.left correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 250,
|
||||
showLegend: true,
|
||||
legendOrientation: LegendOrientation.Left,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).left).toEqual(270);
|
||||
});
|
||||
|
||||
test('legend margin: right orientation sets grid.right correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 270,
|
||||
showLegend: true,
|
||||
legendOrientation: LegendOrientation.Right,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).right).toEqual(270);
|
||||
});
|
||||
|
||||
test('should add a formula annotation when X-axis column has dataset-level label', () => {
|
||||
const formula: FormulaAnnotationLayer = {
|
||||
name: 'My Formula',
|
||||
annotationType: AnnotationType.Formula,
|
||||
value: 'x*2',
|
||||
style: AnnotationStyle.Solid,
|
||||
show: true,
|
||||
showLabel: true,
|
||||
};
|
||||
const timeColumnName = 'ds';
|
||||
const timeColumnLabel = 'Time Label';
|
||||
const testData = [
|
||||
{
|
||||
[timeColumnLabel]: 599616000000,
|
||||
boy: 1,
|
||||
girl: 2,
|
||||
},
|
||||
{
|
||||
[timeColumnLabel]: 599916000000,
|
||||
boy: 3,
|
||||
girl: 4,
|
||||
},
|
||||
];
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [],
|
||||
formData: {
|
||||
...formData,
|
||||
x_axis: timeColumnName,
|
||||
annotationLayers: [formula],
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(testData, {
|
||||
label_map: {
|
||||
[timeColumnName]: [timeColumnLabel],
|
||||
boy: ['boy'],
|
||||
girl: ['girl'],
|
||||
},
|
||||
}),
|
||||
createTestQueryData(testData, {
|
||||
label_map: {
|
||||
[timeColumnName]: [timeColumnLabel],
|
||||
boy: ['boy'],
|
||||
girl: ['girl'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
datasource: {
|
||||
verboseMap: {
|
||||
[timeColumnName]: timeColumnLabel,
|
||||
},
|
||||
columnFormats: {},
|
||||
currencyFormats: {},
|
||||
},
|
||||
});
|
||||
const result = transformProps(chartProps);
|
||||
const formulaSeries = (
|
||||
result.echartOptions.series as SeriesOption[] | undefined
|
||||
)?.find((s: SeriesOption) => s.name === 'My Formula');
|
||||
expect(formulaSeries).toBeDefined();
|
||||
expect(formulaSeries?.data).toBeDefined();
|
||||
expect(Array.isArray(formulaSeries?.data)).toBe(true);
|
||||
expect((formulaSeries?.data as unknown[])?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
110
superset-frontend/plugins/plugin-chart-echarts/test/helpers.ts
Normal file
110
superset-frontend/plugins/plugin-chart-echarts/test/helpers.ts
Normal file
@@ -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 { ChartProps, ChartDataResponseResult } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
|
||||
/**
|
||||
* Datasource shape used by Echarts Timeseries and Mixed Timeseries chart props.
|
||||
*/
|
||||
export interface EchartsTimeseriesTestDatasource {
|
||||
verboseMap?: Record<string, string>;
|
||||
columnFormats?: Record<string, string>;
|
||||
currencyFormats?: Record<string, { symbol: string; symbolPosition: string }>;
|
||||
currencyCodeColumn?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_DATASOURCE: EchartsTimeseriesTestDatasource = {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyFormats: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Form data shape that at minimum has datasource and viz_type (used for merging).
|
||||
*/
|
||||
export interface EchartsTimeseriesTestFormDataBase {
|
||||
datasource?: string;
|
||||
viz_type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Config for creating Echarts Timeseries-style chart props in tests.
|
||||
* Shared by Timeseries and Mixed Timeseries transformProps tests.
|
||||
*/
|
||||
export interface CreateEchartsTimeseriesTestChartPropsConfig<TFormData> {
|
||||
defaultFormData: TFormData;
|
||||
defaultVizType: string;
|
||||
defaultQueriesData?: ChartDataResponseResult[];
|
||||
formData?: Partial<TFormData>;
|
||||
queriesData?: ChartDataResponseResult[];
|
||||
datasource?: EchartsTimeseriesTestDatasource;
|
||||
annotationData?: Record<string, unknown>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates chart props for Echarts Timeseries-style plugins in tests.
|
||||
* Merges partial formData with defaultFormData and builds a ChartProps-like object.
|
||||
* Use this to avoid duplicating createTestChartProps in Timeseries and Mixed Timeseries tests.
|
||||
*
|
||||
* @param config - defaultFormData, defaultVizType, defaultQueriesData, and optional overrides
|
||||
* @returns Chart props object typed as TProps (e.g. EchartsTimeseriesChartProps)
|
||||
*/
|
||||
export function createEchartsTimeseriesTestChartProps<
|
||||
TFormData extends EchartsTimeseriesTestFormDataBase,
|
||||
TProps,
|
||||
>(config: CreateEchartsTimeseriesTestChartPropsConfig<TFormData>): TProps {
|
||||
const {
|
||||
defaultFormData,
|
||||
defaultVizType,
|
||||
defaultQueriesData = [],
|
||||
formData: partialFormData = {},
|
||||
queriesData: customQueriesData,
|
||||
datasource: customDatasource,
|
||||
annotationData,
|
||||
width = 800,
|
||||
height = 600,
|
||||
} = config;
|
||||
|
||||
const partial = partialFormData as Partial<EchartsTimeseriesTestFormDataBase>;
|
||||
const fullFormData = {
|
||||
...defaultFormData,
|
||||
...partialFormData,
|
||||
datasource: partial.datasource ?? '3__table',
|
||||
viz_type: partial.viz_type ?? defaultVizType,
|
||||
} as TFormData;
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
formData: fullFormData,
|
||||
width,
|
||||
height,
|
||||
queriesData: customQueriesData ?? defaultQueriesData,
|
||||
theme: supersetTheme,
|
||||
datasource: customDatasource ?? { ...DEFAULT_DATASOURCE },
|
||||
...(annotationData !== undefined && { annotationData }),
|
||||
});
|
||||
|
||||
return {
|
||||
...chartProps,
|
||||
formData: fullFormData,
|
||||
queriesData: customQueriesData ?? defaultQueriesData,
|
||||
} as TProps;
|
||||
}
|
||||
@@ -127,57 +127,102 @@ const mockIntervalAnnotationData: AnnotationData = {
|
||||
|
||||
describe('transformIntervalAnnotation', () => {
|
||||
test('should transform data correctly', () => {
|
||||
expect(
|
||||
transformIntervalAnnotation(
|
||||
mockIntervalAnnotationLayer,
|
||||
mockData,
|
||||
mockIntervalAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
)
|
||||
.map(annotation => annotation.markArea)
|
||||
.map(markArea => markArea.data),
|
||||
).toEqual([
|
||||
const result = transformIntervalAnnotation(
|
||||
mockIntervalAnnotationLayer,
|
||||
mockData,
|
||||
mockIntervalAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
);
|
||||
|
||||
// Should return a single series with all intervals
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].markArea.data).toEqual([
|
||||
[
|
||||
[
|
||||
{ name: 'Interval annotation layer - Timeseries 1', xAxis: 10 },
|
||||
{ xAxis: 12 },
|
||||
],
|
||||
{ name: 'Interval annotation layer - Timeseries 1', xAxis: 10 },
|
||||
{ xAxis: 12 },
|
||||
],
|
||||
[
|
||||
[
|
||||
{ name: 'Interval annotation layer - Timeseries 2', xAxis: 13 },
|
||||
{ xAxis: 15 },
|
||||
],
|
||||
{ name: 'Interval annotation layer - Timeseries 2', xAxis: 13 },
|
||||
{ xAxis: 15 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should use yAxis for horizontal chart data', () => {
|
||||
expect(
|
||||
transformIntervalAnnotation(
|
||||
mockIntervalAnnotationLayer,
|
||||
mockData,
|
||||
mockIntervalAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
undefined,
|
||||
OrientationType.Horizontal,
|
||||
)
|
||||
.map(annotation => annotation.markArea)
|
||||
.map(markArea => markArea.data),
|
||||
).toEqual([
|
||||
[
|
||||
[
|
||||
{ name: 'Interval annotation layer - Timeseries 1', yAxis: 10 },
|
||||
{ yAxis: 12 },
|
||||
test('should combine labels for intervals with the same start date', () => {
|
||||
const duplicateStartDateData: AnnotationData = {
|
||||
'Interval annotation layer': {
|
||||
records: [
|
||||
{
|
||||
start_dttm: 10,
|
||||
end_dttm: 12,
|
||||
short_descr: 'Same start event 1',
|
||||
long_descr: '',
|
||||
json_metadata: '',
|
||||
},
|
||||
{
|
||||
start_dttm: 10,
|
||||
end_dttm: 15,
|
||||
short_descr: 'Same start event 2',
|
||||
long_descr: '',
|
||||
json_metadata: '',
|
||||
},
|
||||
{
|
||||
start_dttm: 10,
|
||||
end_dttm: 18,
|
||||
short_descr: 'Same start event 3',
|
||||
long_descr: '',
|
||||
json_metadata: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformIntervalAnnotation(
|
||||
mockIntervalAnnotationLayer,
|
||||
mockData,
|
||||
duplicateStartDateData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
);
|
||||
|
||||
// Should return a single series
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
// The markArea data should contain all 3 intervals
|
||||
expect(result[0].markArea.data).toHaveLength(3);
|
||||
|
||||
// All intervals with the same start time should have the combined label
|
||||
const combinedLabel =
|
||||
'Interval annotation layer - Same start event 1\nInterval annotation layer - Same start event 2\nInterval annotation layer - Same start event 3';
|
||||
expect(result[0].markArea.data).toEqual([
|
||||
[{ name: combinedLabel, xAxis: 10 }, { xAxis: 12 }],
|
||||
[{ name: combinedLabel, xAxis: 10 }, { xAxis: 15 }],
|
||||
[{ name: combinedLabel, xAxis: 10 }, { xAxis: 18 }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should use yAxis for horizontal chart data', () => {
|
||||
const result = transformIntervalAnnotation(
|
||||
mockIntervalAnnotationLayer,
|
||||
mockData,
|
||||
mockIntervalAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
undefined,
|
||||
OrientationType.Horizontal,
|
||||
);
|
||||
|
||||
// Should return a single series with all intervals
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].markArea.data).toEqual([
|
||||
[
|
||||
{ name: 'Interval annotation layer - Timeseries 1', yAxis: 10 },
|
||||
{ yAxis: 12 },
|
||||
],
|
||||
[
|
||||
[
|
||||
{ name: 'Interval annotation layer - Timeseries 2', yAxis: 13 },
|
||||
{ yAxis: 15 },
|
||||
],
|
||||
{ name: 'Interval annotation layer - Timeseries 2', yAxis: 13 },
|
||||
{ yAxis: 15 },
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -218,48 +263,96 @@ const mockEventAnnotationData: AnnotationData = {
|
||||
|
||||
describe('transformEventAnnotation', () => {
|
||||
test('should transform data correctly', () => {
|
||||
expect(
|
||||
transformEventAnnotation(
|
||||
mockEventAnnotationLayer,
|
||||
mockData,
|
||||
mockEventAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
)
|
||||
.map(annotation => annotation.markLine)
|
||||
.map(markLine => markLine.data),
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
name: 'Event annotation layer - Test annotation',
|
||||
xAxis: 10,
|
||||
},
|
||||
],
|
||||
[{ name: 'Event annotation layer - Test annotation 2', xAxis: 13 }],
|
||||
const result = transformEventAnnotation(
|
||||
mockEventAnnotationLayer,
|
||||
mockData,
|
||||
mockEventAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
);
|
||||
|
||||
// Should return a single series with all events
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].markLine.data).toEqual([
|
||||
{
|
||||
name: 'Event annotation layer - Test annotation',
|
||||
xAxis: 10,
|
||||
},
|
||||
{ name: 'Event annotation layer - Test annotation 2', xAxis: 13 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('should combine labels for events with the same start date', () => {
|
||||
const duplicateStartDateData: AnnotationData = {
|
||||
'Event annotation layer': {
|
||||
records: [
|
||||
{
|
||||
start_dttm: 10,
|
||||
end_dttm: 12,
|
||||
short_descr: 'Same date event 1',
|
||||
long_descr: '',
|
||||
json_metadata: '',
|
||||
},
|
||||
{
|
||||
start_dttm: 10,
|
||||
end_dttm: 15,
|
||||
short_descr: 'Same date event 2',
|
||||
long_descr: '',
|
||||
json_metadata: '',
|
||||
},
|
||||
{
|
||||
start_dttm: 10,
|
||||
end_dttm: 18,
|
||||
short_descr: 'Same date event 3',
|
||||
long_descr: '',
|
||||
json_metadata: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = transformEventAnnotation(
|
||||
mockEventAnnotationLayer,
|
||||
mockData,
|
||||
duplicateStartDateData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
);
|
||||
|
||||
// Should return a single series
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
// Events on the same date are grouped into a single entry with combined label
|
||||
expect(result[0].markLine.data).toHaveLength(1);
|
||||
|
||||
// The combined label should include all event names
|
||||
expect(result[0].markLine.data).toEqual([
|
||||
{
|
||||
name: 'Event annotation layer - Same date event 1\nEvent annotation layer - Same date event 2\nEvent annotation layer - Same date event 3',
|
||||
xAxis: 10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should use yAxis for horizontal chart data', () => {
|
||||
expect(
|
||||
transformEventAnnotation(
|
||||
mockEventAnnotationLayer,
|
||||
mockData,
|
||||
mockEventAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
undefined,
|
||||
OrientationType.Horizontal,
|
||||
)
|
||||
.map(annotation => annotation.markLine)
|
||||
.map(markLine => markLine.data),
|
||||
).toEqual([
|
||||
[
|
||||
{
|
||||
name: 'Event annotation layer - Test annotation',
|
||||
yAxis: 10,
|
||||
},
|
||||
],
|
||||
[{ name: 'Event annotation layer - Test annotation 2', yAxis: 13 }],
|
||||
const result = transformEventAnnotation(
|
||||
mockEventAnnotationLayer,
|
||||
mockData,
|
||||
mockEventAnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
supersetTheme,
|
||||
undefined,
|
||||
OrientationType.Horizontal,
|
||||
);
|
||||
|
||||
// Should return a single series with all events
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].markLine.data).toEqual([
|
||||
{
|
||||
name: 'Event annotation layer - Test annotation',
|
||||
yAxis: 10,
|
||||
},
|
||||
{ name: 'Event annotation layer - Test annotation 2', yAxis: 13 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
QueryFormMetric,
|
||||
SMART_DATE_ID,
|
||||
validateNonEmpty,
|
||||
QueryFormColumn,
|
||||
} from '@superset-ui/core';
|
||||
import { MetricsLayoutEnum } from '../types';
|
||||
|
||||
@@ -403,10 +404,21 @@ const config: ControlPanelConfig = {
|
||||
renderTrigger: true,
|
||||
label: t('Conditional formatting'),
|
||||
description: t('Apply conditional color formatting to metrics'),
|
||||
shouldMapStateToProps() {
|
||||
return true;
|
||||
},
|
||||
mapStateToProps(explore, _, chart) {
|
||||
const values =
|
||||
const metrics =
|
||||
(explore?.controls?.metrics?.value as QueryFormMetric[]) ??
|
||||
[];
|
||||
const columns =
|
||||
(explore?.controls?.groupbyColumns
|
||||
?.value as QueryFormColumn[]) ?? [];
|
||||
const rows =
|
||||
(explore?.controls?.groupbyRows
|
||||
?.value as QueryFormColumn[]) ?? [];
|
||||
const values = [...new Set([...metrics, ...columns, ...rows])];
|
||||
|
||||
const verboseMap = explore?.datasource?.hasOwnProperty(
|
||||
'verbose_map',
|
||||
)
|
||||
|
||||
@@ -174,6 +174,33 @@ function displayHeaderCell(
|
||||
);
|
||||
}
|
||||
|
||||
function getCellColor(
|
||||
keys: string[],
|
||||
aggValue: string | number | null,
|
||||
cellColorFormatters: Record<string, CellColorFormatter[]> | undefined,
|
||||
): { backgroundColor: string | undefined } {
|
||||
if (!cellColorFormatters) return { backgroundColor: undefined };
|
||||
|
||||
let backgroundColor: string | undefined;
|
||||
|
||||
for (const cellColorFormatter of Object.values(cellColorFormatters)) {
|
||||
if (!Array.isArray(cellColorFormatter)) continue;
|
||||
|
||||
for (const key of keys) {
|
||||
for (const formatter of cellColorFormatter) {
|
||||
if (formatter.column === key) {
|
||||
const result = formatter.getColorFromValue(aggValue);
|
||||
if (result) {
|
||||
backgroundColor = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { backgroundColor };
|
||||
}
|
||||
|
||||
interface HierarchicalNode {
|
||||
currentVal?: number;
|
||||
[key: string]: HierarchicalNode | number | undefined;
|
||||
@@ -717,6 +744,7 @@ export class TableRenderer extends Component<
|
||||
highlightHeaderCellsOnHover,
|
||||
omittedHighlightHeaderGroups = [],
|
||||
highlightedHeaderCells,
|
||||
cellColorFormatters,
|
||||
dateFormatters,
|
||||
} = this.props.tableOptions;
|
||||
|
||||
@@ -816,10 +844,17 @@ export class TableRenderer extends Component<
|
||||
};
|
||||
const headerCellFormattedValue =
|
||||
dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx];
|
||||
const { backgroundColor } = getCellColor(
|
||||
[attrName],
|
||||
headerCellFormattedValue,
|
||||
cellColorFormatters,
|
||||
);
|
||||
const style = { backgroundColor };
|
||||
attrValueCells.push(
|
||||
<th
|
||||
className={colLabelClass}
|
||||
key={`colKey-${flatColKey}`}
|
||||
style={style}
|
||||
colSpan={colSpan}
|
||||
rowSpan={rowSpan}
|
||||
role="columnheader button"
|
||||
@@ -1044,10 +1079,18 @@ export class TableRenderer extends Component<
|
||||
|
||||
const headerCellFormattedValue =
|
||||
dateFormatters?.[rowAttrs[i]]?.(r) ?? r;
|
||||
|
||||
const { backgroundColor } = getCellColor(
|
||||
[rowAttrs[i]],
|
||||
headerCellFormattedValue,
|
||||
cellColorFormatters,
|
||||
);
|
||||
const style = { backgroundColor };
|
||||
return (
|
||||
<th
|
||||
key={`rowKeyLabel-${i}`}
|
||||
className={valueCellClassName}
|
||||
style={style}
|
||||
rowSpan={rowSpan}
|
||||
colSpan={colSpan}
|
||||
role="columnheader button"
|
||||
@@ -1108,26 +1151,12 @@ export class TableRenderer extends Component<
|
||||
const aggValue = agg.value();
|
||||
|
||||
const keys = [...rowKey, ...colKey];
|
||||
let backgroundColor: string | undefined;
|
||||
if (cellColorFormatters) {
|
||||
Object.values(cellColorFormatters).forEach(cellColorFormatter => {
|
||||
if (Array.isArray(cellColorFormatter)) {
|
||||
keys.forEach(key => {
|
||||
if (backgroundColor) {
|
||||
return;
|
||||
}
|
||||
cellColorFormatter
|
||||
.filter(formatter => formatter.column === key)
|
||||
.forEach(formatter => {
|
||||
const formatterResult = formatter.getColorFromValue(aggValue);
|
||||
if (formatterResult) {
|
||||
backgroundColor = formatterResult;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { backgroundColor } = getCellColor(
|
||||
keys,
|
||||
aggValue,
|
||||
cellColorFormatters,
|
||||
);
|
||||
|
||||
const style = agg.isSubtotal
|
||||
? { fontWeight: 'bold' }
|
||||
|
||||
@@ -142,8 +142,8 @@ const naturalSort: SortFunction = (as, bs) => {
|
||||
}
|
||||
|
||||
// finally, "smart" string sorting per http://stackoverflow.com/a/4373421/112871
|
||||
let a = String(as);
|
||||
let b = String(bs);
|
||||
const a = String(as);
|
||||
const b = String(bs);
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -22,9 +22,52 @@ import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import transformProps from '../../src/plugin/transformProps';
|
||||
import { MetricsLayoutEnum } from '../../src/types';
|
||||
|
||||
describe('PivotTableChart transformProps', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const formData = {
|
||||
const setDataMask = jest.fn();
|
||||
const formData = {
|
||||
groupbyRows: ['row1', 'row2'],
|
||||
groupbyColumns: ['col1', 'col2'],
|
||||
metrics: ['metric1', 'metric2'],
|
||||
tableRenderer: 'Table With Subtotal',
|
||||
colOrder: 'key_a_to_z',
|
||||
rowOrder: 'key_a_to_z',
|
||||
aggregateFunction: 'Sum',
|
||||
transposePivot: true,
|
||||
combineMetric: true,
|
||||
rowSubtotalPosition: true,
|
||||
colSubtotalPosition: true,
|
||||
colTotals: true,
|
||||
rowTotals: true,
|
||||
valueFormat: 'SMART_NUMBER',
|
||||
metricsLayout: MetricsLayoutEnum.COLUMNS,
|
||||
viz_type: '',
|
||||
datasource: '',
|
||||
conditionalFormatting: [],
|
||||
dateFormat: '',
|
||||
legacy_order_by: 'count',
|
||||
order_desc: true,
|
||||
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
};
|
||||
const chartProps = new ChartProps<QueryFormData>({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
|
||||
colnames: ['name', 'sum__num', '__timestamp'],
|
||||
coltypes: [1, 0, 2],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: { verboseMap: {}, columnFormats: {} },
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
test('should transform chart props for viz', () => {
|
||||
expect(transformProps(chartProps)).toEqual({
|
||||
width: 800,
|
||||
height: 600,
|
||||
groupbyRows: ['row1', 'row2'],
|
||||
groupbyColumns: ['col1', 'col2'],
|
||||
metrics: ['metric1', 'metric2'],
|
||||
@@ -39,250 +82,256 @@ describe('PivotTableChart transformProps', () => {
|
||||
colTotals: true,
|
||||
rowTotals: true,
|
||||
valueFormat: 'SMART_NUMBER',
|
||||
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
|
||||
setDataMask,
|
||||
selectedFilters: {},
|
||||
verboseMap: {},
|
||||
metricsLayout: MetricsLayoutEnum.COLUMNS,
|
||||
viz_type: '',
|
||||
datasource: '',
|
||||
conditionalFormatting: [],
|
||||
dateFormat: '',
|
||||
legacy_order_by: 'count',
|
||||
order_desc: true,
|
||||
metricColorFormatters: [],
|
||||
dateFormatters: {},
|
||||
emitCrossFilters: false,
|
||||
columnFormats: {},
|
||||
currencyFormats: {},
|
||||
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
});
|
||||
});
|
||||
|
||||
test('should pass AUTO mode through for per-cell detection (single currency data)', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const chartProps = new ChartProps<QueryFormData>({
|
||||
formData,
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
|
||||
colnames: ['name', 'sum__num', '__timestamp'],
|
||||
coltypes: [1, 0, 2],
|
||||
data: [
|
||||
{ country: 'USA', currency: 'USD', revenue: 100 },
|
||||
{ country: 'Canada', currency: 'USD', revenue: 200 },
|
||||
{ country: 'Mexico', currency: 'usd', revenue: 150 },
|
||||
],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: { verboseMap: {}, columnFormats: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
test('should transform chart props for viz', () => {
|
||||
expect(transformProps(chartProps)).toEqual({
|
||||
width: 800,
|
||||
height: 600,
|
||||
groupbyRows: ['row1', 'row2'],
|
||||
groupbyColumns: ['col1', 'col2'],
|
||||
metrics: ['metric1', 'metric2'],
|
||||
tableRenderer: 'Table With Subtotal',
|
||||
colOrder: 'key_a_to_z',
|
||||
rowOrder: 'key_a_to_z',
|
||||
aggregateFunction: 'Sum',
|
||||
transposePivot: true,
|
||||
combineMetric: true,
|
||||
rowSubtotalPosition: true,
|
||||
colSubtotalPosition: true,
|
||||
colTotals: true,
|
||||
rowTotals: true,
|
||||
valueFormat: 'SMART_NUMBER',
|
||||
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
|
||||
setDataMask,
|
||||
selectedFilters: {},
|
||||
const result = transformProps(autoChartProps);
|
||||
// AUTO mode should be preserved for per-cell detection in PivotTableChart
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
// currencyCodeColumn should be passed through for per-cell detection
|
||||
expect(result.currencyCodeColumn).toBe('currency');
|
||||
});
|
||||
|
||||
test('should pass AUTO mode through for per-cell detection (mixed currency data)', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', currency: 'USD', revenue: 100 },
|
||||
{ country: 'UK', currency: 'GBP', revenue: 200 },
|
||||
{ country: 'France', currency: 'EUR', revenue: 150 },
|
||||
],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
metricsLayout: MetricsLayoutEnum.COLUMNS,
|
||||
metricColorFormatters: [],
|
||||
dateFormatters: {},
|
||||
emitCrossFilters: false,
|
||||
columnFormats: {},
|
||||
currencyFormats: {},
|
||||
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
||||
});
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
describe('Per-cell currency detection (AUTO mode passes through)', () => {
|
||||
test('should pass AUTO mode through for per-cell detection (single currency data)', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', currency: 'USD', revenue: 100 },
|
||||
{ country: 'Canada', currency: 'USD', revenue: 200 },
|
||||
{ country: 'Mexico', currency: 'usd', revenue: 150 },
|
||||
],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
const result = transformProps(autoChartProps);
|
||||
// AUTO mode should be preserved - per-cell detection happens in PivotTableChart
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
expect(result.currencyCodeColumn).toBe('currency');
|
||||
});
|
||||
|
||||
test('should pass AUTO mode through when no currency column is defined', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', revenue: 100 },
|
||||
{ country: 'UK', revenue: 200 },
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
colnames: ['country', 'revenue'],
|
||||
coltypes: [1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
// No currencyCodeColumn defined
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const result = transformProps(autoChartProps);
|
||||
// AUTO mode should be preserved for per-cell detection in PivotTableChart
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
// currencyCodeColumn should be passed through for per-cell detection
|
||||
expect(result.currencyCodeColumn).toBe('currency');
|
||||
});
|
||||
const result = transformProps(autoChartProps);
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
// currencyCodeColumn should be undefined when not configured
|
||||
expect(result.currencyCodeColumn).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should pass AUTO mode through for per-cell detection (mixed currency data)', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', currency: 'USD', revenue: 100 },
|
||||
{ country: 'UK', currency: 'GBP', revenue: 200 },
|
||||
{ country: 'France', currency: 'EUR', revenue: 150 },
|
||||
],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
test('should handle empty data gracefully in AUTO mode', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const result = transformProps(autoChartProps);
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
expect(result.currencyCodeColumn).toBe('currency');
|
||||
});
|
||||
|
||||
test('should preserve static currency format when not using AUTO mode', () => {
|
||||
const staticFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'EUR', symbolPosition: 'suffix' },
|
||||
};
|
||||
const staticChartProps = new ChartProps<QueryFormData>({
|
||||
formData: staticFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', currency: 'USD', revenue: 100 },
|
||||
{ country: 'UK', currency: 'GBP', revenue: 200 },
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const result = transformProps(autoChartProps);
|
||||
// AUTO mode should be preserved - per-cell detection happens in PivotTableChart
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
expect(result.currencyCodeColumn).toBe('currency');
|
||||
});
|
||||
|
||||
test('should pass AUTO mode through when no currency column is defined', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', revenue: 100 },
|
||||
{ country: 'UK', revenue: 200 },
|
||||
],
|
||||
colnames: ['country', 'revenue'],
|
||||
coltypes: [1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
// No currencyCodeColumn defined
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const result = transformProps(autoChartProps);
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
// currencyCodeColumn should be undefined when not configured
|
||||
expect(result.currencyCodeColumn).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should handle empty data gracefully in AUTO mode', () => {
|
||||
const autoFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
|
||||
};
|
||||
const autoChartProps = new ChartProps<QueryFormData>({
|
||||
formData: autoFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const result = transformProps(autoChartProps);
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'AUTO',
|
||||
symbolPosition: 'prefix',
|
||||
});
|
||||
expect(result.currencyCodeColumn).toBe('currency');
|
||||
});
|
||||
|
||||
test('should preserve static currency format when not using AUTO mode', () => {
|
||||
const staticFormData = {
|
||||
...formData,
|
||||
currencyFormat: { symbol: 'EUR', symbolPosition: 'suffix' },
|
||||
};
|
||||
const staticChartProps = new ChartProps<QueryFormData>({
|
||||
formData: staticFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', currency: 'USD', revenue: 100 },
|
||||
{ country: 'UK', currency: 'GBP', revenue: 200 },
|
||||
],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const result = transformProps(staticChartProps);
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'EUR',
|
||||
symbolPosition: 'suffix',
|
||||
});
|
||||
});
|
||||
const result = transformProps(staticChartProps);
|
||||
expect(result.currencyFormat).toEqual({
|
||||
symbol: 'EUR',
|
||||
symbolPosition: 'suffix',
|
||||
});
|
||||
});
|
||||
|
||||
test('should map conditional formatting rules to metricColorFormatters with correct colors', () => {
|
||||
const formattingFormData = {
|
||||
...formData,
|
||||
conditionalFormatting: [
|
||||
{
|
||||
colorScheme: '#ACE1C4',
|
||||
column: 'country',
|
||||
operator: '=',
|
||||
targetValue: 'country',
|
||||
},
|
||||
{
|
||||
colorScheme: '#5ac189',
|
||||
column: 'revenue',
|
||||
operator: '=',
|
||||
targetValue: 'revenue',
|
||||
},
|
||||
],
|
||||
};
|
||||
const formattingChartProps = new ChartProps<QueryFormData>({
|
||||
formData: formattingFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ country: 'USA', currency: 'USD', revenue: 100 },
|
||||
{ country: 'UK', currency: 'GBP', revenue: 200 },
|
||||
],
|
||||
colnames: ['country', 'currency', 'revenue'],
|
||||
coltypes: [1, 1, 0],
|
||||
},
|
||||
],
|
||||
hooks: { setDataMask },
|
||||
filterState: { selectedFilters: {} },
|
||||
datasource: {
|
||||
verboseMap: {},
|
||||
columnFormats: {},
|
||||
currencyCodeColumn: 'currency',
|
||||
},
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const result = transformProps(formattingChartProps);
|
||||
const column1Formatting = result.metricColorFormatters[0].column;
|
||||
const column2Formatting = result.metricColorFormatters[1].column;
|
||||
expect(
|
||||
result.metricColorFormatters[0].getColorFromValue(column1Formatting),
|
||||
).toEqual('#ACE1C4FF');
|
||||
expect(
|
||||
result.metricColorFormatters[1].getColorFromValue(column2Formatting),
|
||||
).toEqual('#5ac189FF');
|
||||
});
|
||||
|
||||
@@ -74,7 +74,11 @@ import {
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { isEmpty, debounce, isEqual } from 'lodash';
|
||||
import { ColorFormatters, ColorSchemeEnum } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
ColorFormatters,
|
||||
ObjectFormattingEnum,
|
||||
ColorSchemeEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import {
|
||||
DataColumnMeta,
|
||||
SearchOption,
|
||||
@@ -874,12 +878,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
isUsingTimeComparison &&
|
||||
Array.isArray(basicColorFormatters) &&
|
||||
basicColorFormatters.length > 0;
|
||||
const generalShowCellBars =
|
||||
config.showCellBars === undefined ? showCellBars : config.showCellBars;
|
||||
const valueRange =
|
||||
!hasBasicColorFormatters &&
|
||||
!hasColumnColorFormatters &&
|
||||
(config.showCellBars === undefined
|
||||
? showCellBars
|
||||
: config.showCellBars) &&
|
||||
generalShowCellBars &&
|
||||
(isMetric || isRawRecords || isPercentMetric) &&
|
||||
getValueRange(key, alignPositiveNegative);
|
||||
|
||||
@@ -914,6 +917,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
|
||||
let backgroundColor;
|
||||
let color;
|
||||
let backgroundColorCellBar;
|
||||
let valueRangeFlag = true;
|
||||
let arrow = '';
|
||||
const originKey = column.key.substring(column.label.length).trim();
|
||||
if (!hasColumnColorFormatters && hasBasicColorFormatters) {
|
||||
@@ -934,18 +939,43 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
formatter.getColorFromValue(valueToFormat);
|
||||
if (!formatterResult) return;
|
||||
|
||||
if (formatter.toTextColor) {
|
||||
if (
|
||||
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR
|
||||
) {
|
||||
color = formatterResult.slice(0, -2);
|
||||
} else if (
|
||||
formatter.objectFormatting === ObjectFormattingEnum.CELL_BAR
|
||||
) {
|
||||
if (generalShowCellBars)
|
||||
backgroundColorCellBar = formatterResult.slice(0, -2);
|
||||
} else {
|
||||
backgroundColor = formatterResult;
|
||||
valueRangeFlag = false;
|
||||
}
|
||||
};
|
||||
columnColorFormatters
|
||||
.filter(formatter => formatter.column === column.key)
|
||||
.forEach(formatter => applyFormatter(formatter, value));
|
||||
.filter(formatter => {
|
||||
if (formatter.columnFormatting) {
|
||||
return formatter.columnFormatting === column.key;
|
||||
}
|
||||
return formatter.column === column.key;
|
||||
})
|
||||
.forEach(formatter => {
|
||||
let valueToFormat;
|
||||
if (formatter.columnFormatting) {
|
||||
valueToFormat = row.original[formatter.column];
|
||||
} else {
|
||||
valueToFormat = value;
|
||||
}
|
||||
applyFormatter(formatter, valueToFormat);
|
||||
});
|
||||
|
||||
columnColorFormatters
|
||||
.filter(formatter => formatter.toAllRow)
|
||||
.filter(
|
||||
formatter =>
|
||||
formatter.columnFormatting ===
|
||||
ObjectFormattingEnum.ENTIRE_ROW,
|
||||
)
|
||||
.forEach(formatter =>
|
||||
applyFormatter(formatter, row.original[formatter.column]),
|
||||
);
|
||||
@@ -968,6 +998,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
text-align: ${sharedStyle.textAlign};
|
||||
white-space: ${value instanceof Date ? 'nowrap' : undefined};
|
||||
position: relative;
|
||||
font-weight: ${color
|
||||
? `${theme.fontWeightBold}`
|
||||
: `${theme.fontWeightNormal}`};
|
||||
background: ${backgroundColor || undefined};
|
||||
padding-left: ${column.isChildColumn
|
||||
? `${theme.sizeUnit * 5}px`
|
||||
@@ -981,6 +1014,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
top: 0;
|
||||
${valueRange &&
|
||||
typeof value === 'number' &&
|
||||
valueRangeFlag &&
|
||||
`
|
||||
width: ${`${cellWidth({
|
||||
value: value as number,
|
||||
@@ -992,11 +1026,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
valueRange,
|
||||
alignPositiveNegative,
|
||||
})}%`};
|
||||
background-color: ${cellBackground({
|
||||
value: value as number,
|
||||
colorPositiveNegative,
|
||||
theme,
|
||||
})};
|
||||
background-color: ${
|
||||
(backgroundColorCellBar && `${backgroundColorCellBar}99`) ||
|
||||
cellBackground({
|
||||
value: value as number,
|
||||
colorPositiveNegative,
|
||||
theme,
|
||||
})
|
||||
};
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
{
|
||||
...queryObject,
|
||||
time_offsets: [],
|
||||
row_limit: Number(formData?.row_limit) ?? 0,
|
||||
row_limit: Number(formData?.row_limit ?? 0),
|
||||
row_offset: 0,
|
||||
post_processing: [],
|
||||
is_rowcount: true,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
isRegularMetric,
|
||||
isPercentMetric,
|
||||
ConditionalFormattingConfig,
|
||||
ObjectFormattingEnum,
|
||||
ColorSchemeEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { t } from '@apache-superset/core';
|
||||
@@ -781,12 +782,16 @@ const config: ControlPanelConfig = {
|
||||
item.colorScheme &&
|
||||
!['Green', 'Red'].includes(item.colorScheme)
|
||||
) {
|
||||
if (!item.toAllRow || !item.toTextColor) {
|
||||
if (item.columnFormatting === undefined) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
array[index] = {
|
||||
...item,
|
||||
toAllRow: item.toAllRow ?? false,
|
||||
toTextColor: item.toTextColor ?? false,
|
||||
...(item.toTextColor === true && {
|
||||
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
|
||||
}),
|
||||
...(item.toAllRow === true && {
|
||||
columnFormatting: ObjectFormattingEnum.ENTIRE_ROW,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -795,6 +800,23 @@ const config: ControlPanelConfig = {
|
||||
}
|
||||
const { colnames, coltypes } =
|
||||
chart?.queriesResponse?.[0] ?? {};
|
||||
const allColumns =
|
||||
Array.isArray(colnames) && Array.isArray(coltypes)
|
||||
? [
|
||||
{
|
||||
value: ObjectFormattingEnum.ENTIRE_ROW,
|
||||
label: t('entire row'),
|
||||
dataType: GenericDataType.String,
|
||||
},
|
||||
...colnames.map((colname: string, index: number) => ({
|
||||
value: colname,
|
||||
label: Array.isArray(verboseMap)
|
||||
? colname
|
||||
: (verboseMap[colname] ?? colname),
|
||||
dataType: coltypes[index],
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
const numericColumns =
|
||||
Array.isArray(colnames) && Array.isArray(coltypes)
|
||||
? colnames.reduce((acc, colname, index) => {
|
||||
@@ -826,10 +848,7 @@ const config: ControlPanelConfig = {
|
||||
removeIrrelevantConditions: chartStatus === 'success',
|
||||
columnOptions,
|
||||
verboseMap,
|
||||
conditionalFormattingFlag: {
|
||||
toAllRowCheck: true,
|
||||
toColorTextCheck: true,
|
||||
},
|
||||
allColumns,
|
||||
extraColorChoices,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import { ObjectFormattingEnum } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@@ -614,9 +615,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
|
||||
'',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByText('N/A')).background).toBe(
|
||||
'rgba(172, 225, 196, 1)',
|
||||
);
|
||||
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
|
||||
});
|
||||
test('should display original label in grouped headers', () => {
|
||||
const props = transformProps(testData.comparison);
|
||||
@@ -1252,7 +1251,7 @@ describe('plugin-chart-table', () => {
|
||||
column: 'sum__num',
|
||||
operator: '>',
|
||||
targetValue: 2467,
|
||||
toAllRow: true,
|
||||
columnFormatting: ObjectFormattingEnum.ENTIRE_ROW,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1288,7 +1287,7 @@ describe('plugin-chart-table', () => {
|
||||
column: 'sum__num',
|
||||
operator: '>',
|
||||
targetValue: 2467,
|
||||
toTextColor: true,
|
||||
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1321,8 +1320,8 @@ describe('plugin-chart-table', () => {
|
||||
column: 'sum__num',
|
||||
operator: '>',
|
||||
targetValue: 2467,
|
||||
toAllRow: true,
|
||||
toTextColor: true,
|
||||
columnFormatting: ObjectFormattingEnum.ENTIRE_ROW,
|
||||
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"d3-scale": "^3.0.1"
|
||||
"d3-scale": "^4.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/chart-controls": "*",
|
||||
|
||||
@@ -531,6 +531,7 @@ const ResultSet = ({
|
||||
placement="left"
|
||||
>
|
||||
<Label
|
||||
monospace
|
||||
css={css`
|
||||
line-height: ${theme.fontSizeLG}px;
|
||||
`}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default class DeckglLayerVisibilityCustomizationPlugin extends ChartPlugi
|
||||
tags: [t('Deckgl'), t('Experimental')],
|
||||
thumbnail: '',
|
||||
enableNoResults: false,
|
||||
datasourceCount: 0,
|
||||
});
|
||||
|
||||
super({
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import rison from 'rison';
|
||||
import { PureComponent, useCallback, ReactNode } from 'react';
|
||||
import { PureComponent, useCallback, type ReactNode } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import type { JsonObject } from '@superset-ui/core';
|
||||
import type { SupersetTheme } from '@apache-superset/core/ui';
|
||||
@@ -77,6 +77,12 @@ import {
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { clearDatasetCache } from 'src/utils/cachedSupersetGet';
|
||||
import { makeUrl } from 'src/utils/pathUtils';
|
||||
import {
|
||||
OwnerSelectLabel,
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
OWNER_OPTION_FILTER_PROPS,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
import { DatabaseSelector } from '../../../DatabaseSelector';
|
||||
import CollectionTable from '../CollectionTable';
|
||||
import Fieldset from '../Fieldset';
|
||||
@@ -98,9 +104,11 @@ const extensionsRegistry = getExtensionsRegistry();
|
||||
interface Owner {
|
||||
id?: number;
|
||||
value?: number;
|
||||
label?: string;
|
||||
label?: ReactNode;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Currency {
|
||||
@@ -757,7 +765,12 @@ function OwnersSelector({
|
||||
.filter(item => item.extra.active)
|
||||
.map(item => ({
|
||||
value: item.value as number,
|
||||
label: item.text as string,
|
||||
label: OwnerSelectLabel({
|
||||
name: item.text as string,
|
||||
email: item.extra?.email as string | undefined,
|
||||
}),
|
||||
[OWNER_TEXT_LABEL_PROP]: item.text as string,
|
||||
[OWNER_EMAIL_PROP]: (item.extra?.email as string) ?? '',
|
||||
})),
|
||||
totalCount: response.json.count,
|
||||
}));
|
||||
@@ -775,6 +788,7 @@ function OwnersSelector({
|
||||
onChange={value => onChange(value as Owner[])}
|
||||
header={<FormLabel>{t('Owners')}</FormLabel>}
|
||||
allowClear
|
||||
optionFilterProps={OWNER_OPTION_FILTER_PROPS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -847,10 +861,20 @@ class DatasourceEditor extends PureComponent<
|
||||
this.state = {
|
||||
datasource: {
|
||||
...props.datasource,
|
||||
owners: props.datasource.owners.map(owner => ({
|
||||
value: owner.value || owner.id,
|
||||
label: owner.label || `${owner.first_name} ${owner.last_name}`,
|
||||
})),
|
||||
owners: props.datasource.owners.map(owner => {
|
||||
const ownerName =
|
||||
owner.label || `${owner.first_name} ${owner.last_name}`;
|
||||
return {
|
||||
value: owner.value || owner.id,
|
||||
label: OwnerSelectLabel({
|
||||
name: typeof ownerName === 'string' ? ownerName : '',
|
||||
email: owner.email,
|
||||
}),
|
||||
[OWNER_TEXT_LABEL_PROP]:
|
||||
typeof ownerName === 'string' ? ownerName : '',
|
||||
[OWNER_EMAIL_PROP]: owner.email ?? '',
|
||||
};
|
||||
}),
|
||||
metrics: props.datasource.metrics?.map(metric => {
|
||||
const {
|
||||
certified_by: certifiedByMetric,
|
||||
|
||||
@@ -35,6 +35,7 @@ interface SelectFilterProps extends BaseFilter {
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
name?: string;
|
||||
onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void;
|
||||
optionFilterProps?: string[];
|
||||
paginate?: boolean;
|
||||
selects: Filter['selects'];
|
||||
loading?: boolean;
|
||||
@@ -48,6 +49,7 @@ function SelectFilter(
|
||||
fetchSelects,
|
||||
initialValue,
|
||||
onSelect,
|
||||
optionFilterProps,
|
||||
selects = [],
|
||||
loading = false,
|
||||
dropdownStyle,
|
||||
@@ -58,7 +60,15 @@ function SelectFilter(
|
||||
|
||||
const onChange = (selected: SelectOption) => {
|
||||
onSelect(
|
||||
selected ? { label: selected.label, value: selected.value } : undefined,
|
||||
selected
|
||||
? {
|
||||
label:
|
||||
typeof selected.label === 'string'
|
||||
? selected.label
|
||||
: String(selected.value),
|
||||
value: selected.value,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setSelectedOption(selected);
|
||||
};
|
||||
@@ -108,6 +118,7 @@ function SelectFilter(
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
options={fetchAndFormatSelects}
|
||||
optionFilterProps={optionFilterProps}
|
||||
placeholder={placeholder}
|
||||
dropdownStyle={dropdownStyle}
|
||||
showSearch
|
||||
|
||||
@@ -72,6 +72,7 @@ function UIFilters(
|
||||
key,
|
||||
id,
|
||||
input,
|
||||
optionFilterProps,
|
||||
paginate,
|
||||
selects,
|
||||
toolTipDescription,
|
||||
@@ -109,6 +110,7 @@ function UIFilters(
|
||||
|
||||
updateFilterValue(index, option);
|
||||
}}
|
||||
optionFilterProps={optionFilterProps}
|
||||
paginate={paginate}
|
||||
selects={selects}
|
||||
loading={loading ?? false}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export interface SortColumn {
|
||||
id: string;
|
||||
@@ -24,8 +24,9 @@ export interface SortColumn {
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
value: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CardSortSelectOption {
|
||||
@@ -59,6 +60,7 @@ export interface ListViewFilter {
|
||||
page: number,
|
||||
pageSize: number,
|
||||
) => Promise<{ data: SelectOption[]; totalCount: number }>;
|
||||
optionFilterProps?: string[];
|
||||
paginate?: boolean;
|
||||
loading?: boolean;
|
||||
dateFilterValueType?: 'unix' | 'iso';
|
||||
@@ -81,7 +83,7 @@ export type InnerFilterValue =
|
||||
| undefined
|
||||
| string[]
|
||||
| number[]
|
||||
| { label: string; value: string | number }
|
||||
| { label: ReactNode; value: string | number }
|
||||
| [number | null, number | null];
|
||||
|
||||
export interface ListViewFilterValue {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function RowCountLabel(props: RowCountLabelProps) {
|
||||
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
|
||||
const formattedRowCount = getNumberFormatter()(rowcount);
|
||||
const labelText = (
|
||||
<Label type={type}>
|
||||
<Label type={type} monospace>
|
||||
{loading ? (
|
||||
t('Loading...')
|
||||
) : (
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
OwnerSelectLabel,
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
|
||||
/**
|
||||
* Hook for loading dashboard access options (owners and roles)
|
||||
@@ -38,10 +43,29 @@ export const useAccessOptions = () => {
|
||||
.filter((item: { extra: { active: boolean } }) =>
|
||||
item.extra.active !== undefined ? item.extra.active : true,
|
||||
)
|
||||
.map((item: { value: number; text: string }) => ({
|
||||
value: item.value,
|
||||
label: item.text,
|
||||
})),
|
||||
.map(
|
||||
(item: {
|
||||
value: number;
|
||||
text: string;
|
||||
extra: { email?: string };
|
||||
}) => {
|
||||
if (accessType === 'owners') {
|
||||
return {
|
||||
value: item.value,
|
||||
label: OwnerSelectLabel({
|
||||
name: item.text,
|
||||
email: item.extra?.email,
|
||||
}),
|
||||
[OWNER_TEXT_LABEL_PROP]: item.text,
|
||||
[OWNER_EMAIL_PROP]: item.extra?.email ?? '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: item.value,
|
||||
label: item.text,
|
||||
};
|
||||
},
|
||||
),
|
||||
totalCount: response.json.count,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -38,6 +38,10 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import {
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
import { fetchTags, OBJECT_TYPES } from 'src/features/tags/tags';
|
||||
import {
|
||||
applyColors,
|
||||
@@ -79,6 +83,7 @@ type Owners = {
|
||||
full_name?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
}[];
|
||||
type DashboardInfo = {
|
||||
id: number;
|
||||
@@ -240,10 +245,16 @@ const PropertiesModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnChangeOwners = (owners: { value: number; label: string }[]) => {
|
||||
const parsedOwners: Owners = ensureIsArray(owners).map(o => ({
|
||||
const handleOnChangeOwners = (
|
||||
owners: { value: number; label: string }[],
|
||||
options: Record<string, unknown>[],
|
||||
) => {
|
||||
const parsedOwners: Owners = ensureIsArray(owners).map((o, i) => ({
|
||||
id: o.value,
|
||||
full_name: o.label,
|
||||
full_name:
|
||||
(options?.[i]?.[OWNER_TEXT_LABEL_PROP] as string) ||
|
||||
(typeof o.label === 'string' ? o.label : ''),
|
||||
email: (options?.[i]?.[OWNER_EMAIL_PROP] as string) || '',
|
||||
}));
|
||||
setOwners(parsedOwners);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,12 @@ import { loadTags } from 'src/components/Tag/utils';
|
||||
import getOwnerName from 'src/utils/getOwnerName';
|
||||
import Owner from 'src/types/Owner';
|
||||
import { ModalFormField } from 'src/components/Modal';
|
||||
import {
|
||||
OwnerSelectLabel,
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
OWNER_OPTION_FILTER_PROPS,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
import { useAccessOptions } from '../hooks/useAccessOptions';
|
||||
|
||||
type Roles = { id: number; name: string }[];
|
||||
@@ -33,6 +39,7 @@ type Owners = {
|
||||
full_name?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
}[];
|
||||
|
||||
interface AccessSectionProps {
|
||||
@@ -40,7 +47,10 @@ interface AccessSectionProps {
|
||||
owners: Owners;
|
||||
roles: Roles;
|
||||
tags: TagType[];
|
||||
onChangeOwners: (owners: { value: number; label: string }[]) => void;
|
||||
onChangeOwners: (
|
||||
owners: { value: number; label: string }[],
|
||||
options: Record<string, unknown>[],
|
||||
) => void;
|
||||
onChangeRoles: (roles: { value: number; label: string }[]) => void;
|
||||
onChangeTags: (tags: { label: string; value: number }[]) => void;
|
||||
onClearTags: () => void;
|
||||
@@ -60,9 +70,14 @@ const AccessSection = ({
|
||||
|
||||
const ownersSelectValue = useMemo(
|
||||
() =>
|
||||
(owners || []).map((owner: Owner) => ({
|
||||
(owners || []).map((owner: Owner & { email?: string }) => ({
|
||||
value: owner.id,
|
||||
label: getOwnerName(owner),
|
||||
label: OwnerSelectLabel({
|
||||
name: getOwnerName(owner),
|
||||
email: owner.email,
|
||||
}),
|
||||
[OWNER_TEXT_LABEL_PROP]: getOwnerName(owner),
|
||||
[OWNER_EMAIL_PROP]: owner.email ?? '',
|
||||
})),
|
||||
[owners],
|
||||
);
|
||||
@@ -107,6 +122,7 @@ const AccessSection = ({
|
||||
value={ownersSelectValue}
|
||||
showSearch
|
||||
placeholder={t('Search owners')}
|
||||
optionFilterProps={OWNER_OPTION_FILTER_PROPS}
|
||||
/>
|
||||
</ModalFormField>
|
||||
{isFeatureEnabled(FeatureFlag.DashboardRbac) && (
|
||||
|
||||
@@ -221,7 +221,7 @@ test('should render a DeleteComponentButton in editMode', () => {
|
||||
|
||||
/* oxlint-disable-next-line jest/no-disabled-tests */
|
||||
test.skip('should render a BackgroundStyleDropdown when focused', () => {
|
||||
let { rerender } = setup({ component: rowWithoutChildren });
|
||||
const { rerender } = setup({ component: rowWithoutChildren });
|
||||
expect(screen.queryByTestId('background-style-dropdown')).toBeFalsy();
|
||||
|
||||
// we cannot set props on the Row because of the WithDragDropContext wrapper
|
||||
|
||||
@@ -210,10 +210,9 @@ export function useIsFilterInScope() {
|
||||
if (hasChartsInScope) {
|
||||
isChartInScope = filter.chartsInScope!.some((chartId: number) => {
|
||||
const tabParents = selectChartTabParents(chartId);
|
||||
// Note: every() returns true for empty arrays, so length check is unnecessary
|
||||
return (
|
||||
!tabParents ||
|
||||
tabParents.length === 0 ||
|
||||
tabParents.every(tab => activeTabs.includes(tab))
|
||||
!tabParents || tabParents.every(tab => activeTabs.includes(tab))
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -276,10 +275,9 @@ export function useIsCustomizationInScope() {
|
||||
customization.chartsInScope.length > 0 &&
|
||||
customization.chartsInScope.some((chartId: number) => {
|
||||
const tabParents = selectChartTabParents(chartId);
|
||||
// Note: every() returns true for empty arrays, so length check is unnecessary
|
||||
return (
|
||||
!tabParents ||
|
||||
tabParents.length === 0 ||
|
||||
tabParents.every(tab => activeTabs.includes(tab))
|
||||
!tabParents || tabParents.every(tab => activeTabs.includes(tab))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -113,8 +113,10 @@ const Styles = styled.div<{ showSplite: boolean }>`
|
||||
}
|
||||
|
||||
.gutter {
|
||||
border-top: 1px solid ${({ theme }) => theme.colorSplit};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colorSplit};
|
||||
border-top: 1px solid
|
||||
color-mix(in srgb, ${({ theme }) => theme.colorSplit}, black 15%);
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, ${({ theme }) => theme.colorSplit}, black 15%);
|
||||
width: ${({ theme }) => theme.sizeUnit * 9}px;
|
||||
margin: ${({ theme }) => theme.sizeUnit * GUTTER_SIZE_FACTOR}px auto;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ import {
|
||||
import Chart, { Slice } from 'src/types/Chart';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { type TagType } from 'src/components';
|
||||
import {
|
||||
OwnerSelectLabel,
|
||||
OWNER_TEXT_LABEL_PROP,
|
||||
OWNER_EMAIL_PROP,
|
||||
OWNER_OPTION_FILTER_PROPS,
|
||||
} from 'src/features/owners/OwnerSelectLabel';
|
||||
import { TagTypeEnum } from 'src/components/Tag/TagType';
|
||||
import { loadTags } from 'src/components/Tag/utils';
|
||||
import {
|
||||
@@ -153,6 +159,7 @@ function PropertiesModal({
|
||||
'owners.id',
|
||||
'owners.first_name',
|
||||
'owners.last_name',
|
||||
'owners.email',
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.type',
|
||||
@@ -164,10 +171,25 @@ function PropertiesModal({
|
||||
});
|
||||
const chart = response.json.result;
|
||||
setSelectedOwners(
|
||||
chart?.owners?.map((owner: any) => ({
|
||||
value: owner.id,
|
||||
label: `${owner.first_name} ${owner.last_name}`,
|
||||
})),
|
||||
chart?.owners?.map(
|
||||
(owner: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email?: string;
|
||||
}) => {
|
||||
const ownerName = `${owner.first_name} ${owner.last_name}`;
|
||||
return {
|
||||
value: owner.id,
|
||||
label: OwnerSelectLabel({
|
||||
name: ownerName,
|
||||
email: owner.email,
|
||||
}),
|
||||
[OWNER_TEXT_LABEL_PROP]: ownerName,
|
||||
[OWNER_EMAIL_PROP]: owner.email ?? '',
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
|
||||
const customTags = chart.tags?.filter(
|
||||
@@ -196,10 +218,21 @@ function PropertiesModal({
|
||||
}).then(response => ({
|
||||
data: response.json.result
|
||||
.filter((item: { extra: { active: boolean } }) => item.extra.active)
|
||||
.map((item: { value: number; text: string }) => ({
|
||||
value: item.value,
|
||||
label: item.text,
|
||||
})),
|
||||
.map(
|
||||
(item: {
|
||||
value: number;
|
||||
text: string;
|
||||
extra: { email?: string };
|
||||
}) => ({
|
||||
value: item.value,
|
||||
label: OwnerSelectLabel({
|
||||
name: item.text,
|
||||
email: item.extra?.email,
|
||||
}),
|
||||
[OWNER_TEXT_LABEL_PROP]: item.text,
|
||||
[OWNER_EMAIL_PROP]: item.extra?.email ?? '',
|
||||
}),
|
||||
),
|
||||
totalCount: response.json.count,
|
||||
}));
|
||||
},
|
||||
@@ -372,6 +405,7 @@ function PropertiesModal({
|
||||
options={loadOptions}
|
||||
disabled={!selectedOwners}
|
||||
allowClear
|
||||
optionFilterProps={OWNER_OPTION_FILTER_PROPS}
|
||||
/>
|
||||
</ModalFormField>
|
||||
{isFeatureEnabled(FeatureFlag.TaggingSystem) && (
|
||||
|
||||
@@ -74,7 +74,7 @@ const ConditionalFormattingControl = ({
|
||||
verboseMap,
|
||||
removeIrrelevantConditions,
|
||||
extraColorChoices,
|
||||
conditionalFormattingFlag,
|
||||
allColumns,
|
||||
...props
|
||||
}: ConditionalFormattingControlProps) => {
|
||||
const [conditionalFormattingConfigs, setConditionalFormattingConfigs] =
|
||||
@@ -159,6 +159,7 @@ const ConditionalFormattingControl = ({
|
||||
}
|
||||
destroyTooltipOnHide
|
||||
extraColorChoices={extraColorChoices}
|
||||
allColumns={allColumns}
|
||||
>
|
||||
<OptionControlContainer withCaret>
|
||||
<Label>{createLabel(config)}</Label>
|
||||
@@ -175,7 +176,7 @@ const ConditionalFormattingControl = ({
|
||||
onChange={onSave}
|
||||
destroyTooltipOnHide
|
||||
extraColorChoices={extraColorChoices}
|
||||
conditionalFormattingFlag={conditionalFormattingFlag}
|
||||
allColumns={allColumns}
|
||||
>
|
||||
<AddControlLabel>
|
||||
<Icons.PlusOutlined
|
||||
|
||||
@@ -28,7 +28,7 @@ export const FormattingPopover = ({
|
||||
config,
|
||||
children,
|
||||
extraColorChoices,
|
||||
conditionalFormattingFlag,
|
||||
allColumns,
|
||||
...props
|
||||
}: FormattingPopoverProps) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -50,7 +50,7 @@ export const FormattingPopover = ({
|
||||
config={config}
|
||||
columns={columns}
|
||||
extraColorChoices={extraColorChoices}
|
||||
conditionalFormattingFlag={conditionalFormattingFlag}
|
||||
allColumns={allColumns}
|
||||
/>
|
||||
}
|
||||
open={visible}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import { Comparator, ColorSchemeEnum } from '@superset-ui/chart-controls';
|
||||
import { GenericDataType } from '@apache-superset/core/api/core';
|
||||
import { FormattingPopoverContent } from './FormattingPopoverContent';
|
||||
import { ConditionalFormattingConfig } from './types';
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
@@ -44,6 +43,12 @@ const columnsBooleanType = [
|
||||
{ label: 'Column 2', value: 'column2', dataType: GenericDataType.Boolean },
|
||||
];
|
||||
|
||||
const mixColumns = [
|
||||
{ label: 'Name', value: 'name', dataType: GenericDataType.String },
|
||||
{ label: 'Sales', value: 'sales', dataType: GenericDataType.Numeric },
|
||||
{ label: 'Active', value: 'active', dataType: GenericDataType.Boolean },
|
||||
];
|
||||
|
||||
const extraColorChoices = [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
@@ -55,11 +60,6 @@ const extraColorChoices = [
|
||||
},
|
||||
];
|
||||
|
||||
const config: ConditionalFormattingConfig = {
|
||||
toAllRow: true,
|
||||
toTextColor: true,
|
||||
};
|
||||
|
||||
test('renders FormattingPopoverContent component', () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
@@ -168,46 +168,15 @@ test('does not display the input fields when selected a boolean type operator',
|
||||
expect(await screen.queryByLabelText('Target value')).toBeNull();
|
||||
});
|
||||
|
||||
test('displays the toAllRow and toTextColor flags based on the selected numeric type operator', () => {
|
||||
test('displays Use gradient checkbox', () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columns}
|
||||
config={config}
|
||||
allColumns={columns}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('To entire row')).toBeInTheDocument();
|
||||
expect(screen.getByText('To text color')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays the toAllRow and toTextColor flags based on the selected string type operator', () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columnsStringType}
|
||||
config={config}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('To entire row')).toBeInTheDocument();
|
||||
expect(screen.getByText('To text color')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Not displays the toAllRow and toTextColor flags', () => {
|
||||
render(
|
||||
<FormattingPopoverContent onChange={mockOnChange} columns={columns} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('To entire row')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('To text color')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays Use gradient checkbox', () => {
|
||||
render(
|
||||
<FormattingPopoverContent onChange={mockOnChange} columns={columns} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Use gradient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -229,7 +198,11 @@ const findUseGradientCheckbox = (): HTMLInputElement => {
|
||||
|
||||
test('Use gradient checkbox defaults to checked', () => {
|
||||
render(
|
||||
<FormattingPopoverContent onChange={mockOnChange} columns={columns} />,
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columns}
|
||||
allColumns={columns}
|
||||
/>,
|
||||
);
|
||||
|
||||
const checkbox = findUseGradientCheckbox();
|
||||
@@ -238,7 +211,11 @@ test('Use gradient checkbox defaults to checked', () => {
|
||||
|
||||
test('Use gradient checkbox can be toggled', async () => {
|
||||
render(
|
||||
<FormattingPopoverContent onChange={mockOnChange} columns={columns} />,
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columns}
|
||||
allColumns={columns}
|
||||
/>,
|
||||
);
|
||||
|
||||
const checkbox = findUseGradientCheckbox();
|
||||
@@ -252,3 +229,81 @@ test('Use gradient checkbox can be toggled', async () => {
|
||||
fireEvent.click(checkbox);
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('The Use Gradient check box is not displayed for string and boolean and is displayed for numeric data types.', () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columnsStringType}
|
||||
allColumns={columnsStringType}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Use gradient')).not.toBeInTheDocument();
|
||||
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columnsBooleanType}
|
||||
allColumns={columnsBooleanType}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Use gradient')).not.toBeInTheDocument();
|
||||
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columns}
|
||||
allColumns={columns}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Use gradient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display formatting column and object fields when allColumns is provided and non-empty', async () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
columns={mixColumns}
|
||||
allColumns={mixColumns}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Formatting column')).toBeInTheDocument();
|
||||
expect(screen.getByText('Formatting object')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide formatting fields when allColumns is empty', async () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
columns={mixColumns}
|
||||
allColumns={[]}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Formatting column')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Formatting object')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide formatting fields when color scheme is Green', async () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
config={{ colorScheme: extraColorChoices[0].value }}
|
||||
columns={mixColumns}
|
||||
allColumns={mixColumns}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Formatting column')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Formatting object')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { t } from '@apache-superset/core';
|
||||
import { styled } from '@apache-superset/core/ui';
|
||||
import { GenericDataType } from '@apache-superset/core/api/core';
|
||||
import {
|
||||
Comparator,
|
||||
MultipleValueComparators,
|
||||
ObjectFormattingEnum,
|
||||
ColorSchemeEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import {
|
||||
@@ -37,10 +38,14 @@ import {
|
||||
Checkbox,
|
||||
type FormProps,
|
||||
} from '@superset-ui/core/components';
|
||||
import { ConditionalFormattingConfig, ColumnOption } from './types';
|
||||
import {
|
||||
ConditionalFormattingConfig,
|
||||
ConditionalFormattingFlag,
|
||||
} from './types';
|
||||
operatorOptions,
|
||||
stringOperatorOptions,
|
||||
booleanOperatorOptions,
|
||||
formattingOptions,
|
||||
colorSchemeOptions,
|
||||
} from './constants';
|
||||
|
||||
const FullWidthInputNumber = styled(InputNumber)`
|
||||
width: 100%;
|
||||
@@ -55,43 +60,6 @@ const JustifyEnd = styled.div`
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
// Use theme token names instead of hex values to support theme switching
|
||||
const colorSchemeOptions = () => [
|
||||
{ value: 'colorSuccess', label: t('success') },
|
||||
{ value: 'colorWarning', label: t('alert') },
|
||||
{ value: 'colorError', label: t('error') },
|
||||
];
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: Comparator.None, label: t('None') },
|
||||
{ value: Comparator.GreaterThan, label: '>' },
|
||||
{ value: Comparator.LessThan, label: '<' },
|
||||
{ value: Comparator.GreaterOrEqual, label: '≥' },
|
||||
{ value: Comparator.LessOrEqual, label: '≤' },
|
||||
{ value: Comparator.Equal, label: '=' },
|
||||
{ value: Comparator.NotEqual, label: '≠' },
|
||||
{ value: Comparator.Between, label: '< x <' },
|
||||
{ value: Comparator.BetweenOrEqual, label: '≤ x ≤' },
|
||||
{ value: Comparator.BetweenOrLeftEqual, label: '≤ x <' },
|
||||
{ value: Comparator.BetweenOrRightEqual, label: '< x ≤' },
|
||||
];
|
||||
|
||||
const stringOperatorOptions = [
|
||||
{ value: Comparator.None, label: t('None') },
|
||||
{ value: Comparator.Equal, label: '=' },
|
||||
{ value: Comparator.BeginsWith, label: t('begins with') },
|
||||
{ value: Comparator.EndsWith, label: t('ends with') },
|
||||
{ value: Comparator.Containing, label: t('containing') },
|
||||
{ value: Comparator.NotContaining, label: t('not containing') },
|
||||
];
|
||||
|
||||
const booleanOperatorOptions = [
|
||||
{ value: Comparator.IsNull, label: t('is null') },
|
||||
{ value: Comparator.IsTrue, label: t('is true') },
|
||||
{ value: Comparator.IsFalse, label: t('is false') },
|
||||
{ value: Comparator.IsNotNull, label: t('is not null') },
|
||||
];
|
||||
|
||||
const targetValueValidator =
|
||||
(
|
||||
compare: (targetValue: number, compareValue: number) => boolean,
|
||||
@@ -263,16 +231,13 @@ export const FormattingPopoverContent = ({
|
||||
onChange,
|
||||
columns = [],
|
||||
extraColorChoices = [],
|
||||
conditionalFormattingFlag = {
|
||||
toAllRowCheck: false,
|
||||
toColorTextCheck: false,
|
||||
},
|
||||
allColumns = [],
|
||||
}: {
|
||||
config?: ConditionalFormattingConfig;
|
||||
onChange: (config: ConditionalFormattingConfig) => void;
|
||||
columns: { label: string; value: string; dataType: GenericDataType }[];
|
||||
extraColorChoices?: { label: string; value: string }[];
|
||||
conditionalFormattingFlag?: ConditionalFormattingFlag;
|
||||
allColumns?: ColumnOption[];
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const colorScheme = colorSchemeOptions();
|
||||
@@ -282,35 +247,10 @@ export const FormattingPopoverContent = ({
|
||||
config?.colorScheme !== ColorSchemeEnum.Red),
|
||||
);
|
||||
|
||||
const [toAllRow, setToAllRow] = useState(() => Boolean(config?.toAllRow));
|
||||
const [toTextColor, setToTextColor] = useState(() =>
|
||||
Boolean(config?.toTextColor),
|
||||
);
|
||||
const [useGradient, setUseGradient] = useState(() =>
|
||||
config?.useGradient !== undefined ? config.useGradient : true,
|
||||
);
|
||||
|
||||
const useConditionalFormattingFlag = (
|
||||
flagKey: 'toAllRowCheck' | 'toColorTextCheck',
|
||||
configKey: 'toAllRow' | 'toTextColor',
|
||||
) =>
|
||||
useMemo(
|
||||
() =>
|
||||
conditionalFormattingFlag && conditionalFormattingFlag[flagKey]
|
||||
? config?.[configKey] === undefined
|
||||
: config?.[configKey] !== undefined,
|
||||
[conditionalFormattingFlag], // oxlint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const showToAllRow = useConditionalFormattingFlag(
|
||||
'toAllRowCheck',
|
||||
'toAllRow',
|
||||
);
|
||||
const showToColorText = useConditionalFormattingFlag(
|
||||
'toColorTextCheck',
|
||||
'toTextColor',
|
||||
);
|
||||
|
||||
const handleChange = (event: any) => {
|
||||
setShowOperatorFields(
|
||||
!(event === ColorSchemeEnum.Green || event === ColorSchemeEnum.Red),
|
||||
@@ -320,6 +260,23 @@ export const FormattingPopoverContent = ({
|
||||
const [column, setColumn] = useState<string>(
|
||||
config?.column || columns[0]?.value,
|
||||
);
|
||||
const visibleAllColumns = useMemo(
|
||||
() => !!(allColumns && Array.isArray(allColumns) && allColumns.length),
|
||||
[allColumns],
|
||||
);
|
||||
|
||||
const [columnFormatting, setColumnFormatting] = useState<string | undefined>(
|
||||
config?.columnFormatting ??
|
||||
(Array.isArray(allColumns)
|
||||
? allColumns.find(item => item.value === column)?.value
|
||||
: undefined),
|
||||
);
|
||||
|
||||
const [objectFormatting, setObjectFormatting] =
|
||||
useState<ObjectFormattingEnum>(
|
||||
config?.objectFormatting || formattingOptions[0].value,
|
||||
);
|
||||
|
||||
const [previousColumnType, setPreviousColumnType] = useState<
|
||||
GenericDataType | undefined
|
||||
>();
|
||||
@@ -355,6 +312,51 @@ export const FormattingPopoverContent = ({
|
||||
setPreviousColumnType(newColumnType);
|
||||
};
|
||||
|
||||
const handleAllColumnChange = (value: string | undefined) => {
|
||||
setColumnFormatting(value);
|
||||
};
|
||||
const numericColumns = useMemo(
|
||||
() => allColumns.filter(col => col.dataType === GenericDataType.Numeric),
|
||||
[allColumns],
|
||||
);
|
||||
|
||||
const visibleUseGradient = useMemo(
|
||||
() =>
|
||||
numericColumns.length > 0
|
||||
? numericColumns.some((col: ColumnOption) => col.value === column) &&
|
||||
objectFormatting === ObjectFormattingEnum.BACKGROUND_COLOR
|
||||
: false,
|
||||
[column, numericColumns, objectFormatting],
|
||||
);
|
||||
|
||||
const handleObjectChange = (value: ObjectFormattingEnum) => {
|
||||
setObjectFormatting(value);
|
||||
|
||||
if (value === ObjectFormattingEnum.CELL_BAR) {
|
||||
const currentColumnValue = form.getFieldValue('columnFormatting');
|
||||
|
||||
const isCurrentColumnNumeric = numericColumns.some(
|
||||
col => col.value === currentColumnValue,
|
||||
);
|
||||
|
||||
if (!isCurrentColumnNumeric && numericColumns.length > 0) {
|
||||
const newValue = numericColumns[0]?.value || '';
|
||||
form.setFieldsValue({
|
||||
columnFormatting: newValue,
|
||||
});
|
||||
setColumnFormatting(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getColumnOptions = useCallback(
|
||||
() =>
|
||||
objectFormatting === ObjectFormattingEnum.CELL_BAR
|
||||
? numericColumns
|
||||
: allColumns,
|
||||
[objectFormatting, numericColumns, allColumns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (column && !previousColumnType) {
|
||||
setPreviousColumnType(
|
||||
@@ -403,23 +405,68 @@ export const FormattingPopoverContent = ({
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={20}>
|
||||
<Col span={1}>
|
||||
<FormItem
|
||||
name="useGradient"
|
||||
valuePropName="checked"
|
||||
initialValue={useGradient}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={event => setUseGradient(event.target.checked)}
|
||||
checked={useGradient}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col>
|
||||
<FormItem required>{t('Use gradient')}</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
{visibleAllColumns && showOperatorFields ? (
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
name="columnFormatting"
|
||||
label={t('Formatting column')}
|
||||
rules={rulesRequired}
|
||||
initialValue={columnFormatting}
|
||||
>
|
||||
<Select
|
||||
ariaLabel={t('Select column name')}
|
||||
options={getColumnOptions()}
|
||||
onChange={(value: string | undefined) => {
|
||||
handleAllColumnChange(value as string);
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FormItem
|
||||
name="objectFormatting"
|
||||
label={t('Formatting object')}
|
||||
rules={rulesRequired}
|
||||
initialValue={objectFormatting}
|
||||
tooltip={
|
||||
objectFormatting === ObjectFormattingEnum.CELL_BAR
|
||||
? t(
|
||||
'Applies only when "Cell bars" formatting is selected: the background of the histogram columns is displayed if the "Show cell bars" flag is enabled.',
|
||||
)
|
||||
: null
|
||||
}
|
||||
>
|
||||
<Select
|
||||
ariaLabel={t('Select object name')}
|
||||
options={formattingOptions}
|
||||
onChange={(value: ObjectFormattingEnum) => {
|
||||
handleObjectChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
) : null}
|
||||
{visibleUseGradient && (
|
||||
<Row gutter={20}>
|
||||
<Col span={1}>
|
||||
<FormItem
|
||||
name="useGradient"
|
||||
valuePropName="checked"
|
||||
initialValue={useGradient}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={event => setUseGradient(event.target.checked)}
|
||||
checked={useGradient}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col>
|
||||
<FormItem required>{t('Use gradient')}</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<FormItem noStyle shouldUpdate={shouldFormItemUpdate}>
|
||||
{showOperatorFields ? (
|
||||
(props: GetFieldValue) => renderOperatorFields(props, columnType)
|
||||
@@ -431,47 +478,6 @@ export const FormattingPopoverContent = ({
|
||||
</Row>
|
||||
)}
|
||||
</FormItem>
|
||||
<Row>
|
||||
{showOperatorFields && showToAllRow && (
|
||||
<Row gutter={20}>
|
||||
<Col span={1}>
|
||||
<FormItem
|
||||
name="toAllRow"
|
||||
valuePropName="checked"
|
||||
initialValue={toAllRow}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={event => setToAllRow(event.target.checked)}
|
||||
checked={toAllRow}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col>
|
||||
<FormItem required>{t('To entire row')}</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{showOperatorFields && showToColorText && (
|
||||
<Row gutter={20}>
|
||||
<Col span={1}>
|
||||
<FormItem
|
||||
name="toTextColor"
|
||||
valuePropName="checked"
|
||||
initialValue={toTextColor}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={event => setToTextColor(event.target.checked)}
|
||||
checked={toTextColor}
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col>
|
||||
<FormItem required>{t('To text color')}</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<FormItem>
|
||||
<JustifyEnd>
|
||||
<Button htmlType="submit" buttonStyle="primary">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user