Compare commits

...
Author SHA1 Message Date
rusackasandClaude Opus 4.8 19fe2f717a docs(security): credit dataset owners, clarify per-error-type placeholders
Contacts in the access-denied payload come from datasource.owners, and
PERMISSION_INSTRUCTIONS_LINK placeholders that don't apply to the
triggering error type render as empty strings — document both so a
single URL template can safely cover chart/dashboard and SQL Lab
denials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 18:05:26 -07:00
rusackasandClaude ad3b337aba docs(security): document PERMISSION_INSTRUCTIONS_LINK templating
#41843 added templated {datasource_id}/{datasource_name}/{table_names}/{username}
placeholders to PERMISSION_INSTRUCTIONS_LINK so the "Request access" link on
data-permission errors can deep-link into an org's access-request tool, but the
security config docs only described roles/permissions in general terms.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 13:40:11 -07:00
Endi Monan 0d025daaf7 fix(plugin-chart-table): defer search until IME composition ends (#43756) 2026-09-04 13:33:30 -07:00
c3109899e2 fix(dashboard): is_externally_managed still allows edits (#38584)
Co-authored-by: Matthew Kramer <matthewkramer@Matthews-MacBook-Pro-2.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: rusackas <evan@rusackas.com>
2026-09-04 13:15:50 -07:00
ʈᵃᵢ 6956da1de5 fix(sql): close StarRocks SQL parsing and mutation-detection gaps (#43809) 2026-09-04 12:38:31 -07:00
Shivam Goel c1822666da ci: fail when the published OpenAPI spec is stale (#43841) 2026-09-04 12:35:04 -07:00
581ef8b7c1 chore(deps): bump antd from 6.6.1 to 6.6.2 in /docs (#43852)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:11:11 -07:00
Ville Brofeldt 0636ac4b11 fix(examples): correct chart metadata (#43810) 2026-09-04 10:49:26 -07:00
Joe LiandClaude Sonnet 5 99a910b81c fix(echarts): render Plain Timeseries legends as HTML, not canvas (#43314)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:48:36 -07:00
Ville Brofeldt 635de27b25 fix(explore): prevent editable title over-truncation (#43811) 2026-09-04 10:29:10 -07:00
dependabot[bot] e1ce6b601c chore(deps-dev): bump jest-environment-jsdom from 30.4.1 to 30.5.0 in /superset-frontend (#43878)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 10:18:56 -07:00
dependabot[bot] db60f25ee9 chore(deps-dev): bump babel-jest from 30.4.1 to 30.5.0 in /superset-frontend (#43879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 10:18:35 -07:00
dependabot[bot]andgithub-actions[bot] 297b0455f6 chore(deps): bump nh3 from 0.3.6 to 0.3.7 (#43868)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:18:01 -07:00
dependabot[bot] b1c33e5435 chore(deps-dev): bump hdbcli from 2.29.25 to 2.29.27 (#43869)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 10:17:51 -07:00
dependabot[bot]andgithub-actions[bot] b925246bef chore(deps): bump click from 8.4.2 to 8.5.0 (#43872)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:15:28 -07:00
dependabot[bot]andgithub-actions[bot] 592d378c0d chore(deps-dev): bump pandas-gbq from 0.35.1 to 0.35.2 (#43873)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:14:47 -07:00
dependabot[bot]andgithub-actions[bot] 935ee5f1a6 chore(deps): bump simplejson from 4.1.1 to 4.1.2 (#43874)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:14:37 -07:00
Mike BridgeandClaude Fable 5 d9b201db74 fix(explore): open version-history forks in a usable tab, not a blank one (#43830)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 09:19:06 -07:00
Đỗ Trọng Hải bc6ff77af0 feat(ci): broaden usage of fast-startup ubuntu-slim runner (#43477)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-04 22:25:10 +07:00
dependabot[bot] 3b1e43be5c chore(deps-dev): bump css-loader from 7.1.4 to 7.1.5 in /superset-frontend (#43880)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 22:23:45 +07:00
dependabot[bot] d27124e5f6 chore(deps-dev): bump webpack-cli from 7.2.2 to 7.2.3 in /superset-embedded-sdk (#43882)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 22:17:25 +07:00
dependabot[bot] 31a3a19037 chore(deps-dev): bump @types/node from 25.9.5 to 26.4.0 in /superset-embedded-sdk (#43881)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 22:17:07 +07:00
Enzo MartellucciandClaude Sonnet 5 f63a0e9d9c feat(conditional-formatting): add manual min/max color-range bounds (#43820)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 17:07:48 +02:00
Đỗ Trọng Hải 8f6caf4ff9 feat(build): upgrade to Typescript 7, Babel 8, Webpack CLI 7 and JSDOM 30 for superset-embedded-sdk (#43702)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-04 21:31:34 +07:00
Đỗ Trọng Hải e9412576b2 feat(build/websocket): upgrade to Typescript 7 + migrate ESLint to TS7-compatible oxlint andtsgolint (#43703)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-09-04 21:31:10 +07:00
Đỗ Trọng HảiandEvan Rusackas c84c7c4bdd chore(ci): optimize Dependabot processes (#43681)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-04 20:50:00 +07:00
dependabot[bot] a068517393 chore(deps-dev): bump webpack from 5.109.2 to 5.110.1 in /superset-frontend (#43853)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 20:49:17 +07:00
dependabot[bot] 0a5eddb18b chore(deps): bump morgan from 1.11.0 to 1.12.0 in /superset-websocket/utils/client-ws-app (#43850)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 20:38:02 +07:00
dependabot[bot] 34286883d3 chore(deps): bump postcss from 8.5.23 to 8.5.26 in /superset-frontend (#43854)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 20:35:28 +07:00
dependabot[bot]andhainenber 0fc410eecc chore(deps-dev): bump @testing-library/react from 15.0.7 to 16.3.3 in /superset-frontend (#43855)
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-09-04 20:34:36 +07:00
dependabot[bot] 173d2173aa chore(deps-dev): bump baseline-browser-mapping from 2.11.19 to 2.11.20 in /superset-frontend (#43856)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 20:14:59 +07:00
dependabot[bot] 0e01b5fb4c chore(deps): bump baseline-browser-mapping from 2.11.19 to 2.11.20 in /docs (#43851)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 20:13:14 +07:00
dependabot[bot] a6a794864b chore(deps-dev): bump minimizer-webpack-plugin from 5.7.0 to 5.8.0 in /superset-frontend (#43857)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 19:59:06 +07:00
dependabot[bot] 2aacf57932 chore(deps): bump antd from 6.6.1 to 6.6.2 in /superset-frontend (#43858)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 19:58:42 +07:00
Mike BridgeandClaude Fable 5 8d0637efad fix(semantic-layers): serve filter-value suggestions for semantic views (#43777)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 01:43:00 -04:00
93df5bc362 fix(semantic-layers): classify SemanticLayer/SemanticView in FAB role sets (#43783)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 01:41:19 -04:00
Elizabeth ThompsonandClaude Opus 4.8 cceb52e776 fix(embedded): don't let guest-user check turn error responses into 500s (#43834)
Signed-off-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 21:18:43 -07:00
120 changed files with 10994 additions and 4219 deletions
+30
View File
@@ -84,6 +84,18 @@ updates:
- "@swc/core"
- "@swc/plugin-emotion"
- "@swc/plugin-transform-imports"
jsonforms:
patterns:
- "@jsonforms/*"
visx:
patterns:
- "@visx/*"
emotion:
patterns:
- "@emotion/*"
fontsource:
patterns:
- "@fontsource/*"
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
@@ -180,3 +192,21 @@ updates:
versioning-strategy: increase
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/superset-embedded-sdk/"
schedule:
interval: "daily"
labels:
- npm
- dependabot
groups:
security:
applies-to: "security-updates"
patterns: ["*"]
babel:
patterns:
- "@babel/*"
versioning-strategy: increase
cooldown:
default-days: 7
@@ -0,0 +1,71 @@
# 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.
name: Check OpenAPI spec drift
on:
push:
branches:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
# No `paths:` filter on purpose, matching enforce-single-migration-head: a
# required check that never runs for a given PR blocks that PR forever. The
# job is ~10s, so it fires on every PR rather than guessing which file edits
# can move the spec.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
check-openapi-spec-drift:
runs-on: ubuntu-26.04
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: ./.github/actions/setup-backend/
with:
# base.txt pins apispec, which decides the generated output: 6.10.0
# renders marshmallow 4's unknown=RAISE as "additionalProperties":
# false while the pinned 6.6.1 does not. Regenerating off-pin
# produces a spec no CI run can reproduce.
requirements-type: base
- name: Regenerate the spec
env:
# No SUPERSET_CONFIG_PATH: the published spec documents the routes a
# default deployment registers. A config enabling feature flags adds
# paths that would 404 for everyone who has not enabled them.
SUPERSET__SQLALCHEMY_DATABASE_URI: "sqlite:///:memory:"
FLASK_APP: "superset.app:create_app()"
run: superset update-api-docs
- name: Assert the published spec is up to date
run: |
if ! git diff --quiet -- docs/static/resources/openapi.json; then
echo "::error::docs/static/resources/openapi.json is stale."
echo "Regenerate it on the pinned requirements, with no config file:"
echo " SUPERSET__SQLALCHEMY_DATABASE_URI='sqlite:///:memory:' \\"
echo " FLASK_APP='superset.app:create_app()' superset update-api-docs"
git diff --stat -- docs/static/resources/openapi.json
exit 1
fi
+1 -1
View File
@@ -266,7 +266,7 @@ jobs:
actions-timeline:
needs: [docker-build, docker-compose-image-tag]
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: read
steps:
@@ -19,7 +19,7 @@ concurrency:
jobs:
validate-all-ghas:
runs-on: ubuntu-26.04
runs-on: ubuntu-26.04 # Don't switch to ubuntu-slim as zizmore-action requires Docker
permissions:
contents: read
# Required for the zizmor action to upload its SARIF results to
@@ -34,7 +34,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"
node-version-file: "./superset-frontend/.nvmrc"
- name: Install Dependencies
# Versions are pinned to avoid ad-hoc, unpinned package installs.
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
cancel-on-hold:
name: Cancel CI runs when hold label applied
if: github.event.action == 'labeled' && startsWith(github.event.label.name, 'hold')
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: write
pull-requests: read
@@ -69,7 +69,7 @@ jobs:
rerun-on-unhold:
name: Re-run CI when hold label removed
if: github.event.action == 'unlabeled' && startsWith(github.event.label.name, 'hold')
runs-on: ubuntu-24.04
runs-on: ubuntu-slim
permissions:
actions: write
pull-requests: read
+1 -1
View File
@@ -202,7 +202,7 @@ jobs:
actions-timeline:
needs: pre-commit
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
steps:
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
+1 -1
View File
@@ -354,7 +354,7 @@ jobs:
actions-timeline:
needs: [cypress-matrix, playwright-tests, cypress-matrix-required, playwright-tests-required]
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: read
steps:
@@ -74,7 +74,7 @@ jobs:
actions-timeline:
needs: test-superset-extensions-cli-package
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
steps:
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
with:
+2 -2
View File
@@ -111,7 +111,7 @@ jobs:
report-coverage:
needs: [sharded-jest-tests]
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
timeout-minutes: 15
permissions:
id-token: write
@@ -206,7 +206,7 @@ jobs:
actions-timeline:
needs: [report-coverage, lint-frontend, validate-frontend, test-storybook]
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: read
steps:
+1 -1
View File
@@ -188,7 +188,7 @@ jobs:
actions-timeline:
needs: playwright-tests-experimental
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: read
steps:
@@ -257,7 +257,7 @@ jobs:
actions-timeline:
needs: [test-mysql, test-postgres, test-sqlite, test-postgres-required]
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: read
steps:
@@ -165,7 +165,7 @@ jobs:
actions-timeline:
needs: [test-postgres-presto, test-postgres-hive]
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: read
steps:
@@ -125,7 +125,7 @@ jobs:
unit-tests-required:
needs: [changes, unit-tests]
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
timeout-minutes: 5
permissions:
contents: read
@@ -20,7 +20,7 @@ permissions:
jobs:
post-comment:
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
# Only act when the Translations workflow failed (which means a regression
# was detected — the workflow exits 1 on regression).
if: github.event.workflow_run.conclusion == 'failure'
+1 -1
View File
@@ -160,7 +160,7 @@ jobs:
actions-timeline:
needs: [frontend-check-translations, babel-extract]
if: always()
runs-on: ubuntu-26.04
runs-on: ubuntu-slim
permissions:
actions: read
steps:
+3 -3
View File
@@ -35,10 +35,10 @@ jobs:
- name: Install dependencies
working-directory: ./superset-websocket
run: npm ci
- name: eslint
- name: lint
working-directory: ./superset-websocket
run: npm run eslint -- . --quiet
- name: typescript checks
run: npm run lint-check
- name: typescript check
working-directory: ./superset-websocket
run: npm run type
- name: code format check
+24
View File
@@ -24,6 +24,30 @@ assists people when migrating to a new version.
## Next
- **[BREAKING] `SemanticLayer` and `SemanticView` are now classified in the
Flask-AppBuilder role sets**, so `sync_role_definitions` (run on
`superset init` and on startup) stops granting the built-in **Gamma** role
write access to them. `SemanticLayer` is treated like `Database`
(`READ_ONLY_MODEL_VIEWS`): create/edit/delete become **admin-only**, while
read stays broadly available (its configuration is returned masked).
`SemanticView` is treated like `Dataset` (`GAMMA_READ_ONLY_MODEL_VIEWS`):
writes are Alpha-tier, reads Gamma-tier. Its custom read endpoints
(`views`, `connections`) are mapped to `can_read` so they remain
accessible under the read-only classification. A deployment relying on
Gamma users creating or editing semantic layers/views must grant those
permissions through a custom role. A migration retires the now-unused
`can_views` / `can_connections` permissions left on the `SemanticLayer`
view menu by earlier builds. Two upgrade-time notes on that migration:
it seeds the `SemanticLayer` view menu and its `can_read` PVM if absent, so
even a fresh or flag-off install gains that permission (harmless — the
endpoints 404 while `SEMANTIC_LAYERS` is off); and retiring the stale
permissions remaps any role that held them onto `can_read`, a small
widening — a custom role granted only `can_views` or `can_connections` gains
`can_read` (the semantic-layer list and its masked-configuration detail),
which it could not previously reach. Operators who hand-rolled semantic-layer
roles should re-audit them after upgrading. The feature remains gated behind
the default-off `SEMANTIC_LAYERS` flag.
### Archived dataset purge requires impact confirmation
`GET /api/v1/dataset/<uuid>/purge-impact` returns the charts and distinct
+27
View File
@@ -132,6 +132,33 @@ tables in the **Permissions** dropdown. To select the data sources you want to a
You can then confirm with users assigned to the **Gamma** role that they see the
objects (dashboards and slices) associated with the tables you just extended them.
### Actionable Access-Denied Messages
When a viewer opens a chart or dashboard built on a dataset they don't have access to, Superset
shows a plain-language error naming the dataset (or tables, for SQL Lab) and, when known, the
dataset owners to contact. You can also point users at your own access-request process by setting
`PERMISSION_INSTRUCTIONS_LINK` in `superset_config.py`:
```python
PERMISSION_INSTRUCTIONS_LINK = (
"https://access.example.com/request?dataset={datasource_name}&table={table_names}&user={username}"
)
```
The URL may include any of the following placeholders, which are substituted with URL-encoded
values so the link can deep-link into an internal ticketing or access-request tool with the
denied resource pre-filled:
- `{datasource_id}` — id of the denied dataset (chart/dashboard errors only)
- `{datasource_name}` — name of the denied dataset (chart/dashboard errors only)
- `{table_names}` — comma-separated denied table names (SQL Lab/table errors only)
- `{username}` — the requesting user's username
Only the placeholders that apply to the triggering error type are filled in; the rest are replaced
with an empty string, so a single URL template can cover both chart/dashboard and SQL Lab denials.
A URL with no placeholders is used as-is, and leaving `PERMISSION_INSTRUCTIONS_LINK` unset (the
default) omits the "Request access" link, falling back to owner-contact guidance.
### Subjects
A **subject** is a unified identity that can be granted access to Superset resources such as
@@ -328,6 +328,12 @@ Conditional formatting rules highlight cells based on their values. Rules can be
Each rule has a **"Use gradient"** toggle: enabled applies a varying opacity (lighter = further from threshold), disabled applies a solid fill at full opacity regardless of value.
For numeric rules, the optional **"Min bound"** / **"Max bound"** fields let you override the auto-detected color range with fixed values instead of relying on the minimum/maximum found in the data — useful when you want consistent coloring across dashboards or data refreshes.
Set **"Bound unit"** to **"% of column"** to express the bounds as percentages of either the column maximum or **"Column sum"**, which adds the absolute values in the column so positive and negative values do not cancel into an unstable denominator. A non-positive maximum or zero sum falls back to the automatic data range. A percentage center based on **"Column sum"** must still resolve inside the color range to activate diverging colors. This option is unavailable with server pagination because the browser only receives one page of rows and cannot calculate a stable full-column denominator; existing percentage rules also use the automatic range while server pagination is enabled.
For a diverging scale, set a **"Center value"** together with **"Low color"**, **"Mid color"**, and **"High color"**. Values below and above the center interpolate toward the corresponding endpoint color. Turning off **"Use gradient"** disables this interpolation and applies the base color as a solid fill.
Each rule's color is set with a full color picker rather than a fixed dropdown of presets. Pick any custom color, or use the **Colors** preset swatches, which reference theme tokens (success, warning, error, and their background variants) so a rule's color updates automatically if the active theme changes, including switching between light and dark mode.
When a rule targets a column with an active time comparison, a **Trend colors** preset also appears, letting you color cells green for an increase and red for a decrease (or the reverse).
+2 -2
View File
@@ -61,8 +61,8 @@
"@storybook/addon-docs": "^10.5.10",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.1",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.19",
"antd": "^6.6.2",
"baseline-browser-mapping": "^2.11.20",
"caniuse-lite": "^1.0.30001810",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
+110 -46
View File
@@ -13719,6 +13719,111 @@
],
"type": "object"
},
"chart_get_list_schema": {
"properties": {
"columns": {
"items": {
"type": "string"
},
"type": "array"
},
"filters": {
"items": {
"properties": {
"col": {
"type": "string"
},
"opr": {
"type": "string"
},
"value": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"type": "boolean"
},
{
"items": {
"anyOf": [
{
"type": "number"
},
{
"type": "string"
},
{
"type": "boolean"
}
]
},
"type": "array"
}
]
}
},
"required": [
"col",
"opr",
"value"
],
"type": "object"
},
"type": "array"
},
"keys": {
"items": {
"enum": [
"list_columns",
"order_columns",
"label_columns",
"description_columns",
"list_title",
"none"
],
"type": "string"
},
"type": "array"
},
"order_column": {
"type": "string"
},
"order_direction": {
"enum": [
"asc",
"desc"
],
"type": "string"
},
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"select_columns": {
"items": {
"type": "string"
},
"type": "array"
},
"viz_type_order": {
"description": "Visualization type slugs in display-name order. Used only when order_column is viz_type.",
"items": {
"maxLength": 250,
"type": "string"
},
"maxItems": 256,
"type": "array",
"uniqueItems": true
}
},
"type": "object"
},
"database_catalogs_query_schema": {
"properties": {
"force": {
@@ -15680,10 +15785,11 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/get_list_schema"
"$ref": "#/components/schemas/chart_get_list_schema"
}
}
},
"description": "Rison-encoded list query. viz_type_order may contain up to 256 unique visualization type slugs, each at most 250 characters, in the display-name order to use when sorting by viz_type.",
"in": "query",
"name": "q"
}
@@ -15695,57 +15801,15 @@
"schema": {
"properties": {
"count": {
"description": "The total record count on the backend",
"type": "number"
},
"description_columns": {
"properties": {
"column_name": {
"description": "The description for the column name. Will be translated by babel",
"example": "A Nice description for the column",
"type": "string"
}
},
"type": "object"
"type": "integer"
},
"ids": {
"description": "A list of item ids, useful when you don't know the column id",
"items": {
"type": "string"
},
"type": "array"
},
"label_columns": {
"properties": {
"column_name": {
"description": "The label for the column name. Will be translated by babel",
"example": "A Nice label for the column",
"type": "string"
}
},
"type": "object"
},
"list_columns": {
"description": "A list of columns",
"items": {
"type": "string"
},
"type": "array"
},
"list_title": {
"description": "A title to render. Will be translated by babel",
"example": "List Items",
"type": "string"
},
"order_columns": {
"description": "A list of allowed columns to sort",
"items": {
"type": "string"
"type": "integer"
},
"type": "array"
},
"result": {
"description": "The result from the get list query",
"items": {
"$ref": "#/components/schemas/ChartRestApi.get_list"
},
@@ -15756,7 +15820,7 @@
}
}
},
"description": "Items from Model"
"description": "Charts"
},
"400": {
"$ref": "#/components/responses/400"
+48 -48
View File
@@ -3687,21 +3687,21 @@
"@rc-component/virtual-list" "^1.4.0"
clsx "^2.1.1"
"@rc-component/mentions@~1.11.0":
version "1.11.0"
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.11.0.tgz#cee0c4710f26766ad8550d386cfec5ff86fd58d9"
integrity sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==
"@rc-component/mentions@~1.12.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.12.0.tgz#4c939e22ad8bc82e2936bdf8427132bc367dc66d"
integrity sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA==
dependencies:
"@rc-component/input" "~1.3.1"
"@rc-component/menu" "~1.4.0"
"@rc-component/menu" "~1.5.0"
"@rc-component/trigger" "^3.0.0"
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/menu@~1.4.0", "@rc-component/menu@~1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@rc-component/menu/-/menu-1.4.1.tgz#aa20b6d6087f5ddd23d4a21b0ccce35b98988c8e"
integrity sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==
"@rc-component/menu@~1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@rc-component/menu/-/menu-1.5.0.tgz#53599217a012d5f9087e02ba71cff856e9cba05b"
integrity sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q==
dependencies:
"@rc-component/motion" "^1.1.4"
"@rc-component/overflow" "^1.0.0"
@@ -3731,10 +3731,10 @@
dependencies:
"@rc-component/util" "^1.2.0"
"@rc-component/notification@~2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@rc-component/notification/-/notification-2.0.7.tgz#f2450a482f87e4698285833c4a8efcac169acabb"
integrity sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==
"@rc-component/notification@~2.0.8":
version "2.0.8"
resolved "https://registry.yarnpkg.com/@rc-component/notification/-/notification-2.0.8.tgz#27bfd803306c7215384e0781c75343216495b15a"
integrity sha512-MTRA3b8eHE14gh5R9nlXzbWZEDEBFVkEHj/FI1VOXw2zrSnSxqbuuU3drsq+k53e6MHwtegoFay1tnNYhtl61Q==
dependencies:
"@rc-component/motion" "^1.1.4"
"@rc-component/util" "^1.11.0"
@@ -3777,12 +3777,12 @@
"@rc-component/util" "^1.11.0"
clsx "^2.1.1"
"@rc-component/progress@~1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@rc-component/progress/-/progress-1.0.2.tgz#9aba5e24d3ca73a61a451fd041f5d03ca8907c62"
integrity sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==
"@rc-component/progress@~1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@rc-component/progress/-/progress-1.0.3.tgz#e2bddab0e6bccdbe6b87a313efa9daab346273f5"
integrity sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw==
dependencies:
"@rc-component/util" "^1.2.1"
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/qrcode@~2.0.0":
@@ -3836,12 +3836,12 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/steps@~1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@rc-component/steps/-/steps-1.2.2.tgz#8440329540e987ccaed252e008972d0b63723d6f"
integrity sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==
"@rc-component/steps@~1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@rc-component/steps/-/steps-1.2.3.tgz#e423f501da8b03a93e97b4800977f59777457d14"
integrity sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA==
dependencies:
"@rc-component/util" "^1.2.1"
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/switch@~1.0.3":
@@ -3863,22 +3863,22 @@
"@rc-component/virtual-list" "^1.0.1"
clsx "^2.1.1"
"@rc-component/tabs@~1.12.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.12.0.tgz#41a1a77ed1afc4f1b8b727003a058c631aceea1b"
integrity sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==
"@rc-component/tabs@~1.13.0":
version "1.13.0"
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.13.0.tgz#22dd1ac7b96a81e26b39eac1fea6ffdb5e90b58f"
integrity sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw==
dependencies:
"@rc-component/dropdown" "~1.0.0"
"@rc-component/menu" "~1.4.0"
"@rc-component/menu" "~1.5.0"
"@rc-component/motion" "^1.1.3"
"@rc-component/resize-observer" "^1.0.0"
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/tooltip@~1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.0.tgz#422aa0760b310e0a1d0f9f7223e7f0d455de57a2"
integrity sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==
"@rc-component/tooltip@~1.5.2":
version "1.5.2"
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.2.tgz#616366cd797626b08fb134458468dd489d4949da"
integrity sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw==
dependencies:
"@rc-component/trigger" "^3.10.0"
"@rc-component/util" "^1.11.1"
@@ -3933,7 +3933,7 @@
"@rc-component/util" "^1.11.1"
clsx "^2.1.1"
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f"
integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==
@@ -6213,10 +6213,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.1.tgz#3235d76413b525b1f3287b87bdaf6ba0e7148521"
integrity sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==
antd@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.2.tgz#f111adec5c5b53c97e3fbc3af71b21aea9938a4a"
integrity sha512-aTOPWXsqfWrlSiI0a1qR9UWR4jBCTJ8FNZfZmYQP9/aGGLJSrw9/c5uE+4vSVSt3riQlIPAwR3BcAgEHmE7GMg==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6237,25 +6237,25 @@ antd@^6.6.1:
"@rc-component/input" "~1.3.1"
"@rc-component/input-number" "~1.6.2"
"@rc-component/listy" "~1.2.3"
"@rc-component/mentions" "~1.11.0"
"@rc-component/menu" "~1.4.1"
"@rc-component/mentions" "~1.12.0"
"@rc-component/menu" "~1.5.0"
"@rc-component/motion" "^1.3.3"
"@rc-component/mutate-observer" "^2.0.1"
"@rc-component/notification" "~2.0.7"
"@rc-component/notification" "~2.0.8"
"@rc-component/pagination" "~1.4.0"
"@rc-component/picker" "~1.12.0"
"@rc-component/progress" "~1.0.2"
"@rc-component/progress" "~1.0.3"
"@rc-component/qrcode" "~2.0.0"
"@rc-component/rate" "~1.0.1"
"@rc-component/resize-observer" "^1.1.2"
"@rc-component/segmented" "~1.3.0"
"@rc-component/select" "~1.10.1"
"@rc-component/slider" "~1.1.1"
"@rc-component/steps" "~1.2.2"
"@rc-component/steps" "~1.2.3"
"@rc-component/switch" "~1.0.3"
"@rc-component/table" "~1.11.1"
"@rc-component/tabs" "~1.12.0"
"@rc-component/tooltip" "~1.5.0"
"@rc-component/tabs" "~1.13.0"
"@rc-component/tooltip" "~1.5.2"
"@rc-component/tour" "~2.4.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/tree-select" "~1.16.1"
@@ -6471,10 +6471,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.11.19, baseline-browser-mapping@^2.9.19:
version "2.11.19"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz#4711abac48b88ccb56b5817e86f1b3a9a0764276"
integrity sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==
baseline-browser-mapping@^2.11.12, baseline-browser-mapping@^2.11.20, baseline-browser-mapping@^2.9.19:
version "2.11.20"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303"
integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==
batch@0.6.1:
version "0.6.1"
+8 -5
View File
@@ -44,7 +44,7 @@ dependencies = [
# without the ``base.txt`` lock file (#40962).
"cachetools>=7.1.7, <8",
"celery>=5.6.3, <6.0.0",
"click>=8.4.2",
"click>=8.5.0",
"click-option-group",
"colorama",
"flask-cors>=6.0.5, <7.0",
@@ -83,7 +83,7 @@ dependencies = [
"marshmallow>=3.0, <5",
"marshmallow-union>=0.1.15.post1",
"msgpack>=1.2.0, <1.3",
"nh3>=0.3.5, <0.4",
"nh3>=0.3.7, <0.4",
"numpy>=1.23.5, <2.5",
"packaging",
# --------------------------
@@ -109,11 +109,14 @@ dependencies = [
"shillelagh[gsheetsapi]>=1.4.5, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
"simplejson>=4.1.2",
"slack_sdk>=3.43.0, <4",
"sqlalchemy>=2.0.52, <2.1",
"sqlalchemy-continuum>=1.6.0, <2.0.0",
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
# Dialect-specific gaps/bugs against this pin are worked around in
# superset/sql/dialects/ (e.g. starrocks.py); check there for anything
# that can be cleaned up when bumping
"sqlglot>=30.17.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
# newer pandas needs 0.9+
"tabulate>=0.10.0, <1.0",
@@ -135,7 +138,7 @@ athena = ["pyathena[pandas]>=3.35.4, <4"]
# superset/db_engine_specs/aurora.py's known_incompatibilities metadata.
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.35.1",
"pandas-gbq>=0.35.2",
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
"sqlalchemy-bigquery>=1.17.2",
@@ -210,7 +213,7 @@ firebird = ["sqlalchemy-firebird>=2.2.0"]
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
gevent = ["gevent>=26.8.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
hana = ["hdbcli==2.29.27", "sqlalchemy_hana==3.0.3"]
hive = [
"pyhive[hive_pure_sasl]>=0.7.0",
"tableschema",
+3 -3
View File
@@ -58,7 +58,7 @@ cffi==2.0.0
# pynacl
charset-normalizer==3.4.2
# via requests
click==8.4.2
click==8.5.0
# via
# apache-superset (pyproject.toml)
# celery
@@ -240,7 +240,7 @@ msgpack==1.2.1
# via apache-superset (pyproject.toml)
msgspec==0.19.0
# via flask-session
nh3==0.3.6
nh3==0.3.7
# via apache-superset (pyproject.toml)
numexpr==2.10.2
# via -r requirements/base.in
@@ -370,7 +370,7 @@ setuptools==84.0.0
# via -r requirements/base.in
shillelagh==1.4.5
# via apache-superset (pyproject.toml)
simplejson==4.1.1
simplejson==4.1.2
# via apache-superset (pyproject.toml)
six==1.17.0
# via
+4 -4
View File
@@ -131,7 +131,7 @@ charset-normalizer==3.4.2
# via
# -c requirements/base-constraint.txt
# requests
click==8.4.2
click==8.5.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -569,7 +569,7 @@ msgspec==0.19.0
# flask-session
mysqlclient==2.2.8
# via apache-superset
nh3==0.3.6
nh3==0.3.7
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -639,7 +639,7 @@ pandas==2.3.3
# db-dtypes
# pandas-gbq
# prophet
pandas-gbq==0.35.1
pandas-gbq==0.35.2
# via apache-superset
parameterized==0.9.0
# via apache-superset
@@ -932,7 +932,7 @@ shillelagh==1.4.5
# via
# -c requirements/base-constraint.txt
# apache-superset
simplejson==4.1.1
simplejson==4.1.2
# via
# -c requirements/base-constraint.txt
# apache-superset
+2107 -2260
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -37,18 +37,18 @@
"jwt-decode": "^4.0.0"
},
"devDependencies": {
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-typescript": "7.29.7",
"@types/node": "^25.4.0",
"babel-loader": "^9.1.3",
"jsdom": "^26.1.0",
"@babel/cli": "^8.0.1",
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.1",
"@babel/preset-typescript": "^8.0.1",
"@types/node": "^26.4.0",
"babel-loader": "^10.1.1",
"jsdom": "^30.0.1",
"tscw-config": "^1.1.2",
"typescript": "^5.9.3",
"typescript": "^7.0.2",
"vitest": "^4.0.18",
"webpack": "^5.94.0",
"webpack-cli": "^5.1.4"
"webpack": "^5.110.0",
"webpack-cli": "^7.2.3"
},
"repository": {
"type": "git",
+2
View File
@@ -1,5 +1,7 @@
{
"compilerOptions": {
"rootDir": "./src",
// syntax rules
"strict": true,
+1012 -108
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -79,7 +79,7 @@
"playwright:report": "playwright show-report",
"docs:screenshots": "playwright test --config=playwright/generators/playwright.config.ts docs/",
"prod": "npm run build",
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache ./coverage ./storybook-static",
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently --kill-others --success first --names \"SB,TEST\" --prefix-colors \"magenta,blue\" \"python3 -m http.server 6006 --directory storybook-static\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
@@ -157,7 +157,7 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.6.1",
"antd": "^6.6.2",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^3.0.0",
@@ -192,7 +192,7 @@
"mustache": "^4.2.0",
"nanoid": "^6.0.1",
"ol": "^10.10.0",
"postcss": "^8.5.15",
"postcss": "^8.5.26",
"query-string": "9.5.0",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
@@ -270,7 +270,7 @@
"@swc/plugin-transform-imports": "^13.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^12.8.3",
"@types/content-disposition": "^0.5.9",
"@types/dom-to-image": "^2.6.7",
@@ -294,16 +294,16 @@
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.68.0",
"@typescript-eslint/parser": "^8.63.0",
"babel-jest": "^30.4.1",
"babel-jest": "^30.5.0",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.19",
"baseline-browser-mapping": "^2.11.20",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"css-loader": "^7.1.5",
"eslint": "^10.9.1",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
@@ -321,7 +321,7 @@
"html-webpack-plugin": "^5.6.8",
"imports-loader": "^5.0.0",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"jest-environment-jsdom": "^30.5.0",
"jest-html-reporter": "^4.4.0",
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
@@ -329,7 +329,7 @@
"lerna": "^10.0.1",
"lightningcss": "^1.33.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.7.0",
"minimizer-webpack-plugin": "^5.8.0",
"open-cli": "^9.0.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
@@ -353,7 +353,7 @@
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.109.2",
"webpack": "^5.110.1",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0",
@@ -104,7 +104,7 @@
"@emotion/styled": "^11.14.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "*",
"@testing-library/user-event": "*",
"@types/lodash": "^4.17.25",
"@types/react": "*",
@@ -39,7 +39,7 @@
"@superset-ui/core": "*",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "*",
"@testing-library/user-event": "*",
"ace-builds": "^1.4.14",
"brace": "^0.11.1",
@@ -489,6 +489,16 @@ export const MultipleValueComparators = [
Comparator.BetweenOrRightEqual,
];
export enum BoundUnit {
Value = 'value',
Percent = 'percent',
}
export enum PercentDenominator {
Sum = 'sum',
Max = 'max',
}
export type ConditionalFormattingConfig = {
operator?: Comparator;
targetValue?: number | string;
@@ -501,6 +511,14 @@ export type ConditionalFormattingConfig = {
useGradient?: boolean;
columnFormatting?: string;
objectFormatting?: ObjectFormattingEnum;
minBound?: number;
maxBound?: number;
centerValue?: number;
lowColor?: RGBColor | string;
midColor?: RGBColor | string;
highColor?: RGBColor | string;
boundUnit?: BoundUnit;
percentDenominator?: PercentDenominator;
};
export type ColorFormatters = {
@@ -16,10 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import memoizeOne from 'memoize-one';
import memoizeOne, { type MemoizedFn } from 'memoize-one';
import { isString, isBoolean } from 'lodash-es';
import { isBlank } from '@apache-superset/core/utils';
import { addAlpha, DataRecord, rgbaToHex } from '@superset-ui/core';
import { type RGBColor } from '@superset-ui/core/components';
import tinycolor from 'tinycolor2';
import {
ColorFormatters,
@@ -28,6 +29,8 @@ import {
MultipleValueComparators,
ResolvedColorFormatterResult,
ColorSchemeEnum,
BoundUnit,
PercentDenominator,
} from '../types';
export const round = (num: number, precision = 0) =>
@@ -72,6 +75,58 @@ export const getOpacity = (
);
};
const parseColorToRgb = (color: RGBColor | string) => {
if (typeof color === 'string') {
const { r, g, b } = tinycolor(color).toRgb();
return { r, g, b };
}
return { r: color.r, g: color.g, b: color.b };
};
export const getDivergingColor = (
value: number,
cutoffValue: number,
centerValue: number,
extremeValue: number,
lowColor: RGBColor | string,
midColor: RGBColor | string,
highColor: RGBColor | string,
): string => {
const clampedValue = Math.min(Math.max(value, cutoffValue), extremeValue);
const belowCenter = clampedValue <= centerValue;
const from = parseColorToRgb(belowCenter ? lowColor : midColor);
const to = parseColorToRgb(belowCenter ? midColor : highColor);
const rangeStart = belowCenter ? cutoffValue : centerValue;
const rangeEnd = belowCenter ? centerValue : extremeValue;
const ratio =
rangeEnd === rangeStart
? 1
: (clampedValue - rangeStart) / (rangeEnd - rangeStart);
return rgbaToHex({
r: from.r + (to.r - from.r) * ratio,
g: from.g + (to.g - from.g) * ratio,
b: from.b + (to.b - from.b) * ratio,
a: 1,
});
};
const isValidDivergingConfig = (
centerValue: number | undefined,
lowColor: RGBColor | string | undefined,
midColor: RGBColor | string | undefined,
highColor: RGBColor | string | undefined,
cutoffValue: number | string,
extremeValue: number | string,
): centerValue is number =>
centerValue !== undefined &&
lowColor !== undefined &&
midColor !== undefined &&
highColor !== undefined &&
typeof cutoffValue === 'number' &&
typeof extremeValue === 'number' &&
centerValue > cutoffValue &&
centerValue < extremeValue;
const isSpecialColor = (value: unknown): value is ColorSchemeEnum =>
Object.values(ColorSchemeEnum).includes(value as ColorSchemeEnum);
@@ -83,17 +138,61 @@ export const getColorFunction = (
targetValueRight,
colorScheme,
useGradient,
minBound: rawMinBound,
maxBound: rawMaxBound,
centerValue: rawCenterValue,
lowColor,
midColor,
highColor,
boundUnit,
percentDenominator,
}: ConditionalFormattingConfig,
columnValues: number[] | string[] | (boolean | null)[],
alpha?: boolean,
) => {
const resolvePercentBound = (bound: number | undefined) => {
if (boundUnit !== BoundUnit.Percent || bound === undefined) {
return bound;
}
const numericColumnValues = (
columnValues as (number | string | boolean | null)[]
).filter((value): value is number => typeof value === 'number');
if (numericColumnValues.length === 0) {
return undefined;
}
// Sum magnitudes independently so mixed signs do not cancel into an
// unstable denominator. A non-positive column maximum cannot produce
// ordered percentage bounds, so it falls back to the automatic range.
const denominatorValue =
percentDenominator === PercentDenominator.Sum
? numericColumnValues.reduce((sum, value) => sum + Math.abs(value), 0)
: numericColumnValues.reduce(
(max, value) => (value > max ? value : max),
-Infinity,
);
if (denominatorValue <= 0) {
return undefined;
}
return (bound / 100) * denominatorValue;
};
const minBound = resolvePercentBound(rawMinBound);
const maxBound = resolvePercentBound(rawMaxBound);
const centerValue = resolvePercentBound(rawCenterValue);
let minOpacity = MIN_OPACITY_BOUNDED;
const maxOpacity = MAX_OPACITY;
let comparatorFunction: (
value: number | string | boolean | null,
allValues: number[] | string[] | (boolean | null)[],
) => false | { cutoffValue: number | string; extremeValue: number | string };
) =>
| false
| {
cutoffValue: number | string;
extremeValue: number | string;
opacityValue?: number;
};
if (operator === undefined || colorScheme === undefined) {
return () => undefined;
}
@@ -117,8 +216,19 @@ export const getColorFunction = (
if (typeof value !== 'number') {
return { cutoffValue: value!, extremeValue: value! };
}
const cutoffValue = Math.min(...allValues);
const extremeValue = Math.max(...allValues);
const cutoffValue = minBound ?? Math.min(...allValues);
const extremeValue = maxBound ?? Math.max(...allValues);
const hasManualBound = minBound !== undefined || maxBound !== undefined;
if (cutoffValue > extremeValue) {
return false;
}
if (hasManualBound) {
return {
cutoffValue,
extremeValue,
opacityValue: Math.min(Math.max(value, cutoffValue), extremeValue),
};
}
return value >= cutoffValue && value <= extremeValue
? { cutoffValue, extremeValue }
: false;
@@ -129,7 +239,10 @@ export const getColorFunction = (
typeof targetValue === 'number' && value > targetValue!
? {
cutoffValue: targetValue!,
extremeValue: Math.max(...allValues),
extremeValue:
maxBound !== undefined && maxBound > targetValue
? maxBound
: Math.max(...allValues),
}
: false;
break;
@@ -138,7 +251,10 @@ export const getColorFunction = (
typeof targetValue === 'number' && value < targetValue!
? {
cutoffValue: targetValue!,
extremeValue: Math.min(...allValues),
extremeValue:
minBound !== undefined && minBound < targetValue
? minBound
: Math.min(...allValues),
}
: false;
break;
@@ -147,7 +263,10 @@ export const getColorFunction = (
typeof targetValue === 'number' && value >= targetValue!
? {
cutoffValue: targetValue!,
extremeValue: Math.max(...allValues),
extremeValue:
maxBound !== undefined && maxBound > targetValue
? maxBound
: Math.max(...allValues),
}
: false;
break;
@@ -156,7 +275,10 @@ export const getColorFunction = (
typeof targetValue === 'number' && value <= targetValue!
? {
cutoffValue: targetValue!,
extremeValue: Math.min(...allValues),
extremeValue:
minBound !== undefined && minBound < targetValue
? minBound
: Math.min(...allValues),
}
: false;
break;
@@ -272,7 +394,32 @@ export const getColorFunction = (
}
const compareResult = comparatorFunction(value, columnValues);
if (compareResult === false) return undefined;
const { cutoffValue, extremeValue } = compareResult;
const { cutoffValue, extremeValue, opacityValue } = compareResult;
const resolvedValue = opacityValue ?? value;
if (
useGradient !== false &&
operator === Comparator.None &&
typeof resolvedValue === 'number' &&
isValidDivergingConfig(
centerValue,
lowColor,
midColor,
highColor,
cutoffValue,
extremeValue,
)
) {
return getDivergingColor(
resolvedValue,
cutoffValue as number,
centerValue,
extremeValue as number,
lowColor!,
midColor!,
highColor!,
);
}
if (typeof colorScheme === 'string') {
if (isSpecialColor(colorScheme)) {
@@ -297,7 +444,13 @@ export const getColorFunction = (
if (alpha === undefined || alpha) {
return addAlpha(
cleanHex,
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
getOpacity(
opacityValue ?? value,
cutoffValue,
extremeValue,
minOpacity,
maxOpacity,
),
);
}
return colorScheme;
@@ -315,22 +468,47 @@ export const getColorFunction = (
if (alpha === undefined || alpha) {
return addAlpha(
baseHexColor,
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
getOpacity(
opacityValue ?? value,
cutoffValue,
extremeValue,
minOpacity,
maxOpacity,
),
);
}
return baseHexColor;
};
};
export const getColorFormatters = memoizeOne(
type GetColorFormatters = (
columnConfig: ConditionalFormattingConfig[] | undefined,
data: DataRecord[],
theme?: Record<string, any>,
alpha?: boolean,
disablePercentBounds?: boolean,
) => ColorFormatters;
export const getColorFormatters: MemoizedFn<GetColorFormatters> = memoizeOne(
(
columnConfig: ConditionalFormattingConfig[] | undefined,
data: DataRecord[],
theme?: Record<string, any>,
alpha?: boolean,
disablePercentBounds = false,
) =>
columnConfig?.reduce(
(acc: ColorFormatters, config: ConditionalFormattingConfig) => {
const colorFunctionConfig =
disablePercentBounds && config.boundUnit === BoundUnit.Percent
? {
...config,
boundUnit: BoundUnit.Value,
minBound: undefined,
maxBound: undefined,
centerValue: undefined,
}
: config;
let resolvedColorScheme = config.colorScheme;
if (
theme &&
@@ -357,7 +535,7 @@ export const getColorFormatters = memoizeOne(
columnFormatting: config?.columnFormatting,
objectFormatting: config?.objectFormatting,
getColorFromValue: getColorFunction(
{ ...config, colorScheme: resolvedColorScheme },
{ ...colorFunctionConfig, colorScheme: resolvedColorScheme },
data.map(row => row[config.column!] as number),
alpha,
),
@@ -21,10 +21,13 @@ import {
Comparator,
getOpacity,
round,
getColorFormatters,
getColorFunction,
getDivergingColor,
BoundUnit,
PercentDenominator,
} from '../../src';
import {
getColorFormatters,
getReadableTextColor,
getNormalizedTextColor,
getTextColorForBackground,
@@ -84,6 +87,18 @@ test('getOpacity', () => {
expect(getOpacity(50, 75, NaN)).toEqual(1);
});
test('getDivergingColor parses string color endpoints', () => {
expect(
getDivergingColor(25, 0, 50, 100, '#ff0000', '#ffffff', '#008000'),
).toEqual('#ff8080');
});
test('getDivergingColor returns the segment endpoint outright when its range has zero width', () => {
expect(
getDivergingColor(10, 10, 10, 20, '#ff0000', '#ffffff', '#008000'),
).toEqual('#ffffff');
});
test('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
@@ -735,6 +750,38 @@ test('correct column config', () => {
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
test('getColorFormatters falls back to automatic bounds for saved percentage rules when requested', () => {
const colorFormatters = getColorFormatters(
[
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
useGradient: true,
boundUnit: BoundUnit.Percent,
minBound: 0,
maxBound: 200,
},
{
operator: Comparator.None,
colorScheme: '#00FF00',
column: 'sum',
boundUnit: BoundUnit.Value,
},
],
mockData,
undefined,
undefined,
true,
);
expect(colorFormatters).toHaveLength(2);
expect(colorFormatters[0].column).toBe('count');
expect(colorFormatters[0].getColorFromValue(50)).toEqual('#FF000000');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toBe('sum');
});
test('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
@@ -1116,3 +1163,410 @@ test('should strip alpha channel when alpha is false and colorScheme has 9 chars
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(100)).toHaveLength(7);
});
test('should discard the fixed alpha of a 9-char colorScheme before applying gradient opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF000080',
useGradient: true,
column: 'count',
},
countValues,
);
expect(colorFunction(75)).toEqual('#FF000087');
});
test('getColorFunction GREATER_THAN respects manual maxBound', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 0,
maxBound: 100,
colorScheme: '#FF0000',
column: 'count',
},
[25, 50],
);
expect(colorFunction(0)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF000087');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_THAN ignores a maxBound below its target', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 100,
maxBound: 90,
colorScheme: '#FF0000',
column: 'count',
},
[100, 150],
);
expect(colorFunction(125)).toEqual('#FF000087');
expect(colorFunction(150)).toEqual('#FF0000FF');
});
test('getColorFunction LESS_THAN respects manual minBound', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
minBound: 0,
colorScheme: '#FF0000',
column: 'count',
},
[50, 75],
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF000087');
expect(colorFunction(0)).toEqual('#FF0000FF');
expect(colorFunction(-50)).toEqual('#FF0000FF');
});
test('getColorFunction LESS_THAN ignores a minBound above its target', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
minBound: 110,
colorScheme: '#FF0000',
column: 'count',
},
[50, 100],
);
expect(colorFunction(75)).toEqual('#FF000087');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_OR_EQUAL respects manual maxBound', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 0,
maxBound: 100,
colorScheme: '#FF0000',
column: 'count',
},
[25, 50],
);
expect(colorFunction(-10)).toBeUndefined();
expect(colorFunction(0)).toEqual('#FF00000D');
expect(colorFunction(50)).toEqual('#FF000087');
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction LESS_OR_EQUAL respects manual minBound', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
minBound: 0,
colorScheme: '#FF0000',
column: 'count',
},
[50, 75],
);
expect(colorFunction(150)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(50)).toEqual('#FF000087');
expect(colorFunction(0)).toEqual('#FF0000FF');
});
test('getColorFunction NONE respects manual minBound and maxBound', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
minBound: 0,
maxBound: 200,
},
countValues,
);
expect(colorFunction(-10)).toEqual('#FF000000');
expect(colorFunction(0)).toEqual('#FF000000');
expect(colorFunction(100)).toEqual('#FF000080');
expect(colorFunction(200)).toEqual('#FF0000FF');
expect(colorFunction(250)).toEqual('#FF0000FF');
});
test('getColorFunction NONE applies a diverging low/mid/high scale when centerValue and all three colors are set', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#000000',
column: 'count',
minBound: 0,
maxBound: 100,
centerValue: 50,
lowColor: { r: 255, g: 0, b: 0, a: 1 },
midColor: { r: 255, g: 255, b: 255, a: 1 },
highColor: { r: 0, g: 128, b: 0, a: 1 },
},
[10, 90],
);
expect(colorFunction(0)).toEqual('#ff0000');
expect(colorFunction(25)).toEqual('#ff8080');
expect(colorFunction(50)).toEqual('#ffffff');
expect(colorFunction(75)).toEqual('#80c080');
expect(colorFunction(100)).toEqual('#008000');
});
test('getColorFunction NONE uses the solid base color when gradient is disabled for a complete diverging config', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#000000',
useGradient: false,
column: 'count',
minBound: 0,
maxBound: 100,
centerValue: 50,
lowColor: { r: 255, g: 0, b: 0, a: 1 },
midColor: { r: 255, g: 255, b: 255, a: 1 },
highColor: { r: 0, g: 128, b: 0, a: 1 },
},
[10, 90],
);
expect(colorFunction(25)).toEqual('#000000');
expect(colorFunction(75)).toEqual('#000000');
});
test('getColorFunction NONE ignores an incomplete diverging config and falls back to colorScheme', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
minBound: 0,
maxBound: 100,
centerValue: 50,
lowColor: { r: 255, g: 0, b: 0, a: 1 },
// midColor and highColor intentionally omitted
},
[10, 90],
);
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction NONE ignores a centerValue outside the min/max range', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
minBound: 0,
maxBound: 100,
centerValue: 150,
lowColor: { r: 255, g: 0, b: 0, a: 1 },
midColor: { r: 255, g: 255, b: 255, a: 1 },
highColor: { r: 0, g: 128, b: 0, a: 1 },
},
[10, 90],
);
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction NONE resolves percent bounds against column sum', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
boundUnit: BoundUnit.Percent,
percentDenominator: PercentDenominator.Sum,
minBound: 0,
maxBound: 100,
},
[50, 150],
);
// sum = 200, so minBound 0% -> 0, maxBound 100% -> 200 -- the same absolute
// range as the existing 'NONE respects manual minBound and maxBound' test
// above, just reached via percent-of-sum instead of typed directly.
expect(colorFunction(-10)).toEqual('#FF000000');
expect(colorFunction(0)).toEqual('#FF000000');
expect(colorFunction(100)).toEqual('#FF000080');
expect(colorFunction(200)).toEqual('#FF0000FF');
expect(colorFunction(250)).toEqual('#FF0000FF');
});
test('getColorFunction NONE resolves percent bounds against column max', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
boundUnit: BoundUnit.Percent,
percentDenominator: PercentDenominator.Max,
minBound: 0,
maxBound: 100,
},
[10, 40, 90],
);
// max = 90, so maxBound 100% -> 90.
expect(colorFunction(0)).toEqual('#FF000000');
expect(colorFunction(45)).toEqual('#FF000080');
expect(colorFunction(90)).toEqual('#FF0000FF');
});
test('getColorFunction NONE defaults percentDenominator to column max when unset', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
boundUnit: BoundUnit.Percent,
minBound: 0,
maxBound: 100,
},
[10, 40, 90],
);
expect(colorFunction(45)).toEqual('#FF000080');
expect(colorFunction(90)).toEqual('#FF0000FF');
});
test('getColorFunction NONE ignores percentDenominator and treats bounds as absolute when boundUnit is unset', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
percentDenominator: PercentDenominator.Sum,
minBound: 0,
maxBound: 200,
},
[50, 150],
);
// percentDenominator is present but boundUnit is not 'percent', so minBound
// and maxBound are used exactly as typed rather than resolved against the
// sum.
expect(colorFunction(100)).toEqual('#FF000080');
expect(colorFunction(200)).toEqual('#FF0000FF');
});
test('getColorFunction NONE applies a percent-resolved centerValue to the diverging scale', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#000000',
column: 'count',
boundUnit: BoundUnit.Percent,
percentDenominator: PercentDenominator.Max,
minBound: 0,
maxBound: 100,
centerValue: 50,
lowColor: { r: 255, g: 0, b: 0, a: 1 },
midColor: { r: 255, g: 255, b: 255, a: 1 },
highColor: { r: 0, g: 128, b: 0, a: 1 },
},
[0, 100],
);
// max = 100, so every bound resolves to exactly the value it names. This
// verifies that boundUnit and percentDenominator also apply to centerValue.
expect(colorFunction(0)).toEqual('#ff0000');
expect(colorFunction(50)).toEqual('#ffffff');
expect(colorFunction(100)).toEqual('#008000');
});
test('getColorFunction NONE degrades percent bounds to unset when the column has no numeric values', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
boundUnit: BoundUnit.Percent,
percentDenominator: PercentDenominator.Sum,
minBound: 0,
maxBound: 100,
},
[],
);
// No numeric values to sum -> both bounds resolve to unset -> the None
// branch's own no-manual-bound path runs Math.min/Math.max on an empty
// array (Infinity/-Infinity), so cutoffValue > extremeValue and every
// value is rejected -- the same degrade-safe "no coloring" outcome Phase
// 1/2 use elsewhere for invalid config shapes.
expect(colorFunction(0)).toBeUndefined();
expect(colorFunction(50)).toBeUndefined();
});
test('getColorFunction NONE falls back to automatic bounds for a non-positive column max', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
boundUnit: BoundUnit.Percent,
percentDenominator: PercentDenominator.Max,
minBound: 0,
maxBound: 100,
},
[-100, -5],
);
// A negative denominator would reverse the configured 0%-100% bounds.
// Treating both as unset preserves an ordered, data-derived range.
expect(colorFunction(-100)).toEqual('#FF000000');
expect(colorFunction(-5)).toEqual('#FF0000FF');
});
test('getColorFunction NONE uses the sum of magnitudes for mixed-sign values', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
boundUnit: BoundUnit.Percent,
percentDenominator: PercentDenominator.Sum,
minBound: 0,
maxBound: 100,
},
[500, -499, 400, -400],
);
// The signed sum is 1, but the magnitude sum is 1799. Using the signed sum
// would make every positive value saturate at the upper endpoint.
expect(colorFunction(-400)).toEqual('#FF000000');
expect(colorFunction(0)).toEqual('#FF000000');
expect(colorFunction(899.5)).toEqual('#FF000080');
expect(colorFunction(1799)).toEqual('#FF0000FF');
});
test('getColorFunction NONE keeps the running column max when a later value is smaller', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
boundUnit: BoundUnit.Percent,
percentDenominator: PercentDenominator.Max,
minBound: 0,
maxBound: 100,
},
[90, 10, 40],
);
// max = 90 regardless of position in the array, so maxBound 100% -> 90.
expect(colorFunction(45)).toEqual('#FF000080');
expect(colorFunction(90)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_THAN ignores diverging fields even when fully set', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 0,
colorScheme: '#FF0000',
column: 'count',
centerValue: 50,
lowColor: { r: 255, g: 0, b: 0, a: 1 },
midColor: { r: 255, g: 255, b: 255, a: 1 },
highColor: { r: 0, g: 128, b: 0, a: 1 },
},
[25, 100],
);
expect(colorFunction(100)).toEqual('#FF0000FF');
});
@@ -119,7 +119,7 @@
"@emotion/styled": "^11.14.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "*",
"@testing-library/user-event": "*",
"@types/react": "*",
"@types/react-loadable": "*",
@@ -1,115 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render, screen, userEvent } from '@superset-ui/core/spec';
import { useState } from 'react';
import { DynamicEditableTitle } from '.';
const Harness = ({ initialTitle = 'Original' }: { initialTitle?: string }) => {
const [title, setTitle] = useState(initialTitle);
return (
<DynamicEditableTitle
title={title}
placeholder="placeholder"
canEdit
label="Title"
onSave={setTitle}
/>
);
};
test('rapid typing then backspacing keeps every keystroke', async () => {
render(<Harness />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
await userEvent.type(input, 'abc', { delay: 1 });
expect(input.value).toBe('Originalabc');
await userEvent.type(input, '{backspace}{backspace}{backspace}', {
delay: 1,
});
expect(input.value).toBe('Original');
});
test('a change event that arrives before isEditing flips is not dropped', () => {
// Reproduces the regression: the input is focused but `isEditing` is still
// false because no click has been registered yet (e.g. focus arrived via
// tab, autofocus, or programmatic focus). The pre-fix `handleChange`
// bailed out with `!isEditing`, dropping the keystroke. Because the
// input is controlled, antd's internal `useMergedState` then resyncs the
// DOM value back to the (stale) `props.value`, so the user sees their
// typed character disappear. This test fires a raw change event so it
// doesn't go through userEvent's implicit click.
const onSave = jest.fn();
render(
<DynamicEditableTitle
title="Foo"
placeholder="placeholder"
canEdit
label="Title"
onSave={onSave}
/>,
);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'FooX' } });
expect(input.value).toBe('FooX');
});
test('prop changes mid-edit do not clobber unsaved typing', async () => {
// Rerender DynamicEditableTitle directly with a changed title prop so the
// sync effect actually runs. Going through Harness would not exercise the
// bug because Harness owns its own state and only reads initialTitle once.
const onSave = jest.fn();
const props = {
placeholder: 'placeholder',
canEdit: true,
label: 'Title',
onSave,
};
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
await userEvent.type(input, 'X', { delay: 1 });
expect(input.value).toBe('FooX');
rerender(<DynamicEditableTitle {...props} title="Bar" />);
expect(input.value).toBe('FooX');
// Locks in commit semantics: blur after a real edit must persist the
// user's typed value, even when a competing parent-driven title arrived
// mid-edit.
fireEvent.blur(input);
expect(onSave).toHaveBeenCalledWith('FooX');
});
test('passive focus then parent-driven title change then blur does not revert', () => {
// Phantom-revert scenario: user clicks the input but does not type, the
// parent autosaves a new title from elsewhere, then the user blurs. The
// component must NOT call onSave with the stale local value, otherwise it
// would silently overwrite the parent's update.
const onSave = jest.fn();
const props = {
placeholder: 'placeholder',
canEdit: true,
label: 'Title',
onSave,
};
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
rerender(<DynamicEditableTitle {...props} title="Bar" />);
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
});
@@ -16,10 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from '@superset-ui/core/spec';
import { useState } from 'react';
import { DynamicEditableTitle } from '.';
import type { DynamicEditableTitleProps } from './types';
const createProps = (overrides: Record<string, any> = {}) => ({
const createProps = (
overrides: Partial<DynamicEditableTitleProps> = {},
): DynamicEditableTitleProps => ({
title: 'Chart title',
placeholder: 'Add the name of the chart',
canEdit: true,
@@ -28,41 +38,147 @@ const createProps = (overrides: Record<string, any> = {}) => ({
...overrides,
});
describe('Chart editable title', () => {
test('renders chart title', () => {
const props = createProps();
render(<DynamicEditableTitle {...props} />);
expect(screen.getByText('Chart title')).toBeVisible();
const makeRect = (width: number): DOMRect => ({
bottom: 0,
height: 0,
left: 0,
right: width,
top: 0,
width,
x: 0,
y: 0,
toJSON: () => ({}),
});
const Harness = ({ initialTitle = 'Original' }: { initialTitle?: string }) => {
const [title, setTitle] = useState(initialTitle);
return (
<DynamicEditableTitle
title={title}
placeholder="placeholder"
canEdit
label="Title"
onSave={setTitle}
/>
);
};
test('renders chart title', () => {
const props = createProps();
render(<DynamicEditableTitle {...props} />);
expect(screen.getByText('Chart title')).toBeVisible();
});
test('renders placeholder', () => {
const props = createProps({
title: '',
});
render(<DynamicEditableTitle {...props} />);
expect(screen.getByText('Add the name of the chart')).toBeVisible();
});
test('click, edit and save title', async () => {
const props = createProps();
render(<DynamicEditableTitle {...props} />);
const textboxElement = screen.getByRole('textbox');
await userEvent.click(textboxElement);
await userEvent.type(textboxElement, ' edited');
expect(screen.getByText('Chart title edited')).toBeVisible();
await userEvent.type(textboxElement, '{enter}');
expect(props.onSave).toHaveBeenCalled();
});
test('renders in non-editable mode', async () => {
const props = createProps({ canEdit: false });
render(<DynamicEditableTitle {...props} />);
const titleElement = screen.getByLabelText('Chart title');
const inputElement = screen.getByRole('textbox');
expect(inputElement).toBeDisabled();
expect(titleElement).toBeVisible();
await userEvent.click(titleElement);
await userEvent.type(titleElement, ' edited{enter}');
expect(props.onSave).not.toHaveBeenCalled();
});
test('rapid typing then backspacing keeps every keystroke', async () => {
render(<Harness />);
const input = screen.getByRole('textbox') as HTMLInputElement;
await userEvent.click(input);
await userEvent.type(input, 'abc', { delay: 1 });
expect(input.value).toBe('Originalabc');
await userEvent.type(input, '{backspace}{backspace}{backspace}', {
delay: 1,
});
expect(input.value).toBe('Original');
});
test('renders placeholder', () => {
const props = createProps({
title: '',
test('a change event that arrives before edit mode is committed is not dropped', () => {
const onSave = jest.fn();
render(
<DynamicEditableTitle
title="Foo"
placeholder="placeholder"
canEdit
label="Title"
onSave={onSave}
/>,
);
const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'FooX' } });
expect(input.value).toBe('FooX');
});
test('prop changes mid-edit do not clobber unsaved typing', async () => {
const onSave = jest.fn();
const props = {
placeholder: 'placeholder',
canEdit: true,
label: 'Title',
onSave,
};
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
await userEvent.click(input);
await userEvent.type(input, 'X', { delay: 1 });
expect(input.value).toBe('FooX');
rerender(<DynamicEditableTitle {...props} title="Bar" />);
expect(input.value).toBe('FooX');
fireEvent.blur(input);
expect(onSave).toHaveBeenCalledWith('FooX');
});
test('passive focus then parent-driven title change then blur does not revert', async () => {
const onSave = jest.fn();
const props = {
placeholder: 'placeholder',
canEdit: true,
label: 'Title',
onSave,
};
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
await userEvent.click(input);
rerender(<DynamicEditableTitle {...props} title="Bar" />);
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
});
test('rounds fractional title measurements up when sizing the input', async () => {
const getBoundingClientRect = jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getRect(this: HTMLElement) {
return this.classList.contains('input-sizer')
? makeRect(280.31)
: makeRect(0);
});
render(<DynamicEditableTitle {...props} />);
expect(screen.getByText('Add the name of the chart')).toBeVisible();
});
test('click, edit and save title', async () => {
const props = createProps();
render(<DynamicEditableTitle {...props} />);
const textboxElement = screen.getByRole('textbox');
await userEvent.click(textboxElement);
await userEvent.type(textboxElement, ' edited');
expect(screen.getByText('Chart title edited')).toBeVisible();
await userEvent.type(textboxElement, '{enter}');
expect(props.onSave).toHaveBeenCalled();
});
try {
render(<DynamicEditableTitle {...createProps({ title: 'Trends' })} />);
test('renders in non-editable mode', async () => {
const props = createProps({ canEdit: false });
render(<DynamicEditableTitle {...props} />);
const titleElement = screen.getByLabelText('Chart title');
const inputElement = screen.getByRole('textbox');
expect(inputElement).toBeDisabled();
expect(titleElement).toBeVisible();
await userEvent.click(titleElement);
await userEvent.type(titleElement, ' edited{enter}');
expect(props.onSave).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveStyle({ width: '281px' });
});
} finally {
getBoundingClientRect.mockRestore();
}
});
@@ -34,6 +34,9 @@ import { Input } from '../Input';
import type { InputRef } from '../Input';
import type { DynamicEditableTitleProps } from './types';
const measureWidth = (element: HTMLElement) =>
Math.ceil(element.getBoundingClientRect().width);
const titleStyles = (theme: SupersetTheme) => css`
display: flex;
font-size: ${theme.fontSizeXL}px;
@@ -117,14 +120,14 @@ export const DynamicEditableTitle = memo(
// a trick to make the input grow when user types text
// we make an additional span component, place it somewhere out of view and
// mirror the input value, then measure the span synchronously (pre-paint)
// to resize the input element. Reading offsetWidth in a useLayoutEffect
// forces a sync layout, so the input width updates in the same commit as
// the value change — preventing a flicker frame where the input is shown
// with new value but stale width.
// to resize the input element. Measuring in a useLayoutEffect forces a
// sync layout, so the input width updates in the same commit as the value
// change — preventing a flicker frame where the input is shown with new
// value but stale width.
useLayoutEffect(() => {
if (sizerRef.current) {
sizerRef.current.textContent = currentTitle || placeholder;
setInputWidth(sizerRef.current.offsetWidth);
setInputWidth(measureWidth(sizerRef.current));
}
}, [currentTitle, placeholder]);
@@ -135,7 +138,7 @@ export const DynamicEditableTitle = memo(
let cancelled = false;
document.fonts?.ready?.then(() => {
if (!cancelled && sizerRef.current) {
setInputWidth(sizerRef.current.offsetWidth);
setInputWidth(measureWidth(sizerRef.current));
}
});
return () => {
@@ -236,6 +239,7 @@ export const DynamicEditableTitle = memo(
onClick={handleClick}
onPressEnter={handleKeyPress}
placeholder={placeholder}
style={inputWidth > 0 ? { width: inputWidth } : undefined}
css={css`
${
!canEdit &&
@@ -246,13 +250,6 @@ export const DynamicEditableTitle = memo(
}
font-size: ${theme.fontSizeXL}px;
transition: auto;
${
inputWidth &&
inputWidth > 0 &&
css`
width: ${inputWidth}px;
`
}
`}
disabled={!canEdit}
/>
@@ -45,7 +45,7 @@
"@superset-ui/core": "*",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "*",
"@testing-library/user-event": "*",
"@types/react": "*",
"react": "^18.3.0",
@@ -779,6 +779,9 @@ const config: ControlPanelConfig = {
columnOptions,
verboseMap,
extraColorChoices,
serverPagination: Boolean(
explore?.controls?.server_pagination?.value,
),
};
},
},
@@ -779,6 +779,8 @@ const transformProps = (
),
passedData,
theme,
undefined,
serverPagination,
) ?? [];
const hasPageLength = isPositiveNumber(pageLength);
@@ -20,6 +20,7 @@ import transformProps from '../src/transformProps';
import { TableChartProps } from '../src/types';
import { GenericDataType } from '@apache-superset/core/common';
import { QueryMode } from '@superset-ui/core';
import { BoundUnit } from '@superset-ui/chart-controls';
function createMockChartProps(
overrides: Partial<TableChartProps> = {},
@@ -314,3 +315,59 @@ test('excludes Green/Red color-scheme rules from columnColorFormatters', () => {
// ...but the Green rule is excluded.
expect(formattedColumns).not.toContain('metric_a');
});
test('retains saved percentage rules with automatic bounds when server pagination is enabled', () => {
const props = createMockChartProps({
rawFormData: {
viz_type: 'table',
datasource: '1__table',
query_mode: QueryMode.Aggregate,
metrics: ['metric_a', 'metric_b'],
percent_metrics: [],
column_config: {},
table_timestamp_format: '',
granularity_sqla: 'day',
time_range: 'No filter',
server_pagination: true,
conditional_formatting: [
{
column: 'metric_a',
operator: 'None',
colorScheme: '#FF0000',
boundUnit: BoundUnit.Percent,
minBound: 0,
maxBound: 100,
},
{
column: 'metric_b',
operator: 'None',
colorScheme: '#00FF00',
boundUnit: BoundUnit.Value,
},
],
} as unknown as TableChartProps['rawFormData'],
queriesData: [
{
data: [{ metric_a: 10, metric_b: 20 }],
colnames: ['metric_a', 'metric_b'],
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
{
data: [{ rowcount: 1 }],
colnames: ['rowcount'],
coltypes: [GenericDataType.Numeric],
rowcount: 1,
applied_filters: [],
rejected_filters: [],
},
] as unknown as TableChartProps['queriesData'],
});
const result = transformProps(props);
expect(
result.columnColorFormatters.map(formatter => formatter.column),
).toEqual(['metric_a', 'metric_b']);
});
@@ -290,6 +290,66 @@ test('observes extra control height changes when ResizeObserver is available', a
expect(disconnectSpy).toHaveBeenCalled();
});
test('uses the post-control body height for compact custom-legend visibility', async () => {
mockOffsetHeight = 40;
const { queryByTestId } = render(
<EchartsTimeseries
{...defaultProps}
height={140}
echartOptions={{
grid: { bottom: 80, containLabel: true, top: 20 },
}}
formData={{ ...defaultFormData, zoomable: true }}
customLegend={
{
grid: { bottom: 80, top: 20 },
items: Array.from({ length: 20 }, (_, index) => ({
color: '#123456',
interactive: true,
name: `Series ${index}`,
selected: true,
})),
orientation: LegendOrientation.Top,
showSelectors: true,
} as never
}
/>,
);
await waitFor(() => {
expect(queryByTestId('timeseries-custom-legend')).not.toBeInTheDocument();
expect(getLatestHeight()).toBe(100);
expect(getLatestEchartProps().echartOptions.grid).toEqual({
bottom: 80,
containLabel: false,
top: 12,
});
});
});
test('keeps a no-legend grid within a very small post-control body', async () => {
mockOffsetHeight = 40;
render(
<EchartsTimeseries
{...defaultProps}
height={50}
echartOptions={{
grid: { bottom: 37, containLabel: false, top: 12 },
}}
formData={{ ...defaultFormData, zoomable: true }}
/>,
);
await waitFor(() => {
expect(getLatestHeight()).toBe(10);
expect(getLatestEchartProps().echartOptions.grid).toEqual({
bottom: 0,
containLabel: false,
top: 9,
});
});
});
test('falls back to window resize listener when ResizeObserver is unavailable', async () => {
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
undefined;
@@ -26,6 +26,7 @@ import {
getColumnLabel,
getNumberFormatter,
LegendState,
WithLegend,
ensureIsArray,
createTimeRangeFromGranularity,
} from '@superset-ui/core';
@@ -40,17 +41,50 @@ import type GlobalModel from 'echarts/types/src/model/Global';
import type ComponentModel from 'echarts/types/src/model/Component';
import { EchartsHandler, EventHandlers } from '../types';
import Echart from '../components/Echart';
import {
getViableTimeseriesEchartOptions,
resolveTimeseriesGridOffset,
} from './transformers';
import {
rebaseSeriesData,
snapToNearestX,
SeriesDataPoint,
} from './percentChange';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import {
OrientationType,
TimeseriesChartTransformedProps,
TimeseriesCustomLegend,
} from './types';
import { formatSeriesName } from '../utils/series';
import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
import { ExtraControls } from '../components/ExtraControls';
import TimeseriesLegend from './TimeseriesLegend';
import { TIMESERIES_CONSTANTS } from '../constants';
const TIMER_DURATION = 300;
const MAX_CUSTOM_LEGEND_HEIGHT = 160;
const MAX_CUSTOM_LEGEND_HEIGHT_RATIO = 0.3;
const MIN_TIMESERIES_PLOT_HEIGHT = 80;
// Bound the legend after accounting for the fixed ECharts grid reservations,
// leaving enough coordinate space for the plot itself to remain usable.
export const getTimeseriesLegendMaxHeight = (
chartBodyHeight: number,
grid: TimeseriesCustomLegend['grid'],
) =>
Math.min(
MAX_CUSTOM_LEGEND_HEIGHT,
Math.floor(Math.max(chartBodyHeight, 0) * MAX_CUSTOM_LEGEND_HEIGHT_RATIO),
Math.max(
Math.floor(
chartBodyHeight -
resolveTimeseriesGridOffset(grid.top, chartBodyHeight) -
resolveTimeseriesGridOffset(grid.bottom, chartBodyHeight) -
MIN_TIMESERIES_PLOT_HEIGHT,
),
0,
),
);
const getTimestampFromTimeAxisValue = (value: string | number) => {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
@@ -84,6 +118,7 @@ const BASELINE_HANDLE_STRIPE_WIDTH = 2;
export default function EchartsTimeseries({
formData,
height,
customLegend,
width,
echartOptions,
groupby,
@@ -775,23 +810,87 @@ export default function EchartsTimeseries({
},
};
const dispatchLegendAction = useCallback(
(action: { name?: string; seriesName?: string; type: string }) => {
echartRef.current?.getEchartInstance()?.dispatchAction(action);
},
[],
);
const chartBodyHeight = Math.max(height - extraControlHeight, 0);
const customLegendMaxHeight = customLegend
? getTimeseriesLegendMaxHeight(chartBodyHeight, customLegend.grid)
: 0;
const shouldRenderCustomLegend =
customLegend !== undefined &&
chartBodyHeight > TIMESERIES_CONSTANTS.compactChartHeight &&
customLegendMaxHeight > 0;
const chartEchartOptions = useMemo(
() =>
getViableTimeseriesEchartOptions(
echartOptions,
chartBodyHeight,
formData.zoomable,
),
[chartBodyHeight, echartOptions, formData.zoomable],
);
const renderEchart = ({
chartHeight,
chartWidth,
}: {
chartHeight: number;
chartWidth: number;
}) => (
<Echart
ref={echartRef}
refs={refs}
height={chartHeight}
width={chartWidth}
echartOptions={chartEchartOptions}
eventHandlers={eventHandlers}
queryEventHandlers={queryEventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
);
return (
<>
<div ref={extraControlRef}>
<ExtraControls formData={formData} setControlValue={setControlValue} />
</div>
<Echart
ref={echartRef}
refs={refs}
height={height - extraControlHeight}
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
queryEventHandlers={queryEventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
{customLegend && shouldRenderCustomLegend ? (
<WithLegend
height={chartBodyHeight}
position={customLegend.orientation}
width={width}
renderLegend={() => (
<TimeseriesLegend
{...customLegend}
maxHeight={customLegendMaxHeight}
onAll={() => dispatchLegendAction({ type: 'legendAllSelect' })}
onHover={name =>
dispatchLegendAction({
seriesName: name ?? undefined,
type: name === null ? 'downplay' : 'highlight',
})
}
onInverse={() =>
dispatchLegendAction({ type: 'legendInverseSelect' })
}
onToggle={name =>
dispatchLegendAction({ name, type: 'legendToggleSelect' })
}
/>
)}
renderChart={({ height: chartHeight, width: chartWidth }) =>
renderEchart({ chartHeight, chartWidth })
}
/>
) : (
renderEchart({ chartHeight: chartBodyHeight, chartWidth: width })
)}
</>
);
}
@@ -0,0 +1,196 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import type { TimeseriesCustomLegend } from './types';
const LegendViewport = styled.div<{ maxHeight: number }>`
${({ maxHeight, theme }) => `
box-sizing: border-box;
color: ${theme.colorText};
font-size: ${theme.fontSizeSM}px;
max-height: ${maxHeight}px;
overflow-x: hidden;
overflow-y: auto;
padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
width: 100%;
`}
`;
const SelectorRow = styled.div`
${({ theme }) => `
align-items: center;
background: ${theme.colorBgContainer};
display: flex;
gap: ${theme.sizeUnit}px;
justify-content: flex-end;
padding-bottom: ${theme.sizeUnit}px;
position: sticky;
top: 0;
z-index: 1;
`}
`;
const SelectorButton = styled.button`
${({ theme }) => `
background: transparent;
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
color: ${theme.colorText};
cursor: pointer;
font: inherit;
padding: 0 ${theme.sizeUnit}px;
&:hover {
color: ${theme.colorPrimary};
}
`}
`;
const ItemList = styled.ul`
${({ theme }) => `
align-items: flex-start;
display: flex;
flex-wrap: wrap;
gap: ${theme.sizeUnit * 2}px;
list-style: none;
margin: 0;
padding: 0;
`}
`;
const LegendItem = styled.li`
min-width: 0;
`;
const ItemButton = styled.button<{ selected: boolean }>`
${({ selected, theme }) => `
align-items: flex-start;
appearance: none;
background: none;
border: none;
color: ${selected ? theme.colorText : theme.colorTextDisabled};
cursor: pointer;
display: inline-flex;
font: inherit;
gap: ${theme.sizeUnit}px;
max-width: 100%;
padding: 0;
text-align: left;
&:disabled {
cursor: default;
}
`}
`;
const Swatch = styled.span<{ color: string; selected: boolean }>`
${({ color, selected }) => `
background: ${selected ? color : 'transparent'};
border: 1px solid ${color};
box-sizing: border-box;
flex: 0 0 auto;
height: 12px;
margin-top: 2px;
width: 12px;
`}
`;
const Label = styled.span`
overflow-wrap: anywhere;
white-space: pre-line;
`;
const RowBreak = styled.li`
flex-basis: 100%;
height: 0;
`;
export type TimeseriesLegendProps = TimeseriesCustomLegend & {
maxHeight: number;
onAll: () => void;
onHover?: (name: string | null) => void;
onInverse: () => void;
onToggle: (name: string) => void;
};
function TimeseriesLegend({
items,
maxHeight,
onAll,
onHover,
onInverse,
onToggle,
showSelectors,
}: TimeseriesLegendProps) {
if (items.length === 0 || maxHeight <= 0) {
return null;
}
return (
<LegendViewport data-test="timeseries-custom-legend" maxHeight={maxHeight}>
{showSelectors && (
<SelectorRow>
<SelectorButton type="button" onClick={onAll}>
{t('All')}
</SelectorButton>
<SelectorButton type="button" onClick={onInverse}>
{t('Inverse')}
</SelectorButton>
</SelectorRow>
)}
<ItemList>
{items.map((item, index) =>
item.name === '' || item.name === '\n' ? (
<RowBreak
// ECharts treats these exact values as row-break sentinels.
key={`row-break-${item.name}-${index}`}
/>
) : (
<LegendItem key={item.name}>
<ItemButton
aria-pressed={item.selected}
disabled={!item.interactive}
selected={item.selected}
type="button"
onClick={() => {
if (item.interactive) {
onToggle(item.name);
}
}}
onMouseEnter={() => onHover?.(item.name)}
onMouseLeave={() => onHover?.(null)}
>
<Swatch
aria-hidden
color={item.color}
selected={item.selected}
/>
<Label>{item.name}</Label>
</ItemButton>
</LegendItem>
),
)}
</ItemList>
</LegendViewport>
);
}
export default memo(TimeseriesLegend);
@@ -41,6 +41,7 @@ import {
isIntervalAnnotationLayer,
isPhysicalColumn,
isTimeseriesAnnotationLayer,
LegendState,
resolveAutoCurrency,
TimeseriesChartDataResponseResult,
TimeseriesDataRecord,
@@ -66,7 +67,9 @@ import {
EchartsTimeseriesSeriesType,
BarValueLabelPosition,
OrientationType,
TimeseriesCustomLegend,
TimeseriesChartTransformedProps,
TimeseriesLegendItem,
} from './types';
import { DEFAULT_FORM_DATA } from './constants';
import {
@@ -112,6 +115,7 @@ import { defaultGrid, defaultYAxis } from '../defaults';
import {
getBaselineSeriesForStream,
getPadding,
getViableTimeseriesEchartOptions,
transformEventAnnotation,
transformFormulaAnnotation,
transformIntervalAnnotation,
@@ -137,6 +141,77 @@ import {
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
type LegendSeriesVisual = {
itemStyle?: { color?: unknown };
lineStyle?: { color?: unknown };
name?: string | number;
};
function getLegendSeriesColor(
series: SeriesOption | undefined,
fallbackColor: string,
): string {
const visual = series as LegendSeriesVisual | undefined;
const color = visual?.itemStyle?.color ?? visual?.lineStyle?.color;
return typeof color === 'string' ? color : fallbackColor;
}
function buildTimeseriesCustomLegend({
fallbackColor,
grid,
interactive,
legendNames,
legendState,
orientation,
series,
}: {
fallbackColor: string;
grid: TimeseriesCustomLegend['grid'];
interactive: boolean;
legendNames: string[];
legendState?: LegendState;
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
series: SeriesOption[];
}): TimeseriesCustomLegend {
const firstSeriesByName = new Map<string, SeriesOption>();
series.forEach(seriesOption => {
const { name } = seriesOption as LegendSeriesVisual;
if (name !== undefined && !firstSeriesByName.has(String(name))) {
firstSeriesByName.set(String(name), seriesOption);
}
});
const seen = new Set<string>();
const items = legendNames.flatMap<TimeseriesLegendItem>(name => {
if (seen.has(name)) {
return [];
}
seen.add(name);
const rowBreak = name === '' || name === '\n';
const matchingSeries = firstSeriesByName.get(name);
if (!rowBreak && !matchingSeries) {
return [];
}
return [
{
color: getLegendSeriesColor(matchingSeries, fallbackColor),
interactive: interactive && !rowBreak,
name,
selected: legendState?.[name] !== false,
},
];
});
return {
grid,
items,
orientation,
showSelectors: interactive,
};
}
const visibleDashPatterns: ([number, number] | 'dashed' | 'dotted')[] = [
'dashed',
'dotted',
@@ -1138,9 +1213,30 @@ export default function transformProps(
name,
icon: 'roundRect',
}));
const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight;
const usesCompactLayout = height <= TIMESERIES_CONSTANTS.compactChartHeight;
const isLegendVisible = showLegend && !usesCompactLayout;
const usesPrimaryAxisLegend = colorByPrimaryAxis && groupBy.length === 0;
const resolvedLegendData = usesPrimaryAxisLegend
? colorByPrimaryAxisLegendData
: sortedLegendData;
const resolvedLegendNames = (
usesPrimaryAxisLegend ? legendData : sortedLegendData
).map(String);
const usesCustomLegend =
isLegendVisible &&
legendType === LegendType.Plain &&
(legendOrientation === LegendOrientation.Top ||
legendOrientation === LegendOrientation.Bottom);
const nativeLegendVisible = isLegendVisible && !usesCustomLegend;
// Use the exact final ordering ECharts receives. Forecast components share
// a legend name, and ECharts takes the first matching series as its visual.
const renderedSeries = dedupSeries(
reorderForecastSeries([...series]) as SeriesOption[],
);
const getLegendLayout = (candidateLegendMargin?: string | number | null) => {
const padding = getPadding(
showLegend,
nativeLegendVisible,
legendOrientation,
addYAxisLabelOffset,
zoomable,
@@ -1165,20 +1261,18 @@ export default function transformProps(
: undefined,
chartHeight: height,
chartWidth: width,
legendItems:
colorByPrimaryAxis && groupBy.length === 0
? colorByPrimaryAxisLegendData
: sortedLegendData,
legendItems: resolvedLegendData,
legendMargin: candidateLegendMargin,
orientation: legendOrientation,
show: showLegend,
showSelectors: !(colorByPrimaryAxis && groupBy.length === 0),
show: nativeLegendVisible,
showSelectors: !usesPrimaryAxisLegend,
theme,
type: legendType,
});
};
const initialLegendLayout = getLegendLayout(legendMargin);
const legendLayout =
nativeLegendVisible &&
isHorizontal &&
legendOrientation === LegendOrientation.Bottom &&
initialLegendLayout.effectiveLegendType === LegendType.Plain
@@ -1192,7 +1286,7 @@ export default function transformProps(
? legendMargin
: legendLayout.effectiveLegendMargin;
const padding = getPadding(
showLegend,
nativeLegendVisible,
legendOrientation,
addYAxisLabelOffset,
zoomable,
@@ -1207,7 +1301,7 @@ export default function transformProps(
// Reduce grid padding for small charts to maximize the drawing area.
// Keep enough top padding so the max label doesn't clip against the cell border.
// Preserve bottom padding when zoomable, since getPadding() reserves space for the dataZoom slider.
if (height < TIMESERIES_CONSTANTS.compactChartHeight) {
if (usesCompactLayout) {
padding.top = Math.min(padding.top, 12);
if (!zoomable) {
padding.bottom = Math.min(padding.bottom, 5);
@@ -1301,7 +1395,6 @@ export default function transformProps(
// >= 100px: full axis with proportional tick count
// 60-99px: show only min/max boundary labels (splitNumber=1), hide lines/ticks
// < 60px: hide all axis decorations, show line only
const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight;
const isMicroChart = height < TIMESERIES_CONSTANTS.microChartHeight;
const yAxisSplitNumber = isMicroChart
? undefined
@@ -1378,6 +1471,9 @@ export default function transformProps(
grid: {
...defaultGrid,
...padding,
// Compact charts prioritize a viable coordinate system over keeping
// axis labels inside an already constrained grid rectangle.
containLabel: !usesCompactLayout,
},
xAxis,
yAxis,
@@ -1504,27 +1600,23 @@ export default function transformProps(
...getLegendProps(
effectiveLegendType,
legendOrientation,
// Hide legend on compact charts — not enough vertical space
isSmallChart ? false : showLegend,
nativeLegendVisible,
theme,
zoomable,
legendState,
padding,
),
scrollDataIndex: legendIndex || 0,
data:
colorByPrimaryAxis && groupBy.length === 0
? colorByPrimaryAxisLegendData
: sortedLegendData,
data: resolvedLegendData,
// Disable legend selection and buttons when colorByPrimaryAxis is enabled
...(colorByPrimaryAxis && groupBy.length === 0
...(usesPrimaryAxisLegend
? {
selectedMode: false, // Disable clicking legend items
selector: false, // Hide All/Invert buttons
}
: {}),
},
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
series: renderedSeries,
toolbox: {
show: zoomable,
top: TIMESERIES_CONSTANTS.toolboxTop,
@@ -1581,9 +1673,57 @@ export default function transformProps(
const mergedEchartOptions = customEchartOptions
? mergeCustomEChartOptions(echartOptions, customEchartOptions)
: echartOptions;
const viableEchartOptions = getViableTimeseriesEchartOptions(
mergedEchartOptions,
height,
zoomable,
);
const mergedSeries = viableEchartOptions.series;
const finalSeries = Array.isArray(mergedSeries)
? (mergedSeries as SeriesOption[])
: mergedSeries && typeof mergedSeries === 'object'
? [mergedSeries as SeriesOption]
: renderedSeries;
const mergedGrid = Array.isArray(viableEchartOptions.grid)
? viableEchartOptions.grid[0]
: viableEchartOptions.grid;
const finalGrid =
mergedGrid && typeof mergedGrid === 'object' ? mergedGrid : padding;
const customLegend = usesCustomLegend
? buildTimeseriesCustomLegend({
fallbackColor: theme.colorTextSecondary,
grid: {
bottom:
typeof finalGrid.bottom === 'number' ||
typeof finalGrid.bottom === 'string'
? finalGrid.bottom
: padding.bottom,
top:
typeof finalGrid.top === 'number' ||
typeof finalGrid.top === 'string'
? finalGrid.top
: padding.top,
},
interactive: !usesPrimaryAxisLegend,
legendNames: resolvedLegendNames,
legendState,
orientation: legendOrientation,
series: finalSeries,
})
: undefined;
const finalEchartOptions = usesCustomLegend
? {
...viableEchartOptions,
legend: {
...(viableEchartOptions.legend as Record<string, unknown>),
show: false,
},
}
: viableEchartOptions;
return {
echartOptions: mergedEchartOptions,
customLegend,
echartOptions: finalEchartOptions,
emitCrossFilters,
formData,
groupby: groupBy,
@@ -1002,3 +1002,76 @@ export function getPadding(
isHorizontal,
);
}
const MIN_ECHARTS_GRID_HEIGHT = 1;
export function resolveTimeseriesGridOffset(
offset: unknown,
chartHeight: number,
) {
if (typeof offset === 'number') {
return Number.isFinite(offset) ? Math.max(offset, 0) : 0;
}
if (typeof offset !== 'string') {
return 0;
}
const percentage = offset.match(/^\s*(-?\d+(?:\.\d+)?)%\s*$/);
const pixels = percentage
? (Number(percentage[1]) / 100) * chartHeight
: Number(offset);
return Number.isFinite(pixels) ? Math.max(pixels, 0) : 0;
}
export function getViableTimeseriesEchartOptions<Options extends object>(
options: Options,
chartHeight: number,
zoomable: boolean,
): Options {
const optionWithGrid = options as Options & { grid?: unknown };
const gridOption = Array.isArray(optionWithGrid.grid)
? optionWithGrid.grid[0]
: optionWithGrid.grid;
if (!gridOption || typeof gridOption !== 'object') {
return options;
}
const grid = gridOption as Record<string, unknown>;
const rawTop = resolveTimeseriesGridOffset(grid.top, chartHeight);
const rawBottom = resolveTimeseriesGridOffset(grid.bottom, chartHeight);
const isCompact = chartHeight <= TIMESERIES_CONSTANTS.compactChartHeight;
const requestedTop = isCompact ? Math.min(rawTop, 12) : rawTop;
const requestedBottom =
isCompact && !zoomable ? Math.min(rawBottom, 5) : rawBottom;
// Cap both reservations so even a tiny canvas retains a coordinate region.
const reservationBudget = Math.max(chartHeight - MIN_ECHARTS_GRID_HEIGHT, 0);
const top = Math.min(requestedTop, reservationBudget);
const bottom = Math.min(
requestedBottom,
Math.max(reservationBudget - top, 0),
);
const mustDisableContainLabel =
isCompact || requestedTop + requestedBottom > reservationBudget;
if (
top === rawTop &&
bottom === rawBottom &&
(!mustDisableContainLabel || grid.containLabel === false)
) {
return options;
}
const viableGrid = {
...grid,
bottom,
...(mustDisableContainLabel ? { containLabel: false } : {}),
top,
};
return {
...options,
grid: Array.isArray(optionWithGrid.grid)
? [viableGrid, ...optionWithGrid.grid.slice(1)]
: viableGrid,
} as Options;
}
@@ -34,6 +34,7 @@ import {
CrossFilterTransformedProps,
LabelPositionEnum,
LegendFormData,
LegendOrientation,
StackType,
TitleFormData,
} from '../types';
@@ -133,10 +134,28 @@ export interface EchartsTimeseriesChartProps extends BaseChartProps<EchartsTimes
formData: EchartsTimeseriesFormData;
}
export type TimeseriesLegendItem = {
color: string;
interactive: boolean;
name: string;
selected: boolean;
};
export type TimeseriesCustomLegend = {
grid: {
bottom: number | string;
top: number | string;
};
items: TimeseriesLegendItem[];
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
showSelectors: boolean;
};
export type TimeseriesChartTransformedProps =
BaseTransformedProps<EchartsTimeseriesFormData> &
ContextMenuTransformedProps &
CrossFilterTransformedProps & {
customLegend?: TimeseriesCustomLegend;
legendData?: OptionName[];
isRefreshing?: boolean;
xValueFormatter: TimeFormatter | StringConstructor;
@@ -43,11 +43,6 @@ import {
OrientationType,
EchartsTimeseriesSeriesType,
} from '../../../src/Timeseries/types';
import { getPadding } from '../../../src/Timeseries/transformers';
import {
getHorizontalLegendAvailableWidth,
getLegendLayoutResult,
} from '../../../src/utils/series';
import { createEchartsTimeseriesTestChartProps } from '../../helpers';
function createTestQueryData(
@@ -646,6 +641,41 @@ describe('Bar Chart X-axis Time Formatting', () => {
expect(legend.selector).toBe(false);
});
test('marks custom Plain legend items non-interactive when color by x-axis is enabled', () => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: {
...baseFormData,
colorByPrimaryAxis: true,
groupby: [],
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
metric: 'value',
showLegend: true,
x_axis: 'category',
},
queriesData: categoricalData,
});
const transformedProps = transformProps(
chartProps as unknown as EchartsTimeseriesChartProps,
);
const { customLegend } = transformedProps as unknown as {
customLegend?: {
items: { interactive: boolean; name: string }[];
showSelectors: boolean;
};
};
expect(customLegend?.showSelectors).toBe(false);
expect(customLegend?.items.map(item => item.name)).toEqual([
'A',
'B',
'C',
]);
expect(customLegend?.items.every(item => !item.interactive)).toBe(true);
});
test('should work without stacking enabled', () => {
const formData = {
...baseFormData,
@@ -922,39 +952,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
});
describe('Legend layout regressions', () => {
const getBottomLegendLayout = (
chartWidth: number,
legendItems: string[],
legendMargin?: string | number | null,
) =>
getLegendLayoutResult({
availableWidth: getHorizontalLegendAvailableWidth({
chartWidth,
orientation: LegendOrientation.Bottom,
padding: getPadding(
true,
LegendOrientation.Bottom,
false,
false,
legendMargin,
false,
undefined,
undefined,
undefined,
true,
),
}),
chartHeight: baseChartPropsConfig.height,
chartWidth,
legendItems,
legendMargin,
orientation: LegendOrientation.Bottom,
show: true,
theme: supersetTheme,
type: LegendType.Plain,
});
test('honors an explicit List selection for horizontal bottom legends and reserves margin', () => {
test('honors an explicit List selection with a custom horizontal bottom legend', () => {
const legendLabels = [
'This is a long sales legend',
'This is a long marketing legend',
@@ -1019,58 +1017,26 @@ describe('Bar Chart X-axis Time Formatting', () => {
const legend = transformedProps.echartOptions
.legend as LegendComponentOption;
const grid = transformedProps.echartOptions.grid as GridComponentOption;
const legendItems = (legend.data as Array<string | { name: string }>).map(
item => (typeof item === 'string' ? item : item.name),
);
const layout = getBottomLegendLayout(chartWidth, legendItems, null);
const basePadding = getPadding(
true,
LegendOrientation.Bottom,
false,
false,
null,
false,
undefined,
undefined,
undefined,
true,
);
[basePadding.bottom, basePadding.left] = [
basePadding.left,
basePadding.bottom,
];
const { customLegend } = transformedProps as unknown as {
customLegend?: {
items: { name: string }[];
orientation: LegendOrientation;
};
};
const resolvedLegendItems = (
legend.data as Array<string | { name: string }>
).map(item => (typeof item === 'string' ? item : item.name));
// The explicit List selection is honored end-to-end (never flips).
expect(legend.type).toBe(LegendType.Plain);
expect(layout.effectiveType).toBe(LegendType.Plain);
// #38675's margin reservation is retained: the wrapped rows reserve a
// finite margin beyond the single-row baseline, so the grid shrinks to
// reduce clipping instead of the legend flipping to scroll.
expect(Number.isFinite(layout.effectiveMargin)).toBe(true);
const reservedPadding = getPadding(
true,
LegendOrientation.Bottom,
false,
false,
layout.effectiveMargin,
false,
undefined,
undefined,
undefined,
true,
);
[reservedPadding.bottom, reservedPadding.left] = [
reservedPadding.left,
reservedPadding.bottom,
];
expect(grid.bottom).toBe(reservedPadding.bottom);
expect(grid.bottom as number).toBeGreaterThan(
basePadding.bottom as number,
expect(legend.show).toBe(false);
expect(customLegend?.orientation).toBe(LegendOrientation.Bottom);
expect(customLegend?.items.map(item => item.name)).toEqual(
resolvedLegendItems,
);
// The plot canvas no longer reserves native legend rows; the independently
// scrolling HTML legend consumes space outside the ECharts grid.
expect(grid.bottom).toBe(20);
});
});
@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { logging } from '@apache-superset/core/utils';
@@ -27,6 +28,49 @@ import {
TimeseriesChartTransformedProps,
} from '../../src/Timeseries/types';
import type { EchartsProps } from '../../src/types';
import { LegendOrientation } from '../../src/types';
jest.mock('@visx/responsive', () => ({
ParentSize: ({
children,
}: {
children: (size: object) => React.ReactNode;
}) => {
const React = jest.requireActual<typeof import('react')>('react');
const hostRef = React.useRef<HTMLDivElement>(null);
const [size, setSize] = React.useState<{
height: number;
width: number;
}>();
React.useLayoutEffect(() => {
const frame = hostRef.current?.closest<HTMLElement>('.with-legend');
const legend = frame?.querySelector<HTMLElement>(
'[data-test="timeseries-custom-legend"]',
);
if (!frame || !legend) {
return;
}
const frameHeight = Number.parseFloat(frame.style.height);
const frameWidth = Number.parseFloat(frame.style.width);
const maxHeight = Number.parseFloat(
globalThis.getComputedStyle(legend).maxHeight,
);
const itemCount = legend.querySelectorAll('[aria-pressed]').length;
const selectorHeight = legend.querySelectorAll('[aria-pressed]').length
? 20
: 0;
const naturalHeight = selectorHeight + Math.ceil(itemCount / 4) * 20;
setSize({
height: frameHeight - Math.min(maxHeight, naturalHeight),
width: frameWidth,
});
}, []);
return <div ref={hostRef}>{size ? children(size) : null}</div>;
},
}));
// Percent-change draggable baseline: this is the one piece of the ECharts
// rebuilds with zero prior test coverage despite six separate production
@@ -40,6 +84,7 @@ import type { EchartsProps } from '../../src/types';
// mockImplementation afterward) because forwardRef() returns a React
// element descriptor, not a plain function a jest mock can invoke.
let mockChart: {
dispatchAction: jest.Mock;
setOption: jest.Mock;
getHeight: jest.Mock;
convertToPixel: jest.Mock;
@@ -57,7 +102,8 @@ jest.mock('../../src/components/Echart', () => {
useImperativeHandle(ref, () => ({
getEchartInstance: () => mockChart,
}));
return null;
const { height } = props as { height: number };
return <div data-height={height} data-test="mock-echart" />;
}),
};
});
@@ -66,6 +112,7 @@ const PX_PER_UNIT = 100;
function setupChartMock() {
mockChart = {
dispatchAction: jest.fn(),
setOption: jest.fn(),
getHeight: jest.fn(() => 400),
// A trivial, invertible mapping so drag pixel deltas translate to
@@ -82,6 +129,24 @@ function setupChartMock() {
};
}
function getCustomLegend(
itemCount: number,
overrides: Record<string, unknown> = {},
) {
return {
items: Array.from({ length: itemCount }, (_, index) => ({
color: `rgb(${index % 255}, 0, 0)`,
interactive: true,
name: `Series ${index + 1}`,
selected: true,
})),
orientation: LegendOrientation.Top,
grid: { bottom: 20, top: 20 },
showSelectors: true,
...overrides,
};
}
const BASE_SERIES_DATA: [number, number][] = [
[0, 10],
[1, 20],
@@ -158,6 +223,160 @@ afterEach(() => {
jest.restoreAllMocks();
});
test('renders fitting content at the allocated height without a scroll viewport', () => {
const { container } = renderTimeseries();
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
'data-height',
'400',
);
expect(container.querySelector('[style*="overflow-y"]')).toBeNull();
});
test('caps a dense custom Plain legend while keeping scrolling inside the legend region', () => {
renderTimeseries({
...({ customLegend: getCustomLegend(200) } as any),
formData: { rebasePercentChange: false } as any,
});
const legend = screen.getByTestId('timeseries-custom-legend');
expect(legend).toHaveStyle({
maxHeight: '120px',
overflowY: 'auto',
});
expect(legend.style.height).toBe('');
expect(screen.getAllByRole('button')).toHaveLength(202);
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
'data-height',
'280',
);
expect(legend.closest('.with-legend')).toHaveStyle({
height: '400px',
width: '800px',
});
});
test('lets a short custom Plain legend use its natural height', () => {
renderTimeseries({
...({ customLegend: getCustomLegend(2) } as any),
formData: { rebasePercentChange: false } as any,
});
const legend = screen.getByTestId('timeseries-custom-legend');
expect(legend).toHaveStyle({ maxHeight: '120px' });
expect(legend.style.height).toBe('');
expect(screen.getAllByRole('button')).toHaveLength(4);
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
'data-height',
'360',
);
});
test.each([
[99, 99],
[100, 100],
[120, 120],
])(
'does not allocate a custom legend when a %ipx zoomable chart has no usable grid space',
(height, expectedChartHeight) => {
renderTimeseries({
...({
customLegend: getCustomLegend(200, {
grid: { bottom: 80, top: 20 },
}),
} as any),
formData: { rebasePercentChange: false, zoomable: true } as any,
height,
});
expect(
screen.queryByTestId('timeseries-custom-legend'),
).not.toBeInTheDocument();
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
'data-height',
String(expectedChartHeight),
);
},
);
test('accounts for axis-title grid reservations when bounding the custom legend', () => {
renderTimeseries({
...({
customLegend: getCustomLegend(200, {
grid: { bottom: 80, top: 60 },
}),
} as any),
formData: { rebasePercentChange: false } as any,
height: 240,
});
expect(screen.getByTestId('timeseries-custom-legend')).toHaveStyle({
maxHeight: '20px',
});
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
'data-height',
'220',
);
});
test.each([
[LegendOrientation.Top, 'column'],
[LegendOrientation.Bottom, 'column-reverse'],
])('places a custom Plain legend at %s', (orientation, flexDirection) => {
renderTimeseries({
...({
customLegend: getCustomLegend(2, { orientation }),
} as any),
formData: { rebasePercentChange: false } as any,
});
expect(
screen.getByTestId('timeseries-custom-legend').closest('.with-legend'),
).toHaveStyle({ flexDirection });
});
test('dispatches the native ECharts toggle, All, and Inverse legend actions', () => {
renderTimeseries({
...({ customLegend: getCustomLegend(2) } as any),
formData: { rebasePercentChange: false } as any,
});
fireEvent.click(screen.getByRole('button', { name: 'Series 1' }));
fireEvent.click(screen.getByRole('button', { name: 'All' }));
fireEvent.click(screen.getByRole('button', { name: 'Inverse' }));
expect(mockChart.dispatchAction.mock.calls).toEqual([
[{ name: 'Series 1', type: 'legendToggleSelect' }],
[{ type: 'legendAllSelect' }],
[{ type: 'legendInverseSelect' }],
]);
});
test('does not dispatch legend actions for non-interactive color-by-primary-axis items', () => {
renderTimeseries({
...({
customLegend: getCustomLegend(2, {
items: getCustomLegend(2).items.map(item => ({
...item,
interactive: false,
})),
showSelectors: false,
}),
} as any),
formData: { rebasePercentChange: false } as any,
});
const item = screen.getByRole('button', { name: 'Series 1' });
expect(item).toBeDisabled();
fireEvent.click(item);
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Inverse' }),
).not.toBeInTheDocument();
expect(mockChart.dispatchAction).not.toHaveBeenCalled();
});
test('draws the baseline handle at the first x value on mount', () => {
renderTimeseries();
@@ -37,7 +37,8 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
import type { SeriesOption } from 'echarts';
import { init, type SeriesOption } from 'echarts';
import type { GridComponentOption } from 'echarts/components';
import transformProps from '../../src/Timeseries/transformProps';
import {
EchartsTimeseriesSeriesType,
@@ -46,6 +47,7 @@ import {
} from '../../src/Timeseries/types';
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
import {
ForecastSeriesEnum,
LegendOrientation,
LegendType,
EchartsTimeseriesChartProps,
@@ -160,6 +162,27 @@ const formData: SqlaFormData = {
viz_type: 'my_viz',
};
type CustomLegendResult = {
customLegend?: {
grid: {
bottom: number | string;
top: number | string;
};
items: {
color: string;
interactive: boolean;
name: string;
selected: boolean;
}[];
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
showSelectors: boolean;
};
};
function getCustomLegend(transformed: ReturnType<typeof transformProps>) {
return (transformed as unknown as CustomLegendResult).customLegend;
}
describe('EchartsTimeseries transformProps', () => {
test('should transform chart props for viz', () => {
const chartProps = createTestChartProps({});
@@ -1177,6 +1200,345 @@ test('honors an explicit List selection for zoomable top legends even when toolb
expect((transformed.echartOptions.legend as any).type).toBe(LegendType.Plain);
});
test('moves a visible horizontal Plain legend into a custom HTML legend and restores normal plot padding', () => {
const chartProps = createTestChartProps({
width: 800,
height: 400,
formData: {
...formData,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: true,
yAxisTitleMargin: 0,
yAxisTitlePosition: 'Left',
},
});
const transformed = transformProps(chartProps);
const legend = transformed.echartOptions.legend as {
show?: boolean;
type?: LegendType;
};
const grid = transformed.echartOptions.grid as GridComponentOption;
const customLegend = getCustomLegend(transformed);
expect(legend).toMatchObject({ show: false, type: LegendType.Plain });
expect(grid).toMatchObject({ top: 20, bottom: 20 });
expect(customLegend).toMatchObject({
orientation: LegendOrientation.Top,
showSelectors: true,
});
expect(customLegend?.items.map(item => item.name)).toEqual([
'San Francisco',
'New York',
]);
expect(customLegend?.items.every(item => item.interactive)).toBe(true);
expect(customLegend?.items.every(item => item.selected)).toBe(true);
expect(customLegend?.items.every(item => Boolean(item.color))).toBe(true);
expect('contentHeight' in transformed).toBe(false);
});
test.each([LegendOrientation.Top, LegendOrientation.Bottom])(
'uses the custom HTML legend for a %s-oriented Plain legend',
legendOrientation => {
const transformed = transformProps(
createTestChartProps({
formData: {
...formData,
legendOrientation,
legendType: LegendType.Plain,
showLegend: true,
},
}),
);
expect(getCustomLegend(transformed)?.orientation).toBe(legendOrientation);
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
false,
);
},
);
test.each([
[LegendType.Scroll, LegendOrientation.Top],
[LegendType.Plain, LegendOrientation.Left],
[LegendType.Plain, LegendOrientation.Right],
] as const)(
'keeps %s/%s legends on the native ECharts path',
(legendType, legendOrientation) => {
const transformed = transformProps(
createTestChartProps({
formData: {
...formData,
legendOrientation,
legendType,
showLegend: true,
},
}),
);
expect(getCustomLegend(transformed)).toBeUndefined();
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
true,
);
},
);
test('keeps the custom legend absent when a compact chart hides the Plain legend', () => {
const transformed = transformProps(
createTestChartProps({
height: 80,
formData: {
...formData,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: true,
},
}),
);
const grid = transformed.echartOptions.grid as GridComponentOption;
expect(getCustomLegend(transformed)).toBeUndefined();
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
false,
);
expect(grid).toMatchObject({ top: 12, bottom: 5 });
expect(80 - Number(grid.top) - Number(grid.bottom)).toBeGreaterThan(0);
});
test.each([
[10, 9, 0.25, 9, 0],
[13, 12, 0.25, 12, 0],
[20, 12, 0.25, 12, 7],
[30, 12, 1, 12, 17],
[99, 12, 7, 12, 80],
[100, 12, 8, 12, 80],
])(
'keeps the hidden-legend zoomable ECharts grid within a %ipx canvas',
(height, expectedGridY, expectedGridHeight, expectedTop, expectedBottom) => {
const getContext = jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue({
measureText: (text: string) => ({ width: text.length * 7 }),
} as never);
const transformed = transformProps(
createTestChartProps({
height,
formData: {
...formData,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: true,
zoomable: true,
},
}),
);
const chart = init(null, null, {
height,
renderer: 'svg',
ssr: true,
width: transformed.width,
});
try {
chart.setOption(transformed.echartOptions);
const gridModel = (
chart as unknown as {
getModel: () => {
getComponent: (component: string) => {
coordinateSystem: {
getRect: () => { height: number; y: number };
};
};
};
}
)
.getModel()
.getComponent('grid');
expect(getCustomLegend(transformed)).toBeUndefined();
expect(transformed.echartOptions.grid).toMatchObject({
bottom: expectedBottom,
top: expectedTop,
});
const gridRect = gridModel.coordinateSystem.getRect();
expect(gridRect).toMatchObject({
height: expectedGridHeight,
y: expectedGridY,
});
expect(gridRect.y).toBeGreaterThanOrEqual(0);
expect(gridRect.y + gridRect.height).toBeLessThanOrEqual(height);
} finally {
chart.dispose();
getContext.mockRestore();
}
},
);
test('passes final axis-title grid reservations to the custom legend', () => {
const transformed = transformProps(
createTestChartProps({
height: 300,
formData: {
...formData,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: true,
xAxisTitle: 'Time',
xAxisTitleMargin: 60,
yAxisTitle: 'Value',
yAxisTitleMargin: 40,
yAxisTitlePosition: 'Top',
zoomable: true,
},
}),
);
const grid = transformed.echartOptions.grid as GridComponentOption;
expect(getCustomLegend(transformed)?.grid).toEqual({
bottom: grid.bottom,
top: grid.top,
});
});
test('derives custom legend items from a single-object custom series override', () => {
const transformed = transformProps(
createTestChartProps({
formData: {
...formData,
echartOptions: `{
series: {
name: 'San Francisco',
type: 'line',
data: [[0, 9]],
itemStyle: { color: '#123456' }
}
}`,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: true,
},
}),
);
expect(getCustomLegend(transformed)?.items).toEqual([
expect.objectContaining({
color: '#123456',
name: 'San Francisco',
}),
]);
});
test('keeps a hidden native legend model active for custom legend dispatch actions', () => {
const transformed = transformProps(
createTestChartProps({
formData: {
...formData,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: true,
},
}),
);
const chart = init(null, null, {
height: transformed.height,
renderer: 'svg',
ssr: true,
width: transformed.width,
});
try {
chart.setOption(transformed.echartOptions);
const toggled = jest.fn();
const inverted = jest.fn();
const selectedAll = jest.fn();
chart.on('legendselectchanged', toggled);
chart.on('legendinverseselect', inverted);
chart.on('legendselectall', selectedAll);
chart.dispatchAction({
name: 'San Francisco',
type: 'legendToggleSelect',
});
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
false,
);
expect(toggled).toHaveBeenCalledWith(
expect.objectContaining({
selected: expect.objectContaining({
'New York': true,
'San Francisco': false,
}),
}),
);
chart.dispatchAction({ type: 'legendInverseSelect' });
expect(inverted).toHaveBeenCalledWith(
expect.objectContaining({
selected: expect.objectContaining({
'New York': false,
'San Francisco': true,
}),
}),
);
chart.dispatchAction({ type: 'legendAllSelect' });
expect(selectedAll).toHaveBeenCalledWith(
expect.objectContaining({
selected: expect.objectContaining({
'New York': true,
'San Francisco': true,
}),
}),
);
} finally {
chart.dispose();
}
});
test('derives custom legend visuals from the final reordered Forecast series', () => {
const legendNames = ['Forecast Alpha', 'Forecast Beta'];
const forecastValues = Object.fromEntries(
legendNames.flatMap((name, index) => [
[name, index + 1],
[`${name}${ForecastSeriesEnum.ForecastLower}`, index],
[`${name}${ForecastSeriesEnum.ForecastUpper}`, index + 2],
[`${name}${ForecastSeriesEnum.ForecastTrend}`, index + 1.5],
]),
);
const transformed = transformProps(
createTestChartProps({
formData: {
...formData,
forecastEnabled: true,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: true,
},
queriesData: [
createTestQueryData(
createTestData([forecastValues], { intervalMs: 300000000 }),
),
],
}),
);
const renderedSeries = transformed.echartOptions.series as SeriesOption[];
const customLegend = getCustomLegend(transformed);
legendNames.forEach(name => {
const representative = renderedSeries.find(series => series.name === name);
const item = customLegend?.items.find(candidate => candidate.name === name);
expect(representative?.id).toBe(
`${name}${ForecastSeriesEnum.ForecastLower}`,
);
expect(item?.color).toBe(
(representative as { itemStyle?: { color?: string } } | undefined)
?.itemStyle?.color,
);
});
});
test('honors user-selected plain legend type for top orientation when space allows (#39540)', () => {
// Regression test for issue #39540: switching the legend type control from
// scroll to plain must reach the rendered ECharts config. Horizontal legends
@@ -1195,8 +1557,9 @@ test('honors user-selected plain legend type for top orientation when space allo
legend: { show?: boolean; type?: LegendType };
};
expect(legend.show).toBe(true);
expect(legend.show).toBe(false);
expect(legend.type).toBe(LegendType.Plain);
expect(getCustomLegend(transformProps(chartProps))).toBeDefined();
});
test('honors user-selected plain legend type for bottom orientation when space allows (#39540)', () => {
@@ -1213,8 +1576,9 @@ test('honors user-selected plain legend type for bottom orientation when space a
legend: { show?: boolean; type?: LegendType };
};
expect(legend.show).toBe(true);
expect(legend.show).toBe(false);
expect(legend.type).toBe(LegendType.Plain);
expect(getCustomLegend(transformProps(chartProps))).toBeDefined();
});
const timeCompareFormData: SqlaFormData = {
@@ -35,7 +35,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
}
@@ -30,7 +30,7 @@
"@babel/types": "^7.29.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "*",
"@testing-library/user-event": "*",
"@types/jest": "^30.0.0",
"jest": "^30.4.2"
@@ -45,7 +45,7 @@
"@superset-ui/core": "*",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"@testing-library/react": "*",
"@testing-library/user-event": "*",
"@types/react": "*",
"match-sorter": "^8.2.0",
@@ -20,6 +20,10 @@ import {
memo,
ComponentType,
ChangeEventHandler,
CompositionEvent,
CompositionEventHandler,
FocusEvent,
FocusEventHandler,
useRef,
useEffect,
Ref,
@@ -33,7 +37,9 @@ export interface SearchInputProps {
count: number;
value: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onBlur?: () => void;
onBlur?: FocusEventHandler<HTMLInputElement>;
onCompositionStart?: CompositionEventHandler<HTMLInputElement>;
onCompositionEnd?: CompositionEventHandler<HTMLInputElement>;
inputRef?: Ref<InputRef>;
}
@@ -56,6 +62,8 @@ function DefaultSearchInput({
value,
onChange,
onBlur,
onCompositionStart,
onCompositionEnd,
inputRef,
}: SearchInputProps) {
return (
@@ -68,6 +76,8 @@ function DefaultSearchInput({
value={value}
onChange={onChange}
onBlur={onBlur}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
className="form-control input-sm"
/>
</Space>
@@ -87,10 +97,14 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
}: GlobalFilterProps<D>) {
const count = serverPagination ? rowCount : preGlobalFilteredRows.length;
const inputRef = useRef<InputRef>(null);
const isComposingRef = useRef(false);
const [value, setValue] = useAsyncState(
filterValue,
(newValue: string) => {
if (isComposingRef.current) {
return;
}
setGlobalFilter(newValue || undefined);
},
200,
@@ -114,8 +128,21 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
setValue(target.value);
};
const handleBlur = () => {
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
isSearchFocused.set(id, false);
if (isComposingRef.current) {
isComposingRef.current = false;
setValue(e.currentTarget.value);
}
};
const handleCompositionStart = () => {
isComposingRef.current = true;
};
const handleCompositionEnd = (e: CompositionEvent<HTMLInputElement>) => {
isComposingRef.current = false;
setValue(e.currentTarget.value);
};
const SearchInput = searchInput || DefaultSearchInput;
@@ -127,6 +154,8 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
inputRef={inputRef}
onChange={handleChange}
onBlur={handleBlur}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
);
});
@@ -264,7 +264,14 @@ const VisuallyHidden = styled.label`
border: 0;
`;
function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
function SearchInput({
value,
onChange,
onBlur,
onCompositionStart,
onCompositionEnd,
inputRef,
}: SearchInputProps) {
return (
<Space direction="vertical" size={4} className="dt-global-filter">
<span aria-hidden="true">{t('Search')}</span>
@@ -275,6 +282,8 @@ function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
size="small"
onChange={onChange}
onBlur={onBlur}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
ref={inputRef}
/>
</Space>
@@ -894,6 +894,9 @@ const config: ControlPanelConfig = {
verboseMap,
allColumns,
extraColorChoices,
serverPagination: Boolean(
explore?.controls?.server_pagination?.value,
),
};
},
},
@@ -760,6 +760,8 @@ const transformProps = (
),
passedData,
theme,
undefined,
serverPagination,
) ?? defaultColorFormatters;
const basicColorColumnFormatters = getBasicColorFormatterForColumn(
@@ -18,12 +18,15 @@
*/
import '@testing-library/jest-dom';
import {
BoundUnit,
Comparator,
getTextColorForBackground,
ObjectFormattingEnum,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import { supersetTheme } from '@apache-superset/core/theme';
import {
act,
render,
screen,
fireEvent,
@@ -151,6 +154,33 @@ test('sanitizeHeaderId should handle inputs with only special characters', () =>
expect(sanitizeHeaderId('% # △')).toBe('percent_hash_delta');
});
test('transformProps retains percentage rules with automatic bounds under server pagination', () => {
const transformedProps = transformProps({
...testData.basic,
rawFormData: {
...testData.basic.rawFormData,
server_pagination: true,
conditional_formatting: [
{
column: 'sum__num',
operator: Comparator.None,
colorScheme: '#FF0000',
useGradient: true,
boundUnit: BoundUnit.Percent,
minBound: 0,
maxBound: 200,
},
],
},
});
expect(transformedProps.columnColorFormatters).toHaveLength(1);
const formatter = transformedProps.columnColorFormatters?.[0];
expect(formatter?.column).toBe('sum__num');
expect(formatter?.getColorFromValue(2467)).toBe('#FF000000');
expect(formatter?.getColorFromValue(2467063)).toBe('#FF0000FF');
});
describe('plugin-chart-table', () => {
describe('transformProps', () => {
test('should parse pageLength to pageSize', () => {
@@ -2658,6 +2688,253 @@ describe('plugin-chart-table', () => {
expect(screen.queryByText('Search by')).toBeInTheDocument();
});
test.each([
{
eventOrder: 'change before compositionend',
commitComposition: (searchInput: HTMLElement) => {
fireEvent.change(searchInput, { target: { value: '你好' } });
fireEvent.compositionEnd(searchInput);
},
},
{
eventOrder: 'compositionend carrying the committed value',
commitComposition: (searchInput: HTMLElement) => {
fireEvent.compositionEnd(searchInput, {
target: { value: '你好' },
});
},
},
])(
'defers server-side search until IME composition ends ($eventOrder)',
async ({ commitComposition }) => {
jest.useFakeTimers();
try {
const setDataMask = jest.fn();
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
server_pagination: true,
include_search: true,
},
hooks: { setDataMask },
queriesData: [
{
...testData.raw.queriesData[0],
colnames: ['name'],
coltypes: [GenericDataType.String],
data: [{ name: 'Michael' }, { name: 'John' }],
},
],
});
render(
ProviderWrapper({
children: (
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
),
}),
);
const searchInput = screen.getByRole('textbox');
const searchCalls = () =>
setDataMask.mock.calls.filter(([mask]) =>
Object.prototype.hasOwnProperty.call(
mask?.ownState ?? {},
'searchText',
),
);
fireEvent.compositionStart(searchInput);
fireEvent.change(searchInput, { target: { value: 'nihao' } });
await act(async () => {
jest.advanceTimersByTime(300);
});
await act(async () => {
jest.advanceTimersByTime(900);
});
expect(searchInput).toHaveValue('nihao');
expect(searchCalls()).toHaveLength(0);
commitComposition(searchInput);
await act(async () => {
jest.advanceTimersByTime(300);
});
await act(async () => {
jest.advanceTimersByTime(900);
});
const calls = searchCalls();
expect(calls).toHaveLength(1);
expect(calls[0][0].ownState.searchText).toBe('你好');
} finally {
jest.useRealTimers();
}
},
);
test('restores server-side search after composition is interrupted by blur', async () => {
jest.useFakeTimers();
try {
const setDataMask = jest.fn();
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
server_pagination: true,
include_search: true,
},
hooks: { setDataMask },
queriesData: [
{
...testData.raw.queriesData[0],
colnames: ['name'],
coltypes: [GenericDataType.String],
data: [{ name: 'Michael' }, { name: 'John' }],
},
],
});
render(
ProviderWrapper({
children: (
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
),
}),
);
const searchInput = screen.getByRole('textbox');
const searchCalls = () =>
setDataMask.mock.calls.filter(([mask]) =>
Object.prototype.hasOwnProperty.call(
mask?.ownState ?? {},
'searchText',
),
);
fireEvent.compositionStart(searchInput);
fireEvent.change(searchInput, { target: { value: 'nihao' } });
await act(async () => {
jest.advanceTimersByTime(300);
});
await act(async () => {
jest.advanceTimersByTime(900);
});
expect(searchInput).toHaveValue('nihao');
expect(searchCalls()).toHaveLength(0);
fireEvent.blur(searchInput);
expect(searchCalls()).toHaveLength(0);
fireEvent.change(searchInput, { target: { value: 'hello' } });
await act(async () => {
jest.advanceTimersByTime(300);
});
await act(async () => {
jest.advanceTimersByTime(900);
});
const calls = searchCalls();
expect(calls).toHaveLength(1);
expect(calls[0][0].ownState.searchText).toBe('hello');
} finally {
jest.useRealTimers();
}
});
test.each([
{
eventOrder: 'compositionend before blur',
pauseBeforeLeaving: 50,
leaveInput: (searchInput: HTMLElement) => {
fireEvent.compositionEnd(searchInput, {
target: { value: 'nihao' },
});
fireEvent.blur(searchInput);
},
},
{
eventOrder: 'blur without compositionend',
pauseBeforeLeaving: 50,
leaveInput: (searchInput: HTMLElement) => {
fireEvent.blur(searchInput);
},
},
{
eventOrder: 'blur without compositionend after the debounce fired',
pauseBeforeLeaving: 300,
leaveInput: (searchInput: HTMLElement) => {
fireEvent.blur(searchInput);
},
},
])(
'searches the input value after blur mid-composition ($eventOrder)',
async ({ pauseBeforeLeaving, leaveInput }) => {
jest.useFakeTimers();
try {
const setDataMask = jest.fn();
const props = transformProps({
...testData.raw,
rawFormData: {
...testData.raw.rawFormData,
server_pagination: true,
include_search: true,
},
hooks: { setDataMask },
queriesData: [
{
...testData.raw.queriesData[0],
colnames: ['name'],
coltypes: [GenericDataType.String],
data: [{ name: 'Michael' }, { name: 'John' }],
},
],
});
render(
ProviderWrapper({
children: (
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
),
}),
);
const searchInput = screen.getByRole('textbox');
const searchCalls = () =>
setDataMask.mock.calls.filter(([mask]) =>
Object.prototype.hasOwnProperty.call(
mask?.ownState ?? {},
'searchText',
),
);
fireEvent.compositionStart(searchInput);
fireEvent.change(searchInput, { target: { value: 'nihao' } });
await act(async () => {
jest.advanceTimersByTime(pauseBeforeLeaving);
});
leaveInput(searchInput);
expect(searchCalls()).toHaveLength(0);
await act(async () => {
jest.advanceTimersByTime(300);
});
await act(async () => {
jest.advanceTimersByTime(900);
});
expect(searchInput).toHaveValue('nihao');
const calls = searchCalls();
expect(calls).toHaveLength(1);
expect(calls[0][0].ownState.searchText).toBe('nihao');
} finally {
jest.useRealTimers();
}
},
);
test(
'should read the totals row from the correct query when percent metrics ' +
'use the "all records" calculation mode',
@@ -104,6 +104,7 @@ const dashboard: Dashboard = {
charts: [],
editors: [editorSubject],
viewers: [],
is_managed_externally: false,
};
jest.mock('src/utils/getBootstrapData', () => ({
@@ -27,6 +27,10 @@ import {
within,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import type {
SelectValue,
SelectOptionsPagePromise,
} from '@superset-ui/core/components/Select';
import * as saveModalActions from 'src/explore/actions/saveModalActions';
import SaveModal, {
@@ -45,14 +49,34 @@ jest.mock('src/utils/getBootstrapData', () => ({
})),
}));
// Captures the AsyncSelect `options` loader (SaveModal's loadDashboards) so
// tests can invoke it directly and assert on the request it issues.
let mockLoadDashboards: SelectOptionsPagePromise | undefined;
jest.mock('@superset-ui/core/components/Select', () => ({
...jest.requireActual('@superset-ui/core/components/Select/AsyncSelect'),
AsyncSelect: ({ onChange }: { onChange: (val: any) => void }) => (
<input
data-test="mock-async-select"
onChange={({ target: { value } }) => onChange({ label: value, value })}
/>
),
AsyncSelect: ({
onChange,
options,
value,
}: {
onChange: (val: SelectValue) => void;
options?: SelectOptionsPagePromise;
value?: { label?: string } | null;
}) => {
mockLoadDashboards = options;
return (
<input
data-test="mock-async-select"
// Surfaces the currently selected label so tests can assert on what
// the user actually sees, rather than only on side-effect requests.
value={value?.label ?? ''}
onChange={({ target: { value: newValue } }) =>
onChange({ label: newValue, value: newValue })
}
/>
);
},
}));
jest.mock('@superset-ui/core/components/TreeSelect', () => ({
@@ -133,14 +157,24 @@ const queryStore = mockStore({
const fetchChartEndpoint = `glob:*/api/v1/chart/${1}*`;
const fetchDashboardEndpoint = `glob:*/api/v1/dashboard/*`;
beforeAll(() => {
const registerDefaultRoutes = () => {
fetchMock.get(fetchChartEndpoint, { id: 1, dashboards: [1] });
fetchMock.get(fetchDashboardEndpoint, {
result: [{ id: 'id', dashboard_title: 'dashboard title' }],
});
};
beforeEach(() => {
registerDefaultRoutes();
});
afterAll(() => fetchMock.clearHistory());
// Guaranteed teardown so per-test route overrides can never leak into later
// tests, even if an assertion fails before any inline cleanup would run.
afterEach(() => {
fetchMock.removeRoutes();
fetchMock.clearHistory();
mockLoadDashboards = undefined;
});
const setup = (
props: Record<string, any> = defaultProps,
@@ -268,6 +302,78 @@ test('renders a message when saving as with new dashboard', () => {
);
});
test('does not preselect an externally managed dashboard on mount', async () => {
const dashboardId = 1;
fetchMock.removeRoutes();
fetchMock.get(fetchChartEndpoint, { id: 1, dashboards: [1] });
fetchMock.get(`glob:*/api/v1/dashboard/${dashboardId}`, {
result: {
id: dashboardId,
dashboard_title: 'Managed Dashboard',
owners: [{ id: 1 }],
is_managed_externally: true,
},
});
const store = mockStore({
...initialState,
explore: {
...initialState.explore,
slice: {
...initialState.explore.slice,
dashboards: [dashboardId],
},
},
});
const { queryByTestId } = setup(
{
...defaultProps,
dashboardId,
},
store,
);
await waitFor(() => {
expect(
fetchMock.callHistory.calls(`glob:*/api/v1/dashboard/${dashboardId}`),
).toHaveLength(1);
});
const selectInput = queryByTestId('mock-async-select') as HTMLInputElement;
expect(selectInput).toBeInTheDocument();
// Assert on what the user actually sees: the externally managed dashboard
// must never appear as the selected value.
expect(selectInput.value).toBe('');
expect(
fetchMock.callHistory.calls(`glob:*/api/v1/dashboard/${dashboardId}/tabs`),
).toHaveLength(0);
});
test('loadDashboards includes is_managed_externally filter', async () => {
const dashboardListEndpoint = `glob:*/api/v1/dashboard/?q=*`;
fetchMock.removeRoutes();
fetchMock.clearHistory();
fetchMock.get(fetchChartEndpoint, { id: 1, dashboards: [1] });
fetchMock.get(dashboardListEndpoint, {
result: [{ id: 1, dashboard_title: 'Test' }],
count: 1,
});
fetchMock.get(fetchDashboardEndpoint, {
result: [{ id: 'id', dashboard_title: 'dashboard title' }],
});
setup();
await waitFor(() => expect(mockLoadDashboards).toBeDefined());
await mockLoadDashboards!('test', 0, 25);
const calls = fetchMock.callHistory.calls(dashboardListEndpoint);
const lastCall = calls[calls.length - 1];
expect(lastCall.url).toContain('is_managed_externally');
});
test('disables overwrite option for new slice', () => {
const { getByRole } = setup(
{},
@@ -341,7 +341,10 @@ const SaveModal = ({
if (dashboardId) {
try {
const result = (await loadDashboard(dashboardId)) as Dashboard;
if (canUserEditDashboard(result, user)) {
if (
canUserEditDashboard(result, user) &&
!result.is_managed_externally
) {
setDashboard({ label: result.dashboard_title, value: result.id });
await loadTabs(dashboardId);
}
@@ -362,7 +365,11 @@ const SaveModal = ({
for (const { id } of metadataDashboards) {
// eslint-disable-next-line no-await-in-loop
const result = await loadDashboard(id).catch(() => null);
if (result && canUserEditDashboard(result, user)) {
if (
result &&
canUserEditDashboard(result, user) &&
!result.is_managed_externally
) {
editable = result as Dashboard;
break;
}
@@ -641,6 +648,11 @@ const SaveModal = ({
opr: 'is_editable',
value: true,
},
{
col: 'is_managed_externally',
opr: 'eq',
value: false,
},
],
page,
page_size: pageSize,
@@ -75,6 +75,7 @@ const ConditionalFormattingControl = ({
removeIrrelevantConditions,
extraColorChoices,
allColumns,
serverPagination,
...props
}: ConditionalFormattingControlProps) => {
const [conditionalFormattingConfigs, setConditionalFormattingConfigs] =
@@ -165,6 +166,7 @@ const ConditionalFormattingControl = ({
destroyOnHidden
extraColorChoices={extraColorChoices}
allColumns={allColumns}
serverPagination={serverPagination}
>
<OptionControlContainer withCaret>
<Label>{createLabel(config)}</Label>
@@ -182,6 +184,7 @@ const ConditionalFormattingControl = ({
destroyOnHidden
extraColorChoices={extraColorChoices}
allColumns={allColumns}
serverPagination={serverPagination}
>
<AddControlLabel>
<Icons.PlusOutlined
@@ -29,6 +29,7 @@ export const FormattingPopover = ({
children,
extraColorChoices,
allColumns,
serverPagination,
...props
}: FormattingPopoverProps) => {
const [visible, setVisible] = useState(false);
@@ -51,6 +52,7 @@ export const FormattingPopover = ({
columns={columns}
extraColorChoices={extraColorChoices}
allColumns={allColumns}
serverPagination={serverPagination}
/>
}
open={visible}
@@ -22,7 +22,9 @@ import {
fireEvent,
waitFor,
userEvent,
within,
} from 'spec/helpers/testing-library';
import tinycolor from 'tinycolor2';
import { Comparator, ColorSchemeEnum } from '@superset-ui/chart-controls';
import { GenericDataType } from '@apache-superset/core/common';
import { FormattingPopoverContent } from './FormattingPopoverContent';
@@ -57,6 +59,41 @@ const extraColorChoices = [
},
];
// This form uses `requiredMark="optional"`, so antd appends a literal
// "(optional)" suffix inside the <label> of any non-required field (see
// antd's FormItemLabel.js). Min bound/Max bound are intentionally optional,
// so every label query for them — positive or negative — needs `exact: false`
// to match regardless of that suffix.
const boundLabelOptions = { exact: false };
const getBoundInputs = () => ({
minBoundInput: screen.getByLabelText('Min bound', boundLabelOptions),
maxBoundInput: screen.getByLabelText('Max bound', boundLabelOptions),
});
// The shared `selectOption` helper (spec/helpers/testing-library) resolves
// the open dropdown via `document.querySelector('.ant-select-dropdown-list')`
// -- the first match anywhere in the document -- which is safe only when a
// test opens a single Select. This `Select` (from `@superset-ui/core/components`)
// renders its popup through `getPopupContainer={trigger => trigger.parentNode}`
// (inline, as a sibling of the trigger, rather than portaled to <body>), and
// antd never removes a closed dropdown's list node afterward. Because "Bound
// unit" sits earlier in the DOM than "Percent denominator", opening "Bound
// unit" once leaves a stale, closed dropdown list that permanently wins any
// *unscoped* "first match" query -- a later interaction with "Percent
// denominator" via the shared helper would silently re-read "Bound unit"'s
// stale list and time out looking for an option that was never there.
// Scope explicitly to the target Select's own popup container (its trigger's
// parent) to sidestep that.
const selectFieldOption = async (option: string, selectName: string) => {
const trigger = await screen.findByRole('combobox', { name: selectName });
const container = trigger.closest('.ant-select')!
.parentElement as HTMLElement;
await userEvent.click(trigger);
const item = await within(container).findByText(option);
await userEvent.click(item);
};
test('renders FormattingPopoverContent component', () => {
render(
<FormattingPopoverContent
@@ -335,16 +372,22 @@ test('should hide formatting fields when color scheme is Green', async () => {
});
test('should not display tooltip when extraColorChoices is not provided', async () => {
const { container } = render(
render(
<FormattingPopoverContent onChange={mockOnChange} columns={columns} />,
);
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
const colorSchemeFormItem = screen
.getByText('Color scheme')
.closest('.ant-form-item');
expect(colorSchemeFormItem).toBeInTheDocument();
const tooltipIcon = colorSchemeFormItem?.querySelector(
'.ant-form-item-tooltip',
);
expect(tooltipIcon).not.toBeInTheDocument();
});
test('should display tooltip icon when extraColorChoices is provided', () => {
const { container } = render(
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
@@ -352,7 +395,14 @@ test('should display tooltip icon when extraColorChoices is provided', () => {
/>,
);
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
const colorSchemeFormItem = screen
.getByText('Color scheme')
.closest('.ant-form-item');
expect(colorSchemeFormItem).toBeInTheDocument();
const tooltipIcon = colorSchemeFormItem?.querySelector(
'.ant-form-item-tooltip',
);
expect(tooltipIcon).toBeInTheDocument();
const questionIcon = tooltipIcon?.querySelector(
@@ -362,7 +412,7 @@ test('should display tooltip icon when extraColorChoices is provided', () => {
});
test('should not display tooltip icon when extraColorChoices is empty', () => {
const { container } = render(
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
@@ -370,6 +420,717 @@ test('should not display tooltip icon when extraColorChoices is empty', () => {
/>,
);
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
const colorSchemeFormItem = screen
.getByText('Color scheme')
.closest('.ant-form-item');
expect(colorSchemeFormItem).toBeInTheDocument();
const tooltipIcon = colorSchemeFormItem?.querySelector(
'.ant-form-item-tooltip',
);
expect(tooltipIcon).not.toBeInTheDocument();
});
test('shows min/max bound fields for the default None operator on a numeric column', () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const { minBoundInput, maxBoundInput } = getBoundInputs();
expect(minBoundInput).toBeInTheDocument();
expect(maxBoundInput).toBeInTheDocument();
});
test('shows only the Max bound field when a greater-than operator is selected', async () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
fireEvent.change(screen.getAllByLabelText('Operator')[0], {
target: { value: Comparator.GreaterThan },
});
fireEvent.click(await screen.findByTitle('>'));
expect(await screen.findByLabelText('Target value')).toBeInTheDocument();
// getColorFunction only reads maxBound for `>`, so minBound is not
// user-facing (and would have no effect) for this operator.
expect(
screen.getByLabelText('Max bound', boundLabelOptions),
).toBeInTheDocument();
expect(
screen.queryByLabelText('Min bound', boundLabelOptions),
).not.toBeInTheDocument();
});
test('shows only the Min bound field when a less-than operator is selected', async () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
fireEvent.change(screen.getAllByLabelText('Operator')[0], {
target: { value: Comparator.LessThan },
});
fireEvent.click(await screen.findByTitle('<'));
expect(await screen.findByLabelText('Target value')).toBeInTheDocument();
// getColorFunction only reads minBound for `<`, so maxBound is not
// user-facing (and would have no effect) for this operator.
expect(
screen.getByLabelText('Min bound', boundLabelOptions),
).toBeInTheDocument();
expect(
screen.queryByLabelText('Max bound', boundLabelOptions),
).not.toBeInTheDocument();
});
test('hides min/max bound fields for operators that already take two values', async () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
fireEvent.change(screen.getAllByLabelText('Operator')[0], {
target: { value: Comparator.Between },
});
fireEvent.click(await screen.findByTitle('< x <'));
expect(await screen.findByLabelText('Left value')).toBeInTheDocument();
expect(
screen.queryByLabelText('Min bound', boundLabelOptions),
).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Max bound', boundLabelOptions),
).not.toBeInTheDocument();
});
test('hides min/max bound fields on string columns', () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columnsStringType}
extraColorChoices={extraColorChoices}
/>,
);
expect(
screen.queryByLabelText('Min bound', boundLabelOptions),
).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Max bound', boundLabelOptions),
).not.toBeInTheDocument();
});
test('shows no validation error for a valid min/max bound pair', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const { minBoundInput, maxBoundInput } = getBoundInputs();
await userEvent.type(minBoundInput, '5');
fireEvent.blur(minBoundInput);
await userEvent.type(maxBoundInput, '10');
fireEvent.blur(maxBoundInput);
fireEvent.blur(minBoundInput);
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
expect(
screen.queryByText('Min bound should be smaller than max bound'),
).not.toBeInTheDocument();
expect(
screen.queryByText('Max bound should be greater than min bound'),
).not.toBeInTheDocument();
});
test('shows a validation error when min bound is greater than max bound', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const { minBoundInput, maxBoundInput } = getBoundInputs();
await userEvent.type(minBoundInput, '10');
fireEvent.blur(minBoundInput);
await userEvent.type(maxBoundInput, '5');
fireEvent.blur(maxBoundInput);
fireEvent.blur(minBoundInput);
expect(
await screen.findByText('Min bound should be smaller than max bound'),
).toBeInTheDocument();
expect(
await screen.findByText('Max bound should be greater than min bound'),
).toBeInTheDocument();
fireEvent.click(screen.getByText('Apply'));
expect(onChange).not.toHaveBeenCalled();
});
test('submits typed minBound/maxBound values to onChange with the correct field names', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const { minBoundInput, maxBoundInput } = getBoundInputs();
await userEvent.type(minBoundInput, '5');
fireEvent.blur(minBoundInput);
await userEvent.type(maxBoundInput, '10');
fireEvent.blur(maxBoundInput);
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const lastCallPayload = onChange.mock.calls[0][0];
expect(lastCallPayload.minBound).toBe(5);
expect(lastCallPayload.maxBound).toBe(10);
});
test('clearing optional bounds restores undefined values', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
config={{
column: 'column1',
operator: Comparator.None,
colorScheme: '#FF0000',
minBound: 5,
maxBound: 10,
}}
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const { minBoundInput, maxBoundInput } = getBoundInputs();
await userEvent.clear(minBoundInput);
fireEvent.blur(minBoundInput);
await userEvent.clear(maxBoundInput);
fireEvent.blur(maxBoundInput);
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
expect(onChange.mock.calls[0][0].minBound).toBeUndefined();
expect(onChange.mock.calls[0][0].maxBound).toBeUndefined();
});
test('requires max bound to be greater than a greater-than target', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
fireEvent.change(screen.getAllByLabelText('Operator')[0], {
target: { value: Comparator.GreaterThan },
});
fireEvent.click(await screen.findByTitle('>'));
const targetInput = await screen.findByLabelText('Target value');
const maxBoundInput = screen.getByLabelText('Max bound', boundLabelOptions);
await userEvent.type(targetInput, '100');
await userEvent.type(maxBoundInput, '90');
fireEvent.blur(maxBoundInput);
expect(
await screen.findByText('Max bound should be greater than target value'),
).toBeInTheDocument();
fireEvent.click(screen.getByText('Apply'));
expect(onChange).not.toHaveBeenCalled();
});
test('requires min bound to be smaller than a less-than target', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
fireEvent.change(screen.getAllByLabelText('Operator')[0], {
target: { value: Comparator.LessThan },
});
fireEvent.click(await screen.findByTitle('<'));
const targetInput = await screen.findByLabelText('Target value');
const minBoundInput = screen.getByLabelText('Min bound', boundLabelOptions);
await userEvent.type(targetInput, '100');
await userEvent.type(minBoundInput, '110');
fireEvent.blur(minBoundInput);
expect(
await screen.findByText('Min bound should be smaller than target value'),
).toBeInTheDocument();
fireEvent.click(screen.getByText('Apply'));
expect(onChange).not.toHaveBeenCalled();
});
test('does not compare a percentage max bound directly with an absolute target', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
await selectFieldOption('>', 'Operator');
await userEvent.type(await screen.findByLabelText('Target value'), '1000');
await selectFieldOption('% of column', 'Bound unit');
const maxBoundInput = screen.getByLabelText('Max bound', boundLabelOptions);
await userEvent.type(maxBoundInput, '100');
fireEvent.blur(maxBoundInput);
expect(
screen.queryByText('Max bound should be greater than target value'),
).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => expect(onChange).toHaveBeenCalled());
});
test('does not compare a percentage min bound directly with an absolute target', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
await selectFieldOption('<', 'Operator');
await userEvent.type(await screen.findByLabelText('Target value'), '-1000');
await selectFieldOption('% of column', 'Bound unit');
const minBoundInput = screen.getByLabelText('Min bound', boundLabelOptions);
await userEvent.type(minBoundInput, '100');
fireEvent.blur(minBoundInput);
expect(
screen.queryByText('Min bound should be smaller than target value'),
).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => expect(onChange).toHaveBeenCalled());
});
test('shows Center value and Low/Mid/High color fields for the default None operator on a numeric column', () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
expect(
screen.getByLabelText('Center value', boundLabelOptions),
).toBeInTheDocument();
expect(screen.getByLabelText('Low color')).toBeInTheDocument();
expect(screen.getByLabelText('Mid color')).toBeInTheDocument();
expect(screen.getByLabelText('High color')).toBeInTheDocument();
});
test('hides Center value and Low/Mid/High color fields for a boundable directional operator', async () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
fireEvent.change(screen.getAllByLabelText('Operator')[0], {
target: { value: Comparator.GreaterThan },
});
fireEvent.click(await screen.findByTitle('>'));
expect(await screen.findByLabelText('Target value')).toBeInTheDocument();
expect(
screen.queryByLabelText('Center value', boundLabelOptions),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Low color')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Mid color')).not.toBeInTheDocument();
expect(screen.queryByLabelText('High color')).not.toBeInTheDocument();
});
test('hides Center value and color fields on string columns', () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columnsStringType}
extraColorChoices={extraColorChoices}
/>,
);
expect(
screen.queryByLabelText('Center value', boundLabelOptions),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Low color')).not.toBeInTheDocument();
});
test.each([
[10, 'Center value should be greater than min bound'],
[40, 'Center value should be smaller than max bound'],
])(
'rejects Center value %s when it equals a bound',
async (centerValue, expectedError) => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const { minBoundInput, maxBoundInput } = getBoundInputs();
await userEvent.type(minBoundInput, '10');
fireEvent.blur(minBoundInput);
await userEvent.type(maxBoundInput, '40');
fireEvent.blur(maxBoundInput);
const centerValueInput = screen.getByLabelText(
'Center value',
boundLabelOptions,
);
await userEvent.type(centerValueInput, String(centerValue));
fireEvent.blur(centerValueInput);
fireEvent.click(screen.getByText('Apply'));
expect(await screen.findByText(expectedError)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
},
);
test('validates Center value against Min bound and Max bound when both are set', async () => {
render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const { minBoundInput, maxBoundInput } = getBoundInputs();
await userEvent.type(minBoundInput, '0');
fireEvent.blur(minBoundInput);
await userEvent.type(maxBoundInput, '100');
fireEvent.blur(maxBoundInput);
const centerValueInput = screen.getByLabelText(
'Center value',
boundLabelOptions,
);
await userEvent.type(centerValueInput, '150');
fireEvent.blur(centerValueInput);
expect(
await screen.findByText('Center value should be smaller than max bound'),
).toBeInTheDocument();
});
// Opens the given ColorPickerControl's popover, clicks the preset swatch at
// `presetIndex`, and returns the hex color that swatch actually represents
// (read from its own inline `style.background`, normalized via `tinycolor`
// so it's comparable to the hex string ColorPickerControl's
// `outputFormat="hex"` produces). Returning the *actual* clicked color
// (rather than just asserting "some string came back") lets the caller
// verify each of the three fields resolved to the swatch it was individually
// told to click, not merely that all three happened to end up non-empty.
// The `ariaLabel` prop on ColorPickerControl lands directly on the
// `.ant-color-picker-trigger` element (see ColorTrigger in antd), so
// `getByLabelText` resolves the correct trigger among the several color
// pickers rendered in this form without any extra scoping.
//
// Each picker's popover stays mounted (unclosed) after a swatch is picked,
// so with three pickers on the page there can be more than one
// `.ant-color-picker-presets-items` panel present at once by the time this
// runs a second or third time. Rather than a plain `querySelector` (which
// would grab whichever panel happens to be first in the DOM — possibly a
// stale one from an earlier pick), wait for the panel *count* to grow past
// what it was before this trigger was clicked, then act on the newest
// (last) panel, which is the one this click just opened.
const pickPresetColorAt = async (label: string, presetIndex: number) => {
const panelSelector = '.ant-color-picker-presets-items';
const panelCountBefore = document.querySelectorAll(panelSelector).length;
const trigger = screen.getByLabelText(label);
expect(trigger).toHaveClass('ant-color-picker-trigger');
await userEvent.click(trigger);
await waitFor(() => {
expect(document.querySelectorAll(panelSelector).length).toBeGreaterThan(
panelCountBefore,
);
});
const panels = document.querySelectorAll(panelSelector);
const newestPanel = panels[panels.length - 1];
const presets = newestPanel.querySelectorAll(
'.ant-color-picker-presets-color',
);
const preset = presets[presetIndex] as HTMLElement;
expect(preset).toBeInTheDocument();
const swatch = preset.querySelector(
'.ant-color-picker-color-block-inner',
) as HTMLElement;
expect(swatch).toBeInTheDocument();
const clickedColor = tinycolor(swatch.style.background).toHexString();
await userEvent.click(preset);
return clickedColor;
};
test('selecting Low/Mid/High colors submits the exact colors clicked to onChange, independently per field', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
// Distinct preset indices per field are the whole point of this test: it
// proves the three fields wire independently, not merely that each ends
// up holding some string (which selecting the same swatch three times
// would also satisfy).
const lowExpected = await pickPresetColorAt('Low color', 0);
const midExpected = await pickPresetColorAt('Mid color', 1);
const highExpected = await pickPresetColorAt('High color', 2);
// Sanity check: if the default categorical scheme ever collapsed to fewer
// distinct colors, the assertions below could pass vacuously.
expect(new Set([lowExpected, midExpected, highExpected]).size).toBe(3);
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const payload = onChange.mock.calls[0][0];
expect(tinycolor(payload.lowColor).toHexString()).toEqual(lowExpected);
expect(tinycolor(payload.midColor).toHexString()).toEqual(midExpected);
expect(tinycolor(payload.highColor).toHexString()).toEqual(highExpected);
});
test('keeps Use gradient available for complete diverging configs', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
allColumns={columns}
extraColorChoices={extraColorChoices}
/>,
);
expect(screen.getByText('Use gradient')).toBeInTheDocument();
const centerValueInput = screen.getByLabelText(
'Center value',
boundLabelOptions,
);
await userEvent.type(centerValueInput, '50');
fireEvent.blur(centerValueInput);
await pickPresetColorAt('Low color', 0);
await pickPresetColorAt('Mid color', 1);
await pickPresetColorAt('High color', 2);
const gradientCheckbox = screen.getByRole('checkbox');
expect(screen.getByText('Use gradient')).toBeInTheDocument();
await userEvent.click(gradientCheckbox);
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => expect(onChange).toHaveBeenCalled());
expect(onChange.mock.calls[0][0].useGradient).toBe(false);
});
test('shows the percent denominator select only when Bound unit is set to percent, for both None and a directional operator', async () => {
const { rerender } = render(
<FormattingPopoverContent
onChange={jest.fn()}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
expect(screen.getByLabelText('Bound unit')).toBeInTheDocument();
expect(
screen.queryByLabelText('Percent denominator'),
).not.toBeInTheDocument();
await selectFieldOption('% of column', 'Bound unit');
expect(screen.getByLabelText('Percent denominator')).toBeInTheDocument();
await selectFieldOption('Value', 'Bound unit');
expect(
screen.queryByLabelText('Percent denominator'),
).not.toBeInTheDocument();
// Also present for a directional operator (only Max bound shows for '>',
// per getBoundVisibility) -- Bound unit isn't None-specific.
await selectFieldOption('>', 'Operator');
expect(screen.getByLabelText('Bound unit')).toBeInTheDocument();
rerender(
<FormattingPopoverContent
onChange={jest.fn()}
columns={columnsStringType}
extraColorChoices={extraColorChoices}
/>,
);
// String columns never show bound fields, so Bound unit must not appear.
expect(screen.queryByLabelText('Bound unit')).not.toBeInTheDocument();
});
test('submits boundUnit and percentDenominator to onChange with the correct field names', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
await selectFieldOption('% of column', 'Bound unit');
await selectFieldOption('Column sum', 'Percent denominator');
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const payload = onChange.mock.calls[0][0];
expect(payload.boundUnit).toBe('percent');
expect(payload.percentDenominator).toBe('sum');
});
test('defaults percentDenominator to Column max once switched to percent mode without picking one', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
await selectFieldOption('% of column', 'Bound unit');
// Left untouched, the select must show the same explicit default used by
// getColorFunction rather than an ambiguous blank state.
expect(screen.getByText('Column max')).toBeInTheDocument();
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const payload = onChange.mock.calls[0][0];
expect(payload.percentDenominator).toBe('max');
});
test('defaults to Value and Column max when never touched', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
const payload = onChange.mock.calls[0][0];
expect(payload.boundUnit).toBe('value');
expect(payload.percentDenominator).toBeUndefined();
});
test('disables the % of column option when serverPagination is true', async () => {
const onChange = jest.fn();
render(
<FormattingPopoverContent
onChange={onChange}
columns={columns}
extraColorChoices={extraColorChoices}
serverPagination
/>,
);
const trigger = await screen.findByRole('combobox', { name: 'Bound unit' });
await userEvent.click(trigger);
const option = await screen.findByText('% of column');
await userEvent.click(option);
// The disabled option must not be selected: the denominator select stays
// hidden and the submitted config keeps Bound unit at its Value default.
expect(
screen.queryByLabelText('Percent denominator'),
).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Apply'));
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
expect(onChange.mock.calls[0][0].boundUnit).toBe('value');
});
@@ -25,6 +25,7 @@ import {
MultipleValueComparators,
ObjectFormattingEnum,
ColorSchemeEnum,
BoundUnit,
} from '@superset-ui/chart-controls';
import {
Select,
@@ -45,6 +46,8 @@ import {
booleanOperatorOptions,
formattingOptions,
colorScheme,
boundUnitOptions,
percentDenominatorOptions,
} from './constants';
import ColorPickerControl from '../ColorPickerControl';
@@ -69,8 +72,10 @@ const targetValueValidator =
(targetValue: number | string) =>
(_: any, compareValue: number | string) => {
if (
!targetValue ||
!compareValue ||
targetValue === null ||
targetValue === undefined ||
compareValue === null ||
compareValue === undefined ||
compare(Number(targetValue), Number(compareValue))
) {
return Promise.resolve();
@@ -88,12 +93,72 @@ const targetValueRightValidator = targetValueValidator(
t('This value should be greater than the left target value'),
);
const minBoundValidator = targetValueValidator(
(max: number, min: number) => min < max,
t('Min bound should be smaller than max bound'),
);
const maxBoundValidator = targetValueValidator(
(min: number, max: number) => max > min,
t('Max bound should be greater than min bound'),
);
const minBoundTargetValidator = targetValueValidator(
(target: number, min: number) => min < target,
t('Min bound should be smaller than target value'),
);
const maxBoundTargetValidator = targetValueValidator(
(target: number, max: number) => max > target,
t('Max bound should be greater than target value'),
);
const centerValueMinValidator = targetValueValidator(
(min: number, center: number) => center > min,
t('Center value should be greater than min bound'),
);
const centerValueMaxValidator = targetValueValidator(
(max: number, center: number) => center < max,
t('Center value should be smaller than max bound'),
);
const normalizeOptionalNumber = (value: number | string | null | undefined) =>
value === '' || value === null || value === undefined
? undefined
: Number(value);
const isOperatorMultiValue = (operator?: Comparator) =>
operator && MultipleValueComparators.includes(operator);
const isOperatorNone = (operator?: Comparator) =>
!operator || operator === Comparator.None;
type BoundVisibility = { showMin: boolean; showMax: boolean };
// `>`/`>=` only use maxBound and `<`/`<=` only use minBound; targetValue
// covers the other end, so only show the bound that actually applies.
const getBoundVisibility = (operator?: Comparator): BoundVisibility => {
if (isOperatorNone(operator)) {
return { showMin: true, showMax: true };
}
if (
operator === Comparator.GreaterThan ||
operator === Comparator.GreaterOrEqual
) {
return { showMin: false, showMax: true };
}
if (operator === Comparator.LessThan || operator === Comparator.LessOrEqual) {
return { showMin: true, showMax: false };
}
return { showMin: false, showMax: false };
};
const isOperatorBoundable = (operator?: Comparator) => {
const { showMin, showMax } = getBoundVisibility(operator);
return showMin || showMax;
};
const rulesRequired = [{ required: true, message: t('Required') }];
type GetFieldValue = Pick<Required<FormProps>['form'], 'getFieldValue'>;
@@ -114,14 +179,71 @@ const rulesTargetValueRight = [
const targetValueLeftDeps = ['targetValueRight'];
const targetValueRightDeps = ['targetValueLeft'];
const rulesMinBound = [
({ getFieldValue }: GetFieldValue) => ({
validator: minBoundValidator(getFieldValue('maxBound')),
}),
];
const rulesMaxBound = [
({ getFieldValue }: GetFieldValue) => ({
validator: maxBoundValidator(getFieldValue('minBound')),
}),
];
const rulesMinBoundTarget = [
({ getFieldValue }: GetFieldValue) => ({
validator:
getFieldValue('boundUnit') === BoundUnit.Percent
? () => Promise.resolve()
: minBoundTargetValidator(getFieldValue('targetValue')),
}),
];
const rulesMaxBoundTarget = [
({ getFieldValue }: GetFieldValue) => ({
validator:
getFieldValue('boundUnit') === BoundUnit.Percent
? () => Promise.resolve()
: maxBoundTargetValidator(getFieldValue('targetValue')),
}),
];
const minBoundDeps = ['maxBound'];
const maxBoundDeps = ['minBound'];
const targetValueDeps = ['targetValue', 'boundUnit'];
const rulesCenterValue = [
({ getFieldValue }: GetFieldValue) => ({
validator: centerValueMinValidator(getFieldValue('minBound')),
}),
({ getFieldValue }: GetFieldValue) => ({
validator: centerValueMaxValidator(getFieldValue('maxBound')),
}),
];
const centerValueDeps = ['minBound', 'maxBound'];
const shouldFormItemUpdate = (
prevValues: ConditionalFormattingConfig,
currentValues: ConditionalFormattingConfig,
) =>
isOperatorNone(prevValues.operator) !==
isOperatorNone(currentValues.operator) ||
isOperatorMultiValue(prevValues.operator) !==
isOperatorMultiValue(currentValues.operator);
) => {
const prevBounds = getBoundVisibility(prevValues.operator);
const currentBounds = getBoundVisibility(currentValues.operator);
return (
isOperatorNone(prevValues.operator) !==
isOperatorNone(currentValues.operator) ||
isOperatorMultiValue(prevValues.operator) !==
isOperatorMultiValue(currentValues.operator) ||
prevBounds.showMin !== currentBounds.showMin ||
prevBounds.showMax !== currentBounds.showMax
);
};
const boundUnitShouldUpdate = (
prevValues: ConditionalFormattingConfig,
currentValues: ConditionalFormattingConfig,
) => prevValues.boundUnit !== currentValues.boundUnit;
const renderOperator = ({
showOnlyNone,
@@ -154,9 +276,155 @@ const renderOperator = ({
);
};
const renderBoundFields = (
operator?: Comparator,
serverPagination?: boolean,
) => {
const { showMin, showMax } = getBoundVisibility(operator);
// Cross-validate min/max only when both are shown; a lone bound
// validates against targetValue instead, its other end of the scale.
const useCrossFieldRules = showMin && showMax;
const minRules = useCrossFieldRules ? rulesMinBound : rulesMinBoundTarget;
const maxRules = useCrossFieldRules ? rulesMaxBound : rulesMaxBoundTarget;
const minDependencies = useCrossFieldRules ? minBoundDeps : targetValueDeps;
const maxDependencies = useCrossFieldRules ? maxBoundDeps : targetValueDeps;
// Percentage bounds require the complete result set. Existing percentage
// configurations remain editable here, but formatters use automatic bounds
// while server pagination is enabled.
const boundUnitSelectOptions = serverPagination
? boundUnitOptions.map(option =>
option.value === boundUnitOptions[1].value
? { ...option, disabled: true }
: option,
)
: boundUnitOptions;
return (
<>
<Row gutter={12}>
<Col span={12}>
<FormItem
name="boundUnit"
label={t('Bound unit')}
initialValue={boundUnitOptions[0].value}
tooltip={
serverPagination
? t(
'Value: type the exact numbers used for coloring below. % of column is unavailable with Server pagination enabled, since each page would compute a different percentage. Existing percentage rules use the automatic data range while Server pagination is enabled.',
)
: t(
'Value: type the exact numbers used for coloring below. % of column: type a percentage of the column maximum or sum selected below, so the rule keeps working as the data changes. Column sum adds the absolute values so positive and negative values do not cancel each other out.',
)
}
>
<Select
ariaLabel={t('Bound unit')}
options={boundUnitSelectOptions}
/>
</FormItem>
</Col>
<Col span={12}>
<FormItem noStyle shouldUpdate={boundUnitShouldUpdate}>
{({ getFieldValue }: GetFieldValue) =>
getFieldValue('boundUnit') === boundUnitOptions[1].value ? (
<FormItem
name="percentDenominator"
label={t('% of')}
initialValue={percentDenominatorOptions[0].value}
>
<Select
ariaLabel={t('Percent denominator')}
options={percentDenominatorOptions}
/>
</FormItem>
) : null
}
</FormItem>
</Col>
</Row>
<Row gutter={12}>
{showMin && (
<Col span={showMax ? 12 : 24}>
<FormItem
name="minBound"
label={t('Min bound')}
rules={minRules}
dependencies={minDependencies}
normalize={normalizeOptionalNumber}
validateTrigger="onBlur"
tooltip={t(
'Overrides the lowest value used for coloring. Leave blank to use the lowest value in the data.',
)}
>
<FullWidthInputNumber />
</FormItem>
</Col>
)}
{showMax && (
<Col span={showMin ? 12 : 24}>
<FormItem
name="maxBound"
label={t('Max bound')}
rules={maxRules}
dependencies={maxDependencies}
normalize={normalizeOptionalNumber}
validateTrigger="onBlur"
tooltip={t(
'Overrides the highest value used for coloring. Leave blank to use the highest value in the data.',
)}
>
<FullWidthInputNumber />
</FormItem>
</Col>
)}
</Row>
</>
);
};
const renderDivergingFields = () => (
<>
<Row gutter={12}>
<Col span={24}>
<FormItem
name="centerValue"
label={t('Center value')}
rules={rulesCenterValue}
dependencies={centerValueDeps}
normalize={normalizeOptionalNumber}
validateTrigger="onBlur"
tooltip={t(
'Optional. When set together with Low color, Mid color, and High color below, colors diverge from Mid color at this value toward Low color below it and High color above it, instead of a single color fading in and out. For % of column with Column sum, the resolved center must still fall inside the color range; otherwise the rule uses its single color.',
)}
>
<FullWidthInputNumber />
</FormItem>
</Col>
</Row>
<Row gutter={12}>
<Col span={8}>
<FormItem name="lowColor" label={t('Low color')}>
<ColorPickerControl ariaLabel={t('Low color')} outputFormat="hex" />
</FormItem>
</Col>
<Col span={8}>
<FormItem name="midColor" label={t('Mid color')}>
<ColorPickerControl ariaLabel={t('Mid color')} outputFormat="hex" />
</FormItem>
</Col>
<Col span={8}>
<FormItem name="highColor" label={t('High color')}>
<ColorPickerControl ariaLabel={t('High color')} outputFormat="hex" />
</FormItem>
</Col>
</Row>
</>
);
const renderOperatorFields = (
{ getFieldValue }: GetFieldValue,
columnType?: GenericDataType,
serverPagination?: boolean,
) => {
const columnTypeString = columnType === GenericDataType.String;
const columnTypeBoolean = columnType === GenericDataType.Boolean;
@@ -179,11 +447,19 @@ const renderOperatorFields = (
);
}
return isOperatorNone(getFieldValue('operator')) ? (
<Row gutter={12}>
<Col span={operatorColSpan}>{renderOperator({ columnType })}</Col>
</Row>
) : isOperatorMultiValue(getFieldValue('operator')) ? (
const operator = getFieldValue('operator');
const showBoundFields = !columnTypeString && isOperatorBoundable(operator);
const showDivergingFields = !columnTypeString && isOperatorNone(operator);
return isOperatorNone(operator) ? (
<>
<Row gutter={12}>
<Col span={operatorColSpan}>{renderOperator({ columnType })}</Col>
</Row>
{showBoundFields && renderBoundFields(operator, serverPagination)}
{showDivergingFields && renderDivergingFields()}
</>
) : isOperatorMultiValue(operator) ? (
<Row gutter={12}>
<Col span={9}>
<FormItem
@@ -212,18 +488,21 @@ const renderOperatorFields = (
</Col>
</Row>
) : (
<Row gutter={12}>
<Col span={operatorColSpan}>{renderOperator({ columnType })}</Col>
<Col span={valueColSpan}>
<FormItem
name="targetValue"
label={t('Target value')}
rules={rulesRequired}
>
{columnTypeString ? <FullWidthInput /> : <FullWidthInputNumber />}
</FormItem>
</Col>
</Row>
<>
<Row gutter={12}>
<Col span={operatorColSpan}>{renderOperator({ columnType })}</Col>
<Col span={valueColSpan}>
<FormItem
name="targetValue"
label={t('Target value')}
rules={rulesRequired}
>
{columnTypeString ? <FullWidthInput /> : <FullWidthInputNumber />}
</FormItem>
</Col>
</Row>
{showBoundFields && renderBoundFields(operator, serverPagination)}
</>
);
};
@@ -233,12 +512,14 @@ export const FormattingPopoverContent = ({
columns = [],
extraColorChoices = [],
allColumns = [],
serverPagination = false,
}: {
config?: ConditionalFormattingConfig;
onChange: (config: ConditionalFormattingConfig) => void;
columns: { label: string; value: string; dataType: GenericDataType }[];
extraColorChoices?: { label: string; colors: string[] }[];
allColumns?: ColumnOption[];
serverPagination?: boolean;
}) => {
const [form] = Form.useForm();
const colors = colorScheme();
@@ -482,7 +763,8 @@ export const FormattingPopoverContent = ({
)}
<FormItem noStyle shouldUpdate={shouldFormItemUpdate}>
{showOperatorFields ? (
(props: GetFieldValue) => renderOperatorFields(props, columnType)
(props: GetFieldValue) =>
renderOperatorFields(props, columnType, serverPagination)
) : (
<Row gutter={12}>
<Col span={6}>
@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Comparator, ObjectFormattingEnum } from '@superset-ui/chart-controls';
import {
Comparator,
ObjectFormattingEnum,
BoundUnit,
PercentDenominator,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
export const operatorOptions = [
@@ -64,6 +69,16 @@ export const formattingOptions = [
},
];
export const boundUnitOptions = [
{ value: BoundUnit.Value, label: t('Value') },
{ value: BoundUnit.Percent, label: t('% of column') },
];
export const percentDenominatorOptions = [
{ value: PercentDenominator.Max, label: t('Column max') },
{ value: PercentDenominator.Sum, label: t('Column sum') },
];
// Use theme token names instead of hex values to support theme switching
export const colorScheme = () => [
{
@@ -23,6 +23,8 @@ import {
Comparator,
ControlComponentProps,
ObjectFormattingEnum,
BoundUnit,
PercentDenominator,
} from '@superset-ui/chart-controls';
import { GenericDataType } from '@apache-superset/core/common';
import { type RGBColor } from '@superset-ui/core/components';
@@ -39,6 +41,14 @@ export type ConditionalFormattingConfig = {
useGradient?: boolean;
columnFormatting?: string;
objectFormatting?: ObjectFormattingEnum;
minBound?: number;
maxBound?: number;
centerValue?: number;
lowColor?: RGBColor | string;
midColor?: RGBColor | string;
highColor?: RGBColor | string;
boundUnit?: BoundUnit;
percentDenominator?: PercentDenominator;
};
export type ConditionalFormattingControlProps = ControlComponentProps<
@@ -51,6 +61,7 @@ export type ConditionalFormattingControlProps = ControlComponentProps<
description: string;
extraColorChoices?: { label: string; colors: string[] }[];
allColumns?: ColumnOption[];
serverPagination?: boolean;
};
export type FormattingPopoverProps = PopoverProps & {
@@ -61,6 +72,7 @@ export type FormattingPopoverProps = PopoverProps & {
children: ReactNode;
extraColorChoices?: { label: string; colors: string[] }[];
allColumns?: ColumnOption[];
serverPagination?: boolean;
};
export interface ColumnOption {
+1
View File
@@ -39,6 +39,7 @@ export interface Dashboard {
// `columns` projection. Bare ids, not Subjects.
extra_editors?: number[];
viewers?: Subject[];
is_managed_externally: boolean;
theme?: {
id: number;
theme_name: string;
@@ -778,3 +778,158 @@ describe('navigateTo duplicate-assign suppression', () => {
});
});
});
describe('openBlankTab / navigateOpenedTab / closeOpenedTab', () => {
let openSpy: jest.SpyInstance;
beforeEach(() => {
// Default to the blocked-popup return so a test that forgets to set one
// does not fall through to jsdom's unimplemented window.open.
openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
});
afterEach(() => {
openSpy.mockRestore();
});
const makeTab = () =>
({
closed: false,
close: jest.fn(),
location: { replace: jest.fn() },
}) as unknown as Window;
test('openBlankTab opens the placeholder without noopener so the handle is usable', async () => {
// Regression: opening a version as new stranded a blank about:blank tab
// because window.open(..., 'noopener') returns null, so openBlankTab handed
// the caller no window handle to navigate.
const tab = makeTab();
openSpy.mockReturnValue(tab);
const { openBlankTab } = await import('src/utils/navigationUtils');
const result = openBlankTab();
expect(result).toBe(tab);
// Assert the exact call, not merely the absence of the `noopener` token:
// `noreferrer` also forces window.open to return null, so a features arg of
// any kind would reship the stranded-tab bug. Only a two-arg call keeps the
// handle.
expect(openSpy).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith('', '_blank');
});
test('openBlankTab returns null when the browser blocks the popup', async () => {
openSpy.mockReturnValue(null);
const { openBlankTab } = await import('src/utils/navigationUtils');
// The null is the contract callers rely on to detect a blocked popup.
expect(openBlankTab()).toBeNull();
});
test('navigateOpenedTab points a live claimed tab at the resolved URL', async () => {
await withApplicationRoot('', async () => {
const tab = makeTab();
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
navigateOpenedTab(tab, '/dashboard/9/');
expect(tab.location.replace as jest.Mock).toHaveBeenCalledWith(
'/dashboard/9/',
);
// The live-handle branch must NOT fall through to a second window.open,
// which by the time it runs has lost user activation and is popup-blocked.
expect(openSpy).not.toHaveBeenCalled();
});
});
test('navigateOpenedTab prefixes the app root before replacing', async () => {
await withApplicationRoot('/superset/', async () => {
const tab = makeTab();
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
navigateOpenedTab(tab, '/dashboard/9/');
expect(tab.location.replace as jest.Mock).toHaveBeenCalledWith(
'/superset/dashboard/9/',
);
});
});
test('navigateOpenedTab falls back to a fresh window.open when the tab is null', async () => {
await withApplicationRoot('', async () => {
openSpy.mockReturnValue(null);
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
navigateOpenedTab(null, '/explore/?slice_id=1');
expect(openSpy).toHaveBeenCalledWith(
'/explore/?slice_id=1',
'_blank',
'noopener noreferrer',
);
});
});
test('navigateOpenedTab falls back when the claimed tab was already closed', async () => {
await withApplicationRoot('', async () => {
const tab = { ...makeTab(), closed: true } as unknown as Window;
openSpy.mockReturnValue(null);
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
navigateOpenedTab(tab, '/explore/?slice_id=1');
expect(tab.location.replace as jest.Mock).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith(
'/explore/?slice_id=1',
'_blank',
'noopener noreferrer',
);
});
});
test('navigateOpenedTab does not expose the opener-connected tab to an external URL', async () => {
await withApplicationRoot('', async () => {
const tab = makeTab();
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
// A safe-but-absolute URL is permitted by assertSafeNavigationUrl, but
// must not be navigated on the noopener-less claimed tab: it is closed
// and reopened through the noopener fallback instead.
navigateOpenedTab(tab, 'https://example.com/');
expect(tab.location.replace as jest.Mock).not.toHaveBeenCalled();
expect(tab.close as jest.Mock).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith(
'https://example.com/',
'_blank',
'noopener noreferrer',
);
});
});
test('navigateOpenedTab validates the URL before touching a claimed tab', async () => {
await withApplicationRoot('', async () => {
const tab = makeTab();
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
expect(() => navigateOpenedTab(tab, '//evil.com')).toThrow();
expect(tab.location.replace as jest.Mock).not.toHaveBeenCalled();
expect(tab.close as jest.Mock).not.toHaveBeenCalled();
expect(openSpy).not.toHaveBeenCalled();
});
});
test('closeOpenedTab closes a live tab and no-ops on null or already-closed', async () => {
const { closeOpenedTab } = await import('src/utils/navigationUtils');
const tab = makeTab();
closeOpenedTab(tab);
expect(tab.close as jest.Mock).toHaveBeenCalledTimes(1);
const closed = { ...makeTab(), closed: true } as unknown as Window;
closeOpenedTab(closed);
expect(closed.close as jest.Mock).not.toHaveBeenCalled();
expect(() => closeOpenedTab(null)).not.toThrow();
});
});
+29 -2
View File
@@ -466,9 +466,18 @@ export function openInNewTab(path: string): void {
* refusal is silent -- `window.open` just returns null -- so a caller that
* awaits first appears to do nothing at all. Call this synchronously in the
* handler and hand the result to `navigateOpenedTab` when the URL is known.
*
* The placeholder is opened WITHOUT `noopener`: per the HTML standard,
* `window.open(..., 'noopener')` always returns null, which would discard the
* very handle this function exists to return and leave the caller with a
* stranded `about:blank` tab it can never navigate. The destination is always
* a same-origin app route (`ensureAppRoot` in `navigateOpenedTab`), so the
* opener relationship carries no cross-origin tabnabbing risk. The one-shot
* `window.open(url, ...)` fallback in `navigateOpenedTab` keeps `noopener`,
* since it passes the real URL and never needs the handle.
*/
export function openBlankTab(): Window | null {
return window.open('', '_blank', NEW_TAB_FEATURES);
return window.open('', '_blank');
}
/**
@@ -477,13 +486,31 @@ export function openBlankTab(): Window | null {
*
* The URL is validated before either branch, so an unsafe path cannot reach
* a pre-opened tab any more than it could reach `openInNewTab`.
*
* The external-URL branch (a safe absolute URL on a claimed tab) is defensive:
* no current caller passes one, and by the time it would run the click's
* activation has lapsed, so the reopen is popup-blocked and the placeholder
* closes with nothing opening. It exists only to keep an absolute URL off the
* opener-connected tab; a caller needing an external target after an await
* should not claim a tab up front.
*/
export function navigateOpenedTab(tab: Window | null, path: string): void {
const url = assertSafeNavigationUrl(ensureAppRoot(path));
if (tab && !tab.closed) {
// Only a same-origin app route may reuse the opener-connected placeholder
// from `openBlankTab` (which drops `noopener` to keep its handle).
// `ensureAppRoot` leaves an absolute URL untouched, so anything not starting
// with a single `/` is external: it must not ride the opener chain. Route it
// through the `noopener` fallback instead — closing the claimed tab first so
// no `about:blank` is stranded — which makes the same-origin guarantee
// structural rather than a caller convention.
const isSameOriginRoute = url.startsWith('/') && !url.startsWith('//');
if (isSameOriginRoute && tab && !tab.closed) {
tab.location.replace(url);
return;
}
if (tab && !tab.closed) {
tab.close();
}
window.open(url, '_blank', NEW_TAB_FEATURES);
}
-1
View File
@@ -10,7 +10,6 @@
"node_modules",
"dist",
"coverage",
".eslintrc.js",
"*.md",
"*.json"
]
-50
View File
@@ -1,50 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import typescriptEslintParser from '@typescript-eslint/parser';
import typescriptEslintPlugin from '@typescript-eslint/eslint-plugin';
import typescriptEslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
import js from '@eslint/js';
import globals from 'globals';
export default [
js.configs.recommended,
...typescriptEslint.configs.recommended,
eslintConfigPrettier,
{
files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'],
ignores: ['*.min.js', 'node_modules', 'dist', 'coverage'],
languageOptions: {
parser: typescriptEslintParser,
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
typescript: typescriptEslintPlugin,
},
rules: {
'@typescript-eslint/explicit-module-boundary-types': 0,
'@typescript-eslint/no-var-requires': 0,
'@typescript-eslint/no-require-imports': 0, // Re-enable once superset-websocket is converted to ESM
},
},
];
+146
View File
@@ -0,0 +1,146 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"typescript",
"unicorn"
],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true
},
"env": {
"builtin": true
},
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": [
"**/*.js",
"**/*.jsx",
"**/*.ts",
"**/*.tsx"
],
"rules": {
"typescript/explicit-module-boundary-types": "off",
},
"globals": {
"AsyncDisposableStack": "readonly",
"DisposableStack": "readonly",
"SuppressedError": "readonly"
},
"env": {
"browser": true,
"node": true
}
}
]
}
+848 -1171
View File
File diff suppressed because it is too large Load Diff
+6 -11
View File
@@ -7,9 +7,9 @@
"scripts": {
"start": "node dist/index.js start",
"test": "npx vitest --run --dir spec",
"type": "tsc --noEmit",
"eslint": "eslint",
"lint": "npm run eslint -- . && npm run type",
"type": " tsc --noEmit",
"lint-check": "npx oxlint --config oxlint.json",
"lint": "npm run type && npm run lint-check",
"dev-server": "npm run build && npm run start",
"build": "tsc",
"format": "oxfmt --write .",
@@ -26,21 +26,16 @@
"ws": "^8.21.3"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/eslint__js": "^8.42.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.4.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.68.0",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.11.0",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
"oxlint-tsgolint": "^7.0.2001",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.68.0",
"typescript": "^7.0.2",
"vitest": "^4.1.11"
},
"engines": {
+3 -3
View File
@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
const jwt = require('jsonwebtoken');
const config = require('../config.test.json');
import jwt from 'jsonwebtoken';
import config from '../config.test.json' with { type: 'json' };
import {
describe,
expect,
@@ -645,7 +645,7 @@ describe('server', () => {
pongHandler(Buffer.from('constructor'));
pongHandler(Buffer.from('hasOwnProperty'));
// eslint-disable-next-line no-prototype-builtins
// oxlint-disable-next-line no-prototype-builtins
expect(Object.prototype.hasOwnProperty('pongTs')).toBe(false);
expect(({} as Record<string, unknown>).pongTs).toBeUndefined();
+2 -2
View File
@@ -37,7 +37,7 @@ export type StreamResult = [
// sync with superset-frontend/src/components/ErrorMessage/types
export type ErrorLevel = 'info' | 'warning' | 'error';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// oxlint-disable-next-line no-explicit-any
export type SupersetError<ExtraType = Record<string, any> | null> = {
error_type: string;
extra: ExtraType;
@@ -321,7 +321,7 @@ export const subscribeToGlobalStream = async (
stream: string,
listener: ListenerFunction,
) => {
/*eslint no-constant-condition: ["error", { "checkLoops": false }]*/
/* oxlint no-constant-condition: ["error", { "checkLoops": false }]*/
while (true) {
try {
const reply = await redis.xread(
+10 -8
View File
@@ -16,15 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
import createError from 'http-errors';
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import cookieParser from 'cookie-parser';
import logger from 'morgan';
import indexRouter from './routes/index.js';
var indexRouter = require('./routes/index');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
var app = express();
const app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
@@ -54,4 +56,4 @@ app.use(function (err, req, res) {
res.render('error');
});
module.exports = app;
export default app;
+11 -9
View File
@@ -22,22 +22,24 @@
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('client-ws-app:server');
var http = require('http');
import app from '../app.js';
import http from 'node:http';
import debugModule from 'debug';
const debug = debugModule('client-ws-app:server');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
const server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
@@ -52,7 +54,7 @@ server.on('listening', onListening);
*/
function normalizePort(val) {
var port = parseInt(val, 10);
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
@@ -76,7 +78,7 @@ function onError(error) {
throw error;
}
var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
@@ -98,7 +100,7 @@ function onError(error) {
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
const addr = server.address();
const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
debug('Listening on ' + bind);
}
+8 -7
View File
@@ -13,7 +13,7 @@
"express": "~5.2.1",
"http-errors": "~2.0.1",
"jsonwebtoken": "^9.0.3",
"morgan": "~1.11.0",
"morgan": "~1.12.0",
"pug": "~3.0.4"
}
},
@@ -846,9 +846,10 @@
}
},
"node_modules/morgan": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz",
"integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==",
"license": "MIT",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
@@ -2013,9 +2014,9 @@
}
},
"morgan": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz",
"integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==",
"requires": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
@@ -2,6 +2,7 @@
"name": "client-ws-app",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node ./bin/www"
},
@@ -11,7 +12,7 @@
"express": "~5.2.1",
"http-errors": "~2.0.1",
"jsonwebtoken": "^9.0.3",
"morgan": "~1.11.0",
"morgan": "~1.12.0",
"pug": "~3.0.4"
}
}
@@ -32,7 +32,7 @@ function connect() {
if (socketCount >= tokens.length) return;
// using https://github.com/js-cookie/js-cookie
// eslint-disable-next-line no-undef
// oxlint-disable-next-line no-undef
Cookies.set(cookieName, tokens[socketCount], { path: '' });
// Create WebSocket connection.
@@ -16,10 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
const express = require('express');
import express from 'express';
import jwt from 'jsonwebtoken';
import config from '../../../config.json' with { type: 'json' };
const router = express.Router();
const jwt = require('jsonwebtoken');
const config = require('../../../config.json');
router.get('/', function (req, res) {
let numTokens = req.query.sockets ? Number(req.query.sockets) : 100;
@@ -35,4 +36,4 @@ router.get('/', function (req, res) {
});
});
module.exports = router;
export default router;
+4 -3
View File
@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
const Redis = require('ioredis');
const config = require('../config.json');
const { randomUUID } = require('crypto');
import Redis from 'ioredis';
import config from '../config.json' with { type: 'json' };
import { randomUUID } from 'crypto';
const redis = new Redis(config.redis);
const numClients = 256;
+10 -7
View File
@@ -92,13 +92,16 @@ def update_api_docs() -> None:
if isinstance(base_api, BaseApi) and base_api.version == api_version:
base_api.add_api_spec(api_spec)
version_found = True
if version_found:
click.secho("Generating openapi.json", fg="green")
with open(openapi_json, "w") as outfile:
json.dump(api_spec.to_dict(), outfile, sort_keys=True, indent=2)
outfile.write("\n")
else:
click.secho("API version not found", err=True)
if not version_found:
# Exiting zero here would leave the stale file in place and report
# success, which reads as "the spec is current" to any caller diffing
# the result.
raise click.ClickException(f"No {api_version} API found to document")
click.secho("Generating openapi.json", fg="green")
with open(openapi_json, "w") as outfile:
json.dump(api_spec.to_dict(), outfile, sort_keys=True, indent=2)
outfile.write("\n")
@click.command()
+1 -1
View File
@@ -96,7 +96,7 @@ class CreateChartCommand(CreateMixin, BaseCommand):
if len(dashboards) != len(dashboard_ids):
exceptions.append(DashboardsNotFoundValidationError())
for dash in dashboards:
if not security_manager.is_editor(dash):
if dash.is_managed_externally or not security_manager.is_editor(dash):
raise DashboardsForbiddenError()
self._properties["dashboards"] = dashboards
+4 -3
View File
@@ -115,7 +115,8 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
requested_dashboard_ids = {d.id for d in requested_dashboards}
if new_dashboard_ids := requested_dashboard_ids - existing_dashboard_ids:
# For NEW dashboard relationships, verify user has editorship
# For NEW dashboard relationships, verify user has access first
# to avoid leaking information about inaccessible dashboards
accessible_dashboards = DashboardDAO.find_by_ids(list(new_dashboard_ids))
unauthorized_dashboard_ids = new_dashboard_ids - {
d.id for d in accessible_dashboards
@@ -123,10 +124,10 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
if unauthorized_dashboard_ids:
exceptions.append(DashboardsNotFoundValidationError())
return
# Additional editorship check - must match CreateChartCommand behavior
for dash in accessible_dashboards:
if not security_manager.is_editor(dash):
if dash.is_managed_externally or not security_manager.is_editor(dash):
raise DashboardsForbiddenError()
def _validate_query_context_datasource(
+1
View File
@@ -479,6 +479,7 @@ class DashboardRestApi(
"changed_by",
"dashboard_title",
"id",
"is_managed_externally",
"uuid",
"editors",
"viewers",
+29 -26
View File
@@ -172,17 +172,18 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
]
metric = "sum__num"
defaults = {
shared_defaults = {
"compare_lag": "10",
"compare_suffix": "o10Y",
"limit": "25",
"granularity": "ds",
"groupby": [],
"row_limit": current_app.config["ROW_LIMIT"],
"time_range": "100 years ago : now",
"viz_type": "table",
"markup_type": "markdown",
}
non_echarts_defaults = {**shared_defaults, "granularity": "ds"}
echarts_x_axis_defaults = {**shared_defaults, "x_axis": "ds"}
default_query_context = {
"result_format": "json",
@@ -211,7 +212,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Participants",
viz_type="big_number",
params=get_slice_json(
defaults,
non_echarts_defaults,
viz_type="big_number",
granularity="ds",
compare_lag="5",
@@ -225,7 +226,10 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Genders",
viz_type="pie",
params=get_slice_json(
defaults, viz_type="pie", groupby=["gender"], metric=metric
non_echarts_defaults,
viz_type="pie",
groupby=["gender"],
metric=metric,
),
editors=[],
),
@@ -234,10 +238,9 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Trends",
viz_type="echarts_timeseries_line",
params=get_slice_json(
defaults,
echarts_x_axis_defaults,
viz_type="echarts_timeseries_line",
groupby=["name"],
granularity="ds",
rich_tooltip=True,
show_legend=True,
metrics=metrics,
@@ -249,7 +252,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Genders by State",
viz_type="echarts_timeseries_bar",
params=get_slice_json(
defaults,
echarts_x_axis_defaults,
adhoc_filters=[
{
"clause": "WHERE",
@@ -286,7 +289,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Girls",
viz_type="table",
params=get_slice_json(
defaults,
non_echarts_defaults,
groupby=["name"],
adhoc_filters=[gen_filter("gender", "girl")],
row_limit=50,
@@ -300,7 +303,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Girl Name Cloud",
viz_type="word_cloud",
params=get_slice_json(
defaults,
non_echarts_defaults,
viz_type="word_cloud",
size_from="10",
series="name",
@@ -317,7 +320,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Boys",
viz_type="table",
params=get_slice_json(
defaults,
non_echarts_defaults,
groupby=["name"],
adhoc_filters=[gen_filter("gender", "boy")],
row_limit=50,
@@ -331,7 +334,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Boy Name Cloud",
viz_type="word_cloud",
params=get_slice_json(
defaults,
non_echarts_defaults,
viz_type="word_cloud",
size_from="10",
series="name",
@@ -348,7 +351,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Top 10 Girl Name Share",
viz_type="echarts_area",
params=get_slice_json(
defaults,
echarts_x_axis_defaults,
adhoc_filters=[gen_filter("gender", "girl")],
comparison_type="values",
groupby=["name"],
@@ -356,7 +359,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
stacked_style="expand",
time_grain_sqla="P1D",
viz_type="echarts_area",
x_axis_forma="smart_date",
x_axis_time_format="smart_date",
metrics=metrics,
),
editors=[],
@@ -366,7 +369,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Top 10 Boy Name Share",
viz_type="echarts_area",
params=get_slice_json(
defaults,
echarts_x_axis_defaults,
adhoc_filters=[gen_filter("gender", "boy")],
comparison_type="values",
groupby=["name"],
@@ -374,7 +377,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
stacked_style="expand",
time_grain_sqla="P1D",
viz_type="echarts_area",
x_axis_forma="smart_date",
x_axis_time_format="smart_date",
metrics=metrics,
),
editors=[],
@@ -384,7 +387,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Pivot Table v2",
viz_type="pivot_table_v2",
params=get_slice_json(
defaults,
non_echarts_defaults,
viz_type="pivot_table_v2",
groupbyRows=["name"],
groupbyColumns=["state"],
@@ -408,7 +411,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Average and Sum Trends",
viz_type="mixed_timeseries",
params=get_slice_json(
defaults,
echarts_x_axis_defaults,
viz_type="mixed_timeseries",
metrics=[
{
@@ -420,7 +423,6 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
}
],
metrics_b=["sum__num"],
granularity="ds",
yAxisIndex=0,
yAxisIndexB=1,
),
@@ -431,7 +433,9 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Num Births Trend",
viz_type="echarts_timeseries_line",
params=get_slice_json(
defaults, viz_type="echarts_timeseries_line", metrics=metrics
echarts_x_axis_defaults,
viz_type="echarts_timeseries_line",
metrics=metrics,
),
editors=[],
),
@@ -440,7 +444,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Daily Totals",
viz_type="table",
params=get_slice_json(
defaults,
non_echarts_defaults,
groupby=["ds"],
time_range="1983 : 2023",
viz_type="table",
@@ -463,7 +467,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Number of California Births",
viz_type="big_number_total",
params=get_slice_json(
defaults,
non_echarts_defaults,
metric={
"expressionType": "SIMPLE",
"column": {
@@ -483,7 +487,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Top 10 California Names Timeseries",
viz_type="echarts_timeseries_line",
params=get_slice_json(
defaults,
echarts_x_axis_defaults,
metrics=[
{
"expressionType": "SIMPLE",
@@ -496,7 +500,6 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
}
],
viz_type="echarts_timeseries_line",
granularity="ds",
groupby=["name"],
series_limit_metric={
"expressionType": "SIMPLE",
@@ -518,7 +521,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Names Sorted by Num in California",
viz_type="table",
params=get_slice_json(
defaults,
non_echarts_defaults,
metrics=metrics,
groupby=["name"],
row_limit=50,
@@ -539,7 +542,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Number of Girls",
viz_type="big_number_total",
params=get_slice_json(
defaults,
non_echarts_defaults,
metric=metric,
viz_type="big_number_total",
granularity="ds",
@@ -553,7 +556,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
slice_name="Pivot Table",
viz_type="pivot_table_v2",
params=get_slice_json(
defaults,
non_echarts_defaults,
viz_type="pivot_table_v2",
groupbyRows=["name"],
groupbyColumns=["state"],
@@ -16,7 +16,7 @@
# under the License.
always_filter_main_dttm: false
cache_timeout: null
catalog: examples
catalog: null
columns:
- advanced_data_type: null
column_name: start_time
@@ -27,7 +27,7 @@ params:
color_scheme: supersetColors
comparison_type: null
datasource: 23__table
granularity_sqla: order_date
x_axis: order_date
groupby:
- product_line
label_colors:
@@ -28,7 +28,7 @@ params:
comparison_type: values
contribution: false
datasource: 23__table
granularity_sqla: order_date
x_axis: order_date
groupby:
- deal_size
label_colors: {}
@@ -35,7 +35,7 @@ params:
color_scheme: supersetColors
comparison_type: values
datasource: 56__table
granularity_sqla: ts
x_axis: ts
groupby:
- name
label_colors:
@@ -30,7 +30,7 @@ params:
subject: state
compare_lag: '10'
compare_suffix: o10Y
granularity: ds
x_axis: ds
groupby:
- state
limit: '25'
@@ -29,7 +29,7 @@ params:
compare_lag: '10'
compare_suffix: o10Y
comparison_type: values
granularity: ds
x_axis: ds
groupby:
- name
limit: 10
@@ -47,7 +47,7 @@ params:
time_grain_sqla: P1D
time_range: '100 years ago : now'
viz_type: echarts_area
x_axis_forma: smart_date
x_axis_time_format: smart_date
query_context: null
slice_name: Top 10 Boy Name Share
uuid: 26a9bde8-eb06-4de3-91f0-5e04d448403a
@@ -29,7 +29,7 @@ params:
compare_lag: '10'
compare_suffix: o10Y
comparison_type: values
granularity: ds
x_axis: ds
groupby:
- name
limit: 10
@@ -47,7 +47,7 @@ params:
time_grain_sqla: P1D
time_range: '100 years ago : now'
viz_type: echarts_area
x_axis_forma: smart_date
x_axis_time_format: smart_date
query_context: null
slice_name: Top 10 Girl Name Share
uuid: 44c4c16f-216d-44c8-b033-6876b8c51fd2
@@ -22,7 +22,7 @@ description: null
params:
compare_lag: '10'
compare_suffix: o10Y
granularity: ds
x_axis: ds
groupby:
- name
limit: '25'
@@ -37,7 +37,7 @@ params:
columns: []
contribution: true
datasource: 21__table
granularity_sqla: year
x_axis: year
groupby:
- name
label_colors: {}

Some files were not shown because too many files have changed in this diff Show More