Compare commits

..

11 Commits

Author SHA1 Message Date
Beto Dealmeida
7083d09777 feat: file handler for CSV/XSL 2025-12-19 09:47:32 -05:00
Evan Rusackas
6b948ee894 docs(badges): Restore project badges on README - and re-implement the Docusaurus ones (#36495)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:45:51 -08:00
Joshua Daniel
5e0ee40762 feat(chart): support icons and text in the deck.gl Geojson visualization (#36201)
Co-authored-by: Joshua Daniel <jdaniel@gflenv.com>
2025-12-16 14:28:04 -08:00
Evan Rusackas
6aaf2266a9 chore(deps-dev): add baseline-browser-mapping (#36645)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:20:37 -08:00
Vitor Avila
d14f502126 fix: store form_data as dict during viz type migration (#36680) 2025-12-16 18:32:29 -03:00
Joe Li
d0361cb881 test(playwright): convert and create new dataset list playwright tests (#36196)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-16 11:07:11 -08:00
Vitor Avila
821b259805 fix: Support datetime_format during import (#36679) 2025-12-16 15:15:04 -03:00
Joe Li
2329d49f9e fix(DatasourceEditor): add mount guards and fix async race conditions (#35810)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-16 09:54:32 -08:00
Michael S. Molina
28e3ba749e feat: SQL execution API for Superset (#36529)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-16 14:39:29 -03:00
Evan Rusackas
cd2c889c9a feat(frontend): upgrade Storybook and add extension component documentation (#36498)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 09:31:35 -08:00
Evan Rusackas
52c711b0bc fix(dashboard): import with overwrite flag replaces charts instead of merging (#36551)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-16 09:09:56 -08:00
165 changed files with 16654 additions and 14470 deletions

View File

@@ -117,6 +117,19 @@ testdata() {
say "::endgroup::"
}
playwright_testdata() {
cd "$GITHUB_WORKSPACE"
say "::group::Load all examples for Playwright tests"
# must specify PYTHONPATH to make `tests.superset_test_config` importable
export PYTHONPATH="$GITHUB_WORKSPACE"
pip install -e .
superset db upgrade
superset load_test_users
superset load_examples
superset init
say "::endgroup::"
}
celery-worker() {
cd "$GITHUB_WORKSPACE"
say "::group::Start Celery worker"

View File

@@ -223,7 +223,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6

View File

@@ -167,3 +167,21 @@ jobs:
run: |
docker run --rm $TAG bash -c \
"npm run plugins:build-storybook"
test-storybook:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@v6
with:
name: docker-image
- name: Load Docker Image
run: docker load < docker-image.tar.gz
- name: Build Storybook and Run Tests
run: |
docker run --rm $TAG bash -c \
"npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci"

View File

@@ -97,7 +97,7 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
run: playwright_testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6

View File

@@ -17,6 +17,21 @@ specific language governing permissions and limitations
under the License.
-->
# Superset
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/license/apache-2-0)
[![Latest Release on Github](https://img.shields.io/github/v/release/apache/superset?sort=semver)](https://github.com/apache/superset/releases/latest)
[![Build Status](https://github.com/apache/superset/actions/workflows/superset-python-unittest.yml/badge.svg)](https://github.com/apache/superset/actions)
[![PyPI version](https://badge.fury.io/py/apache_superset.svg)](https://badge.fury.io/py/apache_superset)
[![PyPI](https://img.shields.io/pypi/pyversions/apache_superset.svg?maxAge=2592000)](https://pypi.python.org/pypi/apache_superset)
[![GitHub Stars](https://img.shields.io/github/stars/apache/superset?style=social)](https://github.com/apache/superset/stargazers)
[![Contributors](https://img.shields.io/github/contributors/apache/superset)](https://github.com/apache/superset/graphs/contributors)
[![Last Commit](https://img.shields.io/github/last-commit/apache/superset)](https://github.com/apache/superset/commits/master)
[![Open Issues](https://img.shields.io/github/issues/apache/superset)](https://github.com/apache/superset/issues)
[![Open PRs](https://img.shields.io/github/issues-pr/apache/superset)](https://github.com/apache/superset/pulls)
[![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](http://bit.ly/join-superset-slack)
[![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.apache.org)
<picture width="500">
<source
width="600"

3
docs/.gitignore vendored
View File

@@ -23,3 +23,6 @@ docs/.zshrc
# Gets copied from the root of the project at build time (yarn start / yarn build)
docs/intro.md
# Generated badge images (downloaded at build time by remark-localize-badges plugin)
static/badges/

View File

@@ -0,0 +1,131 @@
---
title: Alert
sidebar_label: Alert
---
<!--
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 { StoryWithControls } from '../../../src/components/StorybookWrapper';
import { Alert } from '@apache-superset/core/ui';
# Alert
Alert component for displaying important messages to users. Wraps Ant Design Alert with sensible defaults and improved accessibility.
## Live Example
<StoryWithControls
component={Alert}
props={{
closable: true,
type: 'info',
message: 'This is a sample alert message.',
description: 'Sample description for additional context.',
showIcon: true
}}
controls={[
{
name: 'type',
label: 'Type',
type: 'select',
options: [
'info',
'error',
'warning',
'success'
]
},
{
name: 'closable',
label: 'Closable',
type: 'boolean'
},
{
name: 'showIcon',
label: 'Show Icon',
type: 'boolean'
},
{
name: 'message',
label: 'Message',
type: 'text'
},
{
name: 'description',
label: 'Description',
type: 'text'
}
]}
/>
## Try It
Edit the code below to experiment with the component:
```tsx live
function Demo() {
return (
<Alert
closable
type="info"
message="This is a sample alert message."
description="Sample description for additional context."
showIcon
/>
);
}
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `closable` | `boolean` | `true` | Whether the Alert can be closed with a close button. |
| `type` | `string` | `"info"` | Type of the alert (e.g., info, error, warning, success). |
| `message` | `string` | `"This is a sample alert message."` | Message |
| `description` | `string` | `"Sample description for additional context."` | Description |
| `showIcon` | `boolean` | `true` | Whether to display an icon in the Alert. |
## Usage in Extensions
This component is available in the `@apache-superset/core/ui` package, which is automatically available to Superset extensions.
```tsx
import { Alert } from '@apache-superset/core/ui';
function MyExtension() {
return (
<Alert
closable
type="info"
message="This is a sample alert message."
/>
);
}
```
## 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)
---
*This page was auto-generated from the component's Storybook story.*

View File

@@ -0,0 +1,93 @@
---
title: Extension Components
sidebar_label: Overview
sidebar_position: 1
---
<!--
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.
-->
# Extension Components
These UI components are available to Superset extension developers through the `@apache-superset/core/ui` package. They provide a consistent look and feel with the rest of Superset and are designed to be used in extension panels, views, and other UI elements.
## Available Components
- [Alert](./alert)
## Usage
All components are exported from the `@apache-superset/core/ui` package:
```tsx
import { Alert } from '@apache-superset/core/ui';
export function MyExtensionPanel() {
return (
<Alert type="info">
Welcome to my extension!
</Alert>
);
}
```
## Adding New Components
Components in `@apache-superset/core/ui` 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`
3. Create a Storybook story with an `Interactive` export:
```tsx
export default {
title: 'Extension Components/MyComponent',
component: MyComponent,
parameters: {
docs: {
description: {
component: 'Description of the component...',
},
},
},
};
export const InteractiveMyComponent = (args) => <MyComponent {...args} />;
InteractiveMyComponent.args = {
variant: 'primary',
disabled: false,
};
InteractiveMyComponent.argTypes = {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary'],
},
disabled: {
control: { type: 'boolean' },
},
};
```
4. Run `yarn start` in `docs/` - the page generates automatically!
## Interactive Documentation
For interactive examples with controls, visit the [Storybook](/storybook/?path=/docs/extension-components--docs).

View File

@@ -237,3 +237,73 @@ superset-extensions dev
✅ Manifest updated
👀 Watching for changes in: /dataset_references/frontend, /dataset_references/backend
```
## Contributing Extension-Compatible Components
Components in `@apache-superset/core` are automatically documented in the Developer Portal. Simply add a component to the package and it will appear in the extension documentation.
### Requirements
1. **Location**: The component must be in `superset-frontend/packages/superset-core/src/ui/components/`
2. **Exported**: The component must be exported from the package's `index.ts`
3. **Story**: The component must have a Storybook story
### Creating a Story for Your Component
Create a story file with an `Interactive` export that defines args and argTypes:
```typescript
// MyComponent.stories.tsx
import { MyComponent } from '.';
export default {
title: 'Extension Components/MyComponent',
component: MyComponent,
parameters: {
docs: {
description: {
component: 'A brief description of what this component does.',
},
},
},
};
// Define an interactive story with args
export const InteractiveMyComponent = (args) => <MyComponent {...args} />;
InteractiveMyComponent.args = {
variant: 'primary',
disabled: false,
};
InteractiveMyComponent.argTypes = {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'danger'],
},
disabled: {
control: { type: 'boolean' },
},
};
```
### How Documentation is Generated
When the docs site is built (`yarn start` or `yarn build` in the `docs/` directory):
1. The `generate-extension-components` script scans all stories in `superset-core`
2. For each story, it generates an MDX page with:
- Component description
- **Live interactive example** with controls extracted from `argTypes`
- **Editable code playground** for experimentation
- Props table from story `args`
- Usage code snippet
- Links to source files
3. Pages appear automatically in **Developer Portal → Extensions → Components**
### Best Practices
- **Use descriptive titles**: The title path determines the component's location in docs (e.g., `Extension Components/Alert`)
- **Define argTypes**: These become interactive controls in the documentation
- **Provide default args**: These populate the initial state of the live example
- **Write clear descriptions**: Help extension developers understand when to use each component

View File

@@ -934,6 +934,20 @@ npm run storybook
When contributing new React components to Superset, please try to add a Story alongside the component's `jsx/tsx` file.
#### Testing Stories
Superset uses [@storybook/test-runner](https://storybook.js.org/docs/writing-tests/test-runner) to validate that all stories compile and render without errors. This helps catch broken stories before they're merged.
```bash
# Run against a running Storybook server (start with `npm run storybook` first)
npm run test-storybook
# Build static Storybook and test (CI-friendly, no server needed)
npm run test-storybook:ci
```
The `test-storybook` job runs automatically in CI on every pull request, ensuring stories remain functional.
## Tips
### Adding a new datasource

View File

@@ -21,6 +21,7 @@ import type { Config } from '@docusaurus/types';
import type { Options, ThemeConfig } from '@docusaurus/preset-classic';
import { themes } from 'prism-react-renderer';
import remarkImportPartial from 'remark-import-partial';
import remarkLocalizeBadges from './plugins/remark-localize-badges.mjs';
import * as fs from 'fs';
import * as path from 'path';
@@ -44,7 +45,7 @@ if (!versionsConfig.components.disabled) {
sidebarPath: require.resolve('./sidebarComponents.js'),
editUrl:
'https://github.com/apache/superset/edit/master/docs/components',
remarkPlugins: [remarkImportPartial],
remarkPlugins: [remarkImportPartial, remarkLocalizeBadges],
docItemComponent: '@theme/DocItem',
includeCurrentVersion: versionsConfig.components.includeCurrentVersion,
lastVersion: versionsConfig.components.lastVersion,
@@ -68,7 +69,7 @@ if (!versionsConfig.developer_portal.disabled) {
sidebarPath: require.resolve('./sidebarTutorials.js'),
editUrl:
'https://github.com/apache/superset/edit/master/docs/developer_portal',
remarkPlugins: [remarkImportPartial],
remarkPlugins: [remarkImportPartial, remarkLocalizeBadges],
docItemComponent: '@theme/DocItem',
includeCurrentVersion: versionsConfig.developer_portal.includeCurrentVersion,
lastVersion: versionsConfig.developer_portal.lastVersion,
@@ -164,7 +165,11 @@ const config: Config = {
favicon: '/img/favicon.ico',
organizationName: 'apache',
projectName: 'superset',
themes: ['@saucelabs/theme-github-codeblock', '@docusaurus/theme-mermaid'],
themes: [
'@saucelabs/theme-github-codeblock',
'@docusaurus/theme-mermaid',
'@docusaurus/theme-live-codeblock',
],
plugins: [
require.resolve('./src/webpack.extend.ts'),
[
@@ -337,6 +342,7 @@ const config: Config = {
}
return `https://github.com/apache/superset/edit/master/docs/${versionDocsDirPath}/${docPath}`;
},
remarkPlugins: [remarkImportPartial, remarkLocalizeBadges],
includeCurrentVersion: versionsConfig.docs.includeCurrentVersion,
lastVersion: versionsConfig.docs.lastVersion, // Make 'next' the default
onlyIncludeVersions: versionsConfig.docs.onlyIncludeVersions,
@@ -474,6 +480,9 @@ const config: Config = {
hideable: true,
},
},
liveCodeBlock: {
playgroundPosition: 'bottom',
},
} satisfies ThemeConfig,
scripts: [
// {

View File

@@ -6,16 +6,17 @@
"scripts": {
"docusaurus": "docusaurus",
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
"start": "yarn run _init && NODE_ENV=development docusaurus start",
"start": "yarn run _init && yarn run generate:extension-components && NODE_ENV=development docusaurus start",
"stop": "pkill -f 'docusaurus start' || pkill -f 'docusaurus serve' || echo 'No docusaurus server running'",
"build": "yarn run _init && DEBUG=docusaurus:* docusaurus build",
"build": "yarn run _init && yarn run generate:extension-components && DEBUG=docusaurus:* docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "yarn run _init && docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"typecheck": "yarn run generate:extension-components && tsc",
"generate:extension-components": "node scripts/generate-extension-components.mjs",
"eslint": "eslint .",
"version:add": "node scripts/manage-versions.mjs add",
"version:remove": "node scripts/manage-versions.mjs remove",
@@ -31,6 +32,7 @@
"@docusaurus/core": "3.9.2",
"@docusaurus/plugin-client-redirects": "3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@docusaurus/theme-live-codeblock": "^3.9.2",
"@docusaurus/theme-mermaid": "^3.9.2",
"@emotion/core": "^11.0.0",
"@emotion/react": "^11.13.3",
@@ -65,7 +67,8 @@
"storybook": "^8.6.11",
"swagger-ui-react": "^5.31.0",
"tinycolor2": "^1.4.2",
"ts-loader": "^9.5.4"
"ts-loader": "^9.5.4",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.1",

View File

@@ -0,0 +1,224 @@
/**
* 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.
*/
/**
* Remark plugin to localize badge images from shields.io and similar services.
*
* This plugin downloads badge SVGs at build time and serves them locally,
* avoiding external dependencies and caching issues with dynamic badges.
*
* Inspired by Apache Commons' fixshields.py approach.
*/
import { visit } from 'unist-util-visit';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
// Badge domains to localize (always localize all URLs from these domains)
const BADGE_DOMAINS = [
'img.shields.io',
'badge.fury.io',
'codecov.io',
'badgen.net',
'nodei.co',
];
// Patterns for badge URLs on other domains (e.g., GitHub Actions badges)
const BADGE_PATH_PATTERNS = [
/github\.com\/.*\/actions\/workflows\/.*\/badge\.svg/,
/github\.com\/.*\/badge\.svg/,
];
// Cache for downloaded badges (persists across files in a single build)
const badgeCache = new Map();
// Track if we've already ensured the badges directory exists
let badgesDirCreated = false;
/**
* Generate a stable filename for a badge URL
*/
function getBadgeFilename(url) {
const hash = crypto.createHash('md5').update(url).digest('hex').slice(0, 12);
// Extract a readable name from the URL
const urlPath = new URL(url).pathname;
const readablePart = urlPath
.replace(/^\//, '')
.replace(/[^a-zA-Z0-9-]/g, '_')
.slice(0, 40);
return `${readablePart}_${hash}.svg`;
}
/**
* Check if a URL is a badge we should localize
*/
function isBadgeUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
// Check if it's from a known badge domain
if (BADGE_DOMAINS.some((domain) => parsed.hostname.includes(domain))) {
return true;
}
// Check if it matches a badge path pattern
return BADGE_PATH_PATTERNS.some((pattern) => pattern.test(url));
} catch {
return false;
}
}
/**
* Download a badge and return the local path
*/
async function downloadBadge(url, staticDir) {
// Check cache first
if (badgeCache.has(url)) {
return badgeCache.get(url);
}
const badgesDir = path.join(staticDir, 'badges');
// Ensure badges directory exists
if (!badgesDirCreated) {
fs.mkdirSync(badgesDir, { recursive: true });
badgesDirCreated = true;
}
const filename = getBadgeFilename(url);
const localPath = path.join(badgesDir, filename);
const webPath = `/badges/${filename}`;
// Check if already downloaded in a previous build
if (fs.existsSync(localPath)) {
badgeCache.set(url, webPath);
return webPath;
}
console.log(`[remark-localize-badges] Downloading: ${url}`);
try {
const response = await fetch(url, {
headers: {
// Some services need a user agent
'User-Agent': 'Mozilla/5.0 (compatible; DocusaurusBuild/1.0)',
Accept: 'image/svg+xml,image/*,*/*',
},
// Follow redirects
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type') || '';
const content = await response.text();
// Validate it's actually an SVG or image
if (
!contentType.includes('svg') &&
!contentType.includes('image') &&
!content.trim().startsWith('<svg') &&
!content.trim().startsWith('<?xml')
) {
throw new Error(
`Invalid content type: ${contentType}. Expected SVG image.`,
);
}
// Write the badge to disk
fs.writeFileSync(localPath, content, 'utf8');
console.log(`[remark-localize-badges] Saved: ${filename}`);
badgeCache.set(url, webPath);
return webPath;
} catch (error) {
// Fail the build on badge download failure
throw new Error(
`[remark-localize-badges] Failed to download badge: ${url}\n` +
`Error: ${error.message}\n` +
`Build cannot continue with broken badges. Please fix the badge URL or remove it.`,
);
}
}
/**
* The remark plugin factory
*/
export default function remarkLocalizeBadges(options = {}) {
// __dirname equivalent for ES modules - use import.meta.url
const currentDir = path.dirname(new URL(import.meta.url).pathname);
const docsRoot = path.resolve(currentDir, '..');
const staticDir = options.staticDir || path.join(docsRoot, 'static');
return async function transformer(tree) {
const promises = [];
// Find all image nodes
visit(tree, 'image', (node) => {
if (isBadgeUrl(node.url)) {
promises.push(
downloadBadge(node.url, staticDir).then((localPath) => {
node.url = localPath;
}),
);
}
});
// Also handle HTML img tags in raw HTML or JSX
visit(tree, ['html', 'jsx'], (node) => {
if (!node.value) return;
// Find img src attributes pointing to badge URLs
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = imgRegex.exec(node.value)) !== null) {
const url = match[1];
if (isBadgeUrl(url)) {
promises.push(
downloadBadge(url, staticDir).then((localPath) => {
node.value = node.value.replace(url, localPath);
}),
);
}
}
});
// Also handle markdown link images: [![alt](img-url)](link-url)
visit(tree, 'link', (node) => {
if (node.children) {
node.children.forEach((child) => {
if (child.type === 'image' && isBadgeUrl(child.url)) {
promises.push(
downloadBadge(child.url, staticDir).then((localPath) => {
child.url = localPath;
}),
);
}
});
}
});
// Wait for all downloads to complete
await Promise.all(promises);
};
}

View File

@@ -0,0 +1,676 @@
/**
* 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.
*/
/**
* This script scans for Storybook stories in superset-core/src and generates
* MDX documentation pages for the developer portal. All components in
* superset-core are considered extension-compatible by virtue of their location.
*
* Usage: node scripts/generate-extension-components.mjs
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, '../..');
const DOCS_DIR = path.resolve(__dirname, '..');
const OUTPUT_DIR = path.join(
DOCS_DIR,
'developer_portal/extensions/components'
);
const TYPES_OUTPUT_DIR = path.join(DOCS_DIR, 'src/types/apache-superset-core');
const TYPES_OUTPUT_PATH = path.join(TYPES_OUTPUT_DIR, 'index.d.ts');
const SUPERSET_CORE_DIR = path.join(
ROOT_DIR,
'superset-frontend/packages/superset-core'
);
/**
* Find all story files in the superset-core package
*/
async function findStoryFiles() {
const files = [];
// Use fs to recursively find files since glob might not be available
function walkDir(dir) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.name.endsWith('.stories.tsx')) {
files.push(fullPath);
}
}
}
walkDir(path.join(SUPERSET_CORE_DIR, 'src'));
return files;
}
/**
* Parse a story file and extract metadata
*
* All stories in superset-core are considered extension-compatible
* by virtue of their location - no tag needed.
*/
function parseStoryFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract component name from title
const titleMatch = content.match(/title:\s*['"]([^'"]+)['"]/);
const title = titleMatch ? titleMatch[1] : null;
// Extract component name (last part of title path)
const componentName = title ? title.split('/').pop() : null;
// Extract description from parameters
// Handle concatenated strings like: 'part1 ' + 'part2'
let description = '';
// First try to find the description block
const descBlockMatch = content.match(
/description:\s*{\s*component:\s*([\s\S]*?)\s*},?\s*}/
);
if (descBlockMatch) {
const descBlock = descBlockMatch[1];
// Extract all string literals and concatenate them
const stringParts = [];
const stringMatches = descBlock.matchAll(/['"]([^'"]*)['"]/g);
for (const match of stringMatches) {
stringParts.push(match[1]);
}
description = stringParts.join('').trim();
}
// Extract package info
const packageMatch = content.match(/package:\s*['"]([^'"]+)['"]/);
const packageName = packageMatch ? packageMatch[1] : '@apache-superset/core/ui';
// Extract import path - handle double-quoted strings containing single quotes
// Match: importPath: "import { Alert } from '@apache-superset/core';"
const importMatchDouble = content.match(/importPath:\s*"([^"]+)"/);
const importMatchSingle = content.match(/importPath:\s*'([^']+)'/);
let importPath = `import { ${componentName} } from '${packageName}';`;
if (importMatchDouble) {
importPath = importMatchDouble[1];
} else if (importMatchSingle) {
importPath = importMatchSingle[1];
}
// Get the directory containing the story to find the component
const storyDir = path.dirname(filePath);
const componentFile = path.join(storyDir, 'index.tsx');
const hasComponentFile = fs.existsSync(componentFile);
// Try to extract props interface from component file (for future use)
if (hasComponentFile) {
// Read component file - props extraction reserved for future enhancement
// const componentContent = fs.readFileSync(componentFile, 'utf-8');
}
// Extract story exports (named exports that aren't the default)
const storyExports = [];
const exportMatches = content.matchAll(
/export\s+(?:const|function)\s+(\w+)/g
);
for (const match of exportMatches) {
if (match[1] !== 'default') {
storyExports.push(match[1]);
}
}
return {
filePath,
title,
componentName,
description,
packageName,
importPath,
storyExports,
hasComponentFile,
relativePath: path.relative(ROOT_DIR, filePath),
};
}
/**
* Extract argTypes/args from story content for generating controls
*/
function extractArgsAndControls(content, componentName, storyContent) {
// Look for InteractiveX.args pattern - handle multi-line objects
const argsMatch = content.match(
new RegExp(`Interactive${componentName}\\.args\\s*=\\s*\\{([\\s\\S]*?)\\};`, 's')
);
// Look for argTypes
const argTypesMatch = content.match(
new RegExp(`Interactive${componentName}\\.argTypes\\s*=\\s*\\{([\\s\\S]*?)\\};`, 's')
);
const args = {};
const controls = [];
const propDescriptions = {};
if (argsMatch) {
// Parse args - handle strings, booleans, numbers
// Note: Using simple regex without escape handling for security (avoids ReDoS)
// This is sufficient for Storybook args which rarely contain escaped quotes
const argsContent = argsMatch[1];
const argLines = argsContent.matchAll(/(\w+):\s*(['"]([^'"]*)['"']|true|false|\d+)/g);
for (const match of argLines) {
const key = match[1];
let value = match[2];
// Convert string booleans
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (!isNaN(Number(value))) value = Number(value);
else if (match[3] !== undefined) value = match[3]; // Use captured string content
args[key] = value;
}
}
if (argTypesMatch) {
const argTypesContent = argTypesMatch[1];
// Match each top-level property in argTypes
// Pattern: propertyName: { ... }, (with balanced braces)
const propPattern = /(\w+):\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g;
let propMatch;
while ((propMatch = propPattern.exec(argTypesContent)) !== null) {
const name = propMatch[1];
const propContent = propMatch[2];
// Extract description if present
const descMatch = propContent.match(/description:\s*['"]([^'"]+)['"]/);
if (descMatch) {
propDescriptions[name] = descMatch[1];
}
// Skip if it's an action (not a control)
if (propContent.includes('action:')) continue;
// Extract label for display
const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
// Check for select control
if (propContent.includes("type: 'select'") || propContent.includes('type: "select"')) {
// Look for options - could be inline array or variable reference
const inlineOptionsMatch = propContent.match(/options:\s*\[([^\]]+)\]/);
const varOptionsMatch = propContent.match(/options:\s*(\w+)/);
let options = [];
if (inlineOptionsMatch) {
options = [...inlineOptionsMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
} else if (varOptionsMatch && storyContent) {
// Look up the variable
const varName = varOptionsMatch[1];
const varDefMatch = storyContent.match(
new RegExp(`const\\s+${varName}[^=]*=\\s*\\[([^\\]]+)\\]`)
);
if (varDefMatch) {
options = [...varDefMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
}
}
if (options.length > 0) {
controls.push({ name, label, type: 'select', options });
}
}
// Check for boolean control
else if (propContent.includes("type: 'boolean'") || propContent.includes('type: "boolean"')) {
controls.push({ name, label, type: 'boolean' });
}
// Check for text/string control (default for props in args without explicit control)
else if (args[name] !== undefined && typeof args[name] === 'string') {
controls.push({ name, label, type: 'text' });
}
}
}
// Add text controls for string args that don't have explicit argTypes
for (const [key, value] of Object.entries(args)) {
if (typeof value === 'string' && !controls.find(c => c.name === key)) {
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
controls.push({ name: key, label, type: 'text' });
}
}
return { args, controls, propDescriptions };
}
/**
* Generate MDX content for a component
*/
function generateMDX(component, storyContent) {
const { componentName, description, importPath, packageName, relativePath } =
component;
// Extract args, controls, and descriptions from the story
const { args, controls, propDescriptions } = extractArgsAndControls(storyContent, componentName, storyContent);
// Generate the controls array for StoryWithControls
const controlsJson = JSON.stringify(controls, null, 2)
.replace(/"(\w+)":/g, '$1:') // Remove quotes from keys
.replace(/"/g, "'"); // Use single quotes for strings
// Generate default props
const propsJson = JSON.stringify(args, null, 2)
.replace(/"(\w+)":/g, '$1:')
.replace(/"/g, "'");
// Generate a realistic live code example from the actual args
const liveExampleProps = Object.entries(args)
.map(([key, value]) => {
if (typeof value === 'string') return `${key}="${value}"`;
if (typeof value === 'boolean') return value ? key : null;
return `${key}={${JSON.stringify(value)}}`;
})
.filter(Boolean)
.join('\n ');
// Generate props table with descriptions from argTypes
const propsTable = Object.entries(args).map(([key, value]) => {
const type = typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'string' : 'any';
const desc = propDescriptions[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
return `| \`${key}\` | \`${type}\` | \`${JSON.stringify(value)}\` | ${desc} |`;
}).join('\n');
// Generate usage example props (simplified for readability)
const usageExampleProps = Object.entries(args)
.slice(0, 3) // Show first 3 props for brevity
.map(([key, value]) => {
if (typeof value === 'string') return `${key}="${value}"`;
if (typeof value === 'boolean') return value ? key : `${key}={false}`;
return `${key}={${JSON.stringify(value)}}`;
})
.join('\n ');
return `---
title: ${componentName}
sidebar_label: ${componentName}
---
<!--
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 { StoryWithControls } from '../../../src/components/StorybookWrapper';
import { ${componentName} } from '@apache-superset/core/ui';
# ${componentName}
${description || `The ${componentName} component from the Superset extension API.`}
## Live Example
<StoryWithControls
component={${componentName}}
props={${propsJson}}
controls={${controlsJson}}
/>
## Try It
Edit the code below to experiment with the component:
\`\`\`tsx live
function Demo() {
return (
<${componentName}
${liveExampleProps}
/>
);
}
\`\`\`
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
${propsTable}
## Usage in Extensions
This component is available in the \`${packageName}\` package, which is automatically available to Superset extensions.
\`\`\`tsx
${importPath}
function MyExtension() {
return (
<${componentName}
${usageExampleProps}
/>
);
}
\`\`\`
## Source Links
- [Story file](https://github.com/apache/superset/blob/master/${relativePath})
- [Component source](https://github.com/apache/superset/blob/master/${relativePath.replace(/\/[^/]+\.stories\.tsx$/, '/index.tsx')})
---
*This page was auto-generated from the component's Storybook story.*
`;
}
/**
* Generate index page for extension components
*/
function generateIndexMDX(components) {
const componentList = components
.map(c => `- [${c.componentName}](./${c.componentName.toLowerCase()})`)
.join('\n');
return `---
title: Extension Components
sidebar_label: Overview
sidebar_position: 1
---
<!--
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.
-->
# Extension Components
These UI components are available to Superset extension developers through the \`@apache-superset/core/ui\` package. They provide a consistent look and feel with the rest of Superset and are designed to be used in extension panels, views, and other UI elements.
## Available Components
${componentList}
## Usage
All components are exported from the \`@apache-superset/core/ui\` package:
\`\`\`tsx
import { Alert } from '@apache-superset/core/ui';
export function MyExtensionPanel() {
return (
<Alert type="info">
Welcome to my extension!
</Alert>
);
}
\`\`\`
## Adding New Components
Components in \`@apache-superset/core/ui\` 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\`
3. Create a Storybook story with an \`Interactive\` export:
\`\`\`tsx
export default {
title: 'Extension Components/MyComponent',
component: MyComponent,
parameters: {
docs: {
description: {
component: 'Description of the component...',
},
},
},
};
export const InteractiveMyComponent = (args) => <MyComponent {...args} />;
InteractiveMyComponent.args = {
variant: 'primary',
disabled: false,
};
InteractiveMyComponent.argTypes = {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary'],
},
disabled: {
control: { type: 'boolean' },
},
};
\`\`\`
4. Run \`yarn start\` in \`docs/\` - the page generates automatically!
## Interactive Documentation
For interactive examples with controls, visit the [Storybook](/storybook/?path=/docs/extension-components--docs).
`;
}
/**
* Extract type exports from a component file
*/
function extractComponentTypes(componentPath) {
if (!fs.existsSync(componentPath)) {
return null;
}
const content = fs.readFileSync(componentPath, 'utf-8');
const types = [];
// Find all "export type X = ..." declarations
const typeMatches = content.matchAll(/export\s+type\s+(\w+)\s*=\s*([^;]+);/g);
for (const match of typeMatches) {
types.push({
name: match[1],
definition: match[2].trim(),
});
}
// Find all "export const X = ..." declarations (components)
const constMatches = content.matchAll(/export\s+const\s+(\w+)\s*[=:]/g);
const components = [];
for (const match of constMatches) {
components.push(match[1]);
}
return { types, components };
}
/**
* Generate the type declarations file content
*/
function generateTypeDeclarations(componentInfos) {
const imports = new Set();
const typeDeclarations = [];
const componentDeclarations = [];
for (const info of componentInfos) {
const componentDir = path.dirname(info.filePath);
const componentFile = path.join(componentDir, 'index.tsx');
const extracted = extractComponentTypes(componentFile);
if (!extracted) continue;
// Check if types reference antd or react
for (const type of extracted.types) {
if (type.definition.includes('AntdAlertProps') || type.definition.includes('AlertProps')) {
imports.add("import type { AlertProps as AntdAlertProps } from 'antd/es/alert';");
}
if (type.definition.includes('PropsWithChildren') || type.definition.includes('FC')) {
imports.add("import type { PropsWithChildren, FC } from 'react';");
}
// Add the type declaration
typeDeclarations.push(` export type ${type.name} = ${type.definition};`);
}
// Add component declarations
for (const comp of extracted.components) {
const propsType = `${comp}Props`;
const hasPropsType = extracted.types.some(t => t.name === propsType);
if (hasPropsType) {
componentDeclarations.push(` export const ${comp}: FC<${propsType}>;`);
} else {
componentDeclarations.push(` export const ${comp}: FC<Record<string, unknown>>;`);
}
}
}
// Remove 'export' prefix for direct exports (not in declare module)
const cleanedTypes = typeDeclarations.map(t => t.replace(/^ {2}export /, 'export '));
const cleanedComponents = componentDeclarations.map(c => c.replace(/^ {2}export /, 'export '));
return `/**
* 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.
*/
/**
* Type declarations for @apache-superset/core/ui
*
* AUTO-GENERATED by scripts/generate-extension-components.mjs
* Do not edit manually - regenerate by running: yarn generate:extension-components
*/
${Array.from(imports).join('\n')}
${cleanedTypes.join('\n')}
${cleanedComponents.join('\n')}
`;
}
/**
* Main function
*/
async function main() {
console.log('Scanning for extension-compatible stories...\n');
// Find all story files
const storyFiles = await findStoryFiles();
console.log(`Found ${storyFiles.length} story files in superset-core\n`);
// Parse each story file
const components = [];
for (const file of storyFiles) {
const parsed = parseStoryFile(file);
if (parsed) {
components.push(parsed);
console.log(`${parsed.componentName} (${parsed.relativePath})`);
}
}
if (components.length === 0) {
console.log(
'\nNo extension-compatible components found. Make sure stories have:'
);
console.log(" tags: ['extension-compatible']");
return;
}
console.log(`\nFound ${components.length} extension-compatible components\n`);
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`Created directory: ${OUTPUT_DIR}\n`);
}
// Generate MDX files
for (const component of components) {
// Read the story content for extracting args/controls
const storyContent = fs.readFileSync(component.filePath, 'utf-8');
const mdxContent = generateMDX(component, storyContent);
const outputPath = path.join(
OUTPUT_DIR,
`${component.componentName.toLowerCase()}.mdx`
);
fs.writeFileSync(outputPath, mdxContent);
console.log(` Generated: ${path.relative(DOCS_DIR, outputPath)}`);
}
// Generate index page
const indexContent = generateIndexMDX(components);
const indexPath = path.join(OUTPUT_DIR, 'index.mdx');
fs.writeFileSync(indexPath, indexContent);
console.log(` Generated: ${path.relative(DOCS_DIR, indexPath)}`);
// Generate type declarations
if (!fs.existsSync(TYPES_OUTPUT_DIR)) {
fs.mkdirSync(TYPES_OUTPUT_DIR, { recursive: true });
}
const typesContent = generateTypeDeclarations(components);
fs.writeFileSync(TYPES_OUTPUT_PATH, typesContent);
console.log(` Generated: ${path.relative(DOCS_DIR, TYPES_OUTPUT_PATH)}`);
console.log('\nDone! Extension component documentation generated.');
console.log(
`\nGenerated ${components.length + 2} files (${components.length + 1} MDX + 1 type declaration)`
);
}
main().catch(console.error);

View File

@@ -73,6 +73,17 @@ const sidebars = {
'extensions/quick-start',
'extensions/architecture',
'extensions/contribution-types',
{
type: 'category',
label: 'Components',
collapsed: true,
items: [
{
type: 'autogenerated',
dirName: 'extensions/components',
},
],
},
{
type: 'category',
label: 'Extension Points',

View File

@@ -0,0 +1,53 @@
/**
* 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 React from 'react';
import { Button, Card, Input, Space, Tag, Tooltip } from 'antd';
// Import extension components from @apache-superset/core/ui
// This matches the established pattern used throughout the Superset codebase
// Resolved via webpack alias to superset-frontend/packages/superset-core/src/ui/components
import { Alert } from '@apache-superset/core/ui';
/**
* ReactLiveScope provides the scope for live code blocks.
* Any component added here will be available in ```tsx live blocks.
*
* To add more components:
* 1. Import the component from @apache-superset/core above
* 2. Add it to the scope object below
*/
const ReactLiveScope = {
// React core
React,
...React,
// Extension components from @apache-superset/core
Alert,
// Common Ant Design components (for demos)
Button,
Card,
Input,
Space,
Tag,
Tooltip,
};
export default ReactLiveScope;

View File

@@ -0,0 +1,31 @@
/**
* 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.
*/
/**
* Type declarations for @apache-superset/core/ui
*
* AUTO-GENERATED by scripts/generate-extension-components.mjs
* Do not edit manually - regenerate by running: yarn generate:extension-components
*/
import type { AlertProps as AntdAlertProps } from 'antd/es/alert';
import type { PropsWithChildren, FC } from 'react';
export type AlertProps = PropsWithChildren<Omit<AntdAlertProps, 'children'>>;
export const Alert: FC<AlertProps>;

View File

@@ -51,6 +51,15 @@ export default function webpackExtendPlugin(): Plugin<void> {
__dirname,
'../../superset-frontend/packages/superset-ui-core/src/components',
),
// Extension API package - allows docs to import from @apache-superset/core/ui
// This matches the established pattern used throughout the Superset codebase
// Point directly to components to avoid importing theme (which has font dependencies)
// Note: TypeScript types come from docs/src/types/apache-superset-core (see tsconfig.json)
// This split is intentional: webpack resolves actual source, tsconfig provides simplified types
'@apache-superset/core/ui': path.resolve(
__dirname,
'../../superset-frontend/packages/superset-core/src/ui/components',
),
// Add proper Storybook aliases
'@storybook/blocks': path.resolve(
__dirname,

View File

@@ -6,19 +6,23 @@
"skipLibCheck": true,
"noImplicitAny": false,
"strict": false,
"types": ["@docusaurus/module-type-aliases"]
},
"jsx": "react-jsx",
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@superset-ui/core": ["../superset-frontend/packages/superset-ui-core/src"],
"@superset-ui/core/*": ["../superset-frontend/packages/superset-ui-core/src/*"],
"*": ["src/*", "node_modules/*"]
"jsx": "react-jsx",
"moduleResolution": "node",
"types": ["@docusaurus/module-type-aliases"],
"paths": {
"@superset-ui/core": ["../superset-frontend/packages/superset-ui-core/src"],
"@superset-ui/core/*": ["../superset-frontend/packages/superset-ui-core/src/*"],
// Types for @apache-superset/core/ui are auto-generated by scripts/generate-extension-components.mjs
// 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/ui": ["./src/types/apache-superset-core"],
"*": ["src/*", "node_modules/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
"src/**/*.tsx",
"src/**/*.d.ts"
],
"exclude": [
"node_modules",

View File

@@ -914,6 +914,20 @@ npm run storybook
When contributing new React components to Superset, please try to add a Story alongside the component's `jsx/tsx` file.
#### Testing Stories
Superset uses [@storybook/test-runner](https://storybook.js.org/docs/writing-tests/test-runner) to validate that all stories compile and render without errors. This helps catch broken stories before they're merged.
```bash
# Run against a running Storybook server (start with `npm run storybook` first)
npm run test-storybook
# Build static Storybook and test (CI-friendly, no server needed)
npm run test-storybook:ci
```
The `test-storybook` job runs automatically in CI on every pull request, ensuring stories remain functional.
## Tips
### Adding a new datasource

View File

@@ -1957,6 +1957,21 @@
tslib "^2.6.0"
utility-types "^3.10.0"
"@docusaurus/theme-live-codeblock@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-live-codeblock/-/theme-live-codeblock-3.9.2.tgz#43f0968fb737fda1dae2222a2ab7caa25c5668d0"
integrity sha512-cgxxZh18dI5Q4iV0GLmwqXtgZbTLOnb0TYgZRiUh0mnIGbuNWFUhUYXXl5owKbDfIXFdFAiI/owJKM83howEAw==
dependencies:
"@docusaurus/core" "3.9.2"
"@docusaurus/theme-common" "3.9.2"
"@docusaurus/theme-translations" "3.9.2"
"@docusaurus/utils-validation" "3.9.2"
"@philpl/buble" "^0.19.7"
clsx "^2.0.0"
fs-extra "^11.1.1"
react-live "^4.1.6"
tslib "^2.6.0"
"@docusaurus/theme-mermaid@^3.9.2":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.2.tgz#f065e4b4b319560ddd8c3be65ce9dd19ce1d5cc8"
@@ -2468,7 +2483,7 @@
"@types/yargs" "^17.0.8"
chalk "^4.0.0"
"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5":
"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5":
version "0.3.13"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
@@ -2621,6 +2636,21 @@
resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe"
integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==
"@philpl/buble@^0.19.7":
version "0.19.7"
resolved "https://registry.yarnpkg.com/@philpl/buble/-/buble-0.19.7.tgz#27231e6391393793b64bc1c982fc7b593198b893"
integrity sha512-wKTA2DxAGEW+QffRQvOhRQ0VBiYU2h2p8Yc1oBNlqSKws48/8faxqKNIuub0q4iuyTuLwtB8EkwiKwhlfV1PBA==
dependencies:
acorn "^6.1.1"
acorn-class-fields "^0.2.1"
acorn-dynamic-import "^4.0.0"
acorn-jsx "^5.0.1"
chalk "^2.4.2"
magic-string "^0.25.2"
minimist "^1.2.0"
os-homedir "^1.0.1"
regexpu-core "^4.5.4"
"@pkgr/core@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b"
@@ -4656,12 +4686,22 @@ accepts@~1.3.4, accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
acorn-class-fields@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/acorn-class-fields/-/acorn-class-fields-0.2.1.tgz#748058bceeb0ef25164bbc671993984083f5a085"
integrity sha512-US/kqTe0H8M4LN9izoL+eykVAitE68YMuYZ3sHn3i1fjniqR7oQ3SPvuMK/VT1kjOQHrx5Q88b90TtOKgAv2hQ==
acorn-dynamic-import@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948"
integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw==
acorn-import-phases@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7"
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
acorn-jsx@^5.0.0, acorn-jsx@^5.3.2:
acorn-jsx@^5.0.0, acorn-jsx@^5.0.1, acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
@@ -4673,6 +4713,11 @@ acorn-walk@^8.0.0:
dependencies:
acorn "^8.11.0"
acorn@^6.1.1:
version "6.4.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6"
integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==
acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0:
version "8.15.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
@@ -4796,6 +4841,13 @@ ansi-regex@^6.0.1:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.0.tgz#2f302e7550431b1b7762705fffb52cf1ffa20447"
integrity sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==
ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
dependencies:
color-convert "^1.9.0"
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
@@ -4862,6 +4914,11 @@ antd@^6.1.0:
scroll-into-view-if-needed "^3.1.0"
throttle-debounce "^5.0.2"
any-promise@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
@@ -5346,6 +5403,15 @@ ccount@^2.0.0:
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
dependencies:
ansi-styles "^3.2.1"
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
@@ -5503,6 +5569,13 @@ collapse-white-space@^2.0.0:
resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz#640257174f9f42c740b40f3b55ee752924feefca"
integrity sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
dependencies:
color-name "1.1.3"
color-convert@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
@@ -5510,6 +5583,11 @@ color-convert@^2.0.1:
dependencies:
color-name "~1.1.4"
color-name@1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
@@ -5557,6 +5635,11 @@ commander@^2.20.0, commander@^2.20.3:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
commander@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
@@ -7696,6 +7779,11 @@ has-bigints@^1.0.2:
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==
has-flag@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
has-flag@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
@@ -8693,6 +8781,11 @@ jsesc@^3.0.2:
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d"
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
jsesc@~0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==
jsesc@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e"
@@ -8992,6 +9085,13 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
magic-string@^0.25.2:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
dependencies:
sourcemap-codec "^1.4.8"
make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -10108,6 +10208,15 @@ multicast-dns@^7.2.5:
dns-packet "^5.2.2"
thunky "^1.0.2"
mz@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
dependencies:
any-promise "^1.0.0"
object-assign "^4.0.1"
thenify-all "^1.0.0"
nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@@ -10244,7 +10353,7 @@ null-loader@^4.0.1:
loader-utils "^2.0.0"
schema-utils "^3.0.0"
object-assign@^4.1.1:
object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@@ -10375,6 +10484,11 @@ optionator@^0.9.3:
type-check "^0.4.0"
word-wrap "^1.2.5"
os-homedir@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
integrity sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==
own-keys@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358"
@@ -10626,6 +10740,11 @@ pify@^4.0.1:
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pirates@^4.0.1:
version "4.0.7"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22"
integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
pkg-dir@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-7.0.0.tgz#8f0c08d6df4476756c5ff29b3282d0bab7517d11"
@@ -11263,7 +11382,7 @@ pretty-time@^1.1.0:
resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e"
integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==
prism-react-renderer@^2.3.0, prism-react-renderer@^2.4.1:
prism-react-renderer@^2.3.0, prism-react-renderer@^2.4.0, prism-react-renderer@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz#ac63b7f78e56c8f2b5e76e823a976d5ede77e35f"
integrity sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==
@@ -11521,6 +11640,15 @@ react-json-view-lite@^2.3.0:
resolved "https://registry.yarnpkg.com/react-json-view-lite/-/react-json-view-lite-2.4.2.tgz#796ed6c650c29123d87b9484889445d1a8a88ede"
integrity sha512-m7uTsXDgPQp8R9bJO4HD/66+i218eyQPAb+7/dGQpwg8i4z2afTFqtHJPQFHvJfgDCjGQ1HSGlL3HtrZDa3Tdg==
react-live@^4.1.6:
version "4.1.8"
resolved "https://registry.yarnpkg.com/react-live/-/react-live-4.1.8.tgz#287fb6c5127c2d89a6fe39380278d95cc8e661b6"
integrity sha512-B2SgNqwPuS2ekqj4lcxi5TibEcjWkdVyYykBEUBshPAPDQ527x2zPEZg560n8egNtAjUpwXFQm7pcXV65aAYmg==
dependencies:
prism-react-renderer "^2.4.0"
sucrase "^3.35.0"
use-editable "^2.3.3"
react-loadable-ssr-addon-v5-slorber@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz#2cdc91e8a744ffdf9e3556caabeb6e4278689883"
@@ -11752,6 +11880,13 @@ regenerate-unicode-properties@^10.2.0:
dependencies:
regenerate "^1.4.2"
regenerate-unicode-properties@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-9.0.0.tgz#54d09c7115e1f53dc2314a974b32c1c344efe326"
integrity sha512-3E12UeNSPfjrgwjkR81m5J7Aw/T55Tu7nUyZVQYCKEOs+2dkxEY+DpPtZzO4YruuiPb7NkYLVcyJC4+zCbk5pA==
dependencies:
regenerate "^1.4.2"
regenerate@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
@@ -11769,6 +11904,18 @@ regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4:
gopd "^1.2.0"
set-function-name "^2.0.2"
regexpu-core@^4.5.4:
version "4.8.0"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.8.0.tgz#e5605ba361b67b1718478501327502f4479a98f0"
integrity sha512-1F6bYsoYiz6is+oz70NWur2Vlh9KWtswuRuzJOfeYUrfPX2o8n74AnUVaOGDbUqVGO9fNHu48/pjJO4sNVwsOg==
dependencies:
regenerate "^1.4.2"
regenerate-unicode-properties "^9.0.0"
regjsgen "^0.5.2"
regjsparser "^0.7.0"
unicode-match-property-ecmascript "^2.0.0"
unicode-match-property-value-ecmascript "^2.0.0"
regexpu-core@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.2.0.tgz#0e5190d79e542bf294955dccabae04d3c7d53826"
@@ -11795,6 +11942,11 @@ registry-url@^6.0.0:
dependencies:
rc "1.2.8"
regjsgen@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
regjsgen@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.8.0.tgz#df23ff26e0c5b300a6470cad160a9d090c3a37ab"
@@ -11807,6 +11959,13 @@ regjsparser@^0.12.0:
dependencies:
jsesc "~3.0.2"
regjsparser@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.7.0.tgz#a6b667b54c885e18b52554cb4960ef71187e9968"
integrity sha512-A4pcaORqmNMDVwUjWoTzuhwMGpP+NykpfqAsEgI1FSH/EzC7lrN5TMd+kN8YCovX+jMpu8eaqXgXPCa0g8FQNQ==
dependencies:
jsesc "~0.5.0"
rehype-raw@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4"
@@ -12555,6 +12714,11 @@ source-map@^0.7.0, source-map@^0.7.4:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02"
integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==
sourcemap-codec@^1.4.8:
version "1.4.8"
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
space-separated-tokens@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f"
@@ -12814,6 +12978,26 @@ stylis@^4.3.4, stylis@^4.3.6:
resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.6.tgz#7c7b97191cb4f195f03ecab7d52f7902ed378320"
integrity sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==
sucrase@^3.35.0:
version "3.35.1"
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1"
integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==
dependencies:
"@jridgewell/gen-mapping" "^0.3.2"
commander "^4.0.0"
lines-and-columns "^1.1.6"
mz "^2.7.0"
pirates "^4.0.1"
tinyglobby "^0.2.11"
ts-interface-checker "^0.1.9"
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
dependencies:
has-flag "^3.0.0"
supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
@@ -12956,6 +13140,20 @@ terser@^5.10.0, terser@^5.15.1, terser@^5.31.1:
commander "^2.20.0"
source-map-support "~0.5.20"
thenify-all@^1.0.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
dependencies:
thenify ">= 3.1.0 < 4"
"thenify@>= 3.1.0 < 4":
version "3.3.1"
resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
dependencies:
any-promise "^1.0.0"
thingies@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/thingies/-/thingies-2.5.0.tgz#5f7b882c933b85989f8466b528a6247a6881e04f"
@@ -12996,7 +13194,7 @@ tinyexec@^1.0.1:
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.1.tgz#70c31ab7abbb4aea0a24f55d120e5990bfa1e0b1"
integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==
tinyglobby@^0.2.15:
tinyglobby@^0.2.11, tinyglobby@^0.2.15:
version "0.2.15"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==
@@ -13094,6 +13292,11 @@ ts-dedent@^2.0.0, ts-dedent@^2.2.0:
resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5"
integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
ts-interface-checker@^0.1.9:
version "0.1.13"
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
ts-loader@^9.5.4:
version "9.5.4"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.4.tgz#44b571165c10fb5a90744aa5b7e119233c4f4585"
@@ -13267,6 +13470,11 @@ unicode-match-property-ecmascript@^2.0.0:
unicode-canonical-property-names-ecmascript "^2.0.0"
unicode-property-aliases-ecmascript "^2.0.0"
unicode-match-property-value-ecmascript@^2.0.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz#65a7adfad8574c219890e219285ce4c64ed67eaa"
integrity sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==
unicode-match-property-value-ecmascript@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz#a0401aee72714598f739b68b104e4fe3a0cb3c71"
@@ -13495,6 +13703,11 @@ url-parse@^1.5.10:
querystringify "^2.1.1"
requires-port "^1.0.0"
use-editable@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/use-editable/-/use-editable-2.3.3.tgz#a292fe9ba4c291cd28d1cc2728c75a5fc8d9a33f"
integrity sha512-7wVD2JbfAFJ3DK0vITvXBdpd9JAz5BcKAAolsnLBuBn6UDDwBGuCIAGvR3yA2BNKm578vAMVHFCWaOcA+BhhiA==
use-sync-external-store@^1.4.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0"

View File

@@ -30,13 +30,22 @@ Usage:
session = get_session()
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from typing import Any, TYPE_CHECKING
from uuid import UUID
from flask_appbuilder import Model
from sqlalchemy.orm import scoped_session
if TYPE_CHECKING:
from superset_core.api.types import (
AsyncQueryHandle,
QueryOptions,
QueryResult,
)
class CoreModel(Model):
"""
@@ -75,6 +84,83 @@ class Database(CoreModel):
def data(self) -> dict[str, Any]:
raise NotImplementedError
def execute(
self,
sql: str,
options: QueryOptions | None = None,
) -> QueryResult:
"""
Execute SQL synchronously.
:param sql: SQL query to execute
:param options: Query execution options (see `QueryOptions`).
If not provided, defaults are used.
:returns: QueryResult with status, data (DataFrame), and metadata
Example:
from superset_core.api.daos import DatabaseDAO
from superset_core.api.types import QueryOptions, QueryStatus
db = DatabaseDAO.find_one_or_none(id=1)
result = db.execute(
"SELECT * FROM users WHERE active = true",
options=QueryOptions(schema="public", limit=100)
)
if result.status == QueryStatus.SUCCESS:
df = result.data
print(f"Found {sum(s.row_count for s in result.statements)} rows")
Example with templates:
result = db.execute(
"SELECT * FROM {{ table }} WHERE date > '{{ start_date }}'",
options=QueryOptions(
schema="analytics",
template_params={"table": "events", "start_date": "2024-01-01"}
)
)
Example with dry_run:
result = db.execute(
"SELECT * FROM users",
options=QueryOptions(schema="public", limit=100, dry_run=True)
)
print(f"Would execute: {result.statements[0].statement}")
"""
raise NotImplementedError("Method will be replaced during initialization")
def execute_async(
self,
sql: str,
options: QueryOptions | None = None,
) -> AsyncQueryHandle:
"""
Execute SQL asynchronously.
Returns immediately with a handle for tracking progress and retrieving
results from the background worker.
:param sql: SQL query to execute
:param options: Query execution options (see `QueryOptions`).
If not provided, defaults are used.
:returns: AsyncQueryHandle for tracking the query
Example:
handle = db.execute_async(
"SELECT * FROM large_table",
options=QueryOptions(schema="analytics")
)
# Check status and get results
status = handle.get_status()
if status == QueryStatus.SUCCESS:
query_result = handle.get_result()
df = query_result.statements[0].data
# Cancel if needed
handle.cancel()
"""
raise NotImplementedError("Method will be replaced during initialization")
class Dataset(CoreModel):
"""

View File

@@ -0,0 +1,177 @@
# 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.
"""
Query execution types for superset-core.
Provides type definitions for query execution that are partially aligned
with frontend types in superset-ui-core/src/query/types/.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
import pandas as pd
class QueryStatus(Enum):
"""
Status of query execution.
"""
PENDING = "pending"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
TIMED_OUT = "timed_out"
STOPPED = "stopped"
@dataclass
class CacheOptions:
"""
Options for query result caching.
"""
timeout: int | None = None # Override default cache timeout (seconds)
force_refresh: bool = False # Bypass cache and re-execute query
@dataclass
class QueryOptions:
"""
Options for query execution via Database.execute() and execute_async().
Supports customization of:
- Basic: catalog, schema, limit, timeout
- Templates: Jinja2 template parameters
- Caching: Cache timeout and refresh control
- Dry run: Return transformed SQL without execution
"""
# Basic options
catalog: str | None = None
schema: str | None = None
limit: int | None = None
timeout_seconds: int | None = None
# Template options
template_params: dict[str, Any] | None = None # For Jinja2 rendering
# Caching options
cache: CacheOptions | None = None
# Dry run option
dry_run: bool = False # Return transformed SQL without executing
@dataclass
class StatementResult:
"""
Result of a single SQL statement execution.
For SELECT queries: data contains DataFrame, row_count is len(data)
For DML queries: data is None, row_count contains affected rows
"""
original_sql: str # The SQL statement as submitted by the user
executed_sql: (
str # The SQL statement after transformations (RLS, mutations, limits)
)
data: pd.DataFrame | None = None
row_count: int = 0
execution_time_ms: float | None = None
@dataclass
class QueryResult:
"""
Result of a multi-statement query execution.
On success: statements contains all executed statements
On failure: statements contains successful statements before failure
Fields:
status: Overall query status (SUCCESS or FAILED)
statements: Results from each executed statement
query_id: Query model ID for entire execution (None if dry_run=True)
total_execution_time_ms: Total execution time across all statements
is_cached: Whether result came from cache
error_message: Query-level error (e.g., "Statement 2 of 3: error")
"""
status: QueryStatus
statements: list[StatementResult] = field(default_factory=list)
query_id: int | None = None
total_execution_time_ms: float | None = None
is_cached: bool = False
error_message: str | None = None
@dataclass
class AsyncQueryHandle:
"""
Handle for tracking an asynchronous query.
Provides methods to check status, retrieve results, and cancel the query.
The methods are bound to concrete implementations at runtime.
This is the return type of Database.execute_async().
"""
query_id: int | None # None for cached results
status: QueryStatus = field(default=QueryStatus.PENDING)
started_at: datetime | None = None
def get_status(self) -> QueryStatus:
"""
Get the current status of the async query.
:returns: Current QueryStatus
"""
raise NotImplementedError("Method will be replaced during initialization")
def get_result(self) -> QueryResult:
"""
Get the result of the async query.
:returns: QueryResult with data if successful
"""
raise NotImplementedError("Method will be replaced during initialization")
def cancel(self) -> bool:
"""
Cancel the async query.
:returns: True if cancellation was successful
"""
raise NotImplementedError("Method will be replaced during initialization")
__all__ = [
"QueryStatus",
"QueryOptions",
"QueryResult",
"StatementResult",
"AsyncQueryHandle",
"CacheOptions",
]

View File

@@ -1,6 +1,9 @@
coverage/*
cypress/screenshots
cypress/videos
playwright/.auth
playwright-report/
test-results/
src/temp
.temp_cache/
.tsbuildinfo

View File

@@ -20,6 +20,57 @@ import { dirname, join } from 'path';
// Superset's webpack.config.js
const customConfig = require('../webpack.config.js');
// Filter out plugins that shouldn't be included in Storybook's static build
// ReactRefreshWebpackPlugin adds Fast Refresh code that requires a dev server runtime,
// which isn't available when serving the static storybook build
const filteredPlugins = customConfig.plugins.filter(
plugin => plugin.constructor.name !== 'ReactRefreshWebpackPlugin',
);
// Deep clone and modify rules to disable React Fast Refresh and dev mode in SWC loader
// The Fast Refresh transform adds $RefreshSig$ calls that require a runtime
// which isn't present when serving the static build.
// Also disable development mode to use jsx instead of jsxDEV runtime.
const disableDevModeInRules = rules =>
rules.map(rule => {
if (!rule.use) return rule;
const newUse = (Array.isArray(rule.use) ? rule.use : [rule.use]).map(
loader => {
// Check if this is the swc-loader with react transform settings
if (
typeof loader === 'object' &&
loader.loader?.includes('swc-loader') &&
loader.options?.jsc?.transform?.react
) {
return {
...loader,
options: {
...loader.options,
jsc: {
...loader.options.jsc,
transform: {
...loader.options.jsc.transform,
react: {
...loader.options.jsc.transform.react,
refresh: false,
development: false,
},
},
},
},
};
}
return loader;
},
);
return {
...rule,
use: Array.isArray(rule.use) ? newUse : newUse[0],
};
});
module.exports = {
stories: [
'../src/@(components|common|filters|explore|views|dashboard|features)/**/*.stories.@(tsx|jsx)',
@@ -41,13 +92,19 @@ module.exports = {
...config,
module: {
...config.module,
rules: customConfig.module.rules,
rules: disableDevModeInRules(customConfig.module.rules),
},
resolve: {
...config.resolve,
...customConfig.resolve,
alias: {
...config.resolve?.alias,
...customConfig.resolve?.alias,
// Fix for Storybook 8.6.x with React 17 - resolve ESM module paths
'react-dom/test-utils': require.resolve('react-dom/test-utils'),
},
},
plugins: [...config.plugins, ...customConfig.plugins],
plugins: [...config.plugins, ...filteredPlugins],
}),
typescript: {

View File

@@ -17,8 +17,7 @@
* under the License.
*/
import { withJsx } from '@mihkeleidast/storybook-addon-source';
import { exampleThemes } from '@superset-ui/core';
import { themeObject, css } from '@apache-superset/core/ui';
import { themeObject, css, exampleThemes } from '@apache-superset/core/ui';
import { combineReducers, createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';

View File

@@ -0,0 +1,38 @@
/**
* 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 type { TestRunnerConfig } from '@storybook/test-runner';
/**
* Test runner configuration for Storybook smoke tests.
*
* The test-runner visits each story and verifies it renders without errors.
* These are basic smoke tests - they don't test interactions or assertions,
* just that stories can render successfully.
*/
const config: TestRunnerConfig = {
async preVisit(page) {
// Listen for page errors (JavaScript exceptions) and log them
// This helps identify stories that crash during rendering
page.on('pageerror', error => {
console.error(`[page error] ${error.message}`);
});
},
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@@ -79,6 +79,8 @@
"prod": "npm run build",
"prune": "rm -rf ./{packages,plugins}/*/{node_modules,lib,esm,tsconfig.tsbuildinfo,package-lock.json} ./.temp_cache",
"storybook": "cross-env NODE_ENV=development BABEL_ENV=development storybook dev -p 6006",
"test-storybook": "test-storybook",
"test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"npx http-server storybook-static --port 6006 --silent\" \"npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook -- --maxWorkers=2\"",
"tdd": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --watch",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80% --silent",
"test-loud": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=8192\" jest --max-workers=80%",
@@ -242,15 +244,17 @@
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.56.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.17",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
"@storybook/addon-links": "8.1.11",
"@storybook/addon-mdx-gfm": "8.1.11",
"@storybook/components": "8.1.11",
"@storybook/preview-api": "8.1.11",
"@storybook/react": "8.1.11",
"@storybook/react-webpack5": "8.1.11",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-controls": "8.6.14",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/components": "8.6.14",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/react-webpack5": "8.6.14",
"@storybook/test": "^8.6.14",
"@storybook/test-runner": "^0.17.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.14.0",
"@swc/plugin-emotion": "^12.0.0",
@@ -291,7 +295,9 @@
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-typescript-to-proptypes": "^2.0.0",
"baseline-browser-mapping": "^2.9.7",
"cheerio": "1.1.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^13.0.1",
"cross-env": "^10.0.0",
"css-loader": "^7.1.2",
@@ -321,6 +327,7 @@
"fork-ts-checker-webpack-plugin": "^9.1.0",
"history": "^5.3.0",
"html-webpack-plugin": "^5.6.4",
"http-server": "^14.1.1",
"imports-loader": "^5.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^29.7.0",
@@ -343,7 +350,7 @@
"source-map": "^0.7.4",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.5.0",
"storybook": "8.1.11",
"storybook": "8.6.14",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.6",
"terser-webpack-plugin": "^5.3.16",
@@ -354,6 +361,7 @@
"tsx": "^4.21.0",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.3",
"webpack": "^5.103.0",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",

View File

@@ -30,8 +30,17 @@ const bigText =
'purus convallis placerat in at nunc. Nulla nec viverra augue.';
export default {
title: 'Components/Alert',
title: 'Extension Components/Alert',
component: Alert,
parameters: {
docs: {
description: {
component:
'Alert component for displaying important messages to users. ' +
'Wraps Ant Design Alert with sensible defaults and improved accessibility.',
},
},
},
};
export const AlertGallery = () => (

View File

@@ -38,13 +38,120 @@ export const DesignSystem = () => (
</a>
While the Superset Design System will use Atomic Design principles, we choose a different language to describe the elements.
| Atomic Design | Atoms | Molecules | Organisms | Templates | Pages / Screens |
| :-------------- | :---------: | :--------: | :-------: | :-------: | :-------------: |
| Superset Design | Foundations | Components | Patterns | Templates | Features |
`}
</Markdown>
<table style={{ borderCollapse: 'collapse', margin: '16px 0' }}>
<thead>
<tr>
<th
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'left',
}}
>
Atomic Design
</th>
<th
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Atoms
</th>
<th
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Molecules
</th>
<th
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Organisms
</th>
<th
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Templates
</th>
<th
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Pages / Screens
</th>
</tr>
</thead>
<tbody>
<tr>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
Superset Design
</td>
<td
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Foundations
</td>
<td
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Components
</td>
<td
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Patterns
</td>
<td
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Templates
</td>
<td
style={{
border: '1px solid #ddd',
padding: '8px',
textAlign: 'center',
}}
>
Features
</td>
</tr>
</tbody>
</table>
<img
src={AtomicDesign}
alt="Atoms = Foundations, Molecules = Components, Organisms = Patterns, Templates = Templates, Pages / Screens = Features"

View File

@@ -66,7 +66,6 @@ import {
ExclamationCircleOutlined,
ExclamationCircleFilled,
ExpandOutlined,
ExperimentOutlined,
EyeOutlined,
EyeInvisibleOutlined,
FallOutlined,
@@ -77,7 +76,6 @@ import {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderOpenOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
@@ -206,7 +204,6 @@ const AntdIcons = {
ExclamationCircleOutlined,
ExclamationCircleFilled,
ExpandOutlined,
ExperimentOutlined,
EyeOutlined,
EyeInvisibleOutlined,
FacebookOutlined,
@@ -218,7 +215,6 @@ const AntdIcons = {
FileOutlined,
FileTextOutlined,
FireOutlined,
FolderOpenOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,

View File

@@ -16,80 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
import { action } from '@storybook/addon-actions';
import { Icons } from '@superset-ui/core/components/Icons';
import { Dropdown } from '../Dropdown';
import { FaveStar } from '../FaveStar';
import { ListViewCard } from '.';
export default {
title: 'Components/ListViewCard',
component: ListViewCard,
argTypes: {
loading: { control: 'boolean', defaultValue: false },
imgURL: {
control: 'text',
defaultValue:
'https://images.unsplash.com/photo-1658163724548-29ef00812a54?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2670&q=80',
},
imgFallbackURL: {
control: 'text',
defaultValue:
'https://images.unsplash.com/photo-1658208193219-e859d9771912?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2670&q=80',
},
isStarred: { control: 'boolean', defaultValue: false },
loading: { control: 'boolean' },
},
};
export const SupersetListViewCard = ({
loading,
imgURL,
imgFallbackURL,
isStarred,
loading = false,
}: {
loading: boolean;
imgURL: string;
imgFallbackURL: string;
isStarred: boolean;
loading?: boolean;
}) => (
<ListViewCard
title="Superset Card Title"
loading={loading}
url="/superset/dashboard/births/"
imgURL={imgURL}
imgFallbackURL={imgFallbackURL}
imgURL="https://images.unsplash.com/photo-1658163724548-29ef00812a54?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2670&q=80"
imgFallbackURL="https://images.unsplash.com/photo-1658208193219-e859d9771912?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2670&q=80"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit..."
coverLeft="Left Section"
coverRight="Right Section"
actions={
<ListViewCard.Actions>
<FaveStar
itemId={0}
fetchFaveStar={action('fetchFaveStar')}
saveFaveStar={action('saveFaveStar')}
isStarred={isStarred}
/>
<Dropdown
menu={{
items: [
{
key: 'delete',
label: 'Delete',
icon: <Icons.DeleteOutlined />,
onClick: action('Delete'),
},
{
key: 'edit',
label: 'Edit',
icon: <Icons.EditOutlined />,
onClick: action('Edit'),
},
],
}}
>
<Icons.EllipsisOutlined />
</Dropdown>
</ListViewCard.Actions>
}
/>
);

View File

@@ -43,8 +43,7 @@ type ExtractedExtra = ExtraFilterQueryField & {
export default function extractExtras(formData: QueryFormData): ExtractedExtra {
const applied_time_extras: AppliedTimeExtras = {};
const filters: QueryObjectFilterClause[] = [];
// Preserve existing extras from formData (e.g., what_if modifications)
const extras: QueryObjectExtras = { ...formData.extras };
const extras: QueryObjectExtras = {};
const extract: ExtractedExtra = {
filters,
extras,

View File

@@ -77,14 +77,6 @@ export type QueryObjectExtras = Partial<{
/** If true, WHERE/HAVING clauses need transpilation to target dialect */
transpile_to_dialect?: boolean;
/** What-if analysis: column value modifications */
what_if?: {
modifications: Array<{
column: string;
multiplier: number;
}>;
};
}>;
export type ResidualQueryObjectData = {

View File

@@ -32,7 +32,7 @@ export const buildTimeRangeString = (since: string, until: string): string =>
const formatDateEndpoint = (dttm: string, isStart?: boolean): string =>
dttm.replace('T00:00:00', '') || (isStart ? '-∞' : '∞');
export const formatTimeRangeLabel = (
export const formatTimeRange = (
timeRange: string,
columnPlaceholder = 'col',
) => {
@@ -86,7 +86,7 @@ export const fetchTimeRange = async (
response?.json?.result[0]?.until || '',
);
return {
value: formatTimeRangeLabel(timeRangeString, columnPlaceholder),
value: formatTimeRange(timeRangeString, columnPlaceholder),
};
}
const timeRanges = response?.json?.result.map((result: any) =>

View File

@@ -26,9 +26,5 @@ export {
getTimeOffset,
computeCustomDateTime,
} from './getTimeOffset';
export {
SEPARATOR,
fetchTimeRange,
formatTimeRangeLabel,
} from './fetchTimeRange';
export { SEPARATOR, fetchTimeRange } from './fetchTimeRange';
export { customTimeRangeDecode } from './customTimeRangeDecode';

View File

@@ -15,11 +15,24 @@ module.exports = {
...config,
module: {
...config.module,
rules: customConfig.module.rules,
rules: [
...customConfig.module.rules,
// Fix for Storybook 8 ESM issue with react-dom/test-utils
{
test: /\.m?js$/,
resolve: {
fullySpecified: false,
},
},
],
},
resolve: {
...config.resolve,
...customConfig.resolve,
alias: {
...config.resolve?.alias,
...customConfig.resolve?.alias,
},
},
}),

View File

@@ -36,11 +36,11 @@
"@emotion/styled": "^11.14.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@react-icons/all-files": "^4.1.0",
"@storybook/addon-actions": "9.0.8",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-links": "8.1.11",
"@storybook/react": "8.1.11",
"@storybook/types": "8.4.7",
"@storybook/addon-actions": "8.6.14",
"@storybook/addon-controls": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/types": "8.6.14",
"@types/react-loadable": "^5.5.11",
"core-js": "3.40.0",
"gh-pages": "^6.3.0",
@@ -56,7 +56,7 @@
"@babel/preset-env": "^7.28.5",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.23.3",
"@storybook/react-webpack5": "8.2.9",
"@storybook/react-webpack5": "8.6.14",
"babel-loader": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"ts-loader": "^9.5.2",

View File

@@ -21,11 +21,10 @@ import {
Menu,
Button,
Card,
Alert,
Input,
Table,
Space,
} from '@superset-ui/core/components';
import { Alert, Table } from 'antd';
// eslint-disable-next-line import/no-extraneous-dependencies
import { Icons } from '@superset-ui/core/components/Icons';

View File

@@ -33,6 +33,9 @@ export default defineConfig({
? undefined
: '**/experimental/**',
// Global setup - authenticate once before all tests
globalSetup: './playwright/global-setup.ts',
// Timeout settings
timeout: 30000,
expect: { timeout: 8000 },
@@ -60,7 +63,11 @@ export default defineConfig({
// Global test setup
use: {
// Use environment variable for base URL in CI, default to localhost:8088 for local
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088',
// Normalize to always end with '/' to prevent URL resolution issues with APP_PREFIX
baseURL: (() => {
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
return url.endsWith('/') ? url : `${url}/`;
})(),
// Browser settings
headless: !!process.env.CI,
@@ -77,10 +84,32 @@ export default defineConfig({
projects: [
{
// Default project - uses global authentication for speed
// E2E tests login once via global-setup.ts and reuse auth state
// Explicitly ignore auth tests (they run in chromium-unauth project)
// Also respect the global experimental testIgnore setting
name: 'chromium',
testIgnore: [
'**/tests/auth/**/*.spec.ts',
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
],
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
// Reuse authentication state from global setup (fast E2E tests)
storageState: 'playwright/.auth/user.json',
},
},
{
// Separate project for unauthenticated tests (login, signup, etc.)
// These tests use beforeEach for per-test navigation - no global auth
// This hybrid approach: simple auth tests, fast E2E tests
name: 'chromium-unauth',
testMatch: '**/tests/auth/**/*.spec.ts',
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
// No storageState = clean browser with no cached cookies
},
},
],

View File

@@ -0,0 +1,118 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Base Modal component for Ant Design modals.
* Provides minimal primitives - extend this for specific modal types.
* Add methods to this class only when multiple modal types need them (YAGNI).
*
* @example
* class DeleteConfirmationModal extends Modal {
* async clickDelete(): Promise<void> {
* await this.footer.locator('button', { hasText: 'Delete' }).click();
* }
* }
*/
export class Modal {
protected readonly page: Page;
protected readonly modalSelector: string;
// Ant Design modal structure selectors (shared by all modal types)
protected static readonly BASE_SELECTORS = {
FOOTER: '.ant-modal-footer',
BODY: '.ant-modal-body',
};
constructor(page: Page, modalSelector = '[role="dialog"]') {
this.page = page;
this.modalSelector = modalSelector;
}
/**
* Gets the modal element locator
*/
get element(): Locator {
return this.page.locator(this.modalSelector);
}
/**
* Gets the modal footer locator (contains action buttons)
*/
get footer(): Locator {
return this.element.locator(Modal.BASE_SELECTORS.FOOTER);
}
/**
* Gets the modal body locator (contains content)
*/
get body(): Locator {
return this.element.locator(Modal.BASE_SELECTORS.BODY);
}
/**
* Gets a footer button by text content (private helper)
* @param buttonText - The text content of the button
*/
private getFooterButton(buttonText: string): Locator {
return this.footer.getByRole('button', { name: buttonText, exact: true });
}
/**
* Clicks a footer button by text content
* @param buttonText - The text content of the button to click
* @param options - Optional click options
*/
protected async clickFooterButton(
buttonText: string,
options?: { timeout?: number; force?: boolean; delay?: number },
): Promise<void> {
await this.getFooterButton(buttonText).click(options);
}
/**
* Waits for the modal to become visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'visible', ...options });
}
/**
* Waits for the modal to be fully ready for interaction.
* This includes waiting for the modal dialog to be visible AND for React to finish
* rendering the modal content. Use this before interacting with modal elements
* to avoid race conditions with React state updates.
*
* @param options - Optional wait options
*/
async waitForReady(options?: { timeout?: number }): Promise<void> {
await this.waitForVisible(options);
await this.body.waitFor({ state: 'visible', ...options });
}
/**
* Waits for the modal to be hidden
* @param options - Optional wait options
*/
async waitForHidden(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'hidden', ...options });
}
}

View File

@@ -0,0 +1,102 @@
/**
* 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 { Locator, Page } from '@playwright/test';
/**
* Table component for Superset ListView tables.
*/
export class Table {
private readonly page: Page;
private readonly tableSelector: string;
private static readonly SELECTORS = {
TABLE_ROW: '[data-test="table-row"]',
};
constructor(page: Page, tableSelector = '[data-test="listview-table"]') {
this.page = page;
this.tableSelector = tableSelector;
}
/**
* Gets the table element locator
*/
get element(): Locator {
return this.page.locator(this.tableSelector);
}
/**
* Gets a table row by exact text match in the first cell (dataset name column).
* Uses exact match to avoid substring collisions (e.g., 'members_channels_2' vs 'duplicate_members_channels_2_123').
*
* Note: Returns a Locator that will auto-wait when used in assertions or actions.
* If row doesn't exist, operations on the locator will timeout with clear error.
*
* @param rowText - Exact text to find in the row's first cell
* @returns Locator for the matching row
*/
getRow(rowText: string): Locator {
return this.element.locator(Table.SELECTORS.TABLE_ROW).filter({
has: this.page.getByRole('cell', { name: rowText, exact: true }),
});
}
/**
* Clicks a link within a specific row
* @param rowText - Text to identify the row
* @param linkSelector - Selector for the link within the row
*/
async clickRowLink(rowText: string, linkSelector: string): Promise<void> {
const row = this.getRow(rowText);
await row.locator(linkSelector).click();
}
/**
* Waits for the table to be visible
* @param options - Optional wait options
*/
async waitForVisible(options?: { timeout?: number }): Promise<void> {
await this.element.waitFor({ state: 'visible', ...options });
}
/**
* Clicks an action button in a row by selector
* @param rowText - Text to identify the row
* @param selector - CSS selector for the action element
*/
async clickRowAction(rowText: string, selector: string): Promise<void> {
const row = this.getRow(rowText);
const actionButton = row.locator(selector);
const count = await actionButton.count();
if (count === 0) {
throw new Error(
`No action button found with selector "${selector}" in row "${rowText}"`,
);
}
if (count > 1) {
throw new Error(
`Multiple action buttons (${count}) found with selector "${selector}" in row "${rowText}". Use more specific selector.`,
);
}
await actionButton.click();
}
}

View File

@@ -0,0 +1,105 @@
/**
* 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 { Page, Locator } from '@playwright/test';
export type ToastType = 'success' | 'danger' | 'warning' | 'info';
const SELECTORS = {
CONTAINER: '[data-test="toast-container"][role="alert"]',
CONTENT: '.toast__content',
CLOSE_BUTTON: '[data-test="close-button"]',
} as const;
/**
* Toast notification component
* Handles success, danger, warning, and info toasts
*/
export class Toast {
private page: Page;
private container: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.locator(SELECTORS.CONTAINER);
}
/**
* Get the toast container locator
*/
get(): Locator {
return this.container;
}
/**
* Get the toast message text
*/
getMessage(): Locator {
return this.container.locator(SELECTORS.CONTENT);
}
/**
* Wait for a toast to appear
*/
async waitForVisible(): Promise<void> {
await this.container.waitFor({ state: 'visible' });
}
/**
* Wait for toast to disappear
*/
async waitForHidden(): Promise<void> {
await this.container.waitFor({ state: 'hidden' });
}
/**
* Get a success toast
*/
getSuccess(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--success`);
}
/**
* Get a danger/error toast
*/
getDanger(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--danger`);
}
/**
* Get a warning toast
*/
getWarning(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--warning`);
}
/**
* Get an info toast
*/
getInfo(): Locator {
return this.page.locator(`${SELECTORS.CONTAINER}.toast--info`);
}
/**
* Close the toast by clicking the close button
*/
async close(): Promise<void> {
await this.container.locator(SELECTORS.CLOSE_BUTTON).click();
}
}

View File

@@ -21,3 +21,5 @@
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';
export { Modal } from './Modal';
export { Table } from './Table';

View File

@@ -0,0 +1,75 @@
/**
* 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 { Modal, Input } from '../core';
/**
* Delete confirmation modal that requires typing "DELETE" to confirm.
* Used throughout Superset for destructive delete operations.
*
* Provides primitives for tests to compose deletion flows.
*/
export class DeleteConfirmationModal extends Modal {
private static readonly SELECTORS = {
CONFIRMATION_INPUT: 'input[type="text"]',
};
/**
* Gets the confirmation input component
*/
private get confirmationInput(): Input {
return new Input(
this.page,
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
);
}
/**
* Fills the confirmation input with the specified text.
*
* @param confirmationText - The text to type
* @param options - Optional fill options (timeout, force)
*
* @example
* const deleteModal = new DeleteConfirmationModal(page);
* await deleteModal.waitForVisible();
* await deleteModal.fillConfirmationInput('DELETE');
* await deleteModal.clickDelete();
* await deleteModal.waitForHidden();
*/
async fillConfirmationInput(
confirmationText: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.confirmationInput.fill(confirmationText, options);
}
/**
* Clicks the Delete button in the footer
*
* @param options - Optional click options (timeout, force, delay)
*/
async clickDelete(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Delete', options);
}
}

View File

@@ -0,0 +1,73 @@
/**
* 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 { Modal, Input } from '../core';
/**
* Duplicate dataset modal that requires entering a new dataset name.
* Used for duplicating virtual datasets with custom SQL.
*/
export class DuplicateDatasetModal extends Modal {
private static readonly SELECTORS = {
NAME_INPUT: '[data-test="duplicate-modal-input"]',
};
/**
* Gets the new dataset name input component
*/
private get nameInput(): Input {
return new Input(
this.page,
this.body.locator(DuplicateDatasetModal.SELECTORS.NAME_INPUT),
);
}
/**
* Fills the new dataset name input
*
* @param datasetName - The new name for the duplicated dataset
* @param options - Optional fill options (timeout, force)
*
* @example
* const duplicateModal = new DuplicateDatasetModal(page);
* await duplicateModal.waitForVisible();
* await duplicateModal.fillDatasetName('my_dataset_copy');
* await duplicateModal.clickDuplicate();
* await duplicateModal.waitForHidden();
*/
async fillDatasetName(
datasetName: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.nameInput.fill(datasetName, options);
}
/**
* Clicks the Duplicate button in the footer
*
* @param options - Optional click options (timeout, force, delay)
*/
async clickDuplicate(options?: {
timeout?: number;
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Duplicate', options);
}
}

View File

@@ -0,0 +1,22 @@
/**
* 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.
*/
// Specific modal implementations
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';

View File

@@ -0,0 +1,93 @@
/**
* 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 {
chromium,
FullConfig,
Browser,
BrowserContext,
} from '@playwright/test';
import { mkdir } from 'fs/promises';
import { dirname } from 'path';
import { AuthPage } from './pages/AuthPage';
import { TIMEOUT } from './utils/constants';
/**
* Global setup function that runs once before all tests.
* Authenticates as admin user and saves the authentication state
* to be reused by tests in the 'chromium' project (E2E tests).
*
* Auth tests (chromium-unauth project) don't use this - they login
* per-test via beforeEach for isolation and simplicity.
*/
async function globalSetup(config: FullConfig) {
// Get baseURL with fallback to default
// FullConfig.use doesn't exist in the type - baseURL is only in projects[0].use
const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:8088';
// Test credentials - can be overridden via environment variables
const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin';
const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general';
console.log('[Global Setup] Authenticating as admin user...');
let browser: Browser | null = null;
let context: BrowserContext | null = null;
try {
// Launch browser
browser = await chromium.launch();
} catch (error) {
console.error('[Global Setup] Failed to launch browser:', error);
throw new Error('Browser launch failed - check Playwright installation');
}
try {
context = await browser.newContext({ baseURL });
const page = await context.newPage();
// Use AuthPage to handle login logic (DRY principle)
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
await authPage.loginWithCredentials(adminUsername, adminPassword);
// Use longer timeout for global setup (cold CI starts may exceed PAGE_LOAD timeout)
await authPage.waitForLoginSuccess({ timeout: TIMEOUT.GLOBAL_SETUP });
// Save authentication state for all tests to reuse
const authStatePath = 'playwright/.auth/user.json';
await mkdir(dirname(authStatePath), { recursive: true });
await context.storageState({
path: authStatePath,
});
console.log(
'[Global Setup] Authentication successful - state saved to playwright/.auth/user.json',
);
} catch (error) {
console.error('[Global Setup] Authentication failed:', error);
throw error;
} finally {
// Ensure cleanup even if auth fails
if (context) await context.close();
if (browser) await browser.close();
}
}
export default globalSetup;

View File

@@ -0,0 +1,79 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Page, APIResponse } from '@playwright/test';
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
const ENDPOINTS = {
DATABASE: 'api/v1/database/',
} as const;
/**
* TypeScript interface for database creation API payload
* Provides compile-time safety for required fields
*/
export interface DatabaseCreatePayload {
database_name: string;
engine: string;
configuration_method?: string;
engine_information?: {
disable_ssh_tunneling?: boolean;
supports_dynamic_catalog?: boolean;
supports_file_upload?: boolean;
supports_oauth2?: boolean;
};
driver?: string;
sqlalchemy_uri_placeholder?: string;
extra?: string;
expose_in_sqllab?: boolean;
catalog?: Array<{ name: string; value: string }>;
parameters?: {
service_account_info?: string;
catalog?: Record<string, string>;
};
masked_encrypted_extra?: string;
impersonate_user?: boolean;
}
/**
* POST request to create a database connection
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Database configuration object with type safety
* @returns API response from database creation
*/
export async function apiPostDatabase(
page: Page,
requestBody: DatabaseCreatePayload,
): Promise<APIResponse> {
return apiPost(page, ENDPOINTS.DATABASE, requestBody);
}
/**
* DELETE request to remove a database connection
* @param page - Playwright page instance (provides authentication context)
* @param databaseId - ID of the database to delete
* @returns API response from database deletion
*/
export async function apiDeleteDatabase(
page: Page,
databaseId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
}

View File

@@ -0,0 +1,133 @@
/**
* 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 { Page, APIResponse } from '@playwright/test';
import rison from 'rison';
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
export const ENDPOINTS = {
DATASET: 'api/v1/dataset/',
} as const;
/**
* TypeScript interface for dataset creation API payload
* Provides compile-time safety for required fields
*/
export interface DatasetCreatePayload {
database: number;
catalog: string | null;
schema: string;
table_name: string;
}
/**
* TypeScript interface for dataset API response
* Represents the shape of dataset data returned from the API
*/
export interface DatasetResult {
id: number;
table_name: string;
sql?: string;
schema?: string;
database: {
id: number;
database_name: string;
};
owners?: Array<{ id: number }>;
dataset_type?: 'physical' | 'virtual';
}
/**
* POST request to create a dataset
* @param page - Playwright page instance (provides authentication context)
* @param requestBody - Dataset configuration object (database, schema, table_name)
* @returns API response from dataset creation
*/
export async function apiPostDataset(
page: Page,
requestBody: DatasetCreatePayload,
): Promise<APIResponse> {
return apiPost(page, ENDPOINTS.DATASET, requestBody);
}
/**
* Get a dataset by its table name
* @param page - Playwright page instance (provides authentication context)
* @param tableName - The table_name to search for
* @returns Dataset object if found, null if not found
*/
export async function getDatasetByName(
page: Page,
tableName: string,
): Promise<DatasetResult | null> {
// Use Superset's filter API to search by table_name
const filter = {
filters: [
{
col: 'table_name',
opr: 'eq',
value: tableName,
},
],
};
const queryParam = rison.encode(filter);
// Use failOnStatusCode: false so we return null instead of throwing on errors
const response = await apiGet(page, `${ENDPOINTS.DATASET}?q=${queryParam}`, {
failOnStatusCode: false,
});
if (!response.ok()) {
return null;
}
const body = await response.json();
if (body.result && body.result.length > 0) {
return body.result[0] as DatasetResult;
}
return null;
}
/**
* GET request to fetch a dataset's details
* @param page - Playwright page instance (provides authentication context)
* @param datasetId - ID of the dataset to fetch
* @returns API response with dataset details
*/
export async function apiGetDataset(
page: Page,
datasetId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiGet(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}
/**
* DELETE request to remove a dataset
* @param page - Playwright page instance (provides authentication context)
* @param datasetId - ID of the dataset to delete
* @returns API response from dataset deletion
*/
export async function apiDeleteDataset(
page: Page,
datasetId: number,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
}

View File

@@ -0,0 +1,193 @@
/**
* 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 { Page, APIResponse } from '@playwright/test';
export interface ApiRequestOptions {
headers?: Record<string, string>;
params?: Record<string, string>;
failOnStatusCode?: boolean;
allowMissingCsrf?: boolean;
}
/**
* Get base URL for Referer header
* Reads from environment variable configured in playwright.config.ts
* Preserves full base URL including path prefix (e.g., /app/prefix/)
* Normalizes to always end with '/' for consistent URL resolution
*/
function getBaseUrl(): string {
// Use environment variable which includes path prefix if configured
// Normalize to always end with '/' (matches playwright.config.ts normalization)
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
return url.endsWith('/') ? url : `${url}/`;
}
interface CsrfResult {
token: string;
error?: string;
}
/**
* Get CSRF token from the API endpoint
* Superset provides a CSRF token via api/v1/security/csrf_token/
* The session cookie is automatically included by page.request
*/
async function getCsrfToken(page: Page): Promise<CsrfResult> {
try {
const response = await page.request.get('api/v1/security/csrf_token/', {
failOnStatusCode: false,
});
if (!response.ok()) {
return {
token: '',
error: `HTTP ${response.status()} ${response.statusText()}`,
};
}
const json = await response.json();
return { token: json.result || '' };
} catch (error) {
return { token: '', error: String(error) };
}
}
/**
* Build headers for mutation requests (POST, PUT, PATCH, DELETE)
* Includes CSRF token and Referer for Flask-WTF CSRFProtect
*/
async function buildHeaders(
page: Page,
options?: ApiRequestOptions,
): Promise<Record<string, string>> {
const { token: csrfToken, error: csrfError } = await getCsrfToken(page);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options?.headers,
};
// Include CSRF token and Referer for Flask-WTF CSRFProtect
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
headers['Referer'] = getBaseUrl();
} else if (!options?.allowMissingCsrf) {
const errorDetail = csrfError ? ` (${csrfError})` : '';
throw new Error(
`Missing CSRF token${errorDetail} - mutation requests require authentication. ` +
'Ensure global authentication completed or test has valid session.',
);
}
return headers;
}
/**
* Send a GET request
* Uses page.request to automatically include browser authentication
*/
export async function apiGet(
page: Page,
url: string,
options?: ApiRequestOptions,
): Promise<APIResponse> {
return page.request.get(url, {
headers: options?.headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a POST request
* Uses page.request to automatically include browser authentication
*/
export async function apiPost(
page: Page,
url: string,
data?: unknown,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.post(url, {
data,
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a PUT request
* Uses page.request to automatically include browser authentication
*/
export async function apiPut(
page: Page,
url: string,
data?: unknown,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.put(url, {
data,
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a PATCH request
* Uses page.request to automatically include browser authentication
*/
export async function apiPatch(
page: Page,
url: string,
data?: unknown,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.patch(url, {
data,
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}
/**
* Send a DELETE request
* Uses page.request to automatically include browser authentication
*/
export async function apiDelete(
page: Page,
url: string,
options?: ApiRequestOptions,
): Promise<APIResponse> {
const headers = await buildHeaders(page, options);
return page.request.delete(url, {
headers,
params: options?.params,
failOnStatusCode: options?.failOnStatusCode ?? true,
});
}

View File

@@ -17,9 +17,10 @@
* under the License.
*/
import { Page, Response } from '@playwright/test';
import { Page, Response, Cookie } from '@playwright/test';
import { Form } from '../components/core';
import { URL } from '../utils/urls';
import { TIMEOUT } from '../utils/constants';
export class AuthPage {
private readonly page: Page;
@@ -56,7 +57,7 @@ export class AuthPage {
* Wait for login form to be visible
*/
async waitForLoginForm(): Promise<void> {
await this.loginForm.waitForVisible({ timeout: 5000 });
await this.loginForm.waitForVisible({ timeout: TIMEOUT.FORM_LOAD });
}
/**
@@ -83,6 +84,67 @@ export class AuthPage {
await loginButton.click();
}
/**
* Wait for successful login by verifying the login response and session cookie.
* Call this after loginWithCredentials to ensure authentication completed.
*
* This does NOT assume a specific landing page (which is configurable).
* Instead it:
* 1. Checks if session cookie already exists (guards against race condition)
* 2. Waits for POST /login/ response with redirect status
* 3. Polls for session cookie to appear
*
* @param options - Optional wait options
*/
async waitForLoginSuccess(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
const startTime = Date.now();
// 1. Guard: Check if session cookie already exists (race condition protection)
const existingCookie = await this.getSessionCookie();
if (existingCookie?.value) {
// Already authenticated - login completed before we started waiting
return;
}
// 2. Wait for POST /login/ response (bounded by caller's timeout)
const loginResponse = await this.page.waitForResponse(
response =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
{ timeout },
);
// 3. Verify it's a redirect (3xx status code indicates successful login)
const status = loginResponse.status();
if (status < 300 || status >= 400) {
throw new Error(`Login failed: expected redirect (3xx), got ${status}`);
}
// 4. Poll for session cookie to appear (HttpOnly cookie, not accessible via document.cookie)
// Use page.context().cookies() since session cookie is HttpOnly
const pollInterval = 500; // 500ms instead of 100ms for less chattiness
while (true) {
const remaining = timeout - (Date.now() - startTime);
if (remaining <= 0) {
break; // Timeout exceeded
}
const sessionCookie = await this.getSessionCookie();
if (sessionCookie && sessionCookie.value) {
// Success - session cookie has landed
return;
}
await this.page.waitForTimeout(Math.min(pollInterval, remaining));
}
const currentUrl = await this.page.url();
throw new Error(
`Login timeout: session cookie did not appear within ${timeout}ms. Current URL: ${currentUrl}`,
);
}
/**
* Get current page URL
*/
@@ -93,9 +155,9 @@ export class AuthPage {
/**
* Get the session cookie specifically
*/
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
async getSessionCookie(): Promise<Cookie | null> {
const cookies = await this.page.context().cookies();
return cookies.find((c: any) => c.name === 'session') || null;
return cookies.find(c => c.name === 'session') || null;
}
/**
@@ -106,7 +168,7 @@ export class AuthPage {
selector => this.page.locator(selector).isVisible(),
);
const visibilityResults = await Promise.all(visibilityPromises);
return visibilityResults.some((isVisible: any) => isVisible);
return visibilityResults.some(isVisible => isVisible);
}
/**
@@ -114,7 +176,7 @@ export class AuthPage {
*/
async waitForLoginRequest(): Promise<Response> {
return this.page.waitForResponse(
(response: any) =>
response =>
response.url().includes('/login/') &&
response.request().method() === 'POST',
);

View File

@@ -0,0 +1,115 @@
/**
* 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 { Page, Locator } from '@playwright/test';
import { Table } from '../components/core';
import { URL } from '../utils/urls';
/**
* Dataset List Page object.
*/
export class DatasetListPage {
private readonly page: Page;
private readonly table: Table;
private static readonly SELECTORS = {
DATASET_LINK: '[data-test="internal-link"]',
DELETE_ACTION: '.action-button svg[data-icon="delete"]',
EXPORT_ACTION: '.action-button svg[data-icon="upload"]',
DUPLICATE_ACTION: '.action-button svg[data-icon="copy"]',
} as const;
constructor(page: Page) {
this.page = page;
this.table = new Table(page);
}
/**
* Navigate to the dataset list page
*/
async goto(): Promise<void> {
await this.page.goto(URL.DATASET_LIST);
}
/**
* Wait for the table to load
* @param options - Optional wait options
*/
async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
await this.table.waitForVisible(options);
}
/**
* Gets a dataset row locator by name.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @param datasetName - The name of the dataset
* @returns Locator for the dataset row
*
* @example
* await expect(datasetListPage.getDatasetRow('birth_names')).toBeVisible();
*/
getDatasetRow(datasetName: string): Locator {
return this.table.getRow(datasetName);
}
/**
* Clicks on a dataset name to navigate to Explore
* @param datasetName - The name of the dataset to click
*/
async clickDatasetName(datasetName: string): Promise<void> {
await this.table.clickRowLink(
datasetName,
DatasetListPage.SELECTORS.DATASET_LINK,
);
}
/**
* Clicks the delete action button for a dataset
* @param datasetName - The name of the dataset to delete
*/
async clickDeleteAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DELETE_ACTION,
);
}
/**
* Clicks the export action button for a dataset
* @param datasetName - The name of the dataset to export
*/
async clickExportAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.EXPORT_ACTION,
);
}
/**
* Clicks the duplicate action button for a dataset (virtual datasets only)
* @param datasetName - The name of the dataset to duplicate
*/
async clickDuplicateAction(datasetName: string): Promise<void> {
await this.table.clickRowAction(
datasetName,
DatasetListPage.SELECTORS.DUPLICATE_ACTION,
);
}
}

View File

@@ -0,0 +1,88 @@
/**
* 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 { Page, Locator } from '@playwright/test';
import { TIMEOUT } from '../utils/constants';
/**
* Explore Page object
*/
export class ExplorePage {
private readonly page: Page;
private static readonly SELECTORS = {
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Waits for the Explore page to load.
* Validates URL contains /explore/ and datasource control is visible.
*
* @param options - Optional wait options
*/
async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
await this.page.waitForURL('**/explore/**', { timeout });
await this.page.waitForSelector(ExplorePage.SELECTORS.DATASOURCE_CONTROL, {
state: 'visible',
timeout,
});
}
/**
* Gets the datasource control locator.
* Returns a Locator that tests can use with expect() or to read text.
*
* @returns Locator for the datasource control
*
* @example
* const name = await explorePage.getDatasourceControl().textContent();
*/
getDatasourceControl(): Locator {
return this.page.locator(ExplorePage.SELECTORS.DATASOURCE_CONTROL);
}
/**
* Gets the currently selected dataset name from the datasource control
*/
async getDatasetName(): Promise<string> {
const text = await this.getDatasourceControl().textContent();
return text?.trim() || '';
}
/**
* Gets the visualization switcher locator.
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
*
* @returns Locator for the viz switcher
*
* @example
* await expect(explorePage.getVizSwitcher()).toBeVisible();
*/
getVizSwitcher(): Locator {
return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
}
}

View File

@@ -20,69 +20,74 @@
import { test, expect } from '@playwright/test';
import { AuthPage } from '../../pages/AuthPage';
import { URL } from '../../utils/urls';
import { TIMEOUT } from '../../utils/constants';
test.describe('Login view', () => {
let authPage: AuthPage;
// Test credentials - can be overridden via environment variables
const adminUsername = process.env.PLAYWRIGHT_ADMIN_USERNAME || 'admin';
const adminPassword = process.env.PLAYWRIGHT_ADMIN_PASSWORD || 'general';
test.beforeEach(async ({ page }: any) => {
authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
/**
* Auth/login tests use per-test navigation via beforeEach.
* Each test starts fresh on the login page without global authentication.
* This follows the Cypress pattern for auth testing - simple and isolated.
*/
test('should redirect to login with incorrect username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
let authPage: AuthPage;
// Attempt login with incorrect credentials
await authPage.loginWithCredentials('admin', 'wrongpassword');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Failed login returns 401 Unauthorized or 302 redirect to login
expect([401, 302]).toContain(loginResponse.status());
// Wait for redirect to complete before checking URL
await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
timeout: 10000,
});
// Verify we stay on login page
const currentUrl = await authPage.getCurrentUrl();
expect(currentUrl).toContain(URL.LOGIN);
// Verify error message is shown
const hasError = await authPage.hasLoginError();
expect(hasError).toBe(true);
});
test('should login with correct username and password', async ({
page,
}: any) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Login with correct credentials
await authPage.loginWithCredentials('admin', 'general');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Successful login returns 302 redirect
expect(loginResponse.status()).toBe(302);
// Wait for successful redirect to welcome page
await page.waitForURL(
(url: any) => url.pathname.endsWith('superset/welcome/'),
{
timeout: 10000,
},
);
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});
test.beforeEach(async ({ page }) => {
// Navigate to login page before each test (ensures clean state)
authPage = new AuthPage(page);
await authPage.goto();
await authPage.waitForLoginForm();
});
test('should redirect to login with incorrect username and password', async ({
page,
}) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Attempt login with incorrect credentials (both username and password invalid)
await authPage.loginWithCredentials('wronguser', 'wrongpassword');
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Failed login returns 401 Unauthorized or 302 redirect to login
expect([401, 302]).toContain(loginResponse.status());
// Wait for redirect to complete before checking URL
await page.waitForURL(url => url.pathname.endsWith(URL.LOGIN), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Verify we stay on login page
const currentUrl = await authPage.getCurrentUrl();
expect(currentUrl).toContain(URL.LOGIN);
// Verify error message is shown
const hasError = await authPage.hasLoginError();
expect(hasError).toBe(true);
});
test('should login with correct username and password', async ({ page }) => {
// Setup request interception before login attempt
const loginRequestPromise = authPage.waitForLoginRequest();
// Login with correct credentials
await authPage.loginWithCredentials(adminUsername, adminPassword);
// Wait for login request and verify response
const loginResponse = await loginRequestPromise;
// Successful login returns 302 redirect
expect(loginResponse.status()).toBe(302);
// Wait for successful redirect to welcome page
await page.waitForURL(url => url.pathname.endsWith(URL.WELCOME), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Verify specific session cookie exists
const sessionCookie = await authPage.getSessionCookie();
expect(sessionCookie).not.toBeNull();
expect(sessionCookie?.value).toBeTruthy();
});

View File

@@ -19,52 +19,98 @@ under the License.
# Experimental Playwright Tests
This directory contains Playwright tests that are still under development or validation.
## Purpose
Tests in this directory run in "shadow mode" with `continue-on-error: true` in CI:
- Failures do NOT block PR merges
- Allows tests to run in CI to validate stability before promotion
- Provides visibility into test reliability over time
This directory contains **experimental** Playwright E2E tests that are being developed and stabilized before becoming part of the required test suite.
## Promoting Tests to Stable
## How Experimental Tests Work
Once a test has proven stable (no false positives/negatives over sufficient time):
1. Move the test file out of `experimental/` to the appropriate feature directory:
```bash
# From the repository root:
git mv superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \
superset-frontend/playwright/tests/dashboard/
# Or from the superset-frontend/ directory:
git mv playwright/tests/experimental/dashboard/test.spec.ts \
playwright/tests/dashboard/
```
2. The test will automatically become required for merge
## Test Organization
Organize tests by feature area:
- `auth/` - Authentication and authorization tests
- `dashboard/` - Dashboard functionality tests
- `explore/` - Chart builder tests
- `sqllab/` - SQL Lab tests
- etc.
## Running Tests
### Running Tests
**By default (CI and local), experimental tests are EXCLUDED:**
```bash
# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var)
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/
# Run specific experimental test
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/dashboard/test.spec.ts
# Run in UI mode for debugging
INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/
npm run playwright:test
# Only runs stable tests (tests/auth/*)
```
**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required because experimental tests are filtered out by default in `playwright.config.ts`. Without it, Playwright will report "No tests found".
**To include experimental tests, set the environment variable:**
```bash
INCLUDE_EXPERIMENTAL=true npm run playwright:test
# Runs all tests including experimental/
```
### CI Behavior
- **Required CI jobs**: Experimental tests are excluded by default
- Tests in `experimental/` do NOT block merges
- Failures in `experimental/` do NOT fail the build
- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/`
- Set `INCLUDE_EXPERIMENTAL=true` in the job environment to include experimental tests
- These jobs can use `continue-on-error: true` for shadow mode
### Configuration
The experimental pattern is configured in `playwright.config.ts`:
```typescript
testIgnore: process.env.INCLUDE_EXPERIMENTAL
? undefined
: '**/experimental/**',
```
This ensures:
- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
## When to Use Experimental
Add tests to `experimental/` when:
1. **Testing new infrastructure** - New page objects, components, or patterns that need real-world validation
2. **Flaky tests** - Tests that pass locally but have intermittent CI failures that need investigation
3. **New test types** - E2E tests for new features that need to prove stability before becoming required
4. **Prototyping** - Experimental approaches that may or may not become standard patterns
## Moving Tests to Stable
Once an experimental test has proven stable (consistent CI passes over time):
1. **Move the test file** from `experimental/` to the appropriate stable directory:
```bash
git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts
```
2. **Commit the move** with a clear message:
```bash
git commit -m "test(playwright): promote my-test from experimental to stable"
```
3. **Test will now be required** - It will run by default and block merges on failure
## Current Experimental Tests
### Dataset Tests
- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests
- Status: Infrastructure complete, validating stability
- Includes: Delete dataset test with API-based test data
- Supporting infrastructure: API helpers, Modal components, page objects
## Infrastructure Location
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
✅ **Correct locations:**
- `playwright/components/` - Components used by any tests
- `playwright/pages/` - Page objects for any features
- `playwright/helpers/api/` - API helpers for test data setup
❌ **Avoid:**
- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure
This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable.
## Questions?
See [Superset Testing Documentation](https://superset.apache.org/docs/contributing/development#testing) or ask in the `#testing` Slack channel.

View File

@@ -0,0 +1,254 @@
/**
* 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 { test, expect } from '@playwright/test';
import { DatasetListPage } from '../../../pages/DatasetListPage';
import { ExplorePage } from '../../../pages/ExplorePage';
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
import { Toast } from '../../../components/core/Toast';
import {
apiDeleteDataset,
apiGetDataset,
getDatasetByName,
ENDPOINTS,
} from '../../../helpers/api/dataset';
/**
* Test data constants
* These reference example datasets loaded via --load-examples in CI.
*
* DEPENDENCY: Tests assume the example dataset exists and is a virtual dataset.
* If examples aren't loaded or the dataset changes, tests will fail.
* This is acceptable for experimental tests; stable tests should use dedicated
* seeded test data to decouple from example data changes.
*/
const TEST_DATASETS = {
EXAMPLE_DATASET: 'members_channels_2',
} as const;
/**
* Dataset List E2E Tests
*
* Uses flat test() structure per project convention (matches login.spec.ts).
* Shared state and hooks are at file scope.
*/
// File-scope state (reset in beforeEach)
let datasetListPage: DatasetListPage;
let explorePage: ExplorePage;
let testResources: { datasetIds: number[] } = { datasetIds: [] };
test.beforeEach(async ({ page }) => {
datasetListPage = new DatasetListPage(page);
explorePage = new ExplorePage(page);
testResources = { datasetIds: [] }; // Reset for each test
// Navigate to dataset list page
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
});
test.afterEach(async ({ page }) => {
// Cleanup any resources created during the test
const promises = [];
for (const datasetId of testResources.datasetIds) {
promises.push(
apiDeleteDataset(page, datasetId, {
failOnStatusCode: false,
}).catch(error => {
// Log cleanup failures to avoid silent resource leaks
console.warn(
`[Cleanup] Failed to delete dataset ${datasetId}:`,
String(error),
);
}),
);
}
await Promise.all(promises);
});
test('should navigate to Explore when dataset name is clicked', async ({
page,
}) => {
// Use existing example dataset (hermetic - loaded in CI via --load-examples)
const datasetName = TEST_DATASETS.EXAMPLE_DATASET;
const dataset = await getDatasetByName(page, datasetName);
expect(dataset).not.toBeNull();
// Verify dataset is visible in list (uses page object + Playwright auto-wait)
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click on dataset name to navigate to Explore
await datasetListPage.clickDatasetName(datasetName);
// Wait for Explore page to load (validates URL + datasource control)
await explorePage.waitForPageLoad();
// Verify correct dataset is loaded in datasource control
const loadedDatasetName = await explorePage.getDatasetName();
expect(loadedDatasetName).toContain(datasetName);
// Verify visualization switcher shows default viz type (indicates full page load)
await expect(explorePage.getVizSwitcher()).toBeVisible();
await expect(explorePage.getVizSwitcher()).toContainText('Table');
});
test('should delete a dataset with confirmation', async ({ page }) => {
// Get example dataset to duplicate
const originalName = TEST_DATASETS.EXAMPLE_DATASET;
const originalDataset = await getDatasetByName(page, originalName);
expect(originalDataset).not.toBeNull();
// Create throwaway copy for deletion (hermetic - uses UI duplication)
const datasetName = `test_delete_${Date.now()}`;
// Verify original dataset is visible in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
// Set up response intercept to capture duplicate dataset ID
const duplicateResponsePromise = page.waitForResponse(
response =>
response.url().includes(`${ENDPOINTS.DATASET}duplicate`) &&
response.status() === 201,
);
// Click duplicate action button
await datasetListPage.clickDuplicateAction(originalName);
// Duplicate modal should appear and be ready for interaction
const duplicateModal = new DuplicateDatasetModal(page);
await duplicateModal.waitForReady();
// Fill in new dataset name
await duplicateModal.fillDatasetName(datasetName);
// Click the Duplicate button
await duplicateModal.clickDuplicate();
// Get the duplicate dataset ID from response and track immediately
const duplicateResponse = await duplicateResponsePromise;
const duplicateData = await duplicateResponse.json();
const duplicateId = duplicateData.id;
// Track duplicate for cleanup immediately (before any operations that could fail)
testResources = { datasetIds: [duplicateId] };
// Modal should close
await duplicateModal.waitForHidden();
// Refresh page to see new dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// Click delete action button
await datasetListPage.clickDeleteAction(datasetName);
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears with correct message
const toast = new Toast(page);
const successToast = toast.getSuccess();
await expect(successToast).toBeVisible();
await expect(toast.getMessage()).toContainText('Deleted');
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
});
test('should duplicate a dataset with new name', async ({ page }) => {
// Use virtual example dataset
const originalName = TEST_DATASETS.EXAMPLE_DATASET;
const duplicateName = `duplicate_${originalName}_${Date.now()}`;
// Get the dataset by name (ID varies by environment)
const original = await getDatasetByName(page, originalName);
expect(original).not.toBeNull();
expect(original!.id).toBeGreaterThan(0);
// Verify original dataset is visible in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
// Set up response intercept to capture duplicate dataset ID
const duplicateResponsePromise = page.waitForResponse(
response =>
response.url().includes(`${ENDPOINTS.DATASET}duplicate`) &&
response.status() === 201,
);
// Click duplicate action button
await datasetListPage.clickDuplicateAction(originalName);
// Duplicate modal should appear and be ready for interaction
const duplicateModal = new DuplicateDatasetModal(page);
await duplicateModal.waitForReady();
// Fill in new dataset name
await duplicateModal.fillDatasetName(duplicateName);
// Click the Duplicate button
await duplicateModal.clickDuplicate();
// Get the duplicate dataset ID from response
const duplicateResponse = await duplicateResponsePromise;
const duplicateData = await duplicateResponse.json();
const duplicateId = duplicateData.id;
// Track duplicate for cleanup (original is example data, don't delete it)
testResources = { datasetIds: [duplicateId] };
// Modal should close
await duplicateModal.waitForHidden();
// Note: Duplicate action does not show a success toast (only errors)
// Verification is done via API and UI list check below
// Refresh to see the duplicated dataset
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets exist in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// API Verification: Compare original and duplicate datasets
const duplicateResponseData = await apiGetDataset(page, duplicateId);
const duplicateDataFull = await duplicateResponseData.json();
// Verify key properties were copied correctly (original data already fetched)
expect(duplicateDataFull.result.sql).toBe(original!.sql);
expect(duplicateDataFull.result.database.id).toBe(original!.database.id);
expect(duplicateDataFull.result.schema).toBe(original!.schema);
// Name should be different (the duplicate name)
expect(duplicateDataFull.result.table_name).toBe(duplicateName);
});

View File

@@ -0,0 +1,46 @@
/**
* 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.
*/
/**
* Timeout constants for Playwright tests.
* Only define timeouts that differ from Playwright defaults or are semantically important.
*
* Default Playwright timeouts (from playwright.config.ts):
* - Test timeout: 30000ms (30s)
* - Expect timeout: 8000ms (8s)
*
* Use these constants instead of magic numbers for better maintainability.
*/
export const TIMEOUT = {
/**
* Global setup timeout (matches test timeout for cold CI starts)
*/
GLOBAL_SETUP: 30000, // 30s for global setup auth
/**
* Page navigation and load timeouts
*/
PAGE_LOAD: 10000, // 10s for page transitions (login → welcome, dataset → explore)
/**
* Form and UI element load timeouts
*/
FORM_LOAD: 5000, // 5s for forms to become visible (login form, modals)
} as const;

View File

@@ -17,7 +17,18 @@
* under the License.
*/
/**
* URL constants for Playwright navigation
*
* These are relative paths (no leading '/') that rely on baseURL ending with '/'.
* playwright.config.ts normalizes baseURL to always end with '/' to ensure
* correct URL resolution with APP_PREFIX (e.g., /app/prefix/).
*
* Example: baseURL='http://localhost:8088/app/prefix/' + 'tablemodelview/list'
* = 'http://localhost:8088/app/prefix/tablemodelview/list'
*/
export const URL = {
DATASET_LIST: 'tablemodelview/list',
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;

View File

@@ -0,0 +1,121 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SqlaFormData } from '@superset-ui/core';
import {
computeGeoJsonTextOptionsFromJsOutput,
computeGeoJsonTextOptionsFromFormData,
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
} from './Geojson';
jest.mock('@deck.gl/react', () => ({
__esModule: true,
default: () => null,
}));
test('computeGeoJsonTextOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonTextOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonTextOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonTextOptionsFromJsOutput extracts valid text options from the input object', () => {
const input = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
invalidOption: true,
};
const expectedOutput = {
getText: 'name',
getTextColor: [1, 2, 3, 255],
};
expect(computeGeoJsonTextOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
const formData: SqlaFormData = {
label_property_name: 'name',
label_color: { r: 1, g: 2, b: 3, a: 1 },
label_size: 123,
label_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getText: expect.any(Function),
getTextColor: [1, 2, 3, 255],
getTextSize: 123,
textSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonTextOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
const sampleFeature = { properties: { name: 'Test' } };
expect(actualOutput.getText(sampleFeature)).toBe('Test');
});
test('computeGeoJsonIconOptionsFromJsOutput returns an empty object for non-object input', () => {
expect(computeGeoJsonIconOptionsFromJsOutput(null)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput(42)).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput([1, 2, 3])).toEqual({});
expect(computeGeoJsonIconOptionsFromJsOutput('string')).toEqual({});
});
test('computeGeoJsonIconOptionsFromJsOutput extracts valid icon options from the input object', () => {
const input = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
invalidOption: false,
};
const expectedOutput = {
getIcon: 'icon_name',
getIconColor: [1, 2, 3, 255],
};
expect(computeGeoJsonIconOptionsFromJsOutput(input)).toEqual(expectedOutput);
});
test('computeGeoJsonIconOptionsFromFormData computes icon options based on form data', () => {
const formData: SqlaFormData = {
icon_url: 'https://example.com/icon.png',
icon_size: 123,
icon_size_unit: 'pixels',
datasource: 'test_datasource',
viz_type: 'deck_geojson',
};
const expectedOutput = {
getIcon: expect.any(Function),
getIconSize: 123,
iconSizeUnits: 'pixels',
};
const actualOutput = computeGeoJsonIconOptionsFromFormData(formData);
expect(actualOutput).toEqual(expectedOutput);
expect(actualOutput.getIcon()).toEqual({
url: 'https://example.com/icon.png',
height: 128,
width: 128,
});
});

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { memo, useCallback, useMemo, useRef } from 'react';
import { GeoJsonLayer } from '@deck.gl/layers';
import { GeoJsonLayer, GeoJsonLayerProps } from '@deck.gl/layers';
// ignoring the eslint error below since typescript prefers 'geojson' to '@types/geojson'
// eslint-disable-next-line import/no-unresolved
import { Feature, Geometry, GeoJsonProperties } from 'geojson';
@@ -29,6 +29,7 @@ import {
JsonValue,
QueryFormData,
SetDataMaskHook,
SqlaFormData,
} from '@superset-ui/core';
import {
@@ -44,6 +45,7 @@ import { TooltipProps } from '../../components/Tooltip';
import { Point } from '../../types';
import { GetLayerType } from '../../factory';
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
import { BLACK_COLOR, PRIMARY_COLOR } from '../../utilities/controls';
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
properties: JsonObject;
@@ -137,6 +139,114 @@ const getFillColor = (feature: JsonObject, filterStateValue: unknown[]) => {
};
const getLineColor = (feature: JsonObject) => feature?.properties?.strokeColor;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export const computeGeoJsonTextOptionsFromJsOutput = (
output: unknown,
): Partial<GeoJsonLayerProps> => {
if (!isObject(output)) return {};
// Properties sourced from:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-2
const options: (keyof GeoJsonLayerProps)[] = [
'getText',
'getTextColor',
'getTextAngle',
'getTextSize',
'getTextAnchor',
'getTextAlignmentBaseline',
'getTextPixelOffset',
'getTextBackgroundColor',
'getTextBorderColor',
'getTextBorderWidth',
'textSizeUnits',
'textSizeScale',
'textSizeMinPixels',
'textSizeMaxPixels',
'textCharacterSet',
'textFontFamily',
'textFontWeight',
'textLineHeight',
'textMaxWidth',
'textWordBreak',
'textBackground',
'textBackgroundPadding',
'textOutlineColor',
'textOutlineWidth',
'textBillboard',
'textFontSettings',
];
const allEntries = Object.entries(output);
const validEntries = allEntries.filter(([k]) =>
options.includes(k as keyof GeoJsonLayerProps),
);
return Object.fromEntries(validEntries);
};
export const computeGeoJsonTextOptionsFromFormData = (
fd: SqlaFormData,
): Partial<GeoJsonLayerProps> => {
const lc = fd.label_color ?? BLACK_COLOR;
return {
getText: (f: JsonObject) => f?.properties?.[fd.label_property_name],
getTextColor: [lc.r, lc.g, lc.b, 255 * lc.a],
getTextSize: parseInt(fd.label_size, 10),
textSizeUnits: fd.label_size_unit,
};
};
export const computeGeoJsonIconOptionsFromJsOutput = (
output: unknown,
): Partial<GeoJsonLayerProps> => {
if (!isObject(output)) return {};
// Properties sourced from:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-1
const options: (keyof GeoJsonLayerProps)[] = [
'getIcon',
'getIconSize',
'getIconColor',
'getIconAngle',
'getIconPixelOffset',
'iconSizeUnits',
'iconSizeScale',
'iconSizeMinPixels',
'iconSizeMaxPixels',
'iconAtlas',
'iconMapping',
'iconBillboard',
'iconAlphaCutoff',
];
const allEntries = Object.entries(output);
const validEntries = allEntries.filter(([k]) =>
options.includes(k as keyof GeoJsonLayerProps),
);
return Object.fromEntries(validEntries);
};
export const computeGeoJsonIconOptionsFromFormData = (
fd: SqlaFormData,
): Partial<GeoJsonLayerProps> => ({
getIcon: fd.icon_url
? () => ({
url: fd.icon_url,
// This is the size deck.gl resizes the icon internally while preserving
// its aspect ratio. This is not the actual size the icon is rendered at,
// which is instead controlled by getIconSize below. These are set because
// deck.gl requires it, and 128x128 is a reasonable default. Read more at:
// https://deck.gl/docs/api-reference/layers/icon-layer#geticon
width: 128,
height: 128,
})
: undefined,
getIconSize: parseInt(fd.icon_size, 10),
iconSizeUnits: fd.icon_size_unit,
});
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
formData,
onContextMenu,
@@ -147,8 +257,8 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
emitCrossFilters,
}) {
const fd = formData;
const fc = fd.fill_color_picker;
const sc = fd.stroke_color_picker;
const fc = fd.fill_color_picker ?? PRIMARY_COLOR;
const sc = fd.stroke_color_picker ?? PRIMARY_COLOR;
const fillColor = [fc.r, fc.g, fc.b, 255 * fc.a];
const strokeColor = [sc.r, sc.g, sc.b, 255 * sc.a];
const propOverrides: JsonObject = {};
@@ -169,6 +279,38 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
processedFeatures = jsFnMutator(features) as ProcessedFeature[];
}
let pointType = 'circle';
if (fd.enable_labels) {
pointType = `${pointType}+text`;
}
if (fd.enable_icons) {
pointType = `${pointType}+icon`;
}
let labelOpts: Partial<GeoJsonLayerProps> = {};
if (fd.enable_labels) {
if (fd.enable_label_javascript_mode) {
const generator = sandboxedEval(fd.label_javascript_config_generator);
if (typeof generator === 'function') {
labelOpts = computeGeoJsonTextOptionsFromJsOutput(generator());
}
} else {
labelOpts = computeGeoJsonTextOptionsFromFormData(fd);
}
}
let iconOpts: Partial<GeoJsonLayerProps> = {};
if (fd.enable_icons) {
if (fd.enable_icon_javascript_mode) {
const generator = sandboxedEval(fd.icon_javascript_config_generator);
if (typeof generator === 'function') {
iconOpts = computeGeoJsonIconOptionsFromJsOutput(generator());
}
} else {
iconOpts = computeGeoJsonIconOptionsFromFormData(fd);
}
}
return new GeoJsonLayer({
id: `geojson-layer-${fd.slice_id}` as const,
data: processedFeatures,
@@ -181,6 +323,9 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
lineWidthUnits: fd.line_width_unit,
pointType,
...labelOpts,
...iconOpts,
...commonLayerProps({
formData: fd,
setTooltip,

View File

@@ -17,7 +17,12 @@
* under the License.
*/
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t, legacyValidateInteger } from '@superset-ui/core';
import {
t,
legacyValidateInteger,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
import { formatSelectOptions } from '../../utilities/utils';
import {
filterNulls,
@@ -36,8 +41,27 @@ import {
lineWidth,
tooltipContents,
tooltipTemplate,
jsFunctionControl,
} from '../../utilities/Shared_DeckGL';
import { dndGeojsonColumn } from '../../utilities/sharedDndControls';
import { BLACK_COLOR } from '../../utilities/controls';
const defaultLabelConfigGenerator = `() => ({
// Check the documentation at:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-2
getText: f => f.properties.name,
getTextColor: [0, 0, 0, 255],
getTextSize: 24,
textSizeUnits: 'pixels',
})`;
const defaultIconConfigGenerator = `() => ({
// Check the documentation at:
// https://deck.gl/docs/api-reference/layers/geojson-layer#pointtype-options-1
getIcon: () => ({ url: '', height: 128, width: 128 }),
getIconSize: 32,
iconSizeUnits: 'pixels',
})`;
const config: ControlPanelConfig = {
controlPanelSections: [
@@ -63,6 +87,245 @@ const config: ControlPanelConfig = {
[fillColorPicker, strokeColorPicker],
[filled, stroked],
[extruded],
[
{
name: 'enable_labels',
config: {
type: 'CheckboxControl',
label: t('Enable labels'),
description: t('Enables rendering of labels for GeoJSON points'),
default: false,
renderTrigger: true,
},
},
],
[
{
name: 'enable_label_javascript_mode',
config: {
type: 'CheckboxControl',
label: t('Enable label JavaScript mode'),
description: t(
'Enables custom label configuration via JavaScript',
),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
default: false,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_property_name',
config: {
type: 'TextControl',
label: t('Label property name'),
description: t('The feature property to use for point labels'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
default: 'name',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_color',
config: {
type: 'ColorPickerControl',
label: t('Label color'),
description: t('The color of the point labels'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
default: BLACK_COLOR,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_size',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Label size'),
description: t('The font size of the point labels'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
validators: [legacyValidateInteger],
choices: formatSelectOptions([8, 16, 24, 32, 64, 128]),
default: 24,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_size_unit',
config: {
type: 'SelectControl',
label: t('Label size unit'),
description: t('The unit for label size'),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
(!form_data.enable_label_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
choices: [
['meters', t('Meters')],
['pixels', t('Pixels')],
],
default: 'pixels',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'label_javascript_config_generator',
config: {
...jsFunctionControl(
t('Label JavaScript config generator'),
t(
'A JavaScript function that generates a label configuration object',
),
undefined,
undefined,
defaultLabelConfigGenerator,
),
visibility: ({ form_data }) =>
!!form_data.enable_labels &&
!!form_data.enable_label_javascript_mode &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
resetOnHide: false,
},
},
],
[
{
name: 'enable_icons',
config: {
type: 'CheckboxControl',
label: t('Enable icons'),
description: t('Enables rendering of icons for GeoJSON points'),
default: false,
renderTrigger: true,
},
},
],
[
{
name: 'enable_icon_javascript_mode',
config: {
type: 'CheckboxControl',
label: t('Enable icon JavaScript mode'),
description: t(
'Enables custom icon configuration via JavaScript',
),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
default: false,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_url',
config: {
type: 'TextControl',
label: t('Icon URL'),
description: t(
'The image URL of the icon to display for GeoJSON points. ' +
'Note that the image URL must conform to the content ' +
'security policy (CSP) in order to load correctly.',
),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
(!form_data.enable_icon_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
default: '',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_size',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Icon size'),
description: t('The size of the point icons'),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
(!form_data.enable_icon_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
validators: [legacyValidateInteger],
choices: formatSelectOptions([16, 24, 32, 64, 128]),
default: 32,
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_size_unit',
config: {
type: 'SelectControl',
label: t('Icon size unit'),
description: t('The unit for icon size'),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
(!form_data.enable_icon_javascript_mode ||
!isFeatureEnabled(FeatureFlag.EnableJavascriptControls)),
choices: [
['meters', t('Meters')],
['pixels', t('Pixels')],
],
default: 'pixels',
renderTrigger: true,
resetOnHide: false,
},
},
],
[
{
name: 'icon_javascript_config_generator',
config: {
...jsFunctionControl(
t('Icon JavaScript config generator'),
t(
'A JavaScript function that generates an icon configuration object',
),
undefined,
undefined,
defaultIconConfigGenerator,
),
visibility: ({ form_data }) =>
!!form_data.enable_icons &&
!!form_data.enable_icon_javascript_mode &&
isFeatureEnabled(FeatureFlag.EnableJavascriptControls),
resetOnHide: false,
},
},
],
[lineWidth],
[
{

View File

@@ -96,7 +96,7 @@ const jsFunctionInfo = (
</div>
);
function jsFunctionControl(
export function jsFunctionControl(
label: string,
description: string,
extraDescr = null,

View File

@@ -39,6 +39,7 @@ export function columnChoices(datasource: Dataset | QueryResponse | null) {
}
export const PRIMARY_COLOR = { r: 0, g: 122, b: 135, a: 1 };
export const BLACK_COLOR = { r: 0, g: 0, b: 0, a: 1 };
export default {
default: null,

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { AlteredSliceTag } from '.';
import { defaultProps } from './AlteredSliceTagMocks';
import { defaultProps, expectedDiffs } from './AlteredSliceTagMocks';
export default {
title: 'Components/AlteredSliceTag',
@@ -27,5 +27,5 @@ export const InteractiveSliceTag = (args: any) => <AlteredSliceTag {...args} />;
InteractiveSliceTag.args = {
origFormData: defaultProps.origFormData,
currentFormData: defaultProps.currentFormData,
diffs: expectedDiffs,
};

View File

@@ -167,21 +167,7 @@ const MessageSpan = styled.span`
text-align: center;
margin: ${({ theme }) => theme.sizeUnit * 4}px auto;
width: fit-content;
color: ${({ theme }) => theme.colorPrimary};
`;
const LoadingOverlay = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: ${({ theme }) => theme.colorBgBase}cc;
z-index: 10;
color: ${({ theme }) => theme.colorText};
`;
class Chart extends PureComponent<ChartProps, {}> {
@@ -328,22 +314,6 @@ class Chart extends PureComponent<ChartProps, {}> {
);
}
renderLoadingOverlay(databaseName: string | undefined) {
const message = databaseName
? t('Waiting on %s', databaseName)
: t('Waiting on database...');
return (
<LoadingOverlay>
<Loading
position="inline-centered"
size={this.props.dashboardId ? 's' : 'm'}
/>
<MessageSpan>{message}</MessageSpan>
</LoadingOverlay>
);
}
render() {
const {
height,
@@ -403,30 +373,6 @@ class Chart extends PureComponent<ChartProps, {}> {
);
}
const hasExistingData =
ensureIsArray(queriesResponse).length > 0 &&
queriesResponse?.some(response => response?.data);
const isFirstLoad = isLoading && !hasExistingData;
if (isFirstLoad) {
return (
<ErrorBoundary
onError={this.handleRenderContainerFailure}
showMessage={false}
>
<Styles
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
width={width}
>
{this.renderSpinner(databaseName)}
</Styles>
</ErrorBoundary>
);
}
return (
<ErrorBoundary
onError={this.handleRenderContainerFailure}
@@ -439,8 +385,9 @@ class Chart extends PureComponent<ChartProps, {}> {
height={height}
width={width}
>
{this.renderChartContainer()}
{isLoading && this.renderLoadingOverlay(databaseName)}
{isLoading
? this.renderSpinner(databaseName)
: this.renderChartContainer()}
</Styles>
</ErrorBoundary>
);

View File

@@ -279,14 +279,8 @@ class ChartRenderer extends Component {
render() {
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
// Skip chart rendering for errors
if (chartAlert) {
return null;
}
const hasData =
this.mutableQueriesResponse && this.mutableQueriesResponse.length > 0;
if (!hasData && (chartStatus === 'loading' || chartStatus === null)) {
// Skip chart rendering
if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
return null;
}

View File

@@ -603,22 +603,6 @@ export function refreshChart(chartKey, force, dashboardId) {
};
}
// What-If caching actions
export const SAVE_ORIGINAL_CHART_DATA = 'SAVE_ORIGINAL_CHART_DATA';
export function saveOriginalChartData(key) {
return { type: SAVE_ORIGINAL_CHART_DATA, key };
}
export const RESTORE_ORIGINAL_CHART_DATA = 'RESTORE_ORIGINAL_CHART_DATA';
export function restoreOriginalChartData(key) {
return { type: RESTORE_ORIGINAL_CHART_DATA, key };
}
export const CLEAR_ORIGINAL_CHART_DATA = 'CLEAR_ORIGINAL_CHART_DATA';
export function clearOriginalChartData(key) {
return { type: CLEAR_ORIGINAL_CHART_DATA, key };
}
export const getDatasourceSamples = async (
datasourceType,
datasourceId,

View File

@@ -177,33 +177,6 @@ export default function chartReducer(
annotationQuery,
};
},
[actions.SAVE_ORIGINAL_CHART_DATA](state) {
// Only save if we don't already have cached data
if (state.originalQueriesResponse) {
return state;
}
return {
...state,
originalQueriesResponse: state.queriesResponse,
};
},
[actions.RESTORE_ORIGINAL_CHART_DATA](state) {
if (!state.originalQueriesResponse) {
return state;
}
return {
...state,
queriesResponse: state.originalQueriesResponse,
originalQueriesResponse: null,
chartStatus: 'success',
};
},
[actions.CLEAR_ORIGINAL_CHART_DATA](state) {
return {
...state,
originalQueriesResponse: null,
};
},
};
/* eslint-disable no-param-reassign */

View File

@@ -668,6 +668,14 @@ class DatasourceEditor extends PureComponent {
usageChartsCount: 0,
};
this.isComponentMounted = false;
this.abortControllers = {
formatQuery: null,
formatSql: null,
syncMetadata: null,
fetchUsageData: null,
};
this.onChange = this.onChange.bind(this);
this.onChangeEditMode = this.onChangeEditMode.bind(this);
this.onDatasourcePropChange = this.onDatasourcePropChange.bind(this);
@@ -758,24 +766,42 @@ class DatasourceEditor extends PureComponent {
});
}
/**
* Formats SQL query using the formatQuery action.
* Aborts any pending format requests before starting a new one.
*/
async onQueryFormat() {
const { datasource } = this.state;
if (!datasource.sql || !this.state.isEditMode) {
return;
}
// Abort previous formatQuery if still pending
if (this.abortControllers.formatQuery) {
this.abortControllers.formatQuery.abort();
}
this.abortControllers.formatQuery = new AbortController();
const { signal } = this.abortControllers.formatQuery;
try {
const response = await this.props.formatQuery(datasource.sql);
const response = await this.props.formatQuery(datasource.sql, { signal });
this.onDatasourcePropChange('sql', response.json.result);
this.props.addSuccessToast(t('SQL was formatted'));
} catch (error) {
if (error.name === 'AbortError') return;
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError ||
statusText ||
t('An error occurred while formatting SQL'),
);
} finally {
this.abortControllers.formatQuery = null;
}
}
@@ -802,36 +828,71 @@ class DatasourceEditor extends PureComponent {
});
}
/**
* Formats SQL query using the SQL format API endpoint.
* Aborts any pending format requests before starting a new one.
*/
async formatSql() {
const { datasource } = this.state;
if (!datasource.sql) {
return;
}
// Abort previous formatSql if still pending
if (this.abortControllers.formatSql) {
this.abortControllers.formatSql.abort();
}
this.abortControllers.formatSql = new AbortController();
const { signal } = this.abortControllers.formatSql;
try {
const response = await SupersetClient.post({
endpoint: '/api/v1/sql/format',
body: JSON.stringify({ sql: datasource.sql }),
headers: { 'Content-Type': 'application/json' },
signal,
});
this.onDatasourcePropChange('sql', response.json.result);
this.props.addSuccessToast(t('SQL was formatted'));
} catch (error) {
if (error.name === 'AbortError') return;
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError ||
statusText ||
t('An error occurred while formatting SQL'),
);
} finally {
this.abortControllers.formatSql = null;
}
}
/**
* Syncs dataset columns with the database schema.
* Fetches column metadata from the underlying table/view and updates the dataset.
* Aborts any pending sync requests before starting a new one.
*/
async syncMetadata() {
const { datasource } = this.state;
// Abort previous syncMetadata if still pending
if (this.abortControllers.syncMetadata) {
this.abortControllers.syncMetadata.abort();
}
this.abortControllers.syncMetadata = new AbortController();
const { signal } = this.abortControllers.syncMetadata;
this.setState({ metadataLoading: true });
try {
const newCols = await fetchSyncedColumns(datasource);
const newCols = await fetchSyncedColumns(datasource, signal);
const columnChanges = updateColumns(
datasource.columns,
newCols,
@@ -848,15 +909,36 @@ class DatasourceEditor extends PureComponent {
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
} catch (error) {
if (error.name === 'AbortError') {
// Only update state if still mounted (abort may happen during unmount)
if (this.isComponentMounted) {
this.setState({ metadataLoading: false });
}
return;
}
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError || statusText || t('An error has occurred'),
);
this.setState({ metadataLoading: false });
} finally {
this.abortControllers.syncMetadata = null;
}
}
/**
* Fetches chart usage data for this dataset (which charts use this dataset).
* Aborts any pending fetch requests before starting a new one.
*
* @param {number} page - Page number (1-indexed)
* @param {number} pageSize - Number of results per page
* @param {string} sortColumn - Column to sort by
* @param {string} sortDirection - Sort direction ('asc' or 'desc')
* @returns {Promise<{charts: Array, count: number, ids: Array}>} Chart usage data
*/
async fetchUsageData(
page = 1,
pageSize = 25,
@@ -864,6 +946,15 @@ class DatasourceEditor extends PureComponent {
sortDirection = 'desc',
) {
const { datasource } = this.state;
// Abort previous fetchUsageData if still pending
if (this.abortControllers.fetchUsageData) {
this.abortControllers.fetchUsageData.abort();
}
this.abortControllers.fetchUsageData = new AbortController();
const { signal } = this.abortControllers.fetchUsageData;
try {
const queryParams = rison.encode({
columns: [
@@ -899,6 +990,7 @@ class DatasourceEditor extends PureComponent {
const { json = {} } = await SupersetClient.get({
endpoint: `/api/v1/chart/?q=${queryParams}`,
signal,
});
const charts = json?.result || [];
@@ -910,10 +1002,13 @@ class DatasourceEditor extends PureComponent {
id: ids[index],
}));
this.setState({
usageCharts: chartsWithIds,
usageChartsCount: json?.count || 0,
});
// Only update state if not aborted and component still mounted
if (!signal.aborted && this.isComponentMounted) {
this.setState({
usageCharts: chartsWithIds,
usageChartsCount: json?.count || 0,
});
}
return {
charts: chartsWithIds,
@@ -921,8 +1016,12 @@ class DatasourceEditor extends PureComponent {
ids,
};
} catch (error) {
// Rethrow AbortError so callers can handle gracefully
if (error.name === 'AbortError') throw error;
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
clientError ||
statusText ||
@@ -932,11 +1031,14 @@ class DatasourceEditor extends PureComponent {
usageCharts: [],
usageChartsCount: 0,
});
return {
charts: [],
count: 0,
ids: [],
};
} finally {
this.abortControllers.fetchUsageData = null;
}
}
@@ -1941,6 +2043,7 @@ class DatasourceEditor extends PureComponent {
}
componentDidMount() {
this.isComponentMounted = true;
Mousetrap.bind('ctrl+shift+f', e => {
e.preventDefault();
if (this.state.isEditMode) {
@@ -1948,10 +2051,19 @@ class DatasourceEditor extends PureComponent {
}
return false;
});
this.fetchUsageData();
this.fetchUsageData().catch(error => {
if (error?.name !== 'AbortError') throw error;
});
}
componentWillUnmount() {
this.isComponentMounted = false;
// Abort all pending requests
Object.values(this.abortControllers).forEach(controller => {
if (controller) controller.abort();
});
Mousetrap.unbind('ctrl+shift+f');
this.props.resetQuery();
}
@@ -1965,7 +2077,7 @@ const DataSourceComponent = withTheme(DatasourceEditor);
const mapDispatchToProps = dispatch => ({
runQuery: payload => dispatch(executeQuery(payload)),
resetQuery: () => dispatch(resetDatabaseState()),
formatQuery: sql => dispatch(formatQuery(sql)),
formatQuery: (sql, options) => dispatch(formatQuery(sql, options)),
});
const mapStateToProps = state => ({
database: state?.database,

View File

@@ -46,88 +46,83 @@ const setupTest = (dashboards = mockDashboards) =>
}),
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DashboardLinksExternal', () => {
test('renders empty state when no dashboards provided', () => {
setupTest([]);
expect(screen.getByText('—')).toBeInTheDocument();
test('renders empty state when no dashboards provided', () => {
setupTest([]);
expect(screen.getByText('—')).toBeInTheDocument();
});
test('renders empty state when dashboards is null/undefined', () => {
render(<DashboardLinksExternal dashboards={null as any} />, {
wrapper: createWrapper({
useRedux: true,
useRouter: true,
}),
});
expect(screen.getByText('—')).toBeInTheDocument();
});
test('renders empty state when dashboards is null/undefined', () => {
render(<DashboardLinksExternal dashboards={null as any} />, {
wrapper: createWrapper({
useRedux: true,
useRouter: true,
}),
});
expect(screen.getByText('—')).toBeInTheDocument();
});
test('renders single dashboard link correctly', () => {
setupTest([mockDashboards[0]]);
test('renders single dashboard link correctly', () => {
setupTest([mockDashboards[0]]);
const link = screen.getByText('Sales Dashboard');
expect(link).toBeInTheDocument();
expect(link.closest('a')).toHaveAttribute('href', '/superset/dashboard/1/');
expect(link.closest('a')).toHaveAttribute('target', '_blank');
});
const link = screen.getByText('Sales Dashboard');
expect(link).toBeInTheDocument();
expect(link.closest('a')).toHaveAttribute('href', '/superset/dashboard/1/');
expect(link.closest('a')).toHaveAttribute('target', '_blank');
});
test('renders multiple dashboard links with commas', () => {
setupTest();
test('renders multiple dashboard links with commas', () => {
setupTest();
expect(screen.getByText('Sales Dashboard')).toBeInTheDocument();
expect(screen.getByText(', Analytics Dashboard')).toBeInTheDocument();
expect(
screen.getByText(', Very Long Dashboard Name That Should Be Truncated'),
).toBeInTheDocument();
});
expect(screen.getByText('Sales Dashboard')).toBeInTheDocument();
expect(screen.getByText(', Analytics Dashboard')).toBeInTheDocument();
expect(
screen.getByText(', Very Long Dashboard Name That Should Be Truncated'),
).toBeInTheDocument();
});
test('all links open in new tabs', () => {
setupTest();
test('all links open in new tabs', () => {
setupTest();
const links = screen.getAllByRole('link');
links.forEach(link => {
expect(link).toHaveAttribute('target', '_blank');
});
});
test('links have correct href attributes', () => {
setupTest();
const salesLink = screen.getByText('Sales Dashboard').closest('a');
const analyticsLink = screen
.getByText(', Analytics Dashboard')
.closest('a');
const longNameLink = screen
.getByText(', Very Long Dashboard Name That Should Be Truncated')
.closest('a');
expect(salesLink).toHaveAttribute('href', '/superset/dashboard/1/');
expect(analyticsLink).toHaveAttribute('href', '/superset/dashboard/2/');
expect(longNameLink).toHaveAttribute('href', '/superset/dashboard/3/');
});
test('applies correct styling classes', () => {
setupTest();
const truncatedSpan = document.querySelector('.truncated');
expect(truncatedSpan).toBeInTheDocument();
expect(truncatedSpan).toContainElement(screen.getAllByRole('link')[0]);
});
test('handles dashboard with empty title', () => {
const dashboardWithEmptyTitle = [
{
id: 1,
dashboard_title: '',
url: '/dashboard/1/',
},
];
setupTest(dashboardWithEmptyTitle);
const link = screen.getByRole('link');
expect(link).toHaveTextContent('');
expect(link).toHaveAttribute('href', '/superset/dashboard/1/');
const links = screen.getAllByRole('link');
links.forEach(link => {
expect(link).toHaveAttribute('target', '_blank');
});
});
test('links have correct href attributes', () => {
setupTest();
const salesLink = screen.getByText('Sales Dashboard').closest('a');
const analyticsLink = screen.getByText(', Analytics Dashboard').closest('a');
const longNameLink = screen
.getByText(', Very Long Dashboard Name That Should Be Truncated')
.closest('a');
expect(salesLink).toHaveAttribute('href', '/superset/dashboard/1/');
expect(analyticsLink).toHaveAttribute('href', '/superset/dashboard/2/');
expect(longNameLink).toHaveAttribute('href', '/superset/dashboard/3/');
});
test('applies correct styling classes', () => {
setupTest();
const truncatedSpan = document.querySelector('.truncated');
expect(truncatedSpan).toBeInTheDocument();
expect(truncatedSpan).toContainElement(screen.getAllByRole('link')[0]);
});
test('handles dashboard with empty title', () => {
const dashboardWithEmptyTitle = [
{
id: 1,
dashboard_title: '',
url: '/dashboard/1/',
},
];
setupTest(dashboardWithEmptyTitle);
const link = screen.getByRole('link');
expect(link).toHaveTextContent('');
expect(link).toHaveAttribute('href', '/superset/dashboard/1/');
});

View File

@@ -129,6 +129,10 @@ afterEach(() => {
fetchMock.restore();
// Restore original scrollTo implementation after each test
Element.prototype.scrollTo = originalScrollTo;
// Restore console.error if it was spied on
if (jest.isMockFunction(console.error)) {
(console.error as jest.Mock).mockRestore();
}
});
test('renders empty state when no charts provided', () => {
@@ -498,3 +502,48 @@ test('cleans up animation frame on unmount during loading', async () => {
cancelAnimationFrameSpy.mockRestore();
});
test('handles AbortError without setState after unmount', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
let rejectPromise: (reason?: any) => void;
const abortedPromise = new Promise((_, reject) => {
rejectPromise = reject;
});
const mockOnFetchCharts = jest.fn(() => abortedPromise);
const { unmount } = setupTest({
onFetchCharts: mockOnFetchCharts,
totalCount: 100,
});
const nextButton = screen.getByTitle('Next Page');
await userEvent.click(nextButton);
// Should be loading
await waitFor(() => {
expect(screen.getByLabelText('Loading')).toBeInTheDocument();
});
// Unmount while loading
unmount();
// Reject with AbortError after unmount
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
rejectPromise!(abortError);
// Flush pending promises and animation frames
await new Promise(resolve => setTimeout(resolve, 0));
// CRITICAL: No setState warnings
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('setState'),
);
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('unmounted component'),
);
consoleErrorSpy.mockRestore();
});

View File

@@ -101,6 +101,14 @@ const DatasetUsageTab = ({
const addDangerToastRef = useRef(addDangerToast);
const tableContainerRef = useRef<HTMLDivElement>(null);
const prevLoadingRef = useRef(false);
const isMountedRef = useRef(false);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@@ -117,14 +125,22 @@ const DatasetUsageTab = ({
try {
await onFetchCharts(page, PAGE_SIZE, column, direction);
setCurrentPage(page);
setSortColumn(column);
setSortDirection(direction);
if (isMountedRef.current) {
setCurrentPage(page);
setSortColumn(column);
setSortDirection(direction);
}
} catch (error) {
if (addDangerToastRef.current)
if ((error as Error).name === 'AbortError') return;
if (addDangerToastRef.current) {
addDangerToastRef.current(t('Error fetching charts'));
}
} finally {
setLoading(false);
if (isMountedRef.current) {
setLoading(false);
}
}
},
[datasourceId, onFetchCharts, sortColumn, sortDirection],

View File

@@ -18,295 +18,526 @@
*/
import fetchMock from 'fetch-mock';
import {
render,
fireEvent,
screen,
waitFor,
userEvent,
cleanup,
within,
} from 'spec/helpers/testing-library';
import mockDatasource from 'spec/fixtures/mockDatasource';
import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
import type { DatasetObject } from 'src/features/datasets/types';
import DatasourceEditor from '..';
import {
createProps,
DATASOURCE_ENDPOINT,
asyncRender,
fastRender,
setupDatasourceEditorMocks,
cleanupAsyncOperations,
dismissDatasourceWarning,
createDeferredPromise,
} from './DatasourceEditor.test.utils';
/* eslint-disable jest/no-export */
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
interface DatasourceEditorProps {
datasource: DatasetObject;
addSuccessToast: () => void;
addDangerToast: () => void;
onChange: jest.Mock;
columnLabels?: Record<string, string>;
columnLabelTooltips?: Record<string, string>;
}
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { overwriteRoutes: true });
setupDatasourceEditorMocks();
jest.clearAllMocks();
});
// Common setup for tests
export const props: DatasourceEditorProps = {
datasource: mockDatasource['7__table'],
addSuccessToast: () => {},
addDangerToast: () => {},
onChange: jest.fn(),
columnLabels: {
state: 'State',
},
columnLabelTooltips: {
state: 'This is a tooltip for state',
},
};
afterEach(async () => {
await cleanupAsyncOperations();
fetchMock.restore();
// Reset module mock since jest.fn() doesn't support mockRestore()
jest.mocked(isFeatureEnabled).mockReset();
// Restore console.error if it was spied on
if (jest.isMockFunction(console.error)) {
(console.error as jest.Mock).mockRestore();
}
});
export const DATASOURCE_ENDPOINT =
'glob:*/datasource/external_metadata_by_name/*';
test('renders Tabs', async () => {
const testProps = createProps();
await asyncRender({
...testProps,
datasource: { ...testProps.datasource, table_name: 'Vehicle Sales +' },
});
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
});
const routeProps = {
history: {},
location: {},
match: {},
};
test('can sync columns from source', async () => {
const testProps = createProps();
await asyncRender({
...testProps,
datasource: { ...testProps.datasource, table_name: 'Vehicle Sales +' },
});
export const asyncRender = (renderProps: DatasourceEditorProps) =>
waitFor(() =>
render(<DatasourceEditor {...renderProps} {...routeProps} />, {
useRedux: true,
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
useRouter: true,
}),
const columnsTab = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
const syncButton = screen.getByText(/sync columns from source/i);
expect(syncButton).toBeInTheDocument();
// Use a Promise to track when fetchMock is called
const fetchPromise = new Promise<string>(resolve => {
fetchMock.get(
DATASOURCE_ENDPOINT,
(url: string) => {
resolve(url);
return [];
},
{ overwriteRoutes: true },
);
});
await userEvent.click(syncButton);
// Wait for the fetch to be called
const url = await fetchPromise;
expect(url).toContain('Vehicle+Sales%20%2B');
});
// to add, remove and modify columns accordingly
test('can modify columns', async () => {
const baseProps = createProps();
const limitedProps = {
...baseProps,
onChange: jest.fn(),
datasource: {
...baseProps.datasource,
table_name: 'Vehicle Sales +',
columns: baseProps.datasource.columns
.slice(0, 1)
.map(column => ({ ...column })),
},
};
fastRender(limitedProps);
await dismissDatasourceWarning();
const columnsTab = await screen.findByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
const getToggles = await screen.findAllByRole('button', {
name: /expand row/i,
});
await userEvent.click(getToggles[0]);
const getTextboxes = await screen.findAllByRole('textbox');
expect(getTextboxes.length).toBeGreaterThanOrEqual(5);
const inputLabel = screen.getByPlaceholderText('Label');
const inputCertDetails = screen.getByPlaceholderText('Certification details');
// Clear onChange mock to track user action callbacks
limitedProps.onChange.mockClear();
// Use fireEvent.change for speed - testing wiring, not per-keystroke behavior
fireEvent.change(inputLabel, { target: { value: 'test_label' } });
fireEvent.change(inputCertDetails, { target: { value: 'test_details' } });
// Verify the inputs were updated and onChange was triggered
await waitFor(() => {
expect(inputLabel).toHaveValue('test_label');
expect(inputCertDetails).toHaveValue('test_details');
expect(limitedProps.onChange).toHaveBeenCalled();
});
});
test('can delete columns', async () => {
const baseProps = createProps();
const limitedProps = {
...baseProps,
onChange: jest.fn(),
datasource: {
...baseProps.datasource,
table_name: 'Vehicle Sales +',
columns: baseProps.datasource.columns
.slice(0, 1)
.map(column => ({ ...column })),
},
};
fastRender(limitedProps);
await dismissDatasourceWarning();
const columnsTab = await screen.findByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
const columnsPanel = within(
await screen.findByRole('tabpanel', { name: /columns/i }),
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasourceEditor', () => {
beforeAll(() => {
jest.clearAllMocks();
});
beforeEach(async () => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { overwriteRoutes: true });
await asyncRender({
...props,
datasource: { ...props.datasource, table_name: 'Vehicle Sales +' },
});
const getToggles = await columnsPanel.findAllByRole('button', {
name: /expand row/i,
});
afterEach(() => {
fetchMock.restore();
// jest.clearAllMocks();
await userEvent.click(getToggles[0]);
const deleteButtons = await columnsPanel.findAllByRole('button', {
name: /delete item/i,
});
const initialCount = deleteButtons.length;
expect(initialCount).toBeGreaterThan(0);
test('renders Tabs', () => {
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
});
// Clear onChange mock to track delete action
limitedProps.onChange.mockClear();
test('can sync columns from source', async () => {
const columnsTab = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
await userEvent.click(deleteButtons[0]);
const syncButton = screen.getByText(/sync columns from source/i);
expect(syncButton).toBeInTheDocument();
// Use a Promise to track when fetchMock is called
const fetchPromise = new Promise<string>(resolve => {
fetchMock.get(
DATASOURCE_ENDPOINT,
(url: string) => {
resolve(url);
return [];
},
{ overwriteRoutes: true },
);
});
await userEvent.click(syncButton);
// Wait for the fetch to be called
const url = await fetchPromise;
expect(url).toContain('Vehicle+Sales%20%2B');
});
// to add, remove and modify columns accordingly
test('can modify columns', async () => {
const columnsTab = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
const getToggles = screen.getAllByRole('button', {
name: /expand row/i,
});
await userEvent.click(getToggles[0]);
const getTextboxes = await screen.findAllByRole('textbox');
expect(getTextboxes.length).toBeGreaterThanOrEqual(5);
const inputLabel = screen.getByPlaceholderText('Label');
const inputDescription = screen.getByPlaceholderText('Description');
const inputDtmFormat = screen.getByPlaceholderText('%Y-%m-%d');
const inputCertifiedBy = screen.getByPlaceholderText('Certified by');
const inputCertDetails = screen.getByPlaceholderText(
'Certification details',
);
// Clear onChange mock to track user action callbacks
props.onChange.mockClear();
await userEvent.type(inputLabel, 'test_label');
await userEvent.type(inputDescription, 'test');
await userEvent.type(inputDtmFormat, 'test');
await userEvent.type(inputCertifiedBy, 'test');
await userEvent.type(inputCertDetails, 'test');
// Verify the inputs were updated with the typed values
await waitFor(() => {
expect(inputLabel).toHaveValue('test_label');
expect(inputDescription).toHaveValue('test');
expect(inputDtmFormat).toHaveValue('test');
expect(inputCertifiedBy).toHaveValue('test');
expect(inputCertDetails).toHaveValue('test');
});
// Verify that onChange was triggered by user actions
await waitFor(() => {
expect(props.onChange).toHaveBeenCalled();
});
}, 40000);
test('can delete columns', async () => {
const columnsTab = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
const getToggles = screen.getAllByRole('button', {
name: /expand row/i,
});
await userEvent.click(getToggles[0]);
const deleteButtons = await screen.findAllByRole('button', {
name: /delete item/i,
});
const initialCount = deleteButtons.length;
expect(initialCount).toBeGreaterThan(0);
await userEvent.click(deleteButtons[0]);
await waitFor(() => {
const countRows = screen.getAllByRole('button', { name: /delete item/i });
expect(countRows.length).toBe(initialCount - 1);
});
}, 60000); // 60 seconds timeout to avoid timeouts
test('can add new columns', async () => {
const calcColsTab = screen.getByTestId('collection-tab-Calculated columns');
await userEvent.click(calcColsTab);
const addBtn = screen.getByRole('button', {
name: /add item/i,
});
expect(addBtn).toBeInTheDocument();
await userEvent.click(addBtn);
// newColumn (Column name) is the first textbox in the tab
await waitFor(() => {
const newColumn = screen.getAllByRole('textbox')[0];
expect(newColumn).toHaveValue('<new column>');
});
}, 60000);
test('renders isSqla fields', async () => {
const columnsTab = screen.getByRole('tab', {
name: /settings/i,
});
await userEvent.click(columnsTab);
const extraField = screen.getAllByText(/extra/i);
expect(extraField.length).toBeGreaterThan(0);
await waitFor(() =>
expect(
screen.getByText(/autocomplete query predicate/i),
).toBeInTheDocument();
expect(screen.getByText(/template parameters/i)).toBeInTheDocument();
columnsPanel.queryAllByRole('button', { name: /delete item/i }),
).toHaveLength(initialCount - 1),
);
await waitFor(() => expect(limitedProps.onChange).toHaveBeenCalled());
});
test('can add new columns', async () => {
const testProps = createProps();
await asyncRender({
...testProps,
datasource: { ...testProps.datasource, table_name: 'Vehicle Sales +' },
});
const calcColsTab = screen.getByTestId('collection-tab-Calculated columns');
await userEvent.click(calcColsTab);
const addBtn = screen.getByRole('button', {
name: /add item/i,
});
expect(addBtn).toBeInTheDocument();
await userEvent.click(addBtn);
// newColumn (Column name) is the first textbox in the tab
await waitFor(() => {
const newColumn = screen.getAllByRole('textbox')[0];
expect(newColumn).toHaveValue('<new column>');
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasourceEditor Source Tab', () => {
beforeAll(() => {
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
test('renders isSqla fields', async () => {
const testProps = createProps();
await asyncRender({
...testProps,
datasource: { ...testProps.datasource, table_name: 'Vehicle Sales +' },
});
beforeEach(async () => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { overwriteRoutes: true });
await asyncRender({
...props,
datasource: { ...props.datasource, table_name: 'Vehicle Sales +' },
});
const columnsTab = screen.getByRole('tab', {
name: /settings/i,
});
await userEvent.click(columnsTab);
const extraField = screen.getAllByText(/extra/i);
expect(extraField.length).toBeGreaterThan(0);
expect(screen.getByText(/autocomplete query predicate/i)).toBeInTheDocument();
expect(screen.getByText(/template parameters/i)).toBeInTheDocument();
});
test('Source Tab: edit mode', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
const testProps = createProps();
await asyncRender({
...testProps,
datasource: { ...testProps.datasource, table_name: 'Vehicle Sales +' },
});
afterEach(() => {
fetchMock.restore();
const getLockBtn = screen.getByRole('img', { name: /lock/i });
await userEvent.click(getLockBtn);
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
});
const virtualRadioBtn = screen.getByRole('radio', {
name: /virtual \(sql\)/i,
});
afterAll(() => {
(isFeatureEnabled as jest.Mock).mockRestore();
expect(physicalRadioBtn).toBeEnabled();
expect(virtualRadioBtn).toBeEnabled();
});
test('Source Tab: readOnly mode', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
const testProps = createProps();
await asyncRender({
...testProps,
datasource: { ...testProps.datasource, table_name: 'Vehicle Sales +' },
});
test('Source Tab: edit mode', async () => {
const getLockBtn = screen.getByRole('img', { name: /lock/i });
await userEvent.click(getLockBtn);
const getLockBtn = screen.getByRole('img', { name: /lock/i });
expect(getLockBtn).toBeInTheDocument();
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
});
const virtualRadioBtn = screen.getByRole('radio', {
name: /virtual \(sql\)/i,
});
expect(physicalRadioBtn).toBeEnabled();
expect(virtualRadioBtn).toBeEnabled();
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
});
const virtualRadioBtn = screen.getByRole('radio', {
name: /virtual \(sql\)/i,
});
test('Source Tab: readOnly mode', () => {
const getLockBtn = screen.getByRole('img', { name: /lock/i });
expect(getLockBtn).toBeInTheDocument();
expect(physicalRadioBtn).toBeDisabled();
expect(virtualRadioBtn).toBeDisabled();
});
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
});
const virtualRadioBtn = screen.getByRole('radio', {
name: /virtual \(sql\)/i,
});
test('calls onChange with empty SQL when switching to physical dataset', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
expect(physicalRadioBtn).toBeDisabled();
expect(virtualRadioBtn).toBeDisabled();
const testProps = createProps();
await asyncRender({
...testProps,
datasource: {
...testProps.datasource,
table_name: 'Vehicle Sales +',
type: DatasourceType.Query,
sql: 'SELECT * FROM users',
},
});
test('calls onChange with empty SQL when switching to physical dataset', async () => {
// Clean previous render
cleanup();
// Enable edit mode
const getLockBtn = screen.getByRole('img', { name: /lock/i });
await userEvent.click(getLockBtn);
props.onChange.mockClear();
// Switch to physical dataset
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
});
await userEvent.click(physicalRadioBtn);
await asyncRender({
...props,
datasource: {
...props.datasource,
table_name: 'Vehicle Sales +',
type: DatasourceType.Query,
sql: 'SELECT * FROM users',
},
});
// Assert that the latest onChange call has empty SQL
expect(testProps.onChange).toHaveBeenCalled();
const updatedDatasource = testProps.onChange.mock.calls[0];
expect(updatedDatasource[0].sql).toBe('');
});
// Enable edit mode
const getLockBtn = screen.getByRole('img', { name: /lock/i });
await userEvent.click(getLockBtn);
test('properly renders the metric information', async () => {
await asyncRender(createProps());
// Switch to physical dataset
const physicalRadioBtn = screen.getByRole('radio', {
name: /physical \(table or view\)/i,
});
await userEvent.click(physicalRadioBtn);
const metricButton = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
// Assert that the latest onChange call has empty SQL
expect(props.onChange).toHaveBeenCalled();
const updatedDatasource = props.onChange.mock.calls[0];
expect(updatedDatasource[0].sql).toBe('');
const expandToggle = await screen.findAllByLabelText(/expand row/i);
// Metrics are sorted by ID descending, so metric with id=1 (which has certification)
// is at position 6 (last). Expand that one.
await userEvent.click(expandToggle[6]);
// Wait for fields to appear
const certificationDetails = await screen.findByPlaceholderText(
/certification details/i,
);
const certifiedBy = await screen.findByPlaceholderText(/certified by/i);
expect(certificationDetails).toHaveValue('foo');
expect(certifiedBy).toHaveValue('someone');
});
test('properly updates the metric information', async () => {
await asyncRender(createProps());
const metricButton = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
const expandToggle = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggle[1]);
const certifiedBy = await screen.findByPlaceholderText(/certified by/i);
const certificationDetails = await screen.findByPlaceholderText(
/certification details/i,
);
// Use fireEvent.change for speed - we're testing wiring, not keystroke behavior
fireEvent.change(certifiedBy, {
target: { value: 'I am typing a new name' },
});
fireEvent.change(certificationDetails, {
target: { value: 'I am typing something new' },
});
await waitFor(() => {
expect(certifiedBy).toHaveValue('I am typing a new name');
expect(certificationDetails).toHaveValue('I am typing something new');
});
});
test('shows the default datetime column', async () => {
await asyncRender(createProps());
const columnsButton = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsButton);
const dsDefaultDatetimeRadio = screen.getByTestId('radio-default-dttm-ds');
expect(dsDefaultDatetimeRadio).toBeChecked();
const genderDefaultDatetimeRadio = screen.getByTestId(
'radio-default-dttm-gender',
);
expect(genderDefaultDatetimeRadio).not.toBeChecked();
});
test('allows choosing only temporal columns as the default datetime', async () => {
await asyncRender(createProps());
const columnsButton = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsButton);
const dsDefaultDatetimeRadio = screen.getByTestId('radio-default-dttm-ds');
expect(dsDefaultDatetimeRadio).toBeEnabled();
const genderDefaultDatetimeRadio = screen.getByTestId(
'radio-default-dttm-gender',
);
expect(genderDefaultDatetimeRadio).toBeDisabled();
});
test('aborts pending requests on unmount without errors', async () => {
// Spy on console.error to catch React warnings
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const props = createProps();
// Mock formatQuery to delay response
const formatQueryDeferred = createDeferredPromise();
props.formatQuery!.mockReturnValue(formatQueryDeferred.promise);
const { unmount } = await asyncRender(props);
// Call formatQuery prop directly to trigger the async operation
// In real usage, this is called via onQueryFormat() method
props.formatQuery!('SELECT * FROM table');
// Unmount BEFORE request completes
unmount();
// Resolve the promise after unmount
formatQueryDeferred.resolve({ json: { result: 'SELECT * FROM table' } });
// Wait for async cleanup
await cleanupAsyncOperations();
// CRITICAL: No setState warnings
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('setState'),
);
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('unmounted component'),
);
consoleErrorSpy.mockRestore();
});
test('resets loading state when request aborted', async () => {
// Spy on console.error to catch React warnings
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const props = createProps();
const { unmount } = await asyncRender(props);
// Navigate to Usage tab
const usageTab = screen.getByRole('tab', { name: /usage/i });
await userEvent.click(usageTab);
// Unmount while usage data is loading
unmount();
// Should not throw "Can't perform a React state update on unmounted component"
await cleanupAsyncOperations();
// Verify no React warnings
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('setState'),
);
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('unmounted component'),
);
consoleErrorSpy.mockRestore();
});
test('allows simultaneous different async operations', async () => {
const props = createProps();
await asyncRender(props);
// Both operations should be able to run simultaneously without interference
// This test verifies per-request controllers don't cancel each other
// Note: We can't easily trigger formatSql and syncMetadata buttons in tests
// without more complex setup, but the pattern is tested via unit structure
expect(props.datasource).toBeDefined();
});
test('fetchUsageData rethrows AbortError without updating state', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const props = createProps();
const { unmount } = await asyncRender(props);
// Mock the API to reject with AbortError
fetchMock.get(
'glob:*/api/v1/chart/*',
() => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
throw error;
},
{ overwriteRoutes: true },
);
// Navigate to Usage tab to trigger fetchUsageData
const usageTab = screen.getByRole('tab', { name: /usage/i });
await userEvent.click(usageTab);
// Unmount immediately
unmount();
await cleanupAsyncOperations();
// Verify no setState warnings
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('setState'),
);
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('unmounted component'),
);
consoleErrorSpy.mockRestore();
});
test('immediate unmount after mount does not cause unhandled rejection from initial fetchUsageData', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Mock chart API to delay long enough for unmount to happen first
fetchMock.get(
'glob:*/api/v1/chart/*',
new Promise(() => {}), // Never resolves - will be aborted
{ overwriteRoutes: true },
);
const props = createProps();
// Use fastRender to mount without waiting for async completion
// This triggers fetchUsageData() in componentDidMount
const { unmount } = fastRender(props);
// Immediately unmount while initial fetchUsageData is in-flight
// This calls AbortController.abort() via componentWillUnmount
unmount();
await cleanupAsyncOperations();
// CRITICAL: The .catch() handler in componentDidMount should swallow AbortError
// No unhandled rejection or React warnings should appear
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Unhandled'),
);
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('unmounted component'),
);
consoleErrorSpy.mockRestore();
});

View File

@@ -0,0 +1,177 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import {
render,
screen,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import mockDatasource from 'spec/fixtures/mockDatasource';
import type { DatasetObject } from 'src/features/datasets/types';
import DatasourceEditor from '..';
export interface DatasourceEditorProps {
datasource: DatasetObject;
addSuccessToast: () => void;
addDangerToast: () => void;
onChange: jest.MockedFunction<
(datasource: DatasetObject, errors?: unknown) => void
>;
formatQuery?: jest.Mock;
columnLabels?: Record<string, string>;
columnLabelTooltips?: Record<string, string>;
}
/**
* Factory function that creates fresh props for each test.
* Deep clones the datasource fixture to prevent test pollution from mutations.
*/
export const createProps = (): DatasourceEditorProps => ({
datasource: JSON.parse(JSON.stringify(mockDatasource['7__table'])),
addSuccessToast: () => {},
addDangerToast: () => {},
onChange: jest.fn(),
formatQuery: jest.fn().mockResolvedValue({ json: { result: '' } }),
columnLabels: {
state: 'State',
},
columnLabelTooltips: {
state: 'This is a tooltip for state',
},
});
/**
* @deprecated Use createProps() factory instead to prevent test pollution.
* Kept for backward compatibility during migration.
*/
export const props: DatasourceEditorProps = {
datasource: mockDatasource['7__table'],
addSuccessToast: () => {},
addDangerToast: () => {},
onChange: jest.fn(),
columnLabels: {
state: 'State',
},
columnLabelTooltips: {
state: 'This is a tooltip for state',
},
};
export const DATASOURCE_ENDPOINT =
'glob:*/datasource/external_metadata_by_name/*';
const routeProps = {
history: {},
location: {},
match: {},
};
export const asyncRender = (renderProps: DatasourceEditorProps) =>
waitFor(() =>
render(<DatasourceEditor {...renderProps} {...routeProps} />, {
useRedux: true,
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
useRouter: true,
}),
);
/**
* Fast render without waitFor wrapper.
* Use when mount side-effects aren't being asserted.
* After calling, use findBy* to ensure mount completion.
*/
export const fastRender = (renderProps: DatasourceEditorProps) =>
render(<DatasourceEditor {...renderProps} {...routeProps} />, {
useRedux: true,
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
useRouter: true,
});
/**
* Setup common API mocks for DatasourceEditor tests.
* Mocks the 3 endpoints called on component mount to prevent test hangs and async warnings.
*/
export const setupDatasourceEditorMocks = () => {
fetchMock.get(
url => url.includes('/api/v1/chart/'),
{ result: [], count: 0, ids: [] },
{ overwriteRoutes: true },
);
fetchMock.get(
url => url.includes('/api/v1/database/'),
{ result: [], count: 0, ids: [] },
{ overwriteRoutes: true },
);
fetchMock.get(
url => url.includes('/api/v1/dataset/related/owners'),
{ result: [], count: 0 },
{ overwriteRoutes: true },
);
};
/**
* Cleanup async operations to prevent test pollution.
* Flushes microtasks and animation frames.
* Call this in afterEach to prevent "document global is not defined" errors.
*
* Note: Uses real timers (not fake timers), so jest.runOnlyPendingTimers() is not used.
*/
export const cleanupAsyncOperations = async () => {
// Flush promise microtasks first
await Promise.resolve();
// Wait for pending animation frames (guard for non-DOM environments)
// Loop twice to catch chained rAFs (max 2 to stay idempotent)
if (typeof requestAnimationFrame !== 'undefined') {
await new Promise(resolve => requestAnimationFrame(resolve));
await new Promise(resolve => requestAnimationFrame(resolve));
}
// Flush microtasks again after rAFs
await Promise.resolve();
// Final flush via setTimeout(0)
await new Promise(resolve => setTimeout(resolve, 0));
};
/**
* Dismiss the datasource warning modal if present.
* Centralized helper to avoid duplication across test files.
*/
export const dismissDatasourceWarning = async () => {
const closeButton = screen.queryByRole('button', { name: /close/i });
if (closeButton) {
await userEvent.click(closeButton);
}
};
/**
* Creates a deferred promise that can be manually resolved/rejected.
* Useful for controlling timing in abort/unmount tests.
*/
export function createDeferredPromise<T = any>() {
let resolve: (value: T) => void;
let reject: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve: resolve!, reject: reject! };
}

View File

@@ -17,176 +17,148 @@
* under the License.
*/
import fetchMock from 'fetch-mock';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import {
screen,
waitFor,
userEvent,
selectOption,
} from 'spec/helpers/testing-library';
import type { DatasetObject } from 'src/features/datasets/types';
import DatasourceEditor from '..';
import { props, DATASOURCE_ENDPOINT } from './DatasourceEditor.test';
import {
createProps,
DATASOURCE_ENDPOINT,
setupDatasourceEditorMocks,
cleanupAsyncOperations,
asyncRender,
dismissDatasourceWarning,
} from './DatasourceEditor.test.utils';
type MetricType = DatasetObject['metrics'][number];
// Optimized render function that doesn't use waitFor initially
// This helps prevent one source of the timeout
const fastRender = (renderProps: typeof props) =>
render(<DatasourceEditor {...renderProps} />, {
useRedux: true,
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
});
// Factory function for currency props - returns fresh copy to prevent test pollution
// Using single metric to minimize DOM size for faster test execution while still validating currency functionality
const createPropsWithCurrency = () => {
const baseProps = createProps();
return {
...baseProps,
datasource: {
...baseProps.datasource,
metrics: [
{
...baseProps.datasource.metrics[0],
currency: { symbol: 'USD', symbolPosition: 'prefix' },
},
],
},
onChange: jest.fn(),
};
};
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasourceEditor Currency Tests', () => {
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { overwriteRoutes: true });
});
afterEach(() => {
fetchMock.restore();
});
// The problematic test, now optimized
test('renders currency controls', async () => {
// Setup a metric with currency data
const propsWithCurrency = {
...props,
datasource: {
...props.datasource,
metrics: [
{
...props.datasource.metrics[0],
currency: { symbol: 'USD', symbolPosition: 'prefix' },
},
...props.datasource.metrics.slice(1),
],
},
// Fresh mock for each test to avoid interference
onChange: jest.fn(),
};
// Faster rendering without initial waitFor
fastRender(propsWithCurrency);
// Navigate to metrics tab
const metricButton = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
// Find and expand the metric row with currency
// Metrics are sorted by ID descending, so metric with id=1 (which has currency)
// is at position 6 (last). Expand that one.
const expandToggles = await screen.findAllByLabelText(
/expand row/i,
{},
{ timeout: 5000 },
);
await userEvent.click(expandToggles[6]);
// Check for currency section header
const currencyHeader = await screen.findByText(
'Metric currency',
{},
{ timeout: 5000 },
);
expect(currencyHeader).toBeVisible();
// Check prefix/suffix dropdown - first find the wrapper
const positionSelector = screen.getByRole('combobox', {
name: 'Currency prefix or suffix',
});
// Verify current value is 'Prefix'
expect(positionSelector).toBeInTheDocument();
// Open the dropdown
await userEvent.click(positionSelector);
// Wait for dropdown to open and find the suffix option
const suffixOption = await waitFor(
() => {
// Look for 'suffix' option in the dropdown
const options = document.querySelectorAll('.ant-select-item-option');
const suffixOpt = Array.from(options).find(opt =>
opt.textContent?.toLowerCase().includes('suffix'),
);
if (!suffixOpt) throw new Error('Suffix option not found');
return suffixOpt;
},
{ timeout: 5000 },
);
// Clear the mock to ensure clean state
propsWithCurrency.onChange.mockClear();
// Click the suffix option
await userEvent.click(suffixOption);
// Check if onChange was called with the expected parameters
await waitFor(
() => {
expect(propsWithCurrency.onChange).toHaveBeenCalledTimes(1);
const callArg = propsWithCurrency.onChange.mock.calls[0][0];
// More robust check for the metrics array
const metrics = callArg.metrics || [];
const updatedMetric = metrics.find(
(m: MetricType) =>
m.currency && m.currency.symbolPosition === 'suffix',
);
expect(updatedMetric).toBeDefined();
expect(updatedMetric?.currency?.symbol).toBe('USD');
},
{ timeout: 5000 },
);
// Now test changing the currency symbol
const currencySymbol = await screen.findByRole(
'combobox',
{
name: 'Currency symbol',
},
{ timeout: 5000 },
);
// Open the currency dropdown
await userEvent.click(currencySymbol);
// Wait for dropdown to open and find the GBP option
const gbpOption = await waitFor(
() => {
// Look for 'GBP' option in the dropdown
const options = document.querySelectorAll('.ant-select-item-option');
const gbpOpt = Array.from(options).find(opt =>
opt.textContent?.includes('GBP'),
);
if (!gbpOpt) throw new Error('GBP option not found');
return gbpOpt;
},
{ timeout: 5000 },
);
// Clear mock again
propsWithCurrency.onChange.mockClear();
// Click the GBP option
await userEvent.click(gbpOption);
// Verify the onChange with GBP was called
await waitFor(
() => {
expect(propsWithCurrency.onChange).toHaveBeenCalledTimes(1);
const callArg = propsWithCurrency.onChange.mock.calls[0][0];
// More robust check
const metrics = callArg.metrics || [];
const updatedMetric = metrics.find(
(m: MetricType) => m.currency && m.currency.symbol === 'GBP',
);
expect(updatedMetric).toBeDefined();
expect(updatedMetric?.currency?.symbolPosition).toBe('suffix');
},
{ timeout: 5000 },
);
}, 60000);
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { overwriteRoutes: true });
setupDatasourceEditorMocks();
});
afterEach(async () => {
await cleanupAsyncOperations();
fetchMock.restore();
});
test('renders currency section in metrics tab', async () => {
const testProps = createPropsWithCurrency();
await asyncRender(testProps);
await dismissDatasourceWarning();
// Navigate to metrics tab
const metricButton = await screen.findByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
// Expand the single metric row with currency
const expandToggles = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggles[0]);
// Check for currency section header
const currencyHeader = await screen.findByText('Metric currency');
expect(currencyHeader).toBeVisible();
// Verify currency position selector exists
const positionSelector = screen.getByRole('combobox', {
name: 'Currency prefix or suffix',
});
expect(positionSelector).toBeInTheDocument();
// Verify currency symbol selector exists
const symbolSelector = screen.getByRole('combobox', {
name: 'Currency symbol',
});
expect(symbolSelector).toBeInTheDocument();
});
// Allow extra headroom for dropdown render on slower CI runners
test('changes currency position from prefix to suffix', async () => {
const testProps = createPropsWithCurrency();
await asyncRender(testProps);
await dismissDatasourceWarning();
// Navigate to metrics tab
const metricButton = await screen.findByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
// Expand the metric with currency
const expandToggles = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggles[0]);
// Select suffix option via shared helper (rc-virtual-list aware)
await selectOption('Suffix', 'Currency prefix or suffix');
await cleanupAsyncOperations();
// Verify onChange was called with suffix position
await waitFor(() => {
expect(testProps.onChange).toHaveBeenCalledTimes(1);
const callArg = testProps.onChange.mock.calls[0][0];
const metrics = callArg.metrics || [];
const updatedMetric = metrics.find(
(m: MetricType) => m.currency && m.currency.symbolPosition === 'suffix',
);
expect(updatedMetric?.currency?.symbol).toBe('USD');
});
}, 30000);
// Allow extra headroom for dropdown render on slower CI runners
test('changes currency symbol from USD to GBP', async () => {
const testProps = createPropsWithCurrency();
await asyncRender(testProps);
await dismissDatasourceWarning();
// Navigate to metrics tab
const metricButton = await screen.findByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
// Expand the metric with currency
const expandToggles = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggles[0]);
// Select GBP option via shared helper (rc-virtual-list aware)
await selectOption('£ (GBP)', 'Currency symbol');
await cleanupAsyncOperations();
// Verify onChange was called with GBP
await waitFor(() => {
expect(testProps.onChange).toHaveBeenCalledTimes(1);
const callArg = testProps.onChange.mock.calls[0][0];
const metrics = callArg.metrics || [];
const updatedMetric = metrics.find(
(m: MetricType) => m.currency && m.currency.symbol === 'GBP',
);
expect(updatedMetric?.currency?.symbolPosition).toBe('prefix');
});
}, 30000);

View File

@@ -1,129 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { screen, userEvent, waitFor } from 'spec/helpers/testing-library';
import {
props,
asyncRender,
DATASOURCE_ENDPOINT,
} from './DatasourceEditor.test';
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasourceEditor RTL Metrics Tests', () => {
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { overwriteRoutes: true });
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
test('properly renders the metric information', async () => {
await asyncRender(props);
const metricButton = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
const expandToggle = await screen.findAllByLabelText(/expand row/i);
// Metrics are sorted by ID descending, so metric with id=1 (which has certification)
// is at position 6 (last). Expand that one.
await userEvent.click(expandToggle[6]);
// Wait for fields to appear
const certificationDetails = await screen.findByPlaceholderText(
/certification details/i,
);
const certifiedBy = await screen.findByPlaceholderText(/certified by/i);
expect(certificationDetails).toHaveValue('foo');
expect(certifiedBy).toHaveValue('someone');
});
test('properly updates the metric information', async () => {
await asyncRender(props);
const metricButton = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricButton);
const expandToggle = await screen.findAllByLabelText(/expand row/i);
await userEvent.click(expandToggle[1]);
const certifiedBy = await screen.findByPlaceholderText(/certified by/i);
// Use userEvent.clear and userEvent.type instead of directly setting value
await userEvent.clear(certifiedBy);
await userEvent.type(certifiedBy, 'I am typing a new name');
const certificationDetails = await screen.findByPlaceholderText(
/certification details/i,
);
await waitFor(() => {
expect(certifiedBy).toHaveValue('I am typing a new name');
});
await userEvent.clear(certificationDetails);
await userEvent.type(certificationDetails, 'I am typing something new');
await waitFor(() => {
expect(certificationDetails).toHaveValue('I am typing something new');
});
});
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasourceEditor RTL Columns Tests', () => {
beforeEach(() => {
fetchMock.get(DATASOURCE_ENDPOINT, [], { overwriteRoutes: true });
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
test('shows the default datetime column', async () => {
await asyncRender(props);
const columnsButton = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsButton);
const dsDefaultDatetimeRadio = screen.getByTestId('radio-default-dttm-ds');
expect(dsDefaultDatetimeRadio).toBeChecked();
const genderDefaultDatetimeRadio = screen.getByTestId(
'radio-default-dttm-gender',
);
expect(genderDefaultDatetimeRadio).not.toBeChecked();
});
test('allows choosing only temporal columns as the default datetime', async () => {
await asyncRender(props);
const columnsButton = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsButton);
const dsDefaultDatetimeRadio = screen.getByTestId('radio-default-dttm-ds');
expect(dsDefaultDatetimeRadio).toBeEnabled();
const genderDefaultDatetimeRadio = screen.getByTestId(
'radio-default-dttm-gender',
);
expect(genderDefaultDatetimeRadio).toBeDisabled();
});
});

View File

@@ -132,7 +132,15 @@ export function updateColumns(prevCols, newCols, addSuccessToast) {
return columnChanges;
}
export async function fetchSyncedColumns(datasource) {
/**
* Fetches column metadata from the datasource's underlying table/view.
* Used to sync dataset columns with the database schema.
*
* @param {Object} datasource - The datasource object
* @param {AbortSignal} [signal] - Optional AbortSignal to cancel the request
* @returns {Promise<Array>} Array of column metadata objects
*/
export async function fetchSyncedColumns(datasource, signal) {
const params = {
datasource_type: datasource.type || datasource.datasource_type,
database_name:
@@ -152,6 +160,6 @@ export async function fetchSyncedColumns(datasource) {
const endpoint = `/datasource/external_metadata_by_name/?q=${rison.encode_uri(
params,
)}`;
const { json } = await SupersetClient.get({ endpoint });
const { json } = await SupersetClient.get({ endpoint, signal });
return json;
}

View File

@@ -774,22 +774,6 @@ export function clearAllChartStates() {
return { type: CLEAR_ALL_CHART_STATES };
}
// What-If Analysis actions
export const SET_WHAT_IF_MODIFICATIONS = 'SET_WHAT_IF_MODIFICATIONS';
export function setWhatIfModifications(modifications) {
return { type: SET_WHAT_IF_MODIFICATIONS, modifications };
}
export const CLEAR_WHAT_IF_MODIFICATIONS = 'CLEAR_WHAT_IF_MODIFICATIONS';
export function clearWhatIfModifications() {
return { type: CLEAR_WHAT_IF_MODIFICATIONS };
}
export const TOGGLE_WHAT_IF_PANEL = 'TOGGLE_WHAT_IF_PANEL';
export function toggleWhatIfPanel(isOpen) {
return { type: TOGGLE_WHAT_IF_PANEL, isOpen };
}
// Undo history ---------------------------------------------------------------
export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {

View File

@@ -312,7 +312,6 @@ export const hydrateDashboard =
dashboardState?.datasetsStatus || ResourceStatus.Loading,
chartStates: chartStates || dashboardState?.chartStates || {},
chartCustomizationItems,
whatIfModifications: [],
},
dashboardLayout,
},

View File

@@ -40,7 +40,6 @@ const TABS_KEYS = {
const BuilderComponentPane = ({ topOffset = 0 }) => (
<div
className="dashboard-builder-sidepane"
data-test="dashboard-builder-sidepane"
css={css`
position: sticky;

View File

@@ -19,7 +19,6 @@
/* eslint-env browser */
import cx from 'classnames';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useHistory } from 'react-router-dom';
import { addAlpha, JsonObject, t, useElementOnScreen } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/ui';
import { useDispatch, useSelector } from 'react-redux';
@@ -43,7 +42,6 @@ import {
import {
setDirectPathToChild,
setEditMode,
toggleWhatIfPanel,
} from 'src/dashboard/actions/dashboardState';
import {
deleteTopLevelTabs,
@@ -57,8 +55,6 @@ import {
DashboardStandaloneMode,
} from 'src/dashboard/util/constants';
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
import WhatIfPanel from 'src/dashboard/components/WhatIfDrawer';
import WhatIfBanner from 'src/dashboard/components/WhatIfBanner';
import { useUiConfig } from 'src/components/UiConfigContext';
import ResizableSidebar from 'src/components/ResizableSidebar';
import {
@@ -276,16 +272,12 @@ const DashboardContentWrapper = styled.div`
const StyledDashboardContent = styled.div<{
editMode: boolean;
marginLeft: number;
marginRight: number;
hasWhatIfPanel: boolean;
}>`
${({ theme, editMode, marginLeft, marginRight, hasWhatIfPanel }) => css`
${({ theme, editMode, marginLeft }) => css`
background-color: ${theme.colorBgLayout};
display: grid;
grid-template-columns: 1fr ${hasWhatIfPanel ? 'auto' : ''} ${editMode
? 'auto'
: ''};
grid-template-rows: auto 1fr;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
height: auto;
flex: 1;
@@ -295,23 +287,27 @@ const StyledDashboardContent = styled.div<{
}
.grid-container {
grid-column: 1;
grid-row: 2;
/* without this, the grid will not get smaller upon toggling the builder panel on */
width: 0;
flex: 1;
position: relative;
margin: ${theme.sizeUnit * 4}px;
margin-left: ${marginLeft}px;
margin-right: ${marginRight}px;
min-width: 0; /* Prevent grid blowout */
${editMode &&
`
max-width: calc(100% - ${
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
}px);
`}
/* this is the ParentSize wrapper */
& > div:first-child {
& > div:first-child {
height: 100% !important;
}
}
.dashboard-builder-sidepane {
grid-column: 2;
grid-row: 1 / -1; /* Span all rows */
width: ${BUILDER_SIDEPANEL_WIDTH}px;
z-index: 1;
}
@@ -389,35 +385,6 @@ const DashboardBuilder = () => {
const filterBarOrientation = useSelector<RootState, FilterBarOrientation>(
({ dashboardInfo }) => dashboardInfo.filterBarOrientation,
);
const whatIfPanelOpen = useSelector<RootState, boolean>(
({ dashboardState }) => dashboardState.whatIfPanelOpen ?? false,
);
// Read simulation ID from URL query parameter
const location = useLocation();
const history = useHistory();
const initialSimulationId = useMemo(() => {
const params = new URLSearchParams(location.search);
const simParam = params.get('simulation');
return simParam ? parseInt(simParam, 10) : null;
}, [location.search]);
// Auto-open What-If panel if simulation ID is in URL
useEffect(() => {
if (initialSimulationId && !whatIfPanelOpen) {
dispatch(toggleWhatIfPanel(true));
}
}, [initialSimulationId, dispatch, whatIfPanelOpen]);
const handleCloseWhatIfPanel = useCallback(() => {
dispatch(toggleWhatIfPanel(false));
// Clear simulation param from URL when closing
const params = new URLSearchParams(location.search);
if (params.has('simulation')) {
params.delete('simulation');
history.replace({ search: params.toString() });
}
}, [dispatch, location.search, history]);
const handleChangeTab = useCallback(
({ pathToTabIndex }: { pathToTabIndex: string[] }) => {
@@ -596,8 +563,6 @@ const DashboardBuilder = () => {
? theme.sizeUnit * 4
: theme.sizeUnit * 8;
const dashboardContentMarginRight = theme.sizeUnit * 4;
const renderChild = useCallback(
adjustedWidth => {
const filterBarWidth = dashboardFiltersOpen
@@ -709,10 +674,7 @@ const DashboardBuilder = () => {
className="dashboard-content"
editMode={editMode}
marginLeft={dashboardContentMarginLeft}
marginRight={dashboardContentMarginRight}
hasWhatIfPanel={!editMode && whatIfPanelOpen}
>
{!editMode && <WhatIfBanner topOffset={barTopOffset} />}
{showDashboard ? (
missingInitialFilters.length > 0 ? (
<div
@@ -722,8 +684,6 @@ const DashboardBuilder = () => {
align-items: center;
justify-content: center;
flex: 1;
grid-column: 1;
grid-row: 2;
& div {
width: 500px;
}
@@ -745,14 +705,6 @@ const DashboardBuilder = () => {
) : (
<Loading />
)}
{!editMode && (
<WhatIfPanel
visible={whatIfPanelOpen}
onClose={handleCloseWhatIfPanel}
topOffset={barTopOffset}
initialSimulationId={initialSimulationId}
/>
)}
{editMode && <BuilderComponentPane topOffset={barTopOffset} />}
</StyledDashboardContent>
</DashboardContentWrapper>

View File

@@ -88,7 +88,6 @@ import {
setMaxUndoHistoryExceeded,
setRefreshFrequency,
setUnsavedChanges,
toggleWhatIfPanel,
} from '../../actions/dashboardState';
import { logEvent } from '../../../logger/actions';
import { dashboardInfoChanged } from '../../actions/dashboardInfo';
@@ -107,26 +106,6 @@ const editButtonStyle = theme => css`
color: ${theme.colorPrimary};
`;
const whatIfButtonStyle = theme => css`
display: flex;
align-items: center;
justify-content: center;
width: ${theme.sizeUnit * 8}px;
height: ${theme.sizeUnit * 8}px;
margin-right: ${theme.sizeUnit * 2}px;
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
background: ${theme.colorBgContainer};
color: ${theme.colorText};
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: ${theme.colorPrimary};
color: ${theme.colorPrimary};
}
`;
const actionButtonsStyle = theme => css`
display: flex;
align-items: center;
@@ -216,7 +195,6 @@ const Header = () => {
maxUndoHistoryExceeded,
editMode,
lastModifiedTime,
whatIfPanelOpen,
} = useSelector(
state => ({
expandedSlices: state.dashboardState.expandedSlices,
@@ -232,7 +210,6 @@ const Header = () => {
maxUndoHistoryExceeded: !!state.dashboardState.maxUndoHistoryExceeded,
editMode: !!state.dashboardState.editMode,
lastModifiedTime: state.lastModifiedTime,
whatIfPanelOpen: !!state.dashboardState.whatIfPanelOpen,
}),
shallowEqual,
);
@@ -738,17 +715,6 @@ const Header = () => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
<Tooltip title={t('What-if playground')}>
<button
type="button"
css={whatIfButtonStyle}
onClick={() => dispatch(toggleWhatIfPanel(!whatIfPanelOpen))}
data-test="what-if-button"
aria-label={t('What-if playground')}
>
<Icons.StarFilled iconSize="m" />
</button>
</Tooltip>
{userCanEdit && (
<Button
buttonStyle="secondary"
@@ -783,7 +749,6 @@ const Header = () => {
undoLength,
userCanEdit,
userCanSaveAs,
whatIfPanelOpen,
],
);

View File

@@ -1,152 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Button } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { clearWhatIfModifications } from 'src/dashboard/actions/dashboardState';
import { restoreOriginalChartData } from 'src/components/Chart/chartAction';
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
import { RootState, WhatIfModification } from 'src/dashboard/types';
const EMPTY_MODIFICATIONS: WhatIfModification[] = [];
/**
* Banner container positioned at top of dashboard content, next to the What-If panel.
*
* Layout strategy:
* - Grid positioning: column 1, row 1 (above dashboard content, next to panel)
* - position: sticky with top: topOffset to stick below the dashboard header
* - z-index: 10 to stay above chart content while scrolling
* - align-self: start prevents the banner from stretching vertically
*/
const BannerContainer = styled.div<{ topOffset: number }>`
grid-column: 1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: ${({ theme }) => theme.sizeUnit * 3}px;
padding: ${({ theme }) => theme.sizeUnit * 2}px
${({ theme }) => theme.sizeUnit * 4}px;
margin-bottom: 0;
background-color: ${({ theme }) => theme.colorSuccessBg};
border-bottom: 1px solid ${({ theme }) => theme.colorSuccessBorder};
position: sticky;
top: ${({ topOffset }) => topOffset}px;
z-index: 10;
align-self: start;
`;
const BannerContent = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
color: ${({ theme }) => theme.colorSuccess};
font-weight: ${({ theme }) => theme.fontWeightStrong};
`;
const Separator = styled.span`
color: ${({ theme }) => theme.colorSuccess};
opacity: 0.5;
`;
const ExitButton = styled(Button)`
&& {
color: ${({ theme }) => theme.colorSuccess};
border-color: ${({ theme }) => theme.colorSuccess};
background-color: ${({ theme }) => theme.colorSuccessBg};
&:hover {
color: ${({ theme }) => theme.colorSuccessHover};
border-color: ${({ theme }) => theme.colorSuccessHover};
background-color: ${({ theme }) => theme.colorSuccessBgHover};
}
}
`;
interface WhatIfBannerProps {
topOffset: number;
}
const WhatIfBanner = ({ topOffset }: WhatIfBannerProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const whatIfModifications = useSelector<RootState, WhatIfModification[]>(
state => state.dashboardState.whatIfModifications ?? EMPTY_MODIFICATIONS,
);
const { columnToChartIds, numericColumns } = useNumericColumns();
const handleExitWhatIf = useCallback(() => {
const affectedChartIds = new Set<number>();
whatIfModifications.forEach(mod => {
const chartIds = columnToChartIds.get(mod.column) || [];
chartIds.forEach(id => affectedChartIds.add(id));
});
// Clear what-if modifications
dispatch(clearWhatIfModifications());
// Restore original chart data from cache (instant, no re-query needed)
affectedChartIds.forEach(chartId => {
dispatch(restoreOriginalChartData(chartId));
});
}, [dispatch, whatIfModifications, columnToChartIds]);
if (whatIfModifications.length === 0) {
return null;
}
const modification = whatIfModifications[0];
const percentageChange = formatPercentageChange(modification.multiplier);
// Get verbose name from numericColumns if available
const columnInfo = numericColumns.find(
col => col.columnName === modification.column,
);
const displayName = columnInfo?.verboseName || modification.column;
return (
<BannerContainer data-test="what-if-banner" topOffset={topOffset}>
<BannerContent>
<Icons.ExperimentOutlined iconSize="m" iconColor={theme.colorSuccess} />
<span>{t('What-if mode active')}</span>
<Separator>|</Separator>
<span>
{t('Showing simulated data with %s %s', displayName, percentageChange)}
</span>
</BannerContent>
<ExitButton
buttonSize="small"
onClick={handleExitWhatIf}
data-test="exit-what-if-button"
>
<Icons.CloseOutlined iconSize="s" />
{t('Exit what-if mode')}
</ExitButton>
</BannerContainer>
);
};
export default WhatIfBanner;

View File

@@ -1,102 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo } from 'react';
import { t } from '@superset-ui/core';
import { Tooltip, Popover } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { Datasource } from 'src/dashboard/types';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover';
import { FilterButtonStyled, FilterPopoverContent } from './styles';
interface FilterButtonProps {
filterPopoverVisible: boolean;
currentAdhocFilter: AdhocFilter | null;
selectedColumn: string | undefined;
selectedDatasource: Datasource | null;
filterColumnOptions: Datasource['columns'];
onOpenFilterPopover: () => void;
onFilterPopoverVisibleChange: (visible: boolean) => void;
onFilterChange: (adhocFilter: AdhocFilter) => void;
onFilterPopoverClose: () => void;
onFilterPopoverResize: () => void;
}
/**
* Component for rendering the filter button with popover.
* Uses memo to prevent unnecessary re-renders when parent state changes
* that don't affect this component.
*/
const FilterButton = memo(function FilterButton({
filterPopoverVisible,
currentAdhocFilter,
selectedColumn,
selectedDatasource,
filterColumnOptions,
onOpenFilterPopover,
onFilterPopoverVisibleChange,
onFilterChange,
onFilterPopoverClose,
onFilterPopoverResize,
}: FilterButtonProps) {
return (
<Popover
open={filterPopoverVisible}
onOpenChange={onFilterPopoverVisibleChange}
trigger="click"
placement="left"
destroyOnHidden
content={
currentAdhocFilter && selectedDatasource ? (
<FilterPopoverContent>
<AdhocFilterEditPopover
adhocFilter={currentAdhocFilter}
options={filterColumnOptions}
datasource={selectedDatasource}
onChange={onFilterChange}
onClose={onFilterPopoverClose}
onResize={onFilterPopoverResize}
requireSave
/>
</FilterPopoverContent>
) : null
}
>
<Tooltip
title={
selectedColumn
? t('Add filter to scope the modification')
: t('Select a column first')
}
>
<FilterButtonStyled
onClick={onOpenFilterPopover}
disabled={!selectedColumn || !selectedDatasource}
aria-label={t('Add filter')}
buttonStyle="tertiary"
>
<Icons.FilterOutlined iconSize="m" />
</FilterButtonStyled>
</Tooltip>
</Popover>
);
});
export default FilterButton;

View File

@@ -1,491 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { keyframes } from '@emotion/react';
import { styled } from '@apache-superset/core/ui';
import { t } from '@superset-ui/core';
// eslint-disable theme-colors/no-literal-colors
// Casting spell motion - dramatic swish and flick!
const castSpell = keyframes`
0% {
transform: rotate(-30deg) translateY(0);
}
15% {
transform: rotate(-45deg) translateY(-5px);
}
30% {
transform: rotate(25deg) translateY(0);
}
45% {
transform: rotate(15deg) translateY(-3px);
}
60% {
transform: rotate(-10deg) translateY(0);
}
75% {
transform: rotate(5deg) translateY(-2px);
}
100% {
transform: rotate(-30deg) translateY(0);
}
`;
// Magic particles floating upward
const floatUp = keyframes`
0% {
opacity: 1;
transform: translateY(0) translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-100px) translateX(var(--drift-x, 0px)) scale(0);
}
`;
// Spiral magic effect
const spiral = keyframes`
0% {
transform: rotate(0deg) translateX(20px) rotate(0deg);
opacity: 1;
}
100% {
transform: rotate(720deg) translateX(80px) rotate(-720deg);
opacity: 0;
}
`;
// Glowing tip pulse
const glowPulse = keyframes`
0%, 100% {
filter: drop-shadow(0 0 8px #fff) drop-shadow(0 0 15px #87CEEB) drop-shadow(0 0 25px #4169E1);
opacity: 0.9;
}
50% {
filter: drop-shadow(0 0 15px #fff) drop-shadow(0 0 30px #87CEEB) drop-shadow(0 0 45px #4169E1);
opacity: 1;
}
`;
// Stars twinkling
const twinkle = keyframes`
0%, 100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
25% {
opacity: 1;
transform: scale(1) rotate(90deg);
}
50% {
opacity: 0.5;
transform: scale(0.8) rotate(180deg);
}
75% {
opacity: 1;
transform: scale(1.2) rotate(270deg);
}
`;
// Lumos light burst
const lumosBurst = keyframes`
0% {
transform: scale(0);
opacity: 0;
}
20% {
transform: scale(1.5);
opacity: 0.8;
}
100% {
transform: scale(3);
opacity: 0;
}
`;
const fadeIn = keyframes`
from { opacity: 0; }
to { opacity: 1; }
`;
const textGlow = keyframes`
0%, 100% {
text-shadow: 0 0 10px #87CEEB, 0 0 20px #4169E1, 0 0 30px #4169E1;
}
50% {
text-shadow: 0 0 20px #87CEEB, 0 0 40px #4169E1, 0 0 60px #4169E1, 0 0 80px #6495ED;
}
`;
/* eslint-disable theme-colors/no-literal-colors */
const Overlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(ellipse at center, rgba(20, 20, 40, 0.65) 0%, rgba(10, 10, 25, 0.75) 50%, rgba(0, 0, 10, 0.85) 100%);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 99999;
animation: ${fadeIn} 0.5s ease-out;
overflow: hidden;
`;
const StarsBackground = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
`;
const Star = styled.div<{ size: number; x: number; y: number; delay: number }>`
position: absolute;
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
left: ${({ x }) => x}%;
top: ${({ y }) => y}%;
background: radial-gradient(circle, #fff 0%, transparent 70%);
border-radius: 50%;
animation: ${twinkle} ${({ delay }) => 2 + delay}s ease-in-out infinite;
animation-delay: ${({ delay }) => delay}s;
`;
const WandScene = styled.div`
position: relative;
width: 300px;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
`;
const WandWrapper = styled.div`
position: relative;
animation: ${castSpell} 2.5s ease-in-out infinite;
transform-origin: 85% 85%;
`;
const WandSvg = styled.svg`
width: 180px;
height: 180px;
transform: rotate(-45deg);
`;
const WandTipGlow = styled.div`
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 20px;
background: radial-gradient(circle, #fff 0%, #87CEEB 30%, #4169E1 60%, transparent 80%);
border-radius: 50%;
animation: ${glowPulse} 1s ease-in-out infinite;
`;
const LumosBurst = styled.div`
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 40px;
background: radial-gradient(circle, rgba(255,255,255,0.8) 0%, rgba(135,206,235,0.4) 40%, transparent 70%);
border-radius: 50%;
animation: ${lumosBurst} 2s ease-out infinite;
`;
const MagicParticle = styled.div<{ delay: number; driftX: number; duration: number }>`
position: absolute;
top: 20%;
left: 50%;
width: 6px;
height: 6px;
background: radial-gradient(circle, #fff 0%, #87CEEB 50%, transparent 100%);
border-radius: 50%;
--drift-x: ${({ driftX }) => driftX}px;
animation: ${floatUp} ${({ duration }) => duration}s ease-out infinite;
animation-delay: ${({ delay }) => delay}s;
`;
const SpiralMagic = styled.div<{ delay: number; color: string }>`
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
background: ${({ color }) => color};
border-radius: 50%;
animation: ${spiral} 3s ease-out infinite;
animation-delay: ${({ delay }) => delay}s;
box-shadow: 0 0 10px ${({ color }) => color};
`;
const MagicStar = styled.div<{ x: number; y: number; delay: number; size: number }>`
position: absolute;
left: ${({ x }) => x}%;
top: ${({ y }) => y}%;
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
animation: ${twinkle} 1.5s ease-in-out infinite;
animation-delay: ${({ delay }) => delay}s;
&::before,
&::after {
content: '';
position: absolute;
background: linear-gradient(135deg, #fff 0%, #87CEEB 50%, #4169E1 100%);
}
&::before {
width: 100%;
height: 2px;
top: 50%;
left: 0;
transform: translateY(-50%);
}
&::after {
width: 2px;
height: 100%;
top: 0;
left: 50%;
transform: translateX(-50%);
}
`;
const SpellText = styled.div`
margin-top: 40px;
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 28px;
font-style: italic;
color: #B8E0F0;
animation: ${textGlow} 2s ease-in-out infinite;
letter-spacing: 3px;
`;
const SubText = styled.div`
margin-top: 16px;
font-size: 14px;
color: #E0E8F0;
letter-spacing: 1px;
`;
const DismissHint = styled.div`
margin-top: 32px;
font-size: 12px;
color: #B0BEC5;
`;
/* eslint-enable theme-colors/no-literal-colors */
// Generate random stars for background
const backgroundStars = Array.from({ length: 50 }, (_, i) => ({
id: i,
size: Math.random() * 3 + 1,
x: Math.random() * 100,
y: Math.random() * 100,
delay: Math.random() * 3,
}));
// Magic particles around wand tip
const particles = Array.from({ length: 12 }, (_, i) => ({
id: i,
delay: i * 0.15,
driftX: (Math.random() - 0.5) * 60,
duration: 1.5 + Math.random() * 0.5,
}));
// Spiral magic colors
/* eslint-disable theme-colors/no-literal-colors */
const spiralColors = ['#87CEEB', '#4169E1', '#6495ED', '#B0C4DE', '#ADD8E6', '#fff'];
/* eslint-enable theme-colors/no-literal-colors */
const spirals = spiralColors.map((color, i) => ({
id: i,
delay: i * 0.5,
color,
}));
// Magic stars around the scene
const magicStars = [
{ x: 15, y: 20, delay: 0, size: 16 },
{ x: 80, y: 25, delay: 0.3, size: 12 },
{ x: 25, y: 70, delay: 0.6, size: 14 },
{ x: 75, y: 65, delay: 0.9, size: 10 },
{ x: 10, y: 45, delay: 0.2, size: 8 },
{ x: 88, y: 50, delay: 0.5, size: 11 },
{ x: 50, y: 10, delay: 0.4, size: 13 },
{ x: 45, y: 85, delay: 0.7, size: 9 },
];
interface HarryPotterWandLoaderProps {
onDismiss?: () => void;
}
const HarryPotterWandLoader = ({ onDismiss }: HarryPotterWandLoaderProps) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onDismiss) {
onDismiss();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onDismiss]);
const content = (
<Overlay data-test="harry-potter-wand-loader" onClick={onDismiss}>
<StarsBackground>
{backgroundStars.map(star => (
<Star
key={star.id}
size={star.size}
x={star.x}
y={star.y}
delay={star.delay}
/>
))}
</StarsBackground>
<WandScene>
{magicStars.map((star, i) => (
<MagicStar
key={i}
x={star.x}
y={star.y}
delay={star.delay}
size={star.size}
/>
))}
{spirals.map(s => (
<SpiralMagic key={s.id} delay={s.delay} color={s.color} />
))}
<WandWrapper>
<LumosBurst />
<WandTipGlow />
{particles.map(p => (
<MagicParticle
key={p.id}
delay={p.delay}
driftX={p.driftX}
duration={p.duration}
/>
))}
<WandSvg viewBox="0 0 100 100" fill="none">
<defs>
{/* Wood grain gradient for authentic wand look */}
<linearGradient id="hpWandWood" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#2C1810" />
<stop offset="20%" stopColor="#4A2C1A" />
<stop offset="40%" stopColor="#3D2314" />
<stop offset="60%" stopColor="#5C3A22" />
<stop offset="80%" stopColor="#3D2314" />
<stop offset="100%" stopColor="#2C1810" />
</linearGradient>
{/* Handle gradient - darker, more ornate */}
<linearGradient id="hpWandHandle" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#1A0F0A" />
<stop offset="30%" stopColor="#2C1810" />
<stop offset="50%" stopColor="#3D2314" />
<stop offset="70%" stopColor="#2C1810" />
<stop offset="100%" stopColor="#1A0F0A" />
</linearGradient>
{/* Glowing tip */}
<radialGradient id="hpWandGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#fff" />
<stop offset="40%" stopColor="#87CEEB" />
<stop offset="100%" stopColor="#4169E1" stopOpacity="0" />
</radialGradient>
</defs>
{/* Wand body - tapered shape like Elder Wand style */}
<path
d="M 50 10
Q 52 15 52 20
L 53 45
Q 54 55 55 65
L 57 80
Q 58 85 56 88
L 54 90
Q 50 92 46 90
L 44 88
Q 42 85 43 80
L 45 65
Q 46 55 47 45
L 48 20
Q 48 15 50 10
Z"
fill="url(#hpWandWood)"
/>
{/* Handle bumps - Elder Wand style nodules */}
<ellipse cx="50" cy="75" rx="6" ry="3" fill="url(#hpWandHandle)" />
<ellipse cx="50" cy="82" rx="5" ry="2.5" fill="url(#hpWandHandle)" />
<ellipse cx="50" cy="88" rx="4" ry="2" fill="url(#hpWandHandle)" />
{/* Wood grain lines */}
<path
d="M 49 25 Q 50 40 49 55"
stroke="#1A0F0A"
strokeWidth="0.5"
fill="none"
opacity="0.3"
/>
<path
d="M 51 30 Q 52 45 51 60"
stroke="#1A0F0A"
strokeWidth="0.5"
fill="none"
opacity="0.3"
/>
{/* Glowing tip */}
<circle cx="50" cy="8" r="6" fill="url(#hpWandGlow)" />
<circle cx="50" cy="8" r="3" fill="#fff" opacity="0.9" />
</WandSvg>
</WandWrapper>
</WandScene>
<SpellText>{t('Revelio...')}</SpellText>
<SubText>{t('Discovering hidden connections')}</SubText>
{onDismiss && (
<DismissHint>{t('Click anywhere or press Esc to cancel')}</DismissHint>
)}
</Overlay>
);
// Use portal to render at document.body level to escape any stacking context issues
return createPortal(content, document.body);
};
export default HarryPotterWandLoader;

View File

@@ -1,287 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { keyframes } from '@emotion/react';
import { css, styled, useTheme } from '@apache-superset/core/ui';
import { t } from '@superset-ui/core';
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`;
const waveWand = keyframes`
0%, 100% {
transform: rotate(-15deg);
}
50% {
transform: rotate(15deg);
}
`;
const sparkle = keyframes`
0%, 100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
`;
const float = keyframes`
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
`;
const pulse = keyframes`
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.2);
opacity: 1;
}
`;
const Overlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${({ theme }) => theme.colorBgMask};
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
animation: ${fadeIn} 0.3s ease-out;
`;
const WandContainer = styled.div`
position: relative;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
animation: ${float} 2s ease-in-out infinite;
`;
const Wand = styled.div`
position: relative;
animation: ${waveWand} 1s ease-in-out infinite;
transform-origin: bottom center;
`;
const WandSvg = styled.svg`
width: 120px;
height: 120px;
filter: drop-shadow(0 0 20px ${({ theme }) => theme.colorWarning});
`;
const Sparkle = styled.div<{ delay: number; x: number; y: number }>`
position: absolute;
width: 12px;
height: 12px;
animation: ${sparkle} 1.5s ease-in-out infinite;
animation-delay: ${({ delay }) => delay}s;
left: ${({ x }) => x}%;
top: ${({ y }) => y}%;
&::before,
&::after {
content: '';
position: absolute;
background: linear-gradient(
135deg,
${({ theme }) => theme.colorWarning} 0%,
${({ theme }) => theme.colorWhite} 50%,
${({ theme }) => theme.colorWarning} 100%
);
}
&::before {
width: 100%;
height: 2px;
top: 50%;
left: 0;
transform: translateY(-50%);
}
&::after {
width: 2px;
height: 100%;
top: 0;
left: 50%;
transform: translateX(-50%);
}
`;
const GlowOrb = styled.div`
position: absolute;
top: 15%;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 30px;
background: radial-gradient(
circle,
${({ theme }) => theme.colorWarning} 0%,
${({ theme }) => theme.colorWarningBg} 40%,
transparent 70%
);
border-radius: 50%;
animation: ${pulse} 0.8s ease-in-out infinite;
`;
const LoadingText = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit * 6}px;
color: ${({ theme }) => theme.colorWhite};
font-size: ${({ theme }) => theme.fontSizeLG}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
text-shadow: 0 2px 10px ${({ theme }) => theme.colorBgMask};
text-align: center;
`;
const SubText = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit}px;
color: ${({ theme }) => theme.colorTextLightSolid};
font-size: ${({ theme }) => theme.fontSizeSM}px;
`;
const DismissHint = styled.div`
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
color: ${({ theme }) => theme.colorTextLightSolid};
font-size: ${({ theme }) => theme.fontSizeXS}px;
opacity: 0.7;
`;
const sparklePositions = [
{ x: 20, y: 10, delay: 0 },
{ x: 75, y: 15, delay: 0.3 },
{ x: 85, y: 35, delay: 0.6 },
{ x: 15, y: 30, delay: 0.9 },
{ x: 60, y: 5, delay: 0.2 },
{ x: 40, y: 20, delay: 0.5 },
{ x: 80, y: 25, delay: 0.8 },
{ x: 25, y: 18, delay: 0.4 },
];
// Magic wand SVG colors - using warm brown/gold tones for the wand aesthetic
/* eslint-disable theme-colors/no-literal-colors */
const WAND_COLORS = {
woodDark: '#654321',
woodMid: '#8B4513',
woodLight: '#A0522D',
goldDark: '#B8860B',
goldMid: '#DAA520',
starLight: '#FFF8DC',
};
/* eslint-enable theme-colors/no-literal-colors */
interface MagicWandLoaderProps {
onDismiss?: () => void;
}
const MagicWandLoader = ({ onDismiss }: MagicWandLoaderProps) => {
const theme = useTheme();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onDismiss) {
onDismiss();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onDismiss]);
return (
<Overlay data-test="magic-wand-loader" onClick={onDismiss}>
<WandContainer>
{sparklePositions.map((pos, i) => (
<Sparkle key={i} x={pos.x} y={pos.y} delay={pos.delay} />
))}
<GlowOrb />
<Wand>
<WandSvg viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient
id="wandGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor={WAND_COLORS.woodMid} />
<stop offset="50%" stopColor={WAND_COLORS.woodLight} />
<stop offset="100%" stopColor={WAND_COLORS.woodDark} />
</linearGradient>
</defs>
{/* Thin wand body */}
<rect
x="30.5"
y="16"
width="3"
height="42"
rx="1.5"
fill="url(#wandGradient)"
css={css`
filter: drop-shadow(1px 1px 2px ${theme.colorBgMask});
`}
/>
{/* Small star at tip */}
<circle
cx="32"
cy="12"
r="4"
fill={theme.colorWarning}
css={css`
filter: drop-shadow(0 0 6px ${theme.colorWarning});
`}
/>
</WandSvg>
</Wand>
</WandContainer>
<LoadingText>{t('Analyzing relationships...')}</LoadingText>
<SubText>{t('AI is finding connected columns')}</SubText>
{onDismiss && (
<DismissHint>{t('Click anywhere or press Esc to cancel')}</DismissHint>
)}
</Overlay>
);
};
export default MagicWandLoader;

View File

@@ -1,121 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useState, useCallback } from 'react';
import { t } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/ui';
import { Tag } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { formatPercentageChange } from 'src/dashboard/util/whatIf';
import { ExtendedWhatIfModification } from './types';
import {
ModificationsSection,
ModificationTagsContainer,
AIBadge,
AIReasoningSection,
AIReasoningToggle,
AIReasoningContent,
AIReasoningItem,
} from './styles';
interface ModificationsDisplayProps {
modifications: ExtendedWhatIfModification[];
}
/**
* Component for displaying applied modifications as tags with AI reasoning.
* Uses memo to prevent unnecessary re-renders when modifications haven't changed.
*/
const ModificationsDisplay = memo(function ModificationsDisplay({
modifications,
}: ModificationsDisplayProps) {
const theme = useTheme();
const [showAIReasoning, setShowAIReasoning] = useState(false);
const toggleAIReasoning = useCallback(() => {
setShowAIReasoning(prev => !prev);
}, []);
const hasAIReasoning = modifications.some(mod => mod.reasoning);
if (modifications.length === 0) {
return null;
}
return (
<ModificationsSection>
<ModificationTagsContainer>
{modifications.map((mod, idx) => (
<Tag
key={idx}
css={css`
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit}px;
margin: 0;
`}
>
<span>{mod.verboseName || mod.column}</span>
{mod.isAISuggested && <AIBadge>{t('AI')}</AIBadge>}
<span
css={css`
font-weight: ${theme.fontWeightStrong};
color: ${mod.multiplier >= 1
? theme.colorSuccess
: theme.colorError};
`}
>
{formatPercentageChange(mod.multiplier, 0)}
</span>
</Tag>
))}
</ModificationTagsContainer>
{hasAIReasoning && (
<AIReasoningSection>
<AIReasoningToggle onClick={toggleAIReasoning}>
{showAIReasoning ? (
<Icons.DownOutlined iconSize="xs" />
) : (
<Icons.RightOutlined iconSize="xs" />
)}
{t('How AI chose these')}
</AIReasoningToggle>
{showAIReasoning && (
<AIReasoningContent>
{modifications
.filter(mod => mod.reasoning)
.map((mod, idx) => (
<AIReasoningItem key={idx}>
<strong>
{mod.verboseName || mod.column}{' '}
{formatPercentageChange(mod.multiplier, 0)}
</strong>
<div>{mod.reasoning}</div>
</AIReasoningItem>
))}
</AIReasoningContent>
)}
</AIReasoningSection>
)}
</ModificationsSection>
);
});
export default ModificationsDisplay;

View File

@@ -1,170 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect } from 'react';
import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { Form, Input } from '@superset-ui/core/components';
import { StandardModal } from 'src/components/Modal';
import { WhatIfModification } from './types';
import {
createSimulation,
updateSimulation,
WhatIfSimulation,
} from './whatIfApi';
const ModalContent = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
.ant-form-item {
margin-bottom: ${({ theme }) => theme.sizeUnit * 6}px;
&:last-child {
margin-bottom: 0;
}
}
.ant-form-item-label {
padding-bottom: ${({ theme }) => theme.sizeUnit}px;
}
`;
const { TextArea } = Input;
interface SaveSimulationModalProps {
show: boolean;
onHide: () => void;
onSaved: (simulation: WhatIfSimulation) => void;
dashboardId: number;
modifications: WhatIfModification[];
cascadingEffectsEnabled: boolean;
existingSimulation?: WhatIfSimulation | null;
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
}
const SaveSimulationModal = ({
show,
onHide,
onSaved,
dashboardId,
modifications,
cascadingEffectsEnabled,
existingSimulation,
addSuccessToast,
addDangerToast,
}: SaveSimulationModalProps) => {
const [form] = Form.useForm();
const isUpdate = Boolean(existingSimulation);
useEffect(() => {
if (show) {
form.setFieldsValue({
name: existingSimulation?.name || '',
description: existingSimulation?.description || '',
});
}
}, [show, existingSimulation, form]);
const handleSave = useCallback(async () => {
try {
const values = await form.validateFields();
if (isUpdate && existingSimulation) {
await updateSimulation(existingSimulation.id, {
name: values.name,
description: values.description,
modifications,
cascadingEffectsEnabled,
});
const updatedSimulation: WhatIfSimulation = {
...existingSimulation,
name: values.name,
description: values.description,
modifications,
cascadingEffectsEnabled,
};
onSaved(updatedSimulation);
addSuccessToast(t('Simulation updated successfully'));
} else {
const simulation = await createSimulation({
name: values.name,
description: values.description,
dashboardId,
modifications,
cascadingEffectsEnabled,
});
onSaved(simulation);
addSuccessToast(t('Simulation saved successfully'));
}
onHide();
} catch (error) {
addDangerToast(t('Failed to save simulation'));
}
}, [
form,
isUpdate,
existingSimulation,
modifications,
cascadingEffectsEnabled,
dashboardId,
onSaved,
onHide,
addSuccessToast,
addDangerToast,
]);
const handleCancel = useCallback(() => {
form.resetFields();
onHide();
}, [form, onHide]);
return (
<StandardModal
show={show}
onHide={handleCancel}
onSave={handleSave}
title={isUpdate ? t('Update Simulation') : t('Save Simulation')}
width={500}
saveText={isUpdate ? t('Update') : t('Save')}
>
<ModalContent>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label={t('Name')}
rules={[{ required: true, message: t('Please enter a name') }]}
>
<Input placeholder={t('My What-If Scenario')} />
</Form.Item>
<Form.Item name="description" label={t('Description')}>
<TextArea
placeholder={t('Optional description of this simulation')}
rows={3}
/>
</Form.Item>
</Form>
</ModalContent>
</StandardModal>
);
};
export default SaveSimulationModal;

View File

@@ -1,411 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { styled, Alert } from '@apache-superset/core/ui';
import { Icons, IconType } from '@superset-ui/core/components/Icons';
import { Collapse, Skeleton } from '@superset-ui/core/components/';
import { RootState, WhatIfModification } from 'src/dashboard/types';
import { whatIfHighlightStyles } from 'src/dashboard/util/useWhatIfHighlightStyles';
import { fetchWhatIfInterpretation } from './whatIfApi';
import { useChartComparison, useAllChartsLoaded } from './useChartComparison';
import {
GroupedWhatIfInsights,
WhatIfAIStatus,
WhatIfInsightType,
WhatIfInterpretResponse,
} from './types';
// Static Skeleton paragraph configs to avoid recreation on each render
const SKELETON_PARAGRAPH_3 = { rows: 3 };
const SKELETON_PARAGRAPH_2 = { rows: 2 };
// Configuration for each insight type
const INSIGHT_TYPE_CONFIG: Record<
WhatIfInsightType,
{ label: string; icon: React.ComponentType<IconType> }
> = {
observation: { label: t('Observations'), icon: Icons.EyeOutlined },
implication: { label: t('Implications'), icon: Icons.WarningOutlined },
recommendation: {
label: t('Recommendations'),
icon: Icons.CheckCircleOutlined,
},
};
// Order in which insight types should appear
const INSIGHT_TYPE_ORDER: WhatIfInsightType[] = [
'observation',
'implication',
'recommendation',
];
/**
* Create a stable key from modifications for comparison.
* This allows us to detect when modifications have meaningfully changed.
*/
function getModificationsKey(modifications: WhatIfModification[]): string {
return modifications
.map(m => `${m.column}:${m.multiplier}`)
.sort()
.join('|');
}
const InsightsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 3}px;
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
padding-top: ${({ theme }) => theme.sizeUnit * 4}px;
border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary};
`;
const InsightsHeader = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
color: ${({ theme }) => theme.colorText};
`;
const InsightCard = styled.div<{ insightType: string }>`
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background-color: ${({ theme, insightType }) => {
switch (insightType) {
case 'observation':
return theme.colorInfoBg;
case 'implication':
return theme.colorWarningBg;
case 'recommendation':
return theme.colorSuccessBg;
default:
return theme.colorBgElevated;
}
}};
border-radius: ${({ theme }) => theme.borderRadius}px;
border-left: 3px solid
${({ theme, insightType }) => {
switch (insightType) {
case 'observation':
return theme.colorInfo;
case 'implication':
return theme.colorWarning;
case 'recommendation':
return theme.colorSuccess;
default:
return theme.colorBorder;
}
}};
`;
const InsightTitle = styled.div`
font-weight: ${({ theme }) => theme.fontWeightStrong};
margin-bottom: ${({ theme }) => theme.sizeUnit}px;
`;
const InsightDescription = styled.div`
color: ${({ theme }) => theme.colorTextSecondary};
font-size: ${({ theme }) => theme.fontSizeSM}px;
line-height: 1.5;
`;
const Summary = styled.div`
font-size: ${({ theme }) => theme.fontSize}px;
line-height: 1.6;
color: ${({ theme }) => theme.colorText};
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background-color: ${({ theme }) => theme.colorBgElevated};
border-radius: ${({ theme }) => theme.borderRadius}px;
${whatIfHighlightStyles}
`;
const StyledCollapse = styled(Collapse)`
background: transparent;
border: none;
.ant-collapse-item {
border: none;
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
background: ${({ theme }) => theme.colorBgElevated};
border-radius: ${({ theme }) => theme.borderRadius}px !important;
overflow: hidden;
.ant-collapse-header {
padding: ${({ theme }) => theme.sizeUnit * 3}px;
background: transparent;
}
.ant-collapse-content {
border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary};
}
.ant-collapse-content-box {
padding: ${({ theme }) => theme.sizeUnit * 2}px;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
}
}
`;
const CollapsePanelHeader = styled.div<{ insightType: WhatIfInsightType }>`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
color: ${({ theme, insightType }) => {
switch (insightType) {
case 'observation':
return theme.colorInfo;
case 'implication':
return theme.colorWarning;
case 'recommendation':
return theme.colorSuccess;
default:
return theme.colorText;
}
}};
`;
interface WhatIfAIInsightsProps {
affectedChartIds: number[];
modifications: WhatIfModification[];
/** Ref to register the abort function for external control */
abortRef?: React.MutableRefObject<(() => void) | null>;
}
const WhatIfAIInsights = ({
affectedChartIds,
modifications,
abortRef,
}: WhatIfAIInsightsProps) => {
const [status, setStatus] = useState<WhatIfAIStatus>('idle');
const [response, setResponse] = useState<WhatIfInterpretResponse | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const dashboardTitle = useSelector<RootState, string>(
// @ts-ignore
state => state.dashboardInfo?.dashboard_title || 'Dashboard',
);
const chartComparisons = useChartComparison(affectedChartIds);
const allChartsLoaded = useAllChartsLoaded(affectedChartIds);
// AbortController for cancelling in-flight /interpret requests
const abortControllerRef = useRef<AbortController | null>(null);
// Cleanup: cancel any pending requests on unmount
useEffect(
() => () => {
abortControllerRef.current?.abort();
},
[],
);
// Register abort function with external ref for parent control
useEffect(() => {
if (abortRef) {
abortRef.current = () => {
abortControllerRef.current?.abort();
};
}
return () => {
if (abortRef) {
abortRef.current = null;
}
};
}, [abortRef]);
// Track modification changes to reset status when user adjusts the slider
const modificationsKey = getModificationsKey(modifications);
const prevModificationsKeyRef = useRef<string>(modificationsKey);
// Reset status when modifications change (user adjusts the slider)
useEffect(() => {
if (
modificationsKey !== prevModificationsKeyRef.current &&
modifications.length > 0
) {
// Cancel any in-flight request when modifications change
abortControllerRef.current?.abort();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: resetting state when modifications change
setStatus('idle');
setResponse(null);
prevModificationsKeyRef.current = modificationsKey;
}
}, [modificationsKey, modifications.length]);
const fetchInsights = useCallback(async () => {
if (modifications.length === 0 || chartComparisons.length === 0) {
return;
}
// Cancel any in-flight request before starting a new one
abortControllerRef.current?.abort();
// Create a new AbortController for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
setStatus('loading');
setError(null);
try {
const result = await fetchWhatIfInterpretation(
{
modifications,
charts: chartComparisons,
dashboardName: dashboardTitle,
},
abortController.signal,
);
setResponse(result);
setStatus('success');
} catch (err) {
// Don't update state if the request was aborted
if (err instanceof Error && err.name === 'AbortError') {
return;
}
setError(
err instanceof Error
? err.message
: t('Failed to generate AI insights'),
);
setStatus('error');
}
}, [modifications, chartComparisons, dashboardTitle]);
// Automatically fetch insights when all affected charts have finished loading.
// We wait for allChartsLoaded to prevent race conditions where we'd send
// stale data before charts have re-queried with the what-if modifications.
// The setState call here is intentional - we're synchronizing with prop changes.
useEffect(() => {
if (
modifications.length > 0 &&
chartComparisons.length > 0 &&
allChartsLoaded &&
status === 'idle'
) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: triggering async fetch based on prop changes
fetchInsights();
}
}, [modifications, chartComparisons, allChartsLoaded, status, fetchInsights]);
// Reset state when modifications are cleared.
// The setState calls here are intentional - we're resetting local state when props change.
useEffect(() => {
if (modifications.length === 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: resetting state when modifications cleared
setStatus('idle');
setResponse(null);
setError(null);
}
}, [modifications]);
// Group insights by type
const insights = response?.insights;
const groupedInsights = useMemo(() => {
if (!insights) return {} as GroupedWhatIfInsights;
return insights.reduce<GroupedWhatIfInsights>((acc, insight) => {
if (!acc[insight.type]) {
acc[insight.type] = [];
}
acc[insight.type].push(insight);
return acc;
}, {} as GroupedWhatIfInsights);
}, [insights]);
// Build collapse items from grouped insights
const collapseItems = useMemo(
() =>
INSIGHT_TYPE_ORDER.filter(type => groupedInsights[type]?.length > 0).map(
type => {
const typeInsights = groupedInsights[type];
const config = INSIGHT_TYPE_CONFIG[type];
const IconComponent = config.icon;
return {
key: type,
label: (
<CollapsePanelHeader insightType={type}>
<IconComponent iconSize="m" />
{config.label}
</CollapsePanelHeader>
),
children: typeInsights.map((insight, index) => (
<InsightCard key={index} insightType={insight.type}>
<InsightTitle>{insight.title}</InsightTitle>
<InsightDescription>{insight.description}</InsightDescription>
</InsightCard>
)),
};
},
),
[groupedInsights],
);
if (modifications.length === 0) {
return null;
}
return (
<InsightsContainer data-test="what-if-ai-insights">
<InsightsHeader>
<Icons.BulbOutlined iconSize="m" />
{t('AI Insights')}
</InsightsHeader>
{status === 'loading' && (
<Skeleton active paragraph={SKELETON_PARAGRAPH_3} />
)}
{status === 'error' && (
<Alert
type="error"
message={t('Failed to generate insights')}
description={error}
showIcon
/>
)}
{status === 'success' && response && (
<>
<Summary>{response.summary}</Summary>
<StyledCollapse
defaultActiveKey={INSIGHT_TYPE_ORDER}
items={collapseItems}
ghost
bordered
/>
</>
)}
{status === 'idle' && !allChartsLoaded && (
<Skeleton active paragraph={SKELETON_PARAGRAPH_2} />
)}
</InsightsContainer>
);
};
export default WhatIfAIInsights;

View File

@@ -1,233 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useCallback, Key } from 'react';
import { t } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/ui';
import { Menu } from '@superset-ui/core/components/Menu';
import {
NoAnimationDropdown,
Button,
Tooltip,
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { Link } from 'react-router-dom';
import { WhatIfSimulation } from './whatIfApi';
enum MenuKeys {
LoadSimulation = 'load-simulation',
SaveSimulation = 'save-simulation',
SaveAsNew = 'save-as-new',
ManageSimulations = 'manage-simulations',
}
interface WhatIfHeaderMenuProps {
selectedSimulation: WhatIfSimulation | null;
onSelectSimulation: (simulation: WhatIfSimulation | null) => void;
onSaveClick: () => void;
onSaveAsNewClick: () => void;
hasModifications: boolean;
simulations: WhatIfSimulation[];
simulationsLoading: boolean;
}
const VerticalDotsTrigger = () => {
const theme = useTheme();
return (
<Icons.EllipsisOutlined
css={css`
transform: rotate(90deg);
&:hover {
cursor: pointer;
}
`}
iconSize="xl"
iconColor={theme.colorTextLabel}
/>
);
};
const WhatIfHeaderMenu = ({
selectedSimulation,
onSelectSimulation,
onSaveClick,
onSaveAsNewClick,
hasModifications,
simulations,
simulationsLoading,
}: WhatIfHeaderMenuProps) => {
const theme = useTheme();
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const handleMenuClick = useCallback(
({ key }: { key: Key }) => {
const keyStr = String(key);
if (keyStr === MenuKeys.SaveSimulation) {
onSaveClick();
setIsDropdownVisible(false);
} else if (keyStr === MenuKeys.SaveAsNew) {
onSaveAsNewClick();
setIsDropdownVisible(false);
} else if (keyStr.startsWith('load-sim-')) {
const simId = parseInt(keyStr.replace('load-sim-', ''), 10);
const sim = simulations.find(s => s.id === simId);
if (sim) {
onSelectSimulation(sim);
}
setIsDropdownVisible(false);
} else if (keyStr === 'clear-simulation') {
onSelectSimulation(null);
setIsDropdownVisible(false);
}
},
[simulations, onSelectSimulation, onSaveClick, onSaveAsNewClick],
);
const simulationMenuItems =
simulations.length > 0
? [
...(selectedSimulation
? [
{
key: 'clear-simulation',
label: t('Clear current simulation'),
icon: <Icons.CloseOutlined />,
},
{ type: 'divider' as const },
]
: []),
...simulations.map(sim => ({
key: `load-sim-${sim.id}`,
label: (
<div
css={css`
display: flex;
justify-content: space-between;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
`}
>
<span
css={css`
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`}
>
{sim.name}
{selectedSimulation?.id === sim.id && (
<Icons.CheckOutlined
css={css`
margin-left: ${theme.sizeUnit}px;
color: ${theme.colorSuccess};
`}
/>
)}
</span>
{sim.description && (
<Tooltip title={sim.description}>
<Icons.InfoCircleOutlined
onClick={e => e.stopPropagation()}
css={css`
color: ${theme.colorTextSecondary};
font-size: ${theme.fontSizeSM}px;
`}
/>
</Tooltip>
)}
</div>
),
})),
]
: [
{
key: 'no-simulations',
label: t('No saved simulations'),
disabled: true,
},
];
const menuItems = [
{
type: 'submenu' as const,
key: MenuKeys.LoadSimulation,
label: simulationsLoading ? t('Loading...') : t('Load simulation'),
icon: <Icons.FolderOpenOutlined />,
children: simulationMenuItems,
},
{
key: MenuKeys.SaveSimulation,
label: selectedSimulation ? t('Update simulation') : t('Save simulation'),
icon: <Icons.SaveOutlined />,
disabled: !hasModifications,
},
...(selectedSimulation
? [
{
key: MenuKeys.SaveAsNew,
label: t('Save as new'),
icon: <Icons.PlusOutlined />,
disabled: !hasModifications,
},
]
: []),
{ type: 'divider' as const },
{
key: MenuKeys.ManageSimulations,
label: <Link to="/whatif/simulations/">{t('Manage simulations')}</Link>,
icon: <Icons.SettingOutlined />,
},
];
return (
<NoAnimationDropdown
popupRender={() => (
<Menu
onClick={handleMenuClick}
data-test="what-if-header-menu"
selectable={false}
items={menuItems}
/>
)}
trigger={['click']}
placement="bottomRight"
open={isDropdownVisible}
onOpenChange={visible => setIsDropdownVisible(visible)}
>
<Button
buttonStyle="link"
aria-label={t('More Options')}
aria-haspopup="true"
css={css`
padding: 0;
display: flex;
align-items: center;
justify-content: center;
height: 20px;
`}
>
<VerticalDotsTrigger />
</Button>
</NoAnimationDropdown>
);
};
export default WhatIfHeaderMenu;

View File

@@ -1,508 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/ui';
import {
Select,
Checkbox,
Tooltip,
Tag,
CheckboxChangeEvent,
} from '@superset-ui/core/components';
import Slider from '@superset-ui/core/components/Slider';
import { Icons } from '@superset-ui/core/components/Icons';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { useNumericColumns } from 'src/dashboard/util/useNumericColumns';
import { RootState, Datasource } from 'src/dashboard/types';
import WhatIfAIInsights from './WhatIfAIInsights';
import { useAllChartsLoaded } from './useChartComparison';
import HarryPotterWandLoader from './HarryPotterWandLoader';
import FilterButton from './FilterButton';
import ModificationsDisplay from './ModificationsDisplay';
import WhatIfHeaderMenu from './WhatIfHeaderMenu';
import SaveSimulationModal from './SaveSimulationModal';
import { useWhatIfFilters } from './useWhatIfFilters';
import { useWhatIfApply } from './useWhatIfApply';
import { WhatIfSimulation, fetchSimulations } from './whatIfApi';
import {
SLIDER_MIN,
SLIDER_MAX,
SLIDER_DEFAULT,
SLIDER_MARKS,
SLIDER_TOOLTIP_CONFIG,
WHAT_IF_PANEL_WIDTH,
} from './constants';
import {
PanelContainer,
PanelHeader,
PanelTitle,
CloseButton,
PanelContent,
FormSection,
Label,
SliderContainer,
ApplyButton,
CheckboxContainer,
ColumnSelectRow,
ColumnSelectWrapper,
FiltersSection,
FilterTagsContainer,
HeaderButtonsContainer,
} from './styles';
export { WHAT_IF_PANEL_WIDTH };
interface WhatIfPanelProps {
visible: boolean;
onClose: () => void;
topOffset: number;
initialSimulationId?: number | null;
}
const WhatIfPanel = ({
visible,
onClose,
topOffset,
initialSimulationId,
}: WhatIfPanelProps) => {
const theme = useTheme();
const { addSuccessToast, addDangerToast } = useToasts();
// Get dashboard ID from Redux
const dashboardId = useSelector((state: RootState) => state.dashboardInfo.id);
// Local state for column selection and slider
const [selectedColumn, setSelectedColumn] = useState<string | undefined>(
undefined,
);
const [sliderValue, setSliderValue] = useState<number>(SLIDER_DEFAULT);
const [enableCascadingEffects, setEnableCascadingEffects] = useState(false);
// Simulation state
const [selectedSimulation, setSelectedSimulation] =
useState<WhatIfSimulation | null>(null);
const [saveModalVisible, setSaveModalVisible] = useState(false);
const [simulations, setSimulations] = useState<WhatIfSimulation[]>([]);
const [simulationsLoading, setSimulationsLoading] = useState(false);
// Track if initial simulation from URL has been loaded
const initialSimulationLoadedRef = useRef(false);
// Custom hook for filter management
const {
filters,
filterPopoverVisible,
currentAdhocFilter,
setFilterPopoverVisible,
setFilters,
handleOpenFilterPopover,
handleEditFilter,
handleFilterChange,
handleRemoveFilter,
handleFilterPopoverClose,
handleFilterPopoverResize,
clearFilters,
formatFilterLabel,
} = useWhatIfFilters();
// Custom hook for apply logic and modifications
const {
appliedModifications,
affectedChartIds,
isLoadingSuggestions,
applyCounter,
handleApply,
handleDismissLoader,
aiInsightsModifications,
loadModificationsDirectly,
clearModifications,
interpretAbortRef,
} = useWhatIfApply({
selectedColumn,
sliderValue,
filters,
enableCascadingEffects,
});
// Get numeric columns and datasources
const { numericColumns, columnToChartIds } = useNumericColumns();
const datasources = useSelector((state: RootState) => state.datasources);
// Get all chart IDs that could be affected by what-if analysis
const allDashboardChartIds = useMemo(() => {
const chartIds = new Set<number>();
columnToChartIds.forEach(ids => ids.forEach(id => chartIds.add(id)));
return Array.from(chartIds);
}, [columnToChartIds]);
// Check if all dashboard charts have completed their initial load
const initialChartsLoaded = useAllChartsLoaded(allDashboardChartIds);
// Column options for the select dropdown
const columnOptions = useMemo(
() =>
numericColumns.map(col => ({
value: col.columnName,
label: col.verboseName || col.columnName,
})),
[numericColumns],
);
// Find info about the selected column
const selectedColumnInfo = useMemo(
() => numericColumns.find(col => col.columnName === selectedColumn),
[numericColumns, selectedColumn],
);
// Find the datasource for the selected column
const selectedDatasource = useMemo((): Datasource | null => {
if (!selectedColumnInfo) return null;
// Find datasource by ID - keys are in format "id__type"
const datasourceEntry = Object.entries(datasources).find(([key]) => {
const [idStr] = key.split('__');
return parseInt(idStr, 10) === selectedColumnInfo.datasourceId;
});
return datasourceEntry ? datasourceEntry[1] : null;
}, [datasources, selectedColumnInfo]);
// Get all columns from the selected datasource for filter options
const filterColumnOptions = useMemo(() => {
if (!selectedDatasource?.columns) return [];
return selectedDatasource.columns;
}, [selectedDatasource]);
// Handle column selection change - also clears filters
const handleColumnChange = useCallback(
(value: string | undefined) => {
setSelectedColumn(value);
// Clear filters when column changes since they're tied to the datasource
clearFilters();
},
[clearFilters],
);
const handleSliderChange = useCallback((value: number) => {
setSliderValue(value);
}, []);
const handleCascadingEffectsChange = useCallback((e: CheckboxChangeEvent) => {
setEnableCascadingEffects(e.target.checked);
}, []);
// Handle loading a saved simulation
const handleLoadSimulation = useCallback(
(simulation: WhatIfSimulation | null) => {
setSelectedSimulation(simulation);
if (simulation && simulation.modifications.length > 0) {
const firstMod = simulation.modifications[0];
setSelectedColumn(firstMod.column);
setSliderValue((firstMod.multiplier - 1) * 100);
setEnableCascadingEffects(simulation.cascadingEffectsEnabled);
// Load filters from the first modification
if (firstMod.filters && firstMod.filters.length > 0) {
setFilters(firstMod.filters);
} else {
clearFilters();
}
// Convert to extended modifications with isAISuggested flag
// First modification is the user's, rest are AI-suggested
const extendedModifications = simulation.modifications.map(
(mod, index) => ({
column: mod.column,
multiplier: mod.multiplier,
filters: mod.filters,
isAISuggested: index > 0,
}),
);
// Load all modifications directly and trigger chart queries + /interpret
loadModificationsDirectly(extendedModifications);
} else if (!simulation) {
// Clear state when deselecting
setSelectedColumn(undefined);
setSliderValue(SLIDER_DEFAULT);
setEnableCascadingEffects(false);
clearFilters();
clearModifications();
}
},
[clearFilters, setFilters, loadModificationsDirectly, clearModifications],
);
// Load simulations list from API
const loadSimulations = useCallback(async () => {
if (!dashboardId) return;
setSimulationsLoading(true);
try {
const result = await fetchSimulations(dashboardId);
setSimulations(result);
} catch (error) {
addDangerToast(t('Failed to load saved simulations'));
} finally {
setSimulationsLoading(false);
}
}, [dashboardId, addDangerToast]);
// Fetch simulations when dashboard is ready
useEffect(() => {
if (dashboardId) {
loadSimulations();
}
}, [dashboardId, loadSimulations]);
// Load initial simulation from URL parameter
// Wait until:
// 1. simulations are loaded (from the earlier useEffect)
// 2. columnToChartIds is populated (chart metadata is available)
// 3. initialChartsLoaded is true (charts have finished their initial queries)
// This ensures we can properly save original chart data before applying what-if modifications
useEffect(() => {
if (
initialSimulationId &&
!initialSimulationLoadedRef.current &&
simulations.length > 0 &&
columnToChartIds.size > 0 &&
initialChartsLoaded
) {
initialSimulationLoadedRef.current = true;
const simulation = simulations.find(s => s.id === initialSimulationId);
if (simulation) {
handleLoadSimulation(simulation);
} else {
addDangerToast(t('Simulation not found'));
}
}
}, [
initialSimulationId,
simulations,
addDangerToast,
columnToChartIds.size,
initialChartsLoaded,
handleLoadSimulation,
]);
// Handle saving a simulation
const handleSaveSimulation = useCallback(
(simulation: WhatIfSimulation) => {
setSelectedSimulation(simulation);
// Refresh the simulations list to include the newly saved simulation
loadSimulations();
},
[loadSimulations],
);
// Track if we're saving as new (vs updating existing)
const [isSavingAsNew, setIsSavingAsNew] = useState(false);
const handleOpenSaveModal = useCallback(() => {
setIsSavingAsNew(false);
setSaveModalVisible(true);
}, []);
const handleOpenSaveAsNewModal = useCallback(() => {
setIsSavingAsNew(true);
setSaveModalVisible(true);
}, []);
const handleCloseSaveModal = useCallback(() => {
setSaveModalVisible(false);
setIsSavingAsNew(false);
}, []);
const isApplyDisabled =
!selectedColumn || sliderValue === SLIDER_DEFAULT || isLoadingSuggestions;
return (
<PanelContainer
data-test="what-if-panel"
topOffset={topOffset}
visible={visible}
>
<PanelHeader>
<PanelTitle>
<Icons.StarFilled
iconSize="m"
css={css`
color: ${theme.colorWarning};
`}
/>
{t('What-if playground')}
</PanelTitle>
<HeaderButtonsContainer>
<WhatIfHeaderMenu
selectedSimulation={selectedSimulation}
onSelectSimulation={handleLoadSimulation}
onSaveClick={handleOpenSaveModal}
onSaveAsNewClick={handleOpenSaveAsNewModal}
hasModifications={appliedModifications.length > 0}
simulations={simulations}
simulationsLoading={simulationsLoading}
/>
<CloseButton onClick={onClose} aria-label={t('Close')}>
<Icons.CloseOutlined iconSize="m" />
</CloseButton>
</HeaderButtonsContainer>
</PanelHeader>
<PanelContent>
<FormSection>
<Label>{t('Select column to adjust')}</Label>
<ColumnSelectRow>
<ColumnSelectWrapper>
<Select
value={selectedColumn}
onChange={handleColumnChange}
options={columnOptions}
placeholder={t('Choose a column...')}
allowClear
showSearch
ariaLabel={t('Select column to adjust')}
/>
</ColumnSelectWrapper>
<FilterButton
filterPopoverVisible={filterPopoverVisible}
currentAdhocFilter={currentAdhocFilter}
selectedColumn={selectedColumn}
selectedDatasource={selectedDatasource}
filterColumnOptions={filterColumnOptions}
onOpenFilterPopover={handleOpenFilterPopover}
onFilterPopoverVisibleChange={setFilterPopoverVisible}
onFilterChange={handleFilterChange}
onFilterPopoverClose={handleFilterPopoverClose}
onFilterPopoverResize={handleFilterPopoverResize}
/>
</ColumnSelectRow>
{filters.length > 0 && (
<FiltersSection>
<Label
css={css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
`}
>
{t('Filters')}
</Label>
<FilterTagsContainer>
{filters.map((filter, index) => (
<Tag
key={`${filter.col}-${filter.op}-${index}`}
closable
onClose={e => handleRemoveFilter(e, index)}
onClick={() => handleEditFilter(index)}
css={css`
cursor: pointer;
margin: 0;
&:hover {
opacity: 0.8;
}
`}
>
{formatFilterLabel(filter)}
</Tag>
))}
</FilterTagsContainer>
</FiltersSection>
)}
</FormSection>
<FormSection>
<Label>{t('Adjust value')}</Label>
<SliderContainer>
<Slider
min={SLIDER_MIN}
max={SLIDER_MAX}
value={sliderValue}
onChange={handleSliderChange}
marks={SLIDER_MARKS}
tooltip={SLIDER_TOOLTIP_CONFIG}
/>
</SliderContainer>
</FormSection>
<CheckboxContainer>
<Checkbox
checked={enableCascadingEffects}
onChange={handleCascadingEffectsChange}
>
{t('Show the bigger picture with AI')}
</Checkbox>
<Tooltip
title={t(
'Automatically includes related metrics and columns affected by this change. AI infers relationships based on how metrics and columns are used across the dashboard.',
)}
>
<Icons.InfoCircleOutlined
iconSize="s"
css={css`
color: ${theme.colorTextSecondary};
cursor: help;
`}
/>
</Tooltip>
</CheckboxContainer>
<ApplyButton
buttonStyle="primary"
onClick={handleApply}
disabled={isApplyDisabled}
loading={isLoadingSuggestions}
>
<Icons.StarFilled iconSize="s" />
{isLoadingSuggestions
? t('Analyzing relationships...')
: t('See what if')}
</ApplyButton>
<ModificationsDisplay modifications={appliedModifications} />
{affectedChartIds.length > 0 && (
<WhatIfAIInsights
key={applyCounter}
affectedChartIds={affectedChartIds}
modifications={aiInsightsModifications}
abortRef={interpretAbortRef}
/>
)}
</PanelContent>
{isLoadingSuggestions && (
<HarryPotterWandLoader onDismiss={handleDismissLoader} />
)}
<SaveSimulationModal
show={saveModalVisible}
onHide={handleCloseSaveModal}
onSaved={handleSaveSimulation}
dashboardId={dashboardId}
modifications={appliedModifications}
cascadingEffectsEnabled={enableCascadingEffects}
existingSimulation={isSavingAsNew ? null : selectedSimulation}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
/>
</PanelContainer>
);
};
export default WhatIfPanel;

View File

@@ -1,216 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { styled } from '@apache-superset/core/ui';
import { Button } from '@superset-ui/core/components';
import { WHAT_IF_PANEL_WIDTH } from './constants';
export const PanelContainer = styled.div<{
topOffset: number;
visible: boolean;
}>`
grid-column: 2;
grid-row: 1 / -1; /* Span all rows */
width: ${WHAT_IF_PANEL_WIDTH}px;
min-width: ${WHAT_IF_PANEL_WIDTH}px;
background-color: ${({ theme }) => theme.colorBgContainer};
border-left: 1px solid ${({ theme }) => theme.colorBorderSecondary};
display: ${({ visible }) => (visible ? 'flex' : 'none')};
flex-direction: column;
overflow: hidden;
position: sticky;
top: ${({ topOffset }) => topOffset}px;
height: calc(100vh - ${({ topOffset }) => topOffset}px);
align-self: start;
z-index: 10;
`;
export const PanelHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${({ theme }) => theme.sizeUnit * 3}px
${({ theme }) => theme.sizeUnit * 4}px;
border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary};
`;
export const PanelTitle = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
font-weight: ${({ theme }) => theme.fontWeightStrong};
font-size: ${({ theme }) => theme.fontSizeLG}px;
`;
export const CloseButton = styled.button`
background: none;
border: none;
cursor: pointer;
padding: ${({ theme }) => theme.sizeUnit}px;
display: flex;
align-items: center;
justify-content: center;
color: ${({ theme }) => theme.colorTextSecondary};
border-radius: ${({ theme }) => theme.borderRadius}px;
&:hover {
background-color: ${({ theme }) => theme.colorBgTextHover};
color: ${({ theme }) => theme.colorText};
}
`;
export const PanelContent = styled.div`
flex: 1;
overflow-y: auto;
padding: ${({ theme }) => theme.sizeUnit * 4}px;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 5}px;
`;
export const FormSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const Label = styled.label`
color: ${({ theme }) => theme.colorText};
`;
export const SliderContainer = styled.div`
padding: 0 ${({ theme }) => theme.sizeUnit}px;
& .ant-slider-mark {
font-size: ${({ theme }) => theme.fontSizeSM}px;
}
`;
export const ApplyButton = styled(Button)`
width: 100%;
min-height: 32px;
`;
export const CheckboxContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
export const ModificationsSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 5}px;
`;
export const ModificationTagsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const AIBadge = styled.span`
font-size: 10px;
padding: 0 4px;
background-color: ${({ theme }) => theme.colorInfo};
color: ${({ theme }) => theme.colorWhite};
border-radius: 16px;
line-height: 1.2;
`;
export const AIReasoningSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
export const AIReasoningToggle = styled.button`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: ${({ theme }) => theme.colorTextTertiary};
font-size: ${({ theme }) => theme.fontSizeSM}px;
&:hover {
color: ${({ theme }) => theme.colorText};
}
`;
export const AIReasoningContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit}px;
padding-left: ${({ theme }) => theme.sizeUnit * 4}px;
`;
export const AIReasoningItem = styled.div`
font-size: ${({ theme }) => theme.fontSizeSM}px;
color: ${({ theme }) => theme.colorTextSecondary};
`;
export const ColumnSelectRow = styled.div`
display: flex;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
align-items: flex-start;
`;
export const ColumnSelectWrapper = styled.div`
flex: 1;
min-width: 0;
`;
export const FilterButtonStyled = styled(Button)`
flex-shrink: 0;
padding: 0 ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const FilterPopoverContent = styled.div`
.edit-popover-resize {
transform: scaleX(-1);
float: right;
margin-top: ${({ theme }) => theme.sizeUnit * 4}px;
margin-right: ${({ theme }) => theme.sizeUnit * -1}px;
color: ${({ theme }) => theme.colorIcon};
cursor: nwse-resize;
}
.filter-sql-editor {
border: ${({ theme }) => theme.colorBorder} solid thin;
}
`;
export const FiltersSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
export const FilterTagsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.sizeUnit}px;
`;
export const HeaderButtonsContainer = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.sizeUnit}px;
`;

View File

@@ -1,114 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Import shared types for internal use
import type {
WhatIfFilter,
WhatIfFilterOperator,
WhatIfModification,
} from 'src/dashboard/types';
// Re-export shared types from dashboard/types.ts
export type { WhatIfFilter, WhatIfFilterOperator, WhatIfModification };
// Types specific to chart comparison display
export interface ChartMetricComparison {
metricName: string;
originalValue: number;
modifiedValue: number;
percentageChange: number;
}
export interface ChartComparison {
chartId: number;
chartName: string;
chartType: string;
metrics: ChartMetricComparison[];
}
// Types for /interpret API endpoint
export interface WhatIfInterpretRequest {
modifications: Array<{
column: string;
multiplier: number;
filters?: WhatIfFilter[];
}>;
charts: ChartComparison[];
dashboardName?: string;
}
export type WhatIfInsightType =
| 'observation'
| 'implication'
| 'recommendation';
export interface WhatIfInsight {
title: string;
description: string;
type: WhatIfInsightType;
}
export interface WhatIfInterpretResponse {
summary: string;
insights: WhatIfInsight[];
rawResponse?: string;
}
export type GroupedWhatIfInsights = Record<WhatIfInsightType, WhatIfInsight[]>;
export type WhatIfAIStatus = 'idle' | 'loading' | 'success' | 'error';
// Types for /suggest_related API endpoint
export interface AvailableColumn {
columnName: string;
description?: string | null;
verboseName?: string | null;
datasourceId: number;
}
export interface SuggestedModification {
column: string;
multiplier: number;
reasoning: string;
confidence: 'high' | 'medium' | 'low';
}
export interface WhatIfSuggestRelatedRequest {
selectedColumn: string;
userMultiplier: number;
availableColumns: AvailableColumn[];
dashboardName?: string;
}
export interface WhatIfSuggestRelatedResponse {
suggestedModifications: SuggestedModification[];
explanation?: string;
}
// Extended modification type that tracks whether it came from AI
export interface ExtendedWhatIfModification {
column: string;
multiplier: number;
filters?: WhatIfFilter[];
isAISuggested?: boolean;
reasoning?: string;
confidence?: 'high' | 'medium' | 'low';
/** Human-readable column label for display */
verboseName?: string | null;
}

View File

@@ -1,334 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useMemo } from 'react';
import { shallowEqual, useSelector } from 'react-redux';
import { QueryData } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/api/core';
import {
ActiveTabs,
DashboardLayout,
RootState,
Slice,
} from 'src/dashboard/types';
import { ChartComparison, ChartMetricComparison } from './types';
import { CHART_TYPE, TAB_TYPE } from 'src/dashboard/util/componentTypes';
interface ChartStateWithOriginal {
chartStatus?: string;
queriesResponse?: QueryData[] | null;
originalQueriesResponse?: QueryData[] | null;
}
interface QueryResponse {
data?: Array<Record<string, unknown>>;
colnames?: string[];
coltypes?: GenericDataType[];
}
function extractMetricValue(
data: Array<Record<string, unknown>> | undefined,
metricName: string,
): number | null {
if (!data || data.length === 0) return null;
// Sum all values for the metric across rows
let total = 0;
let found = false;
for (const row of data) {
if (metricName in row) {
const value = row[metricName];
if (typeof value === 'number' && !Number.isNaN(value)) {
total += value;
found = true;
}
}
}
return found ? total : null;
}
function isNumericColumn(
data: Array<Record<string, unknown>> | undefined,
colName: string,
): boolean {
if (!data || data.length === 0) return false;
for (const row of data) {
if (colName in row) {
const value = row[colName];
return typeof value === 'number';
}
}
return false;
}
/**
* Hook to get a function that checks if a chart is in an active tab.
* A chart is considered visible if:
* 1. It has no tab parents (not inside any tab)
* 2. All of its tab parents are in the active tabs list
*/
export function useIsChartInActiveTab() {
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout?.present,
);
const activeTabs = useSelector<RootState, ActiveTabs>(
state => state.dashboardState?.activeTabs,
);
const layoutChartItems = useMemo(
() =>
Object.values(dashboardLayout || {}).filter(
item => item.type === CHART_TYPE,
),
[dashboardLayout],
);
return useCallback(
(chartId: number): boolean => {
const chartLayoutItem = layoutChartItems.find(
layoutItem => layoutItem.meta?.chartId === chartId,
);
const tabParents = chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
);
// Chart is visible if it has no tab parents or all tab parents are active
return (
!tabParents ||
tabParents.length === 0 ||
tabParents.every(tab => activeTabs?.includes(tab))
);
},
[dashboardLayout, layoutChartItems, activeTabs],
);
}
/**
* Filter chart IDs to only include those in active tabs.
*/
export function useChartsInActiveTabs(chartIds: number[]): number[] {
const isChartInActiveTab = useIsChartInActiveTab();
return useMemo(
() => chartIds.filter(isChartInActiveTab),
[chartIds, isChartInActiveTab],
);
}
interface ChartComparisonData {
chartStatus?: string;
originalData?: Array<Record<string, unknown>>;
modifiedData?: Array<Record<string, unknown>>;
colnames?: string[];
coltypes?: GenericDataType[];
}
/**
* Selector that extracts only the comparison-relevant data for specific chart IDs.
* This avoids re-renders when unrelated chart data changes.
*/
function useChartComparisonData(
chartIds: number[],
): Record<number, ChartComparisonData> {
return useSelector((state: RootState) => {
const result: Record<number, ChartComparisonData> = {};
for (const chartId of chartIds) {
const chartState = state.charts[chartId] as
| ChartStateWithOriginal
| undefined;
if (chartState) {
const originalResponse = chartState.originalQueriesResponse?.[0] as
| QueryResponse
| undefined;
const modifiedResponse = chartState.queriesResponse?.[0] as
| QueryResponse
| undefined;
result[chartId] = {
chartStatus: chartState.chartStatus,
originalData: originalResponse?.data,
modifiedData: modifiedResponse?.data,
colnames: modifiedResponse?.colnames,
coltypes: modifiedResponse?.coltypes,
};
}
}
return result;
}, shallowEqual);
}
/**
* Selector that extracts chart display names and viz types for specific chart IDs.
* Uses sliceNameOverride from dashboard layout if available, otherwise falls back to slice_name.
*/
function useChartDisplayData(
chartIds: number[],
): Record<number, { displayName: string; viz_type: string }> {
return useSelector((state: RootState) => {
const slices = state.sliceEntities.slices as { [id: number]: Slice };
const dashboardLayout = state.dashboardLayout?.present;
const result: Record<number, { displayName: string; viz_type: string }> =
{};
// Build a map of chartId -> sliceNameOverride from dashboard layout
const nameOverrides: Record<number, string | undefined> = {};
if (dashboardLayout) {
for (const item of Object.values(dashboardLayout)) {
if (item.type === CHART_TYPE && item.meta?.chartId) {
nameOverrides[item.meta.chartId] = item.meta.sliceNameOverride;
}
}
}
for (const chartId of chartIds) {
const slice = slices[chartId];
if (slice) {
result[chartId] = {
displayName: nameOverrides[chartId] || slice.slice_name,
viz_type: slice.viz_type,
};
}
}
return result;
}, shallowEqual);
}
export function useChartComparison(
affectedChartIds: number[],
): ChartComparison[] {
const visibleChartIds = useChartsInActiveTabs(affectedChartIds);
const chartData = useChartComparisonData(visibleChartIds);
const chartDisplayData = useChartDisplayData(visibleChartIds);
return useMemo(() => {
const comparisons: ChartComparison[] = [];
for (const chartId of visibleChartIds) {
const chartState = chartData[chartId];
const displayData = chartDisplayData[chartId];
if (!chartState || !displayData) continue;
const { originalData, modifiedData } = chartState;
if (!originalData || !modifiedData) continue;
// Skip if original and modified data are the same reference
// This indicates the what-if query hasn't completed yet (race condition guard)
if (originalData === modifiedData) {
continue;
}
// Get column names and types from the response
const colnames = chartState.colnames || [];
const coltypes = chartState.coltypes || [];
const metrics: ChartMetricComparison[] = [];
for (let i = 0; i < colnames.length; i += 1) {
const metricName = colnames[i];
const coltype = coltypes[i];
// Only include numeric columns (not temporal/date, string, or boolean)
// This filters out x-axis date columns and dimension columns
// If coltypes is available, use it; otherwise fall back to runtime check
if (coltype !== undefined && coltype !== GenericDataType.Numeric) {
continue;
}
// Runtime check: verify the column actually contains numeric values
// This also catches cases where coltypes is not available
if (!isNumericColumn(modifiedData, metricName)) continue;
const originalValue = extractMetricValue(originalData, metricName);
const modifiedValue = extractMetricValue(modifiedData, metricName);
if (
originalValue !== null &&
modifiedValue !== null &&
originalValue !== 0
) {
const percentageChange =
((modifiedValue - originalValue) / Math.abs(originalValue)) * 100;
metrics.push({
metricName,
originalValue,
modifiedValue,
percentageChange,
});
}
}
if (metrics.length > 0) {
comparisons.push({
chartId,
chartName: displayData.displayName,
chartType: displayData.viz_type,
metrics,
});
}
}
return comparisons;
}, [chartData, chartDisplayData, visibleChartIds]);
}
/**
* Selector that extracts only loading statuses for specific chart IDs.
*/
function useChartLoadingStatuses(
chartIds: number[],
): Record<number, string | undefined> {
return useSelector((state: RootState) => {
const result: Record<number, string | undefined> = {};
for (const chartId of chartIds) {
const chartState = state.charts[chartId] as
| ChartStateWithOriginal
| undefined;
result[chartId] = chartState?.chartStatus;
}
return result;
}, shallowEqual);
}
/**
* Check if all affected charts (in active tabs) have finished loading.
* Returns true only if ALL visible charts are in a definitive complete state
* ('success' or 'rendered'). This prevents race conditions where charts
* might briefly be in an intermediate state.
*/
export function useAllChartsLoaded(chartIds: number[]): boolean {
const visibleChartIds = useChartsInActiveTabs(chartIds);
const chartStatuses = useChartLoadingStatuses(visibleChartIds);
return useMemo(() => {
// Require explicit completion status, not just "not loading"
// This prevents race conditions during state transitions
// Include 'failed' to avoid waiting indefinitely for charts that errored
const completeStatuses = ['success', 'rendered', 'failed'];
return (
visibleChartIds.length > 0 &&
visibleChartIds.every(id =>
completeStatuses.includes(chartStatuses[id] ?? ''),
)
);
}, [chartStatuses, visibleChartIds]);
}

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