Compare commits

..

64 Commits

Author SHA1 Message Date
hainenber
bbe438b375 chore: resolve lint issues
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-19 14:27:02 +07:00
hainenber
9ab099a807 feat(explore-chart-panel): darken the color of vertical split for better recognizability
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-19 11:55:16 +07:00
Evan Rusackas
6b80135aa2 chore(lint): enforce more strict eslint/oxlint rules (batch 2) (#37884)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 19:27:27 -08:00
RealGreenDragon
de079a7b19 feat(deps)!: bump postgresql from 16 to 17 (#37782) 2026-02-18 17:12:48 -08:00
dependabot[bot]
f54bbdc06b chore(deps): bump dawidd6/action-download-artifact from 14 to 15 (#38060)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 17:11:41 -08:00
SBIN2010
33441ccf3d feat: add formatting column and formatting object to conditional formating table (#35897) 2026-02-19 02:07:15 +03:00
Vitor Avila
9ec56f5f02 fix: Include app_root in next param (#37942) 2026-02-18 19:52:06 -03:00
dependabot[bot]
11a36ff488 chore(deps-dev): bump the storybook group across 1 directory with 11 updates (#38068) 2026-02-18 23:48:16 +07:00
Đỗ Trọng Hải
af3e088233 build(deps): resolve GHSA-36jr-mh4h-2g58 by upgrading d3-color to 3.1.0 (#37981)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-18 21:12:39 +07:00
dependabot[bot]
29f499528f chore(deps-dev): bump eslint-plugin-testing-library from 7.15.4 to 7.16.0 in /superset-frontend (#38066)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 21:01:04 +07:00
dependabot[bot]
21481eef4f chore(deps): bump the storybook group in /docs with 9 updates (#38067)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 21:00:01 +07:00
dependabot[bot]
0d2c8fd373 chore(deps): bump @storybook/core from 8.6.15 to 8.6.16 in /docs (#38046)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-18 20:22:21 +07:00
Đỗ Trọng Hải
7b56fc1714 fix(docs): correct DB module filename for editing + update DB metadata file (#37990)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-18 20:08:50 +07:00
Đỗ Trọng Hải
9131739f98 fix(home): null check for possibly undefined filtered other table data due to insufficient permission (#37983) 2026-02-18 17:33:51 +07:00
Đỗ Trọng Hải
a30492f55e fix(plugin/cal-heatmap): properly color tooltip's text for both dark/light theme (#38010) 2026-02-18 17:25:41 +07:00
dependabot[bot]
090eab099c chore(deps): bump storybook from 8.6.15 to 8.6.16 in /docs (#38043) 2026-02-18 16:23:26 +07:00
dependabot[bot]
cd4cd53726 chore(deps-dev): bump css-loader from 7.1.3 to 7.1.4 in /superset-frontend (#38050) 2026-02-18 16:21:39 +07:00
dependabot[bot]
65c460c9d2 chore(deps-dev): bump @swc/plugin-emotion from 14.5.0 to 14.6.0 in /superset-frontend (#38053) 2026-02-18 16:20:49 +07:00
dependabot[bot]
868e719c60 chore(deps-dev): bump oxlint from 1.47.0 to 1.48.0 in /superset-frontend (#38055) 2026-02-18 16:20:16 +07:00
dependabot[bot]
5efc7ea5a5 chore(deps-dev): bump typescript-eslint from 8.55.0 to 8.56.0 in /docs (#38024)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-18 12:10:50 +07:00
dependabot[bot]
b0f9a73f63 chore(deps-dev): bump typescript-eslint from 8.54.0 to 8.56.0 in /superset-websocket (#38020)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 11:49:11 +07:00
dependabot[bot]
746e266e90 chore(deps): bump swagger-ui-react from 5.31.0 to 5.31.1 in /docs (#38023)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-18 11:37:51 +07:00
Damian Pendrak
5a777c0f45 feat(matrixify): add single metric constraint (#37169) 2026-02-17 09:12:24 -08:00
Amin Ghadersohi
aec1f6edce fix(mcp): use last data-bearing statement in execute_sql response (#37968)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:13:55 +01:00
Amin Ghadersohi
f7218e7a19 feat(mcp): expose current user identity in get_instance_info and add created_by_fk filter (#37967) 2026-02-17 13:11:34 +01:00
Amin Ghadersohi
5cd829f13c fix(mcp): handle more chart types in get_chart_data fallback query construction (#37969)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:02:42 +01:00
dependabot[bot]
9566e8a9c6 chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 0.8.5 to 0.9.1 in /superset-frontend (#38000)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 12:03:13 +07:00
dependabot[bot]
604d49f557 chore(deps): bump datamaps from 0.5.9 to 0.5.10 in /superset-frontend (#37913)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 14:51:03 -08:00
SBIN2010
84f1ee4409 feat: added conditional formatting enhancements string to pivot table (#35863) 2026-02-17 01:08:41 +03:00
Kamil Gabryjelski
3e3c9686de perf(dashboard): Batch RLS filter lookups for dashboard digest computation (#37941) 2026-02-16 21:35:55 +01:00
Mehmet Salih Yavuz
7b21979fa3 fix(charts): Force refresh uses async mode when GAQ is enabled (#37845) 2026-02-16 21:45:10 +03:00
Đỗ Trọng Hải
8853ff19d4 chore(websocket): migrate external uuid usage with Node's native UUID generator (#37101)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-02-16 18:05:10 +07:00
Damian Pendrak
22ac5e02b6 fix(deckgl): remove dataset field from Deck.gl Layer Visibility Display controls (#37611) 2026-02-16 11:58:23 +01:00
dependabot[bot]
2c9f0c1c2a chore(deps-dev): bump wait-on from 9.0.3 to 9.0.4 in /superset-frontend (#37999)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 17:18:23 +07:00
dependabot[bot]
d47a7105df chore(deps): bump caniuse-lite from 1.0.30001769 to 1.0.30001770 in /docs (#37994)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:42:53 +07:00
dependabot[bot]
c873225308 chore(deps-dev): bump jsdom from 28.0.0 to 28.1.0 in /superset-frontend (#37997)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:42:28 +07:00
dependabot[bot]
982e2c1ef7 chore(deps-dev): bump webpack from 5.105.0 to 5.105.2 in /superset-frontend (#38003)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:36:17 +07:00
dependabot[bot]
eee3af5775 chore(deps-dev): bump oxlint from 1.46.0 to 1.47.0 in /superset-frontend (#38005)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:35:29 +07:00
dependabot[bot]
232b34d944 chore(deps-dev): bump webpack-sources from 3.3.3 to 3.3.4 in /superset-frontend (#38004)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 15:35:03 +07:00
dependabot[bot]
d748ed19ce chore(deps): bump hot-shots from 13.2.0 to 14.0.0 in /superset-websocket (#37993) 2026-02-16 15:16:31 +07:00
dependabot[bot]
5300f65a74 chore(deps): bump qs from 6.14.1 to 6.14.2 in /superset-frontend (#37936)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 13:39:06 +07:00
Türker Ziya Ercin
440602ef34 fix(utils): datetime_to_epoch function is fixed to timezone aware epoch (#37979) 2026-02-15 22:36:18 +07:00
dependabot[bot]
cbf153845e chore(deps): bump qs from 6.14.1 to 6.14.2 in /superset-websocket/utils/client-ws-app (#37933)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 22:18:14 +07:00
dependabot[bot]
097f474f24 chore(deps): bump pillow from 11.3.0 to 12.1.1 (#37935)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 16:00:47 -08:00
Joe Li
73adff55ee chore(deps): Relax sqlalchemy-utils lower bound for pydoris compatibility (#37949) 2026-02-13 14:55:54 -08:00
dependabot[bot]
a65f73a532 chore(deps): bump qs from 6.14.1 to 6.14.2 in /docs (#37937)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-14 01:01:42 +07:00
dependabot[bot]
475615e118 chore(deps): bump ioredis from 5.9.2 to 5.9.3 in /superset-websocket (#37951)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:40:50 +07:00
dependabot[bot]
79f51e2ae7 chore(deps-dev): bump webpack from 5.105.1 to 5.105.2 in /docs (#37953)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:39:56 +07:00
dependabot[bot]
75d6a95ac3 chore(deps): bump aquasecurity/trivy-action from 0.33.1 to 0.34.0 (#37958)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:39:30 +07:00
dependabot[bot]
ffd7f10320 chore(deps): bump markdown-to-jsx from 9.7.3 to 9.7.4 in /superset-frontend (#37959)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 23:09:31 +07:00
Michael S. Molina
e3e2bece6b feat(owners): display email in owner selectors (#37906) 2026-02-13 09:01:05 -03:00
Jean Massucatto
0c0d915391 fix(echarts-timeseries-combined-labels): combine annotation labels for events at same timestamp (#37164) 2026-02-13 12:39:28 +03:00
Jamile Celento
080f629ea2 fix(echarts): formula annotations not rendering with dataset-level columns label (#37522) 2026-02-13 12:37:19 +03:00
Joe Li
142b2cc425 test(e2e): add Playwright E2E tests for Chart List page (#37866)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:16:11 -08:00
Joe Li
6328e51620 test(examples): add tests for UUID threading and security bypass (#37557)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 14:12:12 -08:00
Joe Li
0d5ddb3674 feat(themes): add enhanced validation and error handling with fallback mechanisms (#37378)
Co-authored-by: Rafael Benitez <rebenitez1802@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 14:06:58 -08:00
Pat Buxton
58d245c6b0 chore(deps): Update sqlachemy-utils to 0.42.0 (#36240) 2026-02-12 12:39:06 -08:00
Jean Massucatto
dbf5e1f131 feat(theme): use IBM Plex Mono for code and numerical displays (#37366)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 09:32:41 -08:00
Jonathan Alberth Quispe Fuentes
88ce1425e2 fix(roles): optimize user fetching and resolve N+1 query issue (#37235) 2026-02-12 09:32:19 -08:00
Amin Ghadersohi
4dfece9ee5 feat(mcp): add event_logger instrumentation to MCP tools (#37859) 2026-02-12 16:50:20 +01:00
Amin Ghadersohi
3f64c25712 fix(mcp): Add database_name as valid filter column for list_datasets (#37865)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:47:46 +01:00
dependabot[bot]
afacca350f chore(deps-dev): bump oxlint from 1.42.0 to 1.46.0 in /superset-frontend (#37917)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-02-12 21:45:26 +07:00
dependabot[bot]
30ccbb2e05 chore(deps): update @types/geojson requirement from ^7946.0.10 to ^7946.0.16 in /superset-frontend/plugins/plugin-chart-cartodiagram (#37908)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-12 20:59:28 +07:00
Michael S. Molina
19ec7b48a0 fix: Conditional formatting painting empty cells (#37894) 2026-02-12 10:22:00 -03:00
194 changed files with 11174 additions and 5381 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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"

View File

@@ -1 +1 @@
v20.18.3
v20.20.0

View File

@@ -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:

View File

@@ -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": [

View File

@@ -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>

View File

@@ -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.",

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.15.3](https://img.shields.io/badge/Version-0.15.3-informational?style=flat-square)
![Version: 0.15.4](https://img.shields.io/badge/Version-0.15.4-informational?style=flat-square)
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

View File

@@ -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",

View File

@@ -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",
]

View File

@@ -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,

View File

@@ -115,7 +115,7 @@ module.exports = {
}),
typescript: {
reactDocgen: 'react-docgen-typescript',
reactDocgen: getAbsolutePath('react-docgen-typescript'),
},
framework: {

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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",

View File

@@ -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';

View File

@@ -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",
},
};

View File

@@ -116,6 +116,7 @@ export interface SupersetSpecificTokens {
fontWeightNormal: string;
fontWeightLight: string;
fontWeightStrong: number;
fontWeightBold: string;
// Brand-related
brandIconMaxWidth: number;

View File

@@ -17,4 +17,5 @@
* under the License.
*/
export { default as isBlank } from './isBlank';
export { default as logging } from './logging';

View File

@@ -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);
});

View File

@@ -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)))
);
}

View File

@@ -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>
);
}

View File

@@ -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}`] = {

View File

@@ -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',

View File

@@ -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';
}

View File

@@ -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');
});

View File

@@ -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 = [
{

View File

@@ -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 />);

View File

@@ -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}
/>
</>

View File

@@ -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

View File

@@ -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>,
);

View File

@@ -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) {

View File

@@ -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();
});

View File

@@ -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');
}
}

View File

@@ -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);
}
}
}

View 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);
}

View File

@@ -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,
);
},
),
}),
),
);
},

View 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);
}
}

View File

@@ -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],
});
});

View File

@@ -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 };
}

View File

@@ -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 ({

View File

@@ -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;

View File

@@ -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": {

View File

@@ -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)

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"
},

View File

@@ -360,7 +360,7 @@ export default function transformProps(
series.push(
transformFormulaAnnotation(
layer,
data1,
rebasedDataA as TimeseriesDataRecord[],
xAxisLabel,
xAxisType,
colorScale,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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,
);
});
});

View File

@@ -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);
});

View 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;
}

View File

@@ -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 },
]);
});
});

View File

@@ -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',
)

View File

@@ -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' }

View File

@@ -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;
}

View File

@@ -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');
});

View File

@@ -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,
})
};
`}
`;

View File

@@ -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,

View File

@@ -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,
};
},

View File

@@ -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,
},
],
},

View File

@@ -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": "*",

View File

@@ -531,6 +531,7 @@ const ResultSet = ({
placement="left"
>
<Label
monospace
css={css`
line-height: ${theme.fontSizeLG}px;
`}

View File

@@ -31,6 +31,7 @@ export default class DeckglLayerVisibilityCustomizationPlugin extends ChartPlugi
tags: [t('Deckgl'), t('Experimental')],
thumbnail: '',
enableNoResults: false,
datasourceCount: 0,
});
super({

View File

@@ -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,

View File

@@ -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

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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...')
) : (

View File

@@ -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,
}));
},

View File

@@ -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);
};

View File

@@ -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) && (

View File

@@ -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

View File

@@ -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))
);
});

View File

@@ -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;
}

View File

@@ -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) && (

View File

@@ -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

View File

@@ -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}

View File

@@ -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();
});
});

View File

@@ -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