mirror of
https://github.com/apache/superset.git
synced 2026-08-22 08:01:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e322f0d96 |
@@ -1,6 +1,6 @@
|
||||
name: Bug report
|
||||
description: Report a bug to improve Superset's stability
|
||||
labels: ["#bug"]
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Cosmetic Issue
|
||||
about: Describe a cosmetic issue with CSS, positioning, layout, labeling, or similar
|
||||
labels: "#bug:cosmetic"
|
||||
labels: "cosmetic-issue"
|
||||
---
|
||||
|
||||
## Screenshot
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
enable-cache: true
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -78,6 +78,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
+4
-4
@@ -64,8 +64,8 @@
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"js-yaml": "^5.2.3",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -78,7 +78,7 @@
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.7",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -93,7 +93,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.11.0",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
|
||||
+23
-23
@@ -8014,10 +8014,10 @@ doctrine@^2.1.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
docusaurus-plugin-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.2.0.tgz#8318ec90cd21fed023be57696211af7d72fd81db"
|
||||
integrity sha512-MjrfRAMB64uvdxRVz6L9AXWe4QFjCdoBAzYs306yyI3nnXHsFj2lv2FnLA90JV9CAUZaGiYMvvkzBo2Nrkq/9w==
|
||||
docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.3.tgz#b8cd5f8451aaf881deb1a744a8295685f1681865"
|
||||
integrity sha512-HnpblSBdXoR39VNTIW9zWERUsMJxXOpvdQoBKyaTkUBPwCM48Z76+ndo2yO2vADq+EhWjJlfxL1DUzCrgNjThQ==
|
||||
dependencies:
|
||||
"@apidevtools/json-schema-ref-parser" "^15.3.3"
|
||||
"@redocly/openapi-core" "^2.25.2"
|
||||
@@ -8035,10 +8035,10 @@ docusaurus-plugin-openapi-docs@^5.2.0:
|
||||
swagger2openapi "^7.0.8"
|
||||
xml-formatter "^3.6.6"
|
||||
|
||||
docusaurus-theme-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.2.0.tgz#6d93a74e2e3cf0ae77d24e1c4144bd2e74a52115"
|
||||
integrity sha512-L0b80LzaMUfr76a9EQXRPCf8nxkEz8Xo6Aknnke1UeE2oXsgoiVki6U+RTE7GmJRjO8zSNKXyckGmGmqqWuHeA==
|
||||
docusaurus-theme-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.3.tgz#e23644a63785352abbc76e42760c0dfdff3669e1"
|
||||
integrity sha512-npbD1QahtjAEmrOet/86i5fTmcJX4/rPhVT+c0qKjm7StUNbyqjwchSVBQuU1rB69T51JOA9TpT/y6QcB9Xjvw==
|
||||
dependencies:
|
||||
"@hookform/error-message" "^2.0.1"
|
||||
"@reduxjs/toolkit" "^2.8.2"
|
||||
@@ -8123,7 +8123,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
dompurify@^3.3.3, dompurify@^3.4.13:
|
||||
dompurify@^3.3.3, dompurify@^3.4.12:
|
||||
version "3.4.13"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
|
||||
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
|
||||
@@ -9174,10 +9174,10 @@ globals@^14.0.0:
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
|
||||
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
|
||||
|
||||
globals@^17.11.0:
|
||||
version "17.11.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
|
||||
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
|
||||
globals@^17.9.0:
|
||||
version "17.9.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
|
||||
integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
|
||||
|
||||
globalthis@^1.0.4:
|
||||
version "1.0.4"
|
||||
@@ -10284,10 +10284,10 @@ js-levenshtein@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
|
||||
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
|
||||
js-yaml@4.1.0, js-yaml@=4.3.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
|
||||
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
@@ -15103,10 +15103,10 @@ swagger-client@^3.37.8:
|
||||
"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.12.0"
|
||||
"@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.12.0"
|
||||
|
||||
swagger-ui-react@^5.32.13:
|
||||
version "5.32.13"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.13.tgz#04c96140b0a2d4ea01ebec4d4cfc655d5ed9a500"
|
||||
integrity sha512-XIDl+Ny6kE1N8wpSPiOFrjPfAevs4GR4XmV6BT6NLMikkMFIbIVocWbA8pnKYyYXQe8Rccfli5o2zDfySw0FnQ==
|
||||
swagger-ui-react@^5.32.12:
|
||||
version "5.32.12"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.12.tgz#47525a26774eb02db0e6203af72f5b32fa6205cc"
|
||||
integrity sha512-WCdkNOQyMTZDu+z356FpwVWHf1dwZgQPUjdQPh1L4r7jULaJTKKlIItXq6WsZdYeXvsHndMdxxccEQXOAroUHQ==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
@@ -15115,11 +15115,11 @@ swagger-ui-react@^5.32.13:
|
||||
classnames "^2.5.1"
|
||||
css.escape "1.5.1"
|
||||
deep-extend "0.6.0"
|
||||
dompurify "^3.4.13"
|
||||
dompurify "^3.4.12"
|
||||
ieee754 "^1.2.1"
|
||||
immutable "^4.3.9"
|
||||
js-file-download "^0.4.12"
|
||||
js-yaml "=4.3.1"
|
||||
js-yaml "=4.3.0"
|
||||
lodash "^4.18.1"
|
||||
prop-types "^15.8.1"
|
||||
randexp "^0.5.3"
|
||||
|
||||
@@ -93,7 +93,7 @@ def find_models(module: ModuleType) -> list[type[Model]]: # noqa: C901
|
||||
# where the current model is out-of-sync with the existing table after a
|
||||
# downgrade
|
||||
sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
|
||||
engine = create_engine(sqlalchemy_uri)
|
||||
engine = create_engine(sqlalchemy_uri, future=True)
|
||||
Base = automap_base() # noqa: N806
|
||||
Base.prepare(engine, reflect=True)
|
||||
seen = set()
|
||||
|
||||
Generated
+20
-11
@@ -99,7 +99,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"google-auth-library": "^11.0.1",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -186,7 +186,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
@@ -11808,9 +11808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
|
||||
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
|
||||
"version": "14.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.15.0.tgz",
|
||||
"integrity": "sha512-nCsTO7mOOPz2UnT3N6YWb014uI0CVxeKg53A/KM/CvuSIE6H3KPkhaziJQ3q2jI3u3LfFuDKEnU5ZmB1330Dqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -22783,9 +22783,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
|
||||
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.1.tgz",
|
||||
"integrity": "sha512-ZqfaYduu9ASUaFuUk5dF9g9QvufdhhSj7jFiEnCrTQcH57sFPKYetM0iU4dcKkQk6CqC1xpSrVr5uQ9NhqjNOg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
@@ -43420,13 +43420,22 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1"
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"@superset-ui/core": "*"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-chord/node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-country-map": {
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"google-auth-library": "^11.0.1",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -263,7 +263,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
|
||||
@@ -130,7 +130,6 @@ export enum GenericDataType {
|
||||
String = 1,
|
||||
Temporal = 2,
|
||||
Boolean = 3,
|
||||
MultiValue = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-5
@@ -28,7 +28,6 @@ import {
|
||||
FieldBinaryOutlined,
|
||||
FieldStringOutlined,
|
||||
NumberOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Icons } from '@superset-ui/core/components';
|
||||
|
||||
@@ -73,10 +72,6 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
|
||||
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
|
||||
} else if (type === GenericDataType.Temporal) {
|
||||
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
|
||||
} else if (type === GenericDataType.MultiValue) {
|
||||
typeIcon = (
|
||||
<UnorderedListOutlined aria-label={t('multi-value type icon')} />
|
||||
);
|
||||
}
|
||||
|
||||
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;
|
||||
|
||||
-17
@@ -64,21 +64,4 @@ describe('ColumnOption', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.Temporal });
|
||||
expect(screen.getByLabelText('temporal type icon')).toBeVisible();
|
||||
});
|
||||
test('multi-value (array) type shows list icon', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.MultiValue });
|
||||
expect(screen.getByLabelText('multi-value type icon')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GenericDataType enum parity', () => {
|
||||
// These numeric values are shared with the backend enum in
|
||||
// superset/utils/core.py (GenericDataType). They must stay in sync because
|
||||
// the backend serializes columns using these integers.
|
||||
test('values match the backend contract', () => {
|
||||
expect(GenericDataType.Numeric).toBe(0);
|
||||
expect(GenericDataType.String).toBe(1);
|
||||
expect(GenericDataType.Temporal).toBe(2);
|
||||
expect(GenericDataType.Boolean).toBe(3);
|
||||
expect(GenericDataType.MultiValue).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,10 +47,6 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1"
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"@superset-ui/core": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getNumberFormatter } from '@superset-ui/core';
|
||||
import { render, fireEvent } from '../../../../spec/helpers/testing-library';
|
||||
import BigNumberVis from './BigNumberViz';
|
||||
|
||||
/**
|
||||
* Tests for the color threshold formatter logic in BigNumberViz.
|
||||
*
|
||||
@@ -87,33 +83,3 @@ describe('BigNumberViz color formatters', () => {
|
||||
expect(getColorFromValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BigNumberViz context menu', () => {
|
||||
test('invokes onContextMenu and stops the event bubbling to ancestor handlers', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const ancestorHandler = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<div onContextMenu={ancestorHandler}>
|
||||
<BigNumberVis
|
||||
width={200}
|
||||
height={100}
|
||||
bigNumber={42}
|
||||
headerFormatter={getNumberFormatter()}
|
||||
headerFontSize={0.3}
|
||||
subheaderFontSize={0.125}
|
||||
subtitleFontSize={0.125}
|
||||
subtitle=""
|
||||
refs={{}}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const headerLine = container.querySelector('.header-line');
|
||||
fireEvent.contextMenu(headerLine!, { clientX: 10, clientY: 20 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledWith(10, 20);
|
||||
expect(ancestorHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,7 +224,6 @@ function BigNumberVis({
|
||||
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,7 +390,6 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
[GenericDataType.String]: undefined,
|
||||
[GenericDataType.Temporal]: tooltipTimeFormatter,
|
||||
[GenericDataType.Boolean]: undefined,
|
||||
[GenericDataType.MultiValue]: undefined,
|
||||
};
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
LegendOrientation,
|
||||
Refs,
|
||||
} from '../types';
|
||||
import { BarValueLabelPosition } from '../Timeseries/types';
|
||||
import { parseAxisBound } from '../utils/controls';
|
||||
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
|
||||
import {
|
||||
@@ -513,7 +512,6 @@ export default function transformProps(
|
||||
areaOpacity: opacity,
|
||||
seriesType,
|
||||
showValue,
|
||||
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
|
||||
onlyTotal,
|
||||
stack: Boolean(stack),
|
||||
stackIdSuffix: '\na',
|
||||
@@ -602,7 +600,6 @@ export default function transformProps(
|
||||
areaOpacity: opacityB,
|
||||
seriesType: seriesTypeB,
|
||||
showValue: showValueB,
|
||||
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
|
||||
onlyTotal: onlyTotalB,
|
||||
stack: Boolean(stackB),
|
||||
stackIdSuffix: '\nb',
|
||||
|
||||
-2
@@ -34,7 +34,6 @@ import {
|
||||
} from '../types';
|
||||
import EchartsTimeseries from './EchartsTimeseries';
|
||||
import {
|
||||
BarValueLabelPosition,
|
||||
EchartsTimeseriesSeriesType,
|
||||
OrientationType,
|
||||
type EchartsTimeseriesFormData,
|
||||
@@ -147,7 +146,6 @@ const defaultFormData: EchartsTimeseriesFormData & {
|
||||
xAxisLabelRotation: 0,
|
||||
xAxisLabelInterval: 0,
|
||||
showValue: false,
|
||||
valueLabelPosition: BarValueLabelPosition.Auto,
|
||||
onlyTotal: false,
|
||||
showExtraControls: true,
|
||||
percentageThreshold: 0,
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { LegendOrientation, LegendType } from '../types';
|
||||
import {
|
||||
BarValueLabelPosition,
|
||||
OrientationType,
|
||||
EchartsTimeseriesSeriesType,
|
||||
EchartsTimeseriesFormData,
|
||||
@@ -87,7 +86,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
xAxisLabelInterval: defaultXAxis.xAxisLabelInterval,
|
||||
groupby: [],
|
||||
showValue: false,
|
||||
valueLabelPosition: BarValueLabelPosition.Auto,
|
||||
onlyTotal: false,
|
||||
percentageThreshold: 0,
|
||||
orientation: OrientationType.Vertical,
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
NumberFormats,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { isThemeDark } from '@apache-superset/core/theme';
|
||||
import {
|
||||
extractExtraMetrics,
|
||||
getOriginalSeries,
|
||||
@@ -64,7 +63,6 @@ import {
|
||||
EchartsTimeseriesChartProps,
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesSeriesType,
|
||||
BarValueLabelPosition,
|
||||
OrientationType,
|
||||
TimeseriesChartTransformedProps,
|
||||
} from './types';
|
||||
@@ -292,7 +290,6 @@ export default function transformProps(
|
||||
seriesType,
|
||||
showLegend,
|
||||
showValue,
|
||||
valueLabelPosition,
|
||||
size,
|
||||
colorByPrimaryAxis,
|
||||
sliceId,
|
||||
@@ -330,8 +327,6 @@ export default function transformProps(
|
||||
zoomable,
|
||||
stackDimension,
|
||||
}: EchartsTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
const resolvedValueLabelPosition =
|
||||
valueLabelPosition ?? BarValueLabelPosition.Auto;
|
||||
|
||||
const refs: Refs = {};
|
||||
const groupBy = ensureIsArray(groupby);
|
||||
@@ -742,7 +737,6 @@ export default function transformProps(
|
||||
labelMap?.[seriesName]?.[0],
|
||||
) ?? defaultFormatter),
|
||||
showValue,
|
||||
valueLabelPosition: resolvedValueLabelPosition,
|
||||
onlyTotal,
|
||||
totalStackedValues: sortedTotalValues,
|
||||
showValueIndexes,
|
||||
@@ -1376,10 +1370,6 @@ export default function transformProps(
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
useUTC: true,
|
||||
...(seriesType === EchartsTimeseriesSeriesType.Bar &&
|
||||
resolvedValueLabelPosition === BarValueLabelPosition.Auto
|
||||
? { darkMode: isThemeDark(theme) }
|
||||
: {}),
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
...padding,
|
||||
|
||||
@@ -35,9 +35,6 @@ import type {
|
||||
CallbackDataParams,
|
||||
DefaultStatesMixin,
|
||||
ItemStyleOption,
|
||||
LabelLayoutOption,
|
||||
LabelLayoutOptionCallback,
|
||||
LabelLayoutOptionCallbackParams,
|
||||
LineStyleOption,
|
||||
OptionName,
|
||||
SeriesLabelOption,
|
||||
@@ -52,7 +49,6 @@ import type {
|
||||
import type { MarkLine1DDataItemOption } from 'echarts/types/src/component/marker/MarkLineModel';
|
||||
import { extractForecastSeriesContext } from '../utils/forecast';
|
||||
import {
|
||||
BarValueLabelPosition,
|
||||
EchartsTimeseriesSeriesType,
|
||||
ForecastSeriesEnum,
|
||||
LegendOrientation,
|
||||
@@ -73,107 +69,6 @@ import {
|
||||
TIMESERIES_CONSTANTS,
|
||||
} from '../constants';
|
||||
|
||||
const AUTO_LABEL_FIT_RATIO = 0.8;
|
||||
const BAR_LABEL_DISTANCE = 5;
|
||||
// Neither an inside nor an outside placement gives a stacked segment's value
|
||||
// label legible, non-overlapping room once the segment's own extent drops
|
||||
// below roughly the label text's height, since the closest available
|
||||
// placement then collides with a neighboring segment's label regardless of
|
||||
// which side it's drawn on. Highcharts and D3 apply the same kind of floor.
|
||||
// The label font size is theme.fontSizeSM (~12px, see series.ts), so 16px
|
||||
// covers the glyph height plus a couple of pixels of breathing room.
|
||||
const MIN_LABEL_SEGMENT_SIZE_PX = 16;
|
||||
// The labelLayout callback only applies align/verticalAlign/width/height/
|
||||
// fontSize from its return value (LABEL_OPTION_TO_STYLE_KEYS in ECharts'
|
||||
// LabelManager) — there is no hide/ignore field, so a zero font size is the
|
||||
// supported way to suppress an individual label from this callback.
|
||||
const HIDDEN_LABEL_LAYOUT: LabelLayoutOption = { fontSize: 0 };
|
||||
|
||||
type BarLabelPosition =
|
||||
| 'bottom'
|
||||
| 'inside'
|
||||
| 'insideBottom'
|
||||
| 'insideLeft'
|
||||
| 'insideRight'
|
||||
| 'insideTop'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top';
|
||||
|
||||
type NegativeBarLabelPosition = BarLabelPosition | 'outside';
|
||||
|
||||
/** Resolve the fixed ECharts label position for a bar value. */
|
||||
function getBarLabelPosition(
|
||||
position: BarValueLabelPosition,
|
||||
isHorizontal: boolean,
|
||||
isNegative = false,
|
||||
): BarLabelPosition {
|
||||
if (position === BarValueLabelPosition.OutsideEnd) {
|
||||
if (isHorizontal) return isNegative ? 'left' : 'right';
|
||||
return isNegative ? 'bottom' : 'top';
|
||||
}
|
||||
if (position === BarValueLabelPosition.InsideCenter) return 'inside';
|
||||
const isEnd = position !== BarValueLabelPosition.InsideBase;
|
||||
const usePositiveEnd = isEnd !== isNegative;
|
||||
if (isHorizontal) return usePositiveEnd ? 'insideRight' : 'insideLeft';
|
||||
return usePositiveEnd ? 'insideTop' : 'insideBottom';
|
||||
}
|
||||
|
||||
/** Place a horizontal bar label just beyond its value end. */
|
||||
function getHorizontalOutsideLayout(
|
||||
params: LabelLayoutOptionCallbackParams,
|
||||
isNegative: boolean,
|
||||
): LabelLayoutOption {
|
||||
return {
|
||||
x: isNegative
|
||||
? params.rect.x - BAR_LABEL_DISTANCE
|
||||
: params.rect.x + params.rect.width + BAR_LABEL_DISTANCE,
|
||||
y: params.rect.y + params.rect.height / 2,
|
||||
align: isNegative ? 'right' : 'left',
|
||||
verticalAlign: 'middle',
|
||||
};
|
||||
}
|
||||
|
||||
/** Place a vertical bar label just beyond its value end. */
|
||||
function getVerticalOutsideLayout(
|
||||
params: LabelLayoutOptionCallbackParams,
|
||||
isNegative: boolean,
|
||||
): LabelLayoutOption {
|
||||
return {
|
||||
x: params.rect.x + params.rect.width / 2,
|
||||
y: isNegative
|
||||
? params.rect.y + params.rect.height + BAR_LABEL_DISTANCE
|
||||
: params.rect.y - BAR_LABEL_DISTANCE,
|
||||
align: 'center',
|
||||
verticalAlign: isNegative ? 'top' : 'bottom',
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep fitting labels inside, move oversized labels outside the bar, and
|
||||
* suppress labels for segments too small to legibly fit one either way. */
|
||||
export function getAutoBarLabelLayout(
|
||||
params: LabelLayoutOptionCallbackParams,
|
||||
isHorizontal: boolean,
|
||||
isNegative = false,
|
||||
): LabelLayoutOption {
|
||||
const segmentSize = isHorizontal
|
||||
? Math.abs(params.rect.width)
|
||||
: Math.abs(params.rect.height);
|
||||
if (segmentSize < MIN_LABEL_SEGMENT_SIZE_PX) {
|
||||
return HIDDEN_LABEL_LAYOUT;
|
||||
}
|
||||
const fitsWidth =
|
||||
params.labelRect.width <=
|
||||
Math.abs(params.rect.width) * AUTO_LABEL_FIT_RATIO;
|
||||
const fitsHeight =
|
||||
params.labelRect.height <=
|
||||
Math.abs(params.rect.height) * AUTO_LABEL_FIT_RATIO;
|
||||
if (fitsWidth && fitsHeight) return {};
|
||||
return isHorizontal
|
||||
? getHorizontalOutsideLayout(params, isNegative)
|
||||
: getVerticalOutsideLayout(params, isNegative);
|
||||
}
|
||||
|
||||
function parseTimeShiftToMs(timeShift?: string | null): number {
|
||||
if (!timeShift) return 0;
|
||||
|
||||
@@ -272,69 +167,31 @@ export const getBaselineSeriesForStream = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Identify object-form ECharts data items. */
|
||||
function isDataItemObject(
|
||||
dataItem: unknown,
|
||||
): dataItem is Record<string, unknown> {
|
||||
return (
|
||||
typeof dataItem === 'object' &&
|
||||
dataItem !== null &&
|
||||
!Array.isArray(dataItem)
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether an ECharts bar datum is negative on its value axis. */
|
||||
function isNegativeBarDataItem(
|
||||
dataItem: unknown,
|
||||
isHorizontal: boolean,
|
||||
): boolean {
|
||||
const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
|
||||
const axisValue = Array.isArray(value)
|
||||
? value[isHorizontal ? 0 : 1]
|
||||
: undefined;
|
||||
return typeof axisValue === 'number' && axisValue < 0;
|
||||
}
|
||||
|
||||
/** Create a fit-aware layout callback bound to one bar series. */
|
||||
function createAutoBarLabelLayout(
|
||||
data: unknown,
|
||||
isHorizontal: boolean,
|
||||
): LabelLayoutOptionCallback {
|
||||
return params => {
|
||||
const dataItem =
|
||||
Array.isArray(data) && params.dataIndex !== undefined
|
||||
? data[params.dataIndex]
|
||||
: undefined;
|
||||
return getAutoBarLabelLayout(
|
||||
params,
|
||||
isHorizontal,
|
||||
isNegativeBarDataItem(dataItem, isHorizontal),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply the value-end label position to a negative bar datum. */
|
||||
function transformNegativeLabel(
|
||||
dataItem: unknown,
|
||||
isHorizontal: boolean,
|
||||
negativePosition: NegativeBarLabelPosition,
|
||||
): unknown {
|
||||
if (!isNegativeBarDataItem(dataItem, isHorizontal)) return dataItem;
|
||||
const value = isDataItemObject(dataItem) ? dataItem.value : dataItem;
|
||||
const item = isDataItemObject(dataItem) ? dataItem : { value };
|
||||
const label = isDataItemObject(item.label) ? item.label : {};
|
||||
return { ...item, label: { ...label, position: negativePosition } };
|
||||
}
|
||||
|
||||
/** Adjust label positions for negative values in a bar series. */
|
||||
export function transformNegativeLabelsPosition(
|
||||
series: SeriesOption,
|
||||
isHorizontal: boolean,
|
||||
negativePosition: NegativeBarLabelPosition = 'outside',
|
||||
): TimeseriesDataRecord[] {
|
||||
return (series.data as unknown[]).map(dataItem =>
|
||||
transformNegativeLabel(dataItem, isHorizontal, negativePosition),
|
||||
) as TimeseriesDataRecord[];
|
||||
/*
|
||||
* Adjusts label position for negative values in bar series
|
||||
* @param series - Array of series options
|
||||
* @param isHorizontal - Whether chart is horizontal
|
||||
* @returns data with adjusted label positions for negative values
|
||||
*/
|
||||
const transformValue = (value: any) => {
|
||||
const [xValue, yValue] = Array.isArray(value) ? value : [null, null];
|
||||
const axisValue = isHorizontal ? xValue : yValue;
|
||||
|
||||
return axisValue < 0
|
||||
? {
|
||||
value,
|
||||
label: {
|
||||
position: 'outside',
|
||||
},
|
||||
}
|
||||
: value;
|
||||
};
|
||||
|
||||
return (series.data as TimeseriesDataRecord[]).map(transformValue);
|
||||
}
|
||||
|
||||
export function applyColorByPrimaryAxis(
|
||||
@@ -380,7 +237,6 @@ export function transformSeries(
|
||||
stackIdSuffix?: string;
|
||||
yAxisIndex?: number;
|
||||
showValue?: boolean;
|
||||
valueLabelPosition?: BarValueLabelPosition;
|
||||
onlyTotal?: boolean;
|
||||
legendState?: LegendState;
|
||||
formatter?: ValueFormatter;
|
||||
@@ -416,7 +272,6 @@ export function transformSeries(
|
||||
stackIdSuffix,
|
||||
yAxisIndex = 0,
|
||||
showValue,
|
||||
valueLabelPosition = BarValueLabelPosition.Auto,
|
||||
onlyTotal,
|
||||
formatter,
|
||||
legendState,
|
||||
@@ -537,33 +392,23 @@ export function transformSeries(
|
||||
symbol = opts.lineSymbol || (isDarkMode ? 'circle' : 'emptyCircle');
|
||||
}
|
||||
|
||||
let transformedData = data;
|
||||
if (Array.isArray(data) && colorByPrimaryAxis) {
|
||||
transformedData = applyColorByPrimaryAxis(
|
||||
series,
|
||||
colorScale,
|
||||
sliceId,
|
||||
opacity,
|
||||
isHorizontal,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(transformedData) && plotType === 'bar') {
|
||||
transformedData = transformNegativeLabelsPosition(
|
||||
{ ...series, data: transformedData },
|
||||
isHorizontal,
|
||||
getBarLabelPosition(valueLabelPosition, isHorizontal, true),
|
||||
);
|
||||
}
|
||||
|
||||
const isAutoBarLabel =
|
||||
plotType === 'bar' && valueLabelPosition === BarValueLabelPosition.Auto;
|
||||
const isInsideBarLabel =
|
||||
plotType === 'bar' &&
|
||||
valueLabelPosition !== BarValueLabelPosition.OutsideEnd;
|
||||
|
||||
return {
|
||||
...series,
|
||||
...(Array.isArray(data) ? { data: transformedData } : null),
|
||||
...(Array.isArray(data)
|
||||
? colorByPrimaryAxis
|
||||
? {
|
||||
data: applyColorByPrimaryAxis(
|
||||
series,
|
||||
colorScale,
|
||||
sliceId,
|
||||
opacity,
|
||||
isHorizontal,
|
||||
),
|
||||
}
|
||||
: seriesType === 'bar' && !stack
|
||||
? { data: transformNegativeLabelsPosition(series, isHorizontal) }
|
||||
: null
|
||||
: null),
|
||||
connectNulls,
|
||||
queryIndex,
|
||||
yAxisIndex,
|
||||
@@ -596,22 +441,10 @@ export function transformSeries(
|
||||
showSymbol,
|
||||
symbol,
|
||||
symbolSize: symbolSizeFn ?? markerSize,
|
||||
...(isAutoBarLabel
|
||||
? {
|
||||
labelLayout: createAutoBarLabelLayout(transformedData, isHorizontal),
|
||||
}
|
||||
: {}),
|
||||
label: {
|
||||
show: !!showValue,
|
||||
position:
|
||||
plotType === 'bar'
|
||||
? getBarLabelPosition(valueLabelPosition, isHorizontal)
|
||||
: isHorizontal
|
||||
? 'right'
|
||||
: 'top',
|
||||
// ECharts derives contrast from the bar fill for inside positions.
|
||||
// Auto x/y overflow clears the position, selecting its outside fill.
|
||||
...(isInsideBarLabel ? {} : { color: theme?.colorText }),
|
||||
position: isHorizontal ? 'right' : 'top',
|
||||
color: theme?.colorText,
|
||||
textBorderWidth: 0,
|
||||
formatter: (params: any) => {
|
||||
// don't show confidence band value labels, as they're already visible on the tooltip
|
||||
@@ -634,14 +467,6 @@ export function transformSeries(
|
||||
return formatter(numericValue);
|
||||
}
|
||||
if (!onlyTotal) {
|
||||
// A stacked segment with no height begins and ends at the same
|
||||
// coordinate as the top of the segment beneath it, so its label is
|
||||
// drawn over that segment's label. Zero and null have no height, so
|
||||
// they carry no label. The rich tooltip omits zero observations from
|
||||
// a stacked series for the same reason.
|
||||
if (stack && !numericValue) {
|
||||
return '';
|
||||
}
|
||||
if (
|
||||
numericValue >=
|
||||
(thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)
|
||||
|
||||
@@ -52,14 +52,6 @@ export enum EchartsTimeseriesSeriesType {
|
||||
End = 'end',
|
||||
}
|
||||
|
||||
export enum BarValueLabelPosition {
|
||||
Auto = 'auto',
|
||||
InsideEnd = 'insideEnd',
|
||||
OutsideEnd = 'outsideEnd',
|
||||
InsideCenter = 'insideCenter',
|
||||
InsideBase = 'insideBase',
|
||||
}
|
||||
|
||||
export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
annotationLayers: AnnotationLayer[];
|
||||
area: boolean;
|
||||
@@ -107,7 +99,6 @@ export type EchartsTimeseriesFormData = QueryFormData & {
|
||||
xAxisLabelRotation: number;
|
||||
xAxisLabelInterval: number | string;
|
||||
showValue: boolean;
|
||||
valueLabelPosition: BarValueLabelPosition;
|
||||
onlyTotal: boolean;
|
||||
showExtraControls: boolean;
|
||||
percentageThreshold: number;
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
StackControlOptionsWithoutStream,
|
||||
} from './constants';
|
||||
import { DEFAULT_FORM_DATA } from './Timeseries/constants';
|
||||
import { BarValueLabelPosition } from './Timeseries/types';
|
||||
import { defaultXAxis } from './defaults';
|
||||
|
||||
const { legendMargin, legendOrientation, legendType, showLegend } =
|
||||
@@ -141,28 +140,6 @@ export const showValueControl: ControlSetItem = {
|
||||
},
|
||||
};
|
||||
|
||||
export const valueLabelPositionControl: ControlSetItem = {
|
||||
name: 'value_label_position',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
freeForm: false,
|
||||
clearable: false,
|
||||
label: t('Value label position'),
|
||||
choices: [
|
||||
[BarValueLabelPosition.Auto, t('Auto')],
|
||||
[BarValueLabelPosition.InsideEnd, t('Inside End')],
|
||||
[BarValueLabelPosition.OutsideEnd, t('Outside End')],
|
||||
[BarValueLabelPosition.InsideCenter, t('Inside Center')],
|
||||
[BarValueLabelPosition.InsideBase, t('Inside Base')],
|
||||
],
|
||||
default: DEFAULT_FORM_DATA.valueLabelPosition,
|
||||
renderTrigger: true,
|
||||
description: t('Choose where to display values relative to the bars'),
|
||||
visibility: ({ controls }: ControlPanelsContainerProps) =>
|
||||
Boolean(controls?.show_value?.value),
|
||||
},
|
||||
};
|
||||
|
||||
export const colorByPrimaryAxisControl: ControlSetItem = {
|
||||
name: 'color_by_primary_axis',
|
||||
config: {
|
||||
@@ -258,7 +235,6 @@ export const showValueSectionWithoutStack: ControlSetRow[] = [
|
||||
|
||||
export const showValueSectionWithoutStream: ControlSetRow[] = [
|
||||
[showValueControl],
|
||||
[valueLabelPositionControl],
|
||||
[stackControlWithoutStream],
|
||||
[onlyTotalControl],
|
||||
[percentageThresholdControl],
|
||||
|
||||
-56
@@ -43,7 +43,6 @@ import {
|
||||
} from '../../src/MixedTimeseries/types';
|
||||
import { createEchartsTimeseriesTestChartProps } from '../helpers';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
import type { BarSeriesOption } from 'echarts/charts';
|
||||
|
||||
type LabelFormatterParams = {
|
||||
value: [number, number];
|
||||
@@ -194,61 +193,6 @@ function formatSeriesLabel(
|
||||
});
|
||||
}
|
||||
|
||||
test('bar value labels retain their legacy outside position', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, showValueB: true },
|
||||
queriesData,
|
||||
});
|
||||
|
||||
const transformed = transformProps(chartProps);
|
||||
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
|
||||
(series): series is BarSeriesOption => series.type === 'bar',
|
||||
);
|
||||
|
||||
expect(barSeries).not.toHaveLength(0);
|
||||
barSeries.forEach(series => {
|
||||
expect(series.label).toMatchObject({ show: true, position: 'top' });
|
||||
expect(series.labelLayout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('negative bar values retain their legacy outside position', () => {
|
||||
const negativeRows = [
|
||||
{ boy: -1, girl: -2, ds: 599616000000 },
|
||||
{ boy: -3, girl: -4, ds: 599916000000 },
|
||||
];
|
||||
const negativeQueriesData = [
|
||||
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
|
||||
createTestQueryData(negativeRows, { label_map: defaultLabelMap }),
|
||||
];
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: negativeQueriesData,
|
||||
formData: { ...formData, showValueB: true },
|
||||
queriesData: negativeQueriesData,
|
||||
});
|
||||
|
||||
const transformed = transformProps(chartProps);
|
||||
const barSeries = (transformed.echartOptions.series as SeriesOption[]).filter(
|
||||
(series): series is BarSeriesOption => series.type === 'bar',
|
||||
);
|
||||
|
||||
expect(barSeries).not.toHaveLength(0);
|
||||
barSeries.forEach(series => {
|
||||
expect(series.data?.[0]).toMatchObject({
|
||||
label: { position: 'bottom' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
|
||||
+1
-39
@@ -23,10 +23,7 @@ import {
|
||||
StackControlOptionsWithoutStream,
|
||||
StackControlsValue,
|
||||
} from '../../../src/constants';
|
||||
import {
|
||||
BarValueLabelPosition,
|
||||
OrientationType,
|
||||
} from '../../../src/Timeseries/types';
|
||||
import { OrientationType } from '../../../src/Timeseries/types';
|
||||
|
||||
const config = controlPanel;
|
||||
|
||||
@@ -135,41 +132,6 @@ test('should include stack control in the panel', () => {
|
||||
expect(stackControl).toBeDefined();
|
||||
});
|
||||
|
||||
test('should expose Auto and manual value label positions for Bar charts', () => {
|
||||
const valueLabelPositionControl = getControl(
|
||||
'value_label_position',
|
||||
) as unknown as {
|
||||
config: {
|
||||
choices: [BarValueLabelPosition, string][];
|
||||
default: BarValueLabelPosition;
|
||||
visibility: (props: ControlPanelsContainerProps) => boolean;
|
||||
};
|
||||
};
|
||||
|
||||
expect(valueLabelPositionControl.config.default).toBe(
|
||||
BarValueLabelPosition.Auto,
|
||||
);
|
||||
expect(
|
||||
valueLabelPositionControl.config.choices.map(([value]) => value),
|
||||
).toEqual([
|
||||
BarValueLabelPosition.Auto,
|
||||
BarValueLabelPosition.InsideEnd,
|
||||
BarValueLabelPosition.OutsideEnd,
|
||||
BarValueLabelPosition.InsideCenter,
|
||||
BarValueLabelPosition.InsideBase,
|
||||
]);
|
||||
expect(
|
||||
valueLabelPositionControl.config.visibility({
|
||||
controls: { show_value: { value: true } },
|
||||
} as unknown as ControlPanelsContainerProps),
|
||||
).toBe(true);
|
||||
expect(
|
||||
valueLabelPositionControl.config.visibility({
|
||||
controls: { show_value: { value: false } },
|
||||
} as unknown as ControlPanelsContainerProps),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('should use StackControlOptionsWithoutStream for stack control', () => {
|
||||
const stackControl: any = getControl('stack');
|
||||
expect(stackControl).toBeDefined();
|
||||
|
||||
-101
@@ -29,7 +29,6 @@ import type {
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
} from 'echarts/components';
|
||||
import type { BarSeriesOption } from 'echarts/charts';
|
||||
import {
|
||||
EchartsTimeseriesChartProps,
|
||||
LegendOrientation,
|
||||
@@ -38,7 +37,6 @@ import {
|
||||
import transformProps from '../../../src/Timeseries/transformProps';
|
||||
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
|
||||
import {
|
||||
BarValueLabelPosition,
|
||||
EchartsTimeseriesFormData,
|
||||
OrientationType,
|
||||
EchartsTimeseriesSeriesType,
|
||||
@@ -76,105 +74,6 @@ function createTestQueryData(
|
||||
};
|
||||
}
|
||||
|
||||
test('manual Bar value label position flows through transformProps', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesChartProps
|
||||
>({
|
||||
defaultFormData: DEFAULT_FORM_DATA,
|
||||
defaultVizType: 'echarts_timeseries_bar',
|
||||
formData: {
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
|
||||
metrics: ['Sales'],
|
||||
xAxis: '__timestamp',
|
||||
showValue: true,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
|
||||
colnames: ['Sales', '__timestamp'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const [series] = echartOptions.series as BarSeriesOption[];
|
||||
|
||||
expect(series.label).toMatchObject({ position: 'top' });
|
||||
expect(series.labelLayout).toBeUndefined();
|
||||
expect(echartOptions.darkMode).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Auto Bar labels enable theme-aware ECharts contrast', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesChartProps
|
||||
>({
|
||||
defaultFormData: DEFAULT_FORM_DATA,
|
||||
defaultVizType: 'echarts_timeseries_bar',
|
||||
formData: {
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
metrics: ['Sales'],
|
||||
xAxis: '__timestamp',
|
||||
showValue: true,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
|
||||
colnames: ['Sales', '__timestamp'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const [series] = echartOptions.series as BarSeriesOption[];
|
||||
|
||||
expect(typeof series.labelLayout).toBe('function');
|
||||
expect(echartOptions.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
test('legacy Bar labels without a saved position enable Auto contrast', () => {
|
||||
const legacyFormData: Partial<EchartsTimeseriesFormData> = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
};
|
||||
delete legacyFormData.valueLabelPosition;
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsTimeseriesFormData,
|
||||
EchartsTimeseriesChartProps
|
||||
>({
|
||||
defaultFormData: legacyFormData as EchartsTimeseriesFormData,
|
||||
defaultVizType: 'echarts_timeseries_bar',
|
||||
formData: {
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
metrics: ['Sales'],
|
||||
xAxis: '__timestamp',
|
||||
showValue: true,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData([{ Sales: 100, __timestamp: 1609459200000 }], {
|
||||
colnames: ['Sales', '__timestamp'],
|
||||
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(chartProps.formData).not.toHaveProperty('valueLabelPosition');
|
||||
const { echartOptions } = transformProps(chartProps);
|
||||
const [series] = echartOptions.series as BarSeriesOption[];
|
||||
|
||||
expect(typeof series.labelLayout).toBe('function');
|
||||
expect(echartOptions.darkMode).toBe(false);
|
||||
|
||||
Reflect.set(chartProps.formData, 'valueLabelPosition', undefined);
|
||||
const undefinedPositionOptions = transformProps(chartProps).echartOptions;
|
||||
const [undefinedPositionSeries] =
|
||||
undefinedPositionOptions.series as BarSeriesOption[];
|
||||
|
||||
expect(typeof undefinedPositionSeries.labelLayout).toBe('function');
|
||||
expect(undefinedPositionOptions.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
describe('Bar Chart X-axis Time Formatting', () => {
|
||||
const baseFormData: SqlaFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
|
||||
+4
-487
@@ -19,19 +19,14 @@
|
||||
import {
|
||||
CategoricalColorScale,
|
||||
ChartProps,
|
||||
getNumberFormatter,
|
||||
TimeGranularity,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { init, type SeriesOption } from 'echarts';
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
LineSeriesOption,
|
||||
ScatterSeriesOption,
|
||||
} from 'echarts/charts';
|
||||
import { BarValueLabelPosition, EchartsTimeseriesSeriesType } from '../../src';
|
||||
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
import type { ScatterSeriesOption } from 'echarts/charts';
|
||||
import { EchartsTimeseriesSeriesType } from '../../src';
|
||||
import { TIMESERIES_CONSTANTS } from '../../src/constants';
|
||||
import {
|
||||
LegendOrientation,
|
||||
EchartsTimeseriesChartProps,
|
||||
@@ -39,7 +34,6 @@ import {
|
||||
import {
|
||||
transformSeries,
|
||||
transformNegativeLabelsPosition,
|
||||
getAutoBarLabelLayout,
|
||||
getPadding,
|
||||
} from '../../src/Timeseries/transformers';
|
||||
import transformProps from '../../src/Timeseries/transformProps';
|
||||
@@ -166,416 +160,6 @@ describe('transformSeries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('Auto bar labels move outside narrow stacked segments', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
showValue: true,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
const { labelLayout } = result;
|
||||
|
||||
expect(result.label).toMatchObject({
|
||||
show: true,
|
||||
position: 'insideTop',
|
||||
});
|
||||
expect((result.label as { color?: string }).color).toBeUndefined();
|
||||
expect(typeof labelLayout).toBe('function');
|
||||
if (typeof labelLayout !== 'function') return;
|
||||
|
||||
expect(
|
||||
labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '1,000',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 12, height: 20 },
|
||||
labelRect: { x: 1, y: 22, width: 30, height: 14 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 16,
|
||||
y: 15,
|
||||
align: 'center',
|
||||
verticalAlign: 'bottom',
|
||||
});
|
||||
});
|
||||
|
||||
test('Auto labels stay inside when both dimensions fit within 80% of the bar', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{ seriesType: EchartsTimeseriesSeriesType.Bar },
|
||||
) as BarSeriesOption;
|
||||
const { labelLayout } = result;
|
||||
|
||||
expect(typeof labelLayout).toBe('function');
|
||||
if (typeof labelLayout !== 'function') return;
|
||||
|
||||
expect(
|
||||
labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '1,000',
|
||||
align: 'center',
|
||||
verticalAlign: 'top',
|
||||
rect: { x: 10, y: 20, width: 50, height: 40 },
|
||||
labelRect: { x: 19, y: 25, width: 32, height: 14 },
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('Auto moves wide labels outside tall narrow vertical bars', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 100]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{ seriesType: EchartsTimeseriesSeriesType.Bar },
|
||||
) as BarSeriesOption;
|
||||
const { labelLayout } = result;
|
||||
|
||||
expect(typeof labelLayout).toBe('function');
|
||||
if (typeof labelLayout !== 'function') return;
|
||||
|
||||
expect(
|
||||
labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '1,000',
|
||||
align: 'center',
|
||||
verticalAlign: 'top',
|
||||
rect: { x: 10, y: 20, width: 12, height: 200 },
|
||||
labelRect: { x: 1, y: 25, width: 30, height: 14 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 16,
|
||||
y: 15,
|
||||
align: 'center',
|
||||
verticalAlign: 'bottom',
|
||||
});
|
||||
});
|
||||
|
||||
test('Auto overflow uses ECharts outside-label text color', () => {
|
||||
const darkBarColorScale = jest.fn(() => '#111111');
|
||||
const series = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[0, 123456789012]] },
|
||||
darkBarColorScale as unknown as CategoricalColorScale,
|
||||
'test-key',
|
||||
{
|
||||
formatter: getNumberFormatter('d'),
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
showValue: true,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
const chart = init(null, null, {
|
||||
renderer: 'svg',
|
||||
ssr: true,
|
||||
width: 300,
|
||||
height: 220,
|
||||
});
|
||||
|
||||
chart.setOption({
|
||||
animation: false,
|
||||
darkMode: false,
|
||||
xAxis: { type: 'category', data: ['A'], show: false },
|
||||
// A tall bar (well above the segment-legibility floor) whose 12-digit
|
||||
// label is too wide to fit inside, so ECharts still moves it outside.
|
||||
yAxis: { type: 'value', max: 250_000_000_000, show: false },
|
||||
series: [series],
|
||||
});
|
||||
|
||||
expect(chart.renderToSVGString()).toMatch(
|
||||
/fill="#333"[^>]*>123456789012<\/text>/,
|
||||
);
|
||||
chart.dispose();
|
||||
});
|
||||
|
||||
test('Auto bar labels use horizontal bar length and move to the value end', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[1, 2026]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{ seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
|
||||
) as BarSeriesOption;
|
||||
const { labelLayout } = result;
|
||||
|
||||
expect(typeof labelLayout).toBe('function');
|
||||
if (typeof labelLayout !== 'function') return;
|
||||
|
||||
expect(
|
||||
labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '1,000',
|
||||
align: 'right',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 20, height: 12 },
|
||||
labelRect: { x: 0, y: 19, width: 30, height: 14 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 35,
|
||||
y: 26,
|
||||
align: 'left',
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
[BarValueLabelPosition.InsideEnd, 'insideTop'],
|
||||
[BarValueLabelPosition.OutsideEnd, 'top'],
|
||||
[BarValueLabelPosition.InsideCenter, 'inside'],
|
||||
[BarValueLabelPosition.InsideBase, 'insideBottom'],
|
||||
] as const)(
|
||||
'manual %s bar labels use fixed position %s',
|
||||
(position, expected) => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 1]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
valueLabelPosition: position,
|
||||
theme: supersetTheme,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
|
||||
expect(result.labelLayout).toBeUndefined();
|
||||
expect(result.label).toMatchObject({ position: expected });
|
||||
if (position === BarValueLabelPosition.OutsideEnd) {
|
||||
expect(result.label).toMatchObject({ color: supersetTheme.colorText });
|
||||
} else {
|
||||
expect(result.label).not.toHaveProperty('color');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('manual Outside End positions negative stacked segments below the bar', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, -1]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
valueLabelPosition: BarValueLabelPosition.OutsideEnd,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
value: [2026, -1],
|
||||
label: { position: 'bottom' },
|
||||
},
|
||||
]);
|
||||
expect(result.labelLayout).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Auto positions negative stacked segments at their inside end', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, -1]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
value: [2026, -1],
|
||||
label: { position: 'insideBottom' },
|
||||
},
|
||||
]);
|
||||
expect(typeof result.labelLayout).toBe('function');
|
||||
if (typeof result.labelLayout !== 'function') return;
|
||||
expect(
|
||||
result.labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '-1,000',
|
||||
align: 'center',
|
||||
verticalAlign: 'bottom',
|
||||
rect: { x: 10, y: 20, width: 12, height: 30 },
|
||||
labelRect: { x: 1, y: 35, width: 30, height: 14 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 16,
|
||||
y: 55,
|
||||
align: 'center',
|
||||
verticalAlign: 'top',
|
||||
});
|
||||
});
|
||||
|
||||
test('Auto moves horizontal negative labels beyond their value end', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[-1, 2026]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{ seriesType: EchartsTimeseriesSeriesType.Bar, isHorizontal: true },
|
||||
) as BarSeriesOption;
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
value: [-1, 2026],
|
||||
label: { position: 'insideLeft' },
|
||||
},
|
||||
]);
|
||||
expect(typeof result.labelLayout).toBe('function');
|
||||
if (typeof result.labelLayout !== 'function') return;
|
||||
expect(
|
||||
result.labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '-1,000',
|
||||
align: 'left',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 20, height: 12 },
|
||||
labelRect: { x: 10, y: 19, width: 30, height: 14 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 5,
|
||||
y: 26,
|
||||
align: 'right',
|
||||
verticalAlign: 'middle',
|
||||
});
|
||||
});
|
||||
|
||||
test('Auto label layout does not change non-Bar series', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'line', data: [[2026, 1]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
theme: supersetTheme,
|
||||
},
|
||||
) as LineSeriesOption;
|
||||
|
||||
expect(result).not.toHaveProperty('labelLayout');
|
||||
expect(result.label).toMatchObject({
|
||||
position: 'top',
|
||||
color: supersetTheme.colorText,
|
||||
});
|
||||
});
|
||||
|
||||
test('Auto suppresses the label for a vertical segment below the legibility floor', () => {
|
||||
// A 10px-tall stacked segment can't legibly fit its 14px-tall label inside
|
||||
// or outside without colliding with a neighboring segment's label.
|
||||
expect(
|
||||
getAutoBarLabelLayout(
|
||||
{
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 40, height: 10 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test('Auto keeps placing labels normally for a vertical segment at the legibility floor', () => {
|
||||
expect(
|
||||
getAutoBarLabelLayout(
|
||||
{
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 40, height: 16 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
},
|
||||
false,
|
||||
),
|
||||
).not.toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test('Auto suppresses the label for a horizontal segment below the legibility floor', () => {
|
||||
// Horizontal bars stack along the x axis, so the value-axis dimension that
|
||||
// matters is rect.width rather than rect.height.
|
||||
expect(
|
||||
getAutoBarLabelLayout(
|
||||
{
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'left',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 10, height: 40 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test('Auto suppresses labels for tiny adjacent stacked segments end to end', () => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
showValue: true,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
const { labelLayout } = result;
|
||||
|
||||
expect(typeof labelLayout).toBe('function');
|
||||
if (typeof labelLayout !== 'function') return;
|
||||
|
||||
expect(
|
||||
labelLayout({
|
||||
dataIndex: 0,
|
||||
seriesIndex: 0,
|
||||
text: '0.14',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
rect: { x: 10, y: 20, width: 40, height: 8 },
|
||||
labelRect: { x: 12, y: 22, width: 20, height: 14 },
|
||||
}),
|
||||
).toEqual({ fontSize: 0 });
|
||||
});
|
||||
|
||||
test.each([
|
||||
[BarValueLabelPosition.InsideEnd, 'insideTop'],
|
||||
[BarValueLabelPosition.OutsideEnd, 'top'],
|
||||
[BarValueLabelPosition.InsideCenter, 'inside'],
|
||||
[BarValueLabelPosition.InsideBase, 'insideBottom'],
|
||||
] as const)(
|
||||
'manual %s label placement is unaffected by tiny segments (no labelLayout applied)',
|
||||
(position, expected) => {
|
||||
const result = transformSeries(
|
||||
{ name: 'test-series', type: 'bar', data: [[2026, 0.14]] },
|
||||
mockColorScale,
|
||||
'test-key',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
valueLabelPosition: position,
|
||||
showValue: true,
|
||||
theme: supersetTheme,
|
||||
},
|
||||
) as BarSeriesOption;
|
||||
|
||||
// Manual positions don't use the fit-aware labelLayout callback at all,
|
||||
// so a tiny segment can't trigger the Auto-only suppression behavior.
|
||||
expect(result.labelLayout).toBeUndefined();
|
||||
expect(result.label).toMatchObject({ position: expected });
|
||||
},
|
||||
);
|
||||
|
||||
describe('transformNegativeLabelsPosition', () => {
|
||||
test('label position bottom of negative value no Horizontal', () => {
|
||||
const isHorizontal = false;
|
||||
@@ -982,70 +566,3 @@ test('getPadding should handle Left position with zero margin correctly', () =>
|
||||
getChartPaddingSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* #42702: a stacked segment with no height starts and ends at the same
|
||||
* coordinate as the top of the segment beneath it, so a value label on it is
|
||||
* drawn over that segment's label. `percentage_threshold` does not filter these
|
||||
* out: it defaults to 0, and `thresholdValues[dataIndex] || MIN_SAFE_INTEGER`
|
||||
* turns a 0 threshold into "no filtering", which is intentional.
|
||||
*/
|
||||
const stackedLabel = (
|
||||
numericValue: number | null,
|
||||
opts: Record<string, unknown> = {},
|
||||
) => {
|
||||
const series = transformSeries(
|
||||
{ id: 'B', name: 'B', data: [[1, numericValue]] } as SeriesOption,
|
||||
mockColorScale,
|
||||
'B',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
showValue: true,
|
||||
onlyTotal: false,
|
||||
formatter: getNumberFormatter(),
|
||||
thresholdValues: [0],
|
||||
...opts,
|
||||
},
|
||||
) as SeriesOption & {
|
||||
label: { formatter: (params: unknown) => string };
|
||||
};
|
||||
return series.label.formatter({
|
||||
value: [1, numericValue],
|
||||
dataIndex: 0,
|
||||
seriesIndex: 1,
|
||||
seriesName: 'B',
|
||||
});
|
||||
};
|
||||
|
||||
test('stacked value labels are omitted for a zero-height segment', () => {
|
||||
expect(stackedLabel(0)).toBe('');
|
||||
expect(stackedLabel(null)).toBe('');
|
||||
});
|
||||
|
||||
test('stacked value labels are kept for segments that have height', () => {
|
||||
expect(stackedLabel(32)).toBe('32');
|
||||
expect(stackedLabel(-5)).toBe('-5');
|
||||
});
|
||||
|
||||
test('a zero value keeps its label when the series is not stacked', () => {
|
||||
// Without a stack the label sits on the bar itself, so there is nothing for
|
||||
// it to collide with.
|
||||
expect(stackedLabel(0, { stack: undefined })).toBe('0');
|
||||
});
|
||||
|
||||
test('percentage_threshold still filters values below the threshold', () => {
|
||||
// 10% of a 100 total. The zero-height guard must not swallow this rule.
|
||||
expect(stackedLabel(5, { thresholdValues: [10] })).toBe('');
|
||||
expect(stackedLabel(50, { thresholdValues: [10] })).toBe('50');
|
||||
});
|
||||
|
||||
test('only-total labels are unaffected by the zero-height guard', () => {
|
||||
expect(
|
||||
stackedLabel(0, {
|
||||
onlyTotal: true,
|
||||
showValueIndexes: [1],
|
||||
totalStackedValues: [32],
|
||||
}),
|
||||
).toBe('32');
|
||||
});
|
||||
|
||||
@@ -223,11 +223,6 @@ export default function chartReducer(
|
||||
}
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
// ADD_CHART creates the entry, so it runs without prior state; every other
|
||||
// handler reads state that is absent once the chart has been removed
|
||||
if (action.type !== actions.ADD_CHART && !charts[action.key]) {
|
||||
return charts;
|
||||
}
|
||||
return {
|
||||
...charts,
|
||||
[action.key]: actionHandlers[action.type](charts[action.key]),
|
||||
|
||||
@@ -91,20 +91,4 @@ describe('chart reducers', () => {
|
||||
expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0);
|
||||
expect(newState[chartKey].chartStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
test('ignores an action for a chart that is no longer in state', () => {
|
||||
const action = actions.chartUpdateStopped(999, new AbortController());
|
||||
expect(() => chartReducer(charts, action)).not.toThrow();
|
||||
expect(chartReducer(charts, action)).toEqual(charts);
|
||||
});
|
||||
|
||||
test('still adds a chart that is not yet in state', () => {
|
||||
const newChartKey = 2;
|
||||
const newState = chartReducer(
|
||||
charts,
|
||||
actions.addChart({ ...chart, id: newChartKey }, newChartKey),
|
||||
);
|
||||
expect(newState[newChartKey].id).toEqual(newChartKey);
|
||||
expect(newState[chartKey]).toEqual(testChart);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-1
@@ -1627,7 +1627,9 @@ function DatasourceEditor({
|
||||
{t(
|
||||
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
|
||||
)}{' '}
|
||||
<Typography.Text code>/dashboard/{'{id}'}/</Typography.Text>
|
||||
<Typography.Text code>
|
||||
/superset/dashboard/{'{id}'}/
|
||||
</Typography.Text>
|
||||
</>
|
||||
}
|
||||
control={<TextControl controlId="default_endpoint" />}
|
||||
|
||||
-11
@@ -71,17 +71,6 @@ test('renders Tabs', async () => {
|
||||
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recommends a registered client route for the default URL', async () => {
|
||||
await asyncRender(createProps());
|
||||
|
||||
userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
||||
|
||||
expect(await screen.findByText('/dashboard/{id}/')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('/superset/dashboard/{id}/'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('can sync columns from source', async () => {
|
||||
const testProps = createProps();
|
||||
await asyncRender({
|
||||
|
||||
@@ -468,7 +468,7 @@ function SliceAdder({
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
style={{ width, height, maxHeight: height }}
|
||||
style={{ width, height }}
|
||||
rowCount={filteredSlices.length}
|
||||
rowHeight={DEFAULT_CELL_HEIGHT}
|
||||
rowProps={listRowProps}
|
||||
|
||||
@@ -105,17 +105,10 @@ export const SamplesPane = ({
|
||||
1,
|
||||
)
|
||||
.then(response => {
|
||||
// A 200 that carries no `result` payload resolves to undefined here.
|
||||
// Read through it so the pane falls back to its empty state instead
|
||||
// of throwing a TypeError that surfaces as an internal error message.
|
||||
const rows = ensureIsArray(response?.data);
|
||||
setData(rows);
|
||||
setColnames(ensureIsArray(response?.colnames));
|
||||
setColtypes(ensureIsArray(response?.coltypes));
|
||||
// Fall back to the rows actually returned rather than to zero: the
|
||||
// controls only render when there are rows, and a hardcoded 0 would
|
||||
// label a populated table as "0 rows".
|
||||
setRowCount(response?.rowcount ?? rows.length);
|
||||
setData(ensureIsArray(response.data));
|
||||
setColnames(ensureIsArray(response.colnames));
|
||||
setColtypes(ensureIsArray(response.coltypes));
|
||||
setRowCount(response.rowcount);
|
||||
setResponseError('');
|
||||
cache.set(queryFormData, true);
|
||||
if (queryForce) {
|
||||
|
||||
@@ -60,27 +60,6 @@ describe('SamplesPane', () => {
|
||||
400,
|
||||
);
|
||||
|
||||
// A 200 response that carries no `result` payload, as reported in #36840.
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=37&per_page=100&page=1',
|
||||
{},
|
||||
);
|
||||
|
||||
// A 200 whose result carries rows but omits `rowcount`.
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=38&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [
|
||||
{ __timestamp: 1230768000000, genre: 'Action' },
|
||||
{ __timestamp: 1230768000010, genre: 'Horror' },
|
||||
],
|
||||
colnames: ['__timestamp', 'genre'],
|
||||
coltypes: [2, 1],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const setForceQuery = jest.fn();
|
||||
|
||||
afterAll(() => {
|
||||
@@ -135,29 +114,4 @@ describe('SamplesPane', () => {
|
||||
expect(queryByText('Action')).toBeVisible();
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the empty state when the response carries no result payload', async () => {
|
||||
const props = createSamplesPaneProps({ datasourceId: 37 });
|
||||
const { findByText, queryByRole } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await findByText('No samples were returned for this dataset'),
|
||||
).toBeVisible();
|
||||
// The pane should not leak an internal TypeError through the error alert.
|
||||
expect(queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('counts the returned rows when the response omits rowcount', async () => {
|
||||
const props = createSamplesPaneProps({ datasourceId: 38 });
|
||||
const { findByText, queryByText } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(await findByText('Action')).toBeVisible();
|
||||
// Falling back to 0 here would label a populated table as "0 rows".
|
||||
expect(queryByText('0 rows')).not.toBeInTheDocument();
|
||||
expect(queryByText('2 rows')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ export const DatasourceItems = ({
|
||||
|
||||
return (
|
||||
<List
|
||||
style={{ width: width - BORDER_WIDTH, height, maxHeight: height }}
|
||||
style={{ width: width - BORDER_WIDTH, height }}
|
||||
rowHeight={rowHeight}
|
||||
rowCount={flattenedItems.length}
|
||||
rowProps={rowProps}
|
||||
|
||||
@@ -251,11 +251,4 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
],
|
||||
[GenericDataType.MultiValue]: [
|
||||
[
|
||||
'columnWidth',
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
['truncateLongCells'],
|
||||
],
|
||||
};
|
||||
|
||||
+32
-68
@@ -207,6 +207,38 @@ describe('AdhocFilter', () => {
|
||||
expect(adhocFilter10.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
test('is invalid when a comparator-taking operator has no comparator', () => {
|
||||
// A comparator that was never set, or that was cleared through the value
|
||||
// Select's clear affordance, is `undefined` rather than `null` or `[]`.
|
||||
const adhocFilter1 = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'is_intro',
|
||||
operator: 'IN',
|
||||
comparator: undefined,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter1.isValid()).toBe(false);
|
||||
|
||||
const adhocFilter2 = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'is_intro',
|
||||
operator: '==',
|
||||
comparator: undefined,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter2.isValid()).toBe(false);
|
||||
|
||||
// `false` is a legitimate boolean comparator, not a missing value
|
||||
const adhocFilter3 = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'is_intro',
|
||||
operator: '==',
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter3.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
test('can translate from simple expressions to sql expressions', () => {
|
||||
const adhocFilter1 = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
@@ -270,74 +302,6 @@ describe('AdhocFilter', () => {
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(undefined);
|
||||
});
|
||||
// Charts saved before #32701 persisted `==` as the operation for IS_TRUE and
|
||||
// IS_FALSE, alongside a boolean comparator. `translateToSql` and the backend
|
||||
// both key off `operator`, so dropping the comparator would render such a
|
||||
// filter as `col =` and query it as `col IS NULL`.
|
||||
test('keeps the legacy boolean comparator for IS_TRUE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('keeps the legacy boolean comparator for IS_FALSE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsFalse,
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(false);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'FALSE'");
|
||||
});
|
||||
test('restores the boolean even when the stored comparator is missing', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
});
|
||||
test('keeps a legacy boolean filter intact when the control re-posts it', () => {
|
||||
const stored = {
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
};
|
||||
// DndFilterSelect wraps props.value and hands those instances to onChange
|
||||
const posted = JSON.parse(JSON.stringify(new AdhocFilter(stored)));
|
||||
expect(posted.operator).toBe('==');
|
||||
expect(posted.comparator).toBe(true);
|
||||
expect(posted.operatorId).toBe(Operators.IsTrue);
|
||||
});
|
||||
test('leaves a genuine equality filter on a boolean value alone', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.Equals,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('sets the label properly if subject is a string', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
|
||||
+4
-21
@@ -30,15 +30,6 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
|
||||
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
||||
);
|
||||
|
||||
// Charts saved before #32701 store `==` for IS_TRUE/IS_FALSE with the boolean
|
||||
// in the comparator; blanking it makes them query `col IS NULL`. Restoring it
|
||||
// leaves the emitted SQL untouched -- reconciling `operator` to `IS TRUE`
|
||||
// would not, and Druid rejects that predicate on VARCHAR columns.
|
||||
const LEGACY_BOOLEAN_COMPARATORS = new Map<string, boolean>([
|
||||
[Operators.IsTrue, true],
|
||||
[Operators.IsFalse, false],
|
||||
]);
|
||||
|
||||
interface AdhocFilterInput {
|
||||
expressionType?: string;
|
||||
subject?: string | { column_name?: string; [key: string]: unknown } | null;
|
||||
@@ -86,16 +77,6 @@ export default class AdhocFilter {
|
||||
) {
|
||||
this.comparator = undefined;
|
||||
}
|
||||
if (
|
||||
this.operator ===
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation &&
|
||||
adhocFilter.operatorId &&
|
||||
LEGACY_BOOLEAN_COMPARATORS.has(adhocFilter.operatorId)
|
||||
) {
|
||||
this.comparator = LEGACY_BOOLEAN_COMPARATORS.get(
|
||||
adhocFilter.operatorId,
|
||||
);
|
||||
}
|
||||
this.clause = adhocFilter.clause || Clauses.Where;
|
||||
this.sqlExpression = null;
|
||||
} else if (this.expressionType === ExpressionTypes.Sql) {
|
||||
@@ -182,8 +163,10 @@ export default class AdhocFilter {
|
||||
// A non-empty array of values ('IN' or 'NOT IN' clauses)
|
||||
return this.comparator.length > 0;
|
||||
}
|
||||
// A value has been selected or typed
|
||||
return this.comparator !== null;
|
||||
// A value has been selected or typed. An unset comparator is
|
||||
// `undefined` rather than `null`: picking a new subject resets it, and
|
||||
// the value Select's clear affordance emits `undefined` too.
|
||||
return this.comparator != null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -181,6 +181,29 @@ describe('AdhocFilterEditPopover', () => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables save button when a boolean column has no value selected', async () => {
|
||||
const booleanColumn = { type: 'BOOL', column_name: 'is_intro' };
|
||||
renderPopover({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
clause: Clauses.Where,
|
||||
}),
|
||||
options: [booleanColumn],
|
||||
datasource: { columns: [booleanColumn], filter_select: false },
|
||||
});
|
||||
|
||||
// Picking the subject resets the comparator to `undefined`; the value
|
||||
// control is then left untouched, mirroring the reported repro.
|
||||
await userEvent.click(screen.getByTestId('select-element'));
|
||||
await userEvent.click(
|
||||
await screen.findByRole('option', { name: /is_intro/ }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('adhoc-filter-edit-popover-save-button'),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('initiates resize when resize handle is dragged', async () => {
|
||||
const onResize = jest.fn();
|
||||
renderPopover({ onResize });
|
||||
|
||||
+2
-16
@@ -367,22 +367,8 @@ function AdhocFilterEditPopover({
|
||||
</ErrorBoundary>
|
||||
),
|
||||
},
|
||||
...(datasource?.type === 'semantic_view' ||
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
].includes(adhocFilter.operatorId as Operators)
|
||||
? // Hide the Custom SQL tab for element-level array operators: they
|
||||
// have no portable SQL representation, and converting one would
|
||||
// silently turn the filter into invalid raw SQL.
|
||||
[]
|
||||
...(datasource?.type === 'semantic_view'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: ExpressionTypes.Sql,
|
||||
|
||||
-138
@@ -35,7 +35,6 @@ import {
|
||||
} from 'src/explore/constants';
|
||||
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
|
||||
@@ -253,78 +252,6 @@ test('shows boolean only operators when subject is number', () => {
|
||||
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
|
||||
});
|
||||
|
||||
test('shows array operators (tier 1 + tier 2) when subject is multi-value', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'skills',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [
|
||||
{
|
||||
id: 3,
|
||||
column_name: 'skills',
|
||||
type: 'Array(String)',
|
||||
type_generic: GenericDataType.MultiValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
// Tier 1 (whole-array) + Tier 2 (element-level) are all relevant.
|
||||
[
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'skills')).toBe(true),
|
||||
);
|
||||
// scalar-only operators are hidden for array columns
|
||||
[Operators.GreaterThan, Operators.LessThan, Operators.Like].forEach(
|
||||
operator => expect(isOperatorRelevant(operator, 'skills')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('hides element-level array operators for non multi-value columns', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [{ id: 3, column_name: 'value', type: 'STRING' }],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('will convert from individual comparator to array if the operator changes to multi', () => {
|
||||
const props = setup();
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
@@ -382,49 +309,6 @@ test('will convert from array to individual comparators if the operator changes
|
||||
);
|
||||
});
|
||||
|
||||
test('resets the comparator when switching between array value families', () => {
|
||||
// Equal to (whole-array literal) -> Contains all (individual elements):
|
||||
// the value spaces are incompatible, so the stale value must be cleared.
|
||||
const wholeArrayFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.Equals,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
|
||||
comparator: '[5,6,7]',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: wholeArrayFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.operatorId).toEqual(Operators.ContainsAll);
|
||||
expect(lastCall.comparator).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps the value when switching within the element family', () => {
|
||||
// Contains any <-> Contains all both take individual elements, so the
|
||||
// selected elements should carry over.
|
||||
const elementFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.ContainsAny,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.ContainsAny].operation,
|
||||
comparator: ['5', '6'],
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: elementFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.comparator).toEqual(['5', '6']);
|
||||
});
|
||||
|
||||
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
|
||||
const props = setup();
|
||||
const { onComparatorChange } = useSimpleTabFilterProps(
|
||||
@@ -515,28 +399,6 @@ test('will not display boolean operators when column type is string', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each(['STRING', 'DATE'])(
|
||||
'will not display boolean operators when an expression column declares type %s',
|
||||
type => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
type: 'table' as const,
|
||||
datasource_name: 'table1',
|
||||
schema: 'schema',
|
||||
columns: [{ column_name: 'value', type, expression: '"value"' }],
|
||||
},
|
||||
adhocFilter: simpleAdhocFilter,
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
|
||||
booleanOnlyOperators.forEach(operator => {
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('will display boolean operators when column is an expression', () => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
|
||||
+14
-112
@@ -32,7 +32,6 @@ import {
|
||||
isDefined,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Operators,
|
||||
@@ -119,8 +118,6 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
const isColumnNumber =
|
||||
!!column && (column.type === 'INT' || column.type === 'INTEGER');
|
||||
const isColumnFunction = !!column && !!column.expression;
|
||||
const isColumnMultiValue =
|
||||
!!column && column.type_generic === GenericDataType.MultiValue;
|
||||
|
||||
if (operator && operator === Operators.LatestPartition) {
|
||||
const { partitionColumn } = props;
|
||||
@@ -130,41 +127,8 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
// hide the TEMPORAL_RANGE operator
|
||||
return false;
|
||||
}
|
||||
// Element-level array operators only apply to multi-value columns.
|
||||
const arrayElementOperators = [
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
];
|
||||
if (arrayElementOperators.includes(operator)) {
|
||||
return isColumnMultiValue;
|
||||
}
|
||||
if (isColumnMultiValue) {
|
||||
// Array columns support whole-array operators (=, !=, In, Not in, null
|
||||
// checks) plus the element-level operators above. Scalar-only operators
|
||||
// (Like, <, >, <=, >=) are hidden because they aren't valid on an array.
|
||||
return [
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
...arrayElementOperators,
|
||||
].includes(operator);
|
||||
}
|
||||
if (operator === Operators.IsTrue || operator === Operators.IsFalse) {
|
||||
// An expression column may evaluate to a boolean, but that is only a
|
||||
// safe assumption while its type is unknown; a declared type wins.
|
||||
return (
|
||||
isColumnBoolean || isColumnNumber || (isColumnFunction && !column?.type)
|
||||
);
|
||||
return isColumnBoolean || isColumnNumber || isColumnFunction;
|
||||
}
|
||||
if (isColumnBoolean) {
|
||||
return operator === Operators.IsNull || operator === Operators.IsNotNull;
|
||||
@@ -203,19 +167,9 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
].operation
|
||||
: null;
|
||||
if (!isDefined(operator)) {
|
||||
// The previous operator is not relevant for the new subject; pick a
|
||||
// sensible default and reset the comparator. Multi-value (array) columns
|
||||
// default to "Contains any" (element membership) rather than the
|
||||
// scalar-only IN.
|
||||
const newColumn = props.datasource.columns?.find(
|
||||
col => col.column_name === subject,
|
||||
);
|
||||
const defaultOperator =
|
||||
newColumn?.type_generic === GenericDataType.MultiValue
|
||||
? Operators.ContainsAny
|
||||
: Operators.In;
|
||||
operator = defaultOperator;
|
||||
operatorId = defaultOperator;
|
||||
// if operator is `null`, use the `IN` and reset the comparator.
|
||||
operator = Operators.In;
|
||||
operatorId = Operators.In;
|
||||
comparator = undefined;
|
||||
}
|
||||
|
||||
@@ -239,38 +193,10 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
};
|
||||
const onOperatorChange = (operatorId: Operators) => {
|
||||
const currentComparator = props.adhocFilter.comparator;
|
||||
// The value space differs between operator families: element-level array
|
||||
// ops (Contains any/all) take individual elements, whole-array/scalar ops
|
||||
// (=, In, …) take whole arrays or scalars, Length ops take a count, and the
|
||||
// unary ops take nothing. A value from one family is meaningless in another,
|
||||
// so reset the value when the family changes (e.g. Equal to -> Contains all).
|
||||
const comparatorKind = (op?: Operators): string => {
|
||||
if (!op) return 'none';
|
||||
if (op === Operators.ContainsAny || op === Operators.ContainsAll) {
|
||||
return 'element';
|
||||
}
|
||||
if (
|
||||
op === Operators.LengthEquals ||
|
||||
op === Operators.LengthGreaterThan ||
|
||||
op === Operators.LengthLessThan ||
|
||||
op === Operators.LengthGreaterThanOrEqual ||
|
||||
op === Operators.LengthLessThanOrEqual
|
||||
) {
|
||||
return 'length';
|
||||
}
|
||||
if (DISABLE_INPUT_OPERATORS.includes(op)) return 'none';
|
||||
return 'value';
|
||||
};
|
||||
const valueFamilyChanged =
|
||||
comparatorKind(props.adhocFilter.operatorId as Operators | undefined) !==
|
||||
comparatorKind(operatorId);
|
||||
|
||||
let newComparator;
|
||||
if (valueFamilyChanged) {
|
||||
newComparator = undefined;
|
||||
} else if (MULTI_OPERATORS.has(operatorId)) {
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
if (MULTI_OPERATORS.has(operatorId)) {
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator
|
||||
: [currentComparator].filter(element => element != null);
|
||||
@@ -507,42 +433,19 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
if (loadingComparatorSuggestions) {
|
||||
controller.abort();
|
||||
}
|
||||
// Element-level array operators (Contains any / Contains all) search
|
||||
// inside the array, so suggest individual elements; whole-array
|
||||
// operators (=, In, …) keep the default distinct-array suggestions.
|
||||
const { operatorId } = props.adhocFilter;
|
||||
const arrayElements =
|
||||
operatorId === Operators.ContainsAny ||
|
||||
operatorId === Operators.ContainsAll;
|
||||
setLoadingComparatorSuggestions(true);
|
||||
SupersetClient.get({
|
||||
signal,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
|
||||
arrayElements ? '?array_elements=true' : ''
|
||||
}`,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setSuggestions(
|
||||
json.result.map((suggestion: unknown) => {
|
||||
// Complex column values arrive as JS arrays or objects: whole
|
||||
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
|
||||
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
|
||||
// A raw array/object is neither a valid single-select value
|
||||
// (antd collapses an array to its first element) nor renderable
|
||||
// as a React child (an object throws). Render it as its literal
|
||||
// string, which is also exactly what the backend's
|
||||
// parse_array_literal expects for the whole-array operators.
|
||||
if (suggestion !== null && typeof suggestion === 'object') {
|
||||
const literal = JSON.stringify(suggestion);
|
||||
return { value: literal, label: literal };
|
||||
}
|
||||
return {
|
||||
value: suggestion as null | number | boolean | string,
|
||||
label: optionLabel(
|
||||
suggestion as null | number | boolean | string,
|
||||
),
|
||||
};
|
||||
}),
|
||||
json.result.map(
|
||||
(suggestion: null | number | boolean | string) => ({
|
||||
value: suggestion,
|
||||
label: optionLabel(suggestion),
|
||||
}),
|
||||
),
|
||||
);
|
||||
setLoadingComparatorSuggestions(false);
|
||||
})
|
||||
@@ -561,7 +464,6 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
}, [
|
||||
props.adhocFilter.subject,
|
||||
props.adhocFilter.clause,
|
||||
props.adhocFilter.operatorId,
|
||||
props.datasource,
|
||||
datePicker,
|
||||
]);
|
||||
|
||||
-11
@@ -44,17 +44,6 @@ export const OPERATORS_TO_SQL = {
|
||||
'IS NULL': 'IS NULL',
|
||||
'IS TRUE': 'IS TRUE',
|
||||
'IS FALSE': 'IS FALSE',
|
||||
// Element-level array operators (shown as filter labels; not executable SQL —
|
||||
// the Custom SQL tab is hidden for these).
|
||||
CONTAINS_ANY: 'CONTAINS ANY',
|
||||
CONTAINS_ALL: 'CONTAINS ALL',
|
||||
IS_EMPTY: 'IS EMPTY',
|
||||
IS_NOT_EMPTY: 'IS NOT EMPTY',
|
||||
LENGTH_EQUALS: 'LENGTH =',
|
||||
LENGTH_GREATER_THAN: 'LENGTH >',
|
||||
LENGTH_LESS_THAN: 'LENGTH <',
|
||||
LENGTH_GREATER_THAN_OR_EQUALS: 'LENGTH >=',
|
||||
LENGTH_LESS_THAN_OR_EQUALS: 'LENGTH <=',
|
||||
'LATEST PARTITION': ({
|
||||
datasource,
|
||||
}: {
|
||||
|
||||
@@ -45,17 +45,6 @@ export enum Operators {
|
||||
IsTrue = 'IS_TRUE',
|
||||
IsFalse = 'IS_FALSE',
|
||||
TemporalRange = 'TEMPORAL_RANGE',
|
||||
// Element-level operators for multi-value (array) columns
|
||||
ContainsAny = 'CONTAINS_ANY',
|
||||
ContainsAll = 'CONTAINS_ALL',
|
||||
IsEmpty = 'IS_EMPTY',
|
||||
IsNotEmpty = 'IS_NOT_EMPTY',
|
||||
// Length (element-count) comparison operators for array columns
|
||||
LengthEquals = 'LENGTH_EQUALS',
|
||||
LengthGreaterThan = 'LENGTH_GREATER_THAN',
|
||||
LengthLessThan = 'LENGTH_LESS_THAN',
|
||||
LengthGreaterThanOrEqual = 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
LengthLessThanOrEqual = 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
}
|
||||
|
||||
export interface OperatorType {
|
||||
@@ -100,39 +89,6 @@ export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
|
||||
display: t('TEMPORAL_RANGE'),
|
||||
operation: 'TEMPORAL_RANGE',
|
||||
},
|
||||
[Operators.ContainsAny]: {
|
||||
display: t('Contains any'),
|
||||
operation: 'CONTAINS_ANY',
|
||||
},
|
||||
[Operators.ContainsAll]: {
|
||||
display: t('Contains all'),
|
||||
operation: 'CONTAINS_ALL',
|
||||
},
|
||||
[Operators.IsEmpty]: { display: t('Is empty'), operation: 'IS_EMPTY' },
|
||||
[Operators.IsNotEmpty]: {
|
||||
display: t('Is not empty'),
|
||||
operation: 'IS_NOT_EMPTY',
|
||||
},
|
||||
[Operators.LengthEquals]: {
|
||||
display: t('Length equals (=)'),
|
||||
operation: 'LENGTH_EQUALS',
|
||||
},
|
||||
[Operators.LengthGreaterThan]: {
|
||||
display: t('Length greater than (>)'),
|
||||
operation: 'LENGTH_GREATER_THAN',
|
||||
},
|
||||
[Operators.LengthLessThan]: {
|
||||
display: t('Length less than (<)'),
|
||||
operation: 'LENGTH_LESS_THAN',
|
||||
},
|
||||
[Operators.LengthGreaterThanOrEqual]: {
|
||||
display: t('Length greater or equal (>=)'),
|
||||
operation: 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
},
|
||||
[Operators.LengthLessThanOrEqual]: {
|
||||
display: t('Length less or equal (<=)'),
|
||||
operation: 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
|
||||
@@ -149,12 +105,7 @@ export const HAVING_OPERATORS = [
|
||||
Operators.GreaterThan,
|
||||
Operators.GreaterThanOrEqual,
|
||||
];
|
||||
export const MULTI_OPERATORS = new Set([
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
]);
|
||||
export const MULTI_OPERATORS = new Set([Operators.In, Operators.NotIn]);
|
||||
// CUSTOM_OPERATORS will show operator in simple mode,
|
||||
// but will generate customized sqlExpression
|
||||
export const CUSTOM_OPERATORS = new Set([
|
||||
@@ -169,8 +120,6 @@ export const DISABLE_INPUT_OPERATORS = [
|
||||
Operators.LatestPartition,
|
||||
Operators.IsTrue,
|
||||
Operators.IsFalse,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
];
|
||||
|
||||
export const sqlaAutoGeneratedMetricNameRegex =
|
||||
|
||||
@@ -82,14 +82,3 @@ test('Should handle boolean true comparator as a string value', () => {
|
||||
"subject operator 'TRUE'",
|
||||
);
|
||||
});
|
||||
|
||||
test('Should render array-literal comparators as-is (not quoted)', () => {
|
||||
// Whole-array = filter: the pasted array literal is shown unquoted.
|
||||
expect(getSimpleSQLExpression('ingredients', '=', "['1 large egg']")).toBe(
|
||||
"ingredients = ['1 large egg']",
|
||||
);
|
||||
// IN with multiple array literals.
|
||||
expect(
|
||||
getSimpleSQLExpression('ingredients', Operators.In, ["['a']", "['b']"]),
|
||||
).toBe(`ingredients ${Operators.In} (['a'], ['b'])`);
|
||||
});
|
||||
|
||||
@@ -461,15 +461,10 @@ export const getSimpleSQLExpression = (
|
||||
if (comparatorArray.length > 0 && showComparator) {
|
||||
const formattedComparators = comparatorArray
|
||||
.map(val => optionLabel(val))
|
||||
.map(val => {
|
||||
// Array-literal values (e.g. ['a', 'b']) are shown as-is rather than
|
||||
// quoted/escaped as a string, so array-column filters read naturally.
|
||||
const asString = String(val);
|
||||
if (asString.startsWith('[') && asString.endsWith(']')) {
|
||||
return asString;
|
||||
}
|
||||
return `${quote}${isString ? asString.replace(/'/g, "''") : val}${quote}`;
|
||||
});
|
||||
.map(
|
||||
val =>
|
||||
`${quote}${isString ? String(val).replace(/'/g, "''") : val}${quote}`,
|
||||
);
|
||||
expression += ` ${prefix}${formattedComparators.join(', ')}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,7 @@ import {
|
||||
import { Group, Role, UserObject } from 'src/pages/UsersList/types';
|
||||
import { Actions } from 'src/constants';
|
||||
import { BaseUserListModalProps, FormValues } from './types';
|
||||
import {
|
||||
createUser,
|
||||
updateUser,
|
||||
atLeastOneRoleOrGroup,
|
||||
handleUserError,
|
||||
} from './utils';
|
||||
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
|
||||
|
||||
export interface UserModalProps extends BaseUserListModalProps {
|
||||
roles: Role[];
|
||||
@@ -56,6 +51,36 @@ function UserListModal({
|
||||
}: UserModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
const handleError = async (
|
||||
err: any,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
) => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 422) {
|
||||
const errorData = await err.json();
|
||||
const detail = errorData?.message || '';
|
||||
|
||||
if (detail.includes('duplicate key value')) {
|
||||
if (detail.includes('ab_user_username_key')) {
|
||||
errorMessage = t(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
} else if (detail.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
if (!user) {
|
||||
throw new Error('User is required in edit mode');
|
||||
@@ -64,14 +89,14 @@ function UserListModal({
|
||||
await updateUser(user.id, values);
|
||||
addSuccessToast(t('The user has been updated successfully.'));
|
||||
} catch (err) {
|
||||
await handleUserError(err as Response, Actions.UPDATE, addDangerToast);
|
||||
await handleError(err, Actions.UPDATE);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await createUser(values);
|
||||
addSuccessToast(t('The user has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleUserError(err as Response, Actions.CREATE, addDangerToast);
|
||||
await handleError(err, Actions.CREATE);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,99 +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 { Actions } from 'src/constants';
|
||||
import { handleUserError } from './utils';
|
||||
|
||||
test('shows the password validation message from a 400 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
password: ['Password must be at least 8 characters long.'],
|
||||
},
|
||||
}),
|
||||
{ status: 400 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'Password must be at least 8 characters long.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows a plain string message from a 400 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({ message: 'User must have at least one role or group!' }),
|
||||
{ status: 400 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.UPDATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'User must have at least one role or group!',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the duplicate username message for a 422 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({
|
||||
message:
|
||||
'duplicate key value violates unique constraint "ab_user_username_key"',
|
||||
}),
|
||||
{ status: 422 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the generic message when a 422 response has no message', async () => {
|
||||
const error = new Response(JSON.stringify({ foo: 'bar' }), { status: 422 });
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'There was an error creating the user. Please, try again.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the generic message when a 400 response is not JSON', async () => {
|
||||
const error = new Response('<html>Bad request</html>', {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'There was an error creating the user. Please, try again.',
|
||||
);
|
||||
});
|
||||
@@ -17,49 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { SelectOption } from 'src/components/ListView';
|
||||
import { Actions } from 'src/constants';
|
||||
import { FormValues } from './types';
|
||||
|
||||
type AddDangerToast = (message: string) => void;
|
||||
|
||||
export const handleUserError = async (
|
||||
err: Response,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
addDangerToast: AddDangerToast,
|
||||
): Promise<never> => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 400 || err.status === 422) {
|
||||
const errorData = await getClientErrorObject(err);
|
||||
const message: unknown = errorData.message;
|
||||
|
||||
if (err.status === 400 && message && errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
} else if (
|
||||
err.status === 422 &&
|
||||
errorData.error?.includes('duplicate key value')
|
||||
) {
|
||||
if (errorData.error.includes('ab_user_username_key')) {
|
||||
errorMessage = t(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
} else if (errorData.error.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
export const createUser = async (values: FormValues) => {
|
||||
const { confirmPassword: _confirmPassword, ...payload } = values;
|
||||
if (payload.active == null) {
|
||||
|
||||
@@ -1157,34 +1157,6 @@ test('dataset links use internal routing when PREVENT_UNSAFE_DEFAULT_URLS_ON_DAT
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy dashboard default URLs use the registered client route', async () => {
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
explore_url: '/superset/dashboard/123/?standalone=1#section',
|
||||
};
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(
|
||||
mockAdminUser,
|
||||
{},
|
||||
{
|
||||
common: {
|
||||
conf: {
|
||||
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const datasetLink = await screen.findByRole('link', {
|
||||
name: dataset.table_name,
|
||||
});
|
||||
expect(datasetLink).toHaveAttribute(
|
||||
'href',
|
||||
'/dashboard/123/?standalone=1#section',
|
||||
);
|
||||
});
|
||||
|
||||
// Note: These delete error tests verify that the modal doesn't open when fetching
|
||||
// related_objects fails. The component's openDatasetDeleteModal error handler
|
||||
// (index.tsx:262-268) returns a string but doesn't call addDangerToast(), so no
|
||||
|
||||
@@ -54,18 +54,10 @@ import {
|
||||
|
||||
const APP_ROOT = '/superset';
|
||||
|
||||
const renderUnderSubdirectory = (preventUnsafeDefaultUrls = false) => {
|
||||
const defaultState = createDefaultStoreState(mockAdminUser);
|
||||
const renderUnderSubdirectory = () => {
|
||||
const store = createMockStore({
|
||||
...defaultState,
|
||||
...createDefaultStoreState(mockAdminUser),
|
||||
user: mockAdminUser,
|
||||
common: {
|
||||
...defaultState.common,
|
||||
conf: {
|
||||
...defaultState.common?.conf,
|
||||
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: preventUnsafeDefaultUrls,
|
||||
},
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
@@ -123,31 +115,6 @@ test('explore link is single-prefixed under a subdirectory deployment', async ()
|
||||
expect(exploreLink.getAttribute('href')).not.toContain('/superset/superset');
|
||||
});
|
||||
|
||||
test('legacy dashboard default URL uses the router basename once', async () => {
|
||||
// A subdirectory user pastes the full browser path, so the saved value
|
||||
// carries both the application root and the legacy `/superset` prefix.
|
||||
// stripAppRoot removes the root and the legacy normalization removes the
|
||||
// prefix, leaving the basename to re-add the root exactly once.
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
explore_url: `${APP_ROOT}/superset/dashboard/123/?standalone=1#section`,
|
||||
};
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderUnderSubdirectory(true);
|
||||
|
||||
const dashboardLink = await screen.findByRole('link', {
|
||||
name: dataset.table_name,
|
||||
});
|
||||
expect(dashboardLink).toHaveAttribute(
|
||||
'href',
|
||||
`${APP_ROOT}/dashboard/123/?standalone=1#section`,
|
||||
);
|
||||
expect(dashboardLink.getAttribute('href')).not.toContain(
|
||||
'/superset/superset',
|
||||
);
|
||||
});
|
||||
|
||||
test('external default_endpoint passes through unprefixed', async () => {
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
|
||||
@@ -87,6 +87,7 @@ import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
|
||||
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
SORT_BY,
|
||||
@@ -113,10 +114,6 @@ import type {
|
||||
} from 'src/types/bootstrapTypes';
|
||||
import type User from 'src/types/User';
|
||||
|
||||
// Keep saved Default URLs compatible with the prefix-free SPA route.
|
||||
const normalizeLegacyDashboardUrl = (url: string) =>
|
||||
url.replace(/^\/superset(?=\/dashboard(?:\/|$))/, '');
|
||||
|
||||
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
|
||||
type DatasetExtra = {
|
||||
certification?: {
|
||||
@@ -725,9 +722,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
// Router basename, which re-prefixes the root — so strip it here to
|
||||
// avoid a doubled `/superset/superset/...`. External
|
||||
// `default_endpoint` URLs pass through unchanged.
|
||||
const exploreTo = normalizeLegacyDashboardUrl(
|
||||
stripAppRoot(exploreURL),
|
||||
);
|
||||
const exploreTo = stripAppRoot(exploreURL);
|
||||
let titleLink: JSX.Element;
|
||||
if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
|
||||
titleLink = (
|
||||
|
||||
Generated
+4
-4
@@ -28,7 +28,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.11.0",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -2053,9 +2053,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
|
||||
"integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
|
||||
"version": "17.9.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
|
||||
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.11.0",
|
||||
"globals": "^17.9.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -280,6 +280,9 @@ def test_sqlalchemy_dialect(
|
||||
"""
|
||||
Test the SQLAlchemy dialect, making sure it supports everything Superset needs.
|
||||
"""
|
||||
if "future" not in engine_kwargs:
|
||||
engine_kwargs["future"] = True
|
||||
|
||||
engine = create_engine(sqlalchemy_uri, **engine_kwargs)
|
||||
dialect = engine.dialect
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ class BaseStreamingCSVExportCommand(BaseCommand):
|
||||
delimiter = csv_export_config.get("sep", ",")
|
||||
decimal_separator = csv_export_config.get("decimal", ".")
|
||||
|
||||
with db.session() as session:
|
||||
with db.session(future=True) as session:
|
||||
# Merge database to prevent DetachedInstanceError
|
||||
merged_database = session.merge(database)
|
||||
|
||||
|
||||
@@ -291,30 +291,19 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
|
||||
),
|
||||
None,
|
||||
)
|
||||
# Point the x-axis at the overridden Time Column (granularity).
|
||||
# Replaces x-axis column values with granularity
|
||||
if x_axis_column:
|
||||
if isinstance(x_axis_column, dict):
|
||||
# Only swap the underlying expression, keeping the
|
||||
# column's original label. The temporal offset join
|
||||
# (``processing_time_offsets``), the post-processing
|
||||
# pivot ``index`` and the frontend all reference this
|
||||
# column by its label; renaming it to the granularity
|
||||
# here desynchronizes those consumers from the label
|
||||
# the saved chart still advertises, which — with a Time
|
||||
# Comparison offset — collapses the series into a single
|
||||
# point.
|
||||
x_axis_column["sqlExpression"] = granularity
|
||||
x_axis_column["label"] = granularity
|
||||
else:
|
||||
# A bare string x-axis has no distinct label, so it is
|
||||
# replaced wholesale and the pivot ``index`` must be
|
||||
# realigned to the overridden column.
|
||||
query_object.columns = [
|
||||
granularity if column == x_axis_column else column
|
||||
for column in query_object.columns
|
||||
]
|
||||
for post_processing in query_object.post_processing:
|
||||
if post_processing.get("operation") == "pivot":
|
||||
post_processing["options"]["index"] = [granularity]
|
||||
for post_processing in query_object.post_processing:
|
||||
if post_processing.get("operation") == "pivot":
|
||||
post_processing["options"]["index"] = [granularity]
|
||||
|
||||
# If no temporal x-axis, then get the default temporal filter
|
||||
if not filter_to_remove:
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
# pylint: disable=invalid-name
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
@@ -206,86 +205,8 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
def _set_post_processing(
|
||||
self, post_processing: list[dict[str, Any] | None] | None
|
||||
) -> None:
|
||||
self.post_processing = [
|
||||
self._drop_unsupported_options(post_proc)
|
||||
for post_proc in post_processing or []
|
||||
if post_proc
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _drop_unsupported_options(post_proc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Drop options that the post-processing operation no longer accepts.
|
||||
|
||||
A chart's ``query_context`` is written when the chart is saved and is
|
||||
never rewritten afterwards, while Explore rebuilds the query from
|
||||
``form_data`` at every render. A chart saved by an older version of
|
||||
Superset can therefore reference an option that has since been removed
|
||||
from the operation. ``exec_post_processing`` passes the stored options
|
||||
as keyword arguments, so that option raises a bare ``TypeError`` on
|
||||
every path that replays the stored ``query_context`` -- the chart data
|
||||
endpoint, alerts and reports, thumbnails, CSV export -- while the same
|
||||
chart still renders correctly in Explore.
|
||||
|
||||
Comparing against the signature avoids a hard-coded list of removed
|
||||
option names, which would need extending at each release.
|
||||
"""
|
||||
operation = post_proc.get("operation")
|
||||
function = (
|
||||
getattr(pandas_postprocessing, operation, None)
|
||||
if isinstance(operation, str)
|
||||
else None
|
||||
)
|
||||
if function is None:
|
||||
# A missing or unknown operation is left untouched, so that
|
||||
# exec_post_processing reports it as InvalidPostProcessingError.
|
||||
return post_proc
|
||||
|
||||
parameters = inspect.signature(function).parameters
|
||||
if any(
|
||||
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for parameter in parameters.values()
|
||||
):
|
||||
return post_proc
|
||||
|
||||
# `exec_post_processing` calls the operation as `operation(df, **options)`,
|
||||
# so an option can only reach a parameter that a caller may fill by
|
||||
# keyword. That excludes the first parameter, which receives the
|
||||
# DataFrame positionally, and any positional-only or `*args` parameter.
|
||||
keyword_parameters = {
|
||||
name
|
||||
for position, (name, parameter) in enumerate(parameters.items())
|
||||
if position > 0
|
||||
and parameter.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
)
|
||||
}
|
||||
|
||||
options = post_proc.get("options") or {}
|
||||
unsupported = {key for key in options if key not in keyword_parameters}
|
||||
if not unsupported:
|
||||
return post_proc
|
||||
|
||||
# Logged at info: a chart saved before the option was removed hits this
|
||||
# on every render, so a warning would repeat for as long as the chart
|
||||
# is not resaved, without anything new to report.
|
||||
logger.info(
|
||||
"Dropping unsupported option(s) %s of post-processing operation "
|
||||
"`%s`. The chart's stored query_context predates the current "
|
||||
"signature of that operation.",
|
||||
sorted(unsupported),
|
||||
operation,
|
||||
)
|
||||
return {
|
||||
**post_proc,
|
||||
"options": {
|
||||
key: value
|
||||
for key, value in options.items()
|
||||
if key in keyword_parameters
|
||||
},
|
||||
}
|
||||
post_processing = post_processing or []
|
||||
self.post_processing = [post_proc for post_proc in post_processing if post_proc]
|
||||
|
||||
def _init_series_columns(
|
||||
self,
|
||||
|
||||
@@ -957,13 +957,7 @@ class AnnotationDatasource(BaseDatasource):
|
||||
def get_query_str(self, query_obj: QueryObjectDict) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def values_for_column(
|
||||
self,
|
||||
column_name: str,
|
||||
limit: int = 10000,
|
||||
denormalize_column: bool = False,
|
||||
array_elements: bool = False,
|
||||
) -> list[Any]:
|
||||
def values_for_column(self, column_name: str, limit: int = 10000) -> list[Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
|
||||
@@ -133,9 +133,6 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
|
||||
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
|
||||
denormalize_column = not datasource.normalize_columns
|
||||
# Element-level operators (Contains any / Contains all) request the
|
||||
# distinct array *elements* rather than distinct whole arrays.
|
||||
array_elements = parse_boolean_string(request.args.get("array_elements"))
|
||||
|
||||
# Cache distinct column-value results so a dashboard with many filters
|
||||
# backed by the same (often heavy) virtual dataset doesn't re-execute
|
||||
@@ -168,7 +165,6 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
"col": column_name,
|
||||
"limit": row_limit,
|
||||
"denorm": denormalize_column,
|
||||
"elements": array_elements,
|
||||
"rls": security_manager.get_rls_cache_key(datasource),
|
||||
"changed_on": str(getattr(datasource, "changed_on", "")),
|
||||
},
|
||||
@@ -193,7 +189,6 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
column_name=column_name,
|
||||
limit=row_limit,
|
||||
denormalize_column=denormalize_column,
|
||||
array_elements=array_elements,
|
||||
)
|
||||
except KeyError:
|
||||
return self.response(
|
||||
|
||||
@@ -55,13 +55,7 @@ from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.sql import literal_column, quoted_name, text
|
||||
from sqlalchemy.sql.expression import (
|
||||
BinaryExpression,
|
||||
ColumnClause,
|
||||
ColumnElement,
|
||||
Select,
|
||||
TextClause,
|
||||
)
|
||||
from sqlalchemy.sql.expression import BinaryExpression, ColumnClause, Select, TextClause
|
||||
from sqlalchemy.types import TypeEngine
|
||||
|
||||
from superset import db
|
||||
@@ -534,11 +528,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
time_groupby_inline = False
|
||||
limit_method = LimitMethod.FORCE_LIMIT
|
||||
supports_multivalues_insert = False
|
||||
# Whether this engine supports first-class multi-value (array-typed) columns.
|
||||
# When True, array columns are classified as ``GenericDataType.MULTI_VALUE`` and
|
||||
# the ``array_*`` capability methods below must be implemented. Defaults to
|
||||
# False so engines that have not opted in keep treating arrays as strings.
|
||||
supports_multivalue_columns = False
|
||||
allows_joins = True
|
||||
allows_subqueries = True
|
||||
allows_alias_in_select = True
|
||||
@@ -2582,105 +2571,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
logger.error(ex, exc_info=True)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build a boolean expression testing whether array column ``col`` contains
|
||||
**any** of ``values`` (element-level membership, like ``IN``). Engines
|
||||
that set ``supports_multivalue_columns = True`` must override this with
|
||||
their native function (e.g. ClickHouse ``hasAny``).
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:param values: element values to look for inside the array
|
||||
:return: a SQLAlchemy boolean expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build a boolean expression testing whether array column ``col`` contains
|
||||
**all** of ``values``. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this with their
|
||||
native function (e.g. ClickHouse ``hasAll``).
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:param values: element values that must all be present
|
||||
:return: a SQLAlchemy boolean expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_length(cls, col: ColumnElement) -> ColumnElement:
|
||||
"""
|
||||
Build a numeric expression returning the number of elements in array
|
||||
column ``col``. Engines that set ``supports_multivalue_columns = True``
|
||||
must override this with their native array-length function. Used both for
|
||||
the ``Length`` filter and the ``Is empty`` / ``Is not empty`` operators.
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:return: a SQLAlchemy numeric expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_literal(cls, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build an array-literal expression from ``values`` (e.g. ClickHouse
|
||||
``array(v1, v2)`` == ``[v1, v2]``). Used for the whole-array (column-
|
||||
level) operators ``=`` / ``!=`` / ``IN`` / ``NOT IN`` where the array is
|
||||
compared as a single value. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this.
|
||||
|
||||
:param values: element values that make up the array
|
||||
:return: a SQLAlchemy array-literal expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_explode(cls, col: ColumnElement) -> ColumnElement:
|
||||
"""
|
||||
Build an expression that expands array column ``col`` into one row per
|
||||
element (e.g. ClickHouse ``arrayJoin``). Used to source **element-level**
|
||||
value suggestions (``SELECT DISTINCT array_explode(col)``) for the
|
||||
``Contains any`` / ``Contains all`` filter operators, so the picker offers
|
||||
individual elements rather than whole arrays. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this.
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:return: a SQLAlchemy expression yielding one element per row
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_array_element_type( # pylint: disable=unused-argument
|
||||
cls, native_type: str | None
|
||||
) -> GenericDataType | None:
|
||||
"""
|
||||
Return the generic type of an array column's **element** type, derived
|
||||
from its native type string (e.g. ClickHouse ``Array(Int32)`` ->
|
||||
``NUMERIC``), or ``None`` when the engine has no array support or the
|
||||
element type cannot be resolved.
|
||||
|
||||
Callers use this to coerce filter values to the element type before
|
||||
building array expressions, so, for example, a ``Contains any`` filter on
|
||||
a numeric array compares against numbers rather than quoted strings.
|
||||
|
||||
:param native_type: native column type string of the array column
|
||||
:return: the element's :class:`GenericDataType`, or ``None``
|
||||
"""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_column_spec( # pylint: disable=unused-argument
|
||||
cls,
|
||||
|
||||
@@ -26,9 +26,8 @@ from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from marshmallow import fields, Schema
|
||||
from marshmallow.validate import Range
|
||||
from sqlalchemy import func, types
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.sql.expression import ColumnElement
|
||||
from urllib3.exceptions import NewConnectionError
|
||||
|
||||
from superset.databases.utils import make_url_safe
|
||||
@@ -56,7 +55,6 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
|
||||
time_groupby_inline = True
|
||||
supports_multivalues_insert = True
|
||||
supports_multivalue_columns = True
|
||||
|
||||
# ClickHouse doesn't support IS true/false syntax, use = true/false instead
|
||||
use_equality_for_boolean_filters = True
|
||||
@@ -130,18 +128,12 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
|
||||
column_type_mappings = (
|
||||
(
|
||||
# Anchor to the start so only top-level arrays match. This must be
|
||||
# ordered before the ``Enum`` entry below: ``Array(Enum8(...))`` is a
|
||||
# real array and should classify as MULTI_VALUE, not STRING. The
|
||||
# anchor also prevents over-matching nested arrays such as
|
||||
# ``Map(String, Array(String))`` or ``Tuple(Array(String))``, which
|
||||
# are not themselves array columns and must keep their own type.
|
||||
re.compile(r"^Array\(", re.IGNORECASE),
|
||||
re.compile(r".*Enum.*", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.MULTI_VALUE,
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
(
|
||||
re.compile(r".*Enum.*", re.IGNORECASE),
|
||||
re.compile(r".*Array.*", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
@@ -182,56 +174,6 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: hasAny(arr, [v1, v2]) -> 1 if arr shares any element.
|
||||
# func.array(*values) renders as array(v1, v2) == [v1, v2].
|
||||
return func.hasAny(col, func.array(*values))
|
||||
|
||||
@classmethod
|
||||
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: hasAll(arr, [v1, v2]) -> 1 if arr contains all elements.
|
||||
return func.hasAll(col, func.array(*values))
|
||||
|
||||
@classmethod
|
||||
def array_length(cls, col: ColumnElement) -> ColumnElement:
|
||||
# ClickHouse: length(arr) -> number of elements
|
||||
return func.length(col)
|
||||
|
||||
@classmethod
|
||||
def array_literal(cls, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: array(v1, v2) is equivalent to the literal [v1, v2].
|
||||
return func.array(*values)
|
||||
|
||||
@classmethod
|
||||
def array_explode(cls, col: ColumnElement) -> ColumnElement:
|
||||
# ClickHouse: arrayJoin(arr) yields one row per element, so
|
||||
# SELECT DISTINCT arrayJoin(arr) returns the distinct elements.
|
||||
return func.arrayJoin(col)
|
||||
|
||||
# Matches the element type inside a top-level ``Array(...)`` column, e.g.
|
||||
# ``Array(Int32)`` -> ``Int32``, ``Array(Nullable(String))`` -> ``String``.
|
||||
_ARRAY_ELEMENT_RE = re.compile(r"^Array\((?P<inner>.+)\)$", re.IGNORECASE)
|
||||
# Element-type wrappers that don't change the underlying generic type.
|
||||
_ELEMENT_WRAPPER_RE = re.compile(
|
||||
r"^(?:Nullable|LowCardinality)\((?P<inner>.+)\)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_array_element_type(cls, native_type: str | None) -> GenericDataType | None:
|
||||
if not native_type:
|
||||
return None
|
||||
match = cls._ARRAY_ELEMENT_RE.match(native_type.strip())
|
||||
if not match:
|
||||
return None
|
||||
inner = match.group("inner").strip()
|
||||
# Peel wrappers (Nullable/LowCardinality) that don't alter the generic
|
||||
# type so the inner scalar type drives classification.
|
||||
while wrapper := cls._ELEMENT_WRAPPER_RE.match(inner):
|
||||
inner = wrapper.group("inner").strip()
|
||||
spec = cls.get_column_spec(inner)
|
||||
return spec.generic_type if spec else None
|
||||
|
||||
@classmethod
|
||||
def epoch_to_dttm(cls) -> str:
|
||||
return "{col}"
|
||||
|
||||
@@ -389,6 +389,7 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
|
||||
}
|
||||
}
|
||||
},
|
||||
future=True,
|
||||
)
|
||||
conn = engine.connect()
|
||||
idx = 0
|
||||
|
||||
@@ -63,7 +63,6 @@ from superset.mcp_service.utils import (
|
||||
sanitize_for_llm_context,
|
||||
)
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
from superset.sql.parse import has_aggregate
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
@@ -387,27 +386,13 @@ class CreateDatasetMetric(BaseModel):
|
||||
"""Metric definition for dataset creation."""
|
||||
|
||||
metric_name: str = Field(..., description="Name of the metric")
|
||||
expression: str = Field(
|
||||
...,
|
||||
description="Aggregate SQL expression for the metric, e.g. SUM(amount)",
|
||||
)
|
||||
expression: str = Field(..., description="SQL expression for the metric")
|
||||
verbose_name: str | None = None
|
||||
description: str | None = None
|
||||
metric_type: str | None = None
|
||||
d3format: str | None = None
|
||||
warning_text: str | None = None
|
||||
|
||||
@field_validator("expression")
|
||||
@classmethod
|
||||
def expression_must_aggregate(cls, value: str) -> str:
|
||||
if not has_aggregate(value):
|
||||
raise ValueError(
|
||||
"saved metrics must aggregate rows; wrap a row-level column in "
|
||||
"an aggregate such as MAX(column), or omit the saved metric and "
|
||||
"use the dataset column directly"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class CreateDatasetCalculatedColumn(BaseModel):
|
||||
"""Calculated column definition for dataset creation."""
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import Any
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.exceptions import SupersetGenericDBErrorException
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.dataset.schemas import (
|
||||
CreateVirtualDatasetRequest,
|
||||
@@ -68,17 +67,14 @@ def _cleanup_failed_dataset(dataset_id: int) -> None:
|
||||
|
||||
|
||||
def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> Any:
|
||||
from superset.commands.dataset.exceptions import (
|
||||
DatasetInvalidError,
|
||||
DatasetUpdateFailedError,
|
||||
)
|
||||
from superset.commands.dataset.exceptions import DatasetUpdateFailedError
|
||||
from superset.commands.dataset.update import UpdateDatasetCommand
|
||||
|
||||
try:
|
||||
return UpdateDatasetCommand(dataset_id, update_props).run()
|
||||
except Exception as exc:
|
||||
_cleanup_failed_dataset(dataset_id)
|
||||
if not isinstance(exc, (DatasetInvalidError, DatasetUpdateFailedError)):
|
||||
if not isinstance(exc, DatasetUpdateFailedError):
|
||||
raise DatasetUpdateFailedError() from exc
|
||||
raise
|
||||
|
||||
@@ -93,7 +89,7 @@ def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> An
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def create_virtual_dataset( # noqa: C901
|
||||
async def create_virtual_dataset(
|
||||
request: CreateVirtualDatasetRequest, ctx: Context
|
||||
) -> CreateVirtualDatasetResponse:
|
||||
"""Save a SQL query as a virtual dataset so it can be charted.
|
||||
@@ -217,18 +213,6 @@ async def create_virtual_dataset( # noqa: C901
|
||||
url=None,
|
||||
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
|
||||
)
|
||||
except SupersetGenericDBErrorException as exc:
|
||||
logger.warning("Virtual dataset SQL validation failed", exc_info=True)
|
||||
await ctx.warning(f"Virtual dataset SQL failed validation: {exc}")
|
||||
return CreateVirtualDatasetResponse(
|
||||
id=None,
|
||||
dataset_name=request.dataset_name,
|
||||
sql=request.sql,
|
||||
database_id=request.database_id,
|
||||
columns=[],
|
||||
url=None,
|
||||
error=f"Dataset SQL could not be executed: {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.error(
|
||||
f"Unexpected error creating virtual dataset: {type(exc).__name__}: {exc}"
|
||||
|
||||
@@ -376,7 +376,7 @@ def upgrade_catalog_perms(engines: set[str] | None = None) -> None:
|
||||
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# The Database model has an eager-loaded (``lazy="joined"``) ``ssh_tunnel``
|
||||
# backref. Eager-loading it here would SELECT every column on ``ssh_tunnels``,
|
||||
@@ -581,7 +581,7 @@ def downgrade_catalog_perms(engines: set[str] | None = None) -> None:
|
||||
WARNING: models (datasets and charts) not in the default catalog are deleted!
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# See upgrade_catalog_perms: avoid eager-loading the ``ssh_tunnel`` backref so the
|
||||
# query stays schema-safe across migration revisions.
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ class Slice(Base):
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
op.add_column("slices", sa.Column("perm", sa.String(length=2000), nullable=True))
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# Use Slice class defined here instead of models.Slice
|
||||
for slc in session.query(Slice).all():
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ def upgrade():
|
||||
)
|
||||
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# don't use models.DruidMetric
|
||||
# because it assumes the context is consistent with the application
|
||||
|
||||
@@ -94,7 +94,7 @@ class Dashboard(AuditMixin, Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
objects = session.query(Slice).all()
|
||||
objects += session.query(Dashboard).all()
|
||||
|
||||
@@ -50,7 +50,7 @@ class Slice(Base):
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
op.add_column("slices", sa.Column("datasource_id", sa.Integer()))
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
if slc.druid_datasource_id:
|
||||
@@ -63,7 +63,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
for slc in session.query(Slice).all():
|
||||
if slc.datasource_type == "druid":
|
||||
slc.druid_datasource_id = slc.datasource_id
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ class Database(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for obj in session.query(Database).all():
|
||||
obj.allow_run_sync = True
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
slices = session.query(Slice).all()
|
||||
slice_len = len(slices)
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ class Url(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
urls = session.query(Url).all()
|
||||
urls_len = len(urls)
|
||||
|
||||
@@ -45,7 +45,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).filter(Slice.viz_type.like("deck_%")):
|
||||
params = json.loads(slc.params)
|
||||
|
||||
@@ -45,7 +45,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).filter(
|
||||
or_(Slice.viz_type.like("line"), Slice.viz_type.like("bar"))
|
||||
@@ -75,7 +75,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).filter(
|
||||
or_(Slice.viz_type.like("line"), Slice.viz_type.like("bar"))
|
||||
|
||||
@@ -46,7 +46,7 @@ class Dashboard(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
dashboards = session.query(Dashboard).all()
|
||||
for i, dashboard in enumerate(dashboards):
|
||||
@@ -68,7 +68,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
dashboards = session.query(Dashboard).all()
|
||||
for i, dashboard in enumerate(dashboards):
|
||||
|
||||
@@ -57,7 +57,7 @@ def upgrade():
|
||||
),
|
||||
)
|
||||
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# Use Slice class defined here instead of models.Slice
|
||||
for tbl in session.query(Table).all():
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
slices = session.query(Slice).filter_by(viz_type="cal_heatmap").all()
|
||||
slice_len = len(slices)
|
||||
|
||||
@@ -45,7 +45,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
@@ -63,7 +63,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
|
||||
@@ -45,7 +45,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ class Database(Base):
|
||||
|
||||
|
||||
def replace(source, target):
|
||||
with db.Session(bind=op.get_bind()) as session:
|
||||
with db.Session(bind=op.get_bind(), future=True) as session:
|
||||
with session.begin():
|
||||
query = (
|
||||
session.query(Slice, Database)
|
||||
@@ -80,7 +80,7 @@ def replace(source, target):
|
||||
|
||||
for slc, database in query:
|
||||
try:
|
||||
engine = create_engine(database.sqlalchemy_uri)
|
||||
engine = create_engine(database.sqlalchemy_uri, future=True)
|
||||
|
||||
if engine.dialect.identifier_preparer._double_percents:
|
||||
params = json.loads(slc.params)
|
||||
|
||||
@@ -50,7 +50,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
@@ -66,7 +66,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).filter(Slice.viz_type == "pie").all():
|
||||
try:
|
||||
@@ -68,7 +68,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).filter(Slice.viz_type == "pie").all():
|
||||
try:
|
||||
|
||||
+2
-2
@@ -134,7 +134,7 @@ def compute_time_compare(granularity, periods):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for chart in session.query(Slice):
|
||||
params = json.loads(chart.params or "{}")
|
||||
@@ -163,7 +163,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for chart in session.query(Slice):
|
||||
params = json.loads(chart.params or "{}")
|
||||
|
||||
@@ -159,7 +159,7 @@ class TableColumn(BaseColumnMixin, Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
tables = [
|
||||
Annotation,
|
||||
|
||||
@@ -59,7 +59,7 @@ class TableColumn(BaseColumnMixin, Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# Delete the orphaned columns records.
|
||||
for record in session.query(DruidColumn).all():
|
||||
|
||||
@@ -59,7 +59,7 @@ class SqlMetric(BaseMetricMixin, Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# Delete the orphaned metrics records.
|
||||
for record in session.query(DruidMetric).all():
|
||||
|
||||
+1
-1
@@ -579,7 +579,7 @@ def scan_dashboard_positions_data(positions):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
dashboards = session.query(Dashboard).all()
|
||||
for i, dashboard in enumerate(dashboards):
|
||||
|
||||
@@ -55,7 +55,7 @@ def is_v2_dash(positions):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
dashboards = session.query(Dashboard).all()
|
||||
for i, dashboard in enumerate(dashboards): # noqa: B007
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class Dashboard(Base):
|
||||
|
||||
def upgrade(): # noqa: C901
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
dashboards = session.query(Dashboard).all()
|
||||
for i, dashboard in enumerate(dashboards):
|
||||
|
||||
@@ -75,7 +75,7 @@ def upgrade_slice(slc):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
filter_box_slices = session.query(Slice).filter_by(viz_type="filter_box")
|
||||
for slc in filter_box_slices.all():
|
||||
@@ -90,7 +90,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
filter_box_slices = session.query(Slice).filter_by(viz_type="filter_box")
|
||||
for slc in filter_box_slices.all():
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ def upgrade():
|
||||
|
||||
bind = op.get_bind()
|
||||
insp = sa.engine.reflection.Inspector.from_engine(bind)
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
tables = session.query(SqlaTable).all()
|
||||
for table in tables:
|
||||
|
||||
@@ -159,7 +159,7 @@ class TableColumn(BaseColumnMixin, Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
tables = [
|
||||
Annotation,
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ def add_parent_ids(node, layout):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
dashboards = session.query(Dashboard).all()
|
||||
for i, dashboard in enumerate(dashboards):
|
||||
@@ -88,7 +88,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
dashboards = session.query(Dashboard).all()
|
||||
for i, dashboard in enumerate(dashboards):
|
||||
|
||||
@@ -47,7 +47,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
@@ -94,7 +94,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class DashboardSlices(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
# find dup records in dashboard_slices tbl
|
||||
dup_records = (
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ def upgrade():
|
||||
op.add_column("tables", Column("schema_perm", String(length=1000), nullable=True))
|
||||
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
for t in session.query(Sqlatable).all():
|
||||
db_name = (
|
||||
t.database.verbose_name
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ down_revision = "11c737c17cc6"
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
tables = [
|
||||
Annotation,
|
||||
|
||||
@@ -48,7 +48,7 @@ class Slice(Base):
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
@@ -63,7 +63,7 @@ def upgrade():
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
session = db.Session(bind=bind, future=True)
|
||||
|
||||
for slc in session.query(Slice).all():
|
||||
try:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user