mirror of
https://github.com/apache/superset.git
synced 2026-04-29 04:54:21 +00:00
Compare commits
43 Commits
fix/postgr
...
file-handl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3344011393 | ||
|
|
ba8494360c | ||
|
|
c8bc0c4d24 | ||
|
|
14db9e0e8c | ||
|
|
3c06a56f83 | ||
|
|
80f6f93ea2 | ||
|
|
442288e149 | ||
|
|
2a3b8285cb | ||
|
|
38fe9bcf42 | ||
|
|
f342ddcd98 | ||
|
|
cc4777d7ab | ||
|
|
777298e13f | ||
|
|
6cec3fc1f8 | ||
|
|
4b4efe1b1f | ||
|
|
c62a9a58d4 | ||
|
|
40c4a740ff | ||
|
|
ecc974d2a6 | ||
|
|
e506a47083 | ||
|
|
6dcf0409f2 | ||
|
|
a284eb2532 | ||
|
|
130a34353f | ||
|
|
4b2fb54fb0 | ||
|
|
82b0fa6d07 | ||
|
|
d97a2a0425 | ||
|
|
40787496e2 | ||
|
|
b180cc4fca | ||
|
|
78e4f55ede | ||
|
|
853d57337d | ||
|
|
3ad694e5a9 | ||
|
|
b66729ad08 | ||
|
|
c5d2329297 | ||
|
|
ca7635dfc2 | ||
|
|
0aaa13ab79 | ||
|
|
902509b1f0 | ||
|
|
2e7df4614c | ||
|
|
3554325104 | ||
|
|
b469b01e0f | ||
|
|
4d9378a818 | ||
|
|
0141bdd2b0 | ||
|
|
808ba668ff | ||
|
|
e9fc7c6f6c | ||
|
|
5c61c40704 | ||
|
|
57a210f7d6 |
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"
|
||||
|
||||
18
.github/workflows/showtime-cleanup.yml
vendored
18
.github/workflows/showtime-cleanup.yml
vendored
@@ -7,12 +7,6 @@ on:
|
||||
|
||||
# Manual trigger for testing
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_age_hours:
|
||||
description: 'Maximum age in hours before cleanup'
|
||||
required: false
|
||||
default: '48'
|
||||
type: string
|
||||
|
||||
# Common environment variables
|
||||
env:
|
||||
@@ -38,13 +32,5 @@ jobs:
|
||||
|
||||
- name: Cleanup expired environments
|
||||
run: |
|
||||
MAX_AGE="${{ github.event.inputs.max_age_hours || '48' }}"
|
||||
|
||||
# Validate max_age is numeric
|
||||
if [[ ! "$MAX_AGE" =~ ^[0-9]+$ ]]; then
|
||||
echo "❌ Invalid max_age_hours format: $MAX_AGE (must be numeric)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cleaning up environments older than ${MAX_AGE}h"
|
||||
python -m showtime cleanup --older-than "${MAX_AGE}h"
|
||||
echo "Cleaning up environments respecting TTL labels"
|
||||
python -m showtime cleanup --respect-ttl
|
||||
|
||||
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"
|
||||
|
||||
@@ -105,7 +105,7 @@ class CeleryConfig:
|
||||
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True}
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATA_ACCESS_RULES": True}
|
||||
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
|
||||
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
|
||||
# The base URL for the email report hyperlinks.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Backend Style Guidelines
|
||||
sidebar_position: 3
|
||||
title: Overview
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -26,22 +26,22 @@ under the License.
|
||||
|
||||
This is a list of statements that describe how we do backend development in Superset. While they might not be 100% true for all files in the repo, they represent the gold standard we strive towards for backend quality and style.
|
||||
|
||||
* We use a monolithic Python/Flask/Flask-AppBuilder backend, with small single-responsibility satellite services where necessary.
|
||||
* Files are generally organized by feature or object type. Within each domain, we can have api controllers, models, schemas, commands, and data access objects (dao).
|
||||
* See: [Proposal for Improving Superset's Python Code Organization](https://github.com/apache/superset/issues/9077)
|
||||
* API controllers use Marshmallow Schemas to serialize/deserialize data.
|
||||
* Authentication and authorization are controlled by the [security manager](https://github.com/apache/superset/blob/master/superset/security/manager).
|
||||
* We use Pytest for unit and integration tests. These live in the `tests` directory.
|
||||
* We add tests for every new piece of functionality added to the backend.
|
||||
* We use pytest fixtures to share setup between tests.
|
||||
* We use sqlalchemy to access both Superset's application database, and users' analytics databases.
|
||||
* We make changes backwards compatible whenever possible.
|
||||
* If a change cannot be made backwards compatible, it goes into a major release.
|
||||
* See: [Proposal For Semantic Versioning](https://github.com/apache/superset/issues/12566)
|
||||
* We use Swagger for API documentation, with docs written inline on the API endpoint code.
|
||||
* We prefer thin ORM models, putting shared functionality in other utilities.
|
||||
* Several linters/checkers are used to maintain consistent code style and type safety: pylint, pypy, black, isort.
|
||||
* `__init__.py` files are kept empty to avoid implicit dependencies.
|
||||
- We use a monolithic Python/Flask/Flask-AppBuilder backend, with small single-responsibility satellite services where necessary.
|
||||
- Files are generally organized by feature or object type. Within each domain, we can have api controllers, models, schemas, commands, and data access objects (DAO).
|
||||
- See: [Proposal for Improving Superset's Python Code Organization](https://github.com/apache/superset/issues/9077)
|
||||
- API controllers use Marshmallow Schemas to serialize/deserialize data.
|
||||
- Authentication and authorization are controlled by the [security manager](https://github.com/apache/superset/blob/master/superset/security/manager).
|
||||
- We use Pytest for unit and integration tests. These live in the `tests` directory.
|
||||
- We add tests for every new piece of functionality added to the backend.
|
||||
- We use pytest fixtures to share setup between tests.
|
||||
- We use SQLAlchemy to access both Superset's application database, and users' analytics databases.
|
||||
- We make changes backwards compatible whenever possible.
|
||||
- If a change cannot be made backwards compatible, it goes into a major release.
|
||||
- See: [Proposal For Semantic Versioning](https://github.com/apache/superset/issues/12566)
|
||||
- We use Swagger for API documentation, with docs written inline on the API endpoint code.
|
||||
- We prefer thin ORM models, putting shared functionality in other utilities.
|
||||
- Several linters/checkers are used to maintain consistent code style and type safety: pylint, mypy, black, isort.
|
||||
- `__init__.py` files are kept empty to avoid implicit dependencies.
|
||||
|
||||
## Code Organization
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: DAO Style Guidelines and Best Practices
|
||||
sidebar_position: 1
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -26,19 +26,29 @@ under the License.
|
||||
|
||||
A Data Access Object (DAO) is a pattern that provides an abstract interface to the SQLAlchemy Object Relational Mapper (ORM). The DAOs are critical as they form the building block of the application which are wrapped by the associated commands and RESTful API endpoints.
|
||||
|
||||
Currently there are numerous inconsistencies and violation of the DRY principal within the codebase as it relates to DAOs and ORMs—unnecessary commits, non-ACID transactions, etc.—which makes the code unnecessarily complex and convoluted. Addressing the underlying issues with the DAOs _should_ help simplify the downstream operations and improve the developer experience.
|
||||
There are numerous inconsistencies and violations of the DRY principal within the codebase as it relates to DAOs and ORMs—unnecessary commits, non-ACID transactions, etc.—which makes the code unnecessarily complex and convoluted. Addressing the underlying issues with the DAOs _should_ help simplify the downstream operations and improve the developer experience.
|
||||
|
||||
To ensure consistency the following rules should be adhered to:
|
||||
|
||||
1. All database operations (including testing) should be defined within a DAO, i.e., there should not be any explicit `db.session.add`, `db.session.merge`, etc. calls outside of a DAO.
|
||||
## Core Rules
|
||||
|
||||
2. A DAO should use `create`, `update`, `delete`, `upsert` terms—typical database operations which ensure consistency with commands—rather than action based terms like `save`, `saveas`, `override`, etc.
|
||||
1. **All database operations (including testing) should be defined within a DAO**, i.e., there should not be any explicit `db.session.add`, `db.session.merge`, etc. calls outside of a DAO.
|
||||
|
||||
3. Sessions should be managed via a [context manager](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#begin-once) which auto-commits on success and rolls back on failure, i.e., there should be no explicit `db.session.commit` or `db.session.rollback` calls within the DAO.
|
||||
2. **A DAO should use `create`, `update`, `delete`, `upsert` terms**—typical database operations which ensure consistency with commands—rather than action based terms like `save`, `saveas`, `override`, etc.
|
||||
|
||||
4. There should be a single atomic transaction representing the entirety of the operation, i.e., when creating a dataset with associated columns and metrics either all the changes succeed when the transaction is committed, or all the changes are undone when the transaction is rolled back. SQLAlchemy supports [nested transactions](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#nested-transaction) via the `begin_nested` method which can be nested—inline with how DAOs are invoked.
|
||||
3. **Sessions should be managed via a [context manager](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#begin-once)** which auto-commits on success and rolls back on failure, i.e., there should be no explicit `db.session.commit` or `db.session.rollback` calls within the DAO.
|
||||
|
||||
5. The database layer should adopt a "shift left" mentality i.e., uniqueness/foreign key constraints, relationships, cascades, etc. should all be defined in the database layer rather than being enforced in the application layer.
|
||||
4. **There should be a single atomic transaction representing the entirety of the operation**, i.e., when creating a dataset with associated columns and metrics either all the changes succeed when the transaction is committed, or all the changes are undone when the transaction is rolled back. SQLAlchemy supports [nested transactions](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#nested-transaction) via the `begin_nested` method which can be nested—inline with how DAOs are invoked.
|
||||
|
||||
5. **The database layer should adopt a "shift left" mentality** i.e., uniqueness/foreign key constraints, relationships, cascades, etc. should all be defined in the database layer rather than being enforced in the application layer.
|
||||
|
||||
6. **Exception-based validation**: Ask for forgiveness rather than permission. Try to perform the operation and rely on database constraints to verify that the model is acceptable, rather than pre-validating conditions.
|
||||
|
||||
7. **Bulk operations**: Provide bulk `create`, `update`, and `delete` methods where applicable for performance optimization.
|
||||
|
||||
8. **Sparse updates**: Updates should only modify explicitly defined attributes.
|
||||
|
||||
9. **Test transactions**: Tests should leverage nested transactions which should be rolled back on teardown, rather than deleting objects.
|
||||
|
||||
## DAO Implementation Examples
|
||||
|
||||
|
||||
@@ -30,21 +30,23 @@ This is an area to host resources and documentation supporting the evolution and
|
||||
|
||||
### Sentence case
|
||||
|
||||
Use sentence-case capitalization for everything in the UI (except these **).
|
||||
Use sentence-case capitalization for everything in the UI (except these exceptions below).
|
||||
|
||||
Sentence case is predominantly lowercase. Capitalize only the initial character of the first word, and other words that require capitalization, like:
|
||||
|
||||
* **Proper nouns.** Objects in the product _are not_ considered proper nouns e.g. dashboards, charts, saved queries etc. Proprietary feature names eg. SQL Lab, Preset Manager _are_ considered proper nouns
|
||||
* **Acronyms** (e.g. CSS, HTML)
|
||||
* When referring to **UI labels that are themselves capitalized** from sentence case (e.g. page titles - Dashboards page, Charts page, Saved queries page, etc.)
|
||||
* User input that is reflected in the UI. E.g. a user-named a dashboard tab
|
||||
- **Proper nouns.** Objects in the product _are not_ considered proper nouns e.g. dashboards, charts, saved queries etc. Proprietary feature names eg. SQL Lab, Preset Manager _are_ considered proper nouns
|
||||
- **Acronyms** (e.g. CSS, HTML)
|
||||
- When referring to **UI labels that are themselves capitalized** from sentence case (e.g. page titles - Dashboards page, Charts page, Saved queries page, etc.)
|
||||
- User input that is reflected in the UI. E.g. a user-named a dashboard tab
|
||||
|
||||
**Sentence case vs. Title case:** Title case: "A Dog Takes a Walk in Paris" Sentence case: "A dog takes a walk in Paris"
|
||||
**Sentence case vs. Title case:**
|
||||
- Title case: "A Dog Takes a Walk in Paris"
|
||||
- Sentence case: "A dog takes a walk in Paris"
|
||||
|
||||
**Why sentence case?**
|
||||
|
||||
* It's generally accepted as the quickest to read
|
||||
* It's the easiest form to distinguish between common and proper nouns
|
||||
- It's generally accepted as the quickest to read
|
||||
- It's the easiest form to distinguish between common and proper nouns
|
||||
|
||||
### How to refer to UI elements
|
||||
|
||||
@@ -52,21 +54,38 @@ When writing about a UI element, use the same capitalization as used in the UI.
|
||||
|
||||
For example, if an input field is labeled "Name" then you refer to this as the "Name input field". Similarly, if a button has the label "Save" in it, then it is correct to refer to the "Save button".
|
||||
|
||||
Where a product page is titled "Settings", you refer to this in writing as follows: "Edit your personal information on the Settings page".
|
||||
Where a product page is titled "Settings", you refer to this in writing as follows:
|
||||
"Edit your personal information on the Settings page".
|
||||
|
||||
Often a product page will have the same title as the objects it contains. In this case, refer to the page as it appears in the UI, and the objects as common nouns:
|
||||
|
||||
* Upload a dashboard on the Dashboards page
|
||||
* Go to Dashboards
|
||||
* View dashboard
|
||||
* View all dashboards
|
||||
* Upload CSS templates on the CSS templates page
|
||||
* Queries that you save will appear on the Saved queries page
|
||||
* Create custom queries in SQL Lab then create dashboards
|
||||
- Upload a dashboard on the Dashboards page
|
||||
- Go to Dashboards
|
||||
- View dashboard
|
||||
- View all dashboards
|
||||
- Upload CSS templates on the CSS templates page
|
||||
- Queries that you save will appear on the Saved queries page
|
||||
- Create custom queries in SQL Lab then create dashboards
|
||||
|
||||
### **Exceptions to sentence case:**
|
||||
### Exceptions to sentence case
|
||||
|
||||
1. Acronyms and abbreviations. Examples: URL, CSV, XML
|
||||
1. **Acronyms and abbreviations.**
|
||||
Examples: URL, CSV, XML, CSS, SQL, SSH, URI, NaN, CRON, CC, BCC
|
||||
|
||||
2. **Proper nouns and brand names.**
|
||||
Examples: Apache, Superset, AntD JavaScript, GeoJSON, Slack, Google Sheets, SQLAlchemy
|
||||
|
||||
3. **Technical terms derived from proper nouns.**
|
||||
Examples: Jinja, Gaussian, European (as in European time zone)
|
||||
|
||||
4. **Key names.** Capitalize button labels and UI elements as they appear in the product UI.
|
||||
Examples: Shift (as in the keyboard button), Enter key
|
||||
|
||||
5. **Named queries or specific labeled items.**
|
||||
Examples: Query A, Query B
|
||||
|
||||
6. **Database names.** Always capitalize names of database engines and connectors.
|
||||
Examples: Presto, Trino, Drill, Hive, Google Sheets
|
||||
|
||||
## Button Design Guidelines
|
||||
|
||||
@@ -98,6 +117,32 @@ Primary buttons have a fourth style: dropdown.
|
||||
| Tertiary | For less prominent actions; can be used in isolation or paired with a primary button |
|
||||
| Destructive | For actions that could have destructive effects on the user's data |
|
||||
|
||||
### Format
|
||||
|
||||
#### Anatomy
|
||||
|
||||
Button text is centered using the Label style. Icons appear left of text when combined. If no text label exists, an icon must indicate the button's function.
|
||||
|
||||
#### Button size
|
||||
|
||||
- Default dimensions: 160px width × 32px height
|
||||
- Text: 11px, Inter Medium, all caps
|
||||
- Corners: 4px border radius
|
||||
- Minimum padding: 8px around text
|
||||
- Width can decrease if space is limited, but maintain minimum padding
|
||||
|
||||
#### Button groups
|
||||
|
||||
- Group related buttons to establish visual hierarchy
|
||||
- Avoid overwhelming users with too many actions
|
||||
- Limit calls to action; use tertiary/ghost buttons for layouts with 3+ actions
|
||||
- Maintain consistent styles within groups when possible
|
||||
- Space buttons 8px apart vertically or horizontally
|
||||
|
||||
#### Content guidelines
|
||||
|
||||
Button labels should be clear and predictable. Use the "\{verb\} + \{noun\}" format, except for common actions like "Done," "Close," "Cancel," "Add," or "Delete." This formula provides necessary context and aids translation, though compact UIs or localization needs may warrant exceptions.
|
||||
|
||||
## Error Message Design Guidelines
|
||||
|
||||
### Definition
|
||||
@@ -128,10 +173,10 @@ In all cases, encountering errors increases user friction and frustration while
|
||||
|
||||
Select one pattern per error (e.g. do not implement an inline and banner pattern for the same error).
|
||||
|
||||
When the error... | Use...
|
||||
---------------- | ------
|
||||
Is directly related to a UI control | Inline error
|
||||
Is not directly related to a UI control | Banner error
|
||||
| When the error... | Use... |
|
||||
|------------------|--------|
|
||||
| Is directly related to a UI control | Inline error |
|
||||
| Is not directly related to a UI control | Banner error |
|
||||
|
||||
#### Inline
|
||||
|
||||
@@ -146,3 +191,45 @@ Use the `LabeledErrorBoundInput` component for this error pattern.
|
||||
##### Implementation details
|
||||
|
||||
- Where and when relevant, scroll the screen to the UI control with the error
|
||||
- When multiple inline errors are present, scroll to the topmost error
|
||||
|
||||
#### Banner
|
||||
|
||||
Banner errors are used when the source of the error is not directly related to a UI control (text input, selector, etc.) such as a technical failure or a loading problem.
|
||||
|
||||
##### Anatomy
|
||||
|
||||
Use the `ErrorAlert` component for this error pattern.
|
||||
|
||||
1. **Headline** (optional): 1-2 word summary of the error
|
||||
2. **Message**: What went wrong and what users should do next
|
||||
3. **Expand option** (optional): "See more"/"See less"
|
||||
4. **Details** (optional): Additional helpful context
|
||||
5. **Modal** (optional): For spatial constraints using `ToastType.DANGER`
|
||||
|
||||
##### Implementation details
|
||||
|
||||
- Place the banner near the content area most relevant to the error
|
||||
- For chart errors in Explore, use the chart area
|
||||
- For modal errors, use the modal footer
|
||||
- For app-wide errors, use the top of the screen
|
||||
|
||||
### Content guidelines
|
||||
|
||||
Effective error messages communicate:
|
||||
|
||||
1. What went wrong
|
||||
2. What users should do next
|
||||
|
||||
Error messages should be:
|
||||
|
||||
- Clear and accurate, leaving no room for misinterpretation
|
||||
- Short and concise
|
||||
- Understandable to non-technical users
|
||||
- Non-blaming and avoiding negative language
|
||||
|
||||
**Example:**
|
||||
|
||||
❌ "Cannot delete a datasource that has slices attached to it."
|
||||
|
||||
✅ "Please delete all charts using this dataset before deleting the dataset."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Frontend Style Guidelines
|
||||
sidebar_position: 2
|
||||
title: Overview
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -26,19 +26,25 @@ under the License.
|
||||
|
||||
This is a list of statements that describe how we do frontend development in Superset. While they might not be 100% true for all files in the repo, they represent the gold standard we strive towards for frontend quality and style.
|
||||
|
||||
* We develop using TypeScript.
|
||||
* We use React for building components, and Redux to manage app/global state.
|
||||
* See: [Component Style Guidelines and Best Practices](./frontend/component-style-guidelines)
|
||||
* We prefer functional components to class components and use hooks for local component state.
|
||||
* We use [Ant Design](https://ant.design/) components from our component library whenever possible, only building our own custom components when it's required.
|
||||
* We use [@emotion](https://emotion.sh/docs/introduction) to provide styling for our components, co-locating styling within component files.
|
||||
* See: [Emotion Styling Guidelines and Best Practices](./frontend/emotion-styling-guidelines)
|
||||
* We use Jest for unit tests, React Testing Library for component tests, and Cypress for end-to-end tests.
|
||||
* See: [Testing Guidelines and Best Practices](./frontend/testing-guidelines)
|
||||
* We add tests for every new component or file added to the frontend.
|
||||
* We organize our repo so similar files live near each other, and tests are co-located with the files they test.
|
||||
* We prefer small, easily testable files and components.
|
||||
* We use ESLint and Prettier to automatically fix lint errors and format the code.
|
||||
* We do not debate code formatting style in PRs, instead relying on automated tooling to enforce it.
|
||||
* If there's not a linting rule, we don't have a rule!
|
||||
* We use [React Storybook](https://storybook.js.org/) and [Applitools](https://applitools.com/) to help preview/test and stabilize our components
|
||||
- We develop using TypeScript.
|
||||
- See: [SIP-36](https://github.com/apache/superset/issues/9101)
|
||||
- We use React for building components, and Redux to manage app/global state.
|
||||
- See: [Component Style Guidelines and Best Practices](./frontend/component-style-guidelines)
|
||||
- We prefer functional components to class components and use hooks for local component state.
|
||||
- We use [Ant Design](https://ant.design/) components from our component library whenever possible, only building our own custom components when it's required.
|
||||
- See: [SIP-48](https://github.com/apache/superset/issues/11283)
|
||||
- We use [@emotion](https://emotion.sh/docs/introduction) to provide styling for our components, co-locating styling within component files.
|
||||
- See: [SIP-37](https://github.com/apache/superset/issues/9145)
|
||||
- See: [Emotion Styling Guidelines and Best Practices](./frontend/emotion-styling-guidelines)
|
||||
- We use Jest for unit tests, React Testing Library for component tests, and Cypress for end-to-end tests.
|
||||
- See: [SIP-56](https://github.com/apache/superset/issues/11830)
|
||||
- See: [Testing Guidelines and Best Practices](../testing/testing-guidelines)
|
||||
- We add tests for every new component or file added to the frontend.
|
||||
- We organize our repo so similar files live near each other, and tests are co-located with the files they test.
|
||||
- See: [SIP-61](https://github.com/apache/superset/issues/12098)
|
||||
- We prefer small, easily testable files and components.
|
||||
- We use ESLint and Prettier to automatically fix lint errors and format the code.
|
||||
- We do not debate code formatting style in PRs, instead relying on automated tooling to enforce it.
|
||||
- If there's not a linting rule, we don't have a rule!
|
||||
- We use [React Storybook](https://storybook.js.org/) and [Applitools](https://applitools.com/) to help preview/test and stabilize our components
|
||||
- A public Storybook with components from the `master` branch is available [here](https://apache-superset.github.io/superset-ui/?path=/story/*)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Component Style Guidelines and Best Practices
|
||||
sidebar_position: 1
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -35,7 +35,7 @@ This guide is intended primarily for reusable components. Whenever possible, all
|
||||
- All components should be made to be reusable whenever possible
|
||||
- All components should follow the structure and best practices as detailed below
|
||||
|
||||
## Directory and component structure
|
||||
### Directory and component structure
|
||||
|
||||
```
|
||||
superset-frontend/src/components
|
||||
@@ -51,208 +51,142 @@ superset-frontend/src/components
|
||||
|
||||
**Component directory name:** Use `PascalCase` for the component directory name
|
||||
|
||||
**Storybook:** Components should come with a storybook file whenever applicable, with the following naming convention `{ComponentName}.stories.tsx`. More details about Storybook below
|
||||
**Storybook:** Components should come with a storybook file whenever applicable, with the following naming convention `\{ComponentName\}.stories.tsx`. More details about Storybook below
|
||||
|
||||
**Unit and end-to-end tests:** All components should come with unit tests using Jest and React Testing Library. The file name should follow this naming convention `{ComponentName}.test.tsx.` Read the [Testing Guidelines and Best Practices](./testing-guidelines) for more details about tests
|
||||
**Unit and end-to-end tests:** All components should come with unit tests using Jest and React Testing Library. The file name should follow this naming convention `\{ComponentName\}.test.tsx`. Read the [Testing Guidelines and Best Practices](../../testing/testing-guidelines) for more details
|
||||
|
||||
## Component Development Best Practices
|
||||
**Reference naming:** Use `PascalCase` for React components and `camelCase` for component instances
|
||||
|
||||
### Use TypeScript
|
||||
|
||||
All new components should be written in TypeScript. This helps catch errors early and provides better development experience with IDE support.
|
||||
|
||||
```tsx
|
||||
interface ComponentProps {
|
||||
title: string;
|
||||
isVisible?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const MyComponent: React.FC<ComponentProps> = ({
|
||||
title,
|
||||
isVisible = true,
|
||||
onClose
|
||||
}) => {
|
||||
// Component implementation
|
||||
};
|
||||
**BAD:**
|
||||
```jsx
|
||||
import mainNav from './MainNav';
|
||||
```
|
||||
|
||||
### Prefer Functional Components
|
||||
**GOOD:**
|
||||
```jsx
|
||||
import MainNav from './MainNav';
|
||||
```
|
||||
|
||||
Use functional components with hooks instead of class components:
|
||||
**BAD:**
|
||||
```jsx
|
||||
const NavItem = <MainNav />;
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```jsx
|
||||
const navItem = <MainNav />;
|
||||
```
|
||||
|
||||
**Component naming:** Use the file name as the component name
|
||||
|
||||
**BAD:**
|
||||
```jsx
|
||||
import MainNav from './MainNav/index';
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```jsx
|
||||
import MainNav from './MainNav';
|
||||
```
|
||||
|
||||
**Props naming:** Do not use DOM related props for different purposes
|
||||
|
||||
**BAD:**
|
||||
```jsx
|
||||
<MainNav style="big" />
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```jsx
|
||||
<MainNav variant="big" />
|
||||
```
|
||||
|
||||
**Importing dependencies:** Only import what you need
|
||||
|
||||
**BAD:**
|
||||
```jsx
|
||||
import * as React from "react";
|
||||
```
|
||||
|
||||
**GOOD:**
|
||||
```jsx
|
||||
import React, { useState } from "react";
|
||||
```
|
||||
|
||||
**Default VS named exports:** As recommended by [TypeScript](https://www.typescriptlang.org/docs/handbook/modules.html), "If a module's primary purpose is to house one specific export, then you should consider exporting it as a default export. This makes both importing and actually using the import a little easier". If you're exporting multiple objects, use named exports instead.
|
||||
|
||||
_As a default export_
|
||||
```jsx
|
||||
import MainNav from './MainNav';
|
||||
```
|
||||
|
||||
_As a named export_
|
||||
```jsx
|
||||
import { MainNav, SecondaryNav } from './Navbars';
|
||||
```
|
||||
|
||||
**ARIA roles:** Always make sure you are writing accessible components by using the official [ARIA roles](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques)
|
||||
|
||||
## Use TypeScript
|
||||
|
||||
All components should be written in TypeScript and their extensions should be `.ts` or `.tsx`
|
||||
|
||||
### type vs interface
|
||||
|
||||
Validate all props with the correct types. This replaces the need for a run-time validation as provided by the prop-types library.
|
||||
|
||||
```tsx
|
||||
// ✅ Good - Functional component with hooks
|
||||
export const MyComponent: React.FC<Props> = ({ data }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
type HeadingProps = {
|
||||
param: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Effect logic
|
||||
}, []);
|
||||
|
||||
return <div>{/* Component JSX */}</div>;
|
||||
};
|
||||
|
||||
// ❌ Avoid - Class component
|
||||
class MyComponent extends React.Component {
|
||||
// Class implementation
|
||||
export default function Heading({ children }: HeadingProps) {
|
||||
return <h2>{children}</h2>
|
||||
}
|
||||
```
|
||||
|
||||
### Follow Ant Design Patterns
|
||||
Use `type` for your component props and state. Use `interface` when you want to enable _declaration merging_.
|
||||
|
||||
Extend Ant Design components rather than building from scratch:
|
||||
### Define default values for non-required props
|
||||
|
||||
In order to improve the readability of your code and reduce assumptions, always add default values for non required props, when applicable, for example:
|
||||
|
||||
```tsx
|
||||
import { Button } from 'antd';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
// Custom styling using emotion
|
||||
`;
|
||||
const applyDiscount = (price: number, discount = 0.05) => price * (1 - discount);
|
||||
```
|
||||
|
||||
### Reusability and Props Design
|
||||
## Functional components and Hooks
|
||||
|
||||
Design components with reusability in mind:
|
||||
We prefer functional components and the usage of hooks over class components.
|
||||
|
||||
### useState
|
||||
|
||||
Always explicitly declare the type unless the type can easily be assumed by the declaration.
|
||||
|
||||
```tsx
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const CustomButton: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
...props
|
||||
}) => {
|
||||
// Implementation
|
||||
};
|
||||
const [customer, setCustomer] = useState<ICustomer | null>(null);
|
||||
```
|
||||
|
||||
## Testing Components
|
||||
### useReducer
|
||||
|
||||
Every component should include comprehensive tests:
|
||||
Always prefer `useReducer` over `useState` when your state has complex logics.
|
||||
|
||||
```tsx
|
||||
// MyComponent.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MyComponent } from './MyComponent';
|
||||
### useMemo and useCallback
|
||||
|
||||
test('renders component with title', () => {
|
||||
render(<MyComponent title="Test Title" />);
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument();
|
||||
});
|
||||
Always memoize when your components take functions or complex objects as props to avoid unnecessary rerenders.
|
||||
|
||||
test('calls onClose when close button is clicked', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
render(<MyComponent title="Test" onClose={mockOnClose} />);
|
||||
### Custom hooks
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
All custom hooks should be located in the directory `/src/hooks`. Before creating a new custom hook, make sure that is not available in the existing custom hooks.
|
||||
|
||||
## Storybook Stories
|
||||
## Storybook
|
||||
|
||||
Create stories for visual testing and documentation:
|
||||
Each component should come with its dedicated storybook file.
|
||||
|
||||
```tsx
|
||||
// MyComponent.stories.tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { MyComponent } from './MyComponent';
|
||||
**One component per story:** Each storybook file should only contain one component unless substantially different variants are required
|
||||
|
||||
const meta: Meta<typeof MyComponent> = {
|
||||
title: 'Components/MyComponent',
|
||||
component: MyComponent,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
**Component variants:** If the component behavior is substantially different when certain props are used, it is best to separate the story into different types. See the `superset-frontend/src/components/Select/Select.stories.tsx` as an example.
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
**Isolated state:** The storybook should show how the component works in an isolated state and with as few dependencies as possible
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'Default Component',
|
||||
isVisible: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
title: 'Hidden Component',
|
||||
isVisible: false,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Use React.memo for Expensive Components
|
||||
|
||||
```tsx
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const ExpensiveComponent = memo<Props>(({ data }) => {
|
||||
// Expensive rendering logic
|
||||
return <div>{/* Component content */}</div>;
|
||||
});
|
||||
```
|
||||
|
||||
### Optimize Re-renders
|
||||
|
||||
Use `useCallback` and `useMemo` appropriately:
|
||||
|
||||
```tsx
|
||||
export const OptimizedComponent: React.FC<Props> = ({ items, onSelect }) => {
|
||||
const expensiveValue = useMemo(() => {
|
||||
return items.reduce((acc, item) => acc + item.value, 0);
|
||||
}, [items]);
|
||||
|
||||
const handleSelect = useCallback((id: string) => {
|
||||
onSelect(id);
|
||||
}, [onSelect]);
|
||||
|
||||
return <div>{/* Component content */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
Ensure components are accessible:
|
||||
|
||||
```tsx
|
||||
export const AccessibleButton: React.FC<Props> = ({ children, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Descriptive label"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Error Boundaries
|
||||
|
||||
For components that might fail, consider error boundaries:
|
||||
|
||||
```tsx
|
||||
export const SafeComponent: React.FC<Props> = ({ children }) => {
|
||||
return (
|
||||
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
```
|
||||
**Use args:** It should be possible to test the component with every variant of the available props. We recommend using [args](https://storybook.js.org/docs/react/writing-stories/args)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Emotion Styling Guidelines and Best Practices
|
||||
sidebar_position: 2
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -33,314 +33,245 @@ under the License.
|
||||
- **DO** use `css` when you want to amend/merge sets of styles compositionally
|
||||
- **DO** use `css` when you're making a small, or single-use set of styles for a component
|
||||
- **DO** move your style definitions from direct usage in the `css` prop to an external variable when they get long
|
||||
- **DO** prefer tagged template literals (`css={css\`...\`}`) over style objects wherever possible for maximum style portability/consistency
|
||||
- **DO** use `useTheme` to get theme variables
|
||||
- **DO** prefer tagged template literals (`css={css`...`}`) over style objects wherever possible for maximum style portability/consistency (note: typescript support may be diminished, but IDE plugins like [this](https://marketplace.visualstudio.com/items?itemName=jpoissonnier.vscode-styled-components) make life easy)
|
||||
- **DO** use `useTheme` to get theme variables. `withTheme` should be only used for wrapping legacy Class-based components.
|
||||
|
||||
### DON'T do these things:
|
||||
|
||||
- **DON'T** use `styled` for small, single-use style tweaks that would be easier to read/review if they were inline
|
||||
- **DON'T** export incomplete Ant Design components
|
||||
- **DON'T** export incomplete AntD components (make sure all their compound components are exported)
|
||||
|
||||
## Emotion Tips and Strategies
|
||||
|
||||
The first thing to consider when adding styles to an element is how much you think a style might be reusable in other areas of Superset. Always err on the side of reusability here. Nobody wants to chase styling inconsistencies, or try to debug little endless overrides scattered around the codebase. The more we can consolidate, the less will have to be figured out by those who follow. Reduce, reuse, recycle.
|
||||
|
||||
## When to use `css` or `styled`
|
||||
|
||||
### Use `css` for:
|
||||
In short, either works for just about any use case! And you'll see them used somewhat interchangeably in the existing codebase. But we need a way to weigh it when we encounter the choice, so here's one way to think about it:
|
||||
|
||||
1. **Small, single-use styles**
|
||||
```tsx
|
||||
import { css } from '@emotion/react';
|
||||
A good use of `styled` syntax if you want to re-use a styled component. In other words, if you wanted to export flavors of a component for use, like so:
|
||||
|
||||
const MyComponent = () => (
|
||||
<div
|
||||
css={css`
|
||||
margin: 8px;
|
||||
padding: 16px;
|
||||
`}
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
2. **Composing styles**
|
||||
```tsx
|
||||
const baseStyles = css`
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
```jsx
|
||||
const StatusThing = styled.div`
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
`;
|
||||
|
||||
const primaryStyles = css`
|
||||
${baseStyles}
|
||||
background-color: blue;
|
||||
color: white;
|
||||
export const InfoThing = styled(StatusThing)`
|
||||
background: blue;
|
||||
&::before {
|
||||
content: "ℹ️";
|
||||
}
|
||||
`;
|
||||
|
||||
const secondaryStyles = css`
|
||||
${baseStyles}
|
||||
background-color: gray;
|
||||
color: black;
|
||||
export const WarningThing = styled(StatusThing)`
|
||||
background: orange;
|
||||
&::before {
|
||||
content: "⚠️";
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
3. **Conditional styling**
|
||||
```tsx
|
||||
const MyComponent = ({ isActive }: { isActive: boolean }) => (
|
||||
<div
|
||||
css={[
|
||||
baseStyles,
|
||||
isActive && activeStyles,
|
||||
]}
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### Use `styled` for:
|
||||
|
||||
1. **Reusable components**
|
||||
```tsx
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledButton = styled.button`
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: ${({ theme }) => theme.colors.primary};
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.colors.primaryDark};
|
||||
export const TerribleThing = styled(StatusThing)`
|
||||
background: red;
|
||||
&::before {
|
||||
content: "🔥";
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
2. **Components with complex nested selectors**
|
||||
```tsx
|
||||
const StyledCard = styled.div`
|
||||
padding: 16px;
|
||||
border: 1px solid ${({ theme }) => theme.colors.border};
|
||||
You can also use `styled` when you're building a bigger component, and just want to have some custom bits for internal use in your JSX. For example:
|
||||
|
||||
.card-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
```jsx
|
||||
const SeparatorOnlyUsedInThisComponent = styled.hr`
|
||||
height: 12px;
|
||||
border: 0;
|
||||
box-shadow: inset 0 12px 12px -12px rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
function SuperComplicatedComponent(props) {
|
||||
return (
|
||||
<>
|
||||
Daily standup for {user.name}!
|
||||
<SeparatorOnlyUsedInThisComponent />
|
||||
<h2>Yesterday:</h2>
|
||||
// spit out a list of accomplishments
|
||||
<SeparatorOnlyUsedInThisComponent />
|
||||
<h2>Today:</h2>
|
||||
// spit out a list of plans
|
||||
<SeparatorOnlyUsedInThisComponent />
|
||||
<h2>Tomorrow:</h2>
|
||||
// spit out a list of goals
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
3. **Extending Ant Design components**
|
||||
```tsx
|
||||
import { Button } from 'antd';
|
||||
import styled from '@emotion/styled';
|
||||
The `css` prop, in reality, shares all the same styling capabilities as `styled` but it does have some particular use cases that jump out as sensible. For example, if you just want to style one element in your component, you could add the styles inline like so:
|
||||
|
||||
const CustomButton = styled(Button)`
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
|
||||
&.ant-btn-primary {
|
||||
background: linear-gradient(45deg, #1890ff, #722ed1);
|
||||
}
|
||||
`;
|
||||
```jsx
|
||||
function SomeFanciness(props) {
|
||||
return (
|
||||
<>
|
||||
Here's an awesome report card for {user.name}!
|
||||
<div
|
||||
css={css`
|
||||
box-shadow: 5px 5px 10px #ccc;
|
||||
border-radius: 10px;
|
||||
`}
|
||||
>
|
||||
<h2>Yesterday:</h2>
|
||||
// ...some stuff
|
||||
<h2>Today:</h2>
|
||||
// ...some stuff
|
||||
<h2>Tomorrow:</h2>
|
||||
// ...some stuff
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using Theme Variables
|
||||
You can also define the styles as a variable, external to your JSX. This is handy if the styles get long and you just want it out of the way. This is also handy if you want to apply the same styles to disparate element types, kind of like you might use a CSS class on varied elements. Here's a trumped up example:
|
||||
|
||||
Always use theme variables for consistent styling:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
const MyComponent = () => {
|
||||
const theme = useTheme();
|
||||
```jsx
|
||||
function FakeGlobalNav(props) {
|
||||
const menuItemStyles = css`
|
||||
display: block;
|
||||
border-bottom: 1px solid cadetblue;
|
||||
font-family: "Comic Sans", cursive;
|
||||
`;
|
||||
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
color: ${theme.colors.grayscale.dark2};
|
||||
padding: ${theme.gridUnit * 4}px;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
`}
|
||||
>
|
||||
Content
|
||||
<Nav>
|
||||
<a css={menuItemStyles} href="#">One link</a>
|
||||
<Link css={menuItemStyles} to={url}>Another link</Link>
|
||||
<div css={menuItemStyles} onClick={() => alert('clicked')}>Another link</div>
|
||||
</Nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## CSS tips and tricks
|
||||
|
||||
### `css` lets you write actual CSS
|
||||
|
||||
By default the `css` prop uses the object syntax with JS style definitions, like so:
|
||||
|
||||
```jsx
|
||||
<div css={{
|
||||
borderRadius: 10,
|
||||
marginTop: 10,
|
||||
backgroundColor: '#00FF00'
|
||||
}}>Howdy</div>
|
||||
```
|
||||
|
||||
But you can use the `css` interpolator as well to get away from icky JS styling syntax. Doesn't this look cleaner?
|
||||
|
||||
```jsx
|
||||
<div css={css`
|
||||
border-radius: 10px;
|
||||
margin-top: 10px;
|
||||
background-color: #00FF00;
|
||||
`}>Howdy</div>
|
||||
```
|
||||
|
||||
You might say "whatever… I can read and write JS syntax just fine." Well, that's great. But… let's say you're migrating in some of our legacy LESS styles… now it's copy/paste! Or if you want to migrate to or from `styled` syntax… also copy/paste!
|
||||
|
||||
### You can combine `css` definitions with array syntax
|
||||
|
||||
You can use multiple groupings of styles with the `css` interpolator, and combine/override them in array syntax, like so:
|
||||
|
||||
```jsx
|
||||
function AnotherSillyExample(props) {
|
||||
const shadowedCard = css`
|
||||
box-shadow: 2px 2px 4px #999;
|
||||
padding: 4px;
|
||||
`;
|
||||
const infoCard = css`
|
||||
background-color: #33f;
|
||||
border-radius: 4px;
|
||||
`;
|
||||
const overrideInfoCard = css`
|
||||
background-color: #f33;
|
||||
`;
|
||||
return (
|
||||
<div className="App">
|
||||
Combining two classes:
|
||||
<div css={[shadowedCard, infoCard]}>Hello</div>
|
||||
Combining again, but now with overrides:
|
||||
<div css={[shadowedCard, infoCard, overrideInfoCard]}>Hello</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Responsive Design
|
||||
```tsx
|
||||
const ResponsiveContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
${({ theme }) => theme.breakpoints.up('md')} {
|
||||
flex-direction: row;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
### Animation
|
||||
```tsx
|
||||
const FadeInComponent = styled.div`
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
### Conditional Props
|
||||
```tsx
|
||||
interface StyledDivProps {
|
||||
isHighlighted?: boolean;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
```
|
||||
|
||||
const StyledDiv = styled.div<StyledDivProps>`
|
||||
padding: 16px;
|
||||
background-color: ${({ isHighlighted, theme }) =>
|
||||
isHighlighted ? theme.colors.primary : theme.colors.grayscale.light5};
|
||||
### Style variations with props
|
||||
|
||||
${({ size }) => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return css`font-size: 12px;`;
|
||||
case 'large':
|
||||
return css`font-size: 18px;`;
|
||||
default:
|
||||
return css`font-size: 14px;`;
|
||||
}
|
||||
}}
|
||||
You can give any component a custom prop, and reference that prop in your component styles, effectively using the prop to turn on a "flavor" of that component.
|
||||
|
||||
For example, let's make a styled component that acts as a card. Of course, this could be done with any AntD component, or any component at all. But we'll do this with a humble `div` to illustrate the point:
|
||||
|
||||
```jsx
|
||||
const SuperCard = styled.div`
|
||||
${({ theme, cutout }) => `
|
||||
padding: ${theme.gridUnit * 2}px;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
box-shadow: 10px 5px 10px #ccc ${cutout && 'inset'};
|
||||
border: 1px solid ${cutout ? 'transparent' : theme.colors.secondary.light3};
|
||||
`}
|
||||
`;
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
Then just use the component as `<SuperCard>Some content</SuperCard>` or with the (potentially dynamic) prop: `<SuperCard cutout>Some content</SuperCard>`
|
||||
|
||||
### 1. Use Semantic CSS Properties
|
||||
```tsx
|
||||
// ✅ Good - semantic property names
|
||||
const Header = styled.h1`
|
||||
font-size: ${({ theme }) => theme.typography.sizes.xl};
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
`;
|
||||
## Styled component tips
|
||||
|
||||
// ❌ Avoid - magic numbers
|
||||
const Header = styled.h1`
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
```
|
||||
### No need to use `theme` the hard way
|
||||
|
||||
### 2. Group Related Styles
|
||||
```tsx
|
||||
// ✅ Good - grouped styles
|
||||
const Card = styled.div`
|
||||
/* Layout */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
It's very tempting (and commonly done) to use the `theme` prop inline in the template literal like so:
|
||||
|
||||
/* Appearance */
|
||||
background-color: ${({ theme }) => theme.colors.grayscale.light5};
|
||||
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
||||
```jsx
|
||||
const SomeStyledThing = styled.div`
|
||||
padding: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
|
||||
/* Typography */
|
||||
font-family: ${({ theme }) => theme.typography.families.sansSerif};
|
||||
color: ${({ theme }) => theme.colors.grayscale.dark2};
|
||||
border: 1px solid ${({ theme }) => theme.colors.secondary.light3};
|
||||
`;
|
||||
```
|
||||
|
||||
### 3. Extract Complex Styles
|
||||
```tsx
|
||||
// ✅ Good - extract complex styles to variables
|
||||
const complexGradient = css`
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
${({ theme }) => theme.colors.primary} 0%,
|
||||
${({ theme }) => theme.colors.secondary} 100%
|
||||
);
|
||||
`;
|
||||
Instead, you can make things a little easier to read/type by writing it like so:
|
||||
|
||||
const GradientButton = styled.button`
|
||||
${complexGradient}
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
color: white;
|
||||
```jsx
|
||||
const SomeStyledThing = styled.div`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.gridUnit * 2}px;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
border: 1px solid ${theme.colors.secondary.light3};
|
||||
`}
|
||||
`;
|
||||
```
|
||||
|
||||
### 4. Avoid Deep Nesting
|
||||
```tsx
|
||||
// ✅ Good - shallow nesting
|
||||
const Navigation = styled.nav`
|
||||
.nav-item {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
## Extend an AntD component with custom styling
|
||||
|
||||
.nav-link {
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
text-decoration: none;
|
||||
}
|
||||
As mentioned, you want to keep your styling as close to the root of your component system as possible, to minimize repetitive styling/overrides, and err on the side of reusability. In some cases, that means you'll want to globally tweak one of our core components to match our design system. In Superset, that's Ant Design (AntD).
|
||||
|
||||
AntD uses a cool trick called compound components. For example, the `Menu` component also lets you use `Menu.Item`, `Menu.SubMenu`, `Menu.ItemGroup`, and `Menu.Divider`.
|
||||
|
||||
### The `Object.assign` trick
|
||||
|
||||
Let's say you want to override an AntD component called `Foo`, and have `Foo.Bar` display some custom CSS for the `Bar` compound component. You can do it effectively like so:
|
||||
|
||||
```jsx
|
||||
import {
|
||||
Foo as AntdFoo,
|
||||
} from 'antd';
|
||||
|
||||
export const StyledBar = styled(AntdFoo.Bar)`
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
`;
|
||||
|
||||
// ❌ Avoid - deep nesting
|
||||
const Navigation = styled.nav`
|
||||
ul {
|
||||
li {
|
||||
a {
|
||||
span {
|
||||
color: blue; /* Too nested */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
### 1. Avoid Inline Functions in CSS
|
||||
```tsx
|
||||
// ✅ Good - external function
|
||||
const getBackgroundColor = (isActive: boolean, theme: any) =>
|
||||
isActive ? theme.colors.primary : theme.colors.secondary;
|
||||
|
||||
const Button = styled.button<{ isActive: boolean }>`
|
||||
background-color: ${({ isActive, theme }) => getBackgroundColor(isActive, theme)};
|
||||
`;
|
||||
|
||||
// ❌ Avoid - inline function (creates new function on each render)
|
||||
const Button = styled.button<{ isActive: boolean }>`
|
||||
background-color: ${({ isActive, theme }) =>
|
||||
isActive ? theme.colors.primary : theme.colors.secondary};
|
||||
`;
|
||||
```
|
||||
|
||||
### 2. Use CSS Objects for Dynamic Styles
|
||||
```tsx
|
||||
// For highly dynamic styles, consider CSS objects
|
||||
const dynamicStyles = (props: Props) => ({
|
||||
backgroundColor: props.color,
|
||||
fontSize: `${props.size}px`,
|
||||
// ... other dynamic properties
|
||||
export const Foo = Object.assign(AntdFoo, {
|
||||
Bar: StyledBar,
|
||||
});
|
||||
|
||||
const DynamicComponent = (props: Props) => (
|
||||
<div css={dynamicStyles(props)}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
You can then import this customized `Foo` and use `Foo.Bar` as expected. You should probably save your creation in `src/components` for maximum reusability, and add a Storybook entry so future engineers can view your creation, and designers can better understand how it fits the Superset Design System.
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
---
|
||||
title: Testing Guidelines and Best Practices
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Testing Guidelines and Best Practices
|
||||
|
||||
We feel that tests are an important part of a feature and not an additional or optional effort. That's why we colocate test files with functionality and sometimes write tests upfront to help validate requirements and shape the API of our components. Every new component or file added should have an associated test file with the `.test` extension.
|
||||
|
||||
We use Jest, React Testing Library (RTL), and Cypress to write our unit, integration, and end-to-end tests. For each type, we have a set of best practices/tips described below:
|
||||
|
||||
## Jest
|
||||
|
||||
### Write simple, standalone tests
|
||||
|
||||
The importance of simplicity is often overlooked in test cases. Clear, dumb code should always be preferred over complex ones. The test cases should be pretty much standalone and should not involve any external logic if not absolutely necessary. That's because you want the corpus of the tests to be easy to read and understandable at first sight.
|
||||
|
||||
### Avoid nesting when you're testing
|
||||
|
||||
Avoid the use of `describe` blocks in favor of inlined tests. If your tests start to grow and you feel the need to group tests, prefer to break them into multiple test files. Check this awesome [article](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) written by [Kent C. Dodds](https://kentcdodds.com/) about this topic.
|
||||
|
||||
### No warnings!
|
||||
|
||||
Your tests shouldn't trigger warnings. This is really common when testing async functionality. It's really difficult to read test results when we have a bunch of warnings.
|
||||
|
||||
## React Testing Library (RTL)
|
||||
|
||||
### Keep accessibility in mind when writing your tests
|
||||
|
||||
One of the most important points of RTL is accessibility and this is also a very important point for us. We should try our best to follow the RTL [Priority](https://testing-library.com/docs/queries/about/#priority) when querying for elements in our tests. `getByTestId` is not viewable by the user and should only be used when the element isn't accessible in any other way.
|
||||
|
||||
### Don't use `act` unnecessarily
|
||||
|
||||
`render` and `fireEvent` are already wrapped in `act`, so wrapping them in `act` again is a common mistake. Some solutions to the warnings related to async testing can be found in the RTL docs.
|
||||
|
||||
## Example Test Structure
|
||||
|
||||
```tsx
|
||||
// MyComponent.test.tsx
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
// ✅ Good - Simple, standalone test
|
||||
test('renders loading state initially', () => {
|
||||
render(<MyComponent />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ✅ Good - Tests user interaction
|
||||
test('calls onSubmit when form is submitted', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnSubmit = jest.fn();
|
||||
|
||||
render(<MyComponent onSubmit={mockOnSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Username'), 'testuser');
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith({ username: 'testuser' });
|
||||
});
|
||||
|
||||
// ✅ Good - Tests async behavior
|
||||
test('displays error message when API call fails', async () => {
|
||||
const mockFetch = jest.fn().mockRejectedValue(new Error('API Error'));
|
||||
global.fetch = mockFetch;
|
||||
|
||||
render(<MyComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error: API Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Use appropriate queries in priority order
|
||||
|
||||
1. **Accessible to everyone**: `getByRole`, `getByLabelText`, `getByPlaceholderText`, `getByText`
|
||||
2. **Semantic queries**: `getByAltText`, `getByTitle`
|
||||
3. **Test IDs**: `getByTestId` (last resort)
|
||||
|
||||
```tsx
|
||||
// ✅ Good - using accessible queries
|
||||
test('user can submit form', () => {
|
||||
render(<LoginForm />);
|
||||
|
||||
const usernameInput = screen.getByLabelText('Username');
|
||||
const passwordInput = screen.getByLabelText('Password');
|
||||
const submitButton = screen.getByRole('button', { name: 'Log in' });
|
||||
|
||||
// Test implementation
|
||||
});
|
||||
|
||||
// ❌ Avoid - using test IDs when better options exist
|
||||
test('user can submit form', () => {
|
||||
render(<LoginForm />);
|
||||
|
||||
const usernameInput = screen.getByTestId('username-input');
|
||||
const passwordInput = screen.getByTestId('password-input');
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
|
||||
### Test behavior, not implementation details
|
||||
|
||||
```tsx
|
||||
// ✅ Good - tests what the user experiences
|
||||
test('shows success message after successful form submission', async () => {
|
||||
render(<ContactForm />);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Message sent successfully!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ❌ Avoid - testing implementation details
|
||||
test('calls setState when form is submitted', () => {
|
||||
const component = shallow(<ContactForm />);
|
||||
const instance = component.instance();
|
||||
const spy = jest.spyOn(instance, 'setState');
|
||||
|
||||
instance.handleSubmit();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
### Mock external dependencies appropriately
|
||||
|
||||
```tsx
|
||||
// Mock API calls
|
||||
jest.mock('../api/userService', () => ({
|
||||
getUser: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock components that aren't relevant to the test
|
||||
jest.mock('../Chart/Chart', () => {
|
||||
return function MockChart() {
|
||||
return <div data-testid="mock-chart">Chart Component</div>;
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Async Testing Patterns
|
||||
|
||||
### Testing async operations
|
||||
|
||||
```tsx
|
||||
test('loads and displays user data', async () => {
|
||||
const mockUser = { id: 1, name: 'John Doe' };
|
||||
const mockGetUser = jest.fn().mockResolvedValue(mockUser);
|
||||
|
||||
render(<UserProfile getUserData={mockGetUser} />);
|
||||
|
||||
// Wait for the async operation to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockGetUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing loading states
|
||||
|
||||
```tsx
|
||||
test('shows loading spinner while fetching data', async () => {
|
||||
const mockGetUser = jest.fn().mockImplementation(
|
||||
() => new Promise(resolve => setTimeout(resolve, 100))
|
||||
);
|
||||
|
||||
render(<UserProfile getUserData={mockGetUser} />);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Component-Specific Testing
|
||||
|
||||
### Testing form components
|
||||
|
||||
```tsx
|
||||
test('validates required fields', async () => {
|
||||
render(<RegistrationForm />);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Register' });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(screen.getByText('Username is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing modals and overlays
|
||||
|
||||
```tsx
|
||||
test('opens and closes modal', async () => {
|
||||
render(<ModalContainer />);
|
||||
|
||||
const openButton = screen.getByRole('button', { name: 'Open Modal' });
|
||||
await userEvent.click(openButton);
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
### Testing with context providers
|
||||
|
||||
```tsx
|
||||
const renderWithTheme = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeProvider theme={mockTheme}>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
test('applies theme colors correctly', () => {
|
||||
renderWithTheme(<ThemedButton />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveStyle({
|
||||
backgroundColor: mockTheme.colors.primary,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Testing with large datasets
|
||||
|
||||
```tsx
|
||||
test('handles large lists efficiently', () => {
|
||||
const largeDataset = Array.from({ length: 10000 }, (_, i) => ({
|
||||
id: i,
|
||||
name: `Item ${i}`,
|
||||
}));
|
||||
|
||||
const { container } = render(<VirtualizedList items={largeDataset} />);
|
||||
|
||||
// Should only render visible items
|
||||
const renderedItems = container.querySelectorAll('[data-testid="list-item"]');
|
||||
expect(renderedItems.length).toBeLessThan(100);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Accessibility
|
||||
|
||||
```tsx
|
||||
test('is accessible to screen readers', () => {
|
||||
render(<AccessibleForm />);
|
||||
|
||||
const form = screen.getByRole('form');
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
|
||||
inputs.forEach(input => {
|
||||
expect(input).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
expect(form).toHaveAttribute('aria-describedby');
|
||||
});
|
||||
```
|
||||
129
docs/developer_portal/testing/testing-guidelines.md
Normal file
129
docs/developer_portal/testing/testing-guidelines.md
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Testing Guidelines and Best Practices
|
||||
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.
|
||||
-->
|
||||
|
||||
# Testing Guidelines and Best Practices
|
||||
|
||||
We feel that tests are an important part of a feature and not an additional or optional effort. That's why we colocate test files with functionality and sometimes write tests upfront to help validate requirements and shape the API of our components. Every new component or file added should have an associated test file with the `.test` extension.
|
||||
|
||||
We use Jest, React Testing Library (RTL), and Cypress to write our unit, integration, and end-to-end tests. For each type, we have a set of best practices/tips described below:
|
||||
|
||||
## Jest
|
||||
|
||||
### Write simple, standalone tests
|
||||
|
||||
The importance of simplicity is often overlooked in test cases. Clear, dumb code should always be preferred over complex ones. The test cases should be pretty much standalone and should not involve any external logic if not absolutely necessary. That's because you want the corpus of the tests to be easy to read and understandable at first sight.
|
||||
|
||||
### Avoid nesting when you're testing
|
||||
|
||||
Avoid the use of `describe` blocks in favor of inlined tests. If your tests start to grow and you feel the need to group tests, prefer to break them into multiple test files. Check this awesome [article](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) written by [Kent C. Dodds](https://kentcdodds.com/) about this topic.
|
||||
|
||||
### No warnings!
|
||||
|
||||
Your tests shouldn't trigger warnings. This is really common when testing async functionality. It's really difficult to read test results when we have a bunch of warnings.
|
||||
|
||||
### Example
|
||||
|
||||
You can find an example of a test [here](https://github.com/apache/superset/blob/e6c5bf4/superset-frontend/src/common/hooks/useChangeEffect/useChangeEffect.test.ts).
|
||||
|
||||
## React Testing Library (RTL)
|
||||
|
||||
### Keep accessibility in mind when writing your tests
|
||||
|
||||
One of the most important points of RTL is accessibility and this is also a very important point for us. We should try our best to follow the RTL [Priority](https://testing-library.com/docs/queries/about/#priority) when querying for elements in our tests. `getByTestId` is not viewable by the user and should only be used when the element isn't accessible in any other way.
|
||||
|
||||
### Don't use `act` unnecessarily
|
||||
|
||||
`render` and `fireEvent` are already wrapped in `act`, so wrapping them in `act` again is a common mistake. Some solutions to the warnings related to `act` might be found [here](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning).
|
||||
|
||||
### Be specific when using *ByRole
|
||||
|
||||
By using the `name` option we can point to the items by their accessible name. For example:
|
||||
|
||||
```jsx
|
||||
screen.getByRole('button', { name: /hello world/i })
|
||||
```
|
||||
|
||||
Using the `name` property also avoids breaking the tests in the future if other components with the same role are added.
|
||||
|
||||
### userEvent vs fireEvent
|
||||
|
||||
Prefer the [user-event](https://github.com/testing-library/user-event) library, which provides a more advanced simulation of browser interactions than the built-in [fireEvent](https://testing-library.com/docs/dom-testing-library/api-events/#fireevent) method.
|
||||
|
||||
### Usage of waitFor
|
||||
|
||||
- [Prefer to use `find`](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-waitfor-to-wait-for-elements-that-can-be-queried-with-find) over `waitFor` when you're querying for elements. Even though both achieve the same objective, the `find` version is simpler and you'll get better error messages.
|
||||
- Prefer to use only [one assertion at a time](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#having-multiple-assertions-in-a-single-waitfor-callback). If you put multiple assertions inside a `waitFor` we could end up waiting for the whole block timeout before seeing a test failure even if the failure occurred at the first assertion. By putting a single assertion in there, we can improve on test execution time.
|
||||
- Do not perform [side-effects](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#performing-side-effects-in-waitfor) inside `waitFor`. The callback can be called a non-deterministic number of times and frequency (it's called both on an interval as well as when there are DOM mutations). So this means that your side-effect could run multiple times.
|
||||
|
||||
### Example
|
||||
|
||||
You can find an example of a test [here](https://github.com/apache/superset/blob/master/superset-frontend/src/dashboard/components/PublishedStatus/PublishedStatus.test.tsx).
|
||||
|
||||
## Cypress
|
||||
|
||||
### Prefer to use Cypress for e2e testing and RTL for integration testing
|
||||
|
||||
Here it's important to make the distinction between e2e and integration testing. This [article](https://www.onpathtesting.com/blog/end-to-end-vs-integration-testing) gives an excellent definition:
|
||||
|
||||
> End-to-end testing verifies that your software works correctly from the beginning to the end of a particular user flow. It replicates expected user behavior and various usage scenarios to ensure that your software works as whole. End-to-end testing uses a production equivalent environment and data to simulate real-world situations and may also involve the integrations your software has with external applications.
|
||||
|
||||
> A typical software project consists of multiple software units, usually coded by different developers. Integration testing combines those software units logically and tests them as a group.
|
||||
> Essentially, integration testing verifies whether or not the individual modules or services that make up your application work well together. The purpose of this level of testing is to expose defects in the interaction between these software modules when they are integrated.
|
||||
|
||||
Do not use Cypress when RTL can do it better and faster. Many of the Cypress tests that we have right now, fall into the integration testing category and can be ported to RTL which is much faster and gives more immediate feedback. Cypress should be used mainly for end-to-end testing, replicating the user experience, with positive and negative flows.
|
||||
|
||||
### Isolated and standalone tests
|
||||
|
||||
Tests should never rely on other tests to pass. This might be hard when a single user is used for testing as data will be stored in the database. At every new test, we should reset the database.
|
||||
|
||||
### Cleaning state
|
||||
|
||||
Cleaning the state of the application, such as resetting the DB, or in general, any state that might affect consequent tests should always be done in the `beforeEach` hook and never in the `afterEach` one as the `beforeEach` is guaranteed to run, while the test might never reach the point to run the `afterEach` hook. One example would be if you refresh Cypress in the middle of the test. At this point, you will have built up a partial state in the database, and your clean-up function will never get called. You can read more about it [here](https://docs.cypress.io/guides/references/best-practices#Using-after-or-afterEach-hooks).
|
||||
|
||||
### Unnecessary use of `cy.wait`
|
||||
|
||||
- Unnecessary when using `cy.request()` as it will resolve when a response is received from the server
|
||||
- Unnecessary when using `cy.visit()` as it resolves only when the page fires the load event
|
||||
- Unnecessary when using `cy.get()`. When the selector should wait for a request to happen, aliases would come in handy:
|
||||
|
||||
```js
|
||||
cy.intercept('GET', '/users', [{ name: 'Maggy' }, { name: 'Joan' }]).as('getUsers')
|
||||
cy.get('#fetch').click()
|
||||
cy.wait('@getUsers') // <--- wait explicitly for this route to finish
|
||||
cy.get('table tr').should('have.length', 2)
|
||||
```
|
||||
|
||||
### Accessibility and Resilience
|
||||
|
||||
The same accessibility principles in the RTL section apply here. Use accessible selectors when querying for elements. Those principles also help to isolate selectors from eventual CSS and JS changes and improve the resilience of your tests.
|
||||
|
||||
## References
|
||||
|
||||
- [Avoid Nesting when you're Testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing)
|
||||
- [RTL - Queries](https://testing-library.com/docs/queries/about/#priority)
|
||||
- [Fix the "not wrapped in act(...)" warning](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning)
|
||||
- [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library)
|
||||
- [ARIA Roles](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles)
|
||||
- [Cypress - Best Practices](https://docs.cypress.io/guides/references/best-practices)
|
||||
- [End-to-end vs integration tests: what's the difference?](https://www.onpathtesting.com/blog/end-to-end-vs-integration-testing)
|
||||
@@ -26,8 +26,8 @@ made available in the Jinja context:
|
||||
|
||||
- `columns`: columns which to group by in the query
|
||||
- `filter`: filters applied in the query
|
||||
- `from_dttm`: start `datetime` value from the selected time range (`None` if undefined) (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `to_dttm`: end `datetime` value from the selected time range (`None` if undefined). (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `from_dttm`: start `datetime` value from the selected time range (`None` if undefined). **Note:** Only available in virtual datasets when a time range filter is applied in Explore/Chart views—not available in standalone SQL Lab queries. (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `to_dttm`: end `datetime` value from the selected time range (`None` if undefined). **Note:** Only available in virtual datasets when a time range filter is applied in Explore/Chart views—not available in standalone SQL Lab queries. (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `groupby`: columns which to group by in the query (deprecated)
|
||||
- `metrics`: aggregate expressions in the query
|
||||
- `row_limit`: row limit of the query
|
||||
@@ -67,6 +67,54 @@ the time filter is not set. For many database engines, this could be replaced wi
|
||||
Note that the Jinja parameters are called within _double_ brackets in the query and with
|
||||
_single_ brackets in the logic blocks.
|
||||
|
||||
### Understanding Context Availability
|
||||
|
||||
Some Jinja variables like `from_dttm`, `to_dttm`, and `filter` are **only available when a chart or dashboard provides them**. They are populated from:
|
||||
|
||||
- Time range filters applied in Explore/Chart views
|
||||
- Dashboard native filters
|
||||
- Filter components
|
||||
|
||||
**These variables are NOT available in standalone SQL Lab queries** because there's no filter context. If you try to use `{{ from_dttm }}` directly in SQL Lab, you'll get an "undefined parameter" error.
|
||||
|
||||
#### Testing Time-Filtered Queries in SQL Lab
|
||||
|
||||
To test queries that use time variables in SQL Lab, you have several options:
|
||||
|
||||
**Option 1: Use Jinja defaults (recommended)**
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM tbl
|
||||
WHERE dttm_col > '{{ from_dttm | default("2024-01-01", true) }}'
|
||||
AND dttm_col < '{{ to_dttm | default("2024-12-31", true) }}'
|
||||
```
|
||||
|
||||
**Option 2: Use SQL Lab Parameters**
|
||||
|
||||
Set parameters in the SQL Lab UI (Parameters menu):
|
||||
```json
|
||||
{
|
||||
"from_dttm": "2024-01-01",
|
||||
"to_dttm": "2024-12-31"
|
||||
}
|
||||
```
|
||||
|
||||
**Option 3: Use `{% set %}` for testing**
|
||||
|
||||
```sql
|
||||
{% set from_dttm = "2024-01-01" %}
|
||||
{% set to_dttm = "2024-12-31" %}
|
||||
SELECT *
|
||||
FROM tbl
|
||||
WHERE dttm_col > '{{ from_dttm }}' AND dttm_col < '{{ to_dttm }}'
|
||||
```
|
||||
|
||||
:::tip
|
||||
When you save a SQL Lab query as a virtual dataset and use it in a chart with time filters,
|
||||
the actual filter values will override any defaults or test values you set.
|
||||
:::
|
||||
|
||||
To add custom functionality to the Jinja context, you need to overload the default Jinja
|
||||
context in your environment by defining the `JINJA_CONTEXT_ADDONS` in your superset configuration
|
||||
(`superset_config.py`). Objects referenced in this dictionary are made available for users to use
|
||||
|
||||
@@ -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);
|
||||
@@ -42,32 +42,24 @@ const sidebars = {
|
||||
'contributing/howtos',
|
||||
'contributing/release-process',
|
||||
'contributing/resources',
|
||||
'guidelines/design-guidelines',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Contribution Guidelines',
|
||||
label: 'Frontend Style Guidelines',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'guidelines/design-guidelines',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Frontend Style Guidelines',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'guidelines/frontend-style-guidelines',
|
||||
'guidelines/frontend/component-style-guidelines',
|
||||
'guidelines/frontend/emotion-styling-guidelines',
|
||||
'guidelines/frontend/testing-guidelines',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Backend Style Guidelines',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'guidelines/backend-style-guidelines',
|
||||
'guidelines/backend/dao-style-guidelines',
|
||||
],
|
||||
},
|
||||
'guidelines/frontend-style-guidelines',
|
||||
'guidelines/frontend/component-style-guidelines',
|
||||
'guidelines/frontend/emotion-styling-guidelines',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Backend Style Guidelines',
|
||||
collapsed: true,
|
||||
items: [
|
||||
'guidelines/backend-style-guidelines',
|
||||
'guidelines/backend/dao-style-guidelines',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -81,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',
|
||||
@@ -102,6 +105,7 @@ const sidebars = {
|
||||
collapsed: true,
|
||||
items: [
|
||||
'testing/overview',
|
||||
'testing/testing-guidelines',
|
||||
'testing/frontend-testing',
|
||||
'testing/backend-testing',
|
||||
'testing/e2e-testing',
|
||||
|
||||
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",
|
||||
|
||||
@@ -17,8 +17,8 @@ made available in the Jinja context:
|
||||
|
||||
- `columns`: columns which to group by in the query
|
||||
- `filter`: filters applied in the query
|
||||
- `from_dttm`: start `datetime` value from the selected time range (`None` if undefined) (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `to_dttm`: end `datetime` value from the selected time range (`None` if undefined). (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `from_dttm`: start `datetime` value from the selected time range (`None` if undefined). **Note:** Only available in virtual datasets when a time range filter is applied in Explore/Chart views—not available in standalone SQL Lab queries. (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `to_dttm`: end `datetime` value from the selected time range (`None` if undefined). **Note:** Only available in virtual datasets when a time range filter is applied in Explore/Chart views—not available in standalone SQL Lab queries. (deprecated beginning in version 5.0, use `get_time_filter` instead)
|
||||
- `groupby`: columns which to group by in the query (deprecated)
|
||||
- `metrics`: aggregate expressions in the query
|
||||
- `row_limit`: row limit of the query
|
||||
@@ -58,6 +58,54 @@ the time filter is not set. For many database engines, this could be replaced wi
|
||||
Note that the Jinja parameters are called within _double_ brackets in the query and with
|
||||
_single_ brackets in the logic blocks.
|
||||
|
||||
### Understanding Context Availability
|
||||
|
||||
Some Jinja variables like `from_dttm`, `to_dttm`, and `filter` are **only available when a chart or dashboard provides them**. They are populated from:
|
||||
|
||||
- Time range filters applied in Explore/Chart views
|
||||
- Dashboard native filters
|
||||
- Filter components
|
||||
|
||||
**These variables are NOT available in standalone SQL Lab queries** because there's no filter context. If you try to use `{{ from_dttm }}` directly in SQL Lab, you'll get an "undefined parameter" error.
|
||||
|
||||
#### Testing Time-Filtered Queries in SQL Lab
|
||||
|
||||
To test queries that use time variables in SQL Lab, you have several options:
|
||||
|
||||
**Option 1: Use Jinja defaults (recommended)**
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM tbl
|
||||
WHERE dttm_col > '{{ from_dttm | default("2024-01-01", true) }}'
|
||||
AND dttm_col < '{{ to_dttm | default("2024-12-31", true) }}'
|
||||
```
|
||||
|
||||
**Option 2: Use SQL Lab Parameters**
|
||||
|
||||
Set parameters in the SQL Lab UI (Parameters menu):
|
||||
```json
|
||||
{
|
||||
"from_dttm": "2024-01-01",
|
||||
"to_dttm": "2024-12-31"
|
||||
}
|
||||
```
|
||||
|
||||
**Option 3: Use `{% set %}` for testing**
|
||||
|
||||
```sql
|
||||
{% set from_dttm = "2024-01-01" %}
|
||||
{% set to_dttm = "2024-12-31" %}
|
||||
SELECT *
|
||||
FROM tbl
|
||||
WHERE dttm_col > '{{ from_dttm }}' AND dttm_col < '{{ to_dttm }}'
|
||||
```
|
||||
|
||||
:::tip
|
||||
When you save a SQL Lab query as a virtual dataset and use it in a chart with time filters,
|
||||
the actual filter values will override any defaults or test values you set.
|
||||
:::
|
||||
|
||||
To add custom functionality to the Jinja context, you need to overload the default Jinja
|
||||
context in your environment by defining the `JINJA_CONTEXT_ADDONS` in your superset configuration
|
||||
(`superset_config.py`). Objects referenced in this dictionary are made available for users to use
|
||||
|
||||
@@ -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;
|
||||
@@ -206,12 +206,20 @@ describe('Charts list', () => {
|
||||
|
||||
// edits in list-view
|
||||
setGridMode('list');
|
||||
cy.getBySel('edit-alt').eq(1).click();
|
||||
// Wait for list view to fully render after mode change
|
||||
cy.get('.loading').should('not.exist');
|
||||
cy.getBySel('table-row').should('be.visible');
|
||||
// Target the specific row by chart title to avoid flakiness from row ordering
|
||||
cy.getBySel('table-row')
|
||||
.contains('1 - Sample chart | EDITED')
|
||||
.parents('[data-test="table-row"]')
|
||||
.find('[data-test="edit-alt"]')
|
||||
.click();
|
||||
cy.getBySel('properties-modal-name-input').clear();
|
||||
cy.getBySel('properties-modal-name-input').type('1 - Sample chart');
|
||||
cy.get('button:contains("Save")').click();
|
||||
cy.wait('@update');
|
||||
cy.getBySel('table-row').eq(1).contains('1 - Sample chart');
|
||||
cy.getBySel('table-row').contains('1 - Sample chart').should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
8574
superset-frontend/package-lock.json
generated
8574
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%",
|
||||
@@ -175,7 +177,6 @@
|
||||
"mustache": "^4.2.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"ol": "^7.5.2",
|
||||
"polished": "^4.3.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"re-resizable": "^6.10.1",
|
||||
"react": "^17.0.2",
|
||||
@@ -243,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",
|
||||
@@ -268,7 +271,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/math-expression-evaluator": "^2.0.0",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-json-tree": "^0.13.0",
|
||||
@@ -292,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",
|
||||
@@ -322,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",
|
||||
@@ -344,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",
|
||||
@@ -355,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 = () => (
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/math-expression-evaluator": "^2.0.0",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -134,6 +134,8 @@ export function mapRows<T extends object>(
|
||||
) {
|
||||
return rows.map(row => {
|
||||
prepareRow(row);
|
||||
return { rowId: row.id, ...row.original, ...row.getRowProps() };
|
||||
// Spread getRowProps first so data properties from row.original take precedence
|
||||
// This prevents HTML attributes like `role: "row"` from overwriting data properties
|
||||
return { ...row.getRowProps(), rowId: row.id, ...row.original };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export enum FeatureFlag {
|
||||
CssTemplates = 'CSS_TEMPLATES',
|
||||
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
|
||||
DashboardRbac = 'DASHBOARD_RBAC',
|
||||
DataAccessRules = 'DATA_ACCESS_RULES',
|
||||
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
|
||||
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
|
||||
/** @deprecated */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* 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 { SupersetClient, t } from '@superset-ui/core';
|
||||
import { css, styled } from '@apache-superset/core/ui';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
import {
|
||||
Modal,
|
||||
AsyncSelect,
|
||||
InfoTooltip,
|
||||
Input,
|
||||
Collapse,
|
||||
} from '@superset-ui/core/components';
|
||||
import rison from 'rison';
|
||||
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
||||
import { DataAccessRuleObject } from './types';
|
||||
import PermissionsTree from './PermissionsTree';
|
||||
import type { PermissionsPayload } from './PermissionsTree/types';
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
max-width: 1200px;
|
||||
min-width: min-content;
|
||||
width: 100%;
|
||||
.ant-modal-footer {
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSectionContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
|
||||
${theme.sizeUnit * 2}px;
|
||||
|
||||
label,
|
||||
.control-label {
|
||||
display: flex;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
color: ${theme.colorTextLabel};
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-solid-small {
|
||||
vertical-align: middle;
|
||||
padding-bottom: ${theme.sizeUnit / 2}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: ${theme.sizeUnit}px;
|
||||
margin-bottom: ${theme.sizeUnit * 4}px;
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.required {
|
||||
margin-left: ${theme.sizeUnit / 2}px;
|
||||
color: ${theme.colorErrorText};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledTextArea = styled(Input.TextArea)`
|
||||
resize: vertical;
|
||||
margin-top: ${({ theme }) => theme.sizeUnit}px;
|
||||
font-family: monospace;
|
||||
`;
|
||||
|
||||
const StyledCollapse = styled(Collapse)`
|
||||
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
|
||||
background: transparent;
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: ${({ theme }) => theme.sizeUnit}px 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: ${({ theme }) => theme.sizeUnit}px 0 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DataAccessRuleModalProps {
|
||||
rule: DataAccessRuleObject | null;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
onAdd?: (rule?: DataAccessRuleObject) => void;
|
||||
onHide: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RULE: DataAccessRuleObject = {
|
||||
name: '',
|
||||
description: '',
|
||||
role_id: 0,
|
||||
rule: JSON.stringify(
|
||||
{
|
||||
allowed: [],
|
||||
denied: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
|
||||
type SelectValue = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function DataAccessRuleModal(props: DataAccessRuleModalProps) {
|
||||
const { rule, addDangerToast, addSuccessToast, onHide, show } = props;
|
||||
|
||||
const [currentRule, setCurrentRule] = useState<DataAccessRuleObject>({
|
||||
...DEFAULT_RULE,
|
||||
});
|
||||
const [selectedRole, setSelectedRole] = useState<SelectValue | null>(null);
|
||||
const [disableSave, setDisableSave] = useState<boolean>(true);
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
const [permissionsPayload, setPermissionsPayload] =
|
||||
useState<PermissionsPayload>({ allowed: [], denied: [] });
|
||||
const [showAdvanced, setShowAdvanced] = useState<string[]>([]);
|
||||
|
||||
const isEditMode = rule !== null;
|
||||
|
||||
const {
|
||||
state: { loading, resource, error: fetchError },
|
||||
fetchResource,
|
||||
createResource,
|
||||
updateResource,
|
||||
clearError,
|
||||
} = useSingleViewResource<DataAccessRuleObject>(
|
||||
'dar',
|
||||
t('data access rule'),
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
const updateRuleState = (name: string, value: any) => {
|
||||
setCurrentRule(currentRuleData => ({
|
||||
...currentRuleData,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validate = useCallback(() => {
|
||||
// Check role is selected
|
||||
if (!selectedRole?.value) {
|
||||
setDisableSave(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If advanced mode is open, validate JSON
|
||||
if (showAdvanced.includes('advanced')) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentRule.rule);
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
setJsonError(t('Rule must be a JSON object'));
|
||||
setDisableSave(true);
|
||||
return;
|
||||
}
|
||||
setJsonError(null);
|
||||
} catch {
|
||||
setJsonError(t('Invalid JSON'));
|
||||
setDisableSave(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDisableSave(false);
|
||||
}, [currentRule.rule, selectedRole, showAdvanced]);
|
||||
|
||||
// Initialize
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
setCurrentRule({ ...DEFAULT_RULE });
|
||||
setSelectedRole(null);
|
||||
setPermissionsPayload({ allowed: [], denied: [] });
|
||||
setShowAdvanced([]);
|
||||
} else if (rule?.id !== null && !loading && !fetchError) {
|
||||
fetchResource(rule.id as number);
|
||||
}
|
||||
}, [rule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resource) {
|
||||
const ruleStr =
|
||||
typeof resource.rule === 'string'
|
||||
? resource.rule
|
||||
: JSON.stringify(resource.rule, null, 2);
|
||||
|
||||
setCurrentRule({
|
||||
...resource,
|
||||
id: rule?.id,
|
||||
rule: ruleStr,
|
||||
});
|
||||
|
||||
// Parse rule into permissions payload
|
||||
try {
|
||||
const parsed =
|
||||
typeof resource.rule === 'string'
|
||||
? JSON.parse(resource.rule)
|
||||
: resource.rule;
|
||||
setPermissionsPayload({
|
||||
allowed: parsed.allowed || [],
|
||||
denied: parsed.denied || [],
|
||||
});
|
||||
} catch {
|
||||
setPermissionsPayload({ allowed: [], denied: [] });
|
||||
}
|
||||
|
||||
if (resource.role) {
|
||||
setSelectedRole({
|
||||
value: resource.role.id,
|
||||
label: resource.role.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [resource]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
}, [validate]);
|
||||
|
||||
const onRuleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
updateRuleState('rule', event.target.value);
|
||||
};
|
||||
|
||||
const onRoleChange = (value: SelectValue | null) => {
|
||||
setSelectedRole(value);
|
||||
if (value) {
|
||||
updateRuleState('role_id', value.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onPermissionsChange = useCallback((payload: PermissionsPayload) => {
|
||||
setPermissionsPayload(payload);
|
||||
// Update JSON representation
|
||||
const ruleJson = JSON.stringify(payload, null, 2);
|
||||
updateRuleState('rule', ruleJson);
|
||||
}, []);
|
||||
|
||||
const onAdvancedChange = (keys: string | string[]) => {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
setShowAdvanced(keyArray);
|
||||
|
||||
// When opening advanced, sync JSON from permissions
|
||||
if (keyArray.includes('advanced')) {
|
||||
const ruleJson = JSON.stringify(permissionsPayload, null, 2);
|
||||
updateRuleState('rule', ruleJson);
|
||||
}
|
||||
// When closing advanced, sync permissions from JSON
|
||||
if (!keyArray.includes('advanced') && showAdvanced.includes('advanced')) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentRule.rule);
|
||||
setPermissionsPayload({
|
||||
allowed: parsed.allowed || [],
|
||||
denied: parsed.denied || [],
|
||||
});
|
||||
} catch {
|
||||
// Keep existing payload if JSON is invalid
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearError();
|
||||
setCurrentRule({ ...DEFAULT_RULE });
|
||||
setSelectedRole(null);
|
||||
setJsonError(null);
|
||||
setPermissionsPayload({ allowed: [], denied: [] });
|
||||
setShowAdvanced([]);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const data = {
|
||||
name: currentRule.name || null,
|
||||
description: currentRule.description || null,
|
||||
role_id: selectedRole?.value,
|
||||
rule: currentRule.rule,
|
||||
};
|
||||
|
||||
if (isEditMode && currentRule.id) {
|
||||
updateResource(currentRule.id, data).then(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
addSuccessToast(t('Rule updated'));
|
||||
hide();
|
||||
});
|
||||
} else {
|
||||
createResource(data).then(response => {
|
||||
if (!response) return;
|
||||
addSuccessToast(t('Rule added'));
|
||||
hide();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoleOptions = useMemo(
|
||||
() =>
|
||||
(input = '', page: number, pageSize: number) => {
|
||||
const query = rison.encode({
|
||||
filter: input,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/dar/related/role?q=${query}`,
|
||||
}).then(response => {
|
||||
const list = response.json.result.map(
|
||||
(item: { value: number; text: string }) => ({
|
||||
label: item.text,
|
||||
value: item.value,
|
||||
}),
|
||||
);
|
||||
return { data: list, totalCount: response.json.count };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className="no-content-padding"
|
||||
responsive
|
||||
show={show}
|
||||
onHide={hide}
|
||||
primaryButtonName={isEditMode ? t('Save') : t('Add')}
|
||||
disablePrimaryButton={disableSave}
|
||||
onHandledPrimaryAction={onSave}
|
||||
width="50%"
|
||||
maxWidth="800px"
|
||||
title={
|
||||
<ModalTitleWithIcon
|
||||
isEditMode={isEditMode}
|
||||
title={isEditMode ? t('Edit Data Access Rule') : t('Add Data Access Rule')}
|
||||
data-test="dar-modal-title"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<StyledSectionContainer>
|
||||
<div className="main-section">
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Role')} <span className="required">*</span>
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'Select the role this rule applies to. Each role can have multiple data access rules.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Role')}
|
||||
onChange={onRoleChange}
|
||||
value={selectedRole}
|
||||
options={loadRoleOptions}
|
||||
data-test="role-select"
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Name')}
|
||||
<InfoTooltip
|
||||
tooltip={t('Optional name to help identify this rule.')}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<Input
|
||||
name="name"
|
||||
value={currentRule.name || ''}
|
||||
onChange={e => updateRuleState('name', e.target.value)}
|
||||
placeholder={t('e.g., Sales team access')}
|
||||
data-test="rule-name"
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Description')}
|
||||
<InfoTooltip
|
||||
tooltip={t('Optional description of what this rule grants or restricts.')}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<Input.TextArea
|
||||
name="description"
|
||||
value={currentRule.description || ''}
|
||||
onChange={e => updateRuleState('description', e.target.value)}
|
||||
placeholder={t('Describe the purpose of this rule...')}
|
||||
rows={2}
|
||||
data-test="rule-description"
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Table Permissions')}
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'Select databases, schemas, and tables to allow or deny access. Click the icons to toggle between Allow (green), Deny (red), and Inherit (gray).',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<PermissionsTree
|
||||
value={permissionsPayload}
|
||||
onChange={onPermissionsChange}
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledCollapse
|
||||
activeKey={showAdvanced}
|
||||
onChange={onAdvancedChange}
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('Advanced: Edit JSON directly'),
|
||||
children: (
|
||||
<>
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Rule (JSON)')}
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
`Define the access rule as a JSON document with "allowed" and "denied" arrays. Each entry specifies database, catalog, schema, table, and optional RLS/CLS configurations.`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<StyledTextArea
|
||||
rows={15}
|
||||
name="rule"
|
||||
value={currentRule.rule}
|
||||
onChange={onRuleChange}
|
||||
status={jsonError ? 'error' : undefined}
|
||||
data-test="rule-json"
|
||||
/>
|
||||
</div>
|
||||
{jsonError && (
|
||||
<div style={{ color: 'red', marginTop: '4px' }}>
|
||||
{jsonError}
|
||||
</div>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</StyledSectionContainer>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataAccessRuleModal;
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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 { SupersetClient } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
|
||||
export interface DatabaseInfo {
|
||||
id: number;
|
||||
database_name: string;
|
||||
}
|
||||
|
||||
export interface FetchResult<T> {
|
||||
result: T[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function fetchDatabases(): Promise<DatabaseInfo[]> {
|
||||
const query = rison.encode({
|
||||
columns: ['id', 'database_name'],
|
||||
page_size: 1000,
|
||||
});
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/database/?q=${query}`,
|
||||
});
|
||||
return response.json.result;
|
||||
}
|
||||
|
||||
export async function fetchCatalogs(
|
||||
databaseId: number,
|
||||
filter?: string,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<FetchResult<string>> {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (filter) params.filter = filter;
|
||||
if (page !== undefined) params.page = page;
|
||||
if (pageSize !== undefined) params.page_size = pageSize;
|
||||
|
||||
const query = rison.encode(params);
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/database/${databaseId}/catalogs/?q=${query}`,
|
||||
});
|
||||
return {
|
||||
result: response.json.result,
|
||||
count: response.json.count ?? response.json.result.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSchemas(
|
||||
databaseId: number,
|
||||
catalog?: string,
|
||||
filter?: string,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<FetchResult<string>> {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (catalog) params.catalog = catalog;
|
||||
if (filter) params.filter = filter;
|
||||
if (page !== undefined) params.page = page;
|
||||
if (pageSize !== undefined) params.page_size = pageSize;
|
||||
|
||||
const query = rison.encode(params);
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/database/${databaseId}/schemas/?q=${query}`,
|
||||
});
|
||||
return {
|
||||
result: response.json.result,
|
||||
count: response.json.count ?? response.json.result.length,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
value: string;
|
||||
type: 'table' | 'view' | 'materialized_view';
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchTables(
|
||||
databaseId: number,
|
||||
schemaName: string,
|
||||
catalogName?: string,
|
||||
filter?: string,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<FetchResult<TableInfo>> {
|
||||
const params: Record<string, unknown> = {
|
||||
schema_name: schemaName,
|
||||
};
|
||||
if (catalogName) params.catalog_name = catalogName;
|
||||
if (filter) params.filter = filter;
|
||||
if (page !== undefined) params.page = page;
|
||||
if (pageSize !== undefined) params.page_size = pageSize;
|
||||
|
||||
const query = rison.encode(params);
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/database/${databaseId}/tables/?q=${query}`,
|
||||
});
|
||||
return {
|
||||
result: response.json.result,
|
||||
count: response.json.count,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable?: boolean;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export async function fetchColumns(
|
||||
databaseId: number,
|
||||
tableName: string,
|
||||
schemaName?: string,
|
||||
catalogName?: string,
|
||||
): Promise<ColumnInfo[]> {
|
||||
const params: Record<string, string> = {
|
||||
name: tableName,
|
||||
};
|
||||
if (schemaName) params.schema = schemaName;
|
||||
if (catalogName) params.catalog = catalogName;
|
||||
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/database/${databaseId}/table_metadata/?${queryString}`,
|
||||
});
|
||||
return response.json.columns || [];
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { css, styled } from '@apache-superset/core/ui';
|
||||
import { Tree, Tooltip, Spin, Button, Select, Input } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
MinusCircleOutlined,
|
||||
DatabaseOutlined,
|
||||
FolderOutlined,
|
||||
TableOutlined,
|
||||
ColumnHeightOutlined,
|
||||
NumberOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
StopOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { TreeProps } from 'antd';
|
||||
import type {
|
||||
PermissionNode,
|
||||
TreeState,
|
||||
PermissionsPayload,
|
||||
CLSAction,
|
||||
RLSRule,
|
||||
} from './types';
|
||||
import {
|
||||
fetchDatabases,
|
||||
fetchCatalogs,
|
||||
fetchSchemas,
|
||||
fetchTables,
|
||||
fetchColumns,
|
||||
type DatabaseInfo,
|
||||
} from './api';
|
||||
import {
|
||||
makeKey,
|
||||
makeClsKey,
|
||||
getEffectiveState,
|
||||
getExplicitState,
|
||||
cyclePermissionState,
|
||||
updateTreeData,
|
||||
generatePermissionsPayload,
|
||||
loadPermissionsFromPayload,
|
||||
countDescendantPermissions,
|
||||
} from './utils';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
.search-input {
|
||||
margin-bottom: ${theme.sizeUnit * 2}px;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
max-height: 500px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ant-tree-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.permission-icon-allow {
|
||||
color: ${theme.colorSuccess};
|
||||
}
|
||||
|
||||
.permission-icon-deny {
|
||||
color: ${theme.colorError};
|
||||
}
|
||||
|
||||
.permission-icon-inherit {
|
||||
color: ${theme.colorTextSecondary};
|
||||
}
|
||||
|
||||
.node-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
margin-left: ${theme.sizeUnit * 3}px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-count {
|
||||
font-size: 11px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
margin-left: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
.cls-select {
|
||||
width: 90px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.column-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.column-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
.column-type {
|
||||
font-size: 11px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
}
|
||||
|
||||
.rls-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
padding: ${theme.sizeUnit}px 0;
|
||||
margin-bottom: ${theme.sizeUnit}px;
|
||||
border-bottom: 1px dashed ${theme.colorBorder};
|
||||
}
|
||||
|
||||
.rls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
}
|
||||
|
||||
.rls-label {
|
||||
font-size: 11px;
|
||||
color: ${theme.colorTextSecondary};
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.rls-input {
|
||||
flex: 1;
|
||||
max-width: 250px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.count-allowed {
|
||||
color: ${theme.colorSuccess};
|
||||
font-weight: ${theme.fontWeightSemiBold};
|
||||
}
|
||||
|
||||
.count-denied {
|
||||
color: ${theme.colorError};
|
||||
font-weight: ${theme.fontWeightSemiBold};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export interface PermissionsTreeProps {
|
||||
value?: PermissionsPayload;
|
||||
onChange?: (payload: PermissionsPayload) => void;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
function PermissionsTree({
|
||||
value,
|
||||
onChange,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
}: PermissionsTreeProps) {
|
||||
const [treeState, setTreeState] = useState<TreeState>({
|
||||
expandedKeys: [],
|
||||
loadedKeys: [],
|
||||
treeData: [],
|
||||
permissionStates: {},
|
||||
clsRules: {},
|
||||
rlsRules: {},
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [databases, setDatabases] = useState<DatabaseInfo[]>([]);
|
||||
|
||||
// Map of database ID -> name and name -> ID
|
||||
const databaseMaps = useMemo(() => {
|
||||
const idToName = new Map<number, string>();
|
||||
const nameToId = new Map<string, number>();
|
||||
databases.forEach(db => {
|
||||
idToName.set(db.id, db.database_name);
|
||||
nameToId.set(db.database_name, db.id);
|
||||
});
|
||||
return { idToName, nameToId };
|
||||
}, [databases]);
|
||||
|
||||
// Track if we're in the middle of an internal update to avoid circular updates
|
||||
const isInternalUpdateRef = useRef(false);
|
||||
|
||||
// Load initial databases
|
||||
useEffect(() => {
|
||||
loadDatabases();
|
||||
}, []);
|
||||
|
||||
// Load initial value (only on mount or when value changes externally)
|
||||
useEffect(() => {
|
||||
if (isInternalUpdateRef.current) {
|
||||
isInternalUpdateRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (value && databaseMaps.nameToId.size > 0) {
|
||||
const { states, clsRules, rlsRules } = loadPermissionsFromPayload(
|
||||
value,
|
||||
databaseMaps.nameToId,
|
||||
);
|
||||
setTreeState(prev => ({
|
||||
...prev,
|
||||
permissionStates: states,
|
||||
clsRules,
|
||||
rlsRules,
|
||||
}));
|
||||
}
|
||||
}, [value, databaseMaps.nameToId]);
|
||||
|
||||
const loadDatabases = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const dbs = await fetchDatabases();
|
||||
setDatabases(dbs);
|
||||
|
||||
const treeData: PermissionNode[] = dbs.map(db => ({
|
||||
key: makeKey({ databaseId: db.id }),
|
||||
title: db.database_name,
|
||||
nodeType: 'database',
|
||||
databaseId: db.id,
|
||||
databaseName: db.database_name,
|
||||
children: [],
|
||||
isLeaf: false,
|
||||
}));
|
||||
|
||||
setTreeState(prev => ({
|
||||
...prev,
|
||||
treeData,
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadChildren = useCallback(
|
||||
async (node: PermissionNode): Promise<PermissionNode[]> => {
|
||||
const { databaseId, catalogName, schemaName, nodeType } = node;
|
||||
|
||||
if (!databaseId) return [];
|
||||
|
||||
// Database level -> load catalogs or schemas
|
||||
if (nodeType === 'database') {
|
||||
// First try to load catalogs
|
||||
try {
|
||||
const catalogs = await fetchCatalogs(databaseId, undefined, 0, 1);
|
||||
if (catalogs.result.length > 0) {
|
||||
// Database has catalogs
|
||||
const catalogsResult = await fetchCatalogs(
|
||||
databaseId,
|
||||
undefined,
|
||||
0,
|
||||
pageSize,
|
||||
);
|
||||
return catalogsResult.result.map(cat => ({
|
||||
key: makeKey({ databaseId, catalogName: cat }),
|
||||
title: cat,
|
||||
nodeType: 'catalog' as const,
|
||||
databaseId,
|
||||
databaseName: node.databaseName,
|
||||
catalogName: cat,
|
||||
children: [],
|
||||
isLeaf: false,
|
||||
hasMore: catalogsResult.count > pageSize,
|
||||
totalCount: catalogsResult.count,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Database doesn't support catalogs, load schemas directly
|
||||
}
|
||||
|
||||
// Load schemas directly
|
||||
const schemasResult = await fetchSchemas(
|
||||
databaseId,
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
pageSize,
|
||||
);
|
||||
return schemasResult.result.map(schema => ({
|
||||
key: makeKey({ databaseId, schemaName: schema }),
|
||||
title: schema,
|
||||
nodeType: 'schema' as const,
|
||||
databaseId,
|
||||
databaseName: node.databaseName,
|
||||
schemaName: schema,
|
||||
children: [],
|
||||
isLeaf: false,
|
||||
hasMore: schemasResult.count > pageSize,
|
||||
totalCount: schemasResult.count,
|
||||
}));
|
||||
}
|
||||
|
||||
// Catalog level -> load schemas
|
||||
if (nodeType === 'catalog' && catalogName) {
|
||||
const schemasResult = await fetchSchemas(
|
||||
databaseId,
|
||||
catalogName,
|
||||
undefined,
|
||||
0,
|
||||
pageSize,
|
||||
);
|
||||
return schemasResult.result.map(schema => ({
|
||||
key: makeKey({ databaseId, catalogName, schemaName: schema }),
|
||||
title: schema,
|
||||
nodeType: 'schema' as const,
|
||||
databaseId,
|
||||
databaseName: node.databaseName,
|
||||
catalogName,
|
||||
schemaName: schema,
|
||||
children: [],
|
||||
isLeaf: false,
|
||||
hasMore: schemasResult.count > pageSize,
|
||||
totalCount: schemasResult.count,
|
||||
}));
|
||||
}
|
||||
|
||||
// Schema level -> load tables
|
||||
if (nodeType === 'schema' && schemaName) {
|
||||
const tablesResult = await fetchTables(
|
||||
databaseId,
|
||||
schemaName,
|
||||
catalogName,
|
||||
undefined,
|
||||
0,
|
||||
pageSize,
|
||||
);
|
||||
return tablesResult.result.map(table => ({
|
||||
key: makeKey({
|
||||
databaseId,
|
||||
catalogName,
|
||||
schemaName,
|
||||
tableName: table.value,
|
||||
}),
|
||||
title: table.value,
|
||||
nodeType: 'table' as const,
|
||||
databaseId,
|
||||
databaseName: node.databaseName,
|
||||
catalogName,
|
||||
schemaName,
|
||||
tableName: table.value,
|
||||
isLeaf: false, // Tables are expandable to show columns
|
||||
children: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Table level -> load columns
|
||||
if (nodeType === 'table' && node.tableName) {
|
||||
const columns = await fetchColumns(
|
||||
databaseId,
|
||||
node.tableName,
|
||||
schemaName,
|
||||
catalogName,
|
||||
);
|
||||
const tableKey = node.key as string;
|
||||
return columns.map(col => ({
|
||||
key: `${tableKey}|col:${col.name}`,
|
||||
title: col.name,
|
||||
nodeType: 'column' as const,
|
||||
databaseId,
|
||||
databaseName: node.databaseName,
|
||||
catalogName,
|
||||
schemaName,
|
||||
tableName: node.tableName,
|
||||
columnName: col.name,
|
||||
isLeaf: true,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
[pageSize],
|
||||
);
|
||||
|
||||
const onLoadData = async (node: PermissionNode): Promise<void> => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const children = await loadChildren(node);
|
||||
const updatedTreeData = updateTreeData(
|
||||
treeState.treeData,
|
||||
node.key as string,
|
||||
children,
|
||||
);
|
||||
|
||||
setTreeState(prev => ({
|
||||
...prev,
|
||||
treeData: updatedTreeData,
|
||||
loadedKeys: [...prev.loadedKeys, node.key as string],
|
||||
}));
|
||||
};
|
||||
|
||||
const onExpand: TreeProps['onExpand'] = expandedKeysValue => {
|
||||
setTreeState(prev => ({
|
||||
...prev,
|
||||
expandedKeys: expandedKeysValue as string[],
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePermissionClick = (
|
||||
nodeKey: string,
|
||||
event: React.MouseEvent,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
const newStates = cyclePermissionState(nodeKey, treeState.permissionStates);
|
||||
|
||||
// Mark as internal update to prevent circular updates
|
||||
isInternalUpdateRef.current = true;
|
||||
|
||||
setTreeState(prev => ({
|
||||
...prev,
|
||||
permissionStates: newStates,
|
||||
}));
|
||||
|
||||
// Notify parent of changes
|
||||
if (onChange && databaseMaps.idToName.size > 0) {
|
||||
const payload = generatePermissionsPayload(
|
||||
newStates,
|
||||
databaseMaps.idToName,
|
||||
treeState.clsRules,
|
||||
treeState.rlsRules,
|
||||
);
|
||||
onChange(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRlsChange = (
|
||||
tableKey: string,
|
||||
field: 'predicate' | 'groupKey',
|
||||
value: string,
|
||||
) => {
|
||||
// Mark as internal update to prevent circular updates
|
||||
isInternalUpdateRef.current = true;
|
||||
|
||||
setTreeState(prev => {
|
||||
const currentRls = prev.rlsRules[tableKey] || { predicate: '' };
|
||||
const newRls: RLSRule = { ...currentRls, [field]: value };
|
||||
|
||||
// Remove the rule if both fields are empty
|
||||
const newRlsRules = { ...prev.rlsRules };
|
||||
if (!newRls.predicate && !newRls.groupKey) {
|
||||
delete newRlsRules[tableKey];
|
||||
} else {
|
||||
newRlsRules[tableKey] = newRls;
|
||||
}
|
||||
|
||||
// Notify parent of changes
|
||||
if (onChange && databaseMaps.idToName.size > 0) {
|
||||
const payload = generatePermissionsPayload(
|
||||
prev.permissionStates,
|
||||
databaseMaps.idToName,
|
||||
prev.clsRules,
|
||||
newRlsRules,
|
||||
);
|
||||
onChange(payload);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
rlsRules: newRlsRules,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleClsChange = (
|
||||
tableKey: string,
|
||||
columnName: string,
|
||||
action: CLSAction | undefined,
|
||||
) => {
|
||||
const clsKey = makeClsKey(tableKey, columnName);
|
||||
|
||||
// Mark as internal update to prevent circular updates
|
||||
isInternalUpdateRef.current = true;
|
||||
|
||||
setTreeState(prev => {
|
||||
const newClsRules = { ...prev.clsRules };
|
||||
if (action) {
|
||||
newClsRules[clsKey] = action;
|
||||
} else {
|
||||
delete newClsRules[clsKey];
|
||||
}
|
||||
|
||||
// Notify parent of changes
|
||||
if (onChange && databaseMaps.idToName.size > 0) {
|
||||
const payload = generatePermissionsPayload(
|
||||
prev.permissionStates,
|
||||
databaseMaps.idToName,
|
||||
newClsRules,
|
||||
prev.rlsRules,
|
||||
);
|
||||
onChange(payload);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
clsRules: newClsRules,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getStateIcon = (nodeKey: string) => {
|
||||
const explicitState = getExplicitState(nodeKey, treeState.permissionStates);
|
||||
const effectiveState = getEffectiveState(
|
||||
nodeKey,
|
||||
treeState.permissionStates,
|
||||
);
|
||||
|
||||
const getTooltipText = () => {
|
||||
if (explicitState === 'allow') return t('Allowed (click to deny)');
|
||||
if (explicitState === 'deny') return t('Denied (click to allow)');
|
||||
if (effectiveState === 'allow')
|
||||
return t('Inherits Allow (click to deny)');
|
||||
return t('Inherits Deny (click to allow)');
|
||||
};
|
||||
|
||||
if (explicitState === 'allow') {
|
||||
return (
|
||||
<Tooltip title={getTooltipText()}>
|
||||
<CheckCircleOutlined
|
||||
className="permission-icon permission-icon-allow"
|
||||
onClick={e => handlePermissionClick(nodeKey, e)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (explicitState === 'deny') {
|
||||
return (
|
||||
<Tooltip title={getTooltipText()}>
|
||||
<CloseCircleOutlined
|
||||
className="permission-icon permission-icon-deny"
|
||||
onClick={e => handlePermissionClick(nodeKey, e)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip title={getTooltipText()}>
|
||||
<MinusCircleOutlined
|
||||
className="permission-icon permission-icon-inherit"
|
||||
onClick={e => handlePermissionClick(nodeKey, e)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const getNodeIcon = (nodeType: string) => {
|
||||
switch (nodeType) {
|
||||
case 'database':
|
||||
return <DatabaseOutlined />;
|
||||
case 'catalog':
|
||||
case 'schema':
|
||||
return <FolderOutlined />;
|
||||
case 'table':
|
||||
return <TableOutlined />;
|
||||
case 'column':
|
||||
return <ColumnHeightOutlined />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clsOptions = [
|
||||
{ value: '', label: ' ' },
|
||||
{
|
||||
value: 'hash',
|
||||
label: (
|
||||
<span>
|
||||
<NumberOutlined /> {t('Hash')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'mask',
|
||||
label: (
|
||||
<span>
|
||||
<StarOutlined /> {t('Mask')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'nullify',
|
||||
label: (
|
||||
<span>
|
||||
<StopOutlined /> {t('Null')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'hide',
|
||||
label: (
|
||||
<span>
|
||||
<EyeInvisibleOutlined /> {t('Hide')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Count CLS rules for a given table
|
||||
const countClsRules = (tableKey: string): number => {
|
||||
const prefix = `${tableKey}::`;
|
||||
return Object.keys(treeState.clsRules).filter(key => key.startsWith(prefix)).length;
|
||||
};
|
||||
|
||||
// Check if a table has RLS rules
|
||||
const hasRlsRules = (tableKey: string): boolean => {
|
||||
const rls = treeState.rlsRules[tableKey];
|
||||
return !!(rls && (rls.predicate || rls.groupKey));
|
||||
};
|
||||
|
||||
const titleRender = (node: PermissionNode) => {
|
||||
const nodeKey = node.key as string;
|
||||
const isExpanded = treeState.expandedKeys.includes(nodeKey);
|
||||
const title = node.title as string;
|
||||
|
||||
// Column nodes get special rendering with CLS dropdown
|
||||
if (node.nodeType === 'column' && node.tableName && node.columnName) {
|
||||
// Get the table key by removing the column part
|
||||
const tableKey = nodeKey.substring(0, nodeKey.lastIndexOf('|col:'));
|
||||
const clsKey = makeClsKey(tableKey, node.columnName);
|
||||
const currentAction = treeState.clsRules[clsKey] || '';
|
||||
|
||||
return (
|
||||
<div className="column-node">
|
||||
<span className="column-name">
|
||||
{getNodeIcon(node.nodeType)}
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<Select
|
||||
className="cls-select"
|
||||
size="small"
|
||||
value={currentAction}
|
||||
onChange={(val: string) =>
|
||||
handleClsChange(
|
||||
tableKey,
|
||||
node.columnName as string,
|
||||
(val || undefined) as CLSAction | undefined,
|
||||
)
|
||||
}
|
||||
onClick={e => e.stopPropagation()}
|
||||
options={clsOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Table nodes get RLS inputs when expanded
|
||||
if (node.nodeType === 'table' && isExpanded) {
|
||||
const currentRls = treeState.rlsRules[nodeKey] || {
|
||||
predicate: '',
|
||||
groupKey: '',
|
||||
};
|
||||
|
||||
// Check if table has permissions, CLS, or RLS
|
||||
const tableCounts = countDescendantPermissions(nodeKey, treeState.permissionStates);
|
||||
const tableExplicitState = getExplicitState(nodeKey, treeState.permissionStates);
|
||||
const clsCount = countClsRules(nodeKey);
|
||||
const hasRls = hasRlsRules(nodeKey);
|
||||
const tableHasConfig =
|
||||
tableExplicitState === 'allow' ||
|
||||
tableExplicitState === 'deny' ||
|
||||
(tableCounts && (tableCounts.allowed > 0 || tableCounts.denied > 0)) ||
|
||||
clsCount > 0 ||
|
||||
hasRls;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="node-title">
|
||||
{getStateIcon(nodeKey)}
|
||||
{getNodeIcon(node.nodeType)}
|
||||
<span style={tableHasConfig ? { fontWeight: 600 } : undefined}>{title}</span>
|
||||
{(hasRls || clsCount > 0) && (
|
||||
<span className="node-count">
|
||||
({[
|
||||
hasRls ? 'RLS' : null,
|
||||
clsCount > 0 ? `CLS: ${clsCount}` : null,
|
||||
].filter(Boolean).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rls-container">
|
||||
<div className="rls-row">
|
||||
<span className="rls-label">{t('RLS Predicate')}</span>
|
||||
<Input
|
||||
className="rls-input"
|
||||
size="small"
|
||||
placeholder={t('e.g., org_id = {{current_user_id}}')}
|
||||
value={currentRls.predicate}
|
||||
onChange={e => handleRlsChange(nodeKey, 'predicate', e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="rls-row">
|
||||
<span className="rls-label">{t('Group Key')}</span>
|
||||
<Input
|
||||
className="rls-input"
|
||||
size="small"
|
||||
placeholder={t('Optional grouping key')}
|
||||
value={currentRls.groupKey || ''}
|
||||
onChange={e => handleRlsChange(nodeKey, 'groupKey', e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-column nodes: check if leaf (columns are leafs, tables are not anymore)
|
||||
const isLeaf = node.nodeType === 'column';
|
||||
|
||||
// Count descendants with custom permissions
|
||||
const counts = !isLeaf
|
||||
? countDescendantPermissions(nodeKey, treeState.permissionStates)
|
||||
: null;
|
||||
const hasCustomRules = counts && (counts.allowed > 0 || counts.denied > 0);
|
||||
|
||||
// Check if this node itself has explicit permissions
|
||||
const explicitState = getExplicitState(nodeKey, treeState.permissionStates);
|
||||
const hasExplicitPermission = explicitState === 'allow' || explicitState === 'deny';
|
||||
|
||||
// For tables, also check CLS/RLS
|
||||
const isTable = node.nodeType === 'table';
|
||||
const clsCount = isTable ? countClsRules(nodeKey) : 0;
|
||||
const hasRls = isTable ? hasRlsRules(nodeKey) : false;
|
||||
|
||||
// Bold if node or any children have permissions, or if table has CLS/RLS
|
||||
const shouldBeBold = hasExplicitPermission || hasCustomRules || clsCount > 0 || hasRls;
|
||||
|
||||
return (
|
||||
<div className="node-title">
|
||||
{getStateIcon(nodeKey)}
|
||||
{getNodeIcon(node.nodeType)}
|
||||
<span style={shouldBeBold ? { fontWeight: 600 } : undefined}>{title}</span>
|
||||
{hasCustomRules && !isExpanded && (
|
||||
<span className="node-count">
|
||||
(
|
||||
<span className="count-allowed">{counts.allowed}</span>
|
||||
{' / '}
|
||||
<span className="count-denied">{counts.denied}</span>
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
{isTable && (hasRls || clsCount > 0) && !isExpanded && (
|
||||
<span className="node-count">
|
||||
({[
|
||||
hasRls ? 'RLS' : null,
|
||||
clsCount > 0 ? `CLS: ${clsCount}` : null,
|
||||
].filter(Boolean).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
{node.hasMore && node.totalCount && (
|
||||
<span className="node-count" style={{ marginLeft: 4 }}>
|
||||
[{node.children?.length || 0}/{node.totalCount}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
isInternalUpdateRef.current = true;
|
||||
setTreeState(prev => ({
|
||||
...prev,
|
||||
permissionStates: {},
|
||||
clsRules: {},
|
||||
rlsRules: {},
|
||||
}));
|
||||
if (onChange) {
|
||||
onChange({ allowed: [], denied: [] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Button size="small" onClick={clearAll}>
|
||||
{t('Reset All')}
|
||||
</Button>
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
<div className="tree-container">
|
||||
<Tree
|
||||
loadData={onLoadData as TreeProps['loadData']}
|
||||
treeData={treeState.treeData}
|
||||
expandedKeys={treeState.expandedKeys}
|
||||
onExpand={onExpand}
|
||||
titleRender={titleRender as TreeProps['titleRender']}
|
||||
showLine={{ showLeafIcon: false }}
|
||||
blockNode
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionsTree;
|
||||
@@ -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 type { DataNode } from 'antd/es/tree';
|
||||
|
||||
export type PermissionState = 'allow' | 'deny' | 'inherit';
|
||||
|
||||
export type NodeType = 'database' | 'catalog' | 'schema' | 'table' | 'column';
|
||||
|
||||
export type CLSAction = 'hash' | 'mask' | 'nullify' | 'hide';
|
||||
|
||||
export interface PermissionNode extends DataNode {
|
||||
key: string;
|
||||
title: string;
|
||||
nodeType: NodeType;
|
||||
databaseId?: number;
|
||||
databaseName?: string;
|
||||
catalogName?: string;
|
||||
schemaName?: string;
|
||||
tableName?: string;
|
||||
columnName?: string;
|
||||
children?: PermissionNode[];
|
||||
isLeaf?: boolean;
|
||||
hasMore?: boolean;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export interface RLSRule {
|
||||
predicate: string;
|
||||
groupKey?: string;
|
||||
}
|
||||
|
||||
export interface TreeState {
|
||||
expandedKeys: string[];
|
||||
loadedKeys: string[];
|
||||
treeData: PermissionNode[];
|
||||
permissionStates: Record<string, PermissionState>;
|
||||
clsRules: Record<string, CLSAction>; // key format: "tableKey::columnName" -> action
|
||||
rlsRules: Record<string, RLSRule>; // key format: tableKey -> RLSRule
|
||||
}
|
||||
|
||||
export interface PermissionsPayload {
|
||||
allowed: PermissionEntry[];
|
||||
denied: PermissionEntry[];
|
||||
}
|
||||
|
||||
export interface PermissionEntry {
|
||||
database: string;
|
||||
catalog?: string;
|
||||
schema?: string;
|
||||
table?: string;
|
||||
rls?: {
|
||||
predicate: string;
|
||||
group_key?: string;
|
||||
};
|
||||
cls?: Record<string, string>; // column name -> action
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* 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 {
|
||||
PermissionNode,
|
||||
PermissionState,
|
||||
PermissionsPayload,
|
||||
PermissionEntry,
|
||||
NodeType,
|
||||
CLSAction,
|
||||
RLSRule,
|
||||
} from './types';
|
||||
|
||||
// Key format: db:{id}|cat:{name}|schema:{name}|table:{name}
|
||||
// Each part is optional based on the level
|
||||
|
||||
export function makeKey(parts: {
|
||||
databaseId: number;
|
||||
catalogName?: string;
|
||||
schemaName?: string;
|
||||
tableName?: string;
|
||||
}): string {
|
||||
let key = `db:${parts.databaseId}`;
|
||||
if (parts.catalogName) {
|
||||
key += `|cat:${parts.catalogName}`;
|
||||
}
|
||||
if (parts.schemaName) {
|
||||
key += `|schema:${parts.schemaName}`;
|
||||
}
|
||||
if (parts.tableName) {
|
||||
key += `|table:${parts.tableName}`;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function parseKey(key: string): {
|
||||
databaseId: number;
|
||||
catalogName?: string;
|
||||
schemaName?: string;
|
||||
tableName?: string;
|
||||
nodeType: NodeType;
|
||||
} {
|
||||
const parts = key.split('|');
|
||||
const result: ReturnType<typeof parseKey> = {
|
||||
databaseId: 0,
|
||||
nodeType: 'database',
|
||||
};
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('db:')) {
|
||||
result.databaseId = parseInt(part.slice(3), 10);
|
||||
} else if (part.startsWith('cat:')) {
|
||||
result.catalogName = part.slice(4);
|
||||
result.nodeType = 'catalog';
|
||||
} else if (part.startsWith('schema:')) {
|
||||
result.schemaName = part.slice(7);
|
||||
result.nodeType = 'schema';
|
||||
} else if (part.startsWith('table:')) {
|
||||
result.tableName = part.slice(6);
|
||||
result.nodeType = 'table';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getParentKey(key: string): string | null {
|
||||
const parts = key.split('|');
|
||||
if (parts.length <= 1) return null;
|
||||
return parts.slice(0, -1).join('|');
|
||||
}
|
||||
|
||||
export function getEffectiveState(
|
||||
nodeKey: string,
|
||||
permissionStates: Record<string, PermissionState>,
|
||||
): PermissionState {
|
||||
// Check if node has explicit state
|
||||
const explicitState = permissionStates[nodeKey];
|
||||
if (explicitState && explicitState !== 'inherit') {
|
||||
return explicitState;
|
||||
}
|
||||
|
||||
// Walk up ancestors to find effective state
|
||||
let currentKey: string | null = nodeKey;
|
||||
while (currentKey) {
|
||||
const parentKey = getParentKey(currentKey);
|
||||
if (!parentKey) break;
|
||||
|
||||
const parentState = permissionStates[parentKey];
|
||||
if (parentState && parentState !== 'inherit') {
|
||||
return parentState;
|
||||
}
|
||||
currentKey = parentKey;
|
||||
}
|
||||
|
||||
// Default is deny (implicit)
|
||||
return 'deny';
|
||||
}
|
||||
|
||||
export function getExplicitState(
|
||||
nodeKey: string,
|
||||
permissionStates: Record<string, PermissionState>,
|
||||
): PermissionState {
|
||||
return permissionStates[nodeKey] || 'inherit';
|
||||
}
|
||||
|
||||
export function cyclePermissionState(
|
||||
nodeKey: string,
|
||||
permissionStates: Record<string, PermissionState>,
|
||||
): Record<string, PermissionState> {
|
||||
const newStates = { ...permissionStates };
|
||||
const currentState = newStates[nodeKey] || 'inherit';
|
||||
const parentKey = getParentKey(nodeKey);
|
||||
|
||||
let nextState: PermissionState;
|
||||
|
||||
if (!parentKey) {
|
||||
// Root element (database) - only toggle between inherit and allow
|
||||
// (deny is the default state, so there's no point in explicitly denying)
|
||||
if (currentState === 'inherit') {
|
||||
nextState = 'allow';
|
||||
} else {
|
||||
nextState = 'inherit';
|
||||
}
|
||||
} else {
|
||||
// Child element - toggle between inherit and opposite of parent's effective state
|
||||
const parentEffective = getEffectiveState(parentKey, permissionStates);
|
||||
const oppositeState = parentEffective === 'allow' ? 'deny' : 'allow';
|
||||
|
||||
if (currentState === 'inherit') {
|
||||
nextState = oppositeState;
|
||||
} else {
|
||||
nextState = 'inherit';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the new state
|
||||
if (nextState === 'inherit') {
|
||||
delete newStates[nodeKey];
|
||||
} else {
|
||||
newStates[nodeKey] = nextState;
|
||||
}
|
||||
|
||||
// Clear all descendant states when setting a non-inherit state on a parent
|
||||
// This ensures children return to "inherit" when parent is set
|
||||
if (nextState !== 'inherit') {
|
||||
clearDescendantStates(nodeKey, newStates);
|
||||
}
|
||||
|
||||
return newStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all permission states for descendants of a given node key.
|
||||
*/
|
||||
function clearDescendantStates(
|
||||
parentKey: string,
|
||||
states: Record<string, PermissionState>,
|
||||
): void {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
Object.keys(states).forEach(key => {
|
||||
if (isDescendantOf(key, parentKey)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
keysToDelete.forEach(key => {
|
||||
delete states[key];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is a descendant of another key.
|
||||
*/
|
||||
function isDescendantOf(childKey: string, ancestorKey: string): boolean {
|
||||
// A key is a descendant if it starts with the ancestor key + '|'
|
||||
return childKey.startsWith(`${ancestorKey}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count children with explicit allow/deny states.
|
||||
* Returns counts for direct children only (not all descendants).
|
||||
*/
|
||||
export function countChildPermissions(
|
||||
parentKey: string,
|
||||
permissionStates: Record<string, PermissionState>,
|
||||
): { allowed: number; denied: number } {
|
||||
let allowed = 0;
|
||||
let denied = 0;
|
||||
|
||||
Object.entries(permissionStates).forEach(([key, state]) => {
|
||||
// Check if this is a direct child (parent key + one more segment)
|
||||
if (isDirectChildOf(key, parentKey)) {
|
||||
if (state === 'allow') {
|
||||
allowed += 1;
|
||||
} else if (state === 'deny') {
|
||||
denied += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { allowed, denied };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all descendants with explicit allow/deny states.
|
||||
*/
|
||||
export function countDescendantPermissions(
|
||||
parentKey: string,
|
||||
permissionStates: Record<string, PermissionState>,
|
||||
): { allowed: number; denied: number } {
|
||||
let allowed = 0;
|
||||
let denied = 0;
|
||||
|
||||
Object.entries(permissionStates).forEach(([key, state]) => {
|
||||
if (isDescendantOf(key, parentKey)) {
|
||||
if (state === 'allow') {
|
||||
allowed += 1;
|
||||
} else if (state === 'deny') {
|
||||
denied += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { allowed, denied };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key is a direct child of another key.
|
||||
*/
|
||||
function isDirectChildOf(childKey: string, parentKey: string): boolean {
|
||||
if (!childKey.startsWith(`${parentKey}|`)) {
|
||||
return false;
|
||||
}
|
||||
// Count the number of '|' segments after the parent
|
||||
const suffix = childKey.slice(parentKey.length + 1);
|
||||
return !suffix.includes('|');
|
||||
}
|
||||
|
||||
export function updateTreeData(
|
||||
list: PermissionNode[],
|
||||
key: string,
|
||||
children: PermissionNode[],
|
||||
): PermissionNode[] {
|
||||
return list.map(node => {
|
||||
if (node.key === key) {
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
};
|
||||
}
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: updateTreeData(node.children, key, children),
|
||||
};
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
export function findNodeByKey(
|
||||
data: PermissionNode[],
|
||||
key: string,
|
||||
): PermissionNode | null {
|
||||
for (const node of data) {
|
||||
if (node.key === key) return node;
|
||||
if (node.children) {
|
||||
const found = findNodeByKey(node.children, key);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a CLS key from a table key and column name.
|
||||
* Format: "{tableKey}::{columnName}"
|
||||
*/
|
||||
export function makeClsKey(tableKey: string, columnName: string): string {
|
||||
return `${tableKey}::${columnName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CLS key into table key and column name.
|
||||
*/
|
||||
export function parseClsKey(clsKey: string): {
|
||||
tableKey: string;
|
||||
columnName: string;
|
||||
} | null {
|
||||
const separatorIndex = clsKey.indexOf('::');
|
||||
if (separatorIndex === -1) return null;
|
||||
return {
|
||||
tableKey: clsKey.slice(0, separatorIndex),
|
||||
columnName: clsKey.slice(separatorIndex + 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLS rules for a specific table from the clsRules state.
|
||||
*/
|
||||
export function getTableClsRules(
|
||||
tableKey: string,
|
||||
clsRules: Record<string, CLSAction>,
|
||||
): Record<string, CLSAction> {
|
||||
const tableRules: Record<string, CLSAction> = {};
|
||||
const prefix = `${tableKey}::`;
|
||||
|
||||
Object.entries(clsRules).forEach(([key, action]) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
const columnName = key.slice(prefix.length);
|
||||
tableRules[columnName] = action;
|
||||
}
|
||||
});
|
||||
|
||||
return tableRules;
|
||||
}
|
||||
|
||||
export function generatePermissionsPayload(
|
||||
permissionStates: Record<string, PermissionState>,
|
||||
databases: Map<number, string>,
|
||||
clsRules: Record<string, CLSAction> = {},
|
||||
rlsRules: Record<string, RLSRule> = {},
|
||||
): PermissionsPayload {
|
||||
const allowed: PermissionEntry[] = [];
|
||||
const denied: PermissionEntry[] = [];
|
||||
|
||||
// Clean up states - remove redundant entries
|
||||
const cleanedStates = cleanupStates(permissionStates);
|
||||
|
||||
// Group CLS rules by table key
|
||||
const clsByTable: Record<string, Record<string, string>> = {};
|
||||
Object.entries(clsRules).forEach(([clsKey, action]) => {
|
||||
const parsed = parseClsKey(clsKey);
|
||||
if (!parsed) return;
|
||||
if (!clsByTable[parsed.tableKey]) {
|
||||
clsByTable[parsed.tableKey] = {};
|
||||
}
|
||||
clsByTable[parsed.tableKey][parsed.columnName] = action;
|
||||
});
|
||||
|
||||
Object.entries(cleanedStates).forEach(([key, state]) => {
|
||||
const parsed = parseKey(key);
|
||||
const databaseName = databases.get(parsed.databaseId);
|
||||
|
||||
if (!databaseName) return;
|
||||
|
||||
const entry: PermissionEntry = {
|
||||
database: databaseName,
|
||||
};
|
||||
|
||||
if (parsed.catalogName) entry.catalog = parsed.catalogName;
|
||||
if (parsed.schemaName) entry.schema = parsed.schemaName;
|
||||
if (parsed.tableName) entry.table = parsed.tableName;
|
||||
|
||||
// Add RLS rules if this is a table entry and has RLS rules
|
||||
if (parsed.tableName && rlsRules[key]) {
|
||||
const rls = rlsRules[key];
|
||||
if (rls.predicate) {
|
||||
entry.rls = {
|
||||
predicate: rls.predicate,
|
||||
};
|
||||
if (rls.groupKey) {
|
||||
entry.rls.group_key = rls.groupKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add CLS rules if this is a table entry and has CLS rules
|
||||
if (parsed.tableName && clsByTable[key]) {
|
||||
entry.cls = clsByTable[key];
|
||||
}
|
||||
|
||||
if (state === 'allow') {
|
||||
allowed.push(entry);
|
||||
} else if (state === 'deny') {
|
||||
denied.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Also add RLS/CLS rules for tables that don't have explicit permission states
|
||||
// but do have RLS or CLS rules (they inherit permission from parent)
|
||||
const tablesWithRules = new Set([
|
||||
...Object.keys(clsByTable),
|
||||
...Object.keys(rlsRules),
|
||||
]);
|
||||
|
||||
tablesWithRules.forEach(tableKey => {
|
||||
// Check if we already added this table
|
||||
const parsed = parseKey(tableKey);
|
||||
const databaseName = databases.get(parsed.databaseId);
|
||||
if (!databaseName) return;
|
||||
|
||||
// Only add if not already in allowed list
|
||||
const alreadyInAllowed = allowed.some(
|
||||
entry =>
|
||||
entry.database === databaseName &&
|
||||
entry.catalog === parsed.catalogName &&
|
||||
entry.schema === parsed.schemaName &&
|
||||
entry.table === parsed.tableName,
|
||||
);
|
||||
|
||||
const hasClsRules =
|
||||
clsByTable[tableKey] && Object.keys(clsByTable[tableKey]).length > 0;
|
||||
const hasRlsRules =
|
||||
rlsRules[tableKey] && rlsRules[tableKey].predicate;
|
||||
|
||||
if (!alreadyInAllowed && (hasClsRules || hasRlsRules)) {
|
||||
// Check if the table has effective allow state (inherited)
|
||||
const effectiveState = getEffectiveState(tableKey, permissionStates);
|
||||
if (effectiveState === 'allow') {
|
||||
const entry: PermissionEntry = {
|
||||
database: databaseName,
|
||||
table: parsed.tableName,
|
||||
};
|
||||
if (parsed.catalogName) entry.catalog = parsed.catalogName;
|
||||
if (parsed.schemaName) entry.schema = parsed.schemaName;
|
||||
|
||||
if (hasRlsRules) {
|
||||
const rls = rlsRules[tableKey];
|
||||
entry.rls = { predicate: rls.predicate };
|
||||
if (rls.groupKey) {
|
||||
entry.rls.group_key = rls.groupKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasClsRules) {
|
||||
entry.cls = clsByTable[tableKey];
|
||||
}
|
||||
|
||||
allowed.push(entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { allowed, denied };
|
||||
}
|
||||
|
||||
function cleanupStates(
|
||||
states: Record<string, PermissionState>,
|
||||
): Record<string, PermissionState> {
|
||||
const cleaned: Record<string, PermissionState> = {};
|
||||
|
||||
Object.entries(states).forEach(([key, state]) => {
|
||||
if (state === 'inherit') return;
|
||||
|
||||
// Check ancestor state
|
||||
let ancestorState: PermissionState | null = null;
|
||||
let currentKey: string | null = key;
|
||||
while (currentKey) {
|
||||
const parentKey = getParentKey(currentKey);
|
||||
if (!parentKey) break;
|
||||
if (states[parentKey] && states[parentKey] !== 'inherit') {
|
||||
ancestorState = states[parentKey];
|
||||
break;
|
||||
}
|
||||
currentKey = parentKey;
|
||||
}
|
||||
|
||||
// Only keep state if it differs from ancestor
|
||||
if (ancestorState) {
|
||||
if (ancestorState !== state) {
|
||||
cleaned[key] = state;
|
||||
}
|
||||
} else {
|
||||
// No ancestor - only keep 'allow' (deny is default)
|
||||
if (state === 'allow') {
|
||||
cleaned[key] = state;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function loadPermissionsFromPayload(
|
||||
payload: PermissionsPayload,
|
||||
databases: Map<string, number>,
|
||||
): {
|
||||
states: Record<string, PermissionState>;
|
||||
clsRules: Record<string, CLSAction>;
|
||||
rlsRules: Record<string, RLSRule>;
|
||||
} {
|
||||
const states: Record<string, PermissionState> = {};
|
||||
const clsRules: Record<string, CLSAction> = {};
|
||||
const rlsRules: Record<string, RLSRule> = {};
|
||||
|
||||
payload.allowed.forEach(entry => {
|
||||
const databaseId = databases.get(entry.database);
|
||||
if (databaseId === undefined) return;
|
||||
|
||||
const key = makeKey({
|
||||
databaseId,
|
||||
catalogName: entry.catalog,
|
||||
schemaName: entry.schema,
|
||||
tableName: entry.table,
|
||||
});
|
||||
states[key] = 'allow';
|
||||
|
||||
// Load RLS rules if present
|
||||
if (entry.rls && entry.table) {
|
||||
rlsRules[key] = {
|
||||
predicate: entry.rls.predicate,
|
||||
groupKey: entry.rls.group_key,
|
||||
};
|
||||
}
|
||||
|
||||
// Load CLS rules if present
|
||||
if (entry.cls && entry.table) {
|
||||
Object.entries(entry.cls).forEach(([columnName, action]) => {
|
||||
const clsKey = makeClsKey(key, columnName);
|
||||
clsRules[clsKey] = action as CLSAction;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
payload.denied.forEach(entry => {
|
||||
const databaseId = databases.get(entry.database);
|
||||
if (databaseId === undefined) return;
|
||||
|
||||
const key = makeKey({
|
||||
databaseId,
|
||||
catalogName: entry.catalog,
|
||||
schemaName: entry.schema,
|
||||
tableName: entry.table,
|
||||
});
|
||||
states[key] = 'deny';
|
||||
});
|
||||
|
||||
return { states, clsRules, rlsRules };
|
||||
}
|
||||
38
superset-frontend/src/features/dataAccessRules/types.ts
Normal file
38
superset-frontend/src/features/dataAccessRules/types.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.
|
||||
*/
|
||||
|
||||
export type RoleObject = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DataAccessRuleObject = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
role_id: number;
|
||||
role?: RoleObject;
|
||||
rule: string;
|
||||
changed_on_delta_humanized?: string;
|
||||
changed_by?: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
407
superset-frontend/src/pages/DataAccessRulesList/index.tsx
Normal file
407
superset-frontend/src/pages/DataAccessRulesList/index.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 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 { t, SupersetClient } from '@superset-ui/core';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components';
|
||||
import {
|
||||
ModifiedInfo,
|
||||
ListView,
|
||||
ListViewFilterOperator as FilterOperator,
|
||||
type ListViewProps,
|
||||
type ListViewFilters,
|
||||
type ListViewFetchDataConfig as FetchDataConfig,
|
||||
} from 'src/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import rison from 'rison';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import DataAccessRuleModal from 'src/features/dataAccessRules/DataAccessRuleModal';
|
||||
import { DataAccessRuleObject } from 'src/features/dataAccessRules/types';
|
||||
import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
|
||||
interface DataAccessRulesListProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
user: {
|
||||
userId: string | number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
}
|
||||
|
||||
function DataAccessRulesList(props: DataAccessRulesListProps) {
|
||||
const { addDangerToast, addSuccessToast, user } = props;
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState<boolean>(false);
|
||||
const [currentRule, setCurrentRule] =
|
||||
useState<DataAccessRuleObject | null>(null);
|
||||
|
||||
const {
|
||||
state: {
|
||||
loading,
|
||||
resourceCount: rulesCount,
|
||||
resourceCollection: rules,
|
||||
bulkSelectEnabled,
|
||||
},
|
||||
hasPerm,
|
||||
fetchData,
|
||||
refreshData,
|
||||
toggleBulkSelect,
|
||||
} = useListViewResource<DataAccessRuleObject>(
|
||||
'dar',
|
||||
t('Data Access Rule'),
|
||||
addDangerToast,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
function handleRuleEdit(rule: DataAccessRuleObject | null) {
|
||||
setCurrentRule(rule);
|
||||
setRuleModalOpen(true);
|
||||
}
|
||||
|
||||
function handleRuleDelete(
|
||||
{ id, name: ruleName }: DataAccessRuleObject,
|
||||
refreshData: (arg0?: FetchDataConfig | null) => void,
|
||||
addSuccessToast: (arg0: string) => void,
|
||||
addDangerToast: (arg0: string) => void,
|
||||
) {
|
||||
const name = ruleName || `Rule ${id}`;
|
||||
return SupersetClient.delete({
|
||||
endpoint: `/api/v1/dar/${id}`,
|
||||
}).then(
|
||||
() => {
|
||||
refreshData();
|
||||
addSuccessToast(t('Deleted %s', name));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(t('There was an issue deleting %s: %s', name, errMsg)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handleBulkRulesDelete(rulesToDelete: DataAccessRuleObject[]) {
|
||||
const ids = rulesToDelete.map(({ id }) => id);
|
||||
return SupersetClient.delete({
|
||||
endpoint: `/api/v1/dar/?q=${rison.encode(ids)}`,
|
||||
}).then(
|
||||
() => {
|
||||
refreshData();
|
||||
addSuccessToast(t(`Deleted`));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(t('There was an issue deleting rules: %s', errMsg)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handleRuleModalHide() {
|
||||
setCurrentRule(null);
|
||||
setRuleModalOpen(false);
|
||||
refreshData();
|
||||
}
|
||||
|
||||
const canWrite = hasPerm('can_write');
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name },
|
||||
},
|
||||
}: {
|
||||
row: { original: DataAccessRuleObject };
|
||||
}) => name || '-',
|
||||
accessor: 'name',
|
||||
Header: t('Name'),
|
||||
size: 'lg',
|
||||
id: 'name',
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { description },
|
||||
},
|
||||
}: {
|
||||
row: { original: DataAccessRuleObject };
|
||||
}) => {
|
||||
if (!description) return '-';
|
||||
const truncated =
|
||||
description.length > 100
|
||||
? `${description.substring(0, 100)}...`
|
||||
: description;
|
||||
return (
|
||||
<Tooltip id="desc-tooltip" title={description} placement="top">
|
||||
<span>{truncated}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
accessor: 'description',
|
||||
Header: t('Description'),
|
||||
size: 'xl',
|
||||
id: 'description',
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { role },
|
||||
},
|
||||
}: {
|
||||
row: { original: DataAccessRuleObject };
|
||||
}) => role?.name || '-',
|
||||
accessor: 'role',
|
||||
Header: t('Role'),
|
||||
size: 'lg',
|
||||
id: 'role',
|
||||
disableSortBy: true,
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: {
|
||||
changed_on_delta_humanized: changedOn,
|
||||
changed_by: changedBy,
|
||||
},
|
||||
},
|
||||
}: {
|
||||
row: { original: DataAccessRuleObject };
|
||||
}) => <ModifiedInfo date={changedOn} user={changedBy} />,
|
||||
Header: t('Last modified'),
|
||||
accessor: 'changed_on_delta_humanized',
|
||||
size: 'xl',
|
||||
id: 'changed_on_delta_humanized',
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: { row: { original: DataAccessRuleObject } }) => {
|
||||
const handleDelete = () =>
|
||||
handleRuleDelete(
|
||||
original,
|
||||
refreshData,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
);
|
||||
const handleEdit = () => handleRuleEdit(original);
|
||||
return (
|
||||
<div className="actions">
|
||||
{canWrite && (
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={
|
||||
<>
|
||||
{t('Are you sure you want to delete')}{' '}
|
||||
<b>{original.name || `Rule ${original.id}`}</b>
|
||||
</>
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
{confirmDelete => (
|
||||
<Tooltip
|
||||
id="delete-action-tooltip"
|
||||
title={t('Delete')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
<Icons.DeleteOutlined
|
||||
data-test="dar-list-trash-icon"
|
||||
iconSize="l"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ConfirmStatusChange>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
id="edit-action-tooltip"
|
||||
title={t('Edit')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Icons.EditOutlined data-test="edit-alt" iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Header: t('Actions'),
|
||||
id: 'actions',
|
||||
hidden: !canEdit && !canWrite && !canExport,
|
||||
disableSortBy: true,
|
||||
size: 'lg',
|
||||
},
|
||||
{
|
||||
accessor: QueryObjectColumns.ChangedBy,
|
||||
hidden: true,
|
||||
id: QueryObjectColumns.ChangedBy,
|
||||
},
|
||||
],
|
||||
[
|
||||
user.userId,
|
||||
canEdit,
|
||||
canWrite,
|
||||
canExport,
|
||||
hasPerm,
|
||||
refreshData,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
title: t('No Data Access Rules yet'),
|
||||
image: 'filter-results.svg',
|
||||
buttonAction: () => handleRuleEdit(null),
|
||||
buttonIcon: canEdit ? (
|
||||
<Icons.PlusOutlined iconSize="m" data-test="add-rule-empty" />
|
||||
) : undefined,
|
||||
buttonText: canEdit ? t('Rule') : null,
|
||||
};
|
||||
|
||||
const filters: ListViewFilters = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: t('Role'),
|
||||
key: 'role',
|
||||
id: 'role',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: createFetchRelated(
|
||||
'dar',
|
||||
'role',
|
||||
createErrorHandler(errMsg =>
|
||||
t('An error occurred while fetching roles: %s', errMsg),
|
||||
),
|
||||
user,
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: t('Modified by'),
|
||||
key: 'changed_by',
|
||||
id: 'changed_by',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: createFetchRelated(
|
||||
'dar',
|
||||
'changed_by',
|
||||
createErrorHandler(errMsg =>
|
||||
t('An error occurred while fetching users: %s', errMsg),
|
||||
),
|
||||
user,
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
],
|
||||
[user],
|
||||
);
|
||||
|
||||
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||
|
||||
if (canWrite) {
|
||||
subMenuButtons.push({
|
||||
name: t('Bulk select'),
|
||||
buttonStyle: 'secondary',
|
||||
'data-test': 'bulk-select',
|
||||
onClick: toggleBulkSelect,
|
||||
});
|
||||
subMenuButtons.push({
|
||||
name: t('Rule'),
|
||||
icon: <Icons.PlusOutlined iconSize="m" data-test="add-rule" />,
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => handleRuleEdit(null),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('Data Access Rules')} buttons={subMenuButtons} />
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t('Are you sure you want to delete the selected rules?')}
|
||||
onConfirm={handleBulkRulesDelete}
|
||||
>
|
||||
{confirmDelete => {
|
||||
const bulkActions: ListViewProps['bulkActions'] = [];
|
||||
if (canWrite) {
|
||||
bulkActions.push({
|
||||
key: 'delete',
|
||||
name: t('Delete'),
|
||||
type: 'danger',
|
||||
onSelect: confirmDelete,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DataAccessRuleModal
|
||||
rule={currentRule}
|
||||
addDangerToast={addDangerToast}
|
||||
onHide={handleRuleModalHide}
|
||||
addSuccessToast={addSuccessToast}
|
||||
show={ruleModalOpen}
|
||||
/>
|
||||
<ListView<DataAccessRuleObject>
|
||||
className="dar-list-view"
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={toggleBulkSelect}
|
||||
columns={columns}
|
||||
count={rulesCount}
|
||||
data={rules}
|
||||
emptyState={emptyState}
|
||||
fetchData={fetchData}
|
||||
filters={filters}
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
addDangerToast={addDangerToast}
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshData={() => {}}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</ConfirmStatusChange>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withToasts(DataAccessRulesList);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user