mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37d65ef731 | ||
|
|
75e4014ac1 | ||
|
|
fee0f9e160 | ||
|
|
5b64150d0a | ||
|
|
b8b2e5ea8b | ||
|
|
f3985d88a1 | ||
|
|
6abab3c1cb | ||
|
|
b310f469c2 | ||
|
|
22891894d4 | ||
|
|
65fa1ce816 | ||
|
|
ee4a90892c | ||
|
|
bba4832946 | ||
|
|
2df06dcd57 | ||
|
|
88cd8bf575 | ||
|
|
5f67e85003 | ||
|
|
87fb7d18e1 |
@@ -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"
|
||||
|
||||
@@ -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)$
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
+3
-3
@@ -62,8 +62,8 @@ yarn version:remove:developer_docs <version> # Remove developer docs version
|
||||
yarn version:remove:components <version> # Remove components version
|
||||
|
||||
# Quality Checks
|
||||
yarn typecheck # TypeScript validation
|
||||
yarn 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="/user-docs/databases/supported/ascend" title="Ascend"><img src="/img/databases/ascend.webp" alt="Ascend" width="117" height="40" /></a>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
+23
-6
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
|
||||
Generated
+4
-4
@@ -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": {
|
||||
|
||||
@@ -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 |
BIN
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 });
|
||||
});
|
||||
+177
-38
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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})",
|
||||
}
|
||||
@@ -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"}}]}})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
+24743
-25511
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()
|
||||
|
||||
|
||||
|
||||
@@ -1849,7 +1849,6 @@ def test_custom_dialect(app: None) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"engine",
|
||||
[
|
||||
"ascend",
|
||||
"awsathena",
|
||||
"base",
|
||||
"bigquery",
|
||||
|
||||
Reference in New Issue
Block a user