Compare commits

...
Author SHA1 Message Date
hainenber 37d65ef731 chore: remove image asset for deprecated ascend.io integration
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-31 17:00:20 +07:00
hainenber 75e4014ac1 chore(docs): regenerate docs and DB metadata
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-31 15:29:48 +07:00
hainenber fee0f9e160 feat(db): remove deprecated ascend.io integration
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-31 15:27:32 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 5b64150d0a chore(deps-dev): bump webpack-bundle-analyzer from 5.3.1 to 5.3.2 in /superset-frontend (#43706)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 14:48:05 +07:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> b8b2e5ea8b chore(deps): bump baseline-browser-mapping from 2.11.17 to 2.11.18 in /docs (#43704)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 00:14:13 -07:00
Đỗ Trọng Hải f3985d88a1 feat(build): migrate to TS7 and TS7-oriented oxlint-tsgolint (#43691) 2026-08-31 09:57:13 +07:00
SBIN2010andCopilot Autofix powered by AI 6abab3c1cb feat(butterfly): added cross-filtering and drill-to-detail for the butterfly chart (#43684)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-30 23:12:50 +03:00
SBIN2010 b310f469c2 feat(dashboard): add a tooltip displaying chart descriptions (#43271) 2026-08-30 12:25:37 -07:00
shaurya 22891894d4 docs: fix superset-core component source links after src/ui removal (#43690) 2026-08-30 12:23:37 -07:00
65fa1ce816 chore(ci): skip Jest test HTML report generation to save up time (#43653)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 11:52:47 -07:00
ee4a90892c test(explore): zero-value stacked bar segment no longer overlaps its neighbor's label (#42882)
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: vjymisal0 <vijay.looprai@gmail.com>
2026-08-30 11:43:01 -07:00
Joe Li bba4832946 fix(sqllab): prefer concluded live query state over a stale history snapshot (#43535) 2026-08-30 11:39:34 -07:00
Amin Ghadersohi 2df06dcd57 feat(mcp): add feature-gated interactive pivot support (#43480) 2026-08-30 11:36:17 -07:00
Joe Li 88cd8bf575 fix(ci): repair nightly pre-commit failures (#43366) 2026-08-30 11:35:19 -07:00
Francesco.Castaldi 5f67e85003 chore(i18n): complete Italian translations with 100% placeholder validation (#43274) 2026-08-30 11:34:56 -07:00
Mike BridgeandMike Bridge 87fb7d18e1 fix(semantic-layer): hydrate the semantic view edit modal from /structure (#42839)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-08-30 11:31:35 -07:00
75 changed files with 34350 additions and 35254 deletions
+1
View File
@@ -97,6 +97,7 @@ jobs:
mkdir -p ${{ github.workspace }}/superset-frontend/coverage
docker run \
-v ${{ github.workspace }}/superset-frontend/coverage:/app/superset-frontend/coverage \
-e CI=true \
--rm $TAG \
bash -c \
"npm run test -- --coverage --shard=${{ matrix.shard }}/8 --coverageReporters=json"
+3 -3
View File
@@ -88,9 +88,9 @@ repos:
language: system
pass_filenames: true
files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$
- id: eslint-docs
name: eslint (docs)
entry: bash -c 'cd docs && FILES=$(printf "%s\n" "$@" | sed "s|^docs/||" | tr "\n" " ") && yarn eslint --fix --quiet $FILES'
- id: oxlint-docs
name: oxlint (docs)
entry: bash -c 'cd docs && FILES=$(printf "%s\n" "$@" | sed "s|^docs/||" | tr "\n" " ") && yarn lint --fix --quiet $FILES'
language: system
pass_filenames: true
files: ^docs/.*\.(js|jsx|ts|tsx)$
-1
View File
@@ -125,7 +125,6 @@ Here are some of the major database solutions that are supported:
<a href="https://superset.apache.org/docs/databases/supported/apache-pinot" title="Apache Pinot"><img src="docs/static/img/databases/apache-pinot.svg" alt="Apache Pinot" width="76" height="40" /></a> &nbsp;
<a href="https://superset.apache.org/docs/databases/supported/apache-solr" title="Apache Solr"><img src="docs/static/img/databases/apache-solr.png" alt="Apache Solr" width="79" height="40" /></a> &nbsp;
<a href="https://superset.apache.org/docs/databases/supported/apache-spark-sql" title="Apache Spark SQL"><img src="docs/static/img/databases/apache-spark.png" alt="Apache Spark SQL" width="75" height="40" /></a> &nbsp;
<a href="https://superset.apache.org/docs/databases/supported/ascend" title="Ascend"><img src="docs/static/img/databases/ascend.webp" alt="Ascend" width="117" height="40" /></a> &nbsp;
<a href="https://superset.apache.org/docs/databases/supported/aurora-mysql-data-api" title="Aurora MySQL (Data API)"><img src="docs/static/img/databases/mysql.png" alt="Aurora MySQL (Data API)" width="77" height="40" /></a> &nbsp;
<a href="https://superset.apache.org/docs/databases/supported/aurora-postgresql-data-api" title="Aurora PostgreSQL (Data API)"><img src="docs/static/img/databases/postgresql.svg" alt="Aurora PostgreSQL (Data API)" width="76" height="40" /></a> &nbsp;
<a href="https://superset.apache.org/docs/databases/supported/azure-data-explorer" title="Azure Data Explorer"><img src="docs/static/img/databases/kusto.png" alt="Azure Data Explorer" width="40" height="40" /></a> &nbsp;
+3 -3
View File
@@ -62,8 +62,8 @@ yarn version:remove:developer_docs <version> # Remove developer docs version
yarn version:remove:components <version> # Remove components version
# Quality Checks
yarn typecheck # TypeScript validation
yarn eslint # Lint TypeScript/JavaScript files
yarn typecheck # TypeScript validation
yarn lint # Lint TypeScript/JavaScript files
```
## 📁 Documentation Structure
@@ -431,7 +431,7 @@ yarn build
yarn typecheck
# Linting issues
yarn eslint
yarn lint
```
### Version Issues
+8 -5
View File
@@ -10,20 +10,19 @@ Future phases will add support for these sources.
## Summary
- **Total skipped:** 19 story files
- **Total skipped:** 22 story files
- **Reason:** Import path resolution not yet implemented
## Skipped by Source
### App Components
9 components
8 components
- [ ] `superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.stories.tsx`
- [ ] `superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.stories.tsx`
- [ ] `superset-frontend/src/components/CopyToClipboard/CopyToClipboard.stories.tsx`
- [ ] `superset-frontend/src/components/ErrorMessage/ErrorAlert.stories.tsx`
- [ ] `superset-frontend/src/components/FacePile/FacePile.stories.tsx`
- [ ] `superset-frontend/src/components/FilterableTable/FilterableTable.stories.tsx`
- [ ] `superset-frontend/src/components/RowCountLabel/RowCountLabel.stories.tsx`
- [ ] `superset-frontend/src/components/Tag/Tag.stories.tsx`
@@ -31,9 +30,10 @@ Future phases will add support for these sources.
### Dashboard Components
2 components
3 components
- [ ] `superset-frontend/src/dashboard/components/AnchorLink/AnchorLink.stories.tsx`
- [ ] `superset-frontend/src/dashboard/components/PropertiesModal/sections/AccessSection.stories.tsx`
- [ ] `superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterDivider.stories.tsx`
### Explore Components
@@ -47,10 +47,13 @@ Future phases will add support for these sources.
### Feature Components
2 components
5 components
- [ ] `superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.stories.tsx`
- [ ] `superset-frontend/src/features/home/LanguagePicker.stories.tsx`
- [ ] `superset-frontend/src/features/subjects/SubjectPicker/SubjectPicker.stories.tsx`
- [ ] `superset-frontend/src/features/subjects/SubjectPile/SubjectPile.stories.tsx`
- [ ] `superset-frontend/src/features/subjects/SubjectSelectLabel/SubjectSelectLabel.stories.tsx`
### Filter Components
@@ -158,7 +158,7 @@ function FullMetadata() {
| `createdBy` | `string` | `"Jane Smith"` | - |
| `modifiedBy` | `string` | `"Jane Smith"` | - |
| `description` | `string` | `"To preview the list of dashboards go to More settings."` | - |
| `items` | `any` | `[{"type":"sql","title":"Click to view query"},{"type":"editor","createdBy":"Jane Smith","editors":["John Doe","Mary Wilson"],"createdOn":"a week ago"},{"type":"lastModified","value":"a week ago","modifiedBy":"Jane Smith"},{"type":"tags","values":["management","research","poc"]},{"type":"dashboards","title":"Added to 3 dashboards","description":"To preview the list of dashboards go to More settings."}]` | Items displayed in the metadata bar. The `editor` type shows editors/creators. |
| `items` | `any` | `[{"type":"sql","title":"Click to view query"},{"type":"editor","createdBy":"Jane Smith","editors":["John Doe","Mary Wilson"],"createdOn":"a week ago"},{"type":"lastModified","value":"a week ago","modifiedBy":"Jane Smith"},{"type":"tags","values":["management","research","poc"]},{"type":"dashboards","title":"Added to 3 dashboards","description":"To preview the list of dashboards go to More settings."}]` | - |
## Import
@@ -78,6 +78,12 @@ The Button component from Superset's UI library.
type: "text",
description: "The button text or content."
},
{
name: "styleConfig",
label: "Style Config",
type: "object",
description: "Optional visual overrides (controlHeight, paddingInline, fontSize, fontWeight, borderRadius, ctaMinWidth, ctaMinHeight, iconGap)."
},
{
name: "target",
label: "Target",
+6 -8
View File
@@ -34,7 +34,6 @@ A versatile select component supporting single and multi-select modes, search fi
component="Select"
props={{
mode: "single",
placeholder: "Select ...",
showSearch: true,
allowNewOptions: false,
allowClear: false,
@@ -101,12 +100,6 @@ A versatile select component supporting single and multi-select modes, search fi
],
description: "Whether to allow selection of a single option or multiple."
},
{
name: "placeholder",
label: "Placeholder",
type: "text",
description: "Placeholder text when no option is selected."
},
{
name: "showSearch",
label: "Show Search",
@@ -154,6 +147,12 @@ A versatile select component supporting single and multi-select modes, search fi
label: "Max Tag Count",
type: "number",
description: "Maximum number of tags to display in multiple mode before showing an overflow count."
},
{
name: "placeholder",
label: "Placeholder",
type: "text",
description: "Placeholder text when no option is selected."
}
]}
/>
@@ -283,7 +282,6 @@ function OneLineDemo() {
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `mode` | `string` | `"single"` | Whether to allow selection of a single option or multiple. |
| `placeholder` | `string` | `"Select ..."` | Placeholder text when no option is selected. |
| `showSearch` | `boolean` | `true` | Whether to show a search input for filtering. |
| `allowNewOptions` | `boolean` | `false` | Whether users can create new options by typing a value not in the list. |
| `allowClear` | `boolean` | `false` | Whether to show a clear button to reset the selection. |
@@ -114,8 +114,8 @@ function MyExtension() {
## Source Links
- [Story file](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/Alert.stories.tsx)
- [Component source](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/index.tsx)
- [Story file](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/components/Alert/Alert.stories.tsx)
- [Component source](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/components/Alert/index.tsx)
---
@@ -47,8 +47,8 @@ export function MyExtensionPanel() {
Components in `@apache-superset/core/components` are automatically documented here. To add a new extension component:
1. Add the component to `superset-frontend/packages/superset-core/src/ui/components/`
2. Export it from `superset-frontend/packages/superset-core/src/ui/components/index.ts`
1. Add the component to `superset-frontend/packages/superset-core/src/components/`
2. Export it from `superset-frontend/packages/superset-core/src/components/index.ts`
3. Create a Storybook story with an `Interactive` export:
```tsx
-1
View File
@@ -159,7 +159,6 @@ single source of truth. The README.md retains its own static copy
<a href="/user-docs/databases/supported/apache-pinot" title="Apache Pinot"><img src="/img/databases/apache-pinot.svg" alt="Apache Pinot" width="76" height="40" /></a> &nbsp;
<a href="/user-docs/databases/supported/apache-solr" title="Apache Solr"><img src="/img/databases/apache-solr.png" alt="Apache Solr" width="79" height="40" /></a> &nbsp;
<a href="/user-docs/databases/supported/apache-spark-sql" title="Apache Spark SQL"><img src="/img/databases/apache-spark.png" alt="Apache Spark SQL" width="75" height="40" /></a> &nbsp;
<a href="/user-docs/databases/supported/ascend" title="Ascend"><img src="/img/databases/ascend.webp" alt="Ascend" width="117" height="40" /></a> &nbsp;
<a href="/user-docs/databases/supported/aurora-mysql-data-api" title="Aurora MySQL (Data API)"><img src="/img/databases/mysql.png" alt="Aurora MySQL (Data API)" width="77" height="40" /></a> &nbsp;
<a href="/user-docs/databases/supported/aurora-postgresql-data-api" title="Aurora PostgreSQL (Data API)"><img src="/img/databases/postgresql.svg" alt="Aurora PostgreSQL (Data API)" width="76" height="40" /></a> &nbsp;
<a href="/user-docs/databases/supported/azure-data-explorer" title="Azure Data Explorer"><img src="/img/databases/kusto.png" alt="Azure Data Explorer" width="40" height="40" /></a> &nbsp;
@@ -78,6 +78,18 @@ Charts are **not saved by default**. The workflow is intentionally iterative:
To skip the preview and save immediately, include "and save it" in your prompt.
:::
:::info Deployment-specific chart types
Use `get_chart_type_schema` before generating a chart to discover the types
available on your Superset instance. Some deployments expose additional
feature-gated visualizations. For example, a deployment with an AG Grid pivot
extension enabled can expose `interactive_pivot`, which supports interactive
row groups, pivot columns, totals, and period-over-period comparisons. Pair
`comparison_period` (for example, `1 year ago`) with `comparison_type`
(`values`, `difference`, `percentage`, or `ratio`). It is distinct from the
built-in `pivot_table` chart type and is not offered when the host visualization
is unavailable.
:::
### Create Dashboards
Build dashboards from a collection of charts:
-71
View File
@@ -1,71 +0,0 @@
/* eslint-env node */
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
const typescriptEslintParser = require('@typescript-eslint/parser');
const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin');
const js = require('@eslint/js');
const ts = require('typescript-eslint');
const react = require('eslint-plugin-react');
const globals = require('globals');
const { defineConfig, globalIgnores } = require('eslint/config');
module.exports = defineConfig([
{
files: ['**/*.{js,jsx,ts,tsx}'],
},
globalIgnores(['build/**/*', '.docusaurus/**/*', 'node_modules/**/*']),
js.configs.recommended,
...ts.configs.recommended,
{
files: ['eslint.config.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
{
languageOptions: {
parser: typescriptEslintParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2020,
sourceType: 'module',
},
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
typescript: typescriptEslintPlugin,
react,
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
settings: {
react: {
version: 'detect',
},
},
},
]);
+139
View File
@@ -0,0 +1,139 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"typescript",
"react"
],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true,
"browser": true,
"node": true
},
"ignorePatterns": [
"build/**/*",
".docusaurus/**/*",
"node_modules/**/*"
],
"settings": {
"react": {
"version": "18.3.1"
}
},
"options": {
"typeAware": true
},
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
}
]
}
+6 -12
View File
@@ -29,7 +29,7 @@
"lint:db-metadata": "python3 ../superset/db_engine_specs/lint_metadata.py",
"lint:db-metadata:report": "python3 ../superset/db_engine_specs/lint_metadata.py --markdown -o ../superset/db_engine_specs/METADATA_STATUS.md",
"update:readme-db-logos": "node scripts/generate-database-docs.mjs --update-readme",
"eslint": "eslint .",
"lint": "oxlint --config oxlint.json",
"lint:docs-links": "node scripts/lint-docs-links.mjs",
"version:add": "node scripts/manage-versions.mjs add",
"version:remove": "node scripts/manage-versions.mjs remove",
@@ -62,7 +62,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.1",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.17",
"baseline-browser-mapping": "^2.11.18",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
@@ -85,19 +85,13 @@
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.2",
"@docusaurus/tsconfig": "^3.10.2",
"@eslint/js": "^9.39.2",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.11.0",
"oxfmt": "^0.64.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
"webpack": "^5.109.2"
"oxlint": "^1.80.0",
"oxlint-tsgolint": "^7.0.2001",
"typescript": "7.0.2",
"webpack": "^5.110.1"
},
"browserslist": {
"production": [
+6107 -8111
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

+23 -6
View File
@@ -1,14 +1,30 @@
{
// This file is not used in compilation. It is here just for a nice editor experience.
"extends": "@docusaurus/tsconfig",
// "extends": "@docusaurus/tsconfig",
// First compilerOptions section comes from above commented @docusaurus/tsconfig
// We moved them here to help with TS v7 migration so whenever Docusaurus readily supports TS v7,
// re-install @docusaurus/tsconfig and remove said section.
// Commented options are overriden in the next section.
"compilerOptions": {
"baseUrl": ".",
"allowJs": true,
// "esModuleInterop": true,
// "jsx": "preserve",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
// "moduleResolution": "bundler",
"module": "esnext",
"noEmit": true,
// "paths": {
// "@site/*": ["./*"]
// },
// "skipLibCheck": true,
"ignoreDeprecations": "6.0",
"skipLibCheck": true,
"noImplicitAny": false,
"strict": false,
"jsx": "react-jsx",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"types": ["@docusaurus/module-type-aliases"],
@@ -23,9 +39,10 @@
// Runtime resolution uses webpack alias pointing to actual source (see src/webpack.extend.ts)
// Using /ui path matches the established pattern used throughout the Superset codebase
"@apache-superset/core/components": ["./src/types/apache-superset-core"],
"*": ["src/*", "node_modules/*"]
"@site/*": ["./*"],
"*": ["./src/*", "./node_modules/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["node_modules", "../superset-frontend/**/*", "src/shims/**"]
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./src/**/*.d.ts"],
"exclude": ["./node_modules", "../superset-frontend/**/*", "src/shims/**"]
}
+353 -1248
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -18,6 +18,19 @@
*/
// timezone for unit tests
process.env.TZ = 'America/New_York';
const reporters = ['default'];
// HTML reporter is not used on CI so skipping its generation for saving time
if (!process.env.CI) {
reporters.push([
'./node_modules/jest-html-reporter',
{
pageTitle: 'Test Report',
},
]);
}
module.exports = {
// [/\\] matches both path separators so the suite also collects on
// native Windows, where jest hands the regex backslash-separated paths.
@@ -88,14 +101,6 @@ module.exports = {
__DEV__: true,
caches: true,
},
reporters: [
'default',
[
'./node_modules/jest-html-reporter',
{
pageTitle: 'Test Report',
},
],
],
reporters: reporters,
testTimeout: 20000,
};
+4 -4
View File
@@ -277,7 +277,7 @@
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.109.2",
"webpack-bundle-analyzer": "^5.3.1",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
@@ -41117,9 +41117,9 @@
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.1.tgz",
"integrity": "sha512-wP2EusncRGL1tZyMHC/umLkjPdYMkTL9nPEKh8G8dkYCJ9TyF6xnXFqjhdqmv5J900irN/g0P5jMvLT22krEXQ==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.2.tgz",
"integrity": "sha512-IagCa/GrdxSz+ba9OMgK7UCfQp97HtXbgjXu7ObcqmRo9PTp0d+24rgY+mIFOx7JPQ9bo6FGsqZhA55rYRZrrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -354,7 +354,7 @@
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.109.2",
"webpack-bundle-analyzer": "^5.3.1",
"webpack-bundle-analyzer": "^5.3.2",
"webpack-cli": "^7.0.3",
"webpack-dev-server": "^5.2.5",
"webpack-manifest-plugin": "^6.0.1",
@@ -47,6 +47,8 @@ export default function extractQueryFields(
metric: 'metrics',
metric_2: 'metrics',
secondary_metric: 'metrics',
left_metric: 'metrics',
right_metric: 'metrics',
x: 'metrics',
y: 'metrics',
size: 'metrics',
@@ -59,6 +59,16 @@ describe('extractQueryFields', () => {
).toEqual(['metric_1', 'metric_2', 'my_custom_metric']);
});
test('should extract butterfly chart metrics', () => {
expect(
extractQueryFields({
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
}).metrics,
).toEqual(['left_sum', 'right_sum']);
});
test('should extract columns', () => {
expect(extractQueryFields({ columns: 'col_1' })).toEqual({
columns: ['col_1'],
@@ -573,12 +573,6 @@ images:
alt: Apache Spark SQL
source_file: docs/intro.md
selector: null
- type: database-logo
page_url: "https://superset.apache.org/user-docs"
image_url: docs/static/img/databases/ascend.webp
alt: Ascend
source_file: docs/intro.md
selector: null
- type: database-logo
page_url: "https://superset.apache.org/user-docs"
image_url: docs/static/img/databases/aws.png
@@ -947,13 +941,6 @@ images:
alt: Apache Spark SQL
source_file: docs/index.mdx
selector: null
- type: database-logo
page_url: "https://superset.apache.org/user-docs"
image_url: "https://superset.apache.org/img/databases/ascend.webp"
output_path: docs/static/img/databases/ascend.webp
alt: Ascend
source_file: docs/index.mdx
selector: null
- type: database-logo
page_url: "https://superset.apache.org/user-docs"
image_url: "https://superset.apache.org/img/databases/aws.png"
@@ -16,15 +16,44 @@
* specific language governing permissions and limitations
* under the License.
*/
import { allEventHandlers, type Event } from '../utils/eventHandlers';
import Echart from '../components/Echart';
import { ButterflyTransformedProps } from './types';
import { EventHandlers } from '../types';
import { ButterflyTransformedProps } from './types';
type ButterflyChartEvent = {
name?: string;
data?: { name?: string };
event?: Event['event'];
};
function getCategoryKey(params: ButterflyChartEvent): string {
return params.data?.name ?? params.name ?? '';
}
export default function Butterfly(props: ButterflyTransformedProps) {
const { height, width, echartOptions, refs, onLegendStateChanged, formData } =
props;
const {
height,
width,
echartOptions,
selectedValues,
refs,
onLegendStateChanged,
formData,
} = props;
const { click, contextmenu } = allEventHandlers(props);
const eventHandlers: EventHandlers = {
click: (params: ButterflyChartEvent) => {
click({ name: getCategoryKey(params) });
},
contextmenu: (params: ButterflyChartEvent) => {
contextmenu({
...params,
name: getCategoryKey(params),
});
},
legendselectchanged: payload => {
onLegendStateChanged?.(payload.selected);
},
@@ -43,6 +72,7 @@ export default function Butterfly(props: ButterflyTransformedProps) {
width={width}
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
vizType={formData.vizType}
/>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -17,11 +17,15 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
import { Behavior, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import { EchartsButterflyChartProps, EchartsButterflyFormData } from './types';
import example from './images/example.png';
import exampleDark from './images/example-dark.png';
import thumbnail from './images/thumbnail.png';
import thumbnailDark from './images/thumbnail-dark.png';
export default class EchartsButterflyChartPlugin extends ChartPlugin<
EchartsButterflyFormData,
@@ -33,12 +37,18 @@ export default class EchartsButterflyChartPlugin extends ChartPlugin<
controlPanel,
loadChart: () => import('./Butterfly'),
metadata: new ChartMetadata({
behaviors: [
Behavior.InteractiveChart,
Behavior.DrillToDetail,
Behavior.DrillBy,
],
credits: ['https://echarts.apache.org'],
category: t('Comparison'),
description: t(
'A butterfly chart compares two metrics across categories using horizontal bars ' +
'that extend left and right from a central axis.',
),
exampleGallery: [{ url: example, urlDark: exampleDark }],
name: t('Butterfly Chart'),
tags: [
t('Categorical'),
@@ -46,7 +56,8 @@ export default class EchartsButterflyChartPlugin extends ChartPlugin<
t('ECharts'),
t('Multi-Variables'),
],
thumbnail: '',
thumbnail,
thumbnailDark,
}),
transformProps,
});
@@ -34,8 +34,13 @@ import { DEFAULT_FORM_DATA } from './constants';
import { defaultGrid } from '../defaults';
import { getDefaultTooltip } from '../utils/tooltip';
import { Refs } from '../types';
import { NULL_STRING } from '../constants';
import { getChartPadding, getLegendProps } from '../utils/series';
import { OpacityEnum } from '../constants';
import {
getChartPadding,
getLegendProps,
getColtypesMapping,
extractGroupbyLabel,
} from '../utils/series';
import { resolveLegendLayout } from '../utils/legendLayout';
import { convertInteger } from '../utils/convertInteger';
@@ -44,19 +49,11 @@ type EChartsOption = ComposeOption<BarSeriesOption>;
const LABEL_LEFT = { position: 'left' as const };
const LABEL_RIGHT = { position: 'right' as const };
function formatCategory(value: unknown): string {
if (value == null) {
return NULL_STRING;
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return String(value);
}
function formatTooltip(
params: CallbackDataParams[],
formatter: NumberFormatter | CurrencyFormatter,
categoryLabels: string[],
categoryByKey: Map<string, string>,
) {
const axisParams = params.filter(
param => param.seriesName && typeof param.value === 'number',
@@ -65,7 +62,13 @@ function formatTooltip(
return '';
}
const title = axisParams[0].name;
const { dataIndex, name } = axisParams[0];
const title =
(typeof dataIndex === 'number'
? categoryLabels.at(dataIndex)
: undefined) ??
(typeof name === 'string' ? categoryByKey.get(name) : undefined) ??
name;
const rows = axisParams.map(param => [
param.seriesName!,
formatter(Math.abs(param.value as number)),
@@ -86,6 +89,8 @@ export default function transformProps(
hooks,
theme,
inContextMenu,
filterState,
emitCrossFilters,
} = chartProps;
const refs: Refs = {};
const { data = [] } = queriesData[0];
@@ -117,32 +122,75 @@ export default function transformProps(
...formData,
};
const groupbyColumn = ensureIsArray(groupby)[0];
const categoryLabel = getColumnLabel(groupbyColumn);
const leftMetricLabel = leftMetric ? getMetricLabel(leftMetric) : '';
const rightMetricLabel = rightMetric ? getMetricLabel(rightMetric) : '';
const leftSeriesName = leftLabel || leftMetricLabel;
const rightSeriesName = rightLabel || rightMetricLabel;
const coltypeMapping = getColtypesMapping(queriesData[0]);
const groupbyColumns = ensureIsArray(groupby);
const groupbyLabels = groupbyColumns.map(getColumnLabel);
const defaultFormatter = currencyFormat?.symbol
? new CurrencyFormatter({ d3Format: xAxisFormat, currency: currencyFormat })
: getNumberFormatter(xAxisFormat);
const categories = data.map(row => formatCategory(row[categoryLabel]));
const leftData = data.map(row => {
const value = Number(row[leftMetricLabel] ?? 0);
return {
value: -Math.abs(value),
label: LABEL_LEFT,
};
});
const rightData = data.map(row => {
const value = Number(row[rightMetricLabel] ?? 0);
return {
value: Math.abs(value),
label: LABEL_RIGHT,
};
const categories = data.map(datum =>
extractGroupbyLabel({ datum, groupby: groupbyLabels, coltypeMapping }),
);
const categoryKeys = data.map((datum, index) => {
const label = categories.at(index) ?? '';
return `${label}__${JSON.stringify(
groupbyLabels.map(col =>
Object.hasOwn(datum, col) ? datum[col] : undefined,
),
)}`;
});
const categoryByKey = new Map(
categoryKeys.flatMap((key, index) => {
const label = categories.at(index);
return label === undefined ? [] : [[key, label] as const];
}),
);
const labelMap = data.reduce<Record<string, string[]>>(
(acc, datum, index) => {
const uniqueKey = categoryKeys.at(index);
if (uniqueKey === undefined) {
return acc;
}
acc[uniqueKey] = groupbyLabels.map(col =>
Object.hasOwn(datum, col) ? (datum[col] as string) : '',
);
return acc;
},
{},
);
const selectedValues = (filterState.selectedValues || []).reduce(
(acc: Record<number, string>, value: string) => {
const index = categoryKeys.indexOf(value);
return index >= 0 ? { ...acc, [index]: value } : acc;
},
{},
);
const getOpacity = (categoryKey: string) =>
filterState.selectedValues?.length &&
!filterState.selectedValues.includes(categoryKey)
? OpacityEnum.SemiTransparent
: OpacityEnum.NonTransparent;
const leftData = data.map((row, i) => ({
name: categoryKeys[i],
value: -Math.abs(Number(row[leftMetricLabel] ?? 0)),
label: LABEL_LEFT,
itemStyle: { opacity: getOpacity(categoryKeys[i]) },
}));
const rightData = data.map((row, i) => ({
name: categoryKeys[i],
value: Math.abs(Number(row[rightMetricLabel] ?? 0)),
label: LABEL_RIGHT,
itemStyle: { opacity: getOpacity(categoryKeys[i]) },
}));
const labelFormatter = (params: CallbackDataParams) => {
const value = Math.abs(params.value as number);
@@ -280,6 +328,8 @@ export default function transformProps(
formatTooltip(
ensureIsArray(params) as CallbackDataParams[],
defaultFormatter,
categories,
categoryByKey,
),
},
series,
@@ -294,5 +344,10 @@ export default function transformProps(
setDataMask,
onContextMenu,
onLegendStateChanged,
groupby: groupbyColumns,
labelMap,
selectedValues,
emitCrossFilters,
coltypeMapping,
};
}
@@ -24,7 +24,12 @@ import {
QueryFormMetric,
RgbaColor,
} from '@superset-ui/core';
import { BaseTransformedProps, LegendFormData, TitleFormData } from '../types';
import {
BaseTransformedProps,
LegendFormData,
TitleFormData,
CrossFilterTransformedProps,
} from '../types';
export type EchartsButterflyFormData = QueryFormData &
LegendFormData &
@@ -49,4 +54,4 @@ export interface EchartsButterflyChartProps extends ChartProps {
}
export type ButterflyTransformedProps =
BaseTransformedProps<EchartsButterflyFormData>;
BaseTransformedProps<EchartsButterflyFormData> & CrossFilterTransformedProps;
@@ -0,0 +1,202 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render } from '@testing-library/react';
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import Butterfly from '../../src/Butterfly/Butterfly';
import transformProps from '../../src/Butterfly/transformProps';
import { EchartsButterflyChartProps } from '../../src/Butterfly/types';
import Echart from '../../src/components/Echart';
import { EventHandlers } from '../../src/types';
jest.mock('../../src/components/Echart', () => ({
__esModule: true,
default: jest.fn(() => null),
}));
const mockedEchart = jest.mocked(Echart);
const data = [
{ category: 'A', left_sum: 10, right_sum: 25 },
{ category: 'B', left_sum: 5, right_sum: 19 },
];
const categoryKeyA = 'A__["A"]';
const categoryKeyB = 'B__["B"]';
function setup(
overrides: {
filterState?: { selectedValues?: string[] };
onLegendStateChanged?: jest.Mock;
} = {},
) {
const onContextMenu = jest.fn();
const setDataMask = jest.fn();
const onLegendStateChanged = overrides.onLegendStateChanged ?? jest.fn();
const chartProps = {
...new ChartProps({
formData: {
groupby: ['category'],
left_metric: 'left_sum',
right_metric: 'right_sum',
viz_type: 'butterfly',
},
width: 800,
height: 600,
queriesData: [{ data }],
theme: supersetTheme,
hooks: { onContextMenu, setDataMask, onLegendStateChanged },
}),
filterState: overrides.filterState ?? {},
emitCrossFilters: true,
} as unknown as EchartsButterflyChartProps;
const transformed = transformProps(chartProps);
render(
<Butterfly
{...transformed}
onContextMenu={onContextMenu}
setDataMask={setDataMask}
onLegendStateChanged={onLegendStateChanged}
emitCrossFilters
/>,
);
const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1];
const { eventHandlers, selectedValues } = lastCall[0] as {
eventHandlers: EventHandlers;
selectedValues: Record<number, string>;
};
return {
eventHandlers,
onContextMenu,
setDataMask,
onLegendStateChanged,
selectedValues,
};
}
beforeEach(() => {
mockedEchart.mockClear();
});
test('context menu exposes drill to detail for the selected category', () => {
const { eventHandlers, onContextMenu } = setup();
eventHandlers.contextmenu({
name: 'A',
data: { name: categoryKeyA },
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [x, y, payload] = onContextMenu.mock.calls[0];
expect(x).toBe(10);
expect(y).toBe(20);
expect(payload.drillToDetail).toEqual([
expect.objectContaining({
col: 'category',
op: '==',
val: 'A',
formattedVal: 'A',
}),
]);
});
test('context menu exposes drill by for the selected category', () => {
const { eventHandlers, onContextMenu } = setup();
eventHandlers.contextmenu({
name: 'A',
data: { name: categoryKeyA },
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
});
const payload = onContextMenu.mock.calls[0][2];
expect(payload.drillBy).toEqual({
filters: [
expect.objectContaining({
col: 'category',
op: '==',
val: 'A',
formattedVal: 'A',
}),
],
groupbyFieldName: 'groupby',
});
});
test('click emits cross-filter for the selected category', () => {
const { eventHandlers, setDataMask } = setup();
eventHandlers.click({ name: 'B', data: { name: categoryKeyB } });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [{ col: 'category', op: 'IN', val: ['B'] }],
},
filterState: {
value: [['B']],
selectedValues: [categoryKeyB],
},
}),
);
});
test('click clears cross-filter when the category is already selected', () => {
const { eventHandlers, setDataMask } = setup({
filterState: { selectedValues: [categoryKeyB] },
});
eventHandlers.click({ name: 'B', data: { name: categoryKeyB } });
expect(setDataMask).toHaveBeenCalledWith(
expect.objectContaining({
extraFormData: {
filters: [],
},
filterState: {
value: null,
selectedValues: null,
},
}),
);
});
test('legend selection forwards legend state to the chart hook', () => {
const onLegendStateChanged = jest.fn();
const { eventHandlers } = setup({ onLegendStateChanged });
const selected = { left_sum: true, right_sum: false };
eventHandlers.legendselectchanged({ selected });
eventHandlers.legendselectall({ selected });
eventHandlers.legendinverseselect({ selected });
expect(onLegendStateChanged).toHaveBeenCalledTimes(3);
expect(onLegendStateChanged).toHaveBeenCalledWith(selected);
});
test('passes selectedValues through to the chart component', () => {
const { selectedValues } = setup({
filterState: { selectedValues: [categoryKeyA] },
});
expect(selectedValues).toEqual({ 0: categoryKeyA });
});
@@ -18,25 +18,40 @@
*/
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import type { CallbackDataParams } from 'echarts/types/src/util/types';
import {
EchartsButterflyChartProps,
ButterflyTransformedProps,
} from '../../src/Butterfly/types';
import transformProps from '../../src/Butterfly/transformProps';
import { NULL_STRING } from '../../src/constants';
import { NULL_STRING, OpacityEnum } from '../../src/constants';
type SeriesDataPoint = { value?: number } | number;
const categoryKeyA = 'A__["A"]';
const categoryKeyB = 'B__["B"]';
type SeriesDataPoint = {
name?: string;
value?: number;
itemStyle?: { opacity?: number };
};
type ButterflyTestSeries = {
name?: string;
data?: SeriesDataPoint[];
itemStyle?: { color?: string };
label?: { show?: boolean };
label?: {
show?: boolean;
formatter?: (params: CallbackDataParams) => string;
};
};
type ButterflyTestEchartOptions = {
series?: ButterflyTestSeries[];
xAxis?: { name?: string; nameGap?: number };
xAxis?: {
name?: string;
nameGap?: number;
axisLabel?: { formatter?: (value: number) => string };
};
yAxis?: {
name?: string;
nameGap?: number;
@@ -45,7 +60,10 @@ type ButterflyTestEchartOptions = {
};
legend?: { orient?: string; data?: string[] };
grid?: { left?: number; top?: number };
tooltip?: { show?: boolean };
tooltip?: {
show?: boolean;
formatter?: (params: CallbackDataParams | CallbackDataParams[]) => string;
};
};
const getEchartOptions = (
@@ -55,13 +73,7 @@ const getEchartOptions = (
const extractSeriesValues = (props: ButterflyTransformedProps) => {
const series = getEchartOptions(props).series ?? [];
return series.map(item =>
(item.data ?? []).map(entry =>
typeof entry === 'object' && entry !== null && 'value' in entry
? entry.value
: entry,
),
);
return series.map(item => (item.data ?? []).map(entry => entry.value));
};
const extractSeriesNames = (props: ButterflyTransformedProps) => {
@@ -88,19 +100,22 @@ const createChartProps = (
overrides: Record<string, unknown> = {},
queryData: Record<string, unknown>[] = data,
) =>
new ChartProps({
formData: { ...formData, ...overrides },
width: 800,
height: 600,
queriesData: [{ data: queryData }],
theme: supersetTheme,
...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
});
({
...new ChartProps({
formData: { ...formData, ...overrides },
width: 800,
height: 600,
queriesData: [{ data: queryData }],
theme: supersetTheme,
...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
}),
filterState: overrides.filterState ?? {},
emitCrossFilters: overrides.emitCrossFilters,
inContextMenu: overrides.inContextMenu,
}) as unknown as EchartsButterflyChartProps;
test('transforms chart props into diverging bar series', () => {
const transformedProps = transformProps(
createChartProps() as unknown as EchartsButterflyChartProps,
);
const transformedProps = transformProps(createChartProps());
expect(extractSeriesValues(transformedProps)).toEqual([
[-10, -5],
@@ -108,11 +123,23 @@ test('transforms chart props into diverging bar series', () => {
]);
});
test('assigns composite category keys to each bar data point', () => {
const transformedProps = transformProps(createChartProps());
const series = getEchartOptions(transformedProps).series ?? [];
expect(series[0]?.data?.map(point => point.name)).toEqual([
categoryKeyA,
categoryKeyB,
]);
expect(series[1]?.data?.map(point => point.name)).toEqual([
categoryKeyA,
categoryKeyB,
]);
});
test('uses absolute values for negative right-side metrics', () => {
const transformedProps = transformProps(
createChartProps({}, [
{ category: 'A', left_sum: -8, right_sum: -15 },
]) as unknown as EchartsButterflyChartProps,
createChartProps({}, [{ category: 'A', left_sum: -8, right_sum: -15 }]),
);
expect(extractSeriesValues(transformedProps)).toEqual([[-8], [15]]);
@@ -122,7 +149,7 @@ test('formats null categories and missing metric values', () => {
const transformedProps = transformProps(
createChartProps({}, [
{ category: null, left_sum: undefined, right_sum: 7 },
]) as unknown as EchartsButterflyChartProps,
]),
);
const { yAxis } = getEchartOptions(transformedProps);
@@ -141,7 +168,7 @@ test('applies custom series labels, colors, and axis titles', () => {
right_color: { r: 0, g: 255, b: 0 },
x_axis_label: 'Value axis',
y_axis_label: 'Category axis',
}) as unknown as EchartsButterflyChartProps,
}),
);
const { series, xAxis, yAxis } = getEchartOptions(transformedProps);
@@ -163,7 +190,7 @@ test('applies legend orientation, sort, and axis margin settings', () => {
xAxisLabelRotation: 45,
x_axis_title_margin: 60,
y_axis_title_margin: 80,
}) as unknown as EchartsButterflyChartProps,
}),
);
const { legend, xAxis, yAxis, grid } = getEchartOptions(transformedProps);
@@ -178,9 +205,7 @@ test('applies legend orientation, sort, and axis margin settings', () => {
test('hides value labels when showValue is false', () => {
const transformedProps = transformProps(
createChartProps({
showValue: false,
}) as unknown as EchartsButterflyChartProps,
createChartProps({ showValue: false }),
);
const { series } = getEchartOptions(transformedProps);
@@ -188,15 +213,129 @@ test('hides value labels when showValue is false', () => {
expect(series?.[1]?.label?.show).toBe(false);
});
test('hides tooltip while the context menu is open', () => {
test('hides zero value labels but keeps non-zero labels', () => {
const transformedProps = transformProps(
createChartProps({}, data) as unknown as EchartsButterflyChartProps,
createChartProps({}, [{ category: 'A', left_sum: 0, right_sum: 12 }]),
);
const formatter =
getEchartOptions(transformedProps).series?.[0]?.label?.formatter;
expect(formatter?.({ value: 0 } as CallbackDataParams)).toBe('');
expect(formatter?.({ value: -10 } as CallbackDataParams)).toBe('10');
});
test('formats axis and tooltip values as absolute numbers', () => {
const transformedProps = transformProps(createChartProps());
const { xAxis, tooltip } = getEchartOptions(transformedProps);
expect(xAxis?.axisLabel?.formatter?.(-25)).toBe('25');
const tooltipHtml = tooltip?.formatter?.([
{
name: categoryKeyA,
dataIndex: 0,
seriesName: 'left_sum',
value: -10,
} as CallbackDataParams,
{
name: categoryKeyA,
dataIndex: 0,
seriesName: 'right_sum',
value: 25,
} as CallbackDataParams,
]);
expect(tooltipHtml).toContain('A');
expect(tooltipHtml).not.toContain(categoryKeyA);
expect(tooltipHtml).toContain('left_sum');
expect(tooltipHtml).toContain('right_sum');
expect(tooltipHtml).toContain('10');
expect(tooltipHtml).toContain('25');
});
test('shows the category label in the tooltip when ECharts reports a unique key', () => {
const transformedProps = transformProps(createChartProps());
const tooltipHtml = getEchartOptions(transformedProps).tooltip?.formatter?.({
name: categoryKeyA,
seriesName: 'left_sum',
value: -10,
} as CallbackDataParams);
expect(tooltipHtml).toContain('A');
expect(tooltipHtml).not.toContain(categoryKeyA);
});
test('hides tooltip while the context menu is open', () => {
const transformedProps = transformProps(createChartProps());
const withContextMenu = transformProps(
createChartProps({ inContextMenu: true }),
);
const withContextMenu = transformProps({
...createChartProps(),
inContextMenu: true,
} as unknown as EchartsButterflyChartProps);
expect(getEchartOptions(transformedProps).tooltip?.show).toBe(true);
expect(getEchartOptions(withContextMenu).tooltip?.show).toBe(false);
});
test('builds labelMap and groupby for drill and cross-filter handlers', () => {
const transformedProps = transformProps(createChartProps());
expect(transformedProps.groupby).toEqual(['category']);
expect(transformedProps.labelMap).toEqual({
'A__["A"]': ['A'],
'B__["B"]': ['B'],
});
});
test('uses unique keys for interactions and readable labels on the y-axis', () => {
const transformedProps = transformProps(
createChartProps({ groupby: ['country', 'state'] }, [
{ country: 'US', state: 'CA', left_sum: 4, right_sum: 6 },
{ country: 'US', state: 'NY', left_sum: 8, right_sum: 3 },
]),
);
const series = getEchartOptions(transformedProps).series ?? [];
const firstKey = 'US, CA__["US","CA"]';
const secondKey = 'US, NY__["US","NY"]';
expect(firstKey).not.toBe(secondKey);
expect(series[0]?.data?.map(point => point.name)).toEqual([
firstKey,
secondKey,
]);
expect(transformedProps.labelMap).toEqual({
[firstKey]: ['US', 'CA'],
[secondKey]: ['US', 'NY'],
});
expect(getEchartOptions(transformedProps).yAxis?.data).toEqual([
'US, CA',
'US, NY',
]);
});
test('dims unselected categories when a cross-filter is active', () => {
const transformedProps = transformProps(
createChartProps({
filterState: { selectedValues: [categoryKeyA] },
}),
);
const series = getEchartOptions(transformedProps).series ?? [];
expect(series[0]?.data?.[0]?.itemStyle?.opacity).toBe(
OpacityEnum.NonTransparent,
);
expect(series[0]?.data?.[1]?.itemStyle?.opacity).toBe(
OpacityEnum.SemiTransparent,
);
expect(series[1]?.data?.[1]?.itemStyle?.opacity).toBe(
OpacityEnum.SemiTransparent,
);
});
test('maps selectedValues to category indexes', () => {
const transformedProps = transformProps(
createChartProps({
filterState: { selectedValues: [categoryKeyB] },
}),
);
expect(transformedProps.selectedValues).toEqual({ 1: categoryKeyB });
});
@@ -19,6 +19,7 @@
import {
CategoricalColorScale,
ChartProps,
NumberFormatter,
TimeGranularity,
getNumberFormatter,
} from '@superset-ui/core';
@@ -159,6 +160,72 @@ describe('transformSeries', () => {
expect((result as ScatterSeriesOption).symbolSize).toBe(7);
});
test('does not render a per-series stacked label for a zero-value segment (#42702)', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: true,
onlyTotal: false,
isHorizontal: false,
timeShiftColor: false,
// percentage_threshold defaults to 0, so thresholdValues[dataIndex] is
// 0 too — a value of exactly 0 would satisfy `numericValue >= (thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)`
// without the explicit `numericValue !== 0` guard.
thresholdValues: [0],
formatter: new NumberFormatter({
id: 'test-formatter',
formatFunc: (value: number) => `${value}`,
}),
};
const result = transformSeries(series, mockColorScale, 'test-key', opts);
const { formatter: labelFormatter } = (result as any).label;
const zeroValueLabel = labelFormatter({
value: [null, 0],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(zeroValueLabel).toBe('');
const nonZeroValueLabel = labelFormatter({
value: [null, 32],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(nonZeroValueLabel).toBe('32');
});
test('still renders a per-series stacked label for a genuine negative value that clears the threshold', () => {
const opts = {
seriesType: EchartsTimeseriesSeriesType.Bar,
stack: true,
onlyTotal: false,
isHorizontal: false,
timeShiftColor: false,
// A category whose stacked total is itself negative produces a
// negative threshold — a strictly-positive check would wrongly
// suppress a real, meaningful negative-value label here.
thresholdValues: [-10],
formatter: new NumberFormatter({
id: 'test-formatter',
formatFunc: (value: number) => `${value}`,
}),
};
const result = transformSeries(series, mockColorScale, 'test-key', opts);
const { formatter: labelFormatter } = (result as any).label;
const negativeValueLabel = labelFormatter({
value: [null, -5],
dataIndex: 0,
seriesIndex: 0,
seriesName: 'test-series',
});
expect(negativeValueLabel).toBe('-5');
});
});
describe('transformNegativeLabelsPosition', () => {
@@ -18,7 +18,7 @@
*/
import fetchMock from 'fetch-mock';
import { FeatureFlag, isFeatureEnabled, QueryState } from '@superset-ui/core';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import QueryHistory from 'src/SqlLab/components/QueryHistory';
import {
initialState,
@@ -252,6 +252,247 @@ test('displays multiple queries with newest query first', async () => {
isFeatureEnabledMock.mockClear();
});
// `sql` is never part of the merge's overlay bundle, so a merged row's `sql`
// always comes from the `{...remoteQuery}` base, whether or not an override
// happened. Every live-only Redux fixture below uses `sql: 'SELECT 1'`,
// while the backend snapshot uses this distinctive query text - so this can
// only resolve once the backend response has actually loaded *and* been
// folded into the rendered row, unlike `waitFor(() => calls.length === 1)`,
// which resolves as soon as the request is issued, while `data` is still
// `undefined` and the component is still rendering the pre-merge,
// Redux-only fallback. Deliberately not a Duration-cell/`endDttm` barrier:
// a real Redux row that has concluded always has an `endDttm` (see
// `QUERY_SUCCESS` in `reducers/sqlLab.ts`), so that barrier would silently
// go vacuous the moment a fixture became realistic about timestamps.
const findRemoteSqlCell = () => screen.findByText(/FCC 2018 Survey/);
// The barrier above holds only while the live fixture's sql differs from the
// snapshot's. If they ever match, findRemoteSqlCell() resolves pre-merge and
// every assertion after it goes vacuous. Fail loudly rather than silently.
const assertLiveSqlDiffersFromSnapshot = (q: { sql: string }) =>
expect(q.sql).not.toMatch(/FCC 2018 Survey/);
test('overrides a stale non-concluded backend snapshot with a concluded live Redux state', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
// A non-concluded row's `end_time` is never set by the backend (every
// write of `end_time` is paired with a concluded status - see
// `superset/sql_lab.py` and `superset/daos/query.py`). Note this maps to
// `endDttm: 0`, not `undefined` - `mapQueryResponse` does
// `Number(query.end_time)` and `Number(null) === 0`.
const staleApiResult = {
count: 1,
ids: [692],
result: [
{
...fakeApiResult.result[0],
client_id: 'stuckClientId',
status: QueryState.Running,
progress: 0,
rows: 0,
end_time: null,
sql_editor_id: defaultQueryEditor.id,
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, staleApiResult);
const stateWithLiveQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
stuckClientId: {
id: 'stuckClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Success,
startDttm: 1710273662445,
// A real Redux row at Success always has an endDttm too -
// QUERY_SUCCESS sets both together.
endDttm: 1710273662500,
progress: 100,
rows: 443,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithLiveQuery.sqlLab.queries.stuckClientId,
);
render(setup(), { useRedux: true, initialState: stateWithLiveQuery });
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const row = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(row).getByLabelText('check')).toBeInTheDocument();
expect(within(row).queryByLabelText('loading')).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('does not override an already-concluded backend snapshot with a non-concluded Redux state', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
const concludedApiResult = {
count: 1,
ids: [692],
result: [
{
...fakeApiResult.result[0],
client_id: 'scheduledClientId',
status: QueryState.Success,
progress: 100,
rows: 443,
sql_editor_id: defaultQueryEditor.id,
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, concludedApiResult);
// Redux hasn't observed this query conclude yet: it's still Scheduled.
// Deliberately not Running/Pending with progress 0, which is the tuple
// CLEAR_INACTIVE_QUERIES evicts once stale - that combination can't
// actually reach this merge in production.
const stateWithScheduledQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
scheduledClientId: {
id: 'scheduledClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Scheduled,
startDttm: 1710273662445,
progress: 0,
rows: 0,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithScheduledQuery.sqlLab.queries.scheduledClientId,
);
render(setup(), { useRedux: true, initialState: stateWithScheduledQuery });
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const row = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(row).getByLabelText('check')).toBeInTheDocument();
expect(within(row).queryByLabelText('loading')).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('renders a backend-only historical query the client never ran, alongside a live one', async () => {
const isFeatureEnabledMock = mockedIsFeatureEnabled.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SqllabBackendPersistence,
);
const twoRowApiResult = {
count: 2,
ids: [692, 700],
result: [
{
...fakeApiResult.result[0],
client_id: 'liveClientId',
status: QueryState.Running,
progress: 0,
rows: 0,
// Non-concluded: the backend never sets end_time for this status
// (maps to endDttm: 0, not undefined - see the comment above).
end_time: null,
sql_editor_id: defaultQueryEditor.id,
},
{
...fakeApiResult.result[0],
id: 700,
client_id: 'historicalOnlyClientId',
status: QueryState.Success,
progress: 100,
rows: 12,
sql_editor_id: defaultQueryEditor.id,
start_time: '1710273660000.000000',
// A different table than the live row's, so findRemoteSqlCell's
// target text is unique to that row, not duplicated on this one.
sql: 'SELECT * from "Population"',
executed_sql: 'SELECT * from "Population"\nLIMIT 1001',
},
],
};
const editorQueryApiRoute = `glob:*/api/v1/query/?q=*`;
fetchMock.get(editorQueryApiRoute, twoRowApiResult);
const stateWithOnlyOneLiveQuery = {
...initialState,
sqlLab: {
...initialState.sqlLab,
queries: {
liveClientId: {
id: 'liveClientId',
sqlEditorId: defaultQueryEditor.id,
sql: 'SELECT 1',
state: QueryState.Success,
startDttm: 1710273662445,
// A real Redux row at Success always has an endDttm too -
// QUERY_SUCCESS sets both together.
endDttm: 1710273662500,
progress: 100,
rows: 443,
},
},
},
};
assertLiveSqlDiffersFromSnapshot(
stateWithOnlyOneLiveQuery.sqlLab.queries.liveClientId,
);
const { container } = render(setup(), {
useRedux: true,
initialState: stateWithOnlyOneLiveQuery,
});
await waitFor(() =>
expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1),
);
await findRemoteSqlCell();
const tableRows = container.querySelectorAll(
'table > tbody > tr:not(.ant-table-measure-row)',
);
expect(tableRows).toHaveLength(2);
const liveRow = screen.getByText('443').closest('tr') as HTMLElement;
expect(within(liveRow).getByLabelText('check')).toBeInTheDocument();
expect(within(liveRow).queryByLabelText('loading')).not.toBeInTheDocument();
const historicalRow = screen.getByText('12').closest('tr') as HTMLElement;
expect(within(historicalRow).getByLabelText('check')).toBeInTheDocument();
expect(
within(historicalRow).queryByLabelText('loading'),
).not.toBeInTheDocument();
isFeatureEnabledMock.mockClear();
});
test('renders contributed toolbar action in queryHistory slot', () => {
registerToolbarAction(
ViewLocations.sqllab.queryHistory,
@@ -19,7 +19,6 @@
import { useEffect, useMemo, useState } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { useInView } from 'react-intersection-observer';
import { omit } from 'lodash-es';
import { EmptyState, Skeleton } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
@@ -31,6 +30,7 @@ import useEffectEvent from 'src/hooks/useEffectEvent';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import PanelToolbar from 'src/components/PanelToolbar';
import { ViewLocations } from 'src/SqlLab/contributions';
import { mergeQueryStatus } from './mergeQueryStatus';
interface QueryHistoryProps {
queryEditorId: string | number;
@@ -82,25 +82,26 @@ const QueryHistory = ({
skip: !isFeatureEnabled(FeatureFlag.SqllabBackendPersistence),
},
);
const editorQueries = useMemo(
() =>
data
? getEditorQueries(
omit(
queries,
data.result.map(({ id }) => id),
),
editorId,
)
.concat(data.result)
.sort((a, b) => {
const aTime = a.startDttm || 0;
const bTime = b.startDttm || 0;
return aTime - bTime;
})
: getEditorQueries(queries, editorId),
[queries, data, editorId],
);
const editorQueries = useMemo(() => {
if (!data) {
return getEditorQueries(queries, editorId);
}
const remoteIds = new Set(data.result.map(({ id }) => id));
const mergedRemoteQueries = data.result.map(remoteQuery => {
const localQuery = queries[remoteQuery.id];
return localQuery
? mergeQueryStatus(remoteQuery, localQuery)
: remoteQuery;
});
return getEditorQueries(queries, editorId)
.filter(({ id }) => !remoteIds.has(id))
.concat(mergedRemoteQueries)
.sort((a, b) => {
const aTime = a.startDttm || 0;
const bTime = b.startDttm || 0;
return aTime - bTime;
});
}, [queries, data, editorId]);
const loadNext = useEffectEvent(() => {
setPageIndex(pageIndex + 1);
@@ -0,0 +1,151 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { QueryState, testQueryResponse } from '@superset-ui/core';
import { mergeQueryStatus } from './mergeQueryStatus';
// remoteBase and localBase deliberately differ on backend-only metadata
// (queryId, tab, executedSql) that the merge bundle never touches, not just
// on the seven bundle fields. If they only differed on the bundle fields,
// `{...remoteQuery, <bundle>}` and `{...localQuery, <bundle>}` would be
// structurally equal and toEqual/toBe could not tell a correct base from a
// wrong one — see the "keeps the snapshot's backend-only metadata" test
// below, which exists specifically to catch that class of regression.
const remoteBase = {
...testQueryResponse,
queryId: 692,
tab: 'Untitled Query 16',
executedSql: 'SELECT * from "FCC 2018 Survey"\nLIMIT 1001',
state: QueryState.Running,
progress: 0,
rows: 0,
startDttm: 1000,
endDttm: undefined as unknown as number,
resultsKey: null,
errorMessage: null,
};
const localBase = {
...testQueryResponse,
queryId: undefined as unknown as number,
tab: 'stale local tab',
executedSql: undefined as unknown as string,
state: QueryState.Success,
progress: 100,
rows: 443,
startDttm: 2000,
endDttm: 3000,
resultsKey: 'a-results-key',
errorMessage: null,
};
test('both non-concluded: returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Scheduled };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('remote concluded, local not: returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Success };
const local = { ...localBase, state: QueryState.Running };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('both concluded: declines to override, returns the remote row unchanged', () => {
const remote = { ...remoteBase, state: QueryState.Success };
const local = { ...localBase, state: QueryState.Stopped };
expect(mergeQueryStatus(remote, local)).toBe(remote);
});
test('local concluded, remote not: local supplies status fields and both timestamps together', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Success };
expect(mergeQueryStatus(remote, local)).toEqual({
...remote,
state: QueryState.Success,
progress: 100,
rows: 443,
startDttm: 2000,
endDttm: 3000,
resultsKey: 'a-results-key',
errorMessage: null,
});
});
test('local concluded, remote not: undefined local fields fall back to the remote value', () => {
const remote = {
...remoteBase,
state: QueryState.Running,
startDttm: 1000,
endDttm: 1500,
resultsKey: 'remote-results-key',
errorMessage: 'remote error',
};
const local = {
...localBase,
state: QueryState.Success,
startDttm: undefined as unknown as number,
endDttm: undefined as unknown as number,
resultsKey: undefined as unknown as string,
errorMessage: undefined as unknown as string,
};
const merged = mergeQueryStatus(remote, local);
expect(merged.startDttm).toBe(1000);
expect(merged.endDttm).toBe(1500);
expect(merged.resultsKey).toBe('remote-results-key');
expect(merged.errorMessage).toBe('remote error');
});
test('local concluded, remote not: a null local field overrides a remote value (does not fall back)', () => {
const remote = {
...remoteBase,
state: QueryState.Running,
resultsKey: 'remote-results-key',
errorMessage: 'remote error',
};
const local = {
...localBase,
state: QueryState.Failed,
resultsKey: null,
errorMessage: null,
};
const merged = mergeQueryStatus(remote, local);
expect(merged.resultsKey).toBeNull();
expect(merged.errorMessage).toBeNull();
});
test('local concluded, remote not: keeps the snapshot-only metadata (queryId, tab, executedSql)', () => {
const remote = { ...remoteBase, state: QueryState.Running };
const local = { ...localBase, state: QueryState.Success };
const merged = mergeQueryStatus(remote, local);
expect(merged.queryId).toBe(692);
expect(merged.tab).toBe('Untitled Query 16');
expect(merged.executedSql).toBe(
'SELECT * from "FCC 2018 Survey"\nLIMIT 1001',
);
});
@@ -0,0 +1,71 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { concludedQueryStateList, QueryResponse } from '@superset-ui/core';
export const isConcludedState = (state: QueryResponse['state']) =>
concludedQueryStateList.includes(state);
// The backend history snapshot fetched by `useEditorQueriesQuery` is a
// one-shot fetch that is never invalidated, so it can strand a query at a
// non-terminal state forever once `QueryAutoRefresh` stops polling it (see
// `QueryAutoRefresh.MAX_QUERY_AGE_TO_POLL`). This function corrects only
// that one case: when the live Redux copy has concluded and the snapshot
// has not, the live row supplies the status fields and both timestamps
// together, since they must come from a single clock (the backend records
// both `startDttm`/`endDttm` in server time, while the client stamps
// `endDttm` from the browser clock when a query concludes locally). Every
// other combination — including both sides concluded, or the snapshot
// already concluded — returns the snapshot row unchanged.
export const mergeQueryStatus = (
remoteQuery: QueryResponse,
localQuery: QueryResponse,
): QueryResponse => {
if (
!isConcludedState(localQuery.state) ||
isConcludedState(remoteQuery.state)
) {
return remoteQuery;
}
return {
...remoteQuery,
state:
localQuery.state !== undefined ? localQuery.state : remoteQuery.state,
progress:
localQuery.progress !== undefined
? localQuery.progress
: remoteQuery.progress,
rows: localQuery.rows !== undefined ? localQuery.rows : remoteQuery.rows,
startDttm:
localQuery.startDttm !== undefined
? localQuery.startDttm
: remoteQuery.startDttm,
endDttm:
localQuery.endDttm !== undefined
? localQuery.endDttm
: remoteQuery.endDttm,
resultsKey:
localQuery.resultsKey !== undefined
? localQuery.resultsKey
: remoteQuery.resultsKey,
errorMessage:
localQuery.errorMessage !== undefined
? localQuery.errorMessage
: remoteQuery.errorMessage,
};
};
@@ -19,7 +19,12 @@
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { getExtensionsRegistry, VizType } from '@superset-ui/core';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
fireEvent,
render,
screen,
userEvent,
} from 'spec/helpers/testing-library';
import {
enableMobileConsumptionFlag,
mockMobileMatchMedia,
@@ -106,6 +111,13 @@ jest.mock('src/dashboard/components/FiltersBadge', () => ({
),
}));
jest.mock('./SliceInfo', () => ({
__esModule: true,
default: ({ slice }: { slice: { description: string } }) => (
<div data-test="slice-info">{slice.description}</div>
),
}));
jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: jest.fn().mockReturnValue(false),
}));
@@ -571,6 +583,172 @@ test('Correct actions to "SliceHeaderControls"', () => {
expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1);
});
test('Should show chart description info icon when description exists and is collapsed', () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(screen.getByTestId('chart-description-info-icon')).toBeInTheDocument();
});
test('Should hide chart description info icon when description is expanded', () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: true,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(
screen.queryByTestId('chart-description-info-icon'),
).not.toBeInTheDocument();
});
test('Should hide chart description info icon when chart has no description', () => {
const props = createProps({
slice: {
...createProps().slice,
description: '',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(
screen.queryByTestId('chart-description-info-icon'),
).not.toBeInTheDocument();
});
test('Chart description icon is a keyboard-focusable button', () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
const icon = screen.getByRole('button', { name: 'Chart description' });
icon.focus();
expect(icon).toHaveFocus();
});
test('Should show chart description in popover on hover', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
await userEvent.hover(screen.getByTestId('chart-description-info-icon'));
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Should show chart description in popover on click', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId('chart-description-info-icon'));
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Should open chart description popover with Enter', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
const icon = screen.getByRole('button', { name: 'Chart description' });
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
// user-event v12 (pinned in this repo) doesn't expose .keyboard(); use
// fireEvent to dispatch keydown directly to the focused icon.
icon.focus();
fireEvent.keyDown(icon, { key: 'Enter' });
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Should open chart description popover with Space', async () => {
const props = createProps({
slice: {
...createProps().slice,
description: 'Test chart description',
},
isExpanded: false,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState,
});
const icon = screen.getByRole('button', { name: 'Chart description' });
expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
icon.focus();
fireEvent.keyDown(icon, { key: ' ' });
expect(await screen.findByTestId('slice-info')).toHaveTextContent(
'Test chart description',
);
});
test('Add extension to SliceHeader', () => {
const extensionsRegistry = getExtensionsRegistry();
extensionsRegistry.set('dashboard.slice.header', () => (
@@ -0,0 +1,64 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import SliceInfo from './SliceInfo';
jest.mock('@superset-ui/core/components/SafeMarkdown/SafeMarkdown', () => ({
SafeMarkdown: ({ source }: { source: string }) => (
<div data-test="safe-markdown">{source}</div>
),
}));
const setup = (description = 'Default description') =>
render(<SliceInfo slice={{ description }} />);
test('Should render chart description', () => {
setup('Hello world');
expect(screen.getByTestId('safe-markdown')).toHaveTextContent('Hello world');
});
test('Should pass markdown source to SafeMarkdown', () => {
const markdown = [
'# Chart overview',
'',
'This chart shows **revenue** by region.',
'',
'- North',
'- South',
'',
'[Learn more](https://superset.apache.org)',
].join('\n');
setup(markdown);
expect(screen.getByTestId('safe-markdown').textContent).toBe(markdown);
});
test('Should render long markdown description without crashing', () => {
const longMarkdown = `# Summary\n\n${'Long description paragraph. '.repeat(100)}`;
setup(longMarkdown);
const content = screen.getByTestId('safe-markdown').textContent ?? '';
expect(content).toContain('# Summary');
expect(content.match(/Long description paragraph\./g)).toHaveLength(100);
});
test('Should render empty description without crashing', () => {
setup('');
expect(screen.getByTestId('safe-markdown')).toBeEmptyDOMElement();
});
@@ -0,0 +1,45 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { css, styled } from '@apache-superset/core/theme';
import { SafeMarkdown } from '@superset-ui/core/components';
const SliceInfoContainer = styled.div`
${({ theme }) => css`
max-width: 350px;
max-height: 400px;
overflow-y: auto;
overflow-x: auto;
font-size: ${theme.fontSize}px;
`}
`;
interface SliceInfoProps {
slice: {
description: string;
};
}
const SliceInfo: FC<SliceInfoProps> = ({ slice }) => (
<SliceInfoContainer>
<SafeMarkdown source={slice.description} />
</SliceInfoContainer>
);
export default SliceInfo;
@@ -28,6 +28,7 @@ import {
import { t } from '@apache-superset/core/translation';
import {
getExtensionsRegistry,
handleKeyboardActivation,
JsonObject,
QueryData,
VizType,
@@ -40,7 +41,12 @@ import {
} from '@apache-superset/core/theme';
import { useUiConfig } from 'src/components/UiConfigContext';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components';
import {
Tooltip,
EditableTitle,
Icons,
Popover,
} from '@superset-ui/core/components';
import { useSelector } from 'react-redux';
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
import { useIsMobile } from 'src/hooks/useIsMobile';
@@ -52,6 +58,7 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
import RowCountLabel from 'src/components/RowCountLabel';
import { Link } from 'react-router-dom';
import SliceInfo from './SliceInfo';
const extensionsRegistry = getExtensionsRegistry();
@@ -210,6 +217,8 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
state => state.charts[slice.slice_id].queriesResponse?.[1],
);
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false);
const theme = useTheme();
const rowLimit = Number(formData.row_limit ?? 0);
@@ -339,6 +348,27 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
<CrossFilterIcon iconSize="m" />
</Tooltip>
)}
{slice.description && !isExpanded && (
<Popover
trigger={['hover', 'click']}
content={<SliceInfo slice={slice} />}
placement="leftBottom"
open={isDescriptionOpen}
onOpenChange={setIsDescriptionOpen}
>
<Icons.InfoCircleOutlined
iconSize="m"
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
role="button"
tabIndex={0}
aria-label={t('Chart description')}
data-test="chart-description-info-icon"
onKeyDown={handleKeyboardActivation(() =>
setIsDescriptionOpen(open => !open),
)}
/>
</Popover>
)}
{!uiConfig.hideChartControls && (
<MemoizedCustomizationsBadge chartId={slice.slice_id} />
)}
@@ -17,7 +17,7 @@
* under the License.
*/
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
import SemanticViewEditModal from './SemanticViewEditModal';
@@ -38,6 +38,10 @@ const mockedGetClientErrorObject = getClientErrorObject as jest.Mock;
const MOCK_STRUCTURE = {
result: {
// Matches createProps() so tests that are not about hydration keep
// asserting the same values whichever source the form reads from.
description: 'old description',
cache_timeout: 60,
dimensions: [
{
name: 'order_date',
@@ -158,6 +162,137 @@ test('fetches structure on mount', async () => {
});
});
// sc-107904: the caller passes its own copy of description/cache_timeout, and
// that copy goes stale as soon as this modal saves — Explore feeds the
// pre-edit datasource straight back into the store. The modal must therefore
// hydrate the Details tab from /structure, not from the prop.
test('hydrates Details from the server, not the stale caller prop', async () => {
mockedGet.mockResolvedValue({
json: {
result: {
...MOCK_STRUCTURE.result,
description: 'saved on the server',
cache_timeout: 900,
},
},
});
const props = createProps();
render(<SemanticViewEditModal {...props} />);
await waitFor(() => {
expect(screen.getByDisplayValue('saved on the server')).toBeInTheDocument();
});
expect(screen.queryByDisplayValue('old description')).not.toBeInTheDocument();
mockedPut.mockResolvedValue({});
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedPut).toHaveBeenCalledWith({
endpoint: '/api/v1/semantic_view/7',
jsonPayload: {
description: 'saved on the server',
cache_timeout: 900,
},
});
});
});
test('preserves unsaved edits when the parent recreates its props', async () => {
const props = createProps();
const { rerender } = render(<SemanticViewEditModal {...props} />);
const description = await screen.findByDisplayValue('old description');
await userEvent.clear(description);
await userEvent.type(description, 'unsaved edit');
rerender(
<SemanticViewEditModal
{...props}
semanticView={{
...props.semanticView,
description: 'changed parent value',
cache_timeout: 120,
}}
/>,
);
expect(screen.getByDisplayValue('unsaved edit')).toBeInTheDocument();
});
test('hydrates null description and cache timeout from the server', async () => {
mockedGet.mockResolvedValue({
json: {
result: {
...MOCK_STRUCTURE.result,
description: null,
cache_timeout: null,
},
},
});
const props = createProps();
render(<SemanticViewEditModal {...props} />);
mockedPut.mockResolvedValue({});
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedPut).toHaveBeenCalledWith({
endpoint: '/api/v1/semantic_view/7',
jsonPayload: {
description: null,
cache_timeout: null,
},
});
});
});
test('keeps prop-seeded values when the server omits the editable fields', async () => {
// An older backend (deploy skew) returns structure without description /
// cache_timeout. Hydration must not blank the form — otherwise a save
// right after would silently null the user's persisted description.
const { dimensions, metrics } = MOCK_STRUCTURE.result;
mockedGet.mockResolvedValue({ json: { result: { dimensions, metrics } } });
const props = createProps();
render(<SemanticViewEditModal {...props} />);
mockedPut.mockResolvedValue({});
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
expect(screen.getByDisplayValue('old description')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedPut).toHaveBeenCalledWith({
endpoint: '/api/v1/semantic_view/7',
jsonPayload: {
description: 'old description',
cache_timeout: 60,
},
});
});
});
test('falls back to the caller prop when the structure fetch fails', async () => {
mockedGet.mockRejectedValue(new Error('structure failed'));
mockedGetClientErrorObject.mockResolvedValue({ error: 'boom' });
const props = createProps();
render(<SemanticViewEditModal {...props} />);
await waitFor(() => {
expect(props.addDangerToast).toHaveBeenCalledWith('boom');
});
expect(screen.getByDisplayValue('old description')).toBeInTheDocument();
});
test('fetches and displays dimensions tab', async () => {
const props = createProps();
@@ -238,6 +373,45 @@ test('handles structure fetch error', async () => {
});
});
test('does not toast if the fetch is cancelled while formatting its error', async () => {
let resolveClientError: (value: { error: string }) => void = () => {};
mockedGet.mockRejectedValue(new Error('fetch failed'));
mockedGetClientErrorObject.mockImplementation(
() =>
new Promise(resolve => {
resolveClientError = resolve;
}),
);
const props = createProps();
const { rerender } = render(<SemanticViewEditModal {...props} />);
await waitFor(() => {
expect(mockedGetClientErrorObject).toHaveBeenCalled();
});
rerender(<SemanticViewEditModal {...props} show={false} />);
await act(async () => {
resolveClientError({ error: 'Failed to load structure' });
});
expect(props.addDangerToast).not.toHaveBeenCalled();
});
test('clears structure loading when the semantic view is removed', async () => {
mockedGet.mockReturnValue(new Promise(() => {}));
const props = createProps();
const { rerender } = render(<SemanticViewEditModal {...props} />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});
rerender(<SemanticViewEditModal {...props} semanticView={null} />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
});
test('details tab save still works after viewing structure tabs', async () => {
mockedPut.mockResolvedValue({});
const props = createProps();
@@ -272,6 +446,8 @@ test('details tab save still works after viewing structure tabs', async () => {
const LARGE_STRUCTURE = {
result: {
description: 'old description',
cache_timeout: 60,
dimensions: Array.from({ length: 500 }, (_, i) => ({
name: `dim_${String(i).padStart(3, '0')}`,
type: 'string',
@@ -57,6 +57,10 @@ interface SemanticMetric {
}
interface SemanticViewStructure {
// Optional because an older backend may not emit them (deploy skew); the
// hydration effect only overwrites the form when they are present.
description?: string | null;
cache_timeout?: number | null;
dimensions: SemanticDimension[];
metrics: SemanticMetric[];
}
@@ -115,36 +119,61 @@ export default function SemanticViewEditModal({
);
const [structureLoading, setStructureLoading] = useState(false);
// Seeds the form from the caller's copy — what the form falls back to if
// /structure fails (a spinner covers the form while that fetch is in
// flight). Keyed to open/identity rather than the semanticView object so a
// parent re-render cannot clobber in-progress edits.
useEffect(() => {
if (semanticView) {
setDescription(semanticView.description || '');
setCacheTimeout(semanticView.cache_timeout ?? null);
}
}, [semanticView]);
}, [show, semanticView?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (show && semanticView) {
setStructureLoading(true);
SupersetClient.get({
endpoint: `/api/v1/semantic_view/${semanticView.id}/structure`,
})
.then(({ json }) => {
setStructure(json.result);
})
.catch(async error => {
const clientError = await getClientErrorObject(error);
addDangerToast?.(
clientError.error ||
t('An error occurred while fetching the semantic view structure'),
);
})
.finally(() => {
setStructureLoading(false);
});
} else {
if (!show || !semanticView) {
setStructure(null);
setStructureLoading(false);
return undefined;
}
}, [show, semanticView]); // eslint-disable-line react-hooks/exhaustive-deps
let cancelled = false;
setStructureLoading(true);
SupersetClient.get({
endpoint: `/api/v1/semantic_view/${semanticView.id}/structure`,
})
.then(({ json }) => {
if (cancelled) return;
setStructure(json.result);
// The caller's copy of these fields goes stale the moment this modal
// saves, so re-open hydrates from the server rather than the prop.
// Only overwrite when the response carries the field: an older
// backend that omits it (deploy skew) must not blank the form — while
// an explicit null still means "cleared on the server".
if ('description' in json.result) {
setDescription(json.result.description || '');
}
if ('cache_timeout' in json.result) {
setCacheTimeout(json.result.cache_timeout ?? null);
}
})
.catch(async error => {
if (cancelled) return;
const clientError = await getClientErrorObject(error);
if (cancelled) return;
addDangerToast?.(
clientError.error ||
t('An error occurred while fetching the semantic view structure'),
);
})
.finally(() => {
if (!cancelled) setStructureLoading(false);
});
return () => {
cancelled = true;
};
}, [show, semanticView?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSave = async () => {
if (!semanticView) return;
@@ -59,7 +59,6 @@ These fields should be in every engine spec's `metadata` attribute:
| Superset meta database | superset.py | 0% | category, connection_string, description, pypi_packages | default_port, homepage_url, logo |
| Apache Drill | drill.py | 51% | connection_string, pypi_packages | default_port |
| Amazon Athena | athena.py | 52% | connection_string, pypi_packages | default_port |
| Ascend | ascend.py | 60% | ✓ | default_port, homepage_url, logo |
| Ocient | ocient.py | 61% | ✓ | default_port, homepage_url, logo |
| RisingWave | risingwave.py | 61% | ✓ | default_port, homepage_url, logo |
| Arc | arc.py | 62% | ✓ | default_port, homepage_url, logo |
-7
View File
@@ -101,7 +101,6 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| SQLite | 41 | Supported | Partial | Supported | Supported | Not supported | Not supported |
| Shillelagh | 41 | Supported | Partial | Supported | Supported | Not supported | Not supported |
| KustoKQL | 40 | Supported | Partial | Partial | Partial | Partial | Not supported |
| Ascend | 38 | Supported | Partial | Supported | Not supported | Partial | Not supported |
| DuckDB | 38 | Supported | Partial | Supported | Not supported | Partial | Not supported |
| IBM Db2 | 38 | Supported | Partial | Supported | Not supported | Partial | Not supported |
| IBM Db2 for i | 38 | Supported | Partial | Supported | Not supported | Partial | Not supported |
@@ -149,7 +148,6 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| Apache Pinot | superset.db_engine_specs.pinot | FORCE_LIMIT | True | None |
| Apache Solr | superset.db_engine_specs.solr | FORCE_LIMIT | True | None |
| Apache Spark SQL | superset.db_engine_specs.spark | FORCE_LIMIT | True | 767 |
| Ascend | superset.db_engine_specs.ascend | FORCE_LIMIT | True | None |
| Aurora MySQL (Data API) | superset.db_engine_specs.aurora | FORCE_LIMIT | True | 64 |
| Aurora PostgreSQL (Data API) | superset.db_engine_specs.aurora | FORCE_LIMIT | True | 63 |
| Azure Synapse | superset.db_engine_specs.mssql | FORCE_LIMIT | True | 128 |
@@ -219,7 +217,6 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| Apache Pinot | False | False | False | False | True | True | True | False | False | True | False |
| Apache Solr | False | False | True | True | True | True | True | False | False | True | False |
| Apache Spark SQL | True | True | True | True | True | True | True | False | False | False | False |
| Ascend | True | True | True | True | True | True | True | False | False | True | False |
| Aurora MySQL (Data API) | True | True | True | True | True | True | True | False | False | True | False |
| Aurora PostgreSQL (Data API) | True | True | True | True | True | True | True | False | False | True | False |
| Azure Synapse | True | True | True | True | False | True | True | False | False | True | False |
@@ -289,7 +286,6 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| Apache Pinot | True | True | True | True | True | True | True | True |
| Apache Solr | False | False | False | False | False | False | False | False |
| Apache Spark SQL | True | True | True | True | True | True | True | True |
| Ascend | True | True | True | True | True | True | True | True |
| Aurora MySQL (Data API) | True | True | True | True | True | True | True | True |
| Aurora PostgreSQL (Data API) | True | True | True | True | True | True | True | True |
| Azure Synapse | True | True | True | True | True | True | True | True |
@@ -359,7 +355,6 @@ The tables below (generated via `python superset/db_engine_specs/lib.py`) summar
| Apache Pinot | False | False | True | True | True | True | False | False | False | False | False | False | False |
| Apache Solr | False | False | False | False | False | False | False | False | False | False | False | False | False |
| Apache Spark SQL | False | False | False | False | False | False | False | False | True | False | True | False | False |
| Ascend | False | False | False | False | False | False | False | False | False | False | False | False | False |
| Aurora MySQL (Data API) | False | False | False | False | False | False | False | False | False | True | False | False | False |
| Aurora PostgreSQL (Data API) | True | True | True | True | True | True | False | False | False | False | False | False | False |
| Azure Synapse | False | False | True | True | True | True | False | False | True | True | False | False | False |
@@ -432,7 +427,6 @@ Integration with platform features and metadata handling.
| Apache Pinot | False | False | False | True | False | False | False | True | False | False | False | False | False | False |
| Apache Solr | False | False | False | True | False | False | False | True | False | False | False | False | False | False |
| Apache Spark SQL | False | True | True | True | True | True | True | True | True | True | False | True | False | False |
| Ascend | False | False | False | True | False | False | False | True | False | True | False | False | False | False |
| Aurora MySQL (Data API) | False | True | False | True | True | False | False | True | False | True | False | False | False | False |
| Aurora PostgreSQL (Data API) | False | True | False | True | True | True | True | True | False | True | False | False | False | False |
| Azure Synapse | False | True | False | True | False | False | False | True | False | False | False | False | False | False |
@@ -502,7 +496,6 @@ Integration with platform features and metadata handling.
| Apache Pinot | False | False | False | False |
| Apache Solr | False | False | False | False |
| Apache Spark SQL | True | True | True | False |
| Ascend | False | False | False | False |
| Aurora MySQL (Data API) | False | False | False | False |
| Aurora PostgreSQL (Data API) | False | False | True | True |
| Azure Synapse | False | False | False | False |
-60
View File
@@ -1,60 +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.
from sqlalchemy.dialects import registry
from superset.constants import TimeGrain
from superset.db_engine_specs.base import DatabaseCategory
from superset.db_engine_specs.impala import ImpalaEngineSpec
class AscendEngineSpec(ImpalaEngineSpec):
"""Engine spec for Ascend.io (Hive2+TLS) using Cloudera's Impala"""
engine = "ascend"
registry.register("ascend", "impala.sqlalchemy", "ImpalaDialect")
engine_name = "Ascend"
metadata = {
"description": (
"Ascend.io is a data automation platform for building data pipelines."
),
"logo": "ascend.webp",
"homepage_url": "https://www.ascend.io/",
"categories": [
DatabaseCategory.CLOUD_DATA_WAREHOUSES,
DatabaseCategory.ANALYTICAL_DATABASES,
DatabaseCategory.HOSTED_OPEN_SOURCE,
],
"pypi_packages": ["impyla"],
"connection_string": (
"ascend://{username}:{password}@{hostname}:{port}/{database}"
"?auth_mechanism=PLAIN;use_ssl=true"
),
}
_time_grain_expressions = {
None: "{col}",
TimeGrain.SECOND: "DATE_TRUNC('second', {col})",
TimeGrain.MINUTE: "DATE_TRUNC('minute', {col})",
TimeGrain.HOUR: "DATE_TRUNC('hour', {col})",
TimeGrain.DAY: "DATE_TRUNC('day', {col})",
TimeGrain.WEEK: "DATE_TRUNC('week', {col})",
TimeGrain.MONTH: "DATE_TRUNC('month', {col})",
TimeGrain.QUARTER: "DATE_TRUNC('quarter', {col})",
TimeGrain.YEAR: "DATE_TRUNC('year', {col})",
}
+9 -4
View File
@@ -395,7 +395,11 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
- chart_type="table": Data table for detailed views
- chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
- chart_type="pie": Pie chart for proportional data (set donut=True for donut)
- chart_type="pivot_table": Interactive pivot table for cross-tabulation
- chart_type="pivot_table": OSS Pivot Table for cross-tabulation
- chart_type="interactive_pivot": Extension-provided AG Grid Interactive Pivot Table.
This type is distinct from pivot_table/pivot_table_v2 and is available only
when get_chart_type_schema("interactive_pivot") returns a schema instead of
a disabled_chart_type error.
- chart_type="mixed_timeseries": Dual-series chart combining two chart types
- chart_type="handlebars": Custom HTML template chart (KPI cards, leaderboards, reports)
Requires handlebars_template with Handlebars HTML template string.
@@ -416,9 +420,10 @@ Time grain for temporal x-axis (time_grain parameter):
Chart Types in Existing Charts (viewable via list_charts/get_chart_info):
Each chart returned by list_charts / get_chart_info includes a
chart_type_display_name field with a human-readable name when available.
This field is populated only for the 10 chart types supported by generate_chart
This field is populated for chart types known to the MCP registry
(xy, pie, table, pivot_table, big_number, mixed_timeseries, handlebars,
histogram, box_plot, waterfall).
histogram, box_plot, waterfall, and interactive_pivot). Availability gates
creation and schema discovery, not display names for existing charts.
For all other viz_types (Funnel, Gauge, Heatmap, etc.) it will be null
use the raw viz_type field instead when referring to those chart types.
@@ -426,7 +431,7 @@ Query Examples:
- List all tables:
list_charts(request={{"filters": [{{"col": "viz_type",
"opr": "in",
"value": ["table", "pivot_table_v2"]}}]}})
"value": ["table", "pivot_table_v2", "ag-grid-pivot-table"]}}]}})
- List time series charts:
list_charts(request={{"filters": [{{"col": "viz_type",
"opr": "sw", "value": "echarts_timeseries"}}]}})
+38 -1
View File
@@ -571,6 +571,33 @@ def merge_table_column_config(
new_form_data["column_config"] = merged_column_config
def merge_interactive_pivot_ui_config(
existing_form_data: Mapping[str, Any], new_form_data: Dict[str, Any]
) -> None:
"""Preserve UI-managed Interactive Pivot config during MCP replacement.
Rows, columns, and metric aggregation are declarative MCP fields, so their
three state sections come from ``new_form_data``. Other state sections
(column sizing/order, filters, sorting, and pagination) are managed by the
grid UI and survive an update. Formatting controls that MCP cannot express
also survive rather than being erased by an unrelated config change.
"""
viz_type = "ag-grid-pivot-table"
if (
existing_form_data.get("viz_type") != viz_type
or new_form_data.get("viz_type") != viz_type
):
return
for key in ("column_config", "conditional_formatting"):
if key in existing_form_data and key not in new_form_data:
new_form_data[key] = existing_form_data[key]
existing_state = existing_form_data.get("pivot_table_state")
new_state = new_form_data.get("pivot_table_state")
if isinstance(existing_state, dict) and isinstance(new_state, dict):
new_form_data["pivot_table_state"] = {**existing_state, **new_state}
def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str:
"""Create a metric object for a column with enhanced validation.
@@ -1647,10 +1674,16 @@ def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabi
"deck_scatter",
"deck_hex",
"ag-grid-table", # AG Grid tables are interactive
"ag-grid-pivot-table",
]
supports_interaction = viz_type in interactive_types
supports_drill_down = viz_type in ["table", "pivot_table_v2", "ag-grid-table"]
supports_drill_down = viz_type in [
"table",
"pivot_table_v2",
"ag-grid-table",
"ag-grid-pivot-table",
]
supports_real_time = viz_type in [
"echarts_timeseries_line",
"echarts_timeseries_bar",
@@ -1701,6 +1734,10 @@ def analyze_chart_semantics(viz_type: str | None, config: Any) -> ChartSemantics
"Cross-tabulates data with rows, columns, and aggregated metrics "
"for multi-dimensional analysis"
),
"ag-grid-pivot-table": (
"Interactively cross-tabulates data with AG Grid row groups, pivot "
"columns, value aggregation, and side-panel reconfiguration"
),
"mixed_timeseries": (
"Combines two different chart types on the same time axis "
"for comparing related metrics with different scales"
+4
View File
@@ -200,6 +200,10 @@ class BaseChartPlugin:
# Subclasses must override this with their own class attribute.
native_viz_types: ClassVar[Mapping[str, str]] = {}
def is_available(self) -> bool:
"""Return whether the host deployment provides this visualization."""
return True
def pre_validate(
self,
config: dict[str, Any],
@@ -31,6 +31,9 @@ from superset.mcp_service.chart.plugins.big_number import BigNumberChartPlugin
from superset.mcp_service.chart.plugins.box_plot import BoxPlotChartPlugin
from superset.mcp_service.chart.plugins.handlebars import HandlebarsChartPlugin
from superset.mcp_service.chart.plugins.histogram import HistogramChartPlugin
from superset.mcp_service.chart.plugins.interactive_pivot import (
InteractivePivotChartPlugin,
)
from superset.mcp_service.chart.plugins.mixed_timeseries import (
MixedTimeseriesChartPlugin,
)
@@ -46,6 +49,7 @@ register(XYChartPlugin())
register(TableChartPlugin())
register(PieChartPlugin())
register(PivotTableChartPlugin())
register(InteractivePivotChartPlugin())
register(MixedTimeseriesChartPlugin())
register(HandlebarsChartPlugin())
register(BigNumberChartPlugin())
@@ -58,6 +62,7 @@ __all__ = [
"BoxPlotChartPlugin",
"HandlebarsChartPlugin",
"HistogramChartPlugin",
"InteractivePivotChartPlugin",
"MixedTimeseriesChartPlugin",
"PieChartPlugin",
"PivotTableChartPlugin",
@@ -0,0 +1,235 @@
# 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.
"""AG Grid interactive pivot chart plugin."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, ClassVar
from superset.extensions import feature_flag_manager
from superset.mcp_service.chart.chart_utils import (
_add_adhoc_filters,
_summarize_filters,
add_currency_format,
create_metric_object,
)
from superset.mcp_service.chart.plugin import BaseChartPlugin
from superset.mcp_service.chart.schemas import ColumnRef, InteractivePivotChartConfig
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
from superset.mcp_service.common.error_schemas import ChartGenerationError
INTERACTIVE_PIVOT_FEATURE_FLAG = "AG_GRID_PIVOT_TABLE_ENABLED"
_AG_GRID_AGGREGATION = {
"AVG": "avg",
"MIN": "min",
"MAX": "max",
}
def _metric_label(metric: dict[str, Any] | str) -> str:
"""Return the label AG Grid uses as a metric column identifier."""
return metric if isinstance(metric, str) else str(metric["label"])
def _grid_aggregation(metric: ColumnRef) -> str:
"""Map a Superset metric aggregate to AG Grid's group rollup function."""
return _AG_GRID_AGGREGATION.get((metric.aggregate or "SUM").upper(), "sum")
def map_interactive_pivot_config(
config: InteractivePivotChartConfig,
) -> dict[str, Any]:
"""Map the MCP config to ``ag-grid-pivot-table`` form data."""
metrics = [create_metric_object(metric) for metric in config.metrics]
rows = [column.name for column in config.rows]
columns = [column.name for column in config.columns]
form_data: dict[str, Any] = {
"viz_type": "ag-grid-pivot-table",
# The control panel stores every dimension in groupby. AG Grid's
# persisted state assigns each one to the Rows or Column Labels bucket.
"groupby": [*rows, *columns],
"metrics": metrics,
"row_limit": config.row_limit,
"order_desc": config.sort_descending,
"rowGroupCounts": config.show_row_group_counts,
"rowTotals": config.show_row_totals,
"colTotals": config.show_column_totals,
"colSubTotals": config.show_column_subtotals,
"valueFormat": config.value_format,
"allow_render_html": config.allow_render_html,
"expand_pivot_groups": config.expand_pivot_groups,
"pivot_table_state": {
"rowGroup": {"groupColIds": rows},
"pivot": {"pivotMode": True, "pivotColIds": columns},
"aggregation": {
"aggregationModel": [
{
"colId": _metric_label(mapped_metric),
"aggFunc": _grid_aggregation(metric),
}
for metric, mapped_metric in zip(
config.metrics, metrics, strict=True
)
]
},
},
}
if config.temporal_column:
form_data["granularity_sqla"] = config.temporal_column
form_data["temporal_columns_lookup"] = {config.temporal_column: True}
if config.time_grain:
# buildQuery consults temporal_columns_lookup before applying this grain
# to a grouped dimension.
form_data["time_grain_sqla"] = config.time_grain.value
if config.series_limit is not None:
form_data["series_limit"] = config.series_limit
if config.series_limit_metric is not None:
form_data["series_limit_metric"] = create_metric_object(
config.series_limit_metric
)
if config.date_format:
form_data["date_format"] = config.date_format
if config.column_sort:
form_data["colOrder"] = config.column_sort
if config.comparison_period and config.comparison_type:
form_data["time_compare"] = [config.comparison_period]
form_data["comparison_type"] = config.comparison_type
add_currency_format(form_data, config.currency_format)
_add_adhoc_filters(form_data, config.filters)
return form_data
class InteractivePivotChartPlugin(BaseChartPlugin):
"""Plugin for a feature-gated AG Grid interactive pivot extension."""
chart_type = "interactive_pivot"
display_name = "Interactive Pivot Table"
native_viz_types: ClassVar[Mapping[str, str]] = {
"ag-grid-pivot-table": "Interactive Pivot Table",
}
def is_available(self) -> bool:
"""Hide the adapter unless the host ships and enables the visualization."""
return feature_flag_manager.is_feature_enabled(INTERACTIVE_PIVOT_FEATURE_FLAG)
def pre_validate(
self,
config: dict[str, Any],
) -> ChartGenerationError | None:
missing_fields = []
if not config.get("rows"):
missing_fields.append("'rows' (AG Grid row groups)")
if not config.get("metrics"):
missing_fields.append("'metrics' (value columns)")
if not missing_fields:
return None
return ChartGenerationError(
error_type="missing_interactive_pivot_fields",
message=(
"Interactive pivot missing required fields: "
+ ", ".join(missing_fields)
),
details="Interactive pivots require at least one row and one metric",
suggestions=[
"Add 'rows': [{'name': 'region'}]",
"Add 'metrics': [{'name': 'revenue', 'aggregate': 'SUM'}]",
"Use 'columns' to populate the AG Grid Column Labels bucket",
],
error_code="MISSING_INTERACTIVE_PIVOT_FIELDS",
)
def extract_column_refs(self, config: Any) -> list[ColumnRef]:
if not isinstance(config, InteractivePivotChartConfig):
return []
refs = [*config.rows, *config.columns, *config.metrics]
if config.series_limit_metric:
refs.append(config.series_limit_metric)
if config.filters:
refs.extend(ColumnRef(name=item.column) for item in config.filters)
return refs
def to_form_data(
self, config: Any, dataset_id: int | str | None = None
) -> dict[str, Any]:
if not isinstance(config, InteractivePivotChartConfig):
raise TypeError("Expected InteractivePivotChartConfig")
return map_interactive_pivot_config(config)
def generate_name(self, config: Any, dataset_name: str | None = None) -> str:
if not isinstance(config, InteractivePivotChartConfig):
return "Interactive Pivot Table"
row_names = ", ".join(row.name or "" for row in config.rows)
context = _summarize_filters(config.filters)
return self._with_context(f"Interactive Pivot Table {row_names}", context)
def resolve_viz_type(self, config: Any) -> str:
return "ag-grid-pivot-table"
def normalize_column_refs(self, config: Any, dataset_context: Any) -> Any:
if not isinstance(config, InteractivePivotChartConfig):
return config
config_dict = config.model_dump()
if temporal_column := config_dict.get("temporal_column"):
config_dict["temporal_column"] = DatasetValidator.get_canonical_column_name(
temporal_column, dataset_context
)
for key in ("rows", "columns", "metrics"):
for column in config_dict.get(key) or []:
if column.get("sql_expression"):
continue
if column.get("saved_metric"):
column["name"] = DatasetValidator.get_canonical_metric_name(
column["name"], dataset_context
)
else:
column["name"] = DatasetValidator.get_canonical_column_name(
column["name"], dataset_context
)
series_metric = config_dict.get("series_limit_metric")
if series_metric and not series_metric.get("sql_expression"):
normalizer = (
DatasetValidator.get_canonical_metric_name
if series_metric.get("saved_metric")
else DatasetValidator.get_canonical_column_name
)
series_metric["name"] = normalizer(series_metric["name"], dataset_context)
DatasetValidator.normalize_filters(config_dict, dataset_context)
return InteractivePivotChartConfig.model_validate(config_dict)
def schema_error_hint(self) -> ChartGenerationError | None:
return ChartGenerationError(
error_type="interactive_pivot_validation_error",
message="Interactive pivot configuration validation failed",
details=(
"Use the dedicated interactive_pivot schema; pivot_table targets "
"the different OSS pivot_table_v2 visualization"
),
suggestions=[
"Provide non-empty 'rows' and 'metrics' arrays",
"Put AG Grid column labels in the optional 'columns' array",
"Pair comparison_period with comparison_type",
],
error_code="INTERACTIVE_PIVOT_VALIDATION_ERROR",
)
+15
View File
@@ -136,6 +136,21 @@ def configure(
def _is_plugin_enabled(chart_type: str) -> bool:
"""Return True if the plugin is currently enabled (not filtered out)."""
plugin = _REGISTRY.get(chart_type)
if plugin is None:
return False
availability_check = getattr(plugin, "is_available", None)
try:
if availability_check is not None and not availability_check():
return False
except Exception: # noqa: BLE001 — host availability checks fail closed
logger.warning(
"Availability check failed for chart_type=%r; failing closed",
chart_type,
exc_info=True,
)
return False
config = _filter_config # read once — atomic reference in CPython
if config.enabled_func is not None:
try:
@@ -230,6 +230,38 @@ def get_chart_configs_resource() -> str:
},
}
from superset.mcp_service.chart.registry import get_registry
interactive_pivot_configs = {}
if get_registry().get("interactive_pivot") is not None:
interactive_pivot_configs["ag_grid_interactive_pivot"] = {
"description": (
"AG Grid pivot with explicit side-panel groups and a "
"year-over-year comparison"
),
"config": {
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}, {"name": "country"}],
"columns": [{"name": "order_date"}],
"metrics": [
{"name": "revenue", "aggregate": "SUM", "label": "Revenue"},
{"name": "margin", "aggregate": "AVG", "label": "Margin"},
],
"temporal_column": "order_date",
"time_grain": "P1M",
"comparison_period": "1 year ago",
"comparison_type": "percentage",
"show_row_totals": True,
"show_column_totals": True,
"show_column_subtotals": True,
},
"use_cases": [
"Spreadsheet-style multidimensional analysis",
"Interactive regrouping and pivoting",
"Period-over-period comparison",
],
}
# Best practices
best_practices = {
"xy_charts": [
@@ -246,6 +278,12 @@ def get_chart_configs_resource() -> str:
"Apply sort_by to highlight important data",
"Use ag-grid-table viz_type for large interactive datasets",
],
"interactive_pivots": [
"Use rows for AG Grid row groups and columns for pivot labels",
"Metric aggregates also seed AG Grid's group aggregation model",
"Pair comparison_period with comparison_type for time comparison",
"This type appears only when the host enables its visualization plugin",
],
"general": [
"Always verify column names with get_dataset_info before charting",
"Set temporal_column when dashboard time filters should use a column "
@@ -259,6 +297,7 @@ def get_chart_configs_resource() -> str:
resource_data = {
"xy_chart_configs": xy_chart_configs,
"table_chart_configs": table_chart_configs,
"interactive_pivot_configs": interactive_pivot_configs,
"best_practices": best_practices,
"usage_notes": [
"All examples are valid ChartConfig objects that pass validation",
+212 -1
View File
@@ -1109,6 +1109,214 @@ class PivotTableChartConfig(BaseChartConfig):
return self
class InteractivePivotChartConfig(BaseChartConfig):
"""Config for an extension-provided AG Grid interactive pivot visualization.
This is intentionally separate from :class:`PivotTableChartConfig`, which
targets the OSS ``pivot_table_v2`` visualization.
"""
model_config = ConfigDict(extra="ignore", populate_by_name=True)
chart_type: Literal["interactive_pivot"] = "interactive_pivot"
rows: List[ColumnRef] = Field(
...,
min_length=1,
description=(
"Dimensions placed in the AG Grid Rows side-panel bucket, in nesting order"
),
)
columns: List[ColumnRef] = Field(
default_factory=list,
description=(
"Dimensions placed in the AG Grid Column Labels side-panel bucket"
),
)
metrics: List[ColumnRef] = Field(
...,
min_length=1,
description=(
"Value columns. Each metric's aggregate controls the SQL aggregation "
"and its AG Grid group rollup (AVG/MIN/MAX map to avg/min/max; additive "
"aggregates roll up with sum)."
),
)
time_grain: TimeGrain | None = Field(
None,
description=(
"PT1H, P1D, P1W, P1M, P3M, or P1Y for the grouped dimension named "
"by temporal_column"
),
validation_alias=AliasChoices("time_grain", "time_grain_sqla"),
)
filters: List[FilterConfig] | None = Field(
None,
description=(
"Structured filters (column/op/value). Do NOT use adhoc_filters or "
"raw SQL expressions."
),
)
series_limit: int | None = Field(
None,
description="Maximum number of pivot series",
ge=0,
le=50000,
)
series_limit_metric: ColumnRef | None = Field(
None,
description="Metric used to rank series when a series/cell limit applies",
)
sort_descending: bool = Field(
True,
description="Sort the series-limit metric descending",
validation_alias=AliasChoices("sort_descending", "order_desc"),
)
row_limit: int = Field(10000, description="Maximum returned cells", ge=1, le=50000)
show_row_group_counts: bool = Field(
True,
description="Show record counts beside row-group labels",
validation_alias=AliasChoices("show_row_group_counts", "rowGroupCounts"),
)
show_row_totals: bool = Field(
False,
description="Show grand-total rows",
validation_alias=AliasChoices("show_row_totals", "rowTotals"),
)
show_column_totals: bool = Field(
False,
description="Show grand-total columns",
validation_alias=AliasChoices("show_column_totals", "colTotals"),
)
show_column_subtotals: bool = Field(
False,
description="Show subtotal columns for nested pivot groups",
validation_alias=AliasChoices("show_column_subtotals", "colSubTotals"),
)
value_format: str = Field(
"SMART_NUMBER",
max_length=50,
validation_alias=AliasChoices("value_format", "valueFormat"),
)
date_format: str | None = Field(
None,
description="D3 format for temporal dimensions (for example, '%Y-%m-%d')",
max_length=50,
)
currency_format: CurrencyFormat | None = Field(
None,
description="Currency symbol applied to numeric metric values",
)
column_sort: Literal["key_a_to_z", "key_z_to_a"] | None = Field(
None,
description="Alphabetic ordering for generated pivot columns",
validation_alias=AliasChoices("column_sort", "colOrder"),
)
allow_render_html: bool = Field(
True,
description="Render applicable cell values as HTML",
)
expand_pivot_groups: bool = Field(
False,
description="Expand generated pivot column groups initially",
)
comparison_period: str | None = Field(
None,
min_length=1,
max_length=100,
description=(
"Relative time shift such as '1 year ago'. Must be paired with "
"comparison_type."
),
validation_alias=AliasChoices("comparison_period", "time_compare"),
)
comparison_type: Literal["values", "difference", "percentage", "ratio"] | None = (
Field(
None,
description=(
"How to present the comparison period: raw values, difference, "
"percentage change, or ratio"
),
)
)
@model_validator(mode="before")
@classmethod
def normalize_comparison_period(cls, data: Any) -> Any:
"""Accept native single-item ``time_compare`` arrays on round trips."""
if not isinstance(data, dict):
return data
if isinstance(data.get("comparison_period"), list):
raise ValueError("comparison_period must be a single string")
if isinstance(data.get("time_compare"), list):
periods = data["time_compare"]
if len(periods) > 1:
raise ValueError(
"interactive_pivot supports one comparison period; pass a "
"single value"
)
data["time_compare"] = periods[0] if periods else None
return data
@field_validator("comparison_period")
@classmethod
def sanitize_comparison_period(cls, value: str | None) -> str | None:
"""Sanitize the relative time-shift label."""
if value is None:
return None
return sanitize_user_input(
value,
"Comparison period",
max_length=100,
allow_empty=False,
)
@model_validator(mode="after")
def validate_interactive_pivot(self) -> "InteractivePivotChartConfig":
"""Validate dimension roles and paired comparison controls."""
for field_name, refs in (("rows", self.rows), ("columns", self.columns)):
for index, ref in enumerate(refs):
_reject_sql_expression_on_dimension(ref, f"{field_name}[{index}]")
if ref.saved_metric:
raise ValueError(
f"{field_name}[{index}] cannot use saved_metric=True; "
"saved metrics belong in the 'metrics' field"
)
dimension_names = [ref.name for ref in [*self.rows, *self.columns]]
if len(dimension_names) != len(set(dimension_names)):
raise ValueError(
"A dimension cannot appear in both rows and columns or more than "
"once in either bucket"
)
if self.time_grain:
if not self.temporal_column:
raise ValueError(
"time_grain requires temporal_column to identify the grouped "
"temporal dimension"
)
if self.temporal_column.lower() not in {
name.lower() for name in dimension_names if name
}:
raise ValueError(
"temporal_column must appear in rows or columns when time_grain "
"is set"
)
if self.series_limit_metric and not self.series_limit_metric.is_metric:
raise ValueError(
"series_limit_metric must define an aggregate, saved_metric=True, "
"or sql_expression"
)
if bool(self.comparison_period) != bool(self.comparison_type):
raise ValueError(
"comparison_period and comparison_type must be provided together"
)
return self
class MixedTimeseriesChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -2080,6 +2288,7 @@ ChartConfig = Annotated[
| TableChartConfig
| PieChartConfig
| PivotTableChartConfig
| InteractivePivotChartConfig
| MixedTimeseriesChartConfig
| HandlebarsChartConfig
| BigNumberChartConfig
@@ -2090,7 +2299,8 @@ ChartConfig = Annotated[
discriminator="chart_type",
description=(
"Chart configuration - specify chart_type as 'xy', 'table', "
"'pie', 'pivot_table', 'mixed_timeseries', 'handlebars', "
"'pie', 'pivot_table', 'interactive_pivot', 'mixed_timeseries', "
"'handlebars', "
"'big_number', 'histogram', 'box_plot', or 'waterfall'"
),
),
@@ -2119,6 +2329,7 @@ _VIZ_TYPE_TO_CHART_TYPE: dict[str, tuple[str, str | None]] = {
"ag-grid-table": ("table", None),
"big_number_total": ("big_number", None),
"pivot_table_v2": ("pivot_table", None),
"ag-grid-pivot-table": ("interactive_pivot", None),
"histogram_v2": ("histogram", None),
}
@@ -87,13 +87,14 @@ async def generate_chart( # noqa: C901
- Use numeric dataset ID or UUID (NOT schema.table_name format)
- MUST include chart_type in config (one of: 'xy', 'table', 'pie',
'pivot_table', 'mixed_timeseries', 'handlebars', 'big_number',
'histogram', 'box_plot', 'waterfall')
'histogram', 'box_plot', 'waterfall', plus host-gated types returned by
get_chart_type_schema such as 'interactive_pivot')
IMPORTANT: The 'chart_type' field in the config is a DISCRIMINATOR that determines
which chart configuration schema to use. It MUST be included and MUST match the
other fields in your configuration. There are exactly 9 valid chart_type values,
listed below. Values such as 'line', 'bar', 'area', and 'scatter' are 'kind'
values WITHIN chart_type='xy', not chart_type values themselves:
other fields in your configuration. Values such as 'line', 'bar', 'area',
and 'scatter' are 'kind' values WITHIN chart_type='xy', not chart_type
values themselves. Call get_chart_type_schema to confirm host-gated types:
- chart_type='xy' for charts with x and y axes (line, bar, area, scatter).
Required fields: y (x is optional defaults to dataset's primary
@@ -109,6 +110,11 @@ async def generate_chart( # noqa: C901
- chart_type='pivot_table' for pivot table visualizations.
Required fields: rows, metrics (columns is optional, for cross-tabs)
- chart_type='interactive_pivot' for an extension-provided AG Grid pivot.
Required fields: rows, metrics (columns is optional). This is distinct
from pivot_table/pivot_table_v2 and is rejected when its host feature is
unavailable. Call get_chart_type_schema('interactive_pivot') first.
- chart_type='mixed_timeseries' for dual-axis time-series charts.
Required fields: x, y (primary metrics), y_secondary (secondary metrics)
@@ -136,6 +142,8 @@ async def generate_chart( # noqa: C901
- "pie chart" / "donut chart" -> chart_type='pie'
- "table" / "data grid" -> chart_type='table'
- "pivot table" / "cross-tab" -> chart_type='pivot_table'
- "interactive pivot" / "AG Grid pivot" -> chart_type='interactive_pivot'
only when get_chart_type_schema confirms it is available
- "compare two metrics over time" -> chart_type='mixed_timeseries'
- "single number" / "KPI" / "scorecard" -> chart_type='big_number'
- "custom HTML template" -> chart_type='handlebars'
@@ -160,6 +160,7 @@ _VIZ_CATEGORY: dict[str, str] = {
"box_plot": "box_plot",
"world_map": "map",
"pivot_table_v2": "table",
"ag-grid-pivot-table": "table",
# Own category: cumulative-flow semantics differ from a plain bar, like
# funnel/gauge carry distinct categories.
"waterfall": "waterfall",
@@ -33,6 +33,7 @@ from superset.mcp_service.chart.schemas import (
BoxPlotChartConfig,
HandlebarsChartConfig,
HistogramChartConfig,
InteractivePivotChartConfig,
MixedTimeseriesChartConfig,
PieChartConfig,
PivotTableChartConfig,
@@ -49,6 +50,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
"table": TypeAdapter(TableChartConfig),
"pie": TypeAdapter(PieChartConfig),
"pivot_table": TypeAdapter(PivotTableChartConfig),
"interactive_pivot": TypeAdapter(InteractivePivotChartConfig),
"mixed_timeseries": TypeAdapter(MixedTimeseriesChartConfig),
"handlebars": TypeAdapter(HandlebarsChartConfig),
"big_number": TypeAdapter(BigNumberChartConfig),
@@ -101,6 +103,32 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"columns": [{"name": "quarter"}],
},
],
"interactive_pivot": [
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"columns": [{"name": "quarter"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"show_row_totals": True,
"show_column_totals": True,
},
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}, {"name": "country"}],
"columns": [{"name": "order_date"}],
"metrics": [
{"name": "revenue", "aggregate": "SUM"},
{"name": "margin", "aggregate": "AVG"},
],
"temporal_column": "order_date",
"time_grain": "P1M",
"comparison_period": "1 year ago",
"comparison_type": "percentage",
"show_row_totals": True,
"show_column_totals": True,
"show_column_subtotals": True,
},
],
"mixed_timeseries": [
{
"chart_type": "mixed_timeseries",
@@ -184,12 +212,15 @@ def _get_chart_type_schema_impl(
include_examples: bool = True,
) -> Dict[str, Any]:
"""Pure logic for chart type schema lookup — no auth, no decorators."""
from superset.mcp_service.chart.registry import get_registry
enabled_types = sorted(get_registry().all_types())
adapter = _CHART_TYPE_ADAPTERS.get(chart_type)
if adapter is None:
# Return a structured error matching ChartGenerationError's shape so
# MCP clients consuming the response see a populated error_type,
# message, details, and suggestions rather than a bare dict.
valid_types_str = ", ".join(VALID_CHART_TYPES)
valid_types_str = ", ".join(enabled_types)
return {
"error": {
"error_type": "invalid_chart_type",
@@ -206,7 +237,26 @@ def _get_chart_type_schema_impl(
],
"error_code": "INVALID_CHART_TYPE",
},
"valid_chart_types": VALID_CHART_TYPES,
"valid_chart_types": enabled_types,
}
if get_registry().get(chart_type) is None:
valid_types_str = ", ".join(enabled_types)
return {
"error": {
"error_type": "disabled_chart_type",
"message": f"Chart type {chart_type!r} is not available",
"details": (
f"The host deployment does not provide {chart_type!r}. "
f"Enabled chart types: {valid_types_str}."
),
"suggestions": [
f"Use one of: {valid_types_str}",
"Contact the instance administrator to enable this chart type",
],
"error_code": "DISABLED_CHART_TYPE",
},
"valid_chart_types": enabled_types,
}
schema = adapter.json_schema()
@@ -240,9 +290,10 @@ def get_chart_type_schema(
Use this tool to discover the exact fields, types, and constraints
for a chart configuration before calling generate_chart or update_chart.
Valid chart_type values: xy, table, pie, pivot_table,
mixed_timeseries, handlebars, big_number, histogram, box_plot,
waterfall.
Valid chart_type values depend on the host deployment. Core types are xy,
table, pie, pivot_table, mixed_timeseries, handlebars, big_number,
histogram, box_plot, and waterfall. Deployments that enable an AG Grid
pivot extension also expose interactive_pivot.
Returns the JSON Schema for the requested chart type, optionally
with working examples.
@@ -40,6 +40,7 @@ from superset.mcp_service.chart.chart_utils import (
analyze_chart_semantics,
generate_chart_name,
map_config_to_form_data,
merge_interactive_pivot_ui_config,
merge_table_column_config,
)
from superset.mcp_service.chart.compile import validate_and_compile
@@ -234,6 +235,7 @@ def _build_update_payload(
)
new_form_data.pop("_mcp_warnings", None)
merge_table_column_config(_get_existing_form_data(chart), new_form_data)
merge_interactive_pivot_ui_config(_get_existing_form_data(chart), new_form_data)
chart_name = (
request.chart_name
@@ -315,6 +317,7 @@ def _build_preview_form_data(
)
new_form_data.pop("_mcp_warnings", None)
merge_table_column_config(existing_form_data, new_form_data)
merge_interactive_pivot_ui_config(existing_form_data, new_form_data)
# In the preview, an explicit filters list, including [], replaces saved
# filters. An omitted filters field preserves them through the shallow merge.
merged = _merge_replacement_config(
@@ -39,6 +39,7 @@ from superset.mcp_service.chart.chart_utils import (
generate_explore_link,
map_config_to_form_data,
MCP_DASHBOARD_TIME_FILTER_SUBJECT,
merge_interactive_pivot_ui_config,
merge_table_column_config,
NO_TIME_RANGE,
)
@@ -238,6 +239,7 @@ def update_chart_preview( # noqa: C901
)
if previous_form_data:
merge_table_column_config(previous_form_data, new_form_data)
merge_interactive_pivot_ui_config(previous_form_data, new_form_data)
# Tier-1 schema validation against the dataset (no DB roundtrip).
# Runs AFTER the filter merge so filter columns are also validated.
@@ -131,7 +131,8 @@ class SchemaValidator:
"Add 'chart_type': 'xy' for line/bar/area/scatter charts",
"Add 'chart_type': 'table' for table visualizations",
"Add 'chart_type': 'pie' for pie or donut charts",
"Add 'chart_type': 'pivot_table' for interactive pivot tables",
"Add 'chart_type': 'pivot_table' for OSS pivot tables",
"Use 'interactive_pivot' only when schema discovery exposes it",
"Add 'chart_type': 'mixed_timeseries' for dual-series time charts",
"Add 'chart_type': 'handlebars' for custom HTML template charts",
"Add 'chart_type': 'big_number' for big number display",
@@ -166,7 +167,8 @@ class SchemaValidator:
"Use 'chart_type': 'xy' for line, bar, area, or scatter charts",
"Use 'chart_type': 'table' for tabular data display",
"Use 'chart_type': 'pie' for pie or donut charts",
"Use 'chart_type': 'pivot_table' for interactive pivot tables",
"Use 'chart_type': 'pivot_table' for OSS pivot tables",
"Use 'interactive_pivot' only when schema discovery exposes it",
"Use 'chart_type': 'mixed_timeseries' for dual-series time charts",
"Use 'chart_type': 'handlebars' for custom HTML template charts",
"Use 'chart_type': 'big_number' for big number display",
@@ -183,8 +185,7 @@ class SchemaValidator:
return False, ChartGenerationError(
error_type="disabled_chart_type",
message=f"Chart type '{chart_type}' is not enabled on this instance",
details=f"Chart type '{chart_type}' is registered but has been "
f"disabled by the operator. "
details=f"Chart type '{chart_type}' is not available on this instance. "
f"Enabled chart types: {valid_types}",
suggestions=[
f"Use one of the enabled chart types: {valid_types}",
+7 -1
View File
@@ -197,7 +197,11 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
log_to_statsd=False,
)
def structure(self, pk: int) -> Response:
"""Get the structure (dimensions and metrics) of a semantic view.
"""Get a semantic view's editable fields and its structure.
The editable fields (``description``, ``cache_timeout``) are served here
because ``SemanticViewRestApi`` exposes no detail route: this is the only
read endpoint an editor can hydrate from after a write.
---
get:
summary: Get semantic view structure
@@ -267,6 +271,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
200,
result={
"name": view.name,
"description": view.description,
"cache_timeout": view.cache_timeout,
"dimensions": dimensions,
"metrics": metrics,
},
-1
View File
@@ -105,7 +105,6 @@ def _check_script_length(script: str, engine: str | None) -> None:
# mapping between DB engine specs and sqlglot dialects
SQLGLOT_DIALECTS = {
"base": Dialects.DIALECT,
"ascend": Dialects.HIVE,
"awsathena": Dialects.ATHENA,
"bigquery": Dialects.BIGQUERY,
"datastore": Dialects.BIGQUERY,
File diff suppressed because it is too large Load Diff
@@ -1,32 +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.
from superset.db_engine_specs.ascend import AscendEngineSpec
from tests.integration_tests.base_tests import SupersetTestCase
class TestAscendDbEngineSpec(SupersetTestCase):
def test_convert_dttm(self):
dttm = self.get_dttm()
assert (
AscendEngineSpec.convert_dttm("DATE", dttm) == "CAST('2019-01-02' AS DATE)"
)
assert (
AscendEngineSpec.convert_dttm("TIMESTAMP", dttm)
== "CAST('2019-01-02T03:04:05.678900' AS TIMESTAMP)"
)
@@ -30,6 +30,7 @@ from pydantic import ValidationError
from superset.mcp_service.chart.schemas import (
GenerateChartRequest,
GenerateExploreLinkRequest,
InteractivePivotChartConfig,
TableChartConfig,
UpdateChartPreviewRequest,
UpdateChartRequest,
@@ -169,6 +170,21 @@ class TestVizTypeTranslation:
)
assert request.config.chart_type == "big_number"
def test_ag_grid_pivot_maps_to_interactive_pivot(self) -> None:
request = GenerateChartRequest.model_validate(
{
"dataset_id": 23,
"config": {
"viz_type": "ag-grid-pivot-table",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
},
}
)
assert isinstance(request.config, InteractivePivotChartConfig)
assert request.config.chart_type == "interactive_pivot"
class TestRequestModelsShareNormalization:
"""Request models share chart vocabulary normalization behavior."""
@@ -38,6 +38,7 @@ from superset.mcp_service.chart.chart_utils import (
map_filter_operator,
map_table_config,
map_xy_config,
merge_interactive_pivot_ui_config,
merge_table_column_config,
validate_chart_dataset,
)
@@ -233,6 +234,75 @@ class TestMergeTableColumnConfig:
assert "column_config" not in updated
class TestMergeInteractivePivotUiConfig:
@pytest.mark.parametrize(
"existing_viz_type,updated_viz_type",
[
("ag-grid-pivot-table", "pivot_table_v2"),
("pivot_table_v2", "ag-grid-pivot-table"),
],
)
def test_does_not_cross_visualization_types(
self, existing_viz_type: str, updated_viz_type: str
) -> None:
updated = {
"viz_type": updated_viz_type,
"pivot_table_state": {"rowGroup": {"groupColIds": ["new"]}},
}
merge_interactive_pivot_ui_config(
{
"viz_type": existing_viz_type,
"pivot_table_state": {"filter": {"filterModel": {}}},
},
updated,
)
assert "filter" not in updated["pivot_table_state"]
def test_preserves_ui_only_formatting_when_omitted(self) -> None:
existing = {
"viz_type": "ag-grid-pivot-table",
"column_config": {"Revenue": {"d3NumberFormat": "$,.2f"}},
"conditional_formatting": [
{"column": "Revenue", "operator": ">", "targetValue": 1000}
],
}
updated: dict[str, Any] = {
"viz_type": "ag-grid-pivot-table",
"pivot_table_state": {},
}
merge_interactive_pivot_ui_config(existing, updated)
assert updated["column_config"] == existing["column_config"]
assert updated["conditional_formatting"] == existing["conditional_formatting"]
def test_merges_state_with_new_declarative_sections_winning(self) -> None:
existing = {
"viz_type": "ag-grid-pivot-table",
"pivot_table_state": {
"sort": {"sortModel": [{"colId": "Revenue", "sort": "desc"}]},
"rowGroup": {"groupColIds": ["old_region"]},
},
}
updated = {
"viz_type": "ag-grid-pivot-table",
"pivot_table_state": {
"rowGroup": {"groupColIds": ["region"]},
"pivot": {"pivotMode": True, "pivotColIds": ["quarter"]},
},
}
merge_interactive_pivot_ui_config(existing, updated)
assert updated["pivot_table_state"] == {
"sort": {"sortModel": [{"colId": "Revenue", "sort": "desc"}]},
"rowGroup": {"groupColIds": ["region"]},
"pivot": {"pivotMode": True, "pivotColIds": ["quarter"]},
}
class TestMapTableConfig:
"""Test map_table_config function"""
@@ -0,0 +1,449 @@
# 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.
"""Tests for the feature-gated AG Grid interactive pivot MCP adapter."""
from unittest.mock import Mock, patch
import pytest
from pydantic import ValidationError
from superset.extensions import feature_flag_manager
from superset.mcp_service.chart.chart_utils import (
analyze_chart_capabilities,
analyze_chart_semantics,
generate_chart_name,
map_config_to_form_data,
)
from superset.mcp_service.chart.plugins.interactive_pivot import (
INTERACTIVE_PIVOT_FEATURE_FLAG,
InteractivePivotChartPlugin,
map_interactive_pivot_config,
)
from superset.mcp_service.chart.registry import get_registry
from superset.mcp_service.chart.schemas import (
GenerateChartRequest,
InteractivePivotChartConfig,
UpdateChartRequest,
)
from superset.mcp_service.chart.tool.get_chart_type_schema import (
_get_chart_type_schema_impl,
)
from superset.mcp_service.chart.tool.update_chart import (
_build_preview_form_data,
_build_update_payload,
)
from superset.mcp_service.chart.validation.schema_validator import SchemaValidator
from superset.mcp_service.common.error_schemas import DatasetContext
from superset.utils import json
@pytest.fixture
def config() -> InteractivePivotChartConfig:
"""Return a representative Explore-compatible interactive pivot config."""
return InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}, {"name": "country"}],
"columns": [{"name": "order_date"}],
"metrics": [
{"name": "revenue", "aggregate": "SUM", "label": "Revenue"},
{"name": "margin", "aggregate": "AVG", "label": "Avg Margin"},
],
"temporal_column": "order_date",
"time_grain": "P1M",
"filters": [{"column": "status", "op": "=", "value": "active"}],
"series_limit": 25,
"series_limit_metric": {
"name": "revenue",
"aggregate": "SUM",
"label": "Revenue",
},
"sort_descending": True,
"row_limit": 12000,
"show_row_group_counts": False,
"show_row_totals": True,
"show_column_totals": True,
"show_column_subtotals": True,
"value_format": "$,.2f",
"date_format": "%Y-%m",
"currency_format": {"symbol": "USD", "symbol_position": "prefix"},
"column_sort": "key_a_to_z",
"allow_render_html": False,
"expand_pivot_groups": True,
"comparison_period": "1 year ago",
"comparison_type": "percentage",
}
)
def test_maps_true_ag_grid_pivot_form_data(
config: InteractivePivotChartConfig,
) -> None:
form_data = map_interactive_pivot_config(config)
assert form_data["viz_type"] == "ag-grid-pivot-table"
assert form_data["groupby"] == ["region", "country", "order_date"]
assert [metric["label"] for metric in form_data["metrics"]] == [
"Revenue",
"Avg Margin",
]
assert form_data["pivot_table_state"] == {
"rowGroup": {"groupColIds": ["region", "country"]},
"pivot": {"pivotMode": True, "pivotColIds": ["order_date"]},
"aggregation": {
"aggregationModel": [
{"colId": "Revenue", "aggFunc": "sum"},
{"colId": "Avg Margin", "aggFunc": "avg"},
]
},
}
assert form_data["rowGroupCounts"] is False
assert form_data["rowTotals"] is True
assert form_data["colTotals"] is True
assert form_data["colSubTotals"] is True
assert form_data["time_compare"] == ["1 year ago"]
assert form_data["comparison_type"] == "percentage"
assert form_data["granularity_sqla"] == "order_date"
assert form_data["temporal_columns_lookup"] == {"order_date": True}
assert form_data["time_grain_sqla"] == "P1M"
def test_time_grain_requires_grouped_temporal_column() -> None:
base_config = {
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"time_grain": "P1M",
}
with pytest.raises(ValidationError, match="time_grain requires temporal_column"):
InteractivePivotChartConfig.model_validate(base_config)
with pytest.raises(ValidationError, match="must appear in rows or columns"):
InteractivePivotChartConfig.model_validate(
{**base_config, "temporal_column": "order_date"}
)
def test_series_limit_metric_requires_metric_shape() -> None:
with pytest.raises(ValidationError, match="series_limit_metric must define"):
InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"series_limit_metric": {"name": "region"},
}
)
def test_comparison_controls_are_paired() -> None:
with pytest.raises(ValidationError, match="must be provided together"):
InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"comparison_period": "1 year ago",
}
)
def test_native_comparison_array_round_trips() -> None:
config = InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"time_compare": ["4 weeks ago"],
"comparison_type": "difference",
}
)
assert config.comparison_period == "4 weeks ago"
assert config.comparison_type == "difference"
assert map_interactive_pivot_config(config)["time_compare"] == ["4 weeks ago"]
@pytest.mark.parametrize(
"comparison_type", ["values", "difference", "percentage", "ratio"]
)
def test_maps_supported_comparison_types(comparison_type: str) -> None:
config = InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"comparison_period": "1 year ago",
"comparison_type": comparison_type,
}
)
form_data = map_interactive_pivot_config(config)
assert form_data["time_compare"] == ["1 year ago"]
assert form_data["comparison_type"] == comparison_type
def test_multiple_native_comparison_periods_are_rejected() -> None:
with pytest.raises(ValidationError, match="supports one comparison period"):
InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"time_compare": ["1 year ago", "2 years ago"],
"comparison_type": "values",
}
)
def test_comparison_period_rejects_arrays() -> None:
with pytest.raises(ValidationError, match="must be a single string"):
InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"comparison_period": ["1 year ago"],
"comparison_type": "values",
}
)
def test_comparison_period_rejects_whitespace() -> None:
with pytest.raises(ValidationError, match="Comparison period cannot be empty"):
InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
"comparison_period": " ",
"comparison_type": "values",
}
)
def test_normalization_canonicalizes_temporal_lookup_key() -> None:
plugin = InteractivePivotChartPlugin()
config = InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "REGION"}],
"columns": [{"name": "Order_Date"}],
"metrics": [{"name": "REVENUE", "aggregate": "SUM"}],
"temporal_column": "ORDER_DATE",
"time_grain": "P1M",
}
)
dataset_context = DatasetContext(
id=7,
table_name="orders",
database_name="analytics",
available_columns=[
{"name": "region", "is_temporal": False},
{"name": "order_date", "is_temporal": True},
{"name": "revenue", "is_temporal": False},
],
)
normalized = plugin.normalize_column_refs(config, dataset_context)
form_data = map_interactive_pivot_config(normalized)
assert normalized.temporal_column == "order_date"
assert form_data["groupby"] == ["region", "order_date"]
assert form_data["granularity_sqla"] == "order_date"
assert form_data["temporal_columns_lookup"] == {"order_date": True}
def test_registry_mapping_keeps_interactive_pivot_distinct(
config: InteractivePivotChartConfig,
) -> None:
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
form_data = map_config_to_form_data(config, dataset_id=7)
assert form_data["viz_type"] == "ag-grid-pivot-table"
assert form_data["viz_type"] != "pivot_table_v2"
def test_native_viz_type_alias_parses_as_interactive_pivot() -> None:
request = GenerateChartRequest.model_validate(
{
"dataset_id": 7,
"config": {
"viz_type": "ag-grid-pivot-table",
"rows": [{"name": "region"}],
"columns": [{"name": "quarter"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
},
}
)
assert isinstance(request.config, InteractivePivotChartConfig)
assert request.config.chart_type == "interactive_pivot"
def test_dimension_cannot_be_both_row_and_column() -> None:
with pytest.raises(ValidationError, match="both rows and columns"):
InteractivePivotChartConfig.model_validate(
{
"chart_type": "interactive_pivot",
"rows": [{"name": "region"}],
"columns": [{"name": "region"}],
"metrics": [{"name": "revenue", "aggregate": "SUM"}],
}
)
def test_oss_deployment_hides_schema_and_rejects_generation(
config: InteractivePivotChartConfig,
) -> None:
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=False):
assert get_registry().get("interactive_pivot") is None
result = _get_chart_type_schema_impl("interactive_pivot")
is_valid, _, error = SchemaValidator.validate_request(
{"dataset_id": 7, "config": config.model_dump(mode="json")}
)
assert result["error"]["error_code"] == "DISABLED_CHART_TYPE"
assert "interactive_pivot" not in result["valid_chart_types"]
assert is_valid is False
assert error is not None
assert error.error_code == "DISABLED_CHART_TYPE"
assert "not available on this instance" in error.details
assert "disabled by the operator" not in error.details
def test_feature_flag_exposes_schema_and_generation(
config: InteractivePivotChartConfig,
) -> None:
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
assert get_registry().get("interactive_pivot") is not None
result = _get_chart_type_schema_impl("interactive_pivot")
is_valid, parsed, error = SchemaValidator.validate_request(
{"dataset_id": 7, "config": config.model_dump(mode="json")}
)
assert result["chart_type"] == "interactive_pivot"
assert result["examples"]
assert "comparison_period" in result["schema"]["properties"]
assert "comparison_type" in result["schema"]["properties"]
assert is_valid is True
assert parsed is not None
assert isinstance(parsed.config, InteractivePivotChartConfig)
assert error is None
def test_plugin_checks_feature_flag() -> None:
plugin = InteractivePivotChartPlugin()
with patch.object(
feature_flag_manager, "is_feature_enabled", return_value=True
) as is_enabled:
assert plugin.is_available() is True
is_enabled.assert_called_once_with(INTERACTIVE_PIVOT_FEATURE_FLAG)
def test_update_preserves_viz_type_and_ui_grid_state(
config: InteractivePivotChartConfig,
) -> None:
chart = Mock(
datasource_id=7,
slice_name="Revenue pivot",
params=json.dumps(
{
"viz_type": "ag-grid-pivot-table",
"column_config": {"Revenue": {"d3NumberFormat": "$,.2f"}},
"conditional_formatting": [
{"column": "Revenue", "operator": ">", "targetValue": 1000}
],
"pivot_table_state": {
"columnSizing": {
"columnSizingModel": [{"colId": "region", "width": 180}]
},
"filter": {"filterModel": {"region": {"values": ["EMEA"]}}},
"rowGroup": {"groupColIds": ["old_row"]},
},
}
),
)
request = UpdateChartRequest(identifier=9, config=config)
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
payload = _build_update_payload(request, chart, parsed_config=config)
assert isinstance(payload, dict)
assert payload["viz_type"] == "ag-grid-pivot-table"
params = json.loads(payload["params"])
assert params["time_compare"] == ["1 year ago"]
assert params["comparison_type"] == "percentage"
assert params["column_config"] == {"Revenue": {"d3NumberFormat": "$,.2f"}}
assert params["conditional_formatting"] == [
{"column": "Revenue", "operator": ">", "targetValue": 1000}
]
state = params["pivot_table_state"]
assert state["columnSizing"]["columnSizingModel"][0]["width"] == 180
assert state["filter"]["filterModel"]["region"]["values"] == ["EMEA"]
assert state["rowGroup"]["groupColIds"] == ["region", "country"]
assert state["pivot"]["pivotColIds"] == ["order_date"]
def test_preview_update_preserves_viz_type_and_ui_grid_state(
config: InteractivePivotChartConfig,
) -> None:
chart = Mock(
id=9,
datasource_id=7,
slice_name="Revenue pivot",
params=json.dumps(
{
"viz_type": "ag-grid-pivot-table",
"column_config": {"Revenue": {"columnWidth": 160}},
"pivot_table_state": {"sort": {"sortModel": []}},
}
),
)
request = UpdateChartRequest(identifier=9, config=config)
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
form_data = _build_preview_form_data(request, chart, parsed_config=config)
assert isinstance(form_data, dict)
assert form_data["viz_type"] == "ag-grid-pivot-table"
assert form_data["time_compare"] == ["1 year ago"]
assert form_data["comparison_type"] == "percentage"
assert form_data["column_config"] == {"Revenue": {"columnWidth": 160}}
assert form_data["pivot_table_state"]["sort"] == {"sortModel": []}
def test_capability_analysis_marks_ag_grid_pivot_interactive(
config: InteractivePivotChartConfig,
) -> None:
capabilities = analyze_chart_capabilities("ag-grid-pivot-table", config)
semantics = analyze_chart_semantics("ag-grid-pivot-table", config)
assert capabilities.supports_interaction is True
assert capabilities.supports_drill_down is True
assert "side-panel" in semantics.primary_insight
def test_chart_name_identifies_interactive_pivot(
config: InteractivePivotChartConfig,
) -> None:
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
name = generate_chart_name(config)
assert name == "Interactive Pivot Table region, country Status active"
@@ -17,10 +17,12 @@
"""Tests for registry plugin filtering (configure / is_enabled / get / all_types)."""
from unittest.mock import Mock
import pytest
import superset.mcp_service.chart.registry as registry_module
from superset.mcp_service.chart.plugin import BaseChartPlugin
from superset.mcp_service.chart.plugin import BaseChartPlugin, ChartTypePlugin
from superset.mcp_service.chart.registry import (
_PluginFilterConfig,
all_types,
@@ -65,6 +67,17 @@ def test_get_returns_plugin_when_enabled():
assert get("beta") is not None
def test_plugin_without_optional_availability_hook_remains_enabled():
"""Plugins implementing the original protocol remain backward compatible."""
legacy_plugin = Mock(spec=ChartTypePlugin)
legacy_plugin.chart_type = "legacy"
legacy_plugin.display_name = "Legacy"
legacy_plugin.native_viz_types = {"legacy_viz": "Legacy"}
register(legacy_plugin)
assert get("legacy") is legacy_plugin
def test_get_returns_none_for_disabled_plugin():
configure(disabled={"alpha"})
assert get("alpha") is None
@@ -19,6 +19,7 @@
import pytest
from superset.extensions import feature_flag_manager
from superset.mcp_service.chart.tool.get_chart_type_schema import (
_CHART_EXAMPLES,
_CHART_TYPE_ADAPTERS,
@@ -27,6 +28,12 @@ from superset.mcp_service.chart.tool.get_chart_type_schema import (
)
@pytest.fixture(autouse=True)
def enable_all_feature_flags(monkeypatch: pytest.MonkeyPatch) -> None:
"""Exercise every registered adapter, including host-gated chart types."""
monkeypatch.setattr(feature_flag_manager, "is_feature_enabled", lambda _: True)
class TestGetChartTypeSchema:
@pytest.mark.parametrize("chart_type", VALID_CHART_TYPES)
def test_valid_chart_type_returns_schema(self, chart_type: str) -> None:
@@ -26,6 +26,7 @@ from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
from superset.extensions import feature_flag_manager
from superset.mcp_service.app import mcp
from superset.mcp_service.chart.chart_utils import map_big_number_config
from superset.mcp_service.chart.schemas import (
@@ -33,6 +34,7 @@ from superset.mcp_service.chart.schemas import (
BigNumberChartConfig,
ColumnRef,
FilterConfig,
InteractivePivotChartConfig,
LegendConfig,
TableChartConfig,
TablePreview,
@@ -998,6 +1000,83 @@ class TestUpdateChartPreview:
assert result["warnings"] == []
mock_get_previous_form_data.assert_called_once_with("valid_key_12345")
@patch.object(update_chart_preview_module, "validate_and_compile")
@patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch.object(update_chart_preview_module, "analyze_chart_semantics")
@patch.object(update_chart_preview_module, "analyze_chart_capabilities")
@patch.object(update_chart_preview_module, "generate_explore_link")
@patch.object(update_chart_preview_module, "_get_previous_form_data")
@patch.object(update_chart_preview_module, "_find_dataset")
@patch("superset.mcp_service.auth.get_user_from_request")
@pytest.mark.asyncio
async def test_preserves_interactive_pivot_ui_config(
self,
mock_get_user_from_request,
mock_find_dataset,
mock_get_previous_form_data,
mock_generate_explore_link,
mock_analyze_chart_capabilities,
mock_analyze_chart_semantics,
mock_find_by_id,
unused_access_mock,
mock_validate_and_compile,
) -> None:
"""Cached preview iteration keeps state and UI-only formatting."""
mock_user = Mock(id=1)
mock_get_user_from_request.return_value = mock_user
mock_find_dataset.return_value = _mock_dataset(id=3)
mock_find_by_id.return_value = _mock_dataset(id=3)
mock_validate_and_compile.return_value = Mock(success=True)
mock_get_previous_form_data.return_value = {
"viz_type": "ag-grid-pivot-table",
"column_config": {"Revenue": {"d3NumberFormat": "$,.2f"}},
"conditional_formatting": [
{"column": "Revenue", "operator": ">", "targetValue": 1000}
],
"pivot_table_state": {
"columnSizing": {
"columnSizingModel": [{"colId": "region", "width": 180}]
},
"sort": {"sortModel": []},
"rowGroup": {"groupColIds": ["old_region"]},
},
}
mock_generate_explore_link.return_value = (
"http://localhost:8088/explore/?form_data_key=new_preview_key"
)
mock_analyze_chart_capabilities.return_value = None
mock_analyze_chart_semantics.return_value = None
request = UpdateChartPreviewRequest(
form_data_key="valid_key_12345",
dataset_id=3,
config=InteractivePivotChartConfig(
chart_type="interactive_pivot",
rows=[ColumnRef(name="region")],
columns=[ColumnRef(name="quarter")],
metrics=[ColumnRef(name="revenue", aggregate="SUM", label="Revenue")],
),
)
with patch.object(
feature_flag_manager, "is_feature_enabled", return_value=True
):
result = update_chart_preview_module.update_chart_preview(
request=request, ctx=Mock()
)
generated = mock_generate_explore_link.call_args.args[1]
assert generated["viz_type"] == "ag-grid-pivot-table"
assert generated["column_config"] == {"Revenue": {"d3NumberFormat": "$,.2f"}}
assert generated["conditional_formatting"][0]["column"] == "Revenue"
state = generated["pivot_table_state"]
assert state["columnSizing"]["columnSizingModel"][0]["width"] == 180
assert state["sort"] == {"sortModel": []}
assert state["rowGroup"] == {"groupColIds": ["region"]}
assert state["pivot"] == {"pivotMode": True, "pivotColIds": ["quarter"]}
assert result["success"] is True
@patch.object(update_chart_preview_module, "validate_and_compile")
@patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@@ -2275,6 +2275,8 @@ def test_get_semantic_view_structure(
mock_view = MagicMock()
mock_view.name = "orders"
mock_view.description = "All orders"
mock_view.cache_timeout = 600
mock_view.implementation.get_dimensions.return_value = {mock_dim}
mock_view.implementation.get_metrics.return_value = {mock_metric}
@@ -2288,6 +2290,10 @@ def test_get_semantic_view_structure(
assert response.status_code == 200
result = response.json["result"]
assert result["name"] == "orders"
# The edit modal has no detail route to hydrate from, so these must ride
# along with the structure or a saved description cannot be read back.
assert result["description"] == "All orders"
assert result["cache_timeout"] == 600
assert len(result["dimensions"]) == 1
assert result["dimensions"][0]["name"] == "order_date"
assert result["dimensions"][0]["type"] == "timestamp[us]"
@@ -2371,6 +2377,8 @@ def test_get_semantic_view_structure_no_grain(
mock_view = MagicMock()
mock_view.name = "customers"
mock_view.description = None
mock_view.cache_timeout = None
mock_view.implementation.get_dimensions.return_value = {mock_dim}
mock_view.implementation.get_metrics.return_value = set()
@@ -2385,6 +2393,10 @@ def test_get_semantic_view_structure_no_grain(
result = response.json["result"]
assert result["dimensions"][0]["grain"] is None
assert result["metrics"] == []
# An unset description must come back as null, not be omitted: the modal
# distinguishes "cleared" from "absent" when hydrating.
assert result["description"] is None
assert result["cache_timeout"] is None
mock_view.raise_for_access.assert_called_once()
-1
View File
@@ -1849,7 +1849,6 @@ def test_custom_dialect(app: None) -> None:
@pytest.mark.parametrize(
"engine",
[
"ascend",
"awsathena",
"base",
"bigquery",