Compare commits

..
Author SHA1 Message Date
Elizabeth ThompsonandClaude Opus 4.8 c18c91a89d fix(chart): catch JSONDecodeError when parsing params on chart create
CreateChartCommand.__init__ called json.loads on the client-supplied
params string without guarding it. A malformed params value in
POST /api/v1/chart raised a raw JSONDecodeError out of __init__ --
before run()'s @transaction or validate() ran -- which the api.py
create() handler does not catch, surfacing as an opaque 500.

Wrap the parse and raise ChartInvalidError(exceptions=[...]) instead,
following the existing *ValidationError idiom, so the existing
except ChartInvalidError branch returns a 422. Adds a unit test
covering both the invalid-JSON and valid-JSON (happy path) cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 16:40:26 +00:00
SBIN2010 96119ffefc feat(plugin-chart-echarts): add Butterfly Chart (#43346) 2026-08-29 17:40:02 +03:00
rlei 74ea780eaa fix(plugin-chart-echarts): misplaced first date label on horizontal bar charts (#43661) 2026-08-29 21:16:58 +07:00
Evan Rusackas 3d91ba3fde chore(deps): bump js-yaml, brace-expansion, d3-color to patch DoS/ReDoS in cypress-base (#43664) 2026-08-29 14:02:56 +07:00
Evan RusackasandSuperset Dev 9151b5c6fb chore(deps): bump brace-expansion to patch DoS in docs (#43665)
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-08-29 14:02:17 +07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
ec19a37e9b chore(deps): bump greenlet from 3.5.4 to 3.5.5 (#43671)
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-08-29 14:01:34 +07:00
Evan Rusackas 2ca3695f30 chore(deps): bump js-yaml to patch quadratic-CPU DoS (superset-frontend) (#43663) 2026-08-29 13:44:31 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ac88acb14b chore(deps-dev): bump eslint from 10.8.1 to 10.9.0 in /superset-websocket (#43670)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 13:42:05 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 46f17aa880 chore(deps): bump baseline-browser-mapping from 2.11.16 to 2.11.17 in /docs (#43673)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 13:41:17 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7bc44d5504 chore(deps): bump reselect from 5.2.0 to 5.3.0 in /docs (#43674)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 13:41:06 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 4444a89f26 chore(deps): bump reselect from 5.2.0 to 5.3.0 in /superset-frontend (#43675)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 13:40:47 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 7c2a3fa777 chore(deps-dev): bump baseline-browser-mapping from 2.11.16 to 2.11.17 in /superset-frontend (#43676)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 13:38:37 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b38fa70256 chore(deps-dev): bump eslint from 10.8.1 to 10.9.0 in /superset-frontend (#43677)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 13:38:10 +07:00
PRATHAMESH HUKKERIandPrathamesh Hukkeri 1c8d58a77b fix(i18n): make language pack endpoint public for embedded dashboards (#42438)
Co-authored-by: Prathamesh Hukkeri <prathamesh04@users.noreply.github.com>
2026-08-28 20:46:27 -07:00
Mallikarjuna Reddy Nimmakayala de33efae98 fix(table-chart): support where for adhoc columns with server-side pagination (#42706) 2026-08-28 20:37:20 -07:00
Sepuri Sai Krishna b30d569028 fix(chart): map the remaining time grains for Prophet forecasting (#43205) 2026-08-28 20:35:52 -07:00
6f69fc6dba feat(postprocessing): teach pivot() to compute percent-of-row/col/total (#42809) (#42976)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-28 20:34:15 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
b2a8842551 chore(deps-dev): bump the swc group in /superset-frontend with 3 updates (#43447)
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-08-28 20:30:12 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Evan RusackasSuperset Dev
c7cc22f4fe chore(deps): bump setuptools to 84.0.0 and pip to 26.2.1 (#43413)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-08-28 18:34:09 -07:00
Francesco.CastaldiandFrancescoCastaldi 058eaf78ab fix(dashboard): remove @has_access_api from filter state REST API (#43564)
Co-authored-by: FrancescoCastaldi <francesco.castaldi@mapsgroup.it>
2026-08-28 17:06:06 -07:00
Elizabeth Thompson 30402b412c fix(explore): catch TemplateError when validating access for query-backed form_data (#43470) 2026-08-28 15:03:08 -07:00
Mike BridgeandClaude Fable 5 782a57fbfc chore(deps-dev): bump nwsapi from 2.2.23 to 2.2.24 to fix the SqlEditor jest flake (#43657)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:59:35 -04:00
ʈᵃᵢ 99a1f756cd fix(trino): coerce string-typed values results back to source type (#43654) 2026-08-28 14:25:42 -07:00
Mehmet Salih Yavuz 0eda633b45 fix(dataset): stop a stale edit modal from silently reverting a saved change (#43583) 2026-08-28 21:07:36 +03:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>hainenber
5c24f72d92 chore(deps): bump content-disposition from 2.0.1 to 3.0.0 in /superset-frontend (#43382)
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-08-29 00:53:43 +07:00
Gabriel Torres Ruiz 60479fb958 feat(dashboard): add dashboard.slice.header.menu extension slot (#43624)
Signed-off-by: Gabriel Torres Ruiz <gabo2595@gmail.com>
2026-08-28 14:52:18 -03:00
Ville Brofeldt 4ced5ca35a chore(superset-core): drop unused __all__ lists (#43626) 2026-08-28 09:46:56 -07:00
rlei fc26991cd4 feat(plugin-chart-echarts): add gridline and axis tick controls (#43428) 2026-08-28 09:44:39 -07:00
shauryaandShaurya a5c68c8df9 fix(number-format): handle sub-byte values and unit rollover in memory formatter (#43549)
Co-authored-by: Shaurya <19599684+no-hup@users.noreply.github.com>
2026-08-28 09:44:19 -07:00
Đỗ Trọng Hải 53e76afd70 feat(ci): enforce min release age for npm dep installation (#43164)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-28 23:42:41 +07:00
shauryaandShaurya d997d363e3 docs: update frontend Node/npm prerequisites to match engines (#43546)
Co-authored-by: Shaurya <19599684+no-hup@users.noreply.github.com>
2026-08-28 23:16:34 +07:00
Lalith Kothuru 9a6f6ee0c0 docs: fix docstring parameter names that do not match signatures (#43630) 2026-08-28 22:57:43 +07:00
Joe Li 94dd3d049c fix(ci): repair scheduled pre-commit drift (#43603) 2026-08-28 22:56:18 +07:00
b3f718da62 fix(explore): keep certification badges after saving or swapping a dataset (#43319)
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 08:47:36 -07:00
Mehmet Salih YavuzandEnzo Martellucci abf338d611 feat(filters): search filter values server-side in Explore (#43518)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-08-28 16:46:09 +03:00
Mehmet Salih Yavuz e518b21994 fix(dashboard): keep the chart menu usable in fullscreen on production builds (#43555) 2026-08-28 15:47:48 +03:00
Mehmet Salih Yavuz e18f27e1ce fix(native-filters): allow clearing an optional "select first value" filter (#43405) 2026-08-28 15:46:46 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9bf457dea6 chore(deps): bump github/codeql-action/analyze from 4.37.7 to 4.37.8 (#43643)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 15:26:18 +03:00
Enzo Martellucci cb7b790733 fix(dataset): surface real validation message in save-error dialog (#43459) 2026-08-28 13:35:09 +02:00
Alexandru Soare 8bec85158c feat(modals): add renderExtraFields prop to chart and dashboard properties modals (#43622) 2026-08-28 13:18:51 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> a30e4a4350 chore(deps): bump github/codeql-action/init from 4.37.7 to 4.37.8 (#43644)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 02:39:11 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 933dbbc2a2 chore(deps-dev): bump eslint-plugin-react-you-might-not-need-an-effect from 1.0.1 to 1.0.2 in /superset-frontend (#43645)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-28 02:39:06 -07:00
Enzo Martellucci a59b96c4f5 fix(explore): keep the row count label visible when the row limit is reached (#43296) 2026-08-28 10:51:27 +02:00
MafiandMatt Fitzgerald 12cd259c55 fix(dataset): preserve validation error messages (#43631)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
2026-08-28 01:01:17 -04:00
Chandan P 3ddc3b1d56 fix(cache): fall back to default timeout when cache_timeout is None (#43149) 2026-08-27 21:50:53 -07:00
Chandan P 98ec6018df fix(embedded): honor "can view query" permission for guest users (#43151) 2026-08-27 21:49:52 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
2ebd415b8a chore(deps-dev): bump mcp from 1.29.1 to 2.0.0 (#43611)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-27 21:49:50 -07:00
81b3e85522 fix(echarts): guard cross-filter when labelMap entry is missing (#42559)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 21:49:21 -07:00
Viktor Högberg fd64efd72d fix(uploads): set catalog on datasets created by file upload (#43301) 2026-08-27 21:46:51 -07:00
bucketbase26andJoe Li e39bfb255b fix(explore): prevent duplicate Date Range tooltips (#43425)
Co-authored-by: Joe Li <joe@preset.io>
2026-08-27 21:02:57 -07:00
b7301ac88a fix: Dashboard export with charts from multiple databases (#37120)
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 20:27:54 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> fa59b44cfe chore(deps): bump mapbox-gl from 3.28.1 to 3.29.0 in /superset-frontend (#43619)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-27 19:58:55 -07:00
195 changed files with 6133 additions and 1998 deletions
+5 -8
View File
@@ -24,14 +24,6 @@ updates:
- dependency-name: "@types/react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
# Source: https://jestjs.io/blog#known-issues
# GH thread: https://github.com/jsdom/jsdom/issues/3492
- dependency-name: "jest-environment-jsdom"
# `@swc/plugin-transform-imports` doesn't work with current Webpack-SWC hybrid setup
# See https://github.com/apache/superset/pull/37384#issuecomment-3793991389
# TODO: remove the plugin once Lodash usage has been migrated to a more readily tree-shakeable alternative
- dependency-name: "@swc/plugin-transform-imports"
# deck.gl and luma.gl share strict peer constraints across the root and
# plugin workspaces, and root overrides pin their transitive versions.
# Upgrade both families together in a manually validated change.
@@ -87,6 +79,11 @@ updates:
patterns:
- "ag-grid-react"
- "ag-grid-community"
swc:
patterns:
- "@swc/core"
- "@swc/plugin-emotion"
- "@swc/plugin-transform-imports"
open-pull-requests-limit: 30
versioning-strategy: increase
cooldown:
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -78,6 +78,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
category: "/language:${{matrix.language}}"
+21
View File
@@ -66,6 +66,27 @@ jobs:
- name: "Set up liccheck"
run: |
# liccheck (as of 0.9.2) still does a bare `import pkg_resources`
# without declaring setuptools as a dependency, relying on it
# having historically been bundled. setuptools 81+ (installed
# above via requirements/base.txt) dropped the pkg_resources
# subpackage entirely, so liccheck's own import breaks outright.
#
# Reinstalling an older setuptools would restore pkg_resources but
# would also downgrade the *real* setuptools install, which then
# trips liccheck's own working_set.resolve() -- it cross-checks
# requirements/base.txt's declared `setuptools==84.0.0` against
# what's actually installed, and a downgrade makes those disagree.
#
# Instead, vendor just the pkg_resources/ package files from an
# old setuptools wheel into site-packages, leaving the real
# setuptools install (and its dist-info metadata) untouched. This
# gives liccheck an importable pkg_resources whose own working-set
# scan still correctly reports the real installed setuptools
# version, so no conflict is raised.
pip download "setuptools<81" --no-deps -d /tmp/old-setuptools
python -m zipfile -e /tmp/old-setuptools/setuptools-*.whl /tmp/old-setuptools-extracted/
cp -r /tmp/old-setuptools-extracted/pkg_resources "$(python -c 'import site; print(site.getsitepackages()[0])')/"
uv pip install --system liccheck
- name: "Run liccheck"
run: |
-107
View File
@@ -1,107 +0,0 @@
# db_engine_specs tests against real databases (testcontainers)
name: Testcontainers
# Spins up real Docker containers (see tests/testcontainers/ for the current
# dialect list) via testcontainers-python, which catches real dialect/driver
# regressions -- the kind mocked db_engine_specs unit tests structurally
# cannot, e.g. apache/superset#42899 (Trino emitting OFFSET before LIMIT).
# Runs on a nightly cron (catches drift from a driver's own releases, not
# just from Superset's changes) and on pull_request, scoped via `paths` to
# only PRs that actually touch this test suite or the workflow itself, so
# unrelated PRs across the repo are never affected.
permissions:
contents: read
on:
schedule:
- cron: "0 5 * * *"
workflow_dispatch: {}
pull_request:
paths:
- ".github/workflows/testcontainers.yml"
- "tests/testcontainers/**"
concurrency:
# Scoped by ref, not just workflow name -- otherwise every PR run and the
# nightly cron share one group, and starting the workflow on another PR
# (or the nightly firing mid-PR-run) cancels an unrelated in-progress run.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
testcontainers:
runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix:
include:
# One job per dialect rather than one job for the whole suite: a
# single slow container would otherwise inflate the wall-clock
# time for every dialect, not just its own. Running in parallel
# means the suite's total time is bounded by the slowest dialect,
# not the sum of all of them. Db2's first-boot init is documented
# upstream as notably slow (a real instance bring-up, not just a
# process start) and untested locally here (no arm64 image), so
# it gets a wider timeout margin than the rest until real CI data
# says otherwise.
- dialect: cockroachdb
timeout: 10
- dialect: crate
timeout: 10
- dialect: trino
timeout: 10
- dialect: mssql
timeout: 10
- dialect: elasticsearch
timeout: 10
- dialect: oracle
timeout: 15
- dialect: db2
timeout: 25
timeout-minutes: ${{ matrix.timeout }}
env:
PYTHONPATH: ${{ github.workspace }}
SUPERSET_TESTENV: true
SUPERSET_SECRET_KEY: not-a-secret
# This job's matrix installs exactly one dialect's testcontainers
# driver for exactly this job, so treat that driver as required: a
# broken/missing import should fail the job, not silently skip to a
# misleadingly green, zero-tests-run result. See _driver.py.
SUPERSET_TESTCONTAINERS_STRICT: true
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup Python
uses: ./.github/actions/setup-backend/
with:
python-version: current
- name: Install db2 driver (ibm-db-sa)
# ibm-db (the db2 DBAPI) ships no Linux arm64 wheel, so it's kept out
# of the baseline dev install (requirements/development.in) to avoid
# breaking the multi-platform dev Docker image build. Install it here
# instead, only for this leg of the matrix.
if: matrix.dialect == 'db2'
run: uv pip install --system -e .[db2]
- name: Run testcontainers db_engine_specs tests (${{ matrix.dialect }})
run: |
pytest --durations-min=2 -v -m testcontainers \
./tests/testcontainers/db_engine_specs/test_${{ matrix.dialect }}.py \
--junit-xml=test-results/junit-testcontainers-${{ matrix.dialect }}.xml
- name: Upload JUnit test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: junit-results-testcontainers-${{ matrix.dialect }}
path: test-results/
retention-days: 7
actions-timeline:
needs: [testcontainers]
if: always()
runs-on: ubuntu-26.04
permissions:
actions: read
steps:
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
+1
View File
@@ -10,6 +10,7 @@
.stylelintignore
.flake8
.nvmrc
.npmrc
.rat-excludes
.swcrc
.*log
+1 -1
View File
@@ -25,7 +25,7 @@ assists people when migrating to a new version.
## Next
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed must `pip uninstall cockroachdb` before reinstalling the extra -- both packages register the same `cockroachdb` SQLAlchemy dialect entry point, so leaving the old one in place can still load the abandoned implementation.
- The `cockroachdb` extra (`pip install apache-superset[cockroachdb]`) now installs `sqlalchemy-cockroachdb` instead of the abandoned `cockroachdb` package, whose SQLAlchemy dialect could not be imported under SQLAlchemy 2.0. Existing environments with the old package installed should `pip uninstall cockroachdb && pip install sqlalchemy-cockroachdb` (or simply reinstall the extra) to restore CockroachDB connectivity.
### MCP tool results preserve stored string values
@@ -493,8 +493,8 @@ Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in or
First, be sure you are using the following versions of Node.js and npm:
- `Node.js`: Version 22 (LTS)
- `npm`: Version 10
- `Node.js`: Version 24 (see `superset-frontend/.nvmrc` for the exact version)
- `npm`: Version 11
We recommend using [nvm](https://github.com/nvm-sh/nvm) to manage your node environment:
@@ -507,8 +507,8 @@ export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
cd superset-frontend
nvm install --lts
nvm use --lts
nvm install
nvm use
```
Or if you use the default macOS starting with Catalina shell `zsh`, try:
+2 -2
View File
@@ -62,7 +62,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.1",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.16",
"baseline-browser-mapping": "^2.11.17",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
@@ -76,7 +76,7 @@
"react-svg-pan-zoom": "^3.13.1",
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"reselect": "^5.3.0",
"storybook": "^10.5.10",
"swagger-ui-react": "^5.32.14",
"swc-loader": "^0.2.7",
+11 -11
View File
@@ -6529,10 +6529,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.10.38, baseline-browser-mapping@^2.11.16, baseline-browser-mapping@^2.9.19:
version "2.11.16"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz#0fa19a4ece2e34439ecaa3fdca8a59acfbd287fb"
integrity sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.17, baseline-browser-mapping@^2.9.19:
version "2.11.17"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz#a168205490077c5d7c542f1610016af4ade8f875"
integrity sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==
batch@0.6.1:
version "0.6.1"
@@ -6614,9 +6614,9 @@ boxen@^7.0.0:
wrap-ansi "^8.1.0"
brace-expansion@^1.1.7:
version "1.1.15"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.15.tgz#a6d90d54067236e5f42570a3b7378d594d9b7738"
integrity sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==
version "1.1.18"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.18.tgz#3ce74d89885136be1535341f8c3d4425c29a5cab"
integrity sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
@@ -14106,10 +14106,10 @@ reselect@^4.0.0:
resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524"
integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==
reselect@^5.1.0, reselect@^5.1.1, reselect@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1"
integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==
reselect@^5.1.0, reselect@^5.1.1, reselect@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.3.0.tgz#0a3e3ed4436bdf2ab7c5e0f392dab2c062595d61"
integrity sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==
resize-observer-polyfill@1.5.1:
version "1.5.1"
+8 -11
View File
@@ -16,7 +16,7 @@
# under the License.
[build-system]
requires = ["setuptools>=40.9.0", "wheel"]
requires = ["setuptools>=84.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
@@ -67,7 +67,7 @@ dependencies = [
"flask-sqlalchemy>=3.1.1, <4.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
"greenlet<=3.5.4, >=3.5.4",
"greenlet<=3.5.5, >=3.5.5",
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
@@ -148,9 +148,10 @@ clickhouse = ["clickhouse-connect>=1.7.2, <2.0"]
# 2.0). sqlalchemy-cockroachdb is the actively maintained replacement,
# already linked from CockroachDbEngineSpec.metadata's docs_url, and
# registers the same `cockroachdb` SQLAlchemy dialect entry point.
# sqlalchemy-cockroachdb itself declares no DBAPI dependency (its own docs
# require picking one), so pull in the same psycopg2-binary pin as the
# `postgres` extra -- CockroachDB speaks the Postgres wire protocol.
# sqlalchemy-cockroachdb depends only on SQLAlchemy itself, not on a DBAPI
# driver, so psycopg2-binary is pinned alongside it (matching the `postgres`
# extra) to keep this extra self-contained -- CockroachDB speaks the
# PostgreSQL wire protocol, so psycopg2 is what actually opens connections.
cockroachdb = ["sqlalchemy-cockroachdb>=2.0.0, <3", "psycopg2-binary==2.9.12"]
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
@@ -195,7 +196,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
excel-export = ["boto3"]
fastmcp = [
"fastmcp>=3.4.7,<4.0",
"mcp>=1.29.1,<2.0",
"mcp>=1.29.1,<3.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.
@@ -281,11 +282,7 @@ development = [
# no bounds for apache-superset-extensions-cli until a stable version
"apache-superset-extensions-cli",
"boto3",
# 7.0.0 raises `docker.errors.DockerException: ... Not supported URL
# scheme http+docker` against the requests/urllib3 versions pinned
# elsewhere in this file -- breaks testcontainers (tests/testcontainers/)
# before any container even starts. 7.2.0 is confirmed working.
"docker>=7.2.0",
"docker",
"flask-testing",
"freezegun",
"grpcio>=1.82.1",
+1 -7
View File
@@ -19,13 +19,7 @@ testpaths =
tests
python_files = *_test.py test_*.py *_tests.py *viz/utils.py
# `-p no:warnings` temporarily disabled in favor of more finely tuned `filterwarnings`.
# `not testcontainers` excludes tests/testcontainers/ by default: those spin up
# real Docker containers, and `testpaths = tests` would otherwise pull them into
# every plain `pytest` run. The dedicated CI job (testcontainers.yml) overrides
# this with an explicit `-m testcontainers` to run them.
addopts = -m "not testcontainers"
markers =
testcontainers: exercises a real database via testcontainers-python (needs Docker); excluded by default, see .github/workflows/testcontainers.yml
#addopts = -p no:warnings
asyncio_mode = auto
# `ignore` is effectively equivalent to `-p no:warnings`.
+2 -2
View File
@@ -52,11 +52,11 @@ marshmallow-sqlalchemy>=1.5.0
# needed for python 3.12 support
openapi-schema-validator>=0.6.3
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
# Pin setuptools <85 until all dependencies migrate from pkg_resources to importlib.metadata
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
# Known affected packages: Preset's 'clients' package
# See docs/docs/contributing/pkg-resources-migration.md for details
setuptools<81
setuptools<85
# google-auth 2.53+ dropped its transitive dependency on cachetools, which is
# imported directly by superset.db_engine_specs.aws_iam. We declare cachetools
+2 -2
View File
@@ -163,7 +163,7 @@ google-auth==2.53.0
# via
# -r requirements/base.in
# shillelagh
greenlet==3.5.4
greenlet==3.5.5
# via
# apache-superset (pyproject.toml)
# shillelagh
@@ -366,7 +366,7 @@ rpds-py==0.25.0
# via
# jsonschema
# referencing
setuptools==80.9.0
setuptools==84.0.0
# via -r requirements/base.in
shillelagh==1.4.5
# via apache-superset (pyproject.toml)
+1 -10
View File
@@ -16,14 +16,5 @@
# specific language governing permissions and limitations
# under the License.
#
-e .[development,bigquery,cockroachdb,crate,druid,duckdb,elasticsearch,fastmcp,gevent,gsheets,mssql,mysql,oracle,postgres,presto,prophet,trino,thumbnails]
-e .[development,bigquery,cockroachdb,druid,duckdb,fastmcp,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
-e ./superset-extensions-cli[test]
# testcontainers-backed db_engine_specs tests (tests/testcontainers/) --
# see .github/workflows/testcontainers.yml
#
# `db2` (the `ibm-db-sa`/`ibm-db` driver) is deliberately left out of the
# baseline dev install above: `ibm-db` ships no Linux arm64 wheel, so
# including it here breaks the multi-platform (amd64+arm64) dev Docker
# image build. The testcontainers CI job installs it on demand, only for
# the db2 matrix leg -- see .github/workflows/testcontainers.yml.
testcontainers[cockroachdb,cratedb,db2,mssql,oracle,trino]>=4.15.0,<5
+8 -66
View File
@@ -117,10 +117,8 @@ celery==5.6.3
certifi==2026.5.20
# via
# -c requirements/base-constraint.txt
# elasticsearch
# httpcore
# httpx
# opensearch-py
# requests
cffi==2.0.0
# via
@@ -173,8 +171,6 @@ contourpy==1.0.7
# via matplotlib
coverage==7.6.8
# via pytest-cov
crate==2.2.1
# via sqlalchemy-cratedb
cron-descriptor==1.4.5
# via
# -c requirements/base-constraint.txt
@@ -190,7 +186,6 @@ cryptography==50.0.0
# authlib
# google-auth
# joserfc
# oracledb
# paramiko
# pyjwt
# pyopenssl
@@ -221,10 +216,8 @@ dnspython==2.7.0
# via
# -c requirements/base-constraint.txt
# email-validator
docker==7.2.0
# via
# apache-superset
# testcontainers
docker==7.0.0
# via apache-superset
docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
@@ -235,10 +228,6 @@ duckdb==1.5.5
# duckdb-engine
duckdb-engine==0.17.0
# via apache-superset
elasticsearch==7.17.13
# via elasticsearch-dbapi
elasticsearch-dbapi==0.2.13
# via apache-superset
email-validator==2.2.0
# via
# -c requirements/base-constraint.txt
@@ -248,8 +237,6 @@ et-xmlfile==2.0.0
# via
# -c requirements/base-constraint.txt
# openpyxl
events==0.5
# via opensearch-py
exceptiongroup==1.3.0
# via fastmcp-slim
fastmcp==3.4.7
@@ -346,8 +333,6 @@ geographiclib==2.0
# via
# -c requirements/base-constraint.txt
# geopy
geojson==3.3.0
# via sqlalchemy-cratedb
geopy==2.4.1
# via
# -c requirements/base-constraint.txt
@@ -390,7 +375,7 @@ googleapis-common-protos==1.66.0
# via
# google-api-core
# grpcio-status
greenlet==3.5.4
greenlet==3.5.5
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -429,17 +414,12 @@ httpx==0.28.1
# via
# fastmcp-slim
# mcp
# testcontainers
httpx-sse==0.4.1
# via mcp
humanize==4.12.3
# via
# -c requirements/base-constraint.txt
# apache-superset
ibm-db==3.3.0
# via ibm-db-sa
ibm-db-sa==0.4.4
# via testcontainers
identify==2.5.36
# via pre-commit
idna==3.15
@@ -625,22 +605,14 @@ openpyxl==3.1.5
# via
# -c requirements/base-constraint.txt
# pandas
opensearch-py==2.8.0
# via elasticsearch-dbapi
opentelemetry-api==1.39.1
# via fastmcp-slim
oracledb==4.0.2
# via
# apache-superset
# testcontainers
ordered-set==4.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
orjson==3.11.9
# via
# crate
# trino
# via trino
packaging==25.0
# via
# -c requirements/base-constraint.txt
@@ -648,8 +620,8 @@ packaging==25.0
# apispec
# db-dtypes
# deprecation
# docker
# duckdb-engine
# elasticsearch-dbapi
# fastmcp-slim
# google-cloud-bigquery
# kombu
@@ -691,7 +663,7 @@ pillow==12.3.0
# -c requirements/base-constraint.txt
# apache-superset
# matplotlib
pip==25.1.1
pip==26.2.1
# via apache-superset
platformdirs==4.3.8
# via
@@ -803,10 +775,6 @@ pyjwt==2.13.0
# mcp
pylint==3.3.7
# via apache-superset
pymssql==2.3.13
# via
# apache-superset
# testcontainers
pynacl==1.6.2
# via
# -c requirements/base-constraint.txt
@@ -856,7 +824,6 @@ python-dateutil==2.9.0.post0
# google-cloud-bigquery
# holidays
# matplotlib
# opensearch-py
# pandas
# pyhive
# shillelagh
@@ -867,7 +834,6 @@ python-dotenv==1.2.2
# apache-superset
# fastmcp-slim
# pydantic-settings
# testcontainers
python-ldap==3.4.7
# via apache-superset
python-multipart==0.0.29
@@ -911,7 +877,6 @@ requests==2.33.0
# google-api-core
# google-cloud-bigquery
# jsonschema-path
# opensearch-py
# pydruid
# pyhive
# requests-cache
@@ -955,7 +920,7 @@ secretstorage==3.5.0
# via keyring
semver==3.0.4
# via apache-superset-extensions-cli
setuptools==80.9.0
setuptools==84.0.0
# via
# -c requirements/base-constraint.txt
# nodeenv
@@ -991,18 +956,14 @@ sqlalchemy==2.0.52
# apache-superset
# apache-superset-core
# duckdb-engine
# elasticsearch-dbapi
# flask-appbuilder
# flask-sqlalchemy
# ibm-db-sa
# marshmallow-sqlalchemy
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-cockroachdb
# sqlalchemy-continuum
# sqlalchemy-cratedb
# sqlalchemy-utils
# testcontainers
sqlalchemy-bigquery==1.17.2
# via apache-superset
sqlalchemy-cockroachdb==2.0.4
@@ -1011,10 +972,6 @@ sqlalchemy-continuum==1.7.0
# via
# -c requirements/base-constraint.txt
# apache-superset
sqlalchemy-cratedb==0.43.1
# via
# apache-superset
# testcontainers
sqlalchemy-utils==0.42.1
# via
# -c requirements/base-constraint.txt
@@ -1046,8 +1003,6 @@ tabulate==0.10.0
# via
# -c requirements/base-constraint.txt
# apache-superset
testcontainers==4.15.0
# via -r requirements/development.in
tiktoken==0.14.0
# via apache-superset
tomli-w==1.2.0
@@ -1059,9 +1014,7 @@ tqdm==4.67.1
# cmdstanpy
# prophet
trino==0.339.0
# via
# apache-superset
# testcontainers
# via apache-superset
typing-extensions==4.16.0
# via
# -c requirements/base-constraint.txt
@@ -1076,7 +1029,6 @@ typing-extensions==4.16.0
# limits
# mcp
# opentelemetry-api
# oracledb
# py-key-value-aio
# pydantic
# pydantic-core
@@ -1085,7 +1037,6 @@ typing-extensions==4.16.0
# shillelagh
# sqlalchemy
# starlette
# testcontainers
# typing-inspection
typing-inspection==0.4.2
# via
@@ -1113,21 +1064,13 @@ urllib3==2.7.0
# via
# -c requirements/base-constraint.txt
# botocore
# crate
# docker
# elasticsearch
# opensearch-py
# requests
# requests-cache
# testcontainers
uvicorn==0.37.0
# via
# fastmcp-slim
# mcp
verlib2==0.3.2
# via
# crate
# sqlalchemy-cratedb
vine==5.1.0
# via
# -c requirements/base-constraint.txt
@@ -1161,7 +1104,6 @@ wrapt==1.17.2
# via
# -c requirements/base-constraint.txt
# deprecated
# testcontainers
wtforms==3.2.2
# via
# -c requirements/base-constraint.txt
@@ -390,19 +390,3 @@ def get_session() -> scoped_session:
:returns: The SQLAlchemy scoped session instance.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"Dataset",
"Database",
"Chart",
"Dashboard",
"User",
"Role",
"Group",
"Tag",
"KeyValue",
"Subject",
"CoreModel",
"get_session",
]
@@ -183,10 +183,3 @@ def prompt(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = [
"tool",
"prompt",
"ToolAnnotations",
]
@@ -55,9 +55,3 @@ class SavedQueryDAO(BaseDAO[SavedQuery]):
model_cls = None
base_filter = None
id_column_name = "id"
__all__ = [
"QueryDAO",
"SavedQueryDAO",
]
@@ -71,9 +71,3 @@ class SavedQuery(CoreModel):
database_id: int | None
description: str | None
user_id: int | None
__all__ = [
"Query",
"SavedQuery",
]
@@ -46,6 +46,3 @@ def get_sqlglot_dialect(database: "Database") -> Dialects:
:returns: The SQLGlot dialect enum corresponding to the database.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["get_sqlglot_dialect"]
@@ -165,13 +165,3 @@ class AsyncQueryHandle:
:returns: True if cancellation was successful
"""
raise NotImplementedError("Method will be replaced during initialization")
__all__ = [
"QueryStatus",
"QueryOptions",
"QueryResult",
"StatementResult",
"AsyncQueryHandle",
"CacheOptions",
]
@@ -27,6 +27,3 @@ class RestApi(BaseApi):
"""
allow_browser_login = True
__all__ = ["RestApi"]
@@ -98,6 +98,3 @@ def api(
"API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["api"]
@@ -164,6 +164,3 @@ class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
:return: SemanticViewModel instance or None
"""
...
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
@@ -97,6 +97,3 @@ def semantic_layer(
"Semantic layer decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["semantic_layer"]
@@ -21,6 +21,7 @@ from abc import ABC, abstractmethod
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
from superset_core.semantic_layers.view import SemanticView
ConfigT = TypeVar("ConfigT", bound=BaseModel)
@@ -80,6 +80,3 @@ class SemanticViewModel(CoreModel):
semantic_layer_uuid: UUID
created_on: datetime | None
changed_on: datetime | None
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
@@ -71,6 +71,3 @@ class TaskDAO(BaseDAO[Task]):
:returns: Task instance or None if not found or not active
"""
...
__all__ = ["TaskDAO"]
@@ -144,9 +144,3 @@ def get_context() -> TaskContext:
)
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"task",
"get_context",
]
@@ -161,9 +161,3 @@ class TaskSubscriber(CoreModel):
changed_on: datetime | None
created_by_fk: int | None
changed_by_fk: int | None
__all__ = [
"Task",
"TaskSubscriber",
]
@@ -226,12 +226,3 @@ class TaskContext(ABC):
cleanup_partial_work()
"""
...
__all__ = [
"TaskStatus",
"TaskScope",
"TaskProperties",
"TaskContext",
"TaskOptions",
]
+1
View File
@@ -0,0 +1 @@
../superset-frontend/.npmrc
+1
View File
@@ -0,0 +1 @@
min-release-age=3
+54 -103
View File
@@ -2086,14 +2086,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -2107,18 +2099,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -3215,9 +3195,9 @@
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -3834,9 +3814,13 @@
}
},
"node_modules/d3-color": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "1.4.5",
@@ -4317,18 +4301,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -4854,9 +4826,9 @@
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -5586,9 +5558,19 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -7848,11 +7830,6 @@
"node": ">=8"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@@ -8015,9 +7992,9 @@
"license": "MIT"
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -9842,7 +9819,7 @@
"debug": "4.4.0",
"execa": "4.1.0",
"istanbul-lib-coverage": "^3.0.0",
"js-yaml": "4.1.1",
"js-yaml": "4.3.1",
"nyc": "15.1.0",
"tinyglobby": "^0.2.14"
},
@@ -10121,7 +10098,7 @@
"requires": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
"minimatch": ">=10"
}
},
"@eslint/config-helpers": {
@@ -10213,18 +10190,10 @@
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "4.1.1",
"js-yaml": "4.3.1",
"resolve-from": "^5.0.0"
},
"dependencies": {
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -10234,14 +10203,6 @@
"path-exists": "^4.0.0"
}
},
"js-yaml": {
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -11185,9 +11146,9 @@
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
},
"brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"peer": true,
"requires": {
@@ -11630,9 +11591,9 @@
}
},
"d3-color": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
},
"d3-format": {
"version": "1.4.5",
@@ -11644,7 +11605,7 @@
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
"requires": {
"d3-color": "1"
"d3-color": "3.1.0"
}
},
"d3-scale": {
@@ -11892,7 +11853,7 @@
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.4",
"minimatch": ">=10",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
@@ -11981,11 +11942,6 @@
"eslint-visitor-keys": "^5.0.1"
}
},
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -12334,7 +12290,7 @@
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"minimatch": "<10",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
@@ -12345,9 +12301,9 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -12358,7 +12314,7 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"requires": {
"brace-expansion": "^1.1.7"
"brace-expansion": "1.1.18"
}
}
}
@@ -12852,9 +12808,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"requires": {
"argparse": "^2.0.1"
}
@@ -13435,7 +13391,7 @@
"dev": true,
"peer": true,
"requires": {
"brace-expansion": "^5.0.5"
"brace-expansion": ">=5.0.9"
}
},
"minimist": {
@@ -14383,11 +14339,6 @@
"which": "^2.0.1"
}
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="
},
"sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@@ -14494,7 +14445,7 @@
"requires": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^7.1.4",
"minimatch": "^3.0.4"
"minimatch": "<10"
},
"dependencies": {
"balanced-match": {
@@ -14503,9 +14454,9 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -14516,7 +14467,7 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"requires": {
"brace-expansion": "^1.1.7"
"brace-expansion": "1.1.18"
}
}
}
+8 -2
View File
@@ -30,14 +30,20 @@
"overrides": {
"@babel/core": "^7.29.6",
"@cypress/code-coverage": {
"js-yaml": "4.1.1"
"js-yaml": "4.3.1"
},
"@cypress/request": "^3.0.0",
"cypress": {
"form-data": "^2.3.4"
},
"d3-interpolate": {
"d3-color": "3.1.0"
},
"minimatch@<10": {
"brace-expansion": "1.1.18"
},
"minimatch@>=10": {
"brace-expansion": ">=5.0.8"
"brace-expansion": ">=5.0.9"
},
"qs": "^6.14.2",
"uuid": "^11.1.1"
+1 -1
View File
@@ -77,7 +77,7 @@ module.exports = {
// @ant-design/colors and @ant-design/fast-color are allowed through because
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
// from its CJS output, so babel-jest must transform those files.
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
],
preset: 'ts-jest',
transform: {
+44 -44
View File
@@ -84,7 +84,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"content-disposition": "^3.0.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -108,7 +108,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -218,13 +218,13 @@
"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.16",
"baseline-browser-mapping": "^2.11.17",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.8.1",
"eslint": "^10.9.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -234,7 +234,7 @@
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
"eslint-plugin-storybook": "10.5.10",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -15753,9 +15753,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz",
"integrity": "sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==",
"version": "2.11.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz",
"integrity": "sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -17451,12 +17451,12 @@
}
},
"node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz",
"integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==",
"license": "MIT",
"engines": {
"node": ">=18"
"node": ">=22"
},
"funding": {
"type": "opencollective",
@@ -17804,9 +17804,9 @@
"license": "Python-2.0"
},
"node_modules/cosmiconfig/node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -19858,9 +19858,9 @@
}
},
"node_modules/eslint": {
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz",
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -20208,9 +20208,9 @@
"license": "MIT"
},
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.1.tgz",
"integrity": "sha512-oOhQTYhor88Xp8RVytq25tvBfiAjU0r9SCDC51Qop+3Wg5BR1xGMAkM+/dV4MZbcMhdaU1L9bkv6LC95JmiTig==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-1.0.2.tgz",
"integrity": "sha512-HgYol2zhH3KbnW9Q4FY/FcIINbsYVL6rwQESChqBC2s9SLWtw1wg05+J7SCrr3BfQpNY2ReYhf8xjpd2JhKJOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -26738,9 +26738,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -27662,9 +27662,9 @@
}
},
"node_modules/lerna/node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -28488,9 +28488,9 @@
}
},
"node_modules/mapbox-gl": {
"version": "3.28.1",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz",
"integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==",
"license": "SEE LICENSE IN LICENSE.txt",
"workspaces": [
"src/style-spec",
@@ -30733,9 +30733,9 @@
}
},
"node_modules/nwsapi": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
"dev": true,
"license": "MIT"
},
@@ -34750,9 +34750,9 @@
}
},
"node_modules/react-diff-viewer-continued/node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -36063,9 +36063,9 @@
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz",
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
@@ -38445,9 +38445,9 @@
"license": "ISC"
},
"node_modules/stylelint/node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -43010,7 +43010,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^5.2.0",
"reselect": "^5.3.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.15"
@@ -43490,7 +43490,7 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
+20 -8
View File
@@ -161,7 +161,7 @@
"antd": "^6.6.1",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"content-disposition": "^3.0.0",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dom-to-image-more": "^3.10.2",
@@ -185,7 +185,7 @@
"json-stringify-pretty-compact": "^4.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"markdown-to-jsx": "^9.10.2",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -295,13 +295,13 @@
"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.16",
"baseline-browser-mapping": "^2.11.17",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"eslint": "^10.8.1",
"eslint": "^10.9.0",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -311,7 +311,7 @@
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.2",
"eslint-plugin-storybook": "10.5.10",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
@@ -383,6 +383,9 @@
"@great-expectations/jsonforms-antd-renderers": {
"antd": "$antd"
},
"@istanbuljs/load-nyc-config": {
"js-yaml": "^3.15.1"
},
"@jest/globals": "^30.4.0",
"@jest/types": "^30.4.0",
"@luma.gl/constants": "~9.2.5",
@@ -392,7 +395,10 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"core-js": "^3.38.1",
"dompurify": "^3.4.11",
"cosmiconfig": {
"js-yaml": "^4.3.1"
},
"dompurify": "^3.4.13",
"esbuild": "^0.28.1",
"eslint-plugin-import": {
"eslint": "$eslint"
@@ -408,16 +414,22 @@
"jest-mock": "^30.4.0",
"jest-runtime": "^30.4.0",
"jest-util": "^30.4.0",
"js-yaml-loader": {
"js-yaml": "^3.15.1"
},
"jspdf": "^4.2.0",
"lerna": {
"js-yaml": "^4.3.0"
"js-yaml": "^4.3.1"
},
"minimatch@>=10": {
"brace-expansion": ">=5.0.8"
},
"nanoid@>=3 <4": "3.3.18",
"nwsapi": "^2.2.13",
"nwsapi": "^2.2.24",
"puppeteer": "^22.4.1",
"react-diff-viewer-continued": {
"js-yaml": "^4.3.1"
},
"tar": "^7.5.16",
"typescript-json-schema": "^0.68.0",
"underscore": "^1.13.7",
@@ -89,7 +89,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^5.2.0",
"reselect": "^5.3.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.15"
@@ -25,6 +25,7 @@ export enum VizType {
BoxPlot = 'box_plot',
Bubble = 'bubble_v2',
Bullet = 'bullet',
Butterfly = 'butterfly',
Calendar = 'cal_heatmap',
Cartodiagram = 'cartodiagram',
Chord = 'chord',
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
| 'onOpenChange'
| 'optionRender'
| 'placeholder'
| 'prefix'
| 'showArrow'
| 'showSearch'
| 'tokenSeparators'
@@ -38,11 +38,21 @@ function formatMemory(
: ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB'];
const base = binary ? 1024 : 1000;
const i = Math.min(
suffixes.length - 1,
Math.floor(Math.log(absValue) / Math.log(base)),
let i = Math.max(
0,
Math.min(
suffixes.length - 1,
Math.floor(Math.log(absValue) / Math.log(base)),
),
);
formatted = `${sign}${parseFloat((absValue / Math.pow(base, i)).toFixed(decimals))}${suffixes[i]}`;
let scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
if (scaled >= base && i < suffixes.length - 1) {
i += 1;
scaled = parseFloat((absValue / Math.pow(base, i)).toFixed(decimals));
}
formatted = `${sign}${scaled}${suffixes[i]}`;
}
if (transfer) {
@@ -119,6 +119,20 @@ export function retrieveErrorMessage(
return statusError || parseStringResponse(str);
}
function getFirstValidationError(message: JsonObject): string | undefined {
const [firstError] = Object.values(message);
if (typeof firstError === 'string') {
return firstError;
}
if (Array.isArray(firstError)) {
return firstError.find((item): item is string => typeof item === 'string');
}
return undefined;
}
export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
let error = { ...responseJson };
// Backwards compatibility for old error renderers with the new error object
@@ -126,13 +140,12 @@ export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
error.error = error.description = error.errors[0].message;
error.link = error.errors[0]?.extra?.link;
}
// Marshmallow field validation returns the error message in the format
// of { message: { field1: [msg1, msg2], field2: [msg], } }
// Marshmallow field validation returns arrays for string messages, but
// serializes lazy translation messages as strings instead.
if (!error.error && error.message) {
if (typeof error.message === 'object') {
error.error =
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
t('Invalid input');
getFirstValidationError(error.message) || t('Invalid input');
}
if (typeof error.message === 'string') {
if (checkForHtml(error.message)) {
@@ -30,6 +30,7 @@ import type {
QueryFormData,
} from '../query';
import type { JsonResponse } from '../connection';
import type { MenuItem } from '../components/Menu';
/**
* A function which returns text (or marked-up text)
@@ -164,6 +165,13 @@ export interface SliceHeaderExtension {
dashboardId: number;
}
/**
* Interface for extensions to the Slice Header more-options menu
*/
export interface SliceHeaderMenuExtension extends SliceHeaderExtension {
sliceName: string;
}
/**
* Interface for extensions to Embed Modal
*/
@@ -262,6 +270,9 @@ export type Extensions = Partial<{
'sqleditor.extension.form': ComponentType<SQLFormExtensionProps>;
'sqleditor.extension.resultTable': ComponentType<SQLResultTableExtensionProps>;
'dashboard.slice.header': ComponentType<SliceHeaderExtension>;
'dashboard.slice.header.menu': (
context: SliceHeaderMenuExtension,
) => MenuItem[];
'sqleditor.extension.customAutocomplete': (
args: CustomAutoCompleteArgs,
) => CustomAutocomplete[] | undefined;
@@ -60,6 +60,31 @@ test('formats float bytes in human readable format with default options', () =>
expect(formatter(1200.666)).toBe('1.2kB');
});
test('formats values below one byte without dropping the unit', () => {
const formatter = createMemoryFormatter();
expect(formatter(0.5)).toBe('0.5B');
expect(formatter(0.004)).toBe('0B');
expect(formatter(-0.25)).toBe('-0.25B');
const binaryFormatter = createMemoryFormatter({ binary: true });
expect(binaryFormatter(0.5)).toBe('0.5B');
});
test('rolls over to the next unit when rounding reaches the base', () => {
const formatter = createMemoryFormatter();
expect(formatter(999999)).toBe('1MB');
expect(formatter(999995)).toBe('1MB');
expect(formatter(999994)).toBe('999.99kB');
expect(formatter(-999999)).toBe('-1MB');
const binaryFormatter = createMemoryFormatter({ binary: true });
expect(binaryFormatter(1024 * 1024 - 1)).toBe('1MiB');
// the largest unit has nothing to roll over into
const largest = createMemoryFormatter();
expect(largest(Math.pow(1000, 11))).toBe('1000QB');
});
test('formats bytes in human readable format with additional binary option', () => {
const formatter = createMemoryFormatter({ binary: true });
expect(formatter(0)).toBe('0B');
@@ -244,6 +244,24 @@ test('parseErrorJson with message', () => {
});
});
test('parseErrorJson preserves string-valued validation messages', () => {
const calculatedColumnError =
'Custom SQL fields cannot be parsed as a single SQL statement.';
expect(
parseErrorJson({
message: {
'columns.0.expression': calculatedColumnError,
},
}),
).toEqual({
message: {
'columns.0.expression': calculatedColumnError,
},
error: calculatedColumnError,
});
});
test('parseErrorJson with HTML message', () => {
expect(
parseErrorJson({
@@ -0,0 +1,49 @@
/**
* 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 Echart from '../components/Echart';
import { ButterflyTransformedProps } from './types';
import { EventHandlers } from '../types';
export default function Butterfly(props: ButterflyTransformedProps) {
const { height, width, echartOptions, refs, onLegendStateChanged, formData } =
props;
const eventHandlers: EventHandlers = {
legendselectchanged: payload => {
onLegendStateChanged?.(payload.selected);
},
legendselectall: payload => {
onLegendStateChanged?.(payload.selected);
},
legendinverseselect: payload => {
onLegendStateChanged?.(payload.selected);
},
};
return (
<Echart
refs={refs}
height={height}
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
vizType={formData.vizType}
/>
);
}
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
buildQueryContext,
ensureIsArray,
QueryFormData,
QueryFormOrderBy,
} from '@superset-ui/core';
import { buildSortMetricOrderby } from '@superset-ui/chart-controls';
export default function buildQuery(formData: QueryFormData) {
const columns = ensureIsArray(formData.groupby);
const baseMetrics = [
...ensureIsArray(formData.left_metric),
...ensureIsArray(formData.right_metric),
];
const { orderby, metrics } = buildSortMetricOrderby({
metrics: baseMetrics,
timeseriesLimitMetric: ensureIsArray(formData.orderby)[0],
order_desc: formData.order_desc,
});
const resolvedOrderby: QueryFormOrderBy[] | undefined = orderby.length
? orderby
: columns.length
? [[columns[0], true]]
: undefined;
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
columns,
metrics,
orderby: resolvedOrderby,
},
]);
}
@@ -0,0 +1,29 @@
/**
* 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 {
DEFAULT_LEGEND_FORM_DATA,
DEFAULT_TITLE_FORM_DATA,
} from '../constants';
import { defaultXAxis } from '../defaults';
export const DEFAULT_FORM_DATA = {
...DEFAULT_LEGEND_FORM_DATA,
...DEFAULT_TITLE_FORM_DATA,
xAxisLabelRotation: defaultXAxis.xAxisLabelRotation,
};
@@ -0,0 +1,242 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ensureIsArray } from '@superset-ui/core';
import {
ControlPanelConfig,
ControlSubSectionHeader,
formatSelectOptions,
getStandardizedControls,
sections,
sharedControls,
} from '@superset-ui/chart-controls';
import {
legendSection,
showValueControl,
xAxisLabelRotation,
} from '../controls';
import { DEFAULT_FORM_DATA } from './constants';
const { xAxisTitleMargin, yAxisTitleMargin } = DEFAULT_FORM_DATA;
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
['groupby'],
[
{
name: 'left_metric',
config: {
...sharedControls.metric,
label: t('Left metric'),
description: t(
'Metric displayed on the left side of the butterfly chart',
),
},
},
],
[
{
name: 'right_metric',
config: {
...sharedControls.metric,
label: t('Right metric'),
description: t(
'Metric displayed on the right side of the butterfly chart',
),
},
},
],
['adhoc_filters'],
['row_limit'],
['orderby'],
[
{
name: 'order_desc',
config: {
...sharedControls.order_desc,
visibility: ({ controls }) => Boolean(controls.orderby.value),
},
},
],
],
},
{
label: t('Chart Options'),
expanded: true,
controlSetRows: [[showValueControl], ...legendSection],
},
{
label: t('Series settings'),
expanded: true,
controlSetRows: [
[
<ControlSubSectionHeader>
{t('Left series setting')}
</ControlSubSectionHeader>,
],
[
{
name: 'left_color',
config: {
label: t('Left color'),
type: 'ColorPickerControl',
default: { r: 84, g: 112, b: 198, a: 1 },
renderTrigger: true,
description: t('Color for bars on the left side of the chart'),
},
},
{
name: 'left_label',
config: {
label: t('Left label'),
type: 'TextControl',
renderTrigger: true,
description: t(
'Customize the label for the left series in tooltips and legend',
),
},
},
],
[
<ControlSubSectionHeader>
{t('Right series setting')}
</ControlSubSectionHeader>,
],
[
{
name: 'right_color',
config: {
label: t('Right color'),
type: 'ColorPickerControl',
default: { r: 145, g: 204, b: 117, a: 1 },
renderTrigger: true,
description: t('Color for bars on the right side of the chart'),
},
},
{
name: 'right_label',
config: {
label: t('Right label'),
type: 'TextControl',
renderTrigger: true,
description: t(
'Customize the label for the right series in tooltips and legend',
),
},
},
],
],
},
{
label: t('X Axis'),
expanded: true,
controlSetRows: [
[
{
name: 'x_axis_label',
config: {
type: 'TextControl',
label: t('X Axis Label'),
renderTrigger: true,
default: '',
},
},
],
[
{
name: 'x_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('X Axis title margin'),
renderTrigger: true,
default: xAxisTitleMargin,
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
},
},
],
['x_axis_format'],
['currency_format'],
],
},
{
label: t('Y Axis'),
expanded: true,
controlSetRows: [
[
{
name: 'y_axis_label',
config: {
type: 'TextControl',
label: t('Y Axis Label'),
renderTrigger: true,
default: '',
},
},
],
[
{
name: 'y_axis_title_margin',
config: {
type: 'SelectControl',
freeForm: true,
clearable: true,
label: t('Y Axis title margin'),
renderTrigger: true,
default: yAxisTitleMargin,
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
},
},
],
[
{
name: xAxisLabelRotation.name,
config: {
...xAxisLabelRotation.config,
label: t('Rotate category label'),
description: t(
'Input field supports custom rotation. e.g. 30 for 30°',
),
},
},
],
],
},
],
controlOverrides: {
groupby: {
label: t('Categories'),
description: t('Dimension used for category labels on the vertical axis'),
multi: false,
},
},
formDataOverrides: formData => ({
...formData,
groupby: ensureIsArray(getStandardizedControls().shiftColumn()),
left_metric: getStandardizedControls().shiftMetric(),
right_metric: getStandardizedControls().shiftMetric(),
}),
};
export default config;
@@ -0,0 +1,54 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import { EchartsButterflyChartProps, EchartsButterflyFormData } from './types';
export default class EchartsButterflyChartPlugin extends ChartPlugin<
EchartsButterflyFormData,
EchartsButterflyChartProps
> {
constructor() {
super({
buildQuery,
controlPanel,
loadChart: () => import('./Butterfly'),
metadata: new ChartMetadata({
credits: ['https://echarts.apache.org'],
category: t('Comparison'),
description: t(
'A butterfly chart compares two metrics across categories using horizontal bars ' +
'that extend left and right from a central axis.',
),
name: t('Butterfly Chart'),
tags: [
t('Categorical'),
t('Comparison'),
t('ECharts'),
t('Multi-Variables'),
],
thumbnail: '',
}),
transformProps,
});
}
}
@@ -0,0 +1,298 @@
/**
* 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 {
CurrencyFormatter,
ensureIsArray,
getColumnLabel,
getMetricLabel,
getNumberFormatter,
NumberFormatter,
rgbToHex,
tooltipHtml,
} from '@superset-ui/core';
import type { ComposeOption } from 'echarts/core';
import type { BarSeriesOption } from 'echarts/charts';
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import { EchartsButterflyChartProps, ButterflyTransformedProps } from './types';
import { DEFAULT_FORM_DATA } from './constants';
import { defaultGrid } from '../defaults';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { NULL_STRING } from '../constants';
import { getChartPadding, getLegendProps } from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import { convertInteger } from '../utils/convertInteger';
type EChartsOption = ComposeOption<BarSeriesOption>;
const LABEL_LEFT = { position: 'left' as const };
const LABEL_RIGHT = { position: 'right' as const };
function formatCategory(value: unknown): string {
if (value == null) {
return NULL_STRING;
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return String(value);
}
function formatTooltip(
params: CallbackDataParams[],
formatter: NumberFormatter | CurrencyFormatter,
) {
const axisParams = params.filter(
param => param.seriesName && typeof param.value === 'number',
);
if (!axisParams.length) {
return '';
}
const title = axisParams[0].name;
const rows = axisParams.map(param => [
param.seriesName!,
formatter(Math.abs(param.value as number)),
]);
return tooltipHtml(rows, title);
}
export default function transformProps(
chartProps: EchartsButterflyChartProps,
): ButterflyTransformedProps {
const {
width,
height,
formData,
legendState,
queriesData,
hooks,
theme,
inContextMenu,
} = chartProps;
const refs: Refs = {};
const { data = [] } = queriesData[0];
const { setDataMask = () => {}, onContextMenu, onLegendStateChanged } = hooks;
const {
currencyFormat,
groupby,
leftMetric,
rightMetric,
leftColor = { r: 84, g: 112, b: 198, a: 1 },
rightColor = { r: 145, g: 204, b: 117, a: 1 },
leftLabel,
rightLabel,
xAxisLabel,
yAxisLabel,
xAxisFormat,
xAxisTitleMargin,
yAxisTitleMargin,
showLegend,
legendMargin,
legendOrientation,
legendType,
legendSort,
showValue,
xAxisLabelRotation,
}: EchartsButterflyChartProps['formData'] = {
...DEFAULT_FORM_DATA,
...formData,
};
const groupbyColumn = ensureIsArray(groupby)[0];
const categoryLabel = getColumnLabel(groupbyColumn);
const leftMetricLabel = leftMetric ? getMetricLabel(leftMetric) : '';
const rightMetricLabel = rightMetric ? getMetricLabel(rightMetric) : '';
const leftSeriesName = leftLabel || leftMetricLabel;
const rightSeriesName = rightLabel || rightMetricLabel;
const defaultFormatter = currencyFormat?.symbol
? new CurrencyFormatter({ d3Format: xAxisFormat, currency: currencyFormat })
: getNumberFormatter(xAxisFormat);
const categories = data.map(row => formatCategory(row[categoryLabel]));
const leftData = data.map(row => {
const value = Number(row[leftMetricLabel] ?? 0);
return {
value: -Math.abs(value),
label: LABEL_LEFT,
};
});
const rightData = data.map(row => {
const value = Number(row[rightMetricLabel] ?? 0);
return {
value: Math.abs(value),
label: LABEL_RIGHT,
};
});
const labelFormatter = (params: CallbackDataParams) => {
const value = Math.abs(params.value as number);
if (value === 0) {
return '';
}
return defaultFormatter(value);
};
const series: BarSeriesOption[] = [
{
name: leftSeriesName,
type: 'bar',
stack: 'Total',
label: {
show: showValue,
formatter: labelFormatter,
color: theme.colorText,
},
itemStyle: {
color: rgbToHex(leftColor.r, leftColor.g, leftColor.b),
},
data: leftData,
},
{
name: rightSeriesName,
type: 'bar',
stack: 'Total',
label: {
show: showValue,
formatter: labelFormatter,
color: theme.colorText,
},
itemStyle: {
color: rgbToHex(rightColor.r, rightColor.g, rightColor.b),
},
data: rightData,
},
];
const legendData = [leftSeriesName, rightSeriesName].sort((a, b) => {
if (!legendSort) {
return 0;
}
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
});
const { effectiveLegendMargin, effectiveLegendType } = resolveLegendLayout({
chartHeight: height,
chartWidth: width,
legendItems: legendData,
legendMargin,
orientation: legendOrientation,
show: showLegend,
theme,
type: legendType,
});
const legendPadding = getChartPadding(
showLegend,
legendOrientation,
effectiveLegendMargin,
undefined,
true,
);
const echartOptions: EChartsOption = {
grid: {
...defaultGrid,
top:
theme.sizeUnit * 5 +
legendPadding.top +
convertInteger(xAxisTitleMargin),
bottom: theme.sizeUnit * 5 + legendPadding.bottom,
left:
theme.sizeUnit * 5 +
legendPadding.left +
convertInteger(yAxisTitleMargin),
right: theme.sizeUnit * 5 + legendPadding.right,
},
legend: {
...getLegendProps(
effectiveLegendType,
legendOrientation,
showLegend,
theme,
false,
legendState,
),
data: legendData,
},
xAxis: {
type: 'value',
position: 'top',
name: xAxisLabel,
nameLocation: 'middle',
nameGap: convertInteger(xAxisTitleMargin),
nameTextStyle: {
padding: [theme.sizeUnit * 4, 0, 0, 0],
},
splitLine: {
lineStyle: {
type: 'dashed',
},
},
axisLabel: {
formatter: (value: number) => defaultFormatter(Math.abs(value)),
},
},
yAxis: {
type: 'category',
name: yAxisLabel,
nameLocation: 'middle',
nameGap: convertInteger(yAxisTitleMargin),
nameTextStyle: {
padding: [0, theme.sizeUnit * 4, 0, 0],
},
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
rotate: xAxisLabelRotation,
},
data: categories,
},
tooltip: {
...getDefaultTooltip(refs),
appendToBody: true,
trigger: 'axis',
axisPointer: { type: 'shadow' },
show: !inContextMenu,
formatter: (params: CallbackDataParams | CallbackDataParams[]) =>
formatTooltip(
ensureIsArray(params) as CallbackDataParams[],
defaultFormatter,
),
},
series,
};
return {
refs,
formData,
width,
height,
echartOptions,
setDataMask,
onContextMenu,
onLegendStateChanged,
};
}
@@ -0,0 +1,52 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
ChartDataResponseResult,
ChartProps,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
RgbaColor,
} from '@superset-ui/core';
import { BaseTransformedProps, LegendFormData, TitleFormData } from '../types';
export type EchartsButterflyFormData = QueryFormData &
LegendFormData &
TitleFormData & {
groupby: QueryFormColumn[];
leftMetric: QueryFormMetric;
rightMetric: QueryFormMetric;
leftColor: RgbaColor;
rightColor: RgbaColor;
leftLabel?: string;
rightLabel?: string;
xAxisLabel: string;
yAxisLabel: string;
xAxisFormat: string;
showValue: boolean;
xAxisLabelRotation: number;
};
export interface EchartsButterflyChartProps extends ChartProps {
formData: EchartsButterflyFormData;
queriesData: ChartDataResponseResult[];
}
export type ButterflyTransformedProps =
BaseTransformedProps<EchartsButterflyFormData>;
@@ -38,6 +38,8 @@ import { EchartsTimeseriesSeriesType } from '../Timeseries/types';
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
truncateXAxis,
xAxisBounds,
@@ -391,6 +393,8 @@ const config: ControlPanelConfig = {
...createCustomizeSection(t('Query B'), 'B'),
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
['x_axis_time_format'],
@@ -184,6 +184,8 @@ export default function transformProps(
opacityB,
minorSplitLine,
minorTicks,
gridlines,
axisTicks,
seriesType,
seriesTypeB,
showLegend,
@@ -788,6 +790,8 @@ export default function transformProps(
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
? (TIMEGRAIN_TO_TIMESTAMP[
@@ -818,6 +822,8 @@ export default function transformProps(
min: yAxisMin,
max: yAxisMax,
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
splitLine: { show: gridlines },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
formatter: getYAxisFormatter(
@@ -840,6 +846,7 @@ export default function transformProps(
min: minSecondary,
max: maxSecondary,
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
splitLine: { show: false },
minorSplitLine: { show: minorSplitLine },
axisLabel: {
@@ -48,6 +48,8 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
// shared properties
minorSplitLine: boolean;
minorTicks: boolean;
gridlines: boolean;
axisTicks: boolean;
logAxis: boolean;
logAxisSecondary: boolean;
yAxisFormat?: string;
@@ -113,6 +115,8 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
...DEFAULT_LEGEND_FORM_DATA,
annotationLayers: [],
minorSplitLine: TIMESERIES_DEFAULTS.minorSplitLine,
gridlines: TIMESERIES_DEFAULTS.gridlines,
axisTicks: TIMESERIES_DEFAULTS.axisTicks,
truncateYAxis: TIMESERIES_DEFAULTS.truncateYAxis,
truncateYAxisSecondary: TIMESERIES_DEFAULTS.truncateYAxis,
logAxis: TIMESERIES_DEFAULTS.logAxis,
@@ -44,6 +44,8 @@ import {
truncateXAxis,
xAxisBounds,
minorTicks,
axisTicks,
gridlines,
forceMaxInterval,
} from '../../controls';
import { AreaChartStackControlOptions } from '../../constants';
@@ -174,6 +176,8 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[axisTicks],
[gridlines],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
@@ -133,6 +133,8 @@ const defaultFormData: EchartsTimeseriesFormData & {
metrics: [],
minorSplitLine: false,
minorTicks: false,
gridlines: true,
axisTicks: true,
opacity: 1,
orderDesc: false,
rowLimit: 0,
@@ -40,6 +40,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSectionWithoutStream,
@@ -388,6 +390,8 @@ const config: ControlPanelConfig = {
},
],
[minorTicks],
[axisTicks],
[gridlines],
['zoomable'],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
@@ -37,6 +37,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -156,6 +158,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -42,6 +42,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -480,6 +482,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
...createAxisControl('x'),
@@ -37,6 +37,8 @@ import {
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSectionWithoutStack,
@@ -105,6 +107,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -35,6 +35,8 @@ import { DEFAULT_FORM_DATA, TIME_SERIES_DESCRIPTION_TEXT } from '../constants';
import {
legendSection,
minorTicks,
axisTicks,
gridlines,
richTooltipSection,
seriesOrderSection,
showValueSection,
@@ -157,6 +159,8 @@ const config: ControlPanelConfig = {
],
['zoomable'],
[minorTicks],
[axisTicks],
[gridlines],
...legendSection,
[<ControlSubSectionHeader>{t('X Axis')}</ControlSubSectionHeader>],
[
@@ -67,6 +67,8 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
maxMarkerSize: 30,
minMarkerSize: 5,
minorSplitLine: false,
gridlines: true,
axisTicks: true,
opacity: 0.2,
orderDesc: true,
rowLimit: 10000,
@@ -283,6 +283,8 @@ export default function transformProps(
metrics,
minorSplitLine,
minorTicks,
gridlines,
axisTicks,
onlyTotal,
opacity,
orientation,
@@ -1274,12 +1276,19 @@ export default function transformProps(
// at the axis boundary.
...(showMaxLabel && {
showMaxLabel: true,
alignMaxLabel: 'right',
showMinLabel: true,
alignMinLabel: 'left',
}),
// The alignments assume the axis runs along the bottom; a horizontal
// chart puts this axis on the side, where they misplace the labels.
...(showMaxLabel &&
!isHorizontal && {
alignMaxLabel: 'right',
alignMinLabel: 'left',
}),
},
minorTick: { show: minorTicks },
axisTick: { show: axisTicks ? 'auto' : false },
...(gridlines ? {} : { splitLine: { show: false } }),
minInterval:
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
? (TIMEGRAIN_TO_TIMESTAMP[
@@ -1324,7 +1333,7 @@ export default function transformProps(
max: yAxisMax,
minorTick: { show: isSmallChart ? false : minorTicks },
minorSplitLine: { show: isSmallChart ? false : minorSplitLine },
splitLine: { show: !isSmallChart },
splitLine: { show: isSmallChart ? false : gridlines },
axisLabel: {
show: !isMicroChart,
showMinLabel: !isMicroChart,
@@ -1338,7 +1347,7 @@ export default function transformProps(
yAxisFormat,
),
},
axisTick: { show: !isSmallChart },
axisTick: { show: isSmallChart ? false : axisTicks },
scale: truncateYAxis,
name: isSmallChart ? undefined : yAxisTitle,
nameGap: convertInteger(yAxisTitleMargin),
@@ -73,6 +73,8 @@ export type EchartsTimeseriesFormData = QueryFormData & {
metrics: QueryFormMetric[];
minorSplitLine: boolean;
minorTicks: boolean;
gridlines: boolean;
axisTicks: boolean;
opacity: number;
orderDesc: boolean;
rowLimit: number;
@@ -495,6 +495,28 @@ export const minorTicks: ControlSetItem = {
},
};
export const axisTicks: ControlSetItem = {
name: 'axisTicks',
config: {
type: 'CheckboxControl',
label: t('Axis ticks'),
default: true,
renderTrigger: true,
description: t('Show the main ticks on axes.'),
},
};
export const gridlines: ControlSetItem = {
name: 'gridlines',
config: {
type: 'CheckboxControl',
label: t('Gridlines'),
default: true,
renderTrigger: true,
description: t('Draw split lines for the main value axis ticks.'),
},
};
export const forceCategorical: ControlSetItem = {
name: 'forceCategorical',
config: {
@@ -46,6 +46,7 @@ export { default as EchartsSunburstChartPlugin } from './Sunburst';
export { default as EchartsBubbleChartPlugin } from './Bubble';
export { default as EchartsSankeyChartPlugin } from './Sankey';
export { default as EchartsWaterfallChartPlugin } from './Waterfall';
export { default as EchartsButterflyChartPlugin } from './Butterfly';
export { default as EchartsGanttChartPlugin } from './Gantt';
export { default as BoxPlotTransformProps } from './BoxPlot/transformProps';
@@ -62,6 +63,7 @@ export { default as HeatmapTransformProps } from './Heatmap/transformProps';
export { default as SunburstTransformProps } from './Sunburst/transformProps';
export { default as BubbleTransformProps } from './Bubble/transformProps';
export { default as WaterfallTransformProps } from './Waterfall/transformProps';
export { default as ButterflyTransformProps } from './Butterfly/transformProps';
export { default as HistogramTransformProps } from './Histogram/transformProps';
export { default as SankeyTransformProps } from './Sankey/transformProps';
export { default as GanttTransformProps } from './Gantt/transformProps';
@@ -47,6 +47,9 @@ const getCrossFilterDataMask =
) =>
(value: string) => {
const selected = Object.values(selectedValues);
if (!labelMap[value] && !selected.includes(value)) {
return undefined;
}
let values: string[];
if (selected.includes(value)) {
values = selected.filter(v => v !== value);
@@ -0,0 +1,85 @@
/**
* 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 buildQuery from '../../src/Butterfly/buildQuery';
const formData = {
datasource: '1__table',
viz_type: 'butterfly',
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
};
test('defaults to ordering by the category column', () => {
const [query] = buildQuery(formData).queries;
expect(query.columns).toEqual(['category']);
expect(query.metrics).toEqual(['left_sum', 'right_sum']);
expect(query.orderby).toEqual([['category', true]]);
});
test('wraps the sort metric in a valid orderby tuple', () => {
const sortMetric = {
expressionType: 'SIMPLE',
column: { column_name: 'left_sum' },
aggregate: 'SUM',
label: 'SUM(left_sum)',
};
const [query] = buildQuery({
...formData,
orderby: sortMetric,
order_desc: true,
}).queries;
expect(query.metrics).toEqual(['left_sum', 'right_sum', sortMetric]);
expect(query.orderby).toEqual([[sortMetric, false]]);
});
test('appends the sort metric when it is not already selected', () => {
const sortMetric = {
expressionType: 'SIMPLE',
column: { column_name: 'count' },
aggregate: 'SUM',
label: 'SUM(count)',
};
const [query] = buildQuery({
...formData,
orderby: sortMetric,
order_desc: false,
}).queries;
expect(query.metrics).toEqual(['left_sum', 'right_sum', sortMetric]);
expect(query.orderby).toEqual([[sortMetric, true]]);
});
test('leaves orderby unset when no category column is selected', () => {
const [query] = buildQuery({
...formData,
groupby: [],
}).queries;
expect(query.columns).toEqual([]);
expect(query.metrics).toEqual(['left_sum', 'right_sum']);
expect(query.orderby).toBeUndefined();
});
test('issues no metrics when none are selected', () => {
const [query] = buildQuery({
datasource: '1__table',
viz_type: 'butterfly',
groupby: ['category'],
}).queries;
expect(query.metrics).toEqual([]);
});
@@ -0,0 +1,83 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SqlaFormData } from '@superset-ui/core';
const mockShiftMetric = jest
.fn()
.mockReturnValueOnce('left_sum')
.mockReturnValueOnce('right_sum');
const mockShiftColumn = jest.fn(() => 'category');
jest.mock('@superset-ui/chart-controls', () => {
const actual = jest.requireActual('@superset-ui/chart-controls');
return {
...actual,
getStandardizedControls: jest.fn(() => ({
shiftMetric: mockShiftMetric,
shiftColumn: mockShiftColumn,
})),
};
});
// eslint-disable-next-line import/first
import controlPanel from '../../src/Butterfly/controlPanel';
const collectControlNames = () => {
const names = new Set<string>();
controlPanel.controlPanelSections?.forEach(section => {
section?.controlSetRows?.forEach(row => {
row.forEach(control => {
if (typeof control === 'string') {
names.add(control);
} else if (
control &&
typeof control === 'object' &&
'name' in control
) {
names.add(String(control.name));
}
});
});
});
return names;
};
test('exposes left and right metric controls', () => {
const controlNames = collectControlNames();
expect(controlNames.has('left_metric')).toBe(true);
expect(controlNames.has('right_metric')).toBe(true);
expect(controlNames.has('groupby')).toBe(true);
expect(controlNames.has('orderby')).toBe(true);
});
test('restricts categories to a single dimension', () => {
expect(controlPanel.controlOverrides?.groupby?.multi).toBe(false);
});
test('maps standardized controls to butterfly metrics', () => {
const dummyFormData = { someProp: 'test' } as unknown as SqlaFormData;
const formData = controlPanel.formDataOverrides?.(dummyFormData);
expect(formData?.someProp).toBe('test');
expect(formData?.groupby).toEqual(['category']);
expect(formData?.left_metric).toBe('left_sum');
expect(formData?.right_metric).toBe('right_sum');
expect(mockShiftMetric).toHaveBeenCalledTimes(2);
expect(mockShiftColumn).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,202 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import {
EchartsButterflyChartProps,
ButterflyTransformedProps,
} from '../../src/Butterfly/types';
import transformProps from '../../src/Butterfly/transformProps';
import { NULL_STRING } from '../../src/constants';
type SeriesDataPoint = { value?: number } | number;
type ButterflyTestSeries = {
name?: string;
data?: SeriesDataPoint[];
itemStyle?: { color?: string };
label?: { show?: boolean };
};
type ButterflyTestEchartOptions = {
series?: ButterflyTestSeries[];
xAxis?: { name?: string; nameGap?: number };
yAxis?: {
name?: string;
nameGap?: number;
data?: string[];
axisLabel?: { rotate?: number };
};
legend?: { orient?: string; data?: string[] };
grid?: { left?: number; top?: number };
tooltip?: { show?: boolean };
};
const getEchartOptions = (
props: ButterflyTransformedProps,
): ButterflyTestEchartOptions =>
props.echartOptions as ButterflyTestEchartOptions;
const extractSeriesValues = (props: ButterflyTransformedProps) => {
const series = getEchartOptions(props).series ?? [];
return series.map(item =>
(item.data ?? []).map(entry =>
typeof entry === 'object' && entry !== null && 'value' in entry
? entry.value
: entry,
),
);
};
const extractSeriesNames = (props: ButterflyTransformedProps) => {
const series = getEchartOptions(props).series ?? [];
return series.map(item => item.name);
};
const data: Record<string, unknown>[] = [
{ category: 'A', left_sum: 10, right_sum: 25 },
{ category: 'B', left_sum: 5, right_sum: 19 },
];
const formData = {
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
left_color: { r: 84, g: 112, b: 198 },
right_color: { r: 145, g: 204, b: 117 },
showValue: true,
showLegend: true,
};
const createChartProps = (
overrides: Record<string, unknown> = {},
queryData: Record<string, unknown>[] = data,
) =>
new ChartProps({
formData: { ...formData, ...overrides },
width: 800,
height: 600,
queriesData: [{ data: queryData }],
theme: supersetTheme,
...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
});
test('transforms chart props into diverging bar series', () => {
const transformedProps = transformProps(
createChartProps() as unknown as EchartsButterflyChartProps,
);
expect(extractSeriesValues(transformedProps)).toEqual([
[-10, -5],
[25, 19],
]);
});
test('uses absolute values for negative right-side metrics', () => {
const transformedProps = transformProps(
createChartProps({}, [
{ category: 'A', left_sum: -8, right_sum: -15 },
]) as unknown as EchartsButterflyChartProps,
);
expect(extractSeriesValues(transformedProps)).toEqual([[-8], [15]]);
});
test('formats null categories and missing metric values', () => {
const transformedProps = transformProps(
createChartProps({}, [
{ category: null, left_sum: undefined, right_sum: 7 },
]) as unknown as EchartsButterflyChartProps,
);
const { yAxis } = getEchartOptions(transformedProps);
expect(yAxis?.data).toEqual([NULL_STRING]);
const [leftValues, rightValues] = extractSeriesValues(transformedProps);
expect(Math.abs(leftValues[0] as number)).toBe(0);
expect(rightValues).toEqual([7]);
});
test('applies custom series labels, colors, and axis titles', () => {
const transformedProps = transformProps(
createChartProps({
left_label: 'Left side',
right_label: 'Right side',
left_color: { r: 255, g: 0, b: 0 },
right_color: { r: 0, g: 255, b: 0 },
x_axis_label: 'Value axis',
y_axis_label: 'Category axis',
}) as unknown as EchartsButterflyChartProps,
);
const { series, xAxis, yAxis } = getEchartOptions(transformedProps);
expect(extractSeriesNames(transformedProps)).toEqual([
'Left side',
'Right side',
]);
expect(series?.[0]?.itemStyle?.color).toBe('#ff0000');
expect(series?.[1]?.itemStyle?.color).toBe('#00ff00');
expect(xAxis?.name).toBe('Value axis');
expect(yAxis?.name).toBe('Category axis');
});
test('applies legend orientation, sort, and axis margin settings', () => {
const transformedProps = transformProps(
createChartProps({
legendOrientation: 'left',
legendSort: 'desc',
xAxisLabelRotation: 45,
x_axis_title_margin: 60,
y_axis_title_margin: 80,
}) as unknown as EchartsButterflyChartProps,
);
const { legend, xAxis, yAxis, grid } = getEchartOptions(transformedProps);
expect(legend?.orient).toBe('vertical');
expect(legend?.data).toEqual(['right_sum', 'left_sum']);
expect(xAxis?.nameGap).toBe(60);
expect(yAxis?.axisLabel?.rotate).toBe(45);
expect(yAxis?.nameGap).toBe(80);
expect(grid?.left).toBeGreaterThan(80);
expect(grid?.top).toBeGreaterThan(60);
});
test('hides value labels when showValue is false', () => {
const transformedProps = transformProps(
createChartProps({
showValue: false,
}) as unknown as EchartsButterflyChartProps,
);
const { series } = getEchartOptions(transformedProps);
expect(series?.[0]?.label?.show).toBe(false);
expect(series?.[1]?.label?.show).toBe(false);
});
test('hides tooltip while the context menu is open', () => {
const transformedProps = transformProps(
createChartProps({}, data) as unknown as EchartsButterflyChartProps,
);
const withContextMenu = transformProps({
...createChartProps(),
inContextMenu: true,
} as unknown as EchartsButterflyChartProps);
expect(getEchartOptions(transformedProps).tooltip?.show).toBe(true);
expect(getEchartOptions(withContextMenu).tooltip?.show).toBe(false);
});
@@ -116,6 +116,8 @@ const formData: EchartsMixedTimeseriesFormData = {
markerSizeB: 0,
minorSplitLine: false,
minorTicks: false,
gridlines: true,
axisTicks: true,
opacity: 0,
opacityB: 0,
orderDesc: false,
@@ -1509,3 +1511,56 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
expect(html).not.toContain(longSeriesName);
});
});
function transformWithChrome(
overrides: Partial<EchartsMixedTimeseriesFormData>,
) {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, ...overrides },
queriesData,
});
const { echartOptions } = transformProps(chartProps);
return {
xAxis: echartOptions.xAxis as any,
yAxis: echartOptions.yAxis as any[],
};
}
test('draws gridlines and axis ticks when both are enabled', () => {
const { xAxis, yAxis } = transformWithChrome({});
expect(yAxis[0].splitLine.show).toBe(true);
// Both axes keep ECharts' own default, which the Mixed chart never overrode.
expect(yAxis[0].axisTick.show).toBe('auto');
expect(xAxis.axisTick.show).toBe('auto');
});
test('hides the gridlines on the primary axis', () => {
const { xAxis, yAxis } = transformWithChrome({ gridlines: false });
expect(yAxis[0].splitLine.show).toBe(false);
// The secondary axis never draws gridlines, so the two grids cannot double up.
expect(yAxis[1].splitLine.show).toBe(false);
expect(xAxis.splitLine.show).toBe(false);
});
test('never turns the secondary axis gridlines on', () => {
const { xAxis, yAxis } = transformWithChrome({ gridlines: true });
expect(yAxis[0].splitLine.show).toBe(true);
expect(yAxis[1].splitLine.show).toBe(false);
expect(xAxis.splitLine).toBeUndefined();
});
test('hides the ticks on the x axis and both y axes', () => {
const { xAxis, yAxis } = transformWithChrome({ axisTicks: false });
expect(xAxis.axisTick.show).toBe(false);
expect(yAxis[0].axisTick.show).toBe(false);
expect(yAxis[1].axisTick.show).toBe(false);
});
@@ -2766,3 +2766,119 @@ describe('tooltip for metrics whose labels end in forecast suffixes', () => {
expect(html).toContain('>ci<');
});
});
test('shows gridlines and axis ticks by default', () => {
const { echartOptions } = transformProps(createTestChartProps({}));
const xAxis = echartOptions.xAxis as any;
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(true);
expect(yAxis.axisTick.show).toBe(true);
// Left to ECharts, which draws no ticks on a banded category axis. Forcing
// true would add ticks the chart does not have today.
expect(xAxis.axisTick.show).toBe('auto');
});
test('hides gridlines without touching the minor split lines', () => {
const { echartOptions } = transformProps(
createTestChartProps({ formData: { gridlines: false } }),
);
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(false);
expect(yAxis.minorSplitLine.show).toBe(DEFAULT_FORM_DATA.minorSplitLine);
expect(yAxis.axisTick.show).toBe(true);
});
test('leaves the category axis split lines alone until gridlines are turned off', () => {
const shown = transformProps(createTestChartProps({}));
// Writing show:true here would draw gridlines on axis types that default to
// none, so the key is only ever added to hide them.
expect((shown.echartOptions.xAxis as any).splitLine).toBeUndefined();
const hidden = transformProps(
createTestChartProps({ formData: { gridlines: false } }),
);
expect((hidden.echartOptions.xAxis as any).splitLine.show).toBe(false);
});
test('hides the ticks on both axes', () => {
const { echartOptions } = transformProps(
createTestChartProps({ formData: { axisTicks: false } }),
);
expect((echartOptions.yAxis as any).axisTick.show).toBe(false);
expect((echartOptions.xAxis as any).axisTick.show).toBe(false);
expect((echartOptions.yAxis as any).splitLine.show).toBe(true);
});
test('keeps gridlines and ticks off on a compact chart even when both are enabled', () => {
const { echartOptions } = transformProps(
createTestChartProps({
height: TIMESERIES_CONSTANTS.compactChartHeight - 1,
formData: { gridlines: true, axisTicks: true },
}),
);
const yAxis = echartOptions.yAxis as any;
expect(yAxis.splitLine.show).toBe(false);
expect(yAxis.axisTick.show).toBe(false);
});
test('applies gridlines to the value axis after a horizontal orientation swaps it', () => {
const { echartOptions } = transformProps(
createTestChartProps({
formData: {
orientation: OrientationType.Horizontal,
gridlines: false,
},
}),
);
// The transform swaps the axes for a horizontal chart, so the value axis —
// and the gridlines belonging to it — end up on xAxis.
expect((echartOptions.xAxis as any).splitLine.show).toBe(false);
});
test('boundary label alignment is dropped when the orientation moves the time axis to the side', () => {
// The alignments position labels against the left and right edges of a
// bottom axis. A horizontal chart swaps the axes, so applying them there
// shifts the first label out of line with the rest (#43428 follow-up).
const monthData = [
{ __timestamp: Date.UTC(2003, 4, 1), sales: 100 },
{ __timestamp: Date.UTC(2003, 5, 1), sales: 200 },
];
const build = (orientation: OrientationType) =>
transformProps(
createTestChartProps({
formData: {
granularity_sqla: 'ds',
timeGrainSqla: TimeGranularity.MONTH,
xAxisTimeFormat: 'smart_date',
seriesType: EchartsTimeseriesSeriesType.Bar,
orientation,
},
queriesData: [
createTestQueryData(monthData, {
colnames: ['__timestamp', 'sales'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
}),
],
}),
).echartOptions;
const vertical = build(OrientationType.Vertical).xAxis as any;
expect(vertical.axisLabel.alignMinLabel).toBe('left');
expect(vertical.axisLabel.alignMaxLabel).toBe('right');
// Horizontal swaps the axes, so the time axis is now yAxis.
const horizontal = build(OrientationType.Horizontal).yAxis as any;
expect(horizontal.axisLabel.alignMinLabel).toBeUndefined();
expect(horizontal.axisLabel.alignMaxLabel).toBeUndefined();
// The boundary labels themselves stay forced in both orientations.
expect(vertical.axisLabel.showMinLabel).toBe(true);
expect(vertical.axisLabel.showMaxLabel).toBe(true);
expect(horizontal.axisLabel.showMinLabel).toBe(true);
expect(horizontal.axisLabel.showMaxLabel).toBe(true);
});
@@ -180,3 +180,46 @@ test('cross-filter does nothing when emitCrossFilters is false', () => {
expect(setDataMask).not.toHaveBeenCalled();
});
test('cross-filter does nothing when name is missing from labelMap', () => {
const setDataMask = jest.fn();
const props = buildProps({
groupby: ['topics'],
labelMap: {
cancellations: ['cancellations'],
},
selectedValues: {},
setDataMask,
});
const handlers = allEventHandlers(props);
// e.g. Pie "Other" category is not present in labelMap
handlers.click({ name: 'Other' });
expect(setDataMask).not.toHaveBeenCalled();
});
test('cross-filter still deselects a previously selected value that is missing from labelMap', () => {
const setDataMask = jest.fn();
const props = buildProps({
groupby: ['topics'],
labelMap: {
cancellations: ['cancellations'],
},
// "Other" was selected before it dropped out of labelMap (e.g. a stale
// cross-filter from an earlier render or dashboard state).
selectedValues: { 0: 'Other' },
setDataMask,
});
const handlers = allEventHandlers(props);
handlers.click({ name: 'Other' });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [],
},
}),
);
});
@@ -30,7 +30,7 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.28.1",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
@@ -462,13 +462,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
// only take relevant page size options
const pageSizeOptions = useMemo(() => {
const getServerPagination = (n: number) => n <= rowCount;
const getServerPagination = (n: number) =>
n <= Math.max(rowCount, serverPageLength);
return (
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
).filter(([n]) =>
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
) as SizeOption[];
}, [data.length, rowCount, serverPagination]);
}, [data.length, rowCount, serverPageLength, serverPagination]);
const getValueRange = useCallback(
function getValueRange(key: string, alignPositiveNegative: boolean) {
@@ -355,25 +355,31 @@ describe('SqlEditor', () => {
}),
);
// findByRole('button', { name }) walks every stylesheet rule via nwsapi to
// compute the accessible name, which can crash on an unrelated antd Tabs
// "more" button style; findByLabelText matches the same aria-label without
// that traversal.
test('enables the save dataset button when the latest query succeeded', async () => {
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
});
expect(await findByLabelText('Save dataset')).toBeEnabled();
});
test('disables the save dataset button when the latest query failed', async () => {
const { findByRole } = setupWithLatestQuery({
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Failed,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
expect(await findByLabelText('Save dataset')).toBeDisabled();
});
test('disables the save dataset button when the results are not loaded', async () => {
const { findByRole } = setupWithLatestQuery({
const { findByLabelText } = setupWithLatestQuery({
state: QueryState.Success,
results: undefined,
});
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
expect(await findByLabelText('Save dataset')).toBeDisabled();
});
test('renders an Extension if provided', async () => {
@@ -67,14 +67,23 @@ async function renderAndWait(props = mockedProps) {
container = renderedContainer;
}
beforeEach(() => {
// A modal that wasn't handed an `etag` reads the dataset itself and can't save
// until that lands, so tests must wait before acting on the Save button.
async function waitForSaveEnabled() {
await waitFor(() =>
expect(screen.getByTestId('datasource-modal-save')).toBeEnabled(),
);
}
beforeEach(async () => {
fetchMock.clearHistory().removeRoutes();
cleanup();
renderAndWait();
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, { result: {} });
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
await waitForSaveEnabled();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -118,6 +127,7 @@ describe('DatasourceModal', () => {
onDatasourceSave:
onDatasourceSave as unknown as typeof mockedProps.onDatasourceSave,
});
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
const okButton = await screen.findByRole('button', { name: 'Confirm' });
@@ -151,6 +161,96 @@ describe('DatasourceModal', () => {
putSpy.mockRestore();
});
test('sends the supplied etag as If-Match so a stale save is refused', async () => {
cleanup();
renderAndWait({ ...mockedProps, etag: '"v1"' } as typeof mockedProps);
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v1"');
});
});
test('reads the etag from the dataset when the caller supplies none', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
fetchMock.get(GET_DATASOURCE_ENDPOINT, {
body: { result: {} },
headers: { ETag: '"v2"' },
});
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
// The form is seeded from the same read as the validator, so saving is
// unavailable until it lands.
expect(screen.getByTestId('datasource-modal-save')).toBeDisabled();
await screen.findByTestId('datasource-editor');
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
await waitFor(() => {
const putCall = fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put');
expect(
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
).toEqual('"v2"');
});
});
test('never saves unguarded while the validator read is in flight', async () => {
cleanup();
fetchMock.clearHistory().removeRoutes();
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
// A read that never resolves: the save path must stay closed rather than
// fall through to an unconditional PUT.
fetchMock.get(GET_DATASOURCE_ENDPOINT, new Promise(() => {}));
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
renderAndWait();
const saveButton = await screen.findByTestId('datasource-modal-save');
expect(saveButton).toBeDisabled();
fireEvent.click(saveButton);
expect(
fetchMock.callHistory
.calls()
.find(call => call.options?.method === 'put'),
).toBeUndefined();
});
test('shows a conflict dialog instead of a generic error on 412', async () => {
const putSpy = jest
.spyOn(SupersetClient, 'put')
.mockRejectedValue(new Response('', { status: 412 }));
try {
fireEvent.click(screen.getByTestId('datasource-modal-save'));
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
const conflictElements = await screen.findAllByText(
'Dataset changed since you opened it',
);
expect(conflictElements.length).toBeGreaterThan(0);
expect(
screen.queryByText('Error saving dataset'),
).not.toBeInTheDocument();
} finally {
putSpy.mockRestore();
}
});
test('shows sync columns checkbox when SQL changes', async () => {
cleanup();
const datasourceWithSQL = {
@@ -163,15 +263,24 @@ describe('DatasourceModal', () => {
};
const { rerender } = render(
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
{ store, useRouter: true },
);
// Update with modified SQL
rerender(
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -208,15 +317,24 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -269,15 +387,24 @@ describe('DatasourceModal', () => {
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
const { rerender } = render(
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
<DatasourceModal
{...mockedProps}
datasource={datasourceWithSQL}
etag='"v1"'
/>,
{ store, useRouter: true },
);
// Update with modified SQL to trigger checkbox
rerender(
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
<DatasourceModal
{...mockedProps}
datasource={modifiedDatasource}
etag='"v1"'
/>,
);
await waitForSaveEnabled();
const saveButton = screen.getByTestId('datasource-modal-save');
fireEvent.click(saveButton);
@@ -21,6 +21,7 @@ import {
screen,
fireEvent,
act,
waitFor,
defaultStore as store,
} from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
@@ -72,6 +73,9 @@ test('DatasourceModal - should handle sync columns state without imperative moda
render(<DatasourceModal {...mockedProps} />, { store });
const saveButton = screen.getByTestId('datasource-modal-save');
// The modal fetches the current dataset version on open; save stays disabled
// until that settles
await waitFor(() => expect(saveButton).toBeEnabled());
// This should not throw any DOM errors
await act(async () => {
@@ -33,12 +33,14 @@ import {
Icons,
Button,
Checkbox,
Loading,
Modal,
AsyncEsmComponent,
} from '@superset-ui/core/components';
import withToasts from 'src/components/MessageToasts/withToasts';
import { ErrorMessageWithStackTrace } from 'src/components';
import type { DatasetObject } from 'src/features/datasets/types';
import { withCertificationFields } from '../utils';
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
import type { DatasourceModalProps } from '../types';
@@ -91,12 +93,18 @@ export function buildExtraJsonObject(
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
addSuccessToast,
datasource,
etag,
onDatasourceSave,
onHide,
show,
}) => {
const theme = useTheme();
const [currentDatasource, setCurrentDatasource] = useState(datasource);
// SQL of the server snapshot the form started from. The caller's, unless
// this modal read the dataset itself — then "did the SQL change?" has to be
// asked against the snapshot the payload is actually built from.
const [seededSql, setSeededSql] = useState<string | undefined>();
const [versionEtag, setVersionEtag] = useState(etag);
const [syncColumns, setSyncColumns] = useState(false);
const currencies = useSelector<
{
@@ -111,6 +119,52 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false);
const [modal, contextHolder] = Modal.useModal();
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
const [isLoadingDatasource, setIsLoadingDatasource] = useState(false);
// Callers that read the dataset themselves (the dataset list) hand down the
// ETag of that read. The rest — Explore, where `datasource` comes from the
// page's bootstrap state — read it here, and must seed the form from the
// *same* response: a payload built from an older snapshot than the ETag
// guarding it would still be accepted, and would still clobber.
useEffect(() => {
setVersionEtag(etag);
if (etag || !show || !datasource.id) {
return undefined;
}
let cancelled = false;
setIsLoadingDatasource(true);
SupersetClient.get({
endpoint: `/api/v1/dataset/${datasource.id}`,
})
.then(({ json, response }) => {
if (cancelled) {
return;
}
const seeded = {
...datasource,
...json.result,
columns: withCertificationFields(json.result.columns),
};
setSeededSql(seeded.sql);
setCurrentDatasource(seeded);
setVersionEtag(response.headers.get('ETag') ?? undefined);
})
.catch(() => {
// The read failed outright, so there is no fresher snapshot to edit
// and no validator to send. Fall back to the caller's snapshot and an
// unconditional save, which is what this modal did before the guard.
})
.finally(() => {
if (!cancelled) {
setIsLoadingDatasource(false);
}
});
return () => {
cancelled = true;
};
}, [datasource.id, etag, show]);
const baselineSql = seededSql ?? datasource.sql;
const buildPayload = (datasource: Record<string, any>) => {
const payload: Record<string, any> = {
table_name: datasource.table_name,
@@ -197,11 +251,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
await SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${syncColumns}`,
jsonPayload: buildPayload(currentDatasource),
...(versionEtag ? { headers: { 'If-Match': versionEtag } } : {}),
});
const { json } = await SupersetClient.get({
const { json, response } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
setVersionEtag(response.headers.get('ETag') ?? undefined);
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
@@ -213,6 +269,19 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onHide();
} catch (response) {
setIsSaving(false);
if ((response as Response)?.status === 412) {
modal.error({
title: t('Dataset changed since you opened it'),
okButtonProps: { danger: true, className: 'btn-danger' },
content: t(
'Someone else, or another one of your browser tabs, saved this ' +
'dataset after you opened it. Saving now would undo those ' +
'changes, so it was cancelled. Copy your edits, close this ' +
'dialog, and reopen the dataset to reapply them.',
),
});
return;
}
const error = await getClientErrorObject(response);
let errorResponse: SupersetError | undefined;
let errorText: string | undefined;
@@ -264,7 +333,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
here may affect other charts
in undesirable ways.`)}
/>
{datasource.sql !== currentDatasource.sql && (
{baselineSql !== currentDatasource.sql && (
<div
css={theme => ({
marginBottom: theme.marginMD,
@@ -298,14 +367,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
{t('Are you sure you want to save and apply changes?')}
</div>
),
[currentDatasource.sql, datasource.sql, syncColumns],
[currentDatasource.sql, baselineSql, syncColumns],
);
useEffect(() => {
if (datasource.sql !== currentDatasource.sql) {
if (baselineSql !== currentDatasource.sql) {
setSyncColumns(true);
}
}, [datasource.sql, currentDatasource.sql]);
}, [baselineSql, currentDatasource.sql]);
const onClickSave = () => {
setConfirmModalOpen(true);
@@ -356,6 +425,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
onClick={onClickSave}
disabled={
isSaving ||
isLoadingDatasource ||
errors.length > 0 ||
currentDatasource.is_managed_externally
}
@@ -381,14 +451,18 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
}}
draggable
>
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
{isLoadingDatasource ? (
<Loading />
) : (
<DatasourceEditor
showLoadingForImport
height={500}
datasource={currentDatasource}
onChange={onDatasourceChange}
setIsEditing={setIsEditing}
currencies={currencies}
/>
)}
{contextHolder}
<Modal
title={t('Confirm save')}
@@ -20,4 +20,5 @@ import ChangeDatasourceModal from './ChangeDatasourceModal';
import DatasourceModal from './DatasourceModal';
export { ChangeDatasourceModal, DatasourceModal };
export { withCertificationFields } from './utils';
export type { DatasourceModalProps, ChangeDatasourceModalProps } from './types';
@@ -29,6 +29,12 @@ export interface DatasourceModalProps {
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
datasource: DatasetObject;
/**
* ETag of the dataset read the form was seeded from. Replayed as `If-Match`
* on save so a stale form can't clobber a newer write. Fetched by the modal
* when the caller doesn't already have one.
*/
etag?: string;
onChange: () => {};
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => {};
@@ -27,6 +27,7 @@ import { nanoid } from 'nanoid';
import { SupersetClient } from '@superset-ui/core';
import { tn } from '@apache-superset/core/translation';
import rison from 'rison';
import type { ColumnObject } from 'src/features/datasets/types';
// Type definitions
@@ -248,3 +249,29 @@ export async function fetchSyncedColumns(
const { json } = await SupersetClient.get({ endpoint, signal });
return json as ColumnMetadata[];
}
/**
* Lift each column's certification out of its `extra` JSON into the flat
* fields the datasource editor binds to.
*/
export function withCertificationFields(columns: ColumnObject[] = []) {
return columns.map(column => {
// Malformed `extra` must not take out the whole column list, the way an
// uncaught parse would — same fallback as `hydrateMetricExtra`.
let parsedExtra;
try {
parsedExtra = JSON.parse(column.extra || '{}') || {};
} catch {
parsedExtra = {};
}
const {
certification: { details = '', certified_by: certifiedBy = '' } = {},
} = parsedExtra;
return {
...column,
certification_details: details || '',
certified_by: certifiedBy || '',
is_certified: details || certifiedBy,
};
});
}
@@ -53,6 +53,22 @@ test('RowCountLabel renders limit with danger and tooltip', async () => {
expect(tooltip).toHaveTextContent('The row limit');
});
test('RowCountLabel uses a caller-provided limitReachedMessage instead of the default', async () => {
render(
<RowCountLabel
rowcount={100}
limit={100}
limitReachedMessage="Custom limit message"
/>,
);
const expectedText = '100 rows';
expect(screen.getByText(expectedText)).toBeInTheDocument();
userEvent.hover(screen.getByText(expectedText));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent('Custom limit message');
expect(tooltip).not.toHaveTextContent('The row limit set for the chart');
});
test('RowCountLabel renders loading', () => {
render(<RowCountLabel loading />);
const expectedText = 'Loading...';
@@ -26,6 +26,9 @@ type RowCountLabelProps = {
limit?: number;
loading?: boolean;
label?: JSX.Element;
// Overrides the default "chart" wording for panes (e.g. samples) where the
// limit reached isn't the chart's own row_limit.
limitReachedMessage?: React.ReactNode;
};
const limitReachedMsg = t(
@@ -33,7 +36,13 @@ const limitReachedMsg = t(
);
export default function RowCountLabel(props: RowCountLabelProps) {
const { rowcount = 0, limit = null, loading, label } = props;
const {
rowcount = 0,
limit = null,
loading,
label,
limitReachedMessage,
} = props;
const limitReached = limit && rowcount >= limit;
const type =
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
@@ -50,7 +59,10 @@ export default function RowCountLabel(props: RowCountLabelProps) {
</Label>
);
return limitReached ? (
<Tooltip id="tt-rowcount-tooltip" title={<span>{limitReachedMsg}</span>}>
<Tooltip
id="tt-rowcount-tooltip"
title={<span>{limitReachedMessage ?? limitReachedMsg}</span>}
>
{label || labelText}
</Tooltip>
) : (
@@ -312,7 +312,7 @@ export function handleComponentDrop(dropResult: DropResult) {
source &&
!(
// ensure it has moved
(destination.id === source.id && destination.index === source.index)
destination.id === source.id && destination.index === source.index
)
) {
dispatch(moveComponent(dropResult));
@@ -117,7 +117,7 @@ const StyledDiv = styled.div`
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
[data-test='slice-header'] .header-title {
.slice-header .header-title {
-webkit-line-clamp: unset;
display: block;
white-space: normal;
@@ -79,6 +79,15 @@ type PropertiesModalProps = {
addSuccessToast: (message: string) => void;
addDangerToast: (message: string) => void;
onlyApply?: boolean;
renderExtraFields?: (context: {
assetId: number;
assetType: 'dashboard';
accessorCount: number;
}) => {
content: React.ReactNode;
saveDisabled?: boolean;
saveTooltip?: string;
};
};
type DashboardInfo = {
@@ -107,6 +116,7 @@ const PropertiesModal = ({
onlyApply = false,
onSubmit = () => {},
show = false,
renderExtraFields,
}: PropertiesModalProps) => {
const dispatch = useDispatch();
const [form] = Form.useForm();
@@ -123,6 +133,17 @@ const PropertiesModal = ({
});
const [editors, setEditors] = useState<Subject[]>([]);
const [viewers, setViewers] = useState<Subject[]>([]);
const extraFields = useMemo(
() =>
renderExtraFields?.({
assetId: dashboardId,
assetType: 'dashboard',
accessorCount: editors.length + viewers.length,
}),
[renderExtraFields, dashboardId, editors.length, viewers.length],
);
const saveLabel = onlyApply ? t('Apply') : t('Save');
const [tags, setTags] = useState<TagType[]>([]);
const [customCss, setCustomCss] = useState('');
@@ -698,15 +719,21 @@ const PropertiesModal = ({
}}
title={t('Dashboard properties')}
isEditMode
saveDisabled={dashboardInfo?.isManagedExternally || hasErrors}
saveDisabled={
dashboardInfo?.isManagedExternally ||
hasErrors ||
extraFields?.saveDisabled
}
saveLoading={isApplying}
contentLoading={isLoading}
errorTooltip={
dashboardInfo?.isManagedExternally
? t(
"This dashboard is managed externally, and can't be edited in Superset",
)
: errorTooltip
extraFields?.saveDisabled && extraFields?.saveTooltip
? extraFields.saveTooltip
: dashboardInfo?.isManagedExternally
? t(
"This dashboard is managed externally, and can't be edited in Superset",
)
: errorTooltip
}
saveText={saveLabel}
wrapProps={{ 'data-test': 'properties-edit-modal' }}
@@ -769,6 +796,7 @@ const PropertiesModal = ({
onChangeViewers={handleOnChangeViewers}
onChangeTags={handleChangeTags}
onClearTags={handleClearTags}
renderExtraFields={extraFields}
/>
),
},
@@ -38,6 +38,11 @@ interface AccessSectionProps {
onChangeViewers: (viewers: SubjectPickerValue[]) => void;
onChangeTags: (tags: { label: string; value: number }[]) => void;
onClearTags: () => void;
renderExtraFields?: {
content: React.ReactNode;
saveDisabled?: boolean;
saveTooltip?: string;
};
}
const AccessSection = ({
@@ -49,6 +54,7 @@ const AccessSection = ({
onChangeViewers,
onChangeTags,
onClearTags,
renderExtraFields,
}: AccessSectionProps) => {
const enableViewers = isFeatureEnabled(FeatureFlag.EnableViewers);
@@ -134,6 +140,7 @@ const AccessSection = ({
/>
</ModalFormField>
)}
{renderExtraFields?.content}
</>
);
};
@@ -208,6 +208,18 @@ test('Should render', () => {
expect(screen.getByTestId('slice-header')).toBeInTheDocument();
});
test('Should expose a class hook, not just data-test, for fullscreen styling', () => {
const props = createProps();
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
// The production build strips data-test attributes, so CSS that targets the
// header must hang off a class instead.
expect(screen.getByTestId('slice-header')).toHaveClass('slice-header');
});
test('Should render - default props', () => {
const props = createProps();
@@ -270,7 +270,11 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
);
return (
<ChartHeaderStyles data-test="slice-header" ref={ref}>
<ChartHeaderStyles
className="slice-header"
data-test="slice-header"
ref={ref}
>
<div className="header-title" ref={headerRef}>
<Tooltip title={headerTooltip}>
{/* this div ensures the hover event triggers correctly and prevents flickering */}
@@ -23,7 +23,7 @@ import {
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core';
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import downloadAsImage from 'src/utils/downloadAsImage';
@@ -165,6 +165,9 @@ beforeEach(() => {
afterEach(() => {
Reflect.deleteProperty(document, 'fullscreenElement');
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
// not leak into other tests (the empty array is guarded, so nothing injects).
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
});
test('Should render', () => {
@@ -173,6 +176,58 @@ test('Should render', () => {
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
});
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
{ key: 'custom-ext', label: 'Custom Menu Extension' },
]);
renderWrapper();
openMenu();
const injected = screen.getByText('Custom Menu Extension');
expect(injected).toBeInTheDocument();
// Sits above the built-in entries.
const forceRefresh = screen.getByText('Force refresh');
expect(
injected.compareDocumentPosition(forceRefresh) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
renderWrapper();
openMenu();
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
// The menu still renders its built-in entries unchanged (no dangling divider
// is added since the empty array is guarded).
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
throw new Error('boom');
});
renderWrapper();
openMenu();
// The throw is isolated: the built-in menu still renders.
expect(screen.getByText('Force refresh')).toBeInTheDocument();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
});
test('Injects nothing when the extension returns a non-array', () => {
getExtensionsRegistry().set(
'dashboard.slice.header.menu',
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
(() => undefined) as never,
);
renderWrapper();
openMenu();
expect(screen.getByText('Force refresh')).toBeInTheDocument();
});
test('Should render default props', () => {
const props = createProps();
@@ -19,20 +19,13 @@
import { css, SupersetTheme } from '@apache-superset/core/theme';
export const fullscreenStyles = (theme: SupersetTheme) => css`
[data-test='dashboard-component-chart-holder']:fullscreen {
.dashboard-component-chart-holder:fullscreen {
background-color: ${theme.colorBgBase};
width: 100vw;
height: 100vh;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: ${theme.sizeUnit * 4}px;
overflow: visible;
position: relative;
pointer-events: auto;
z-index: ${theme.zIndexPopupBase};
opacity: 1;
visibility: visible;
/* Ensure children take up available space */
.dashboard-chart,
@@ -58,13 +51,8 @@ export const fullscreenStyles = (theme: SupersetTheme) => css`
}
}
/* Interaction and Header fixes */
[data-test='dashboard-component-chart-holder']:fullscreen * {
pointer-events: auto;
}
[data-test='dashboard-component-chart-holder']:fullscreen
[data-test='slice-header'] {
/* Keep the header above the chart it shares the fullscreen layer with */
.dashboard-component-chart-holder:fullscreen .slice-header {
z-index: ${theme.zIndexPopupBase};
position: relative;
}
@@ -34,11 +34,13 @@ import {
isFeatureEnabled,
FeatureFlag,
getChartMetadataRegistry,
getExtensionsRegistry,
VizType,
BinaryQueryObjectFilterClause,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme, styled } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
@@ -165,6 +167,8 @@ const queueChartResize = () => {
}, 300);
};
const extensionsRegistry = getExtensionsRegistry();
const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => {
@@ -514,6 +518,26 @@ const SliceHeaderControls = (
},
];
const sliceHeaderMenuExtension = extensionsRegistry.get(
'dashboard.slice.header.menu',
);
if (sliceHeaderMenuExtension) {
// Isolate the extension: a bad registration (throwing, or returning a
// non-array) must not take down the whole dashboard render.
try {
const extensionItems = sliceHeaderMenuExtension({
sliceId: slice.slice_id,
sliceName: slice.slice_name,
dashboardId,
});
if (Array.isArray(extensionItems) && extensionItems.length) {
newMenuItems.unshift(...extensionItems, { type: 'divider' });
}
} catch (error) {
logging.error('dashboard.slice.header.menu extension failed', error);
}
}
if (slice.description) {
newMenuItems.push({
key: MenuKeys.ToggleChartDescription,
@@ -337,13 +337,17 @@ const ChartHolder = ({
)}
>
<AntdThemeProvider
getPopupContainer={(triggerNode: HTMLElement) =>
document.fullscreenElement
? (triggerNode?.closest?.(
'[data-test="dashboard-component-chart-holder"]',
) as HTMLElement) || document.body
: document.body
}
getPopupContainer={(triggerNode?: HTMLElement) => {
// Only the fullscreen element's subtree is painted, so popups
// have to be portaled into it rather than to document.body.
// Resolve it directly instead of matching a selector: the
// production build strips data-test attributes.
const fullscreenElement =
document.fullscreenElement as HTMLElement | null;
return triggerNode && fullscreenElement?.contains(triggerNode)
? fullscreenElement
: document.body;
}}
>
{!editMode && (
<AnchorLink
@@ -97,3 +97,29 @@ test('does not render DeckglLayerVisibilityTooltip for standard filter type', ()
screen.queryByTestId('deckgl-layer-visibility-tooltip-icon'),
).not.toBeInTheDocument();
});
test('does not mark a defaultToFirstItem-only filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { defaultToFirstItem: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.queryByText('*')).not.toBeInTheDocument();
});
test('marks an enableEmptyFilter filter as required', () => {
render(
<FilterControl
filter={{
...nativeFilter,
controlValues: { enableEmptyFilter: true },
}}
onFilterSelectionChange={jest.fn()}
/>,
);
expect(screen.getByText('*')).toBeInTheDocument();
});
@@ -65,9 +65,7 @@ const FilterControl = ({
isFilterInScope(filter) &&
checkIsMissingRequiredValue(filter, filter.dataMask?.filterState);
const validateStatus = isMissingRequiredValue ? 'error' : undefined;
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const inverseSelection = !!filter.controlValues?.inverseSelection;
const {
@@ -211,6 +211,16 @@ test('checkIsMissingRequiredValue returns false for non-required filter with und
expect(checkIsMissingRequiredValue(filter, filterState)).toBe(false);
});
test('checkIsMissingRequiredValue returns false when only defaultToFirstItem is set', () => {
const filter = createFilter('test-filter', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
expect(checkIsMissingRequiredValue(filter, { value: null })).toBe(false);
expect(checkIsMissingRequiredValue(filter, { value: undefined })).toBe(false);
});
test('checkIsMissingRequiredValue returns falsy for filter without controlValues', () => {
const filter = { id: 'test-filter' } as Filter;
const filterState: FilterState = { value: undefined };
@@ -299,6 +309,48 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
);
});
test('checkIsApplyDisabled enables Apply after clearing a cascading defaultToFirstItem child', () => {
// Regression: a child filter that is dependent on a parent and configured with
// "Select first filter value by default" but NOT "Filter value is required"
// must stay clearable — clearing it may not disable Apply.
const parent = createFilter('parent', {
enableEmptyFilter: true,
controlValues: { defaultToFirstItem: true },
});
const child = createFilter('child', {
enableEmptyFilter: false,
controlValues: { defaultToFirstItem: true },
});
const dataMaskSelected: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: null },
extraFormData: {},
},
};
const dataMaskApplied: DataMaskStateWithId = {
parent: {
id: 'parent',
filterState: { value: ['USA'] },
extraFormData: createExtraFormDataWithFilter('country', ['USA']),
},
child: {
id: 'child',
filterState: { value: ['CA'] },
extraFormData: createExtraFormDataWithFilter('state', ['CA']),
},
};
expect(
checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, [parent, child]),
).toBe(false);
});
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
// Regression: when a required filter's default isn't applied (Applied missing
// the entry) and the user types a value, Selected gains an entry Applied
@@ -48,9 +48,10 @@ export const checkIsMissingRequiredValue = (
filter: FilterElement,
filterState?: FilterState,
) => {
const isRequired =
!!filter.controlValues?.enableEmptyFilter ||
!!filter.controlValues?.defaultToFirstItem;
// Only `enableEmptyFilter` ("Filter value is required") makes a value
// mandatory. `defaultToFirstItem` merely seeds an initial selection, so a
// filter cleared by the user must stay clearable, with Apply enabled.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
if (!isRequired) return false;

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