mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cadf39ce8 | ||
|
|
876b8641e2 |
@@ -24,6 +24,14 @@ 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.
|
||||
@@ -79,11 +87,6 @@ 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:
|
||||
|
||||
@@ -66,27 +66,6 @@ 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: |
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
node-version-file: "./docs/.nvmrc"
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
|
||||
@@ -97,7 +97,6 @@ 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"
|
||||
|
||||
@@ -88,9 +88,9 @@ repos:
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$
|
||||
- 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'
|
||||
- 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'
|
||||
language: system
|
||||
pass_filenames: true
|
||||
files: ^docs/.*\.(js|jsx|ts|tsx)$
|
||||
|
||||
@@ -441,11 +441,6 @@ categories:
|
||||
url: https://bestpair.info/
|
||||
contributors: ["@stevensuting"]
|
||||
|
||||
- name: Veremes
|
||||
url: https://www.veremes.com/
|
||||
logo: veremes.svg
|
||||
contributors: ["@verdier"]
|
||||
|
||||
- name: Virtuoso QA
|
||||
url: https://www.virtuosoqa.com
|
||||
|
||||
|
||||
+3
-3
@@ -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 lint # Lint TypeScript/JavaScript files
|
||||
yarn typecheck # TypeScript validation
|
||||
yarn eslint # Lint TypeScript/JavaScript files
|
||||
```
|
||||
|
||||
## 📁 Documentation Structure
|
||||
@@ -431,7 +431,7 @@ yarn build
|
||||
yarn typecheck
|
||||
|
||||
# Linting issues
|
||||
yarn lint
|
||||
yarn eslint
|
||||
```
|
||||
|
||||
### Version Issues
|
||||
|
||||
@@ -114,8 +114,8 @@ function MyExtension() {
|
||||
|
||||
## Source Links
|
||||
|
||||
- [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)
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/components/`
|
||||
2. Export it from `superset-frontend/packages/superset-core/src/components/index.ts`
|
||||
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`
|
||||
3. Create a Storybook story with an `Interactive` export:
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -78,18 +78,6 @@ 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:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,139 +0,0 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+13
-7
@@ -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",
|
||||
"lint": "oxlint --config oxlint.json",
|
||||
"eslint": "eslint .",
|
||||
"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.18",
|
||||
"baseline-browser-mapping": "^2.11.16",
|
||||
"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.3.0",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.10",
|
||||
"swagger-ui-react": "^5.32.14",
|
||||
"swc-loader": "^0.2.7",
|
||||
@@ -85,13 +85,19 @@
|
||||
},
|
||||
"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",
|
||||
"oxlint": "^1.80.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"typescript": "7.0.2",
|
||||
"webpack": "^5.110.1"
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"webpack": "^5.109.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
Vendored
-51
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 238 55" style="enable-background:new 0 0 238 55;" xml:space="preserve">
|
||||
<path d="M234.9,34.8c-0.9-1.3-2.2-2.2-3.6-2.8c-0.4-0.2-1.2-0.4-2.5-0.8c-1-0.3-1.9-0.6-2.8-1.1c-0.6-0.3-1.1-0.8-1.5-1.4
|
||||
c-0.3-0.6-0.5-1.3-0.5-1.9c0-1,0.4-2,1.1-2.7c0.7-0.7,1.7-1.1,2.8-1.1c1.1-0.1,2.1,0.3,2.9,1c0.8,0.8,1.2,1.8,1.2,2.9h3.6
|
||||
c0-2-0.7-3.9-2.1-5.3c-1.5-1.3-3.4-2-5.4-1.9c-2,0-4,0.7-5.5,2.1c-1.5,1.3-2.3,3.2-2.3,5.2c-0.1,1.5,0.4,3.1,1.3,4.3
|
||||
c0.9,1.1,2.8,2.1,5.8,3.1c1.4,0.4,2.7,1.2,3.8,2.1c0.7,0.8,1.1,1.9,1.1,3c0,1.2-0.5,2.3-1.3,3.1c-0.9,0.8-2.1,1.3-3.3,1.2
|
||||
c-1.4,0-2.6-0.6-3.5-1.6c-1-1.1-1.5-2.6-1.4-4.1V38h-3.5c-0.1,2.4,0.8,4.7,2.4,6.5c1.5,1.6,3.7,2.5,6,2.4c2.2,0.1,4.4-0.7,6.1-2.2
|
||||
c1.6-1.4,2.5-3.5,2.4-5.6C236.2,37.6,235.8,36.1,234.9,34.8z"/>
|
||||
<path d="M117,31.4c0.3-2.4,1.5-4.7,3.3-6.4c1.8-1.6,4.1-2.4,6.5-2.4c2.4-0.1,4.8,0.7,6.7,2.2c1.9,1.8,3.1,4.1,3.6,6.6L117,31.4z
|
||||
M140.9,34.1c0-4.3-1.3-7.9-3.8-10.6s-6.1-4.2-9.8-4.1c-3.7-0.1-7.4,1.3-10,3.9s-4,6.2-3.9,9.9c-0.1,3.7,1.4,7.2,4,9.8
|
||||
c2.5,2.7,6.1,4.1,9.8,4.1c2.7,0.1,5.4-0.8,7.7-2.3c2.3-1.7,4.1-4,5.2-6.7h-3.8c-0.8,1.7-2.1,3.2-3.7,4.2c-1.6,1-3.5,1.6-5.4,1.5
|
||||
c-2.6,0.1-5.1-0.9-7-2.6c-1.9-1.8-3-4.3-3.1-6.9h24L140.9,34.1z"/>
|
||||
<path d="M175.1,19.6c-1.9,0-3.7,0.4-5.4,1.2c-1.6,0.8-3,2-4,3.5c-0.9-1.5-2.2-2.6-3.8-3.4c-1.7-0.8-3.6-1.3-5.5-1.3
|
||||
c-1.6,0-3.2,0.3-4.7,0.9c-1.4,0.6-2.6,1.5-3.6,2.6v-2.9h-3.4v26.2h3.3V33.6c0-1.6,0-3.2,0.2-4.8c0.2-0.9,0.4-1.7,0.9-2.4
|
||||
c0.6-1.1,1.6-2,2.8-2.6c1.3-0.6,2.7-1,4.1-0.9c2.6,0,4.5,0.7,5.7,2.2c1.2,1.4,1.9,3.7,1.9,6.7v14.7h3.3V33.6c0-1.6,0-3.2,0.3-4.8
|
||||
c0.2-0.9,0.5-1.7,0.9-2.4c0.6-1.1,1.6-2,2.7-2.6c1.2-0.6,2.6-1,4-0.9c2.6,0,4.5,0.7,5.7,2.2s1.7,3.9,1.7,7.4v13.9h3.3V33.1
|
||||
c0-4.7-0.8-8.2-2.5-10.3S178.7,19.6,175.1,19.6z"/>
|
||||
<path d="M193.1,31.4c0.3-2.4,1.5-4.7,3.3-6.4c1.8-1.6,4.1-2.4,6.5-2.4c2.4-0.1,4.9,0.7,6.8,2.2c1.9,1.8,3.1,4.1,3.5,6.6L193.1,31.4z
|
||||
M216.9,34.1c0-4.3-1.3-7.9-3.8-10.6s-6.1-4.2-9.8-4.1c-3.7-0.1-7.4,1.3-10,3.9s-4,6.2-3.9,9.9c-0.1,3.7,1.4,7.2,4,9.8
|
||||
c2.5,2.7,6.1,4.2,9.8,4.1c2.7,0,5.4-0.8,7.7-2.3c2.3-1.7,4.1-4,5.2-6.7h-3.8c-0.8,1.7-2.1,3.2-3.8,4.2c-1.6,1-3.5,1.5-5.4,1.5
|
||||
c-2.6,0.1-5.2-0.9-7.1-2.6c-1.9-1.8-3-4.3-3.1-6.9h24L216.9,34.1L216.9,34.1z"/>
|
||||
<path d="M108.4,20.7c-1.1,0.7-2,1.5-2.7,2.6v-3.1h-3.2v26.2h3.5V30.6c0-2.4,0.5-4.1,1.4-5.2c0.9-1.1,2.4-1.7,4.6-1.9v-3.7
|
||||
C110.6,19.8,109.4,20.1,108.4,20.7z"/>
|
||||
<path d="M74.6,31.4c0.3-2.4,1.5-4.7,3.3-6.4c1.8-1.6,4.1-2.4,6.5-2.4c2.4-0.1,4.8,0.7,6.7,2.2c1.9,1.8,3.1,4.1,3.5,6.6L74.6,31.4z
|
||||
M98.5,34.1c0-4.3-1.3-7.9-3.8-10.6s-6.1-4.2-9.8-4.1c-3.7-0.1-7.4,1.3-10,3.9s-4,6.2-3.9,9.9c-0.1,3.7,1.4,7.2,4,9.8
|
||||
c2.5,2.7,6.1,4.1,9.8,4.1c2.7,0,5.4-0.8,7.7-2.3c2.3-1.7,4.1-4,5.1-6.7h-3.8c-0.8,1.7-2.1,3.2-3.7,4.2s-3.5,1.5-5.4,1.5
|
||||
c-2.6,0.1-5.1-0.9-7-2.6c-1.9-1.8-3-4.3-3.1-6.9h24L98.5,34.1z"/>
|
||||
<path d="M47.4,11.7c-1.7-4-4.2-8.4-8.5-10.1C34.2,0,29.1,1,25.4,4.2c-2.7,2.4-4.3,6.1-4.8,11L20.4,16c-0.1,1.3-0.3,2.6-0.3,3.8
|
||||
c-0.8-3.5-0.5-5.9-0.7-9.7c-0.8,0.4-1.2,1.2-1.2,2.1c0,0.2-1-0.4-1.2-0.3c-0.8,0.4-1.6-1-2-1.5c-0.2,0.4-0.4,0.7-0.7,1.1
|
||||
C14,11,13.5,11,13,10.5c-0.4,1-1.6,0.5-2.7,0.5c0.6,1,0.8,2-0.1,2.4c0.1,0.1,0.9,0.3,0.9,0.5c-0.3,0.4-1.3,0-1.8-0.1v0.8
|
||||
c-1.4-0.8-3.7-2.3-2.9,0.9c-1,0.2-0.5,0-0.5,0.9c-0.4,0.1-0.4,0.1-0.4,0.6c-1.2-0.6-2.2,4.9-2.1,6.5c0.9,0.2,1,0.5,1.5,1.4
|
||||
c-0.7,0.3-1.1,1-1.6,1.3L4,26.6c-0.6,0.3-1.1,0.7-1.5,1.3c0.2-0.3,0.6,0.4,0.5,0.3L2.6,28c0.1,0.4,0.3,0.8,0.4,1.2
|
||||
c-0.8,0.3-0.6,0.6-1.1,1.3c0.2,0,0.5,0.1,0.7,0.1c-0.4,0-0.5,1.9-0.4,2.2c0.2-0.4,0.6-0.9,0.8-1.4l0.5,0.5l-0.7,0.6
|
||||
c1.8,0.5-0.2,1.7,0.6,3.1C3.6,34.8,4,34.7,4,33.8l0.4,0.3c-0.2,0.3-1.4,2.6-0.3,2.6c0.1,0,0.1,3.8,0.3,4.8C4.6,41.3,4.8,41,5,40.6
|
||||
c0,0.6,0.3,0.8,0.1,1.5c1.8-0.5,1.1,0.5,0.4,1.4c0.6-0.1,1.2-0.3,1.8-0.5c0.8-0.3,0.1,1.1,0.4,1.1c0.3,0,1.2-1.8,1.4-2.1v0.6
|
||||
c0.4-0.2,1.8-2,2-2c0.2,0.3,0.3,0.7,0.2,1.1c1.6-2,3-4.2,4-6.6c0.1,0.1,0.2,0.2,0.4,0.2c-0.2,0-4.1,7.7-4.1,8.1l0.9-0.2
|
||||
c-0.3,0.6-0.5,1.2-0.6,1.8c1.6-0.2,0.9,1.2,0.9,2.5c1.3-0.8,3.2-1.3,2.5,0.9c0.4-0.2,0.9-0.4,1.3-0.6c-1.1,0.4,0.5,2.6,0.7,3.2
|
||||
c0.2,0.7,2.3,0.2,3,0.4c0.7-1.1,1,0.2,1.2,1.3s1.4-0.6,1.9-0.4c0.4,0.1-0.3,2.7,1.2,1.6c0.6-0.5,0.8,0.4,1.7-0.5
|
||||
c0,0,2.7,0.4,2.6,0.4c0.5-2.6,2.5-0.1,2.2-2.4h0.6c-0.1-2,2,0.9,2-2c0-0.7-1.6-1.5,0.4-0.9c-0.2-0.9,0.2-1.1-0.8-1.3
|
||||
c-0.1-0.2-0.1-0.4,0.1-0.5c0.9,0,2.5,1.7,3.1,0.9c0.2-0.2-0.8-2-0.9-2.5c0.5,0.1,1.1-0.1,1.6,0c-0.9-0.5-0.3-0.6-1.4-0.9
|
||||
c0.9-1.7,2.6-0.1,3.4-1.4c-1.7,0.3-2.6-3.3-1.5-3.6c-0.9-0.8-1.6-1-2.3-1.9c-1.8-2.3,1.7,0.4,2.3,0.9c0-0.4,0.2-0.9,0.2-1.3l0.9,0.8
|
||||
c0-0.2,0.1-0.4,0.1-0.6c0.8,0.7,1.9,1,2.9,0.9c-0.2-0.5,0-0.6-0.2-1.1c0.8,0.2,1.3,0,2.2,0.1c-0.6-1.8,1.5-1.7,2.9-2.1
|
||||
c2.3-0.7-1.4-1-1.6-1.2c-0.4-0.4,0.5-0.9,0.6-0.9s-0.8-1.1-0.6-0.8c-0.3-1-2.6-0.3-0.6-1.6c-0.9-0.2-2.1-0.4-2.1-1.6
|
||||
c0.7-0.1,1.5-0.2,2.2-0.4c-0.5-0.4-0.9-1.1-1.5-1.4l0.5-0.2c-2.8-0.5,0.3-2.7,1-4c-1.9,0.4-2-1.1-3.7-1.3l0.8-0.7
|
||||
c-0.9,0-1.9-0.1-2.8-0.3c0.2-1,0.9-1.9,1.9-2.2c-3.1-1.5-2.4-3.8-5.6-4.7l0.7-0.7c-0.9-1.3-1.6-0.4-2.6-0.7
|
||||
c-2.1-0.6-1.9,2.3-1.9-1.1c0,0.4-0.5-0.4-0.6-0.6c-0.8,0.5-3.4,2.6-3.9,2.1C26.1,12,26,12.2,25.5,13c-0.3,0.3-1.3,0.9-1.1,0.5
|
||||
c-0.6,0.9-0.6,4.3-1.3,6.6c0-1.2,0.2-2.4,0.3-3.6l0.1-0.9c0.4-4.1,1.7-7.1,3.8-8.9c2.8-2.4,6.7-3.1,10.3-2c3.4,1.2,5.5,5.4,7,9.1
|
||||
L57,46.5h2.7l13.2-34.7h-3.5L58.5,41.2L47.4,11.7z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
+6
-23
@@ -1,30 +1,14 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
// "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.
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
// "esModuleInterop": true,
|
||||
// "jsx": "preserve",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
// "moduleResolution": "bundler",
|
||||
"module": "esnext",
|
||||
"noEmit": true,
|
||||
// "paths": {
|
||||
// "@site/*": ["./*"]
|
||||
// },
|
||||
// "skipLibCheck": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"skipLibCheck": true,
|
||||
"noImplicitAny": false,
|
||||
"strict": false,
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["@docusaurus/module-type-aliases"],
|
||||
@@ -39,10 +23,9 @@
|
||||
// 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"],
|
||||
"@site/*": ["./*"],
|
||||
"*": ["./src/*", "./node_modules/*"]
|
||||
"*": ["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/**"]
|
||||
}
|
||||
|
||||
+1255
-360
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=84.0.0", "wheel"]
|
||||
requires = ["setuptools>=40.9.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.5, >=3.5.5",
|
||||
"greenlet<=3.5.4, >=3.5.4",
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
|
||||
@@ -52,11 +52,11 @@ marshmallow-sqlalchemy>=1.5.0
|
||||
# needed for python 3.12 support
|
||||
openapi-schema-validator>=0.6.3
|
||||
|
||||
# Pin setuptools <85 until all dependencies migrate from pkg_resources to importlib.metadata
|
||||
# Pin setuptools <81 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<85
|
||||
setuptools<81
|
||||
|
||||
# google-auth 2.53+ dropped its transitive dependency on cachetools, which is
|
||||
# imported directly by superset.db_engine_specs.aws_iam. We declare cachetools
|
||||
|
||||
@@ -163,7 +163,7 @@ google-auth==2.53.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# shillelagh
|
||||
greenlet==3.5.5
|
||||
greenlet==3.5.4
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
@@ -366,7 +366,7 @@ rpds-py==0.25.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
setuptools==84.0.0
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
|
||||
@@ -375,7 +375,7 @@ googleapis-common-protos==1.66.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
greenlet==3.5.5
|
||||
greenlet==3.5.4
|
||||
# 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==26.2.1
|
||||
pip==25.1.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==84.0.0
|
||||
setuptools==80.9.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# nodeenv
|
||||
|
||||
@@ -390,3 +390,19 @@ 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,3 +183,10 @@ def prompt(
|
||||
"MCP prompt decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"tool",
|
||||
"prompt",
|
||||
"ToolAnnotations",
|
||||
]
|
||||
|
||||
@@ -55,3 +55,9 @@ class SavedQueryDAO(BaseDAO[SavedQuery]):
|
||||
model_cls = None
|
||||
base_filter = None
|
||||
id_column_name = "id"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QueryDAO",
|
||||
"SavedQueryDAO",
|
||||
]
|
||||
|
||||
@@ -71,3 +71,9 @@ class SavedQuery(CoreModel):
|
||||
database_id: int | None
|
||||
description: str | None
|
||||
user_id: int | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Query",
|
||||
"SavedQuery",
|
||||
]
|
||||
|
||||
@@ -46,3 +46,6 @@ 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,3 +165,13 @@ class AsyncQueryHandle:
|
||||
:returns: True if cancellation was successful
|
||||
"""
|
||||
raise NotImplementedError("Method will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"QueryStatus",
|
||||
"QueryOptions",
|
||||
"QueryResult",
|
||||
"StatementResult",
|
||||
"AsyncQueryHandle",
|
||||
"CacheOptions",
|
||||
]
|
||||
|
||||
@@ -27,3 +27,6 @@ class RestApi(BaseApi):
|
||||
"""
|
||||
|
||||
allow_browser_login = True
|
||||
|
||||
|
||||
__all__ = ["RestApi"]
|
||||
|
||||
@@ -98,3 +98,6 @@ def api(
|
||||
"API decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["api"]
|
||||
|
||||
@@ -164,3 +164,6 @@ class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
|
||||
:return: SemanticViewModel instance or None
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
|
||||
|
||||
@@ -97,3 +97,6 @@ def semantic_layer(
|
||||
"Semantic layer decorator not initialized. "
|
||||
"This decorator should be replaced during Superset startup."
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["semantic_layer"]
|
||||
|
||||
@@ -80,3 +80,6 @@ class SemanticViewModel(CoreModel):
|
||||
semantic_layer_uuid: UUID
|
||||
created_on: datetime | None
|
||||
changed_on: datetime | None
|
||||
|
||||
|
||||
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
|
||||
|
||||
@@ -71,3 +71,6 @@ class TaskDAO(BaseDAO[Task]):
|
||||
:returns: Task instance or None if not found or not active
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = ["TaskDAO"]
|
||||
|
||||
@@ -144,3 +144,9 @@ def get_context() -> TaskContext:
|
||||
)
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"task",
|
||||
"get_context",
|
||||
]
|
||||
|
||||
@@ -161,3 +161,9 @@ class TaskSubscriber(CoreModel):
|
||||
changed_on: datetime | None
|
||||
created_by_fk: int | None
|
||||
changed_by_fk: int | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Task",
|
||||
"TaskSubscriber",
|
||||
]
|
||||
|
||||
@@ -226,3 +226,12 @@ class TaskContext(ABC):
|
||||
cleanup_partial_work()
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TaskStatus",
|
||||
"TaskScope",
|
||||
"TaskProperties",
|
||||
"TaskContext",
|
||||
"TaskOptions",
|
||||
]
|
||||
|
||||
+103
-54
@@ -2086,6 +2086,14 @@
|
||||
"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",
|
||||
@@ -2099,6 +2107,18 @@
|
||||
"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",
|
||||
@@ -3195,9 +3215,9 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -3814,13 +3834,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"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"
|
||||
}
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
|
||||
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "1.4.5",
|
||||
@@ -4301,6 +4317,18 @@
|
||||
"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",
|
||||
@@ -4826,9 +4854,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -5558,19 +5586,9 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -7830,6 +7848,11 @@
|
||||
"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",
|
||||
@@ -7992,9 +8015,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -9819,7 +9842,7 @@
|
||||
"debug": "4.4.0",
|
||||
"execa": "4.1.0",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"js-yaml": "4.1.1",
|
||||
"nyc": "15.1.0",
|
||||
"tinyglobby": "^0.2.14"
|
||||
},
|
||||
@@ -10098,7 +10121,7 @@
|
||||
"requires": {
|
||||
"@eslint/object-schema": "^3.0.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": ">=10"
|
||||
"minimatch": "^10.2.4"
|
||||
}
|
||||
},
|
||||
"@eslint/config-helpers": {
|
||||
@@ -10190,10 +10213,18 @@
|
||||
"camelcase": "^5.3.1",
|
||||
"find-up": "^4.1.0",
|
||||
"get-package-type": "^0.1.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"js-yaml": "4.1.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",
|
||||
@@ -10203,6 +10234,14 @@
|
||||
"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",
|
||||
@@ -11146,9 +11185,9 @@
|
||||
"integrity": "sha512-Fc8Ne62jJlKHiG/ajlonC4Sd66Pq68fFwK4ihJGNZpGqboc324SQk+lRvMzpPRuJOmfrJefdG8/7JdWX4bzJ2Q=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
@@ -11591,9 +11630,9 @@
|
||||
}
|
||||
},
|
||||
"d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
|
||||
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
|
||||
},
|
||||
"d3-format": {
|
||||
"version": "1.4.5",
|
||||
@@ -11605,7 +11644,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
|
||||
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
|
||||
"requires": {
|
||||
"d3-color": "3.1.0"
|
||||
"d3-color": "1"
|
||||
}
|
||||
},
|
||||
"d3-scale": {
|
||||
@@ -11853,7 +11892,7 @@
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": ">=10",
|
||||
"minimatch": "^10.2.4",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
@@ -11942,6 +11981,11 @@
|
||||
"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",
|
||||
@@ -12290,7 +12334,7 @@
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "<10",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
@@ -12301,9 +12345,9 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -12314,7 +12358,7 @@
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"requires": {
|
||||
"brace-expansion": "1.1.18"
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12808,9 +12852,9 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"requires": {
|
||||
"argparse": "^2.0.1"
|
||||
}
|
||||
@@ -13391,7 +13435,7 @@
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"brace-expansion": ">=5.0.9"
|
||||
"brace-expansion": "^5.0.5"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
@@ -14339,6 +14383,11 @@
|
||||
"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",
|
||||
@@ -14445,7 +14494,7 @@
|
||||
"requires": {
|
||||
"@istanbuljs/schema": "^0.1.2",
|
||||
"glob": "^7.1.4",
|
||||
"minimatch": "<10"
|
||||
"minimatch": "^3.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"balanced-match": {
|
||||
@@ -14454,9 +14503,9 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -14467,7 +14516,7 @@
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"requires": {
|
||||
"brace-expansion": "1.1.18"
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,20 +30,14 @@
|
||||
"overrides": {
|
||||
"@babel/core": "^7.29.6",
|
||||
"@cypress/code-coverage": {
|
||||
"js-yaml": "4.3.1"
|
||||
"js-yaml": "4.1.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.9"
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"qs": "^6.14.2",
|
||||
"uuid": "^11.1.1"
|
||||
|
||||
@@ -18,19 +18,6 @@
|
||||
*/
|
||||
// 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.
|
||||
@@ -90,7 +77,7 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
|
||||
'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)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
@@ -101,6 +88,14 @@ module.exports = {
|
||||
__DEV__: true,
|
||||
caches: true,
|
||||
},
|
||||
reporters: reporters,
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'./node_modules/jest-html-reporter',
|
||||
{
|
||||
pageTitle: 'Test Report',
|
||||
},
|
||||
],
|
||||
],
|
||||
testTimeout: 20000,
|
||||
};
|
||||
|
||||
Generated
+952
-870
File diff suppressed because it is too large
Load Diff
@@ -146,6 +146,7 @@
|
||||
"@superset-ui/plugin-chart-world-map": "file:./plugins/plugin-chart-world-map",
|
||||
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
|
||||
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
|
||||
"@types/d3-format": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
@@ -160,7 +161,7 @@
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^3.0.0",
|
||||
"content-disposition": "^2.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
@@ -255,7 +256,7 @@
|
||||
"@formatjs/intl-durationformat": "^0.10.18",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.3",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.10",
|
||||
"@storybook/addon-links": "10.5.10",
|
||||
"@storybook/react-webpack5": "10.5.10",
|
||||
@@ -276,7 +277,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -294,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.18",
|
||||
"baseline-browser-mapping": "^2.11.16",
|
||||
"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.9.0",
|
||||
"eslint": "^10.8.1",
|
||||
"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",
|
||||
@@ -330,8 +331,8 @@
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.65.0",
|
||||
"oxlint": "^1.80.0",
|
||||
"oxfmt": "^0.64.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -353,9 +354,9 @@
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
"webpack": "^5.109.2",
|
||||
"webpack-bundle-analyzer": "^5.3.2",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-dev-server": "^6.0.0",
|
||||
"webpack-bundle-analyzer": "^5.3.1",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.5",
|
||||
"webpack-manifest-plugin": "^6.0.1",
|
||||
"webpack-sources": "^3.5.1",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
@@ -382,9 +383,6 @@
|
||||
"@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",
|
||||
@@ -394,9 +392,6 @@
|
||||
"@luma.gl/shadertools": "~9.2.5",
|
||||
"@luma.gl/webgl": "~9.2.5",
|
||||
"core-js": "^3.38.1",
|
||||
"cosmiconfig": {
|
||||
"js-yaml": "^4.3.1"
|
||||
},
|
||||
"dompurify": "^3.4.13",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint-plugin-import": {
|
||||
@@ -413,22 +408,16 @@
|
||||
"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.1"
|
||||
"js-yaml": "^4.3.0"
|
||||
},
|
||||
"minimatch@>=10": {
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nanoid@>=3 <4": "3.3.18",
|
||||
"nwsapi": "^2.2.24",
|
||||
"nwsapi": "^2.2.13",
|
||||
"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.3.0",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@types/d3-format": "^3.0.4",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^4.0.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
@@ -90,20 +89,21 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"reselect": "^5.3.0",
|
||||
"reselect": "^5.2.0",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/d3-format": "^3.0.4",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-time": "^3.0.4",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.3.0",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -25,7 +25,6 @@ export enum VizType {
|
||||
BoxPlot = 'box_plot',
|
||||
Bubble = 'bubble_v2',
|
||||
Bullet = 'bullet',
|
||||
Butterfly = 'butterfly',
|
||||
Calendar = 'cal_heatmap',
|
||||
Cartodiagram = 'cartodiagram',
|
||||
Chord = 'chord',
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
export { default as NumberFormats } from './NumberFormats';
|
||||
export { default as NumberFormatter, PREVIEW_VALUE } from './NumberFormatter';
|
||||
export { formatSpecifier } from 'd3-format';
|
||||
export type { FormatLocaleDefinition } from 'd3-format';
|
||||
export { DEFAULT_D3_FORMAT } from './D3FormatConfig';
|
||||
|
||||
export {
|
||||
|
||||
@@ -47,8 +47,6 @@ 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,7 +30,6 @@ 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)
|
||||
@@ -165,13 +164,6 @@ 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
|
||||
*/
|
||||
@@ -270,9 +262,6 @@ 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;
|
||||
|
||||
@@ -59,16 +59,6 @@ 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'],
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
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,
|
||||
};
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB |
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
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;
|
||||
+15
-27
@@ -73,8 +73,6 @@ import {
|
||||
getLegendProps,
|
||||
getMinAndMaxFromBounds,
|
||||
getOverMaxHiddenFormatter,
|
||||
getTemporalAxisTickConfig,
|
||||
resolveTemporalTickValues,
|
||||
} from '../utils/series';
|
||||
import { resolveLegendLayout } from '../utils/legendLayout';
|
||||
import {
|
||||
@@ -766,26 +764,6 @@ export default function transformProps(
|
||||
const { setDataMask = () => {}, onContextMenu } = hooks;
|
||||
const alignTicks = yAxisIndex !== yAxisIndexB;
|
||||
|
||||
// Both queries share the axis, so a bucket contributed by either needs a tick.
|
||||
const temporalTickValues = resolveTemporalTickValues(
|
||||
[...rebasedDataA, ...rebasedDataB],
|
||||
xAxisLabel,
|
||||
xAxisType,
|
||||
resolvedTimeGrain,
|
||||
annotationLayers,
|
||||
);
|
||||
|
||||
const temporalAxisTickConfig = getTemporalAxisTickConfig(
|
||||
temporalTickValues,
|
||||
showMaxLabel,
|
||||
xAxisType,
|
||||
xAxisLabelRotation,
|
||||
xAxisLabelInterval,
|
||||
deduplicatedFormatter,
|
||||
false,
|
||||
zoomable,
|
||||
);
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
useUTC: true,
|
||||
grid: {
|
||||
@@ -797,12 +775,22 @@ export default function transformProps(
|
||||
name: xAxisTitle,
|
||||
nameGap: xAxisTitleMarginPx,
|
||||
nameLocation: 'middle',
|
||||
...temporalAxisTickConfig,
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: {
|
||||
...temporalAxisTickConfig.axisTick,
|
||||
show: axisTicks ? 'auto' : false,
|
||||
axisLabel: {
|
||||
hideOverlap: showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
formatter: deduplicatedFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
...(showMaxLabel && {
|
||||
showMaxLabel: true,
|
||||
alignMaxLabel: 'right',
|
||||
showMinLabel: true,
|
||||
alignMinLabel: 'left',
|
||||
}),
|
||||
},
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
...(gridlines ? {} : { splitLine: { show: false } }),
|
||||
minInterval:
|
||||
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
|
||||
|
||||
@@ -88,8 +88,6 @@ import {
|
||||
getHorizontalLegendAvailableWidth,
|
||||
getLegendProps,
|
||||
getMinAndMaxFromBounds,
|
||||
getTemporalAxisTickConfig,
|
||||
resolveTemporalTickValues,
|
||||
} from '../utils/series';
|
||||
import { resolveLegendLayout } from '../utils/legendLayout';
|
||||
import {
|
||||
@@ -1249,25 +1247,6 @@ export default function transformProps(
|
||||
})()
|
||||
: xAxisFormatter;
|
||||
|
||||
const temporalTickValues = resolveTemporalTickValues(
|
||||
rebasedData,
|
||||
xAxisLabel,
|
||||
xAxisType,
|
||||
resolvedTimeGrain,
|
||||
annotationLayers,
|
||||
);
|
||||
|
||||
const temporalAxisTickConfig = getTemporalAxisTickConfig(
|
||||
temporalTickValues,
|
||||
showMaxLabel,
|
||||
xAxisType,
|
||||
xAxisLabelRotation,
|
||||
xAxisLabelInterval,
|
||||
deduplicatedFormatter,
|
||||
isHorizontal,
|
||||
zoomable,
|
||||
);
|
||||
|
||||
let xAxis: any = {
|
||||
type: xAxisType,
|
||||
name: xAxisTitle,
|
||||
@@ -1277,12 +1256,33 @@ export default function transformProps(
|
||||
groupBy.length === 0 && {
|
||||
triggerEvent: true,
|
||||
}),
|
||||
...temporalAxisTickConfig,
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: {
|
||||
...temporalAxisTickConfig.axisTick,
|
||||
show: axisTicks ? 'auto' : false,
|
||||
axisLabel: {
|
||||
// When rotation is applied on time axes, hideOverlap can
|
||||
// aggressively hide the last label. Rotated labels already
|
||||
// have less overlap, so disabling hideOverlap is safe.
|
||||
// At 0° rotation, also disable hideOverlap when showMaxLabel
|
||||
// is active so the forced boundary label is never suppressed
|
||||
// by ECharts' overlap detection (#39899).
|
||||
hideOverlap: showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0),
|
||||
formatter: deduplicatedFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
// Force the boundary labels on non-rotated time axes so the first
|
||||
// and last dates stay visible: hideOverlap can hide the last label,
|
||||
// and a min date that falls between "nice" ticks otherwise renders
|
||||
// no beginning label. Skipped when rotated to avoid phantom labels
|
||||
// at the axis boundary.
|
||||
...(showMaxLabel && {
|
||||
showMaxLabel: true,
|
||||
alignMaxLabel: 'right',
|
||||
showMinLabel: true,
|
||||
alignMinLabel: 'left',
|
||||
}),
|
||||
},
|
||||
minorTick: { show: minorTicks },
|
||||
axisTick: { show: axisTicks ? 'auto' : false },
|
||||
...(gridlines ? {} : { splitLine: { show: false } }),
|
||||
minInterval:
|
||||
xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval
|
||||
|
||||
@@ -89,16 +89,6 @@ export const StackControlOptionsWithoutStream: [
|
||||
[StackControlsValue.Stack, t('Stack')],
|
||||
];
|
||||
|
||||
// Grains ECharts' time axis cannot tick on; see getTemporalTickValues in
|
||||
// utils/series.
|
||||
export const WEEKLY_TIME_GRAINS: ReadonlySet<string> = new Set([
|
||||
TimeGranularity.WEEK,
|
||||
TimeGranularity.WEEK_STARTING_SUNDAY,
|
||||
TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
TimeGranularity.WEEK_ENDING_SATURDAY,
|
||||
TimeGranularity.WEEK_ENDING_SUNDAY,
|
||||
]);
|
||||
|
||||
export const TIMEGRAIN_TO_TIMESTAMP = {
|
||||
[TimeGranularity.HOUR]: 3600 * 1000,
|
||||
[TimeGranularity.DAY]: 3600 * 1000 * 24,
|
||||
|
||||
@@ -46,7 +46,6 @@ 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';
|
||||
@@ -63,7 +62,6 @@ 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';
|
||||
|
||||
@@ -18,14 +18,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
AnnotationLayer,
|
||||
AxisType,
|
||||
ChartDataResponseResult,
|
||||
DataRecord,
|
||||
DataRecordValue,
|
||||
DTTM_ALIAS,
|
||||
ensureIsArray,
|
||||
isTimeseriesAnnotationLayer,
|
||||
LegendState,
|
||||
normalizeTimestamp,
|
||||
NumberFormats,
|
||||
@@ -44,7 +42,6 @@ import {
|
||||
NULL_STRING,
|
||||
StackControlsValue,
|
||||
TIMESERIES_CONSTANTS,
|
||||
WEEKLY_TIME_GRAINS,
|
||||
} from '../constants';
|
||||
import {
|
||||
EchartsTimeseriesSeriesType,
|
||||
@@ -989,167 +986,6 @@ export function getAxisType(
|
||||
return AxisType.Category;
|
||||
}
|
||||
|
||||
// `new Date('2024-04-06')` parses as UTC, but ECharts' own date parser treats
|
||||
// zone-less strings as local time — mismatch would offset the pinned tick.
|
||||
const DATE_ONLY_RE = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/;
|
||||
|
||||
function parseTemporalString(value: string): number {
|
||||
const dateOnly = DATE_ONLY_RE.exec(value);
|
||||
if (dateOnly) {
|
||||
const [, year, month, day] = dateOnly;
|
||||
return new Date(
|
||||
Number(year),
|
||||
Number(month || 1) - 1,
|
||||
Number(day || 1),
|
||||
).getTime();
|
||||
}
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket timestamps a temporal axis should tick on, or undefined to let ECharts
|
||||
* choose.
|
||||
*
|
||||
* ECharts generates time ticks from a calendar ladder with no week unit, so for
|
||||
* weekly data it steps days from the 1st of each month instead: labels drift
|
||||
* across weekdays and snap to month starts (#17226). Coarser grains already land
|
||||
* on their data and keep ECharts' calendar-nice labels.
|
||||
*/
|
||||
export function getTemporalTickValues(
|
||||
data: DataRecord[],
|
||||
xAxisLabel: string,
|
||||
xAxisType: AxisType,
|
||||
timeGrain?: string,
|
||||
): number[] | undefined {
|
||||
if (
|
||||
xAxisType !== AxisType.Time ||
|
||||
!timeGrain ||
|
||||
!WEEKLY_TIME_GRAINS.has(timeGrain)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const values = new Set<number>();
|
||||
data.forEach(row => {
|
||||
const value = row[xAxisLabel];
|
||||
const timestamp =
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
value instanceof Date
|
||||
? value.getTime()
|
||||
: typeof value === 'string'
|
||||
? parseTemporalString(value)
|
||||
: Number(value ?? NaN);
|
||||
if (Number.isFinite(timestamp)) {
|
||||
values.add(timestamp);
|
||||
}
|
||||
});
|
||||
return values.size ? [...values].sort((a, b) => a - b) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly grains: pin the ticks to the buckets ECharts would otherwise miss.
|
||||
* A timeseries annotation contributes its own timestamps and widens the axis
|
||||
* past the buckets, and ECharts clips pinned ticks to the extent, so that
|
||||
* span would render bare — leave those charts on ECharts' own ticks.
|
||||
*/
|
||||
export function resolveTemporalTickValues(
|
||||
data: DataRecord[],
|
||||
xAxisLabel: string,
|
||||
xAxisType: AxisType,
|
||||
timeGrain: string | undefined,
|
||||
annotationLayers: AnnotationLayer[],
|
||||
): number[] | undefined {
|
||||
const hasTimeseriesAnnotation = annotationLayers.some(
|
||||
layer => layer.show && isTimeseriesAnnotationLayer(layer),
|
||||
);
|
||||
return hasTimeseriesAnnotation
|
||||
? undefined
|
||||
: getTemporalTickValues(data, xAxisLabel, xAxisType, timeGrain);
|
||||
}
|
||||
|
||||
// Unlike axisLabel, axisTick has no overlap-based thinning, so pinning it to
|
||||
// every bucket combs a long weekly range. Downsample evenly, keeping ends.
|
||||
const MAX_PINNED_AXIS_TICKS = 60;
|
||||
|
||||
export function capTickMarks(
|
||||
values: number[],
|
||||
maxTicks: number = MAX_PINNED_AXIS_TICKS,
|
||||
): number[] {
|
||||
if (values.length <= maxTicks) {
|
||||
return values;
|
||||
}
|
||||
const step = Math.ceil(values.length / maxTicks);
|
||||
const capped = values.filter((_, index) => index % step === 0);
|
||||
const last = values[values.length - 1];
|
||||
if (capped[capped.length - 1] !== last) {
|
||||
capped.push(last);
|
||||
}
|
||||
return capped;
|
||||
}
|
||||
|
||||
/**
|
||||
* axisLabel/axisTick fragment for a temporal x-axis, shared by Timeseries and
|
||||
* MixedTimeseries. When temporalTickValues pins the axis to weekly buckets,
|
||||
* axisTick.customValues (what splitLine/gridlines follow) is downsampled to
|
||||
* avoid combing a long weekly range. axisLabel.customValues (what hideOverlap
|
||||
* thins from) uses the same capped set on a non-zoomable axis, so a label
|
||||
* surviving hideOverlap thinning always lands on a real tick and gridline
|
||||
* rather than a capped-away bucket. On a zoomable axis the full set is used
|
||||
* instead — zooming lets the user reach any bucket, but customValues never
|
||||
* recomputes on dataZoom, so a capped set there would freeze the visible
|
||||
* labels to the pre-zoom subset.
|
||||
*/
|
||||
export function getTemporalAxisTickConfig(
|
||||
temporalTickValues: number[] | undefined,
|
||||
showMaxLabel: boolean,
|
||||
xAxisType: AxisType,
|
||||
xAxisLabelRotation: number,
|
||||
xAxisLabelInterval: number | string | undefined,
|
||||
formatter: unknown,
|
||||
isHorizontal: boolean = false,
|
||||
zoomable: boolean = false,
|
||||
): {
|
||||
axisLabel: Record<string, unknown>;
|
||||
axisTick?: { customValues: number[] };
|
||||
} {
|
||||
const cappedTickValues = temporalTickValues
|
||||
? capTickMarks(temporalTickValues)
|
||||
: undefined;
|
||||
const labelCustomValues = zoomable ? temporalTickValues : cappedTickValues;
|
||||
return {
|
||||
axisLabel: {
|
||||
// Pinned ticks label every bucket, which does crowd, so thinning
|
||||
// always wins there.
|
||||
hideOverlap:
|
||||
!!temporalTickValues ||
|
||||
(showMaxLabel
|
||||
? false
|
||||
: !(xAxisType === AxisType.Time && xAxisLabelRotation !== 0)),
|
||||
formatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
// Force the boundary labels so the first and last dates stay visible:
|
||||
// hideOverlap can hide the last label, and a min date that falls
|
||||
// between "nice" ticks otherwise renders no beginning label. Applied
|
||||
// for pinned axes too — showMaxLabel only shields its immediate
|
||||
// neighbour, so a farther label on a crowded weekly axis can still be
|
||||
// dropped, but that's strictly better than no shielding at all.
|
||||
...(showMaxLabel && {
|
||||
showMaxLabel: true,
|
||||
showMinLabel: true,
|
||||
}),
|
||||
// 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',
|
||||
}),
|
||||
...(labelCustomValues && { customValues: labelCustomValues }),
|
||||
},
|
||||
...(cappedTickValues && { axisTick: { customValues: cappedTickValues } }),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOverMaxHiddenFormatter(
|
||||
config: {
|
||||
max?: number;
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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 });
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import 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([]);
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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);
|
||||
});
|
||||
@@ -1,341 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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 });
|
||||
});
|
||||
-92
@@ -1512,98 +1512,6 @@ describe('EchartsMixedTimeseries tooltip truncation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly x-axis tick alignment', () => {
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const MONDAYS = Array.from(
|
||||
{ length: 6 },
|
||||
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
|
||||
);
|
||||
const weeklyLabelMap = { ds: ['ds'], sum__num: ['sum__num'] };
|
||||
|
||||
const weeklyQuery = (timestamps: number[]) =>
|
||||
createTestQueryData(
|
||||
timestamps.map((ds, i) => ({ ds, sum__num: 10 + i })),
|
||||
{
|
||||
label_map: weeklyLabelMap,
|
||||
colnames: ['ds', 'sum__num'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
},
|
||||
);
|
||||
|
||||
const weeklyChartProps = (
|
||||
queryA: number[],
|
||||
queryB: number[],
|
||||
overrides: Partial<EchartsMixedTimeseriesFormData> = {},
|
||||
) =>
|
||||
createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: [],
|
||||
groupbyB: [],
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
...overrides,
|
||||
},
|
||||
queriesData: [weeklyQuery(queryA), weeklyQuery(queryB)],
|
||||
});
|
||||
|
||||
test('pins ticks, labels and gridlines to the weekly buckets', () => {
|
||||
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
|
||||
.echartOptions as any;
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Time);
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
|
||||
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.splitLine).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps label thinning on when the labels are rotated', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(MONDAYS, MONDAYS, { xAxisLabelRotation: 45 }),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => {
|
||||
// hideOverlap stays on for pinned ticks (they label every bucket), but
|
||||
// showMaxLabel still shields the boundary label's immediate neighbour
|
||||
// so the last bucket isn't silently dropped (#39899).
|
||||
const { xAxis } = transformProps(weeklyChartProps(MONDAYS, MONDAYS))
|
||||
.echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
|
||||
test('covers buckets contributed by either query', () => {
|
||||
// The two queries share one axis, so a bucket present in only one of them
|
||||
// still needs a tick.
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(MONDAYS.slice(0, 3), MONDAYS.slice(2)),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test('leaves grains ECharts places correctly untouched', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(MONDAYS, MONDAYS, {
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
}),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisTick?.customValues).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function transformWithChrome(
|
||||
overrides: Partial<EchartsMixedTimeseriesFormData>,
|
||||
) {
|
||||
|
||||
-335
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
AnnotationData,
|
||||
AnnotationSourceType,
|
||||
AnnotationStyle,
|
||||
AnnotationType,
|
||||
@@ -2707,297 +2706,6 @@ describe('EchartsTimeseries tooltip truncation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly x-axis tick alignment', () => {
|
||||
// 13 Monday-aligned weekly buckets, the shape produced by a dataset that is
|
||||
// pre-aggregated to weeks.
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const MONDAYS = Array.from(
|
||||
{ length: 13 },
|
||||
(_, i) => Date.UTC(2026, 3, 6) + i * WEEK_MS,
|
||||
);
|
||||
|
||||
const weeklyChartProps = (
|
||||
formDataOverrides: Partial<EchartsTimeseriesFormData> = {},
|
||||
annotationData?: AnnotationData,
|
||||
) =>
|
||||
createTestChartProps({
|
||||
annotationData,
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
xAxisTimeFormat: '%m-%d',
|
||||
...formDataOverrides,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
MONDAYS.map((__timestamp, i) => ({ __timestamp, sales: 100 + i })),
|
||||
{
|
||||
colnames: ['__timestamp', 'sales'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
// transformProps reads annotations off the query, not chartProps.
|
||||
...(annotationData && { annotation_data: annotationData }),
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
test('pins ticks, labels and gridlines to the weekly buckets', () => {
|
||||
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Time);
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
// Gridlines follow axisTick.customValues, so splitLine needs no own copy.
|
||||
expect(xAxis.axisTick.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.splitLine).toBeUndefined();
|
||||
});
|
||||
|
||||
const manyMondaysChartProps = (overrides: Record<string, unknown> = {}) => {
|
||||
const manyMondays = Array.from(
|
||||
{ length: 261 },
|
||||
(_, i) => Date.UTC(2021, 0, 4) + i * WEEK_MS,
|
||||
);
|
||||
return {
|
||||
manyMondays,
|
||||
chartProps: createTestChartProps({
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
xAxisTimeFormat: '%m-%d',
|
||||
...overrides,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
manyMondays.map((__timestamp, i) => ({
|
||||
__timestamp,
|
||||
sales: 100 + i,
|
||||
})),
|
||||
{
|
||||
colnames: ['__timestamp', 'sales'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
},
|
||||
),
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
test('caps both axisTick and axisLabel customValues on a non-zoomable axis', () => {
|
||||
// customValues never recomputes, so on a non-zoomable axis (no dataZoom
|
||||
// to reach hidden buckets) axisLabel is capped to the same subset as
|
||||
// axisTick: a label surviving hideOverlap thinning then always lands on
|
||||
// a real tick and gridline rather than a capped-away bucket.
|
||||
const { manyMondays, chartProps } = manyMondaysChartProps();
|
||||
const { xAxis } = transformProps(chartProps).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisTick.customValues.length).toBeLessThan(manyMondays.length);
|
||||
expect(xAxis.axisLabel.customValues).toEqual(xAxis.axisTick.customValues);
|
||||
});
|
||||
|
||||
test('keeps the full bucket set for axisLabel on a zoomable axis', () => {
|
||||
// A capped, uncapped label set would freeze the visible labels to the
|
||||
// pre-zoom subset since customValues never recomputes on dataZoom, so a
|
||||
// zoomable axis keeps the full set for axisLabel and lets hideOverlap
|
||||
// thin it dynamically; only axisTick (no such thinning) stays capped.
|
||||
const { manyMondays, chartProps } = manyMondaysChartProps({
|
||||
zoomable: true,
|
||||
});
|
||||
const { xAxis } = transformProps(chartProps).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisTick.customValues.length).toBeLessThan(manyMondays.length);
|
||||
expect(xAxis.axisLabel.customValues).toEqual(manyMondays);
|
||||
});
|
||||
|
||||
test('keeps the showMaxLabel override at 0° rotation on pinned axes', () => {
|
||||
// hideOverlap stays on for pinned ticks (they label every bucket), but
|
||||
// showMaxLabel still shields the boundary label's immediate neighbour
|
||||
// so the last bucket isn't silently dropped (#39899).
|
||||
const { xAxis } = transformProps(weeklyChartProps()).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.showMaxLabel).toBe(true);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
|
||||
test('pins ticks when the bucket column holds ISO date strings', () => {
|
||||
// A dataset can arrive with __timestamp serialized as an ISO string
|
||||
// rather than a Date/epoch-ms value.
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
MONDAYS.map((__timestamp, i) => ({
|
||||
__timestamp: new Date(__timestamp).toISOString(),
|
||||
sales: 100 + i,
|
||||
})),
|
||||
{
|
||||
colnames: ['__timestamp', 'sales'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
const { xAxis } = transformProps(chartProps).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test('keeps label thinning on when the labels are rotated', () => {
|
||||
// Rotation normally turns hideOverlap off, but pinned ticks put a label on
|
||||
// every bucket, so without thinning a multi-year range draws hundreds.
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({ xAxisLabelRotation: 45 }),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(true);
|
||||
});
|
||||
|
||||
test('leaves rotation thinning alone when the ticks are not pinned', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({
|
||||
timeGrainSqla: TimeGranularity.MONTH,
|
||||
xAxisLabelRotation: 45,
|
||||
}),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisLabel.hideOverlap).toBe(false);
|
||||
});
|
||||
|
||||
const timeseriesLayer = (show: boolean) =>
|
||||
({
|
||||
name: 'my annotation',
|
||||
annotationType: AnnotationType.Timeseries,
|
||||
sourceType: AnnotationSourceType.Line,
|
||||
style: AnnotationStyle.Solid,
|
||||
show,
|
||||
value: 1,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
|
||||
// The annotation's own timestamps run a year past the last bucket.
|
||||
const annotationRecords = {
|
||||
'my annotation': {
|
||||
records: [
|
||||
{ ds: MONDAYS[0], y: 1 },
|
||||
{ ds: MONDAYS[12] + 52 * WEEK_MS, y: 2 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
test('does not pin ticks when a timeseries annotation widens the axis', () => {
|
||||
// A Time axis takes no min/max, so it stretches to cover the annotation
|
||||
// while ECharts clips pinned ticks to the extent — that span would be bare.
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(
|
||||
{ annotationLayers: [timeseriesLayer(true)] },
|
||||
annotationRecords,
|
||||
),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisTick?.customValues).toBeUndefined();
|
||||
});
|
||||
|
||||
test('still pins ticks for a hidden timeseries annotation', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps(
|
||||
{ annotationLayers: [timeseriesLayer(false)] },
|
||||
annotationRecords,
|
||||
),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test.each([
|
||||
TimeGranularity.WEEK,
|
||||
TimeGranularity.WEEK_STARTING_SUNDAY,
|
||||
TimeGranularity.WEEK_STARTING_MONDAY,
|
||||
TimeGranularity.WEEK_ENDING_SATURDAY,
|
||||
TimeGranularity.WEEK_ENDING_SUNDAY,
|
||||
])('applies to the %s grain', grain => {
|
||||
const { xAxis } = transformProps(weeklyChartProps({ timeGrainSqla: grain }))
|
||||
.echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test('a dashboard time-grain override drives the alignment', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({
|
||||
timeGrainSqla: TimeGranularity.DAY,
|
||||
extraFormData: { time_grain_sqla: TimeGranularity.WEEK },
|
||||
}),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual(MONDAYS);
|
||||
});
|
||||
|
||||
test('deduplicates and sorts the bucket timestamps', () => {
|
||||
// A grouped query repeats each bucket once per series, and the rows are
|
||||
// not necessarily ordered.
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
granularity_sqla: 'ds',
|
||||
timeGrainSqla: TimeGranularity.WEEK,
|
||||
groupby: ['region'],
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
[
|
||||
{ __timestamp: MONDAYS[1], region: 'b', sales: 2 },
|
||||
{ __timestamp: MONDAYS[0], region: 'a', sales: 1 },
|
||||
{ __timestamp: MONDAYS[1], region: 'a', sales: 3 },
|
||||
{ __timestamp: MONDAYS[0], region: 'b', sales: 4 },
|
||||
],
|
||||
{
|
||||
colnames: ['__timestamp', 'region', 'sales'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.String,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
const { xAxis } = transformProps(chartProps).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toEqual([MONDAYS[0], MONDAYS[1]]);
|
||||
});
|
||||
|
||||
test('leaves grains ECharts places correctly untouched', () => {
|
||||
(
|
||||
[
|
||||
TimeGranularity.DAY,
|
||||
TimeGranularity.MONTH,
|
||||
TimeGranularity.QUARTER,
|
||||
TimeGranularity.YEAR,
|
||||
undefined,
|
||||
] as const
|
||||
).forEach(grain => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({ timeGrainSqla: grain }),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
expect(xAxis.axisTick?.customValues).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('leaves a categorical x-axis untouched', () => {
|
||||
const { xAxis } = transformProps(
|
||||
weeklyChartProps({ xAxisForceCategorical: true }),
|
||||
).echartOptions as any;
|
||||
|
||||
expect(xAxis.type).toBe(AxisType.Category);
|
||||
expect(xAxis.axisLabel.customValues).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip for metrics whose labels end in forecast suffixes', () => {
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
|
||||
@@ -3131,46 +2839,3 @@ test('applies gridlines to the value axis after a horizontal orientation swaps i
|
||||
// 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,7 +19,6 @@
|
||||
import {
|
||||
CategoricalColorScale,
|
||||
ChartProps,
|
||||
NumberFormatter,
|
||||
TimeGranularity,
|
||||
getNumberFormatter,
|
||||
} from '@superset-ui/core';
|
||||
@@ -160,72 +159,6 @@ 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', () => {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
DataRecord,
|
||||
getNumberFormatter,
|
||||
getTimeFormatter,
|
||||
TimeGranularity,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme as theme } from '@apache-superset/core/theme';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
@@ -41,8 +40,6 @@ import {
|
||||
getLegendProps,
|
||||
getOverMaxHiddenFormatter,
|
||||
getMinAndMaxFromBounds,
|
||||
capTickMarks,
|
||||
getTemporalTickValues,
|
||||
sanitizeHtml,
|
||||
sortAndFilterSeries,
|
||||
sortRows,
|
||||
@@ -1708,148 +1705,6 @@ test('getAxisType does not coerce Numeric x-axis to Time regardless of values',
|
||||
);
|
||||
});
|
||||
|
||||
describe('getTemporalTickValues', () => {
|
||||
const xAxisLabel = '__timestamp';
|
||||
|
||||
test('returns undefined for a non-time axis', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Category,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined when there is no time grain', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
|
||||
expect(
|
||||
getTemporalTickValues(data, xAxisLabel, AxisType.Time, undefined),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for a non-weekly time grain', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: 1712361600000 }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.MONTH,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns sorted, de-duplicated bucket timestamps for numbers and Dates', () => {
|
||||
const t0 = Date.UTC(2026, 3, 6);
|
||||
const t1 = Date.UTC(2026, 3, 13);
|
||||
const data: DataRecord[] = [
|
||||
{ [xAxisLabel]: t1 },
|
||||
{ [xAxisLabel]: new Date(t0) },
|
||||
{ [xAxisLabel]: t0 }, // duplicate of the Date row above
|
||||
];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([t0, t1]);
|
||||
});
|
||||
|
||||
test('parses a zoned ISO string as the instant it names', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00.000Z' }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([Date.UTC(2026, 3, 6)]);
|
||||
});
|
||||
|
||||
test('parses a zone-less datetime string as local time, matching ECharts', () => {
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06T00:00:00' }];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([new Date(2026, 3, 6, 0, 0, 0).getTime()]);
|
||||
});
|
||||
|
||||
test('parses a bare date string as local midnight, matching ECharts rather than native Date', () => {
|
||||
// `new Date('2026-04-06')` is UTC, but ECharts parses it as local time.
|
||||
// jest.config.js fixes the test TZ to America/New_York, so they disagree.
|
||||
const data: DataRecord[] = [{ [xAxisLabel]: '2026-04-06' }];
|
||||
const localMidnight = new Date(2026, 3, 6).getTime();
|
||||
expect(localMidnight).not.toEqual(new Date('2026-04-06').getTime());
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toEqual([localMidnight]);
|
||||
});
|
||||
|
||||
test('drops unparseable or nullish values and returns undefined when none remain', () => {
|
||||
const data: DataRecord[] = [
|
||||
{ [xAxisLabel]: 'not-a-date' },
|
||||
{ [xAxisLabel]: null },
|
||||
];
|
||||
expect(
|
||||
getTemporalTickValues(
|
||||
data,
|
||||
xAxisLabel,
|
||||
AxisType.Time,
|
||||
TimeGranularity.WEEK,
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capTickMarks', () => {
|
||||
test('returns values unchanged when within the cap', () => {
|
||||
const values = [1, 2, 3];
|
||||
expect(capTickMarks(values, 60)).toEqual(values);
|
||||
});
|
||||
|
||||
test('downsamples to every step-th value when the last value already lands on the step', () => {
|
||||
const values = Array.from({ length: 261 }, (_, i) => i);
|
||||
// step = ceil(261 / 60) = 5, and 260 is already a multiple of 5, so
|
||||
// nothing needs to be appended for the last bucket.
|
||||
expect(capTickMarks(values, 60)).toEqual(
|
||||
Array.from({ length: 53 }, (_, i) => i * 5),
|
||||
);
|
||||
});
|
||||
|
||||
test('appends the last value when it does not land on the step', () => {
|
||||
const values = Array.from({ length: 262 }, (_, i) => i);
|
||||
// step = ceil(262 / 60) = 5, stepping lands on 0..260, and the true last
|
||||
// value (261) is appended on top since it isn't a multiple of 5.
|
||||
expect(capTickMarks(values, 60)).toEqual([
|
||||
...Array.from({ length: 53 }, (_, i) => i * 5),
|
||||
261,
|
||||
]);
|
||||
});
|
||||
|
||||
test('maxTicks is not a hard bound once the last value has to be appended', () => {
|
||||
const values = Array.from({ length: 300 }, (_, i) => i);
|
||||
// step = ceil(300 / 60) = 5, which already lands on 60 stepped values
|
||||
// (0..295) plus the appended last value (299), totaling 61 — one over
|
||||
// maxTicks. Keeping the true last bucket wins over a hard cap.
|
||||
expect(capTickMarks(values, 60)).toHaveLength(61);
|
||||
});
|
||||
});
|
||||
|
||||
test('getMinAndMaxFromBounds returns empty object when not truncating', () => {
|
||||
expect(
|
||||
getMinAndMaxFromBounds(
|
||||
|
||||
@@ -462,14 +462,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
|
||||
// only take relevant page size options
|
||||
const pageSizeOptions = useMemo(() => {
|
||||
const getServerPagination = (n: number) =>
|
||||
n <= Math.max(rowCount, serverPageLength);
|
||||
const getServerPagination = (n: number) => n <= rowCount;
|
||||
return (
|
||||
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
|
||||
).filter(([n]) =>
|
||||
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
|
||||
) as SizeOption[];
|
||||
}, [data.length, rowCount, serverPageLength, serverPagination]);
|
||||
}, [data.length, rowCount, 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, within } from 'spec/helpers/testing-library';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import QueryHistory from 'src/SqlLab/components/QueryHistory';
|
||||
import {
|
||||
initialState,
|
||||
@@ -252,247 +252,6 @@ 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,6 +19,7 @@
|
||||
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';
|
||||
@@ -30,7 +31,6 @@ 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,26 +82,25 @@ const QueryHistory = ({
|
||||
skip: !isFeatureEnabled(FeatureFlag.SqllabBackendPersistence),
|
||||
},
|
||||
);
|
||||
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 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 loadNext = useEffectEvent(() => {
|
||||
setPageIndex(pageIndex + 1);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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',
|
||||
);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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,
|
||||
};
|
||||
};
|
||||
-17
@@ -43,23 +43,6 @@ describe('SaveDatasetActionButton', () => {
|
||||
expect(saveDatasetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables only the dataset button when canSaveDataset is false', () => {
|
||||
const onSaveAsExplore = jest.fn();
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Saving the query needs no results.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the query did not run successfully', async () => {
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
|
||||
@@ -19,14 +19,12 @@
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
createStore,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
@@ -65,12 +63,6 @@ beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// In-body restores are skipped when an assertion throws, leaking a
|
||||
// configured spy into later tests.
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
@@ -526,39 +518,6 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces the error and keeps the modal open when saving fails', async () => {
|
||||
// The chart-payload step's toast was built but never dispatched, so a
|
||||
// failure there was silent.
|
||||
const postFormData = jest.spyOn(
|
||||
require('src/explore/exploreUtils/formData'),
|
||||
'postFormData',
|
||||
);
|
||||
postFormData.mockRejectedValue(new Error('Boom'));
|
||||
const onHide = jest.fn();
|
||||
const store = createStore({ user }, reducerIndex);
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} onHide={onHide} />, { store });
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue(/unimportant/i), {
|
||||
target: { value: 'my dataset' },
|
||||
});
|
||||
userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// `createStore` builds its reducer map at runtime, so state isn't typed.
|
||||
const toasts = () =>
|
||||
(
|
||||
store.getState() as unknown as {
|
||||
messageToasts: { toastType: string }[];
|
||||
}
|
||||
).messageToasts;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
expect(toasts()[0].toastType).toBe('DANGER_TOAST');
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearDatasetCache is imported and available', () => {
|
||||
const { clearDatasetCache } = require('src/utils/cachedSupersetGet');
|
||||
|
||||
|
||||
@@ -61,9 +61,6 @@ import type Subject from 'src/types/Subject';
|
||||
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
|
||||
|
||||
// Derived so it can't drift from what `getClientErrorObject` accepts.
|
||||
type SaveErrorSource = Parameters<typeof getClientErrorObject>[0];
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
}
|
||||
@@ -394,18 +391,9 @@ export const SaveDatasetModal = ({
|
||||
setDatasetName(getDefaultDatasetName());
|
||||
onHide();
|
||||
})
|
||||
.catch((error?: SaveErrorSource) => {
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
// `createDatasource` already toasted the server's message and rejects
|
||||
// with nothing; only the chart-payload step needs its own.
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
getClientErrorObject(error).then(e =>
|
||||
dispatch(
|
||||
addDangerToast(e.error || t('An error occurred saving dataset')),
|
||||
),
|
||||
);
|
||||
addDangerToast(t('An error occurred saving dataset'));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ import {
|
||||
import SaveQuery from 'src/SqlLab/components/SaveQuery';
|
||||
import { initialState, databases } from 'src/SqlLab/fixtures';
|
||||
|
||||
const RESULT_COLUMNS = [{ column_name: 'col', type: 'STRING' }];
|
||||
|
||||
const mockedProps = {
|
||||
queryEditorId: '123',
|
||||
animation: false,
|
||||
@@ -37,6 +35,7 @@ const mockedProps = {
|
||||
onSave: () => {},
|
||||
saveQueryWarning: null,
|
||||
columns: [],
|
||||
canSaveDataset: true,
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
@@ -61,31 +60,8 @@ const splitSaveBtnProps = {
|
||||
...mockedProps.database,
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
columns: RESULT_COLUMNS,
|
||||
};
|
||||
|
||||
const EDITOR_SQL = 'SELECT * FROM t';
|
||||
|
||||
const stateWithLatestQuery = ({
|
||||
id,
|
||||
state,
|
||||
sql = EDITOR_SQL,
|
||||
}: {
|
||||
id: string;
|
||||
state: string;
|
||||
sql?: string;
|
||||
}) => ({
|
||||
...mockState,
|
||||
sqlLab: {
|
||||
...mockState.sqlLab,
|
||||
queryEditors: mockState.sqlLab.queryEditors.map(qe => ({
|
||||
...qe,
|
||||
latestQueryId: id,
|
||||
})),
|
||||
queries: { [id]: { id, state, sql } },
|
||||
},
|
||||
});
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
|
||||
@@ -121,71 +97,6 @@ describe('SavedQuery', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" until the query has run successfully', () => {
|
||||
// Without a successful run the save can only fail server-side.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'failed' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
// Saving the query itself is unaffected.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when no query has been run at all', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the SQL changed after a successful run', () => {
|
||||
// The run succeeded, but not for what is in the editor now -- and it is
|
||||
// the editor's SQL that gets saved.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(
|
||||
stateWithLatestQuery({
|
||||
id: 'qid-1',
|
||||
state: 'success',
|
||||
sql: 'SELECT 1 AS ran_earlier',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the successful query returned no columns', () => {
|
||||
// e.g. a DDL/DML statement -- there is nothing to introspect into a dataset.
|
||||
render(<SaveQuery {...splitSaveBtnProps} columns={[]} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('enables "Save dataset" once the query has succeeded', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /save dataset/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('renders a save query modal when user clicks save button', () => {
|
||||
render(<SaveQuery {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
@@ -323,7 +234,7 @@ describe('SavedQuery', () => {
|
||||
test('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
@@ -337,7 +248,7 @@ describe('SavedQuery', () => {
|
||||
test('renders the save dataset modal UI', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
userEvent.click(saveDatasetMenuItem);
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useMemo, ChangeEvent } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Query, QueryState } from '@superset-ui/core';
|
||||
import type { DatabaseObject } from 'src/features/databases/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -39,7 +37,7 @@ import {
|
||||
} from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { QueryEditor } from 'src/SqlLab/types';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
import {
|
||||
LOG_ACTIONS_SQLLAB_CREATE_CHART,
|
||||
@@ -54,6 +52,7 @@ interface SaveQueryProps {
|
||||
onUpdate: (arg0: QueryPayload, id: string) => void;
|
||||
saveQueryWarning: string | null;
|
||||
database: Partial<DatabaseObject> | undefined;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
export type QueryPayload = {
|
||||
@@ -83,6 +82,7 @@ const SaveQuery = ({
|
||||
saveQueryWarning,
|
||||
database,
|
||||
columns,
|
||||
canSaveDataset,
|
||||
}: SaveQueryProps) => {
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'autorun',
|
||||
@@ -113,17 +113,6 @@ const SaveQuery = ({
|
||||
const [label, setLabel] = useState<string>(defaultLabel);
|
||||
const [showSave, setShowSave] = useState<boolean>(false);
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
// Saving a dataset runs the SQL to introspect columns, so it needs a
|
||||
// successful run of the SQL being saved that produced at least one column
|
||||
// -- editing after a run invalidates it, and running a selection only
|
||||
// validates that selection.
|
||||
const latestQuery = useSelector<SqlLabRootState, Query | undefined>(
|
||||
({ sqlLab }) => sqlLab.queries[queryEditor.latestQueryId || ''],
|
||||
);
|
||||
const canSaveDataset =
|
||||
latestQuery?.state === QueryState.Success &&
|
||||
latestQuery.sql === queryEditor.sql &&
|
||||
columns.length > 0;
|
||||
const isSaved = !!query.remoteId;
|
||||
const isLabelEmpty = label.trim().length === 0;
|
||||
const canExploreDatabase = !!database?.allows_virtual_table_explore;
|
||||
|
||||
@@ -362,7 +362,6 @@ describe('SqlEditor', () => {
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
sql: mockedProps.queryEditor.sql,
|
||||
});
|
||||
expect(await findByLabelText('Save dataset')).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -868,6 +868,7 @@ const SqlEditor: FC<Props> = ({
|
||||
}
|
||||
saveQueryWarning={saveQueryWarning}
|
||||
database={database}
|
||||
canSaveDataset={successful && resultColumns.length > 0}
|
||||
/>
|
||||
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
|
||||
</>
|
||||
|
||||
+8
-135
@@ -67,23 +67,14 @@ async function renderAndWait(props = mockedProps) {
|
||||
container = renderedContainer;
|
||||
}
|
||||
|
||||
// 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 () => {
|
||||
beforeEach(() => {
|
||||
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
|
||||
@@ -127,7 +118,6 @@ 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' });
|
||||
@@ -161,96 +151,6 @@ 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 = {
|
||||
@@ -263,24 +163,15 @@ describe('DatasourceModal', () => {
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL
|
||||
rerender(
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
@@ -317,24 +208,15 @@ describe('DatasourceModal', () => {
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL to trigger checkbox
|
||||
rerender(
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
@@ -387,24 +269,15 @@ describe('DatasourceModal', () => {
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL to trigger checkbox
|
||||
rerender(
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
|
||||
-4
@@ -21,7 +21,6 @@ import {
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
waitFor,
|
||||
defaultStore as store,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
@@ -73,9 +72,6 @@ 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,14 +33,12 @@ 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';
|
||||
|
||||
@@ -93,18 +91,12 @@ 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<
|
||||
{
|
||||
@@ -119,52 +111,6 @@ 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,
|
||||
@@ -251,13 +197,11 @@ 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, response } = await SupersetClient.get({
|
||||
const { json } = 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
|
||||
@@ -269,19 +213,6 @@ 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;
|
||||
@@ -333,7 +264,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
here may affect other charts
|
||||
in undesirable ways.`)}
|
||||
/>
|
||||
{baselineSql !== currentDatasource.sql && (
|
||||
{datasource.sql !== currentDatasource.sql && (
|
||||
<div
|
||||
css={theme => ({
|
||||
marginBottom: theme.marginMD,
|
||||
@@ -367,14 +298,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
{t('Are you sure you want to save and apply changes?')}
|
||||
</div>
|
||||
),
|
||||
[currentDatasource.sql, baselineSql, syncColumns],
|
||||
[currentDatasource.sql, datasource.sql, syncColumns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (baselineSql !== currentDatasource.sql) {
|
||||
if (datasource.sql !== currentDatasource.sql) {
|
||||
setSyncColumns(true);
|
||||
}
|
||||
}, [baselineSql, currentDatasource.sql]);
|
||||
}, [datasource.sql, currentDatasource.sql]);
|
||||
|
||||
const onClickSave = () => {
|
||||
setConfirmModalOpen(true);
|
||||
@@ -425,7 +356,6 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
onClick={onClickSave}
|
||||
disabled={
|
||||
isSaving ||
|
||||
isLoadingDatasource ||
|
||||
errors.length > 0 ||
|
||||
currentDatasource.is_managed_externally
|
||||
}
|
||||
@@ -451,18 +381,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
}}
|
||||
draggable
|
||||
>
|
||||
{isLoadingDatasource ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<DatasourceEditor
|
||||
showLoadingForImport
|
||||
height={500}
|
||||
datasource={currentDatasource}
|
||||
onChange={onDatasourceChange}
|
||||
setIsEditing={setIsEditing}
|
||||
currencies={currencies}
|
||||
/>
|
||||
)}
|
||||
<DatasourceEditor
|
||||
showLoadingForImport
|
||||
height={500}
|
||||
datasource={currentDatasource}
|
||||
onChange={onDatasourceChange}
|
||||
setIsEditing={setIsEditing}
|
||||
currencies={currencies}
|
||||
/>
|
||||
{contextHolder}
|
||||
<Modal
|
||||
title={t('Confirm save')}
|
||||
|
||||
+1
-74
@@ -36,7 +36,6 @@ import {
|
||||
SupersetClient,
|
||||
getClientErrorObject,
|
||||
getExtensionsRegistry,
|
||||
formatSpecifier,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -828,78 +827,6 @@ function EditorsSelector({
|
||||
const ResultTable =
|
||||
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
|
||||
|
||||
// D3's '%' and 'p' types both multiply by 100; parsed via d3-format's own
|
||||
// grammar so garbage like "foo%" is rejected rather than matched by suffix.
|
||||
// The stored value is trimmed before parsing because
|
||||
// NumberFormatterRegistry.get() trims it the same way before rendering, so
|
||||
// this check agrees with what the renderer actually sees.
|
||||
export const isPercentD3Format = (d3format?: string): boolean => {
|
||||
if (!d3format) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { type } = formatSpecifier(d3format.trim());
|
||||
return type === '%' || type === 'p';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Matches the outermost COUNT(...) call's parens by depth, so a ratio like
|
||||
// `COUNT(*) / COUNT(*)` isn't misclassified but a nested call like
|
||||
// `COUNT(DISTINCT COALESCE(a, b))` is still recognized. Parens inside a
|
||||
// quoted string literal (single- or double-quoted, with a doubled quote as
|
||||
// an escaped quote) are ignored so they don't desync the depth count.
|
||||
export const isCountExpression = (expression?: string): boolean => {
|
||||
const trimmed = expression?.trim();
|
||||
if (!trimmed || !/^count\s*\(/i.test(trimmed) || !trimmed.endsWith(')')) {
|
||||
return false;
|
||||
}
|
||||
let depth = 0;
|
||||
let stringDelimiter: string | null = null;
|
||||
for (let i = trimmed.indexOf('('); i < trimmed.length; i += 1) {
|
||||
const char = trimmed[i];
|
||||
if (stringDelimiter) {
|
||||
if (char === stringDelimiter && trimmed[i + 1] === stringDelimiter) {
|
||||
i += 1;
|
||||
} else if (char === stringDelimiter) {
|
||||
stringDelimiter = null;
|
||||
}
|
||||
} else if (char === "'" || char === '"') {
|
||||
stringDelimiter = char;
|
||||
} else if (char === '(') {
|
||||
depth += 1;
|
||||
} else if (char === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return i === trimmed.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
function renderMetricFormatWarning(item: Record<string, any>): ReactNode {
|
||||
if (
|
||||
!isCountExpression(item.expression) ||
|
||||
!isPercentD3Format(item.d3format)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
css={themeParam => ({ marginBottom: themeParam.sizeUnit * 4 })}
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t(
|
||||
'This metric is a count, but its D3 format is a percentage. ' +
|
||||
'Percent formats multiply the value by 100, which will make a ' +
|
||||
'raw count render as a misleadingly large number.',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Redux connector types
|
||||
interface QueryPayload {
|
||||
client_id?: string;
|
||||
@@ -2243,7 +2170,7 @@ function DatasourceEditor({
|
||||
}}
|
||||
expandFieldset={
|
||||
<FormContainer>
|
||||
<Fieldset compact renderWarning={renderMetricFormatWarning}>
|
||||
<Fieldset compact>
|
||||
<Field
|
||||
fieldKey="expression"
|
||||
label={t('SQL expression')}
|
||||
|
||||
-210
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { isCountExpression, isPercentD3Format } from '../DatasourceEditor';
|
||||
import {
|
||||
createProps,
|
||||
DATASOURCE_ENDPOINT,
|
||||
setupDatasourceEditorMocks,
|
||||
cleanupAsyncOperations,
|
||||
fastRender,
|
||||
dismissDatasourceWarning,
|
||||
DatasourceEditorProps,
|
||||
} from './DatasourceEditor.test.utils';
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.get(DATASOURCE_ENDPOINT, [], { name: DATASOURCE_ENDPOINT });
|
||||
setupDatasourceEditorMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupAsyncOperations();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
});
|
||||
|
||||
const WARNING_TEXT = /D3 format is a percentage/i;
|
||||
|
||||
// Selecting the expand toggle by its position in the list is brittle: any
|
||||
// change to the fixture or the table's sort order would expand a different
|
||||
// row and negative assertions would keep passing against the wrong metric.
|
||||
// Look up the toggle via the row that actually contains the metric name.
|
||||
const expandMetricRow = async (metricName: string) => {
|
||||
const nameCell = await screen.findByText(metricName);
|
||||
const row = nameCell.closest('tr');
|
||||
if (!row) {
|
||||
throw new Error(`Could not find a table row for metric "${metricName}"`);
|
||||
}
|
||||
await userEvent.click(within(row).getByLabelText(/expand row/i));
|
||||
};
|
||||
|
||||
// A fixed-time sleep can't prove the debounced value actually committed, so
|
||||
// negative assertions would pass vacuously if the commit landed late on a
|
||||
// loaded runner. Instead, wait for the real signal of a commit: the metric's
|
||||
// d3format reaching the top-level onChange the editor calls after every
|
||||
// datasource state update.
|
||||
const waitForD3FormatCommit = (
|
||||
onChange: DatasourceEditorProps['onChange'],
|
||||
metricName: string,
|
||||
d3format: string,
|
||||
) =>
|
||||
waitFor(() => {
|
||||
const [datasource] = onChange.mock.calls.at(-1) ?? [];
|
||||
const metric = datasource?.metrics?.find(
|
||||
(m: { metric_name?: string }) => m.metric_name === metricName,
|
||||
);
|
||||
expect(metric?.d3format).toBe(d3format);
|
||||
});
|
||||
|
||||
test('isCountExpression matches a COUNT(...) call, including nested calls', () => {
|
||||
expect(isCountExpression('COUNT(*)')).toBe(true);
|
||||
expect(isCountExpression('count( * )')).toBe(true);
|
||||
expect(isCountExpression('COUNT (*)')).toBe(true);
|
||||
expect(isCountExpression('COUNT(DISTINCT name)')).toBe(true);
|
||||
expect(isCountExpression('COUNT(DISTINCT COALESCE(a, b))')).toBe(true);
|
||||
expect(isCountExpression('COUNT(*) / COUNT(*)')).toBe(false);
|
||||
expect(isCountExpression('COUNT(*) * 100')).toBe(false);
|
||||
expect(isCountExpression('SUM(num)')).toBe(false);
|
||||
expect(isCountExpression(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('isCountExpression ignores parens inside string literals', () => {
|
||||
expect(isCountExpression("COUNT(CASE WHEN x = '(' THEN 1 END)")).toBe(true);
|
||||
expect(isCountExpression("COUNT(CASE WHEN x = ')' THEN 1 END)")).toBe(true);
|
||||
expect(isCountExpression("COUNT(CASE WHEN x = '''(' THEN 1 END)")).toBe(true);
|
||||
});
|
||||
|
||||
test('isCountExpression ignores parens inside double-quoted identifiers', () => {
|
||||
expect(isCountExpression('COUNT("x\'")')).toBe(true);
|
||||
expect(isCountExpression('COUNT("y\'")')).toBe(true);
|
||||
expect(isCountExpression('COUNT(CASE WHEN "a""b" = 1 THEN 1 END)')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isPercentD3Format accepts only a valid D3 percent/p spec', () => {
|
||||
expect(isPercentD3Format('.0%')).toBe(true);
|
||||
expect(isPercentD3Format(',.2%')).toBe(true);
|
||||
expect(isPercentD3Format('.1p')).toBe(true);
|
||||
expect(isPercentD3Format('foo%')).toBe(false);
|
||||
expect(isPercentD3Format('.0%garbage%')).toBe(false);
|
||||
expect(isPercentD3Format(',.0f')).toBe(false);
|
||||
expect(isPercentD3Format(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
// NumberFormatterRegistry.get() trims the stored value before parsing it at
|
||||
// render time, so this must trim too rather than reject a format the
|
||||
// renderer accepts.
|
||||
test('isPercentD3Format trims, matching render-time parsing', () => {
|
||||
expect(isPercentD3Format('.0% ')).toBe(true);
|
||||
});
|
||||
|
||||
// A '%' format is valid syntax, so it never hits the "Invalid format" fallback.
|
||||
test('warns when a percent D3 format is set on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('count');
|
||||
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
expect(await screen.findByText(WARNING_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a non-percent format on a COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('count');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), ',.0f');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'count', ',.0f');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a percent format on a non-COUNT metric', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('sum__num');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'sum__num', '.0%');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a ratio built from COUNT, e.g. COUNT(*) / COUNT(*)', async () => {
|
||||
const baseProps = createProps();
|
||||
const testProps = {
|
||||
...baseProps,
|
||||
datasource: {
|
||||
...baseProps.datasource,
|
||||
metrics: [
|
||||
...baseProps.datasource.metrics,
|
||||
{
|
||||
id: 99,
|
||||
uuid: 'metric-99-uuid',
|
||||
expression: 'COUNT(*) / COUNT(*)',
|
||||
verbose_name: 'ratio',
|
||||
metric_name: 'ratio',
|
||||
metric_type: 'count',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('ratio');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), '.0%');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'ratio', '.0%');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not warn for a garbage format string that merely ends in %', async () => {
|
||||
const testProps = createProps();
|
||||
fastRender(testProps);
|
||||
await dismissDatasourceWarning();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('collection-tab-Metrics'));
|
||||
await expandMetricRow('count');
|
||||
|
||||
await userEvent.type(await screen.findByPlaceholderText('%y/%m/%d'), 'foo%');
|
||||
|
||||
await waitForD3FormatCommit(testProps.onChange, 'count', 'foo%');
|
||||
expect(screen.queryByText(WARNING_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -28,7 +28,6 @@ export interface FieldsetProps {
|
||||
item?: Record<string, any>;
|
||||
title?: ReactNode;
|
||||
compact?: boolean;
|
||||
renderWarning?: (item: Record<string, any>) => ReactNode;
|
||||
}
|
||||
|
||||
type fieldKeyType = string | number;
|
||||
@@ -39,7 +38,6 @@ export default function Fieldset({
|
||||
item = {},
|
||||
title = null,
|
||||
compact = false,
|
||||
renderWarning,
|
||||
}: FieldsetProps) {
|
||||
// Controls report their edits asynchronously - TextControl debounces by
|
||||
// FAST_DEBOUNCE - so the callback that eventually fires was built during an
|
||||
@@ -80,7 +78,6 @@ export default function Fieldset({
|
||||
</Typography.Title>
|
||||
)}
|
||||
|
||||
{renderWarning?.(item)}
|
||||
{recurseReactClone(children, Field, propExtender)}
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -20,5 +20,4 @@ import ChangeDatasourceModal from './ChangeDatasourceModal';
|
||||
import DatasourceModal from './DatasourceModal';
|
||||
|
||||
export { ChangeDatasourceModal, DatasourceModal };
|
||||
export { withCertificationFields } from './utils';
|
||||
export type { DatasourceModalProps, ChangeDatasourceModalProps } from './types';
|
||||
|
||||
@@ -29,12 +29,6 @@ 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,7 +27,6 @@ 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
|
||||
|
||||
@@ -249,29 +248,3 @@ 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
import { Router } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { getExtensionsRegistry, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
enableMobileConsumptionFlag,
|
||||
mockMobileMatchMedia,
|
||||
@@ -111,13 +106,6 @@ 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),
|
||||
}));
|
||||
@@ -583,172 +571,6 @@ 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', () => (
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { 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();
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FC } from 'react';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||
|
||||
const SliceInfoContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
max-width: 350px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
font-size: ${theme.fontSize}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
interface SliceInfoProps {
|
||||
slice: {
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
const SliceInfo: FC<SliceInfoProps> = ({ slice }) => (
|
||||
<SliceInfoContainer>
|
||||
<SafeMarkdown source={slice.description} />
|
||||
</SliceInfoContainer>
|
||||
);
|
||||
|
||||
export default SliceInfo;
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
getExtensionsRegistry,
|
||||
handleKeyboardActivation,
|
||||
JsonObject,
|
||||
QueryData,
|
||||
VizType,
|
||||
@@ -41,12 +40,7 @@ import {
|
||||
} from '@apache-superset/core/theme';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import {
|
||||
Tooltip,
|
||||
EditableTitle,
|
||||
Icons,
|
||||
Popover,
|
||||
} from '@superset-ui/core/components';
|
||||
import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components';
|
||||
import { useSelector } from 'react-redux';
|
||||
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
@@ -58,7 +52,6 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
|
||||
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
|
||||
import RowCountLabel from 'src/components/RowCountLabel';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SliceInfo from './SliceInfo';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
@@ -217,8 +210,6 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
state => state.charts[slice.slice_id].queriesResponse?.[1],
|
||||
);
|
||||
|
||||
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const rowLimit = Number(formData.row_limit ?? 0);
|
||||
@@ -348,27 +339,6 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
<CrossFilterIcon iconSize="m" />
|
||||
</Tooltip>
|
||||
)}
|
||||
{slice.description && !isExpanded && (
|
||||
<Popover
|
||||
trigger={['hover', 'click']}
|
||||
content={<SliceInfo slice={slice} />}
|
||||
placement="leftBottom"
|
||||
open={isDescriptionOpen}
|
||||
onOpenChange={setIsDescriptionOpen}
|
||||
>
|
||||
<Icons.InfoCircleOutlined
|
||||
iconSize="m"
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('Chart description')}
|
||||
data-test="chart-description-info-icon"
|
||||
onKeyDown={handleKeyboardActivation(() =>
|
||||
setIsDescriptionOpen(open => !open),
|
||||
)}
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
{!uiConfig.hideChartControls && (
|
||||
<MemoizedCustomizationsBadge chartId={slice.slice_id} />
|
||||
)}
|
||||
|
||||
+1
-56
@@ -23,7 +23,7 @@ import {
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
@@ -165,9 +165,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(document, 'fullscreenElement');
|
||||
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
|
||||
// not leak into other tests (the empty array is guarded, so nothing injects).
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
});
|
||||
|
||||
test('Should render', () => {
|
||||
@@ -176,58 +173,6 @@ test('Should render', () => {
|
||||
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
|
||||
{ key: 'custom-ext', label: 'Custom Menu Extension' },
|
||||
]);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
const injected = screen.getByText('Custom Menu Extension');
|
||||
expect(injected).toBeInTheDocument();
|
||||
// Sits above the built-in entries.
|
||||
const forceRefresh = screen.getByText('Force refresh');
|
||||
expect(
|
||||
injected.compareDocumentPosition(forceRefresh) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
|
||||
// The menu still renders its built-in entries unchanged (no dangling divider
|
||||
// is added since the empty array is guarded).
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
// The throw is isolated: the built-in menu still renders.
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects nothing when the extension returns a non-array', () => {
|
||||
getExtensionsRegistry().set(
|
||||
'dashboard.slice.header.menu',
|
||||
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
|
||||
(() => undefined) as never,
|
||||
);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should render default props', () => {
|
||||
const props = createProps();
|
||||
|
||||
|
||||
@@ -34,13 +34,11 @@ import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
getExtensionsRegistry,
|
||||
VizType,
|
||||
BinaryQueryObjectFilterClause,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
@@ -167,8 +165,6 @@ const queueChartResize = () => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
const SliceHeaderControls = (
|
||||
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
|
||||
) => {
|
||||
@@ -518,26 +514,6 @@ const SliceHeaderControls = (
|
||||
},
|
||||
];
|
||||
|
||||
const sliceHeaderMenuExtension = extensionsRegistry.get(
|
||||
'dashboard.slice.header.menu',
|
||||
);
|
||||
if (sliceHeaderMenuExtension) {
|
||||
// Isolate the extension: a bad registration (throwing, or returning a
|
||||
// non-array) must not take down the whole dashboard render.
|
||||
try {
|
||||
const extensionItems = sliceHeaderMenuExtension({
|
||||
sliceId: slice.slice_id,
|
||||
sliceName: slice.slice_name,
|
||||
dashboardId,
|
||||
});
|
||||
if (Array.isArray(extensionItems) && extensionItems.length) {
|
||||
newMenuItems.unshift(...extensionItems, { type: 'divider' });
|
||||
}
|
||||
} catch (error) {
|
||||
logging.error('dashboard.slice.header.menu extension failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (slice.description) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ToggleChartDescription,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user