Compare commits

...
Author SHA1 Message Date
Elizabeth ThompsonandClaude Opus 4.8 ac3c158c41 fix(tags): catch TemplateError when validating access for tagged SQL Lab queries on delete (#43433)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 15:02:22 -07:00
drivaspresetandClaude Opus 5 1f1539fe0e feat: test(presto) close db_engine_spec unit test gaps (#43607)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:33:29 -07:00
Gabriel Torres Ruiz 5bffceb295 feat(mcp): let get_dashboard_info accept filter_state directly (#43688) 2026-08-31 15:46:53 -03:00
wenjie liang b24cb2af8a fix(db_engine_specs): support SQLAlchemy 2 DBAPI discovery (#43700) 2026-08-31 12:51:50 -04:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 040b33c450 chore(deps): bump yeoman-generator from 8.2.2 to 8.3.0 in /superset-frontend (#43709)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 05:59:31 -07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9d1b5ebdb7 chore(deps-dev): bump baseline-browser-mapping from 2.11.17 to 2.11.18 in /superset-frontend (#43708)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 15:38:46 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 6e185ebce8 chore(deps): bump actions/setup-java from 5.7.0 to 6.0.0 (#43707)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 15:04:47 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5b64150d0a chore(deps-dev): bump webpack-bundle-analyzer from 5.3.1 to 5.3.2 in /superset-frontend (#43706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 14:48:05 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b8b2e5ea8b chore(deps): bump baseline-browser-mapping from 2.11.17 to 2.11.18 in /docs (#43704)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 00:14:13 -07:00
Đỗ Trọng Hải f3985d88a1 feat(build): migrate to TS7 and TS7-oriented oxlint-tsgolint (#43691) 2026-08-31 09:57:13 +07:00
SBIN2010andCopilot Autofix powered by AI 6abab3c1cb feat(butterfly): added cross-filtering and drill-to-detail for the butterfly chart (#43684)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-30 23:12:50 +03:00
SBIN2010 b310f469c2 feat(dashboard): add a tooltip displaying chart descriptions (#43271) 2026-08-30 12:25:37 -07:00
shaurya 22891894d4 docs: fix superset-core component source links after src/ui removal (#43690) 2026-08-30 12:23:37 -07:00
65fa1ce816 chore(ci): skip Jest test HTML report generation to save up time (#43653)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:52:47 -07:00
ee4a90892c test(explore): zero-value stacked bar segment no longer overlaps its neighbor's label (#42882)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: vjymisal0 <vijay.looprai@gmail.com>
2026-08-30 11:43:01 -07:00
Joe Li bba4832946 fix(sqllab): prefer concluded live query state over a stale history snapshot (#43535) 2026-08-30 11:39:34 -07:00
Amin Ghadersohi 2df06dcd57 feat(mcp): add feature-gated interactive pivot support (#43480) 2026-08-30 11:36:17 -07:00
Joe Li 88cd8bf575 fix(ci): repair nightly pre-commit failures (#43366) 2026-08-30 11:35:19 -07:00
Francesco.Castaldi 5f67e85003 chore(i18n): complete Italian translations with 100% placeholder validation (#43274) 2026-08-30 11:34:56 -07:00
Mike BridgeandMike Bridge 87fb7d18e1 fix(semantic-layer): hydrate the semantic view edit modal from /structure (#42839)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-08-30 11:31:35 -07: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
185 changed files with 33965 additions and 27726 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:
+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: |
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "zulu"
java-version: "21"
+1
View File
@@ -97,6 +97,7 @@ jobs:
mkdir -p ${{ github.workspace }}/superset-frontend/coverage
docker run \
-v ${{ github.workspace }}/superset-frontend/coverage:/app/superset-frontend/coverage \
-e CI=true \
--rm $TAG \
bash -c \
"npm run test -- --coverage --shard=${{ matrix.shard }}/8 --coverageReporters=json"
+3 -3
View File
@@ -88,9 +88,9 @@ repos:
language: system
pass_filenames: true
files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$
- id: eslint-docs
name: eslint (docs)
entry: bash -c 'cd docs && FILES=$(printf "%s\n" "$@" | sed "s|^docs/||" | tr "\n" " ") && yarn eslint --fix --quiet $FILES'
- id: oxlint-docs
name: oxlint (docs)
entry: bash -c 'cd docs && FILES=$(printf "%s\n" "$@" | sed "s|^docs/||" | tr "\n" " ") && yarn lint --fix --quiet $FILES'
language: system
pass_filenames: true
files: ^docs/.*\.(js|jsx|ts|tsx)$
+1
View File
@@ -10,6 +10,7 @@
.stylelintignore
.flake8
.nvmrc
.npmrc
.rat-excludes
.swcrc
.*log
+3 -3
View File
@@ -62,8 +62,8 @@ yarn version:remove:developer_docs <version> # Remove developer docs version
yarn version:remove:components <version> # Remove components version
# Quality Checks
yarn typecheck # TypeScript validation
yarn eslint # Lint TypeScript/JavaScript files
yarn typecheck # TypeScript validation
yarn lint # Lint TypeScript/JavaScript files
```
## 📁 Documentation Structure
@@ -431,7 +431,7 @@ yarn build
yarn typecheck
# Linting issues
yarn eslint
yarn lint
```
### Version Issues
@@ -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:
@@ -114,8 +114,8 @@ function MyExtension() {
## Source Links
- [Story file](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/Alert.stories.tsx)
- [Component source](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/index.tsx)
- [Story file](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/components/Alert/Alert.stories.tsx)
- [Component source](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/components/Alert/index.tsx)
---
@@ -47,8 +47,8 @@ export function MyExtensionPanel() {
Components in `@apache-superset/core/components` are automatically documented here. To add a new extension component:
1. Add the component to `superset-frontend/packages/superset-core/src/ui/components/`
2. Export it from `superset-frontend/packages/superset-core/src/ui/components/index.ts`
1. Add the component to `superset-frontend/packages/superset-core/src/components/`
2. Export it from `superset-frontend/packages/superset-core/src/components/index.ts`
3. Create a Storybook story with an `Interactive` export:
```tsx
@@ -78,6 +78,18 @@ Charts are **not saved by default**. The workflow is intentionally iterative:
To skip the preview and save immediately, include "and save it" in your prompt.
:::
:::info Deployment-specific chart types
Use `get_chart_type_schema` before generating a chart to discover the types
available on your Superset instance. Some deployments expose additional
feature-gated visualizations. For example, a deployment with an AG Grid pivot
extension enabled can expose `interactive_pivot`, which supports interactive
row groups, pivot columns, totals, and period-over-period comparisons. Pair
`comparison_period` (for example, `1 year ago`) with `comparison_type`
(`values`, `difference`, `percentage`, or `ratio`). It is distinct from the
built-in `pivot_table` chart type and is not offered when the host visualization
is unavailable.
:::
### Create Dashboards
Build dashboards from a collection of charts:
-71
View File
@@ -1,71 +0,0 @@
/* eslint-env node */
/**
* 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.
*/
const typescriptEslintParser = require('@typescript-eslint/parser');
const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin');
const js = require('@eslint/js');
const ts = require('typescript-eslint');
const react = require('eslint-plugin-react');
const globals = require('globals');
const { defineConfig, globalIgnores } = require('eslint/config');
module.exports = defineConfig([
{
files: ['**/*.{js,jsx,ts,tsx}'],
},
globalIgnores(['build/**/*', '.docusaurus/**/*', 'node_modules/**/*']),
js.configs.recommended,
...ts.configs.recommended,
{
files: ['eslint.config.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
languageOptions: {
parser: typescriptEslintParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2020,
sourceType: 'module',
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
typescript: typescriptEslintPlugin,
react,
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
settings: {
react: {
version: 'detect',
},
},
},
]);
+139
View File
@@ -0,0 +1,139 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"typescript",
"react"
],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true,
"browser": true,
"node": true
},
"ignorePatterns": [
"build/**/*",
".docusaurus/**/*",
"node_modules/**/*"
],
"settings": {
"react": {
"version": "18.3.1"
}
},
"options": {
"typeAware": true
},
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
}
]
}
+7 -13
View File
@@ -29,7 +29,7 @@
"lint:db-metadata": "python3 ../superset/db_engine_specs/lint_metadata.py",
"lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md",
"update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme",
"eslint": "eslint .",
"lint": "oxlint --config oxlint.json",
"lint:docs-links": "node scripts/lint-docs-links.mjs",
"version:add": "node scripts/manage-versions.mjs add",
"version:remove": "node scripts/manage-versions.mjs remove",
@@ -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.18",
"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",
@@ -85,19 +85,13 @@
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.11.0",
"oxfmt": "^0.64.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
"webpack": "^5.109.2"
"oxlint": "^1.80.0",
"oxlint-tsgolint": "^7.0.2001",
"typescript": "7.0.2",
"webpack": "^5.110.1"
},
"browserslist": {
"production": [
+23 -6
View File
@@ -1,14 +1,30 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
// "extends": "@docusaurus/tsconfig",
// First compilerOptions section comes from above commented @docusaurus/tsconfig
// We moved them here to help with TS v7 migration so whenever Docusaurus readily supports TS v7,
// re-install @docusaurus/tsconfig and remove said section.
// Commented options are overriden in the next section.
"compilerOptions": {
"baseUrl": ".",
"allowJs": true,
// "esModuleInterop": true,
// "jsx": "preserve",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
// "moduleResolution": "bundler",
"module": "esnext",
"noEmit": true,
// "paths": {
// "@site/*": ["./*"]
// },
// "skipLibCheck": true,
"ignoreDeprecations": "6.0",
"skipLibCheck": true,
"noImplicitAny": false,
"strict": false,
"jsx": "react-jsx",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"types": ["@docusaurus/module-type-aliases"],
@@ -23,9 +39,10 @@
// Runtime resolution uses webpack alias pointing to actual source (see src/webpack.extend.ts)
// Using /ui path matches the established pattern used throughout the Superset codebase
"@apache-superset/core/components": ["./src/types/apache-superset-core"],
"*": ["src/*", "node_modules/*"]
"@site/*": ["./*"],
"*": ["./src/*", "./node_modules/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["node_modules", "../superset-frontend/**/*", "src/shims/**"]
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./src/**/*.d.ts"],
"exclude": ["./node_modules", "../superset-frontend/**/*", "src/shims/**"]
}
+360 -1255
View File
File diff suppressed because it is too large Load Diff
+2 -2
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
+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)
+3 -3
View File
@@ -375,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
@@ -663,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
@@ -920,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
@@ -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"
+15 -10
View File
@@ -18,6 +18,19 @@
*/
// timezone for unit tests
process.env.TZ = 'America/New_York';
const reporters = ['default'];
// HTML reporter is not used on CI so skipping its generation for saving time
if (!process.env.CI) {
reporters.push([
'./node_modules/jest-html-reporter',
{
pageTitle: 'Test Report',
},
]);
}
module.exports = {
// [/\\] matches both path separators so the suite also collects on
// native Windows, where jest hands the regex backslash-separated paths.
@@ -77,7 +90,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: {
@@ -88,14 +101,6 @@ module.exports = {
__DEV__: true,
caches: true,
},
reporters: [
'default',
[
'./node_modules/jest-html-reporter',
{
pageTitle: 'Test Report',
},
],
],
reporters: reporters,
testTimeout: 20000,
};
+43 -43
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",
@@ -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.18",
"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",
@@ -277,7 +277,7 @@
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.109.2",
"webpack-bundle-analyzer": "^5.3.1",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
@@ -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.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz",
"integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==",
"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": [
@@ -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": [
{
@@ -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": [
{
@@ -41117,9 +41117,9 @@
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.1.tgz",
"integrity": "sha512-wP2EusncRGL1tZyMHC/umLkjPdYMkTL9nPEKh8G8dkYCJ9TyF6xnXFqjhdqmv5J900irN/g0P5jMvLT22krEXQ==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.2.tgz",
"integrity": "sha512-IagCa/GrdxSz+ba9OMgK7UCfQp97HtXbgjXu7ObcqmRo9PTp0d+24rgY+mIFOx7JPQ9bo6FGsqZhA55rYRZrrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -42317,9 +42317,9 @@
}
},
"node_modules/yeoman-generator": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/yeoman-generator/-/yeoman-generator-8.2.2.tgz",
"integrity": "sha512-GIvRULf09VrTyJ1nMIxCRFTI8gzW9zsAxVXTHOmsWVKZ7QYPdByRQvFtnp0XOObM6dvDSoAwBhuYR6i5inp/ig==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/yeoman-generator/-/yeoman-generator-8.3.0.tgz",
"integrity": "sha512-stpxbDj2KQj4CysTd15z51uMJmfXdTzvlQ9TSMwZxVaN5NJ6a0zWMX84GNx+bEhntZs6FoFS5Hnkdck4NRyu1g==",
"license": "BSD-2-Clause",
"dependencies": {
"@types/debug": "^4.1.13",
@@ -42872,7 +42872,7 @@
"dependencies": {
"chalk": "^6.0.0",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.2.2",
"yeoman-generator": "^8.3.0",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -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"
+19 -7
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",
@@ -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.18",
"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",
@@ -354,7 +354,7 @@
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.109.2",
"webpack-bundle-analyzer": "^5.3.1",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
@@ -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",
@@ -33,7 +33,7 @@
"dependencies": {
"chalk": "^6.0.0",
"lodash-es": "^4.18.1",
"yeoman-generator": "^8.2.2",
"yeoman-generator": "^8.3.0",
"yosay": "^3.0.0"
},
"devDependencies": {
@@ -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',
@@ -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) {
@@ -47,6 +47,8 @@ export default function extractQueryFields(
metric: 'metrics',
metric_2: 'metrics',
secondary_metric: 'metrics',
left_metric: 'metrics',
right_metric: 'metrics',
x: 'metrics',
y: 'metrics',
size: 'metrics',
@@ -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');
@@ -59,6 +59,16 @@ describe('extractQueryFields', () => {
).toEqual(['metric_1', 'metric_2', 'my_custom_metric']);
});
test('should extract butterfly chart metrics', () => {
expect(
extractQueryFields({
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
}).metrics,
).toEqual(['left_sum', 'right_sum']);
});
test('should extract columns', () => {
expect(extractQueryFields({ columns: 'col_1' })).toEqual({
columns: ['col_1'],
@@ -0,0 +1,79 @@
/**
* 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 { allEventHandlers, type Event } from '../utils/eventHandlers';
import Echart from '../components/Echart';
import { EventHandlers } from '../types';
import { ButterflyTransformedProps } from './types';
type ButterflyChartEvent = {
name?: string;
data?: { name?: string };
event?: Event['event'];
};
function getCategoryKey(params: ButterflyChartEvent): string {
return params.data?.name ?? params.name ?? '';
}
export default function Butterfly(props: ButterflyTransformedProps) {
const {
height,
width,
echartOptions,
selectedValues,
refs,
onLegendStateChanged,
formData,
} = props;
const { click, contextmenu } = allEventHandlers(props);
const eventHandlers: EventHandlers = {
click: (params: ButterflyChartEvent) => {
click({ name: getCategoryKey(params) });
},
contextmenu: (params: ButterflyChartEvent) => {
contextmenu({
...params,
name: getCategoryKey(params),
});
},
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}
selectedValues={selectedValues}
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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -0,0 +1,65 @@
/**
* 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 { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import { EchartsButterflyChartProps, EchartsButterflyFormData } from './types';
import example from './images/example.png';
import exampleDark from './images/example-dark.png';
import thumbnail from './images/thumbnail.png';
import thumbnailDark from './images/thumbnail-dark.png';
export default class EchartsButterflyChartPlugin extends ChartPlugin<
EchartsButterflyFormData,
EchartsButterflyChartProps
> {
constructor() {
super({
buildQuery,
controlPanel,
loadChart: () => import('./Butterfly'),
metadata: new ChartMetadata({
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
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.',
),
exampleGallery: [{ url: example, urlDark: exampleDark }],
name: t('Butterfly Chart'),
tags: [
t('Categorical'),
t('Comparison'),
t('ECharts'),
t('Multi-Variables'),
],
thumbnail,
thumbnailDark,
}),
transformProps,
});
}
}
@@ -0,0 +1,353 @@
/**
* 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 { OpacityEnum } from '../constants';
import {
getChartPadding,
getLegendProps,
getColtypesMapping,
extractGroupbyLabel,
} 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 formatTooltip(
params: CallbackDataParams[],
formatter: NumberFormatter | CurrencyFormatter,
categoryLabels: string[],
categoryByKey: Map<string, string>,
) {
const axisParams = params.filter(
param => param.seriesName && typeof param.value === 'number',
);
if (!axisParams.length) {
return '';
}
const { dataIndex, name } = axisParams[0];
const title =
(typeof dataIndex === 'number'
? categoryLabels.at(dataIndex)
: undefined) ??
(typeof name === 'string' ? categoryByKey.get(name) : undefined) ??
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,
filterState,
emitCrossFilters,
} = 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 leftMetricLabel = leftMetric ? getMetricLabel(leftMetric) : '';
const rightMetricLabel = rightMetric ? getMetricLabel(rightMetric) : '';
const leftSeriesName = leftLabel || leftMetricLabel;
const rightSeriesName = rightLabel || rightMetricLabel;
const coltypeMapping = getColtypesMapping(queriesData[0]);
const groupbyColumns = ensureIsArray(groupby);
const groupbyLabels = groupbyColumns.map(getColumnLabel);
const defaultFormatter = currencyFormat?.symbol
? new CurrencyFormatter({ d3Format: xAxisFormat, currency: currencyFormat })
: getNumberFormatter(xAxisFormat);
const categories = data.map(datum =>
extractGroupbyLabel({ datum, groupby: groupbyLabels, coltypeMapping }),
);
const categoryKeys = data.map((datum, index) => {
const label = categories.at(index) ?? '';
return `${label}__${JSON.stringify(
groupbyLabels.map(col =>
Object.hasOwn(datum, col) ? datum[col] : undefined,
),
)}`;
});
const categoryByKey = new Map(
categoryKeys.flatMap((key, index) => {
const label = categories.at(index);
return label === undefined ? [] : [[key, label] as const];
}),
);
const labelMap = data.reduce<Record<string, string[]>>(
(acc, datum, index) => {
const uniqueKey = categoryKeys.at(index);
if (uniqueKey === undefined) {
return acc;
}
acc[uniqueKey] = groupbyLabels.map(col =>
Object.hasOwn(datum, col) ? (datum[col] as string) : '',
);
return acc;
},
{},
);
const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<number, string>, value: string) => {
const index = categoryKeys.indexOf(value);
return index >= 0 ? { ...acc, [index]: value } : acc;
},
{},
);
const getOpacity = (categoryKey: string) =>
filterState.selectedValues?.length &&
!filterState.selectedValues.includes(categoryKey)
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent;
const leftData = data.map((row, i) => ({
name: categoryKeys[i],
value: -Math.abs(Number(row[leftMetricLabel] ?? 0)),
label: LABEL_LEFT,
itemStyle: { opacity: getOpacity(categoryKeys[i]) },
}));
const rightData = data.map((row, i) => ({
name: categoryKeys[i],
value: Math.abs(Number(row[rightMetricLabel] ?? 0)),
label: LABEL_RIGHT,
itemStyle: { opacity: getOpacity(categoryKeys[i]) },
}));
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,
categories,
categoryByKey,
),
},
series,
};
return {
refs,
formData,
width,
height,
echartOptions,
setDataMask,
onContextMenu,
onLegendStateChanged,
groupby: groupbyColumns,
labelMap,
selectedValues,
emitCrossFilters,
coltypeMapping,
};
}
@@ -0,0 +1,57 @@
/**
* 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,
CrossFilterTransformedProps,
} 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> & CrossFilterTransformedProps;
@@ -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';
@@ -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 { render } from '@testing-library/react';
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import Butterfly from '../../src/Butterfly/Butterfly';
import transformProps from '../../src/Butterfly/transformProps';
import { EchartsButterflyChartProps } from '../../src/Butterfly/types';
import Echart from '../../src/components/Echart';
import { EventHandlers } from '../../src/types';
jest.mock('../../src/components/Echart', () => ({
__esModule: true,
default: jest.fn(() => null),
}));
const mockedEchart = jest.mocked(Echart);
const data = [
{ category: 'A', left_sum: 10, right_sum: 25 },
{ category: 'B', left_sum: 5, right_sum: 19 },
];
const categoryKeyA = 'A__["A"]';
const categoryKeyB = 'B__["B"]';
function setup(
overrides: {
filterState?: { selectedValues?: string[] };
onLegendStateChanged?: jest.Mock;
} = {},
) {
const onContextMenu = jest.fn();
const setDataMask = jest.fn();
const onLegendStateChanged = overrides.onLegendStateChanged ?? jest.fn();
const chartProps = {
...new ChartProps({
formData: {
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
viz_type: 'butterfly',
},
width: 800,
height: 600,
queriesData: [{ data }],
theme: supersetTheme,
hooks: { onContextMenu, setDataMask, onLegendStateChanged },
}),
filterState: overrides.filterState ?? {},
emitCrossFilters: true,
} as unknown as EchartsButterflyChartProps;
const transformed = transformProps(chartProps);
render(
<Butterfly
{...transformed}
onContextMenu={onContextMenu}
setDataMask={setDataMask}
onLegendStateChanged={onLegendStateChanged}
emitCrossFilters
/>,
);
const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1];
const { eventHandlers, selectedValues } = lastCall[0] as {
eventHandlers: EventHandlers;
selectedValues: Record<number, string>;
};
return {
eventHandlers,
onContextMenu,
setDataMask,
onLegendStateChanged,
selectedValues,
};
}
beforeEach(() => {
mockedEchart.mockClear();
});
test('context menu exposes drill to detail for the selected category', () => {
const { eventHandlers, onContextMenu } = setup();
eventHandlers.contextmenu({
name: 'A',
data: { name: categoryKeyA },
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [x, y, payload] = onContextMenu.mock.calls[0];
expect(x).toBe(10);
expect(y).toBe(20);
expect(payload.drillToDetail).toEqual([
expect.objectContaining({
col: 'category',
op: '==',
val: 'A',
formattedVal: 'A',
}),
]);
});
test('context menu exposes drill by for the selected category', () => {
const { eventHandlers, onContextMenu } = setup();
eventHandlers.contextmenu({
name: 'A',
data: { name: categoryKeyA },
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
const payload = onContextMenu.mock.calls[0][2];
expect(payload.drillBy).toEqual({
filters: [
expect.objectContaining({
col: 'category',
op: '==',
val: 'A',
formattedVal: 'A',
}),
],
groupbyFieldName: 'groupby',
});
});
test('click emits cross-filter for the selected category', () => {
const { eventHandlers, setDataMask } = setup();
eventHandlers.click({ name: 'B', data: { name: categoryKeyB } });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [{ col: 'category', op: 'IN', val: ['B'] }],
},
filterState: {
value: [['B']],
selectedValues: [categoryKeyB],
},
}),
);
});
test('click clears cross-filter when the category is already selected', () => {
const { eventHandlers, setDataMask } = setup({
filterState: { selectedValues: [categoryKeyB] },
});
eventHandlers.click({ name: 'B', data: { name: categoryKeyB } });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [],
},
filterState: {
value: null,
selectedValues: null,
},
}),
);
});
test('legend selection forwards legend state to the chart hook', () => {
const onLegendStateChanged = jest.fn();
const { eventHandlers } = setup({ onLegendStateChanged });
const selected = { left_sum: true, right_sum: false };
eventHandlers.legendselectchanged({ selected });
eventHandlers.legendselectall({ selected });
eventHandlers.legendinverseselect({ selected });
expect(onLegendStateChanged).toHaveBeenCalledTimes(3);
expect(onLegendStateChanged).toHaveBeenCalledWith(selected);
});
test('passes selectedValues through to the chart component', () => {
const { selectedValues } = setup({
filterState: { selectedValues: [categoryKeyA] },
});
expect(selectedValues).toEqual({ 0: categoryKeyA });
});
@@ -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,341 @@
/**
* 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 type { CallbackDataParams } from 'echarts/types/src/util/types';
import {
EchartsButterflyChartProps,
ButterflyTransformedProps,
} from '../../src/Butterfly/types';
import transformProps from '../../src/Butterfly/transformProps';
import { NULL_STRING, OpacityEnum } from '../../src/constants';
const categoryKeyA = 'A__["A"]';
const categoryKeyB = 'B__["B"]';
type SeriesDataPoint = {
name?: string;
value?: number;
itemStyle?: { opacity?: number };
};
type ButterflyTestSeries = {
name?: string;
data?: SeriesDataPoint[];
itemStyle?: { color?: string };
label?: {
show?: boolean;
formatter?: (params: CallbackDataParams) => string;
};
};
type ButterflyTestEchartOptions = {
series?: ButterflyTestSeries[];
xAxis?: {
name?: string;
nameGap?: number;
axisLabel?: { formatter?: (value: number) => string };
};
yAxis?: {
name?: string;
nameGap?: number;
data?: string[];
axisLabel?: { rotate?: number };
};
legend?: { orient?: string; data?: string[] };
grid?: { left?: number; top?: number };
tooltip?: {
show?: boolean;
formatter?: (params: CallbackDataParams | CallbackDataParams[]) => string;
};
};
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 => entry.value));
};
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),
}),
filterState: overrides.filterState ?? {},
emitCrossFilters: overrides.emitCrossFilters,
inContextMenu: overrides.inContextMenu,
}) as unknown as EchartsButterflyChartProps;
test('transforms chart props into diverging bar series', () => {
const transformedProps = transformProps(createChartProps());
expect(extractSeriesValues(transformedProps)).toEqual([
[-10, -5],
[25, 19],
]);
});
test('assigns composite category keys to each bar data point', () => {
const transformedProps = transformProps(createChartProps());
const series = getEchartOptions(transformedProps).series ?? [];
expect(series[0]?.data?.map(point => point.name)).toEqual([
categoryKeyA,
categoryKeyB,
]);
expect(series[1]?.data?.map(point => point.name)).toEqual([
categoryKeyA,
categoryKeyB,
]);
});
test('uses absolute values for negative right-side metrics', () => {
const transformedProps = transformProps(
createChartProps({}, [{ category: 'A', left_sum: -8, right_sum: -15 }]),
);
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 },
]),
);
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',
}),
);
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,
}),
);
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 }),
);
const { series } = getEchartOptions(transformedProps);
expect(series?.[0]?.label?.show).toBe(false);
expect(series?.[1]?.label?.show).toBe(false);
});
test('hides zero value labels but keeps non-zero labels', () => {
const transformedProps = transformProps(
createChartProps({}, [{ category: 'A', left_sum: 0, right_sum: 12 }]),
);
const formatter =
getEchartOptions(transformedProps).series?.[0]?.label?.formatter;
expect(formatter?.({ value: 0 } as CallbackDataParams)).toBe('');
expect(formatter?.({ value: -10 } as CallbackDataParams)).toBe('10');
});
test('formats axis and tooltip values as absolute numbers', () => {
const transformedProps = transformProps(createChartProps());
const { xAxis, tooltip } = getEchartOptions(transformedProps);
expect(xAxis?.axisLabel?.formatter?.(-25)).toBe('25');
const tooltipHtml = tooltip?.formatter?.([
{
name: categoryKeyA,
dataIndex: 0,
seriesName: 'left_sum',
value: -10,
} as CallbackDataParams,
{
name: categoryKeyA,
dataIndex: 0,
seriesName: 'right_sum',
value: 25,
} as CallbackDataParams,
]);
expect(tooltipHtml).toContain('A');
expect(tooltipHtml).not.toContain(categoryKeyA);
expect(tooltipHtml).toContain('left_sum');
expect(tooltipHtml).toContain('right_sum');
expect(tooltipHtml).toContain('10');
expect(tooltipHtml).toContain('25');
});
test('shows the category label in the tooltip when ECharts reports a unique key', () => {
const transformedProps = transformProps(createChartProps());
const tooltipHtml = getEchartOptions(transformedProps).tooltip?.formatter?.({
name: categoryKeyA,
seriesName: 'left_sum',
value: -10,
} as CallbackDataParams);
expect(tooltipHtml).toContain('A');
expect(tooltipHtml).not.toContain(categoryKeyA);
});
test('hides tooltip while the context menu is open', () => {
const transformedProps = transformProps(createChartProps());
const withContextMenu = transformProps(
createChartProps({ inContextMenu: true }),
);
expect(getEchartOptions(transformedProps).tooltip?.show).toBe(true);
expect(getEchartOptions(withContextMenu).tooltip?.show).toBe(false);
});
test('builds labelMap and groupby for drill and cross-filter handlers', () => {
const transformedProps = transformProps(createChartProps());
expect(transformedProps.groupby).toEqual(['category']);
expect(transformedProps.labelMap).toEqual({
'A__["A"]': ['A'],
'B__["B"]': ['B'],
});
});
test('uses unique keys for interactions and readable labels on the y-axis', () => {
const transformedProps = transformProps(
createChartProps({ groupby: ['country', 'state'] }, [
{ country: 'US', state: 'CA', left_sum: 4, right_sum: 6 },
{ country: 'US', state: 'NY', left_sum: 8, right_sum: 3 },
]),
);
const series = getEchartOptions(transformedProps).series ?? [];
const firstKey = 'US, CA__["US","CA"]';
const secondKey = 'US, NY__["US","NY"]';
expect(firstKey).not.toBe(secondKey);
expect(series[0]?.data?.map(point => point.name)).toEqual([
firstKey,
secondKey,
]);
expect(transformedProps.labelMap).toEqual({
[firstKey]: ['US', 'CA'],
[secondKey]: ['US', 'NY'],
});
expect(getEchartOptions(transformedProps).yAxis?.data).toEqual([
'US, CA',
'US, NY',
]);
});
test('dims unselected categories when a cross-filter is active', () => {
const transformedProps = transformProps(
createChartProps({
filterState: { selectedValues: [categoryKeyA] },
}),
);
const series = getEchartOptions(transformedProps).series ?? [];
expect(series[0]?.data?.[0]?.itemStyle?.opacity).toBe(
OpacityEnum.NonTransparent,
);
expect(series[0]?.data?.[1]?.itemStyle?.opacity).toBe(
OpacityEnum.SemiTransparent,
);
expect(series[1]?.data?.[1]?.itemStyle?.opacity).toBe(
OpacityEnum.SemiTransparent,
);
});
test('maps selectedValues to category indexes', () => {
const transformedProps = transformProps(
createChartProps({
filterState: { selectedValues: [categoryKeyB] },
}),
);
expect(transformedProps.selectedValues).toEqual({ 1: categoryKeyB });
});
@@ -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);
});
@@ -19,6 +19,7 @@
import {
CategoricalColorScale,
ChartProps,
NumberFormatter,
TimeGranularity,
getNumberFormatter,
} from '@superset-ui/core';
@@ -159,6 +160,72 @@ describe('transformSeries', () => {
expect((result as ScatterSeriesOption).symbolSize).toBe(7);
});
test('does not render a per-series stacked label for a zero-value segment (#42702)', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: true,
onlyTotal: false,
isHorizontal: false,
timeShiftColor: false,
// percentage_threshold defaults to 0, so thresholdValues[dataIndex] is
// 0 too — a value of exactly 0 would satisfy `numericValue >= (thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)`
// without the explicit `numericValue !== 0` guard.
thresholdValues: [0],
formatter: new NumberFormatter({
id: 'test-formatter',
formatFunc: (value: number) => `${value}`,
}),
};
const result = transformSeries(series, mockColorScale, 'test-key', opts);
const { formatter: labelFormatter } = (result as any).label;
const zeroValueLabel = labelFormatter({
value: [null, 0],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(zeroValueLabel).toBe('');
const nonZeroValueLabel = labelFormatter({
value: [null, 32],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(nonZeroValueLabel).toBe('32');
});
test('still renders a per-series stacked label for a genuine negative value that clears the threshold', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: true,
onlyTotal: false,
isHorizontal: false,
timeShiftColor: false,
// A category whose stacked total is itself negative produces a
// negative threshold — a strictly-positive check would wrongly
// suppress a real, meaningful negative-value label here.
thresholdValues: [-10],
formatter: new NumberFormatter({
id: 'test-formatter',
formatFunc: (value: number) => `${value}`,
}),
};
const result = transformSeries(series, mockColorScale, 'test-key', opts);
const { formatter: labelFormatter } = (result as any).label;
const negativeValueLabel = labelFormatter({
value: [null, -5],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(negativeValueLabel).toBe('-5');
});
});
describe('transformNegativeLabelsPosition', () => {
@@ -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) {
@@ -18,7 +18,7 @@
*/
import fetchMock from 'fetch-mock';
import { FeatureFlag, isFeatureEnabled, QueryState } from '@superset-ui/core';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import QueryHistory from 'src/SqlLab/components/QueryHistory';
import {
initialState,
@@ -252,6 +252,247 @@ test('displays multiple queries with newest query first', async () => {
isFeatureEnabledMock.mockClear();
});
// `sql` is never part of the merge's overlay bundle, so a merged row's `sql`
// always comes from the `{...remoteQuery}` base, whether or not an override
// happened. Every live-only Redux fixture below uses `sql: 'SELECT 1'`,
// while the backend snapshot uses this distinctive query text - so this can
// only resolve once the backend response has actually loaded *and* been
// folded into the rendered row, unlike `waitFor(() => calls.length === 1)`,
// which resolves as soon as the request is issued, while `data` is still
// `undefined` and the component is still rendering the pre-merge,
// Redux-only fallback. Deliberately not a Duration-cell/`endDttm` barrier:
// a real Redux row that has concluded always has an `endDttm` (see
// `QUERY_SUCCESS` in `reducers/sqlLab.ts`), so that barrier would silently
// go vacuous the moment a fixture became realistic about timestamps.
const findRemoteSqlCell = () => screen.findByText(/FCC 2018 Survey/);
// The barrier above holds only while the live fixture's sql differs from the
// snapshot's. If they ever match, findRemoteSqlCell() resolves pre-merge and
// every assertion after it goes vacuous. Fail loudly rather than silently.
const assertLiveSqlDiffersFromSnapshot = (q: { sql: string }) =>
expect(q.sql).not.toMatch(/FCC 2018 Survey/);
test('overrides a stale non-concluded backend snapshot with a concluded live Redux state', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
// A non-concluded row's `end_time` is never set by the backend (every
// write of `end_time` is paired with a concluded status - see
// `superset/sql_lab.py` and `superset/daos/query.py`). Note this maps to
// `endDttm: 0`, not `undefined` - `mapQueryResponse` does
// `Number(query.end_time)` and `Number(null) === 0`.
const staleApiResult = {
count: 1,
ids: [692],
result: [
{
...fakeApiResult.result[0],
client_id: 'stuckClientId',
status: QueryState.Running,
progress: 0,
rows: 0,
end_time: null,
sql_editor_id: defaultQueryEditor.id,
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, staleApiResult);
const stateWithLiveQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
stuckClientId: {
id: 'stuckClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Success,
startDttm: 1710273662445,
// A real Redux row at Success always has an endDttm too -
// QUERY_SUCCESS sets both together.
endDttm: 1710273662500,
progress: 100,
rows: 443,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithLiveQuery.sqlLab.queries.stuckClientId,
);
render(setup(), { useRedux: true, initialState: stateWithLiveQuery });
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const row = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(row).getByLabelText('check')).toBeInTheDocument();
expect(within(row).queryByLabelText('loading')).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('does not override an already-concluded backend snapshot with a non-concluded Redux state', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
const concludedApiResult = {
count: 1,
ids: [692],
result: [
{
...fakeApiResult.result[0],
client_id: 'scheduledClientId',
status: QueryState.Success,
progress: 100,
rows: 443,
sql_editor_id: defaultQueryEditor.id,
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, concludedApiResult);
// Redux hasn't observed this query conclude yet: it's still Scheduled.
// Deliberately not Running/Pending with progress 0, which is the tuple
// CLEAR_INACTIVE_QUERIES evicts once stale - that combination can't
// actually reach this merge in production.
const stateWithScheduledQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
scheduledClientId: {
id: 'scheduledClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Scheduled,
startDttm: 1710273662445,
progress: 0,
rows: 0,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithScheduledQuery.sqlLab.queries.scheduledClientId,
);
render(setup(), { useRedux: true, initialState: stateWithScheduledQuery });
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const row = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(row).getByLabelText('check')).toBeInTheDocument();
expect(within(row).queryByLabelText('loading')).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('renders a backend-only historical query the client never ran, alongside a live one', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
const twoRowApiResult = {
count: 2,
ids: [692, 700],
result: [
{
...fakeApiResult.result[0],
client_id: 'liveClientId',
status: QueryState.Running,
progress: 0,
rows: 0,
// Non-concluded: the backend never sets end_time for this status
// (maps to endDttm: 0, not undefined - see the comment above).
end_time: null,
sql_editor_id: defaultQueryEditor.id,
},
{
...fakeApiResult.result[0],
id: 700,
client_id: 'historicalOnlyClientId',
status: QueryState.Success,
progress: 100,
rows: 12,
sql_editor_id: defaultQueryEditor.id,
start_time: '1710273660000.000000',
// A different table than the live row's, so findRemoteSqlCell's
// target text is unique to that row, not duplicated on this one.
sql: 'SELECT * from "Population"',
executed_sql: 'SELECT * from "Population"\nLIMIT 1001',
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, twoRowApiResult);
const stateWithOnlyOneLiveQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
liveClientId: {
id: 'liveClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Success,
startDttm: 1710273662445,
// A real Redux row at Success always has an endDttm too -
// QUERY_SUCCESS sets both together.
endDttm: 1710273662500,
progress: 100,
rows: 443,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithOnlyOneLiveQuery.sqlLab.queries.liveClientId,
);
const { container } = render(setup(), {
useRedux: true,
initialState: stateWithOnlyOneLiveQuery,
});
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const tableRows = container.querySelectorAll(
'table > tbody > tr:not(.ant-table-measure-row)',
);
expect(tableRows).toHaveLength(2);
const liveRow = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(liveRow).getByLabelText('check')).toBeInTheDocument();
expect(within(liveRow).queryByLabelText('loading')).not.toBeInTheDocument();
const historicalRow = screen.getByText('12').closest('tr') as HTMLElement;
expect(within(historicalRow).getByLabelText('check')).toBeInTheDocument();
expect(
within(historicalRow).queryByLabelText('loading'),
).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('renders contributed toolbar action in queryHistory slot', () => {
registerToolbarAction(
ViewLocations.sqllab.queryHistory,
@@ -19,7 +19,6 @@
import { useEffect, useMemo, useState } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { useInView } from 'react-intersection-observer';
import { omit } from 'lodash-es';
import { EmptyState, Skeleton } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
@@ -31,6 +30,7 @@ import useEffectEvent from 'src/hooks/useEffectEvent';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewLocations } from 'src/SqlLab/contributions';
import { mergeQueryStatus } from './mergeQueryStatus';
interface QueryHistoryProps {
queryEditorId: string | number;
@@ -82,25 +82,26 @@ const QueryHistory = ({
skip: !isFeatureEnabled(FeatureFlag.SqllabBackendPersistence),
},
);
const editorQueries = useMemo(
() =>
data
? getEditorQueries(
omit(
queries,
data.result.map(({ id }) => id),
),
editorId,
)
.concat(data.result)
.sort((a, b) => {
const aTime = a.startDttm || 0;
const bTime = b.startDttm || 0;
return aTime - bTime;
})
: getEditorQueries(queries, editorId),
[queries, data, editorId],
);
const editorQueries = useMemo(() => {
if (!data) {
return getEditorQueries(queries, editorId);
}
const remoteIds = new Set(data.result.map(({ id }) => id));
const mergedRemoteQueries = data.result.map(remoteQuery => {
const localQuery = queries[remoteQuery.id];
return localQuery
? mergeQueryStatus(remoteQuery, localQuery)
: remoteQuery;
});
return getEditorQueries(queries, editorId)
.filter(({ id }) => !remoteIds.has(id))
.concat(mergedRemoteQueries)
.sort((a, b) => {
const aTime = a.startDttm || 0;
const bTime = b.startDttm || 0;
return aTime - bTime;
});
}, [queries, data, editorId]);
const loadNext = useEffectEvent(() => {
setPageIndex(pageIndex + 1);
@@ -0,0 +1,151 @@
/**
* 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 { QueryState, testQueryResponse } from '@superset-ui/core';
import { mergeQueryStatus } from './mergeQueryStatus';
// remoteBase and localBase deliberately differ on backend-only metadata
// (queryId, tab, executedSql) that the merge bundle never touches, not just
// on the seven bundle fields. If they only differed on the bundle fields,
// `{...remoteQuery, <bundle>}` and `{...localQuery, <bundle>}` would be
// structurally equal and toEqual/toBe could not tell a correct base from a
// wrong one — see the "keeps the snapshot's backend-only metadata" test
// below, which exists specifically to catch that class of regression.
const remoteBase = {
...testQueryResponse,
queryId: 692,
tab: 'Untitled Query 16',
executedSql: 'SELECT * from "FCC 2018 Survey"\nLIMIT 1001',
state: QueryState.Running,
progress: 0,
rows: 0,
startDttm: 1000,
endDttm: undefined as unknown as number,
resultsKey: null,
errorMessage: null,
};
const localBase = {
...testQueryResponse,
queryId: undefined as unknown as number,
tab: 'stale local tab',
executedSql: undefined as unknown as string,
state: QueryState.Success,
progress: 100,
rows: 443,
startDttm: 2000,
endDttm: 3000,
resultsKey: 'a-results-key',
errorMessage: null,
};
test('both non-concluded: returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Scheduled };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('remote concluded, local not: returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Success };
const local = { ...localBase, state: QueryState.Running };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('both concluded: declines to override, returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Success };
const local = { ...localBase, state: QueryState.Stopped };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('local concluded, remote not: local supplies status fields and both timestamps together', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Success };
expect(mergeQueryStatus(remote, local)).toEqual({
...remote,
state: QueryState.Success,
progress: 100,
rows: 443,
startDttm: 2000,
endDttm: 3000,
resultsKey: 'a-results-key',
errorMessage: null,
});
});
test('local concluded, remote not: undefined local fields fall back to the remote value', () => {
const remote = {
...remoteBase,
state: QueryState.Running,
startDttm: 1000,
endDttm: 1500,
resultsKey: 'remote-results-key',
errorMessage: 'remote error',
};
const local = {
...localBase,
state: QueryState.Success,
startDttm: undefined as unknown as number,
endDttm: undefined as unknown as number,
resultsKey: undefined as unknown as string,
errorMessage: undefined as unknown as string,
};
const merged = mergeQueryStatus(remote, local);
expect(merged.startDttm).toBe(1000);
expect(merged.endDttm).toBe(1500);
expect(merged.resultsKey).toBe('remote-results-key');
expect(merged.errorMessage).toBe('remote error');
});
test('local concluded, remote not: a null local field overrides a remote value (does not fall back)', () => {
const remote = {
...remoteBase,
state: QueryState.Running,
resultsKey: 'remote-results-key',
errorMessage: 'remote error',
};
const local = {
...localBase,
state: QueryState.Failed,
resultsKey: null,
errorMessage: null,
};
const merged = mergeQueryStatus(remote, local);
expect(merged.resultsKey).toBeNull();
expect(merged.errorMessage).toBeNull();
});
test('local concluded, remote not: keeps the snapshot-only metadata (queryId, tab, executedSql)', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Success };
const merged = mergeQueryStatus(remote, local);
expect(merged.queryId).toBe(692);
expect(merged.tab).toBe('Untitled Query 16');
expect(merged.executedSql).toBe(
'SELECT * from "FCC 2018 Survey"\nLIMIT 1001',
);
});
@@ -0,0 +1,71 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { concludedQueryStateList, QueryResponse } from '@superset-ui/core';
export const isConcludedState = (state: QueryResponse['state']) =>
concludedQueryStateList.includes(state);
// The backend history snapshot fetched by `useEditorQueriesQuery` is a
// one-shot fetch that is never invalidated, so it can strand a query at a
// non-terminal state forever once `QueryAutoRefresh` stops polling it (see
// `QueryAutoRefresh.MAX_QUERY_AGE_TO_POLL`). This function corrects only
// that one case: when the live Redux copy has concluded and the snapshot
// has not, the live row supplies the status fields and both timestamps
// together, since they must come from a single clock (the backend records
// both `startDttm`/`endDttm` in server time, while the client stamps
// `endDttm` from the browser clock when a query concludes locally). Every
// other combination — including both sides concluded, or the snapshot
// already concluded — returns the snapshot row unchanged.
export const mergeQueryStatus = (
remoteQuery: QueryResponse,
localQuery: QueryResponse,
): QueryResponse => {
if (
!isConcludedState(localQuery.state) ||
isConcludedState(remoteQuery.state)
) {
return remoteQuery;
}
return {
...remoteQuery,
state:
localQuery.state !== undefined ? localQuery.state : remoteQuery.state,
progress:
localQuery.progress !== undefined
? localQuery.progress
: remoteQuery.progress,
rows: localQuery.rows !== undefined ? localQuery.rows : remoteQuery.rows,
startDttm:
localQuery.startDttm !== undefined
? localQuery.startDttm
: remoteQuery.startDttm,
endDttm:
localQuery.endDttm !== undefined
? localQuery.endDttm
: remoteQuery.endDttm,
resultsKey:
localQuery.resultsKey !== undefined
? localQuery.resultsKey
: remoteQuery.resultsKey,
errorMessage:
localQuery.errorMessage !== undefined
? localQuery.errorMessage
: remoteQuery.errorMessage,
};
};
@@ -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,
};
});
}
@@ -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));
@@ -19,7 +19,12 @@
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { getExtensionsRegistry, VizType } from '@superset-ui/core';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
fireEvent,
render,
screen,
userEvent,
} from 'spec/helpers/testing-library';
import {
enableMobileConsumptionFlag,
mockMobileMatchMedia,
@@ -106,6 +111,13 @@ jest.mock('src/dashboard/components/FiltersBadge', () => ({
),
}));
jest.mock('./SliceInfo', () => ({
__esModule: true,
default: ({ slice }: { slice: { description: string } }) => (
<div data-test="slice-info">{slice.description}</div>
),
}));
jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: jest.fn().mockReturnValue(false),
}));
@@ -571,6 +583,172 @@ test('Correct actions to "SliceHeaderControls"', () => {
expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1);
});
test('Should show chart description info icon when description exists and is collapsed', () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(screen.getByTestId('chart-description-info-icon')).toBeInTheDocument();
});
test('Should hide chart description info icon when description is expanded', () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: true,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(
screen.queryByTestId('chart-description-info-icon'),
).not.toBeInTheDocument();
});
test('Should hide chart description info icon when chart has no description', () => {
const props = createProps({
slice: {
...createProps().slice,
description: '',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(
screen.queryByTestId('chart-description-info-icon'),
).not.toBeInTheDocument();
});
test('Chart description icon is a keyboard-focusable button', () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
const icon = screen.getByRole('button', { name: 'Chart description' });
icon.focus();
expect(icon).toHaveFocus();
});
test('Should show chart description in popover on hover', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
await userEvent.hover(screen.getByTestId('chart-description-info-icon'));
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Should show chart description in popover on click', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId('chart-description-info-icon'));
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Should open chart description popover with Enter', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
const icon = screen.getByRole('button', { name: 'Chart description' });
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
// user-event v12 (pinned in this repo) doesn't expose .keyboard(); use
// fireEvent to dispatch keydown directly to the focused icon.
icon.focus();
fireEvent.keyDown(icon, { key: 'Enter' });
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Should open chart description popover with Space', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
const icon = screen.getByRole('button', { name: 'Chart description' });
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
icon.focus();
fireEvent.keyDown(icon, { key: ' ' });
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Add extension to SliceHeader', () => {
const extensionsRegistry = getExtensionsRegistry();
extensionsRegistry.set('dashboard.slice.header', () => (
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import SliceInfo from './SliceInfo';
jest.mock('@superset-ui/core/components/SafeMarkdown/SafeMarkdown', () => ({
SafeMarkdown: ({ source }: { source: string }) => (
<div data-test="safe-markdown">{source}</div>
),
}));
const setup = (description = 'Default description') =>
render(<SliceInfo slice={{ description }} />);
test('Should render chart description', () => {
setup('Hello world');
expect(screen.getByTestId('safe-markdown')).toHaveTextContent('Hello world');
});
test('Should pass markdown source to SafeMarkdown', () => {
const markdown = [
'# Chart overview',
'',
'This chart shows **revenue** by region.',
'',
'- North',
'- South',
'',
'[Learn more](https://superset.apache.org)',
].join('\n');
setup(markdown);
expect(screen.getByTestId('safe-markdown').textContent).toBe(markdown);
});
test('Should render long markdown description without crashing', () => {
const longMarkdown = `# Summary\n\n${'Long description paragraph. '.repeat(100)}`;
setup(longMarkdown);
const content = screen.getByTestId('safe-markdown').textContent ?? '';
expect(content).toContain('# Summary');
expect(content.match(/Long description paragraph\./g)).toHaveLength(100);
});
test('Should render empty description without crashing', () => {
setup('');
expect(screen.getByTestId('safe-markdown')).toBeEmptyDOMElement();
});

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