mirror of
https://github.com/apache/superset.git
synced 2026-06-14 12:09:14 +00:00
Compare commits
11 Commits
cloudflare
...
file-handl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7083d09777 | ||
|
|
6b948ee894 | ||
|
|
5e0ee40762 | ||
|
|
6aaf2266a9 | ||
|
|
d14f502126 | ||
|
|
d0361cb881 | ||
|
|
821b259805 | ||
|
|
2329d49f9e | ||
|
|
28e3ba749e | ||
|
|
cd2c889c9a | ||
|
|
52c711b0bc |
13
.github/workflows/bashlib.sh
vendored
13
.github/workflows/bashlib.sh
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/superset-e2e.yml
vendored
2
.github/workflows/superset-e2e.yml
vendored
@@ -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
|
||||
|
||||
18
.github/workflows/superset-frontend.yml
vendored
18
.github/workflows/superset-frontend.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/superset-playwright.yml
vendored
2
.github/workflows/superset-playwright.yml
vendored
@@ -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
|
||||
|
||||
15
README.md
15
README.md
@@ -17,6 +17,21 @@ specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Superset
|
||||
|
||||
[](https://opensource.org/license/apache-2-0)
|
||||
[](https://github.com/apache/superset/releases/latest)
|
||||
[](https://github.com/apache/superset/actions)
|
||||
[](https://badge.fury.io/py/apache_superset)
|
||||
[](https://pypi.python.org/pypi/apache_superset)
|
||||
[](https://github.com/apache/superset/stargazers)
|
||||
[](https://github.com/apache/superset/graphs/contributors)
|
||||
[](https://github.com/apache/superset/commits/master)
|
||||
[](https://github.com/apache/superset/issues)
|
||||
[](https://github.com/apache/superset/pulls)
|
||||
[](http://bit.ly/join-superset-slack)
|
||||
[](https://superset.apache.org)
|
||||
|
||||
<picture width="500">
|
||||
<source
|
||||
width="600"
|
||||
|
||||
3
docs/.gitignore
vendored
3
docs/.gitignore
vendored
@@ -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/
|
||||
|
||||
131
docs/developer_portal/extensions/components/alert.mdx
Normal file
131
docs/developer_portal/extensions/components/alert.mdx
Normal 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.*
|
||||
93
docs/developer_portal/extensions/components/index.mdx
Normal file
93
docs/developer_portal/extensions/components/index.mdx
Normal 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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
// {
|
||||
|
||||
@@ -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",
|
||||
|
||||
224
docs/plugins/remark-localize-badges.mjs
Normal file
224
docs/plugins/remark-localize-badges.mjs
Normal 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: [](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);
|
||||
};
|
||||
}
|
||||
676
docs/scripts/generate-extension-components.mjs
Normal file
676
docs/scripts/generate-extension-components.mjs
Normal 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);
|
||||
@@ -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',
|
||||
|
||||
53
docs/src/theme/ReactLiveScope/index.tsx
Normal file
53
docs/src/theme/ReactLiveScope/index.tsx
Normal 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;
|
||||
31
docs/src/types/apache-superset-core/index.d.ts
vendored
Normal file
31
docs/src/types/apache-superset-core/index.d.ts
vendored
Normal 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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
223
docs/yarn.lock
223
docs/yarn.lock
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
177
superset-core/src/superset_core/api/types.py
Normal file
177
superset-core/src/superset_core/api/types.py
Normal 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",
|
||||
]
|
||||
3
superset-frontend/.gitignore
vendored
3
superset-frontend/.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
coverage/*
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
playwright/.auth
|
||||
playwright-report/
|
||||
test-results/
|
||||
src/temp
|
||||
.temp_cache/
|
||||
.tsbuildinfo
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
38
superset-frontend/.storybook/test-runner.ts
Normal file
38
superset-frontend/.storybook/test-runner.ts
Normal 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;
|
||||
8555
superset-frontend/package-lock.json
generated
8555
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 = () => (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
118
superset-frontend/playwright/components/core/Modal.ts
Normal file
118
superset-frontend/playwright/components/core/Modal.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
102
superset-frontend/playwright/components/core/Table.ts
Normal file
102
superset-frontend/playwright/components/core/Table.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
105
superset-frontend/playwright/components/core/Toast.ts
Normal file
105
superset-frontend/playwright/components/core/Toast.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,5 @@
|
||||
export { Button } from './Button';
|
||||
export { Form } from './Form';
|
||||
export { Input } from './Input';
|
||||
export { Modal } from './Modal';
|
||||
export { Table } from './Table';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
22
superset-frontend/playwright/components/modals/index.ts
Normal file
22
superset-frontend/playwright/components/modals/index.ts
Normal 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';
|
||||
93
superset-frontend/playwright/global-setup.ts
Normal file
93
superset-frontend/playwright/global-setup.ts
Normal 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;
|
||||
79
superset-frontend/playwright/helpers/api/database.ts
Normal file
79
superset-frontend/playwright/helpers/api/database.ts
Normal 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);
|
||||
}
|
||||
133
superset-frontend/playwright/helpers/api/dataset.ts
Normal file
133
superset-frontend/playwright/helpers/api/dataset.ts
Normal 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);
|
||||
}
|
||||
193
superset-frontend/playwright/helpers/api/requests.ts
Normal file
193
superset-frontend/playwright/helpers/api/requests.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
115
superset-frontend/playwright/pages/DatasetListPage.ts
Normal file
115
superset-frontend/playwright/pages/DatasetListPage.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
88
superset-frontend/playwright/pages/ExplorePage.ts
Normal file
88
superset-frontend/playwright/pages/ExplorePage.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
46
superset-frontend/playwright/utils/constants.ts
Normal file
46
superset-frontend/playwright/utils/constants.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -96,7 +96,7 @@ const jsFunctionInfo = (
|
||||
</div>
|
||||
);
|
||||
|
||||
function jsFunctionControl(
|
||||
export function jsFunctionControl(
|
||||
label: string,
|
||||
description: string,
|
||||
extraDescr = null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
superset-frontend/src/assets/images/pwa/icon-192.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
BIN
superset-frontend/src/assets/images/pwa/icon-512.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
superset-frontend/src/assets/images/pwa/screenshot-narrow.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/screenshot-narrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
superset-frontend/src/assets/images/pwa/screenshot-wide.png
Normal file
BIN
superset-frontend/src/assets/images/pwa/screenshot-wide.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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! };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -311,18 +313,17 @@ test('render time filter types as disabled if there are no temporal columns in t
|
||||
test('validates the name', async () => {
|
||||
defaultRender();
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText(NAME_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(NAME_REQUIRED_REGEX, {}, { timeout: 3000 }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('validates the column', async () => {
|
||||
defaultRender();
|
||||
await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
|
||||
expect(await screen.findByText(COLUMN_REQUIRED_REGEX)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(COLUMN_REQUIRED_REGEX, {}, { timeout: 3000 }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
@@ -356,6 +357,7 @@ test('validates the pre-filter value', async () => {
|
||||
expect(errorMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
} finally {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
|
||||
|
||||
@@ -74,12 +74,13 @@ export function executeQuery(payload: QueryExecutePayload) {
|
||||
};
|
||||
}
|
||||
|
||||
export function formatQuery(sql: string) {
|
||||
export function formatQuery(sql: string, options?: { signal?: AbortSignal }) {
|
||||
return function (dispatch: ThunkDispatch<any, undefined, AnyAction>) {
|
||||
return SupersetClient.post({
|
||||
endpoint: `/api/v1/sqllab/format_sql/`,
|
||||
body: JSON.stringify({ sql }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: options?.signal,
|
||||
}).then(response => {
|
||||
dispatch(setQuery(response.json.result));
|
||||
return response;
|
||||
|
||||
@@ -67,6 +67,7 @@ interface UploadDataModalProps {
|
||||
show: boolean;
|
||||
allowedExtensions: string[];
|
||||
type: UploadType;
|
||||
fileListOverride?: File[];
|
||||
}
|
||||
|
||||
const CSVSpecificFields = [
|
||||
@@ -215,6 +216,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
|
||||
show,
|
||||
allowedExtensions,
|
||||
type = 'csv',
|
||||
fileListOverride,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [currentDatabaseId, setCurrentDatabaseId] = useState<number>(0);
|
||||
@@ -524,10 +526,26 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
|
||||
await loadFileMetadata(info.file.originFileObj);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fileListOverride?.length) {
|
||||
setFileList(
|
||||
fileListOverride.map(file => ({
|
||||
uid: file.name,
|
||||
name: file.name,
|
||||
originFileObj: file as UploadFile['originFileObj'],
|
||||
status: 'done' as const,
|
||||
})),
|
||||
);
|
||||
if (previewUploadedFile) {
|
||||
loadFileMetadata(fileListOverride[0]).then(r => r);
|
||||
}
|
||||
}
|
||||
}, [fileListOverride, previewUploadedFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
columns.length > 0 &&
|
||||
fileList[0].originFileObj &&
|
||||
fileList.length > 0 &&
|
||||
fileList[0].originFileObj instanceof File
|
||||
) {
|
||||
if (!previewUploadedFile) {
|
||||
|
||||
368
superset-frontend/src/pages/FileHandler/index.test.tsx
Normal file
368
superset-frontend/src/pages/FileHandler/index.test.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 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 { ComponentType } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { MemoryRouter, Route } from 'react-router-dom';
|
||||
import FileHandler from './index';
|
||||
|
||||
const mockAddDangerToast = jest.fn();
|
||||
const mockAddSuccessToast = jest.fn();
|
||||
const mockHistoryPush = jest.fn();
|
||||
|
||||
type ToastInjectedProps = {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
};
|
||||
|
||||
// Mock the withToasts HOC
|
||||
jest.mock('src/components/MessageToasts/withToasts', () => ({
|
||||
__esModule: true,
|
||||
default: (Component: ComponentType<ToastInjectedProps>) =>
|
||||
function MockedWithToasts(props: Record<string, unknown>) {
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
addDangerToast={mockAddDangerToast}
|
||||
addSuccessToast={mockAddSuccessToast}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
interface UploadDataModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
type: string;
|
||||
allowedExtensions: string[];
|
||||
fileListOverride?: File[];
|
||||
}
|
||||
|
||||
// Mock the UploadDataModal
|
||||
jest.mock('src/features/databases/UploadDataModel', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
show,
|
||||
onHide,
|
||||
type,
|
||||
allowedExtensions,
|
||||
fileListOverride,
|
||||
}: UploadDataModalProps) => (
|
||||
<div data-test="upload-modal">
|
||||
<div data-test="modal-show">{show.toString()}</div>
|
||||
<div data-test="modal-type">{type}</div>
|
||||
<div data-test="modal-extensions">{allowedExtensions.join(',')}</div>
|
||||
<div data-test="modal-file">{fileListOverride?.[0]?.name ?? ''}</div>
|
||||
<button onClick={onHide}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-router-dom's useHistory
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({
|
||||
push: mockHistoryPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the File API
|
||||
type MockFileHandle = {
|
||||
kind: 'file';
|
||||
name: string;
|
||||
getFile: () => Promise<File>;
|
||||
isSameEntry: () => Promise<boolean>;
|
||||
queryPermission: () => Promise<PermissionState>;
|
||||
requestPermission: () => Promise<PermissionState>;
|
||||
};
|
||||
|
||||
const createMockFileHandle = (fileName: string): MockFileHandle => ({
|
||||
kind: 'file',
|
||||
name: fileName,
|
||||
getFile: async () => new File(['test'], fileName),
|
||||
isSameEntry: async () => false,
|
||||
queryPermission: async () => 'granted',
|
||||
requestPermission: async () => 'granted',
|
||||
});
|
||||
|
||||
type LaunchQueue = {
|
||||
setConsumer: (
|
||||
consumer: (params: { files?: MockFileHandle[] }) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
|
||||
let savedConsumer:
|
||||
| ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
|
||||
| null = null;
|
||||
(window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = {
|
||||
setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
|
||||
savedConsumer = consumer;
|
||||
if (fileHandle) {
|
||||
setTimeout(() => {
|
||||
consumer({
|
||||
files: [fileHandle],
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
};
|
||||
return {
|
||||
triggerConsumer: async (params: { files?: MockFileHandle[] }) => {
|
||||
await savedConsumer?.(params);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete (window as any).launchQueue;
|
||||
});
|
||||
|
||||
test('shows error when launchQueue is not supported', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
|
||||
);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
test('redirects when no files are provided', async () => {
|
||||
const { triggerConsumer } = setupLaunchQueue();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
// Trigger the consumer with no files
|
||||
await triggerConsumer({ files: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
test('handles CSV file correctly', async () => {
|
||||
const fileHandle = createMockFileHandle('test.csv');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId('upload-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(screen.getByTestId('modal-show')).toHaveTextContent('true');
|
||||
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
|
||||
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('csv');
|
||||
expect(screen.getByTestId('modal-file')).toHaveTextContent('test.csv');
|
||||
});
|
||||
|
||||
test('handles Excel (.xls) file correctly', async () => {
|
||||
const fileHandle = createMockFileHandle('test.xls');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId('upload-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
|
||||
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
|
||||
});
|
||||
|
||||
test('handles Excel (.xlsx) file correctly', async () => {
|
||||
const fileHandle = createMockFileHandle('test.xlsx');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId('upload-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
|
||||
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
|
||||
});
|
||||
|
||||
test('handles Parquet file correctly', async () => {
|
||||
const fileHandle = createMockFileHandle('test.parquet');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId('upload-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(screen.getByTestId('modal-type')).toHaveTextContent('columnar');
|
||||
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('parquet');
|
||||
});
|
||||
|
||||
test('shows error for unsupported file type', async () => {
|
||||
const { triggerConsumer } = setupLaunchQueue();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
// Trigger with unsupported file
|
||||
const fileHandle = createMockFileHandle('test.pdf');
|
||||
await triggerConsumer({ files: [fileHandle] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'Unsupported file type. Please use CSV, Excel, or Columnar files.',
|
||||
);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
test('handles file with uppercase extension', async () => {
|
||||
const fileHandle = createMockFileHandle('test.CSV');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId('upload-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
|
||||
});
|
||||
|
||||
test('handles errors during file processing', async () => {
|
||||
const { triggerConsumer } = setupLaunchQueue();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
// Trigger with a file handle that throws an error
|
||||
const errorFileHandle: MockFileHandle = {
|
||||
kind: 'file',
|
||||
name: 'error.csv',
|
||||
getFile: async () => {
|
||||
throw new Error('File access denied');
|
||||
},
|
||||
isSameEntry: async () => false,
|
||||
queryPermission: async () => 'granted',
|
||||
requestPermission: async () => 'granted',
|
||||
};
|
||||
|
||||
await triggerConsumer({ files: [errorFileHandle] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
'Failed to open file. Please try again.',
|
||||
);
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
test('modal close redirects to welcome page', async () => {
|
||||
const fileHandle = createMockFileHandle('test.csv');
|
||||
setupLaunchQueue(fileHandle);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
const modal = await screen.findByTestId('upload-modal');
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// Click the close button in the mocked modal
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
closeButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
|
||||
});
|
||||
});
|
||||
|
||||
test('shows loading state while waiting for file', () => {
|
||||
setupLaunchQueue();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/superset/file-handler']}>
|
||||
<Route path="/superset/file-handler">
|
||||
<FileHandler />
|
||||
</Route>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
// Should show loading initially before file is processed
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
138
superset-frontend/src/pages/FileHandler/index.tsx
Normal file
138
superset-frontend/src/pages/FileHandler/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import UploadDataModal from 'src/features/databases/UploadDataModel';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
|
||||
interface FileLaunchParams {
|
||||
readonly files?: readonly FileSystemFileHandle[];
|
||||
}
|
||||
|
||||
interface LaunchQueue {
|
||||
setConsumer: (consumer: (params: FileLaunchParams) => void) => void;
|
||||
}
|
||||
|
||||
interface WindowWithLaunchQueue extends Window {
|
||||
launchQueue?: LaunchQueue;
|
||||
}
|
||||
|
||||
interface FileHandlerProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
const FileHandler = ({ addDangerToast, addSuccessToast }: FileHandlerProps) => {
|
||||
const history = useHistory();
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [uploadType, setUploadType] = useState<
|
||||
'csv' | 'excel' | 'columnar' | null
|
||||
>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [allowedExtensions, setAllowedExtensions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFileLaunch = async () => {
|
||||
const { launchQueue } = window as WindowWithLaunchQueue;
|
||||
|
||||
if (!launchQueue) {
|
||||
addDangerToast(
|
||||
t(
|
||||
'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
|
||||
),
|
||||
);
|
||||
history.push('/superset/welcome/');
|
||||
return;
|
||||
}
|
||||
|
||||
launchQueue.setConsumer(async (launchParams: FileLaunchParams) => {
|
||||
if (!launchParams.files || launchParams.files.length === 0) {
|
||||
history.push('/superset/welcome/');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileHandle = launchParams.files[0];
|
||||
const file = await fileHandle.getFile();
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
let type: 'csv' | 'excel' | 'columnar' | null = null;
|
||||
let extensions: string[] = [];
|
||||
|
||||
if (fileName.endsWith('.csv')) {
|
||||
type = 'csv';
|
||||
extensions = ['csv'];
|
||||
} else if (fileName.endsWith('.xls') || fileName.endsWith('.xlsx')) {
|
||||
type = 'excel';
|
||||
extensions = ['xls', 'xlsx'];
|
||||
} else if (fileName.endsWith('.parquet')) {
|
||||
type = 'columnar';
|
||||
extensions = ['parquet'];
|
||||
} else {
|
||||
addDangerToast(
|
||||
t(
|
||||
'Unsupported file type. Please use CSV, Excel, or Columnar files.',
|
||||
),
|
||||
);
|
||||
history.push('/superset/welcome/');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadFile(file);
|
||||
setUploadType(type);
|
||||
setAllowedExtensions(extensions);
|
||||
setShowModal(true);
|
||||
} catch (error) {
|
||||
console.error('Error handling file launch:', error);
|
||||
addDangerToast(t('Failed to open file. Please try again.'));
|
||||
history.push('/superset/welcome/');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
handleFileLaunch();
|
||||
}, [history, addDangerToast]);
|
||||
|
||||
const handleModalClose = () => {
|
||||
setShowModal(false);
|
||||
setUploadFile(null);
|
||||
setUploadType(null);
|
||||
history.push('/superset/welcome/');
|
||||
};
|
||||
|
||||
if (!uploadFile || !uploadType) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UploadDataModal
|
||||
show={showModal}
|
||||
onHide={handleModalClose}
|
||||
fileListOverride={[uploadFile]}
|
||||
allowedExtensions={allowedExtensions}
|
||||
type={uploadType}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default withToasts(FileHandler);
|
||||
65
superset-frontend/src/pwa-manifest.json
Normal file
65
superset-frontend/src/pwa-manifest.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "Apache Superset",
|
||||
"short_name": "Superset",
|
||||
"description": "Modern data exploration and visualization platform",
|
||||
"start_url": "/superset/welcome/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#20a7c9",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/assets/images/pwa/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/assets/images/pwa/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/static/assets/images/pwa/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/assets/images/pwa/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/static/assets/images/pwa/screenshot-wide.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "Apache Superset Dashboard"
|
||||
},
|
||||
{
|
||||
"src": "/static/assets/images/pwa/screenshot-narrow.png",
|
||||
"sizes": "540x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "Apache Superset Mobile View"
|
||||
}
|
||||
],
|
||||
"file_handlers": [
|
||||
{
|
||||
"action": "/superset/file-handler",
|
||||
"accept": {
|
||||
"text/csv": [".csv"],
|
||||
"application/vnd.ms-excel": [".xls"],
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
|
||||
".xlsx"
|
||||
],
|
||||
"application/vnd.apache.parquet": [".parquet"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
superset-frontend/src/service-worker.ts
Normal file
38
superset-frontend/src/service-worker.ts
Normal 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.
|
||||
*/
|
||||
|
||||
// Service Worker types (declared locally to avoid polluting global scope)
|
||||
declare const self: {
|
||||
skipWaiting(): Promise<void>;
|
||||
clients: { claim(): Promise<void> };
|
||||
addEventListener(
|
||||
type: 'install' | 'activate',
|
||||
listener: (event: { waitUntil(promise: Promise<unknown>): void }) => void,
|
||||
): void;
|
||||
};
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
export {};
|
||||
@@ -171,6 +171,10 @@ const UserRegistrations = lazy(
|
||||
),
|
||||
);
|
||||
|
||||
const FileHandler = lazy(
|
||||
() => import(/* webpackChunkName: "FileHandler" */ 'src/pages/FileHandler'),
|
||||
);
|
||||
|
||||
type Routes = {
|
||||
path: string;
|
||||
Component: ComponentType;
|
||||
@@ -199,6 +203,10 @@ export const routes: Routes = [
|
||||
path: '/superset/welcome/',
|
||||
Component: Home,
|
||||
},
|
||||
{
|
||||
path: '/superset/file-handler',
|
||||
Component: FileHandler,
|
||||
},
|
||||
{
|
||||
path: '/dashboard/list/',
|
||||
Component: DashboardList,
|
||||
|
||||
33
superset-frontend/test-runner-jest.config.js
Normal file
33
superset-frontend/test-runner-jest.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Jest configuration for @storybook/test-runner
|
||||
*
|
||||
* This extends the default test-runner config with custom timeouts
|
||||
* to handle slow story rendering in CI environments.
|
||||
*/
|
||||
const { getJestConfig } = require('@storybook/test-runner');
|
||||
const testRunnerConfig = getJestConfig();
|
||||
|
||||
module.exports = {
|
||||
...testRunnerConfig,
|
||||
// Increase timeout from default 15s to 60s for CI environments
|
||||
testTimeout: 60000,
|
||||
};
|
||||
@@ -66,25 +66,35 @@ const devserverHost =
|
||||
cliHost || process.env.WEBPACK_DEVSERVER_HOST || '127.0.0.1';
|
||||
|
||||
const isDevMode = mode !== 'production';
|
||||
const isDevServer = process.argv[1].includes('webpack-dev-server');
|
||||
const isDevServer = process.argv[1]?.includes('webpack-dev-server') ?? false;
|
||||
|
||||
// TypeScript checker memory limit (in MB)
|
||||
const TYPESCRIPT_MEMORY_LIMIT = 4096;
|
||||
|
||||
const defaultEntryFilename = isDevMode
|
||||
? '[name].[contenthash:8].entry.js'
|
||||
: nameChunks
|
||||
? '[name].[chunkhash].entry.js'
|
||||
: '[name].[chunkhash].entry.js';
|
||||
|
||||
const defaultChunkFilename = isDevMode
|
||||
? '[name].[contenthash:8].chunk.js'
|
||||
: nameChunks
|
||||
? '[name].[chunkhash].chunk.js'
|
||||
: '[chunkhash].chunk.js';
|
||||
|
||||
const output = {
|
||||
path: BUILD_DIR,
|
||||
publicPath: '/static/assets/',
|
||||
filename: pathData =>
|
||||
pathData.chunk?.name === 'service-worker'
|
||||
? '../service-worker.js'
|
||||
: defaultEntryFilename,
|
||||
chunkFilename: pathData =>
|
||||
pathData.chunk?.name === 'service-worker'
|
||||
? '../service-worker.js'
|
||||
: defaultChunkFilename,
|
||||
};
|
||||
if (isDevMode) {
|
||||
output.filename = '[name].[contenthash:8].entry.js';
|
||||
output.chunkFilename = '[name].[contenthash:8].chunk.js';
|
||||
} else if (nameChunks) {
|
||||
output.filename = '[name].[chunkhash].entry.js';
|
||||
output.chunkFilename = '[name].[chunkhash].chunk.js';
|
||||
} else {
|
||||
output.filename = '[name].[chunkhash].entry.js';
|
||||
output.chunkFilename = '[chunkhash].chunk.js';
|
||||
}
|
||||
|
||||
if (!isDevMode) {
|
||||
output.clean = true;
|
||||
@@ -139,7 +149,11 @@ const plugins = [
|
||||
}),
|
||||
|
||||
new CopyPlugin({
|
||||
patterns: ['package.json', { from: 'src/assets/images', to: 'images' }],
|
||||
patterns: [
|
||||
'package.json',
|
||||
{ from: 'src/assets/images', to: 'images' },
|
||||
{ from: 'src/pwa-manifest.json', to: 'pwa-manifest.json' },
|
||||
],
|
||||
}),
|
||||
|
||||
// static pages
|
||||
@@ -184,7 +198,13 @@ if (!process.env.CI) {
|
||||
|
||||
// Add React Refresh plugin for development mode
|
||||
if (isDevMode) {
|
||||
plugins.push(new ReactRefreshWebpackPlugin());
|
||||
plugins.push(
|
||||
new ReactRefreshWebpackPlugin({
|
||||
// Exclude service worker from React Refresh - it runs in a worker context
|
||||
// without DOM/window and doesn't need HMR
|
||||
exclude: /service-worker/,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDevMode) {
|
||||
@@ -300,6 +320,7 @@ const config = {
|
||||
menu: addPreamble('src/views/menu.tsx'),
|
||||
spa: addPreamble('/src/views/index.tsx'),
|
||||
embedded: addPreamble('/src/embedded/index.tsx'),
|
||||
'service-worker': path.join(APP_DIR, 'src/service-worker.ts'),
|
||||
},
|
||||
cache: {
|
||||
type: 'filesystem', // Enable filesystem caching
|
||||
|
||||
@@ -123,7 +123,7 @@ def migrate_chart(config: dict[str, Any]) -> dict[str, Any]:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
query_context = {}
|
||||
if "form_data" in query_context:
|
||||
query_context["form_data"] = output["params"]
|
||||
query_context["form_data"] = params
|
||||
output["query_context"] = json.dumps(query_context)
|
||||
|
||||
return output
|
||||
|
||||
@@ -22,7 +22,7 @@ from typing import Any
|
||||
|
||||
from marshmallow import Schema
|
||||
from sqlalchemy.orm import Session # noqa: F401
|
||||
from sqlalchemy.sql import select
|
||||
from sqlalchemy.sql import delete, select
|
||||
|
||||
from superset import db
|
||||
from superset.charts.schemas import ImportV1ChartSchema
|
||||
@@ -157,9 +157,14 @@ class ImportDashboardsCommand(ImportModelsCommand):
|
||||
)
|
||||
|
||||
# store the existing relationship between dashboards and charts
|
||||
existing_relationships = db.session.execute(
|
||||
select([dashboard_slices.c.dashboard_id, dashboard_slices.c.slice_id])
|
||||
).fetchall()
|
||||
# (only used when overwrite=False to avoid inserting duplicates)
|
||||
existing_relationships: set[tuple[int, int]] = set()
|
||||
if not overwrite:
|
||||
existing_relationships = set(
|
||||
db.session.execute(
|
||||
select(dashboard_slices.c.dashboard_id, dashboard_slices.c.slice_id)
|
||||
).fetchall()
|
||||
)
|
||||
|
||||
# import dashboards
|
||||
dashboards: list[Dashboard] = []
|
||||
@@ -177,11 +182,25 @@ class ImportDashboardsCommand(ImportModelsCommand):
|
||||
del config["theme_uuid"]
|
||||
dashboard = import_dashboard(config, overwrite=overwrite)
|
||||
dashboards.append(dashboard)
|
||||
|
||||
# When overwriting, first delete all existing chart relationships
|
||||
# so the dashboard is replaced rather than merged
|
||||
if overwrite:
|
||||
db.session.execute(
|
||||
delete(dashboard_slices).where(
|
||||
dashboard_slices.c.dashboard_id == dashboard.id
|
||||
)
|
||||
)
|
||||
|
||||
# Collect chart IDs to associate with this dashboard
|
||||
for uuid in find_chart_uuids(config["position"]):
|
||||
if uuid not in chart_ids:
|
||||
break
|
||||
continue
|
||||
chart_id = chart_ids[uuid]
|
||||
if (dashboard.id, chart_id) not in existing_relationships:
|
||||
if (
|
||||
overwrite
|
||||
or (dashboard.id, chart_id) not in existing_relationships
|
||||
):
|
||||
dashboard_chart_ids.append((dashboard.id, chart_id))
|
||||
|
||||
# Handle tags using import_tag function
|
||||
@@ -197,11 +216,12 @@ class ImportDashboardsCommand(ImportModelsCommand):
|
||||
)
|
||||
|
||||
# set ref in the dashboard_slices table
|
||||
values = [
|
||||
{"dashboard_id": dashboard_id, "slice_id": chart_id}
|
||||
for (dashboard_id, chart_id) in dashboard_chart_ids
|
||||
]
|
||||
db.session.execute(dashboard_slices.insert(), values)
|
||||
if dashboard_chart_ids:
|
||||
values = [
|
||||
{"dashboard_id": dashboard_id, "slice_id": chart_id}
|
||||
for (dashboard_id, chart_id) in dashboard_chart_ids
|
||||
]
|
||||
db.session.execute(dashboard_slices.insert(), values)
|
||||
|
||||
# Migrate any filter-box charts to native dashboard filters.
|
||||
for dashboard in dashboards:
|
||||
|
||||
@@ -44,7 +44,9 @@ class CreateRLSRuleCommand(BaseCommand):
|
||||
def validate(self) -> None:
|
||||
roles = populate_roles(self._roles)
|
||||
tables = (
|
||||
db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all() # type: ignore[attr-defined]
|
||||
db.session.query(SqlaTable)
|
||||
.filter(SqlaTable.id.in_(self._tables)) # type: ignore[attr-defined]
|
||||
.all()
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
|
||||
@@ -51,7 +51,9 @@ class UpdateRLSRuleCommand(BaseCommand):
|
||||
raise RLSRuleNotFoundError()
|
||||
roles = populate_roles(self._roles)
|
||||
tables = (
|
||||
db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all() # type: ignore[attr-defined]
|
||||
db.session.query(SqlaTable)
|
||||
.filter(SqlaTable.id.in_(self._tables)) # type: ignore[attr-defined]
|
||||
.all()
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
|
||||
@@ -173,7 +173,7 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
|
||||
def __init_subclass__(cls) -> None:
|
||||
cls.model_cls = get_args(
|
||||
cls.__orig_bases__[0] # type: ignore # pylint: disable=no-member
|
||||
cls.__orig_bases__[0] # type: ignore[attr-defined] # pylint: disable=no-member
|
||||
)[0]
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -257,6 +257,7 @@ class ImportV1ColumnSchema(Schema):
|
||||
expression = fields.String(allow_none=True)
|
||||
description = fields.String(allow_none=True)
|
||||
python_date_format = fields.String(allow_none=True)
|
||||
datetime_format = fields.String(allow_none=True)
|
||||
|
||||
|
||||
class ImportMetricCurrencySchema(Schema):
|
||||
|
||||
@@ -634,7 +634,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
|
||||
def register_request_handlers(self) -> None:
|
||||
"""Register app-level request handlers"""
|
||||
from flask import Response
|
||||
from flask import request, Response
|
||||
|
||||
@self.superset_app.after_request
|
||||
def apply_http_headers(response: Response) -> Response:
|
||||
@@ -650,6 +650,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
for k, v in self.superset_app.config["DEFAULT_HTTP_HEADERS"].items():
|
||||
if k not in response.headers:
|
||||
response.headers[k] = v
|
||||
|
||||
# Allow service worker to control the root scope for PWA file handling
|
||||
if (
|
||||
request.path.endswith("service-worker.js")
|
||||
and "Service-Worker-Allowed" not in response.headers
|
||||
):
|
||||
response.headers["Service-Worker-Allowed"] = "/"
|
||||
|
||||
return response
|
||||
|
||||
@self.superset_app.after_request
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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.
|
||||
"""fix_form_data_string_in_query_context
|
||||
|
||||
Revision ID: f5b5f88d8526
|
||||
Revises: a9c01ec10479
|
||||
Create Date: 2025-12-16 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, Integer, String, Text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from superset import db
|
||||
from superset.migrations.shared.utils import paginated_update
|
||||
from superset.utils import json
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f5b5f88d8526"
|
||||
down_revision = "a9c01ec10479"
|
||||
|
||||
Base = declarative_base()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Viz types that have migrations that were going through the bug
|
||||
MIGRATED_VIZ_TYPES = [
|
||||
"treemap_v2",
|
||||
"pivot_table_v2",
|
||||
"mixed_timeseries",
|
||||
"sunburst_v2",
|
||||
"echarts_timeseries_line",
|
||||
"echarts_timeseries_smooth",
|
||||
"echarts_timeseries_step",
|
||||
"echarts_area",
|
||||
"echarts_timeseries_bar",
|
||||
"bubble_v2",
|
||||
"heatmap_v2",
|
||||
"histogram_v2",
|
||||
"sankey_v2",
|
||||
]
|
||||
|
||||
|
||||
class Slice(Base):
|
||||
__tablename__ = "slices"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
viz_type = Column(String(250))
|
||||
query_context = Column(Text)
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Fix charts where form_data in query_context was stored as a JSON string
|
||||
instead of a dict during chart import migration.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
|
||||
for slc in paginated_update(
|
||||
session.query(Slice).filter(
|
||||
Slice.viz_type.in_(MIGRATED_VIZ_TYPES),
|
||||
Slice.query_context.isnot(None),
|
||||
)
|
||||
):
|
||||
try:
|
||||
query_context = json.loads(slc.query_context)
|
||||
form_data = query_context.get("form_data")
|
||||
|
||||
# Check if form_data is a non-empty string (the bug)
|
||||
if form_data and isinstance(form_data, str):
|
||||
try:
|
||||
query_context["form_data"] = json.loads(form_data)
|
||||
slc.query_context = json.dumps(query_context, sort_keys=True)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Could not parse form_data for slice %s, skipping", slc.id
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Could not parse query_context for slice %s, skipping", slc.id
|
||||
)
|
||||
except Exception: # noqa: S110
|
||||
logger.warning("Could not update form_data for slice %s, skipping", slc.id)
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
|
||||
def downgrade():
|
||||
# This migration fixes data corruption, downgrade is not meaningful
|
||||
pass
|
||||
@@ -95,6 +95,8 @@ metadata = Model.metadata # pylint: disable=no-member
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.api.types import AsyncQueryHandle, QueryOptions, QueryResult
|
||||
|
||||
from superset.models.sql_lab import Query
|
||||
|
||||
|
||||
@@ -1276,6 +1278,38 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
|
||||
DatabaseUserOAuth2Tokens.id == self.id
|
||||
).delete()
|
||||
|
||||
def execute(
|
||||
self,
|
||||
sql: str,
|
||||
options: QueryOptions | None = None,
|
||||
) -> QueryResult:
|
||||
"""
|
||||
Execute SQL synchronously.
|
||||
|
||||
:param sql: SQL query to execute
|
||||
:param options: QueryOptions with execution settings
|
||||
:returns: QueryResult with status, data, and metadata
|
||||
"""
|
||||
from superset.sql.execution import SQLExecutor
|
||||
|
||||
return SQLExecutor(self).execute(sql, options)
|
||||
|
||||
def execute_async(
|
||||
self,
|
||||
sql: str,
|
||||
options: QueryOptions | None = None,
|
||||
) -> AsyncQueryHandle:
|
||||
"""
|
||||
Execute SQL asynchronously via Celery.
|
||||
|
||||
:param sql: SQL query to execute
|
||||
:param options: QueryOptions with execution settings
|
||||
:returns: AsyncQueryHandle for tracking the query
|
||||
"""
|
||||
from superset.sql.execution import SQLExecutor
|
||||
|
||||
return SQLExecutor(self).execute_async(sql, options)
|
||||
|
||||
|
||||
sqla.event.listen(Database, "after_insert", security_manager.database_after_insert)
|
||||
sqla.event.listen(Database, "after_update", security_manager.database_after_update)
|
||||
|
||||
20
superset/sql/execution/__init__.py
Normal file
20
superset/sql/execution/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .executor import SQLExecutor
|
||||
|
||||
__all__ = ["SQLExecutor"]
|
||||
486
superset/sql/execution/celery_task.py
Normal file
486
superset/sql/execution/celery_task.py
Normal file
@@ -0,0 +1,486 @@
|
||||
# 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.
|
||||
"""
|
||||
Celery task for async SQL execution.
|
||||
|
||||
This module provides the Celery task for executing SQL queries asynchronously.
|
||||
It is used by SQLExecutor.execute_async() to run queries in the background.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
import msgpack
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from flask import current_app as app, has_app_context
|
||||
from flask_babel import gettext as __
|
||||
|
||||
from superset import (
|
||||
db,
|
||||
results_backend,
|
||||
security_manager,
|
||||
)
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.constants import QUERY_CANCEL_KEY
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
SupersetErrorException,
|
||||
SupersetErrorsException,
|
||||
)
|
||||
from superset.extensions import celery_app
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.result_set import SupersetResultSet
|
||||
from superset.sql.execution.executor import execute_sql_with_cursor
|
||||
from superset.sql.parse import SQLScript
|
||||
from superset.sqllab.utils import write_ipc_buffer
|
||||
from superset.utils import json
|
||||
from superset.utils.core import override_user, zlib_compress
|
||||
from superset.utils.dates import now_as_float
|
||||
from superset.utils.decorators import stats_timing
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BYTES_IN_MB = 1024 * 1024
|
||||
|
||||
|
||||
def _get_query(query_id: int) -> Query:
|
||||
"""Get the query by ID."""
|
||||
return db.session.query(Query).filter_by(id=query_id).one()
|
||||
|
||||
|
||||
def _handle_query_error(
|
||||
ex: Exception,
|
||||
query: Query,
|
||||
payload: dict[str, Any] | None = None,
|
||||
prefix_message: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Handle error while processing the SQL query."""
|
||||
payload = payload or {}
|
||||
msg = f"{prefix_message} {str(ex)}".strip()
|
||||
query.error_message = msg
|
||||
query.tmp_table_name = None
|
||||
# Preserve TIMED_OUT status if already set (from SoftTimeLimitExceeded handler)
|
||||
if query.status != QueryStatus.TIMED_OUT:
|
||||
query.status = QueryStatus.FAILED
|
||||
|
||||
if not query.end_time:
|
||||
query.end_time = now_as_float()
|
||||
|
||||
# Extract DB-specific errors
|
||||
if isinstance(ex, SupersetErrorException):
|
||||
errors = [ex.error]
|
||||
elif isinstance(ex, SupersetErrorsException):
|
||||
errors = ex.errors
|
||||
else:
|
||||
errors = query.database.db_engine_spec.extract_errors(
|
||||
str(ex), database_name=query.database.unique_name
|
||||
)
|
||||
|
||||
errors_payload = [dataclasses.asdict(error) for error in errors]
|
||||
if errors:
|
||||
query.set_extra_json_key("errors", errors_payload)
|
||||
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
payload.update(
|
||||
{"status": query.status.value, "error": msg, "errors": errors_payload}
|
||||
)
|
||||
if troubleshooting_link := app.config.get("TROUBLESHOOTING_LINK"):
|
||||
payload["link"] = troubleshooting_link
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_payload(payload: dict[Any, Any]) -> bytes:
|
||||
"""Serialize payload for storage based on RESULTS_BACKEND_USE_MSGPACK config."""
|
||||
from superset import results_backend_use_msgpack
|
||||
|
||||
if results_backend_use_msgpack:
|
||||
return msgpack.dumps(payload, default=json.json_iso_dttm_ser, use_bin_type=True)
|
||||
return json.dumps(payload, default=json.json_iso_dttm_ser, ignore_nan=True).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _prepare_statement_blocks(
|
||||
rendered_query: str,
|
||||
db_engine_spec: Any,
|
||||
) -> tuple[SQLScript, list[str]]:
|
||||
"""
|
||||
Parse SQL and build statement blocks for execution.
|
||||
|
||||
Some databases (like BigQuery and Kusto) do not persist state across multiple
|
||||
statements if they're run separately (especially when using `NullPool`), so we run
|
||||
the query as a single block when the database engine spec requires it.
|
||||
"""
|
||||
parsed_script = SQLScript(rendered_query, engine=db_engine_spec.engine)
|
||||
|
||||
# Build statement blocks for execution
|
||||
if db_engine_spec.run_multiple_statements_as_one:
|
||||
blocks = [parsed_script.format(comments=db_engine_spec.allows_sql_comments)]
|
||||
else:
|
||||
blocks = [
|
||||
statement.format(comments=db_engine_spec.allows_sql_comments)
|
||||
for statement in parsed_script.statements
|
||||
]
|
||||
|
||||
return parsed_script, blocks
|
||||
|
||||
|
||||
def _finalize_successful_query(
|
||||
query: Query,
|
||||
original_script: SQLScript,
|
||||
execution_results: list[tuple[str, SupersetResultSet | None, float, int]],
|
||||
payload: dict[str, Any],
|
||||
total_execution_time_ms: float,
|
||||
) -> None:
|
||||
"""Update query metadata and payload after successful execution."""
|
||||
# Calculate total rows across all statements
|
||||
total_rows = 0
|
||||
statements_data: list[dict[str, Any]] = []
|
||||
|
||||
# Get original statement strings
|
||||
original_sqls = [stmt.format() for stmt in original_script.statements]
|
||||
|
||||
for orig_sql, (exec_sql, result_set, exec_time, rowcount) in zip(
|
||||
original_sqls, execution_results, strict=True
|
||||
):
|
||||
if result_set is not None:
|
||||
# SELECT statement
|
||||
total_rows += result_set.size
|
||||
data, columns = _serialize_result_set(result_set)
|
||||
statements_data.append(
|
||||
{
|
||||
"original_sql": orig_sql,
|
||||
"executed_sql": exec_sql,
|
||||
"data": data,
|
||||
"columns": columns,
|
||||
"row_count": result_set.size,
|
||||
"execution_time_ms": exec_time,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# DML statement - no data, just row count
|
||||
statements_data.append(
|
||||
{
|
||||
"original_sql": orig_sql,
|
||||
"executed_sql": exec_sql,
|
||||
"data": None,
|
||||
"columns": [],
|
||||
"row_count": rowcount,
|
||||
"execution_time_ms": exec_time,
|
||||
}
|
||||
)
|
||||
|
||||
query.rows = total_rows
|
||||
query.progress = 100
|
||||
query.set_extra_json_key("progress", None)
|
||||
# Store columns from last statement (for compatibility)
|
||||
if execution_results and execution_results[-1][1] is not None:
|
||||
query.set_extra_json_key("columns", execution_results[-1][1].columns)
|
||||
query.end_time = now_as_float()
|
||||
|
||||
payload.update(
|
||||
{
|
||||
"status": QueryStatus.SUCCESS.value,
|
||||
"statements": statements_data,
|
||||
"total_execution_time_ms": total_execution_time_ms,
|
||||
"query": query.to_dict(),
|
||||
}
|
||||
)
|
||||
payload["query"]["state"] = QueryStatus.SUCCESS.value
|
||||
|
||||
|
||||
def _store_results_in_backend(
|
||||
query: Query,
|
||||
payload: dict[str, Any],
|
||||
database: Any,
|
||||
) -> None:
|
||||
"""Store query results in the results backend."""
|
||||
key = str(uuid.uuid4())
|
||||
payload["query"]["resultsKey"] = key
|
||||
logger.info(
|
||||
"Query %s: Storing results in results backend, key: %s",
|
||||
str(query.id),
|
||||
key,
|
||||
)
|
||||
stats_logger = app.config["STATS_LOGGER"]
|
||||
with stats_timing("sqllab.query.results_backend_write", stats_logger):
|
||||
with stats_timing(
|
||||
"sqllab.query.results_backend_write_serialization", stats_logger
|
||||
):
|
||||
serialized_payload = _serialize_payload(payload)
|
||||
|
||||
# Check payload size limit
|
||||
if sql_lab_payload_max_mb := app.config.get("SQLLAB_PAYLOAD_MAX_MB"):
|
||||
serialized_payload_size = len(serialized_payload)
|
||||
max_bytes = sql_lab_payload_max_mb * BYTES_IN_MB
|
||||
|
||||
if serialized_payload_size > max_bytes:
|
||||
logger.info("Result size exceeds the allowed limit.")
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=(
|
||||
f"Result size "
|
||||
f"({serialized_payload_size / BYTES_IN_MB:.2f} MB) "
|
||||
f"exceeds the allowed limit of "
|
||||
f"{sql_lab_payload_max_mb} MB."
|
||||
),
|
||||
error_type=SupersetErrorType.RESULT_TOO_LARGE_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
|
||||
cache_timeout = database.cache_timeout
|
||||
if cache_timeout is None:
|
||||
cache_timeout = app.config["CACHE_DEFAULT_TIMEOUT"]
|
||||
|
||||
compressed = zlib_compress(serialized_payload)
|
||||
logger.debug("*** serialized payload size: %i", len(serialized_payload))
|
||||
logger.debug("*** compressed payload size: %i", len(compressed))
|
||||
|
||||
write_success = results_backend.set(key, compressed, cache_timeout)
|
||||
if not write_success:
|
||||
logger.error(
|
||||
"Query %s: Failed to store results in backend, key: %s",
|
||||
str(query.id),
|
||||
key,
|
||||
)
|
||||
stats_logger.incr("sqllab.results_backend.write_failure")
|
||||
query.results_key = None
|
||||
query.status = QueryStatus.FAILED
|
||||
query.error_message = (
|
||||
"Failed to store query results in the results backend. "
|
||||
"Please try again or contact your administrator."
|
||||
)
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=__("Failed to store query results. Please try again."),
|
||||
error_type=SupersetErrorType.RESULTS_BACKEND_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
)
|
||||
else:
|
||||
query.results_key = key
|
||||
logger.info(
|
||||
"Query %s: Successfully stored results in backend, key: %s",
|
||||
str(query.id),
|
||||
key,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_result_set(
|
||||
result_set: SupersetResultSet,
|
||||
) -> tuple[bytes | list[Any], list[Any]]:
|
||||
"""
|
||||
Serialize result set based on RESULTS_BACKEND_USE_MSGPACK config.
|
||||
|
||||
When msgpack is enabled, uses Apache Arrow IPC format for efficiency.
|
||||
Otherwise, falls back to JSON-serializable records.
|
||||
|
||||
:param result_set: Query result set to serialize
|
||||
:returns: Tuple of (serialized_data, columns)
|
||||
"""
|
||||
from superset import results_backend_use_msgpack
|
||||
from superset.dataframe import df_to_records
|
||||
|
||||
if results_backend_use_msgpack:
|
||||
if has_app_context():
|
||||
stats_logger = app.config["STATS_LOGGER"]
|
||||
with stats_timing(
|
||||
"sqllab.query.results_backend_pa_serialization", stats_logger
|
||||
):
|
||||
data: bytes | list[Any] = write_ipc_buffer(
|
||||
result_set.pa_table
|
||||
).to_pybytes()
|
||||
else:
|
||||
data = write_ipc_buffer(result_set.pa_table).to_pybytes()
|
||||
else:
|
||||
df = result_set.to_pandas_df()
|
||||
data = df_to_records(df) or []
|
||||
|
||||
return (data, result_set.columns)
|
||||
|
||||
|
||||
@celery_app.task(name="query_execution.execute_sql")
|
||||
def execute_sql_task(
|
||||
query_id: int,
|
||||
rendered_query: str,
|
||||
username: str | None = None,
|
||||
start_time: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Execute SQL query asynchronously via Celery.
|
||||
|
||||
This task is used by SQLExecutor.execute_async() to run queries
|
||||
in background workers with full feature support.
|
||||
|
||||
:param query_id: ID of the Query model
|
||||
:param rendered_query: Pre-rendered SQL query to execute
|
||||
:param username: Username for context override
|
||||
:param start_time: Query start time for timing metrics
|
||||
:returns: Query result payload or None
|
||||
"""
|
||||
with app.test_request_context():
|
||||
with override_user(security_manager.find_user(username)):
|
||||
try:
|
||||
return _execute_sql_statements(
|
||||
query_id,
|
||||
rendered_query,
|
||||
start_time=start_time,
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.debug("Query %d: %s", query_id, ex)
|
||||
stats_logger = app.config["STATS_LOGGER"]
|
||||
stats_logger.incr("error_sqllab_unhandled")
|
||||
query = _get_query(query_id=query_id)
|
||||
return _handle_query_error(ex, query)
|
||||
|
||||
|
||||
def _make_check_stopped_fn(query: Query) -> Any:
|
||||
"""Create a function to check if query was stopped."""
|
||||
|
||||
def check_stopped() -> bool:
|
||||
db.session.refresh(query)
|
||||
return query.status == QueryStatus.STOPPED
|
||||
|
||||
return check_stopped
|
||||
|
||||
|
||||
def _make_execute_fn(query: Query, db_engine_spec: Any) -> Any:
|
||||
"""Create an execute function with stats timing."""
|
||||
|
||||
def execute_with_stats(cursor: Any, sql: str) -> None:
|
||||
query.executed_sql = sql
|
||||
stats_logger = app.config["STATS_LOGGER"]
|
||||
with stats_timing("sqllab.query.time_executing_query", stats_logger):
|
||||
db_engine_spec.execute_with_cursor(cursor, sql, query)
|
||||
|
||||
return execute_with_stats
|
||||
|
||||
|
||||
def _make_log_query_fn(database: Any) -> Any:
|
||||
"""Create a query logging function."""
|
||||
|
||||
def log_query(sql: str, schema: str | None) -> None:
|
||||
if log_query_fn := app.config.get("QUERY_LOGGER"):
|
||||
log_query_fn(
|
||||
database.sqlalchemy_uri,
|
||||
sql,
|
||||
schema,
|
||||
__name__,
|
||||
security_manager,
|
||||
None,
|
||||
)
|
||||
|
||||
return log_query
|
||||
|
||||
|
||||
def _execute_sql_statements(
|
||||
query_id: int,
|
||||
rendered_query: str,
|
||||
start_time: float | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Execute SQL statements and store results."""
|
||||
if start_time:
|
||||
stats_logger = app.config["STATS_LOGGER"]
|
||||
stats_logger.timing("sqllab.query.time_pending", now_as_float() - start_time)
|
||||
|
||||
query = _get_query(query_id=query_id)
|
||||
payload: dict[str, Any] = {"query_id": query_id}
|
||||
database = query.database
|
||||
db_engine_spec = database.db_engine_spec
|
||||
db_engine_spec.patch()
|
||||
|
||||
logger.info("Query %s: Set query to 'running'", str(query_id))
|
||||
query.status = QueryStatus.RUNNING
|
||||
query.start_running_time = now_as_float()
|
||||
execution_start_time = now_as_float()
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
# Parse original SQL (from user) to preserve before transformations
|
||||
original_script = SQLScript(query.sql, engine=db_engine_spec.engine)
|
||||
|
||||
# Parse transformed SQL (with RLS, limits, etc.)
|
||||
parsed_script, blocks = _prepare_statement_blocks(rendered_query, db_engine_spec)
|
||||
|
||||
with database.get_raw_connection(
|
||||
catalog=query.catalog,
|
||||
schema=query.schema,
|
||||
) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cancel_query_id = db_engine_spec.get_cancel_query_id(cursor, query)
|
||||
if cancel_query_id is not None:
|
||||
query.set_extra_json_key(QUERY_CANCEL_KEY, cancel_query_id)
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
try:
|
||||
execution_results = execute_sql_with_cursor(
|
||||
database=database,
|
||||
cursor=cursor,
|
||||
statements=blocks,
|
||||
query=query,
|
||||
log_query_fn=_make_log_query_fn(database),
|
||||
check_stopped_fn=_make_check_stopped_fn(query),
|
||||
execute_fn=_make_execute_fn(query, db_engine_spec),
|
||||
)
|
||||
except SoftTimeLimitExceeded as ex:
|
||||
query.status = QueryStatus.TIMED_OUT
|
||||
logger.warning("Query %d: Time limit exceeded", query.id)
|
||||
timeout_sec = app.config["SQLLAB_ASYNC_TIME_LIMIT_SEC"]
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=__(
|
||||
"The query was killed after %(sqllab_timeout)s seconds. "
|
||||
"It might be too complex, or the database might be "
|
||||
"under heavy load.",
|
||||
sqllab_timeout=timeout_sec,
|
||||
),
|
||||
error_type=SupersetErrorType.SQLLAB_TIMEOUT_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
) from ex
|
||||
|
||||
# Check if stopped
|
||||
if not execution_results:
|
||||
payload.update({"status": QueryStatus.STOPPED.value})
|
||||
return payload
|
||||
|
||||
# Commit for mutations
|
||||
if parsed_script.has_mutation() or query.select_as_cta:
|
||||
conn.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
total_execution_time_ms = (now_as_float() - execution_start_time) * 1000
|
||||
_finalize_successful_query(
|
||||
query, original_script, execution_results, payload, total_execution_time_ms
|
||||
)
|
||||
|
||||
if results_backend:
|
||||
_store_results_in_backend(query, payload, database)
|
||||
|
||||
if query.status != QueryStatus.FAILED:
|
||||
query.status = QueryStatus.SUCCESS
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
return payload
|
||||
1108
superset/sql/execution/executor.py
Normal file
1108
superset/sql/execution/executor.py
Normal file
File diff suppressed because it is too large
Load Diff
27
superset/static/service-worker.js
Normal file
27
superset/static/service-worker.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Minimal service worker for PWA file handling support
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
@@ -28,7 +28,9 @@
|
||||
{% endblock %}
|
||||
</title>
|
||||
|
||||
{% block head_meta %}{% endblock %}
|
||||
{% block head_meta %}
|
||||
<link rel="manifest" href="{{ assets_prefix }}/static/assets/pwa-manifest.json?v=4">
|
||||
{% endblock %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
@@ -73,6 +75,17 @@
|
||||
/>
|
||||
{% block head_js %}
|
||||
{% include "head_custom_extra.html" %}
|
||||
<script nonce="{{ macros.get_nonce() }}">
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker
|
||||
.register('{{ assets_prefix }}/static/service-worker.js')
|
||||
.catch(function(err) {
|
||||
console.error('Service Worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
|
||||
@@ -908,6 +908,21 @@ class Superset(BaseSupersetView):
|
||||
|
||||
return self.render_app_template(extra_bootstrap_data=payload)
|
||||
|
||||
@has_access
|
||||
@event_logger.log_this
|
||||
@expose("/file-handler")
|
||||
def file_handler(self) -> FlaskResponse:
|
||||
"""File handler page for PWA file handling"""
|
||||
if not g.user or not get_user_id():
|
||||
return redirect_to_login()
|
||||
|
||||
payload = {
|
||||
"user": bootstrap_user_data(g.user, include_perms=True),
|
||||
"common": common_bootstrap_payload(),
|
||||
}
|
||||
|
||||
return self.render_app_template(extra_bootstrap_data=payload)
|
||||
|
||||
@has_access
|
||||
@event_logger.log_this
|
||||
@expose("/sqllab/history/", methods=("GET",))
|
||||
|
||||
@@ -171,3 +171,44 @@ def test_migrate_pivot_table() -> None:
|
||||
"version": "1.0.0",
|
||||
"dataset_uuid": "a18b9cb0-b8d3-42ed-bd33-0f0fadbf0f6d",
|
||||
}
|
||||
|
||||
|
||||
def test_migrate_chart_query_context_form_data_is_dict() -> None:
|
||||
"""
|
||||
Test that form_data in query_context remains a dict after migration.
|
||||
"""
|
||||
chart_config = {
|
||||
"slice_name": "Pivot Table with Query Context",
|
||||
"description": None,
|
||||
"certified_by": None,
|
||||
"certification_details": None,
|
||||
"viz_type": "pivot_table",
|
||||
"params": json.dumps(
|
||||
{
|
||||
"columns": ["state"],
|
||||
"groupby": ["name"],
|
||||
"metrics": ["count"],
|
||||
"viz_type": "pivot_table",
|
||||
}
|
||||
),
|
||||
"query_context": json.dumps(
|
||||
{
|
||||
"form_data": {
|
||||
"slice_id": 123,
|
||||
"viz_type": "pivot_table",
|
||||
"columns": ["state"],
|
||||
},
|
||||
"queries": [{"columns": ["state"]}],
|
||||
}
|
||||
),
|
||||
"cache_timeout": None,
|
||||
"uuid": "a18b9cb0-b8d3-42ed-bd33-0f0fadbf0f6d",
|
||||
"version": "1.0.0",
|
||||
"dataset_uuid": "ffd15af2-2188-425c-b6b4-df28aac45872",
|
||||
}
|
||||
|
||||
new_config = migrate_chart(chart_config)
|
||||
query_context = json.loads(new_config["query_context"])
|
||||
|
||||
assert query_context["form_data"]["viz_type"] == "pivot_table_v2"
|
||||
assert isinstance(query_context["form_data"], dict)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user