Compare commits

..

1 Commits

Author SHA1 Message Date
Maxime Beauchemin
e9965abdb9 feat(EmojiTextArea): add Slack-like emoji autocomplete component
Introduces a new EmojiTextArea component with Slack-like emoji autocomplete
behavior:

- Triggers on `:` prefix with 2+ character minimum (configurable)
- Smart trigger detection: colon must be preceded by whitespace, start of
  text, or another emoji (prevents false positives like URLs)
- Prevents accidental Enter key selection when typing quickly
- Includes 400+ curated emojis with shortcodes and keyword search
- Fully typed with TypeScript, includes tests and Storybook stories

Usage:
```tsx
<EmojiTextArea
  placeholder="Type 😄 to add emojis..."
  onChange={(text) => console.log(text)}
  minCharsBeforePopup={2}
/>
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 21:34:02 +00:00
348 changed files with 16120 additions and 28076 deletions

View File

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

View File

@@ -29,7 +29,7 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/setup-node@v5
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -19,7 +19,7 @@ jobs:
working-directory: superset-embedded-sdk
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/setup-node@v5
with:
node-version-file: './superset-embedded-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: '20'

View File

@@ -39,7 +39,7 @@ jobs:
echo "HOMEBREW_REPOSITORY=$HOMEBREW_REPOSITORY" >>"${GITHUB_ENV}"
brew install norwoodj/tap/helm-docs
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: '20'
@@ -54,7 +54,7 @@ jobs:
yarn install --immutable
- name: Cache pre-commit environments
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}

View File

@@ -42,13 +42,13 @@ jobs:
- name: Install Node.js
if: env.HAS_TAGS
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS
key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }}
@@ -62,7 +62,7 @@ jobs:
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Cache npm
if: env.HAS_TAGS
uses: actions/cache@v5
uses: actions/cache@v4
id: npm-cache # use this to check for `cache-hit` (`steps.npm-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.npm-cache-dir-path.outputs.dir }}

View File

@@ -7,6 +7,12 @@ 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:
@@ -32,5 +38,13 @@ jobs:
- name: Cleanup expired environments
run: |
echo "Cleaning up environments respecting TTL labels"
python -m showtime cleanup --respect-ttl
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"

View File

@@ -63,7 +63,7 @@ jobs:
with:
run: testdata
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies

View File

@@ -36,7 +36,7 @@ jobs:
submodules: recursive
ref: master
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install eyes-storybook dependencies

View File

@@ -36,7 +36,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './docs/.nvmrc'
- name: Setup Python

View File

@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v6
# Do not bump this linkinator-action version without opening
# an ASF Infra ticket to allow the new version first!
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
- uses: JustinBeckwith/linkinator-action@3d5ba091319fa7b0ac14703761eebb7d100e6f6d # v1.11.0
continue-on-error: true # This will make the job advisory (non-blocking, no red X)
with:
paths: "**/*.md, **/*.mdx"
@@ -63,7 +63,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './docs/.nvmrc'
- name: yarn install

View File

@@ -109,7 +109,7 @@ jobs:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
@@ -146,7 +146,7 @@ jobs:
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: failure()
with:
path: ${{ github.workspace }}/superset-frontend/cypress-base/cypress/screenshots
@@ -223,10 +223,10 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright_testdata
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
@@ -259,7 +259,7 @@ jobs:
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: failure()
with:
path: |

View File

@@ -58,7 +58,7 @@ jobs:
- name: Upload HTML coverage report
if: steps.check.outputs.superset-extensions-cli
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: superset-extensions-cli-coverage-html
path: htmlcov/

View File

@@ -58,7 +58,7 @@ jobs:
- name: Upload Docker Image Artifact
if: steps.check.outputs.frontend
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: docker-image
path: docker-image.tar.gz
@@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@v5
with:
name: docker-image
@@ -90,7 +90,7 @@ jobs:
"npm run test -- --coverage --shard=${{ matrix.shard }}/8 --coverageReporters=json-summary"
- name: Upload Coverage Artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: coverage-artifacts-${{ matrix.shard }}
path: superset-frontend/coverage
@@ -101,7 +101,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Download Coverage Artifacts
uses: actions/download-artifact@v7
uses: actions/download-artifact@v5
with:
pattern: coverage-artifacts-*
path: coverage/
@@ -127,7 +127,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@v5
with:
name: docker-image
@@ -151,7 +151,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@v7
uses: actions/download-artifact@v5
with:
name: docker-image
@@ -167,21 +167,3 @@ jobs:
run: |
docker run --rm $TAG bash -c \
"npm run plugins:build-storybook"
test-storybook:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
runs-on: ubuntu-24.04
steps:
- name: Download Docker Image Artifact
uses: actions/download-artifact@v6
with:
name: docker-image
- name: Load Docker Image
run: docker load < docker-image.tar.gz
- name: Build Storybook and Run Tests
run: |
docker run --rm $TAG bash -c \
"npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci"

View File

@@ -97,10 +97,10 @@ jobs:
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright_testdata
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
@@ -133,7 +133,7 @@ jobs:
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: failure()
with:
path: |

View File

@@ -31,7 +31,7 @@ jobs:
- name: Setup Node.js
if: steps.check.outputs.frontend
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install dependencies

View File

@@ -60,7 +60,7 @@ jobs:
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 20
@@ -112,7 +112,7 @@ jobs:
fetch-depth: 0
- name: Use Node.js 20
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 20

View File

@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version-file: './superset-frontend/.nvmrc'

View File

@@ -23,12 +23,8 @@ under the License.
[![Latest Release on Github](https://img.shields.io/github/v/release/apache/superset?sort=semver)](https://github.com/apache/superset/releases/latest)
[![Build Status](https://github.com/apache/superset/actions/workflows/superset-python-unittest.yml/badge.svg)](https://github.com/apache/superset/actions)
[![PyPI version](https://badge.fury.io/py/apache_superset.svg)](https://badge.fury.io/py/apache_superset)
[![Coverage Status](https://codecov.io/github/apache/superset/coverage.svg?branch=master)](https://codecov.io/github/apache/superset)
[![PyPI](https://img.shields.io/pypi/pyversions/apache_superset.svg?maxAge=2592000)](https://pypi.python.org/pypi/apache_superset)
[![GitHub Stars](https://img.shields.io/github/stars/apache/superset?style=social)](https://github.com/apache/superset/stargazers)
[![Contributors](https://img.shields.io/github/contributors/apache/superset)](https://github.com/apache/superset/graphs/contributors)
[![Last Commit](https://img.shields.io/github/last-commit/apache/superset)](https://github.com/apache/superset/commits/master)
[![Open Issues](https://img.shields.io/github/issues/apache/superset)](https://github.com/apache/superset/issues)
[![Open PRs](https://img.shields.io/github/issues-pr/apache/superset)](https://github.com/apache/superset/pulls)
[![Get on Slack](https://img.shields.io/badge/slack-join-orange.svg)](http://bit.ly/join-superset-slack)
[![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.apache.org)

View File

@@ -124,7 +124,6 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
---
- [35621](https://github.com/apache/superset/pull/35621): The default hash algorithm has changed from MD5 to SHA-256 for improved security and FedRAMP compliance. This affects cache keys for thumbnails, dashboard digests, chart digests, and filter option names. Existing cached data will be invalidated upon upgrade. To opt out of this change and maintain backward compatibility, set `HASH_ALGORITHM = "md5"` in your `superset_config.py`.
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
@@ -147,31 +146,6 @@ Note: Pillow is now a required dependency (previously optional) to support image
- [32432](https://github.com/apache/superset/pull/31260) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed.
- [34319](https://github.com/apache/superset/pull/34319) Drill to Detail and Drill By is now supported in Embedded mode, and also with the `DASHBOARD_RBAC` FF. If you don't want to expose these features in Embedded / `DASHBOARD_RBAC`, make sure the roles used for Embedded / `DASHBOARD_RBAC`don't have the required permissions to perform D2D actions.
### Breaking Changes
#### CUSTOM_FONT_URLS removed
The `CUSTOM_FONT_URLS` configuration option has been removed. Use the new per-theme `fontUrls` token in `THEME_DEFAULT` or database-managed themes instead.
**Before (5.x):**
```python
CUSTOM_FONT_URLS = [
"https://fonts.example.com/myfont.css",
]
```
**After (6.0):**
```python
THEME_DEFAULT = {
"token": {
"fontUrls": [
"https://fonts.example.com/myfont.css",
],
# ... other tokens
}
}
```
## 5.0.0
- [31976](https://github.com/apache/superset/pull/31976) Removed the `DISABLE_LEGACY_DATASOURCE_EDITOR` feature flag. The previous value of the feature flag was `True` and now the feature is permanently removed.

3
docs/.gitignore vendored
View File

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

View File

@@ -1,131 +0,0 @@
---
title: Alert
sidebar_label: Alert
---
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
import { StoryWithControls } from '../../../src/components/StorybookWrapper';
import { Alert } from '@apache-superset/core/ui';
# Alert
Alert component for displaying important messages to users. Wraps Ant Design Alert with sensible defaults and improved accessibility.
## Live Example
<StoryWithControls
component={Alert}
props={{
closable: true,
type: 'info',
message: 'This is a sample alert message.',
description: 'Sample description for additional context.',
showIcon: true
}}
controls={[
{
name: 'type',
label: 'Type',
type: 'select',
options: [
'info',
'error',
'warning',
'success'
]
},
{
name: 'closable',
label: 'Closable',
type: 'boolean'
},
{
name: 'showIcon',
label: 'Show Icon',
type: 'boolean'
},
{
name: 'message',
label: 'Message',
type: 'text'
},
{
name: 'description',
label: 'Description',
type: 'text'
}
]}
/>
## Try It
Edit the code below to experiment with the component:
```tsx live
function Demo() {
return (
<Alert
closable
type="info"
message="This is a sample alert message."
description="Sample description for additional context."
showIcon
/>
);
}
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `closable` | `boolean` | `true` | Whether the Alert can be closed with a close button. |
| `type` | `string` | `"info"` | Type of the alert (e.g., info, error, warning, success). |
| `message` | `string` | `"This is a sample alert message."` | Message |
| `description` | `string` | `"Sample description for additional context."` | Description |
| `showIcon` | `boolean` | `true` | Whether to display an icon in the Alert. |
## Usage in Extensions
This component is available in the `@apache-superset/core/ui` package, which is automatically available to Superset extensions.
```tsx
import { Alert } from '@apache-superset/core/ui';
function MyExtension() {
return (
<Alert
closable
type="info"
message="This is a sample alert message."
/>
);
}
```
## Source Links
- [Story file](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/Alert.stories.tsx)
- [Component source](https://github.com/apache/superset/blob/master/superset-frontend/packages/superset-core/src/ui/components/Alert/index.tsx)
---
*This page was auto-generated from the component's Storybook story.*

View File

@@ -1,93 +0,0 @@
---
title: Extension Components
sidebar_label: Overview
sidebar_position: 1
---
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
# Extension Components
These UI components are available to Superset extension developers through the `@apache-superset/core/ui` package. They provide a consistent look and feel with the rest of Superset and are designed to be used in extension panels, views, and other UI elements.
## Available Components
- [Alert](./alert)
## Usage
All components are exported from the `@apache-superset/core/ui` package:
```tsx
import { Alert } from '@apache-superset/core/ui';
export function MyExtensionPanel() {
return (
<Alert type="info">
Welcome to my extension!
</Alert>
);
}
```
## Adding New Components
Components in `@apache-superset/core/ui` are automatically documented here. To add a new extension component:
1. Add the component to `superset-frontend/packages/superset-core/src/ui/components/`
2. Export it from `superset-frontend/packages/superset-core/src/ui/components/index.ts`
3. Create a Storybook story with an `Interactive` export:
```tsx
export default {
title: 'Extension Components/MyComponent',
component: MyComponent,
parameters: {
docs: {
description: {
component: 'Description of the component...',
},
},
},
};
export const InteractiveMyComponent = (args) => <MyComponent {...args} />;
InteractiveMyComponent.args = {
variant: 'primary',
disabled: false,
};
InteractiveMyComponent.argTypes = {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary'],
},
disabled: {
control: { type: 'boolean' },
},
};
```
4. Run `yarn start` in `docs/` - the page generates automatically!
## Interactive Documentation
For interactive examples with controls, visit the [Storybook](/storybook/?path=/docs/extension-components--docs).

View File

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

View File

@@ -455,7 +455,7 @@ Add the following to your `superset_config.py`:
```python
# Enable extensions feature
FEATURE_FLAGS = {
"ENABLE_EXTENSIONS": True,
"EXTENSIONS": True,
}
# Set the directory where extensions are stored

View File

@@ -31,7 +31,7 @@ This page serves as a registry of community-created Superset extensions. These e
| Name | Description | Author | Preview |
| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Extensions API Explorer](https://github.com/michael-s-molina/superset-extensions/tree/main/api_explorer) | A SQL Lab panel that demonstrates the Extensions API by providing an interactive explorer for testing commands like getTabs, getCurrentTab, and getDatabases. Useful for extension developers to understand and experiment with the available APIs. | Michael S. Molina | <a href="/img/extensions/api_explorer.png" target="_blank"><img src="/img/extensions/api_explorer.png" alt="Extensions API Explorer" width="120" /></a> |
| [SQL Query Flow Visualizer](https://github.com/msyavuz/superset-sql-visualizer) | A SQL Lab panel that transforms SQL queries into interactive flow diagrams, helping developers and analysts understand query execution paths and data relationships.| Mehmet Salih Yavuz | <a href="/img/extensions/sql_flow_visualizer.png" target="_blank"><img src="/img/extensions/sql_flow_visualizer.png" alt="SQL Flow Visualizer" width="120" /></a> |
## How to Add Your Extension
To add your extension to this registry, submit a pull request to the [Apache Superset repository](https://github.com/apache/superset) with the following changes:

View File

@@ -1,6 +1,6 @@
---
title: Overview
sidebar_position: 1
title: Backend Style Guidelines
sidebar_position: 3
---
<!--
@@ -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, mypy, 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, pypy, black, isort.
* `__init__.py` files are kept empty to avoid implicit dependencies.
## Code Organization

View File

@@ -1,6 +1,6 @@
---
title: DAO Style Guidelines and Best Practices
sidebar_position: 2
sidebar_position: 1
---
<!--
@@ -26,29 +26,19 @@ 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.
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.
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.
To ensure consistency the following rules should be adhered to:
## Core Rules
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.
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.
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.
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.
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.
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.
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.
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.
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.
## DAO Implementation Examples

View File

@@ -30,23 +30,21 @@ 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 exceptions below).
Use sentence-case capitalization for everything in the UI (except these **).
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
@@ -54,38 +52,21 @@ 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, 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
1. Acronyms and abbreviations. Examples: URL, CSV, XML
## Button Design Guidelines
@@ -117,32 +98,6 @@ 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
@@ -173,10 +128,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
@@ -191,45 +146,3 @@ 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."

View File

@@ -1,6 +1,6 @@
---
title: Overview
sidebar_position: 1
title: Frontend Style Guidelines
sidebar_position: 2
---
<!--
@@ -26,25 +26,19 @@ 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.
- 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/*)
* 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

View File

@@ -1,6 +1,6 @@
---
title: Component Style Guidelines and Best Practices
sidebar_position: 2
sidebar_position: 1
---
<!--
@@ -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,142 +51,208 @@ 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/testing-guidelines) for more details
**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
**Reference naming:** Use `PascalCase` for React components and `camelCase` for component instances
## Component Development Best Practices
**BAD:**
```jsx
import mainNav from './MainNav';
```
### Use TypeScript
**GOOD:**
```jsx
import MainNav from './MainNav';
```
**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.
All new components should be written in TypeScript. This helps catch errors early and provides better development experience with IDE support.
```tsx
type HeadingProps = {
param: string;
interface ComponentProps {
title: string;
isVisible?: boolean;
onClose?: () => void;
}
export default function Heading({ children }: HeadingProps) {
return <h2>{children}</h2>
export const MyComponent: React.FC<ComponentProps> = ({
title,
isVisible = true,
onClose
}) => {
// Component implementation
};
```
### Prefer Functional Components
Use functional components with hooks instead of class components:
```tsx
// ✅ Good - Functional component with hooks
export const MyComponent: React.FC<Props> = ({ data }) => {
const [loading, setLoading] = useState(false);
useEffect(() => {
// Effect logic
}, []);
return <div>{/* Component JSX */}</div>;
};
// ❌ Avoid - Class component
class MyComponent extends React.Component {
// Class implementation
}
```
Use `type` for your component props and state. Use `interface` when you want to enable _declaration merging_.
### Follow Ant Design Patterns
### 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:
Extend Ant Design components rather than building from scratch:
```tsx
const applyDiscount = (price: number, discount = 0.05) => price * (1 - discount);
import { Button } from 'antd';
import styled from '@emotion/styled';
const StyledButton = styled(Button)`
// Custom styling using emotion
`;
```
## Functional components and Hooks
### Reusability and Props Design
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.
Design components with reusability in mind:
```tsx
const [customer, setCustomer] = useState<ICustomer | null>(null);
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
};
```
### useReducer
## Testing Components
Always prefer `useReducer` over `useState` when your state has complex logics.
Every component should include comprehensive tests:
### useMemo and useCallback
```tsx
// MyComponent.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { MyComponent } from './MyComponent';
Always memoize when your components take functions or complex objects as props to avoid unnecessary rerenders.
test('renders component with title', () => {
render(<MyComponent title="Test Title" />);
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
### Custom hooks
test('calls onClose when close button is clicked', () => {
const mockOnClose = jest.fn();
render(<MyComponent title="Test" onClose={mockOnClose} />);
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.
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
```
## Storybook
## Storybook Stories
Each component should come with its dedicated storybook file.
Create stories for visual testing and documentation:
**One component per story:** Each storybook file should only contain one component unless substantially different variants are required
```tsx
// MyComponent.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { MyComponent } from './MyComponent';
**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.
const meta: Meta<typeof MyComponent> = {
title: 'Components/MyComponent',
component: MyComponent,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
**Isolated state:** The storybook should show how the component works in an isolated state and with as few dependencies as possible
export default meta;
type Story = StoryObj<typeof meta>;
**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)
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>
);
};
```

View File

@@ -1,6 +1,6 @@
---
title: Emotion Styling Guidelines and Best Practices
sidebar_position: 3
sidebar_position: 2
---
<!--
@@ -33,245 +33,314 @@ 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 (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.
- **DO** prefer tagged template literals (`css={css\`...\`}`) over style objects wherever possible for maximum style portability/consistency
- **DO** use `useTheme` to get theme variables
### 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 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.
- **DON'T** export incomplete Ant Design components
## When to use `css` or `styled`
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:
### Use `css` for:
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:
1. **Small, single-use styles**
```tsx
import { css } from '@emotion/react';
```jsx
const StatusThing = styled.div`
padding: 10px;
border-radius: 10px;
const MyComponent = () => (
<div
css={css`
margin: 8px;
padding: 16px;
`}
>
Content
</div>
);
```
2. **Composing styles**
```tsx
const baseStyles = css`
padding: 16px;
border-radius: 4px;
`;
export const InfoThing = styled(StatusThing)`
background: blue;
&::before {
content: "";
}
const primaryStyles = css`
${baseStyles}
background-color: blue;
color: white;
`;
export const WarningThing = styled(StatusThing)`
background: orange;
&::before {
content: "⚠️";
}
const secondaryStyles = css`
${baseStyles}
background-color: gray;
color: black;
`;
```
export const TerribleThing = styled(StatusThing)`
background: red;
&::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};
}
`;
```
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:
2. **Components with complex nested selectors**
```tsx
const StyledCard = styled.div`
padding: 16px;
border: 1px solid ${({ theme }) => theme.colors.border};
```jsx
const SeparatorOnlyUsedInThisComponent = styled.hr`
height: 12px;
border: 0;
box-shadow: inset 0 12px 12px -12px rgba(0, 0, 0, 0.5);
.card-header {
font-weight: bold;
margin-bottom: 8px;
}
.card-content {
color: ${({ theme }) => theme.colors.text};
p {
margin-bottom: 12px;
}
}
`;
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
</>
);
}
```
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:
3. **Extending Ant Design components**
```tsx
import { Button } from 'antd';
import styled from '@emotion/styled';
```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>
</>
);
}
const CustomButton = styled(Button)`
border-radius: 8px;
font-weight: 600;
&.ant-btn-primary {
background: linear-gradient(45deg, #1890ff, #722ed1);
}
`;
```
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:
## Using Theme Variables
```jsx
function FakeGlobalNav(props) {
const menuItemStyles = css`
display: block;
border-bottom: 1px solid cadetblue;
font-family: "Comic Sans", cursive;
`;
Always use theme variables for consistent styling:
```tsx
import { useTheme } from '@emotion/react';
const MyComponent = () => {
const theme = useTheme();
return (
<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
css={css`
background-color: ${theme.colors.grayscale.light5};
color: ${theme.colors.grayscale.dark2};
padding: ${theme.gridUnit * 4}px;
border-radius: ${theme.borderRadius}px;
`}
>
Content
</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';
}
```
### Style variations with props
const StyledDiv = styled.div<StyledDivProps>`
padding: 16px;
background-color: ${({ isHighlighted, theme }) =>
isHighlighted ? theme.colors.primary : theme.colors.grayscale.light5};
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};
`}
${({ size }) => {
switch (size) {
case 'small':
return css`font-size: 12px;`;
case 'large':
return css`font-size: 18px;`;
default:
return css`font-size: 14px;`;
}
}}
`;
```
Then just use the component as `<SuperCard>Some content</SuperCard>` or with the (potentially dynamic) prop: `<SuperCard cutout>Some content</SuperCard>`
## Best Practices
## Styled component tips
### 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;
`;
### No need to use `theme` the hard way
// ❌ Avoid - magic numbers
const Header = styled.h1`
font-size: 24px;
margin-bottom: 16px;
`;
```
It's very tempting (and commonly done) to use the `theme` prop inline in the template literal like so:
### 2. Group Related Styles
```tsx
// ✅ Good - grouped styles
const Card = styled.div`
/* Layout */
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.gridUnit * 4}px;
```jsx
const SomeStyledThing = styled.div`
padding: ${({ theme }) => theme.gridUnit * 2}px;
/* Appearance */
background-color: ${({ theme }) => theme.colors.grayscale.light5};
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
border-radius: ${({ theme }) => theme.borderRadius}px;
border: 1px solid ${({ theme }) => theme.colors.secondary.light3};
/* Typography */
font-family: ${({ theme }) => theme.typography.families.sansSerif};
color: ${({ theme }) => theme.colors.grayscale.dark2};
`;
```
Instead, you can make things a little easier to read/type by writing it like so:
### 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%
);
`;
```jsx
const SomeStyledThing = styled.div`
${({ theme }) => `
padding: ${theme.gridUnit * 2}px;
border-radius: ${theme.borderRadius}px;
border: 1px solid ${theme.colors.secondary.light3};
`}
const GradientButton = styled.button`
${complexGradient}
padding: 12px 24px;
border: none;
color: white;
`;
```
## Extend an AntD component with custom styling
### 4. Avoid Deep Nesting
```tsx
// ✅ Good - shallow nesting
const Navigation = styled.nav`
.nav-item {
padding: 8px 16px;
}
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;
.nav-link {
color: ${({ theme }) => theme.colors.text};
text-decoration: none;
}
`;
export const Foo = Object.assign(AntdFoo, {
Bar: StyledBar,
// ❌ 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
});
```
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.
const DynamicComponent = (props: Props) => (
<div css={dynamicStyles(props)}>
Content
</div>
);
```

View File

@@ -0,0 +1,297 @@
---
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');
});
```

View File

@@ -1,129 +0,0 @@
---
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)

View File

@@ -54,7 +54,6 @@ are compatible with Superset.
| [Ascend.io](/docs/configuration/databases#ascendio) | `pip install impyla` | `ascend://{username}:{password}@{hostname}:{port}/{database}?auth_mechanism=PLAIN;use_ssl=true` |
| [Azure MS SQL](/docs/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://UserName@presetSQL:TestPassword@presetSQL.database.windows.net:1433/TestSchema` |
| [ClickHouse](/docs/configuration/databases#clickhouse) | `pip install clickhouse-connect` | `clickhousedb://{username}:{password}@{hostname}:{port}/{database}` |
| [Cloudflare D1](/docs/configuration/databases#cloudflare-d1) | `pip install superset-engine-d1` or `pip install apache_superset[d1]` | `d1://{cloudflare_account_id}:{cloudflare_api_token}@{cloudflare_d1_database_id}` |
| [CockroachDB](/docs/configuration/databases#cockroachdb) | `pip install cockroachdb` | `cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable` |
| [Couchbase](/docs/configuration/databases#couchbase) | `pip install couchbase-sqlalchemy` | `couchbase://{username}:{password}@{hostname}:{port}?truststorepath={ssl certificate path}` |
| [CrateDB](/docs/configuration/databases#cratedb) | `pip install sqlalchemy-cratedb` | `crate://{username}:{password}@{hostname}:{port}`, often useful: `?ssl=true/false` or `?schema=testdrive`. |
@@ -360,20 +359,6 @@ uses the default user without a password (and doesn't encrypt the connection):
clickhousedb://localhost/default
```
#### Cloudflare D1
To use Cloudflare D1 with superset, install the [superset-engine-d1](https://github.com/sqlalchemy-cf-d1/superset-engine-d1) library.
```
pip install superset-engine-d1
```
The expected connection string is formatted as follows:
```
d1://{cloudflare_account_id}:{cloudflare_api_token}@{cloudflare_d1_database_id}
```
#### CockroachDB
The recommended connector library for CockroachDB is

View File

@@ -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). **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)
- `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)
- `groupby`: columns which to group by in the query (deprecated)
- `metrics`: aggregate expressions in the query
- `row_limit`: row limit of the query
@@ -67,54 +67,6 @@ 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

View File

@@ -110,30 +110,18 @@ To export a theme for use in configuration files or another instance:
## Custom Fonts
Superset supports custom fonts through the theme configuration, allowing you to use branded or custom typefaces without rebuilding the application.
### Default Fonts
By default, Superset uses Inter and Fira Code fonts which are bundled with the application via `@fontsource` packages. These fonts work offline and require no external network calls.
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
### Configuring Custom Fonts
To use custom fonts, add font URLs to your theme configuration using the `fontUrls` token:
Add font URLs to your `superset_config.py`:
```python
THEME_DEFAULT = {
"token": {
# Load fonts from external sources (e.g., Google Fonts, Adobe Fonts)
"fontUrls": [
"https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap",
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
],
# Reference the loaded fonts
"fontFamily": "Roboto, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
# ... other theme tokens
}
}
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
CUSTOM_FONT_URLS = [
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
]
# Update CSP to allow font sources
TALISMAN_CONFIG = {
@@ -144,24 +132,31 @@ TALISMAN_CONFIG = {
}
```
### Using Custom Fonts in Themes
Once configured, reference the fonts in your theme configuration:
```python
THEME_DEFAULT = {
"token": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
# ... other theme tokens
}
}
```
Or in the CRUD interface theme JSON:
```json
{
"token": {
"fontUrls": [
"https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap"
],
"fontFamily": "Roboto, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
}
}
```
:::note
Font URLs are validated against a configurable allowlist. By default, fonts from `fonts.googleapis.com`, `fonts.gstatic.com`, and `use.typekit.net` are allowed. Configure `THEME_FONT_URL_ALLOWED_DOMAINS` to customize the allowed domains.
:::
### Font Sources
- **Google Fonts**: Free, CDN-hosted fonts with wide variety

View File

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

View File

@@ -21,7 +21,6 @@ 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';
@@ -45,7 +44,7 @@ if (!versionsConfig.components.disabled) {
sidebarPath: require.resolve('./sidebarComponents.js'),
editUrl:
'https://github.com/apache/superset/edit/master/docs/components',
remarkPlugins: [remarkImportPartial, remarkLocalizeBadges],
remarkPlugins: [remarkImportPartial],
docItemComponent: '@theme/DocItem',
includeCurrentVersion: versionsConfig.components.includeCurrentVersion,
lastVersion: versionsConfig.components.lastVersion,
@@ -69,7 +68,7 @@ if (!versionsConfig.developer_portal.disabled) {
sidebarPath: require.resolve('./sidebarTutorials.js'),
editUrl:
'https://github.com/apache/superset/edit/master/docs/developer_portal',
remarkPlugins: [remarkImportPartial, remarkLocalizeBadges],
remarkPlugins: [remarkImportPartial],
docItemComponent: '@theme/DocItem',
includeCurrentVersion: versionsConfig.developer_portal.includeCurrentVersion,
lastVersion: versionsConfig.developer_portal.lastVersion,
@@ -165,11 +164,7 @@ const config: Config = {
favicon: '/img/favicon.ico',
organizationName: 'apache',
projectName: 'superset',
themes: [
'@saucelabs/theme-github-codeblock',
'@docusaurus/theme-mermaid',
'@docusaurus/theme-live-codeblock',
],
themes: ['@saucelabs/theme-github-codeblock', '@docusaurus/theme-mermaid'],
plugins: [
require.resolve('./src/webpack.extend.ts'),
[
@@ -342,7 +337,6 @@ 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,
@@ -480,9 +474,6 @@ const config: Config = {
hideable: true,
},
},
liveCodeBlock: {
playgroundPosition: 'bottom',
},
} satisfies ThemeConfig,
scripts: [
// {

View File

@@ -6,17 +6,16 @@
"scripts": {
"docusaurus": "docusaurus",
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
"start": "yarn run _init && yarn run generate:extension-components && NODE_ENV=development docusaurus start",
"start": "yarn run _init && NODE_ENV=development docusaurus start",
"stop": "pkill -f 'docusaurus start' || pkill -f 'docusaurus serve' || echo 'No docusaurus server running'",
"build": "yarn run _init && yarn run generate:extension-components && DEBUG=docusaurus:* docusaurus build",
"build": "yarn run _init && 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": "yarn run generate:extension-components && tsc",
"generate:extension-components": "node scripts/generate-extension-components.mjs",
"typecheck": "tsc",
"eslint": "eslint .",
"version:add": "node scripts/manage-versions.mjs add",
"version:remove": "node scripts/manage-versions.mjs remove",
@@ -32,11 +31,10 @@
"@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/core": "^10.0.27",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.14.1",
"@emotion/styled": "^10.0.27",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^8.6.11",
@@ -51,11 +49,11 @@
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@superset-ui/core": "^0.20.4",
"antd": "^6.1.0",
"caniuse-lite": "^1.0.30001760",
"antd": "^5.29.1",
"caniuse-lite": "^1.0.30001759",
"docusaurus-plugin-less": "^2.0.2",
"json-bigint": "^1.0.0",
"less": "^4.5.1",
"less": "^4.4.2",
"less-loader": "^12.3.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
@@ -65,26 +63,25 @@
"remark-import-partial": "^0.0.2",
"reselect": "^5.1.1",
"storybook": "^8.6.11",
"swagger-ui-react": "^5.31.0",
"swagger-ui-react": "^5.30.3",
"tinycolor2": "^1.4.2",
"ts-loader": "^9.5.4",
"unist-util-visit": "^5.0.0"
"ts-loader": "^9.5.4"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.1",
"@docusaurus/tsconfig": "^3.9.2",
"@eslint/js": "^9.39.2",
"@eslint/js": "^9.39.1",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.49.0",
"eslint": "^9.39.2",
"@typescript-eslint/parser": "^8.46.4",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"typescript": "~5.9.3",
"typescript-eslint": "^8.49.0",
"typescript-eslint": "^8.48.1",
"webpack": "^5.103.0"
},
"browserslist": {

View File

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

View File

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

View File

@@ -42,24 +42,32 @@ const sidebars = {
'contributing/howtos',
'contributing/release-process',
'contributing/resources',
'guidelines/design-guidelines',
{
type: 'category',
label: 'Frontend Style Guidelines',
label: 'Contribution Guidelines',
collapsed: true,
items: [
'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',
'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',
],
},
],
},
],
@@ -73,17 +81,6 @@ 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',
@@ -105,7 +102,6 @@ const sidebars = {
collapsed: true,
items: [
'testing/overview',
'testing/testing-guidelines',
'testing/frontend-testing',
'testing/backend-testing',
'testing/e2e-testing',

View File

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

View File

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

View File

@@ -51,15 +51,6 @@ 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,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -6,23 +6,19 @@
"skipLibCheck": true,
"noImplicitAny": false,
"strict": false,
"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/*"]
}
"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/*"]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts"
"src/**/*.tsx"
],
"exclude": [
"node_modules",

View File

@@ -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). **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)
- `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)
- `groupby`: columns which to group by in the query (deprecated)
- `metrics`: aggregate expressions in the query
- `row_limit`: row limit of the query
@@ -58,54 +58,6 @@ 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

View File

@@ -110,30 +110,18 @@ To export a theme for use in configuration files or another instance:
## Custom Fonts
Superset supports custom fonts through the theme configuration, allowing you to use branded or custom typefaces without rebuilding the application.
### Default Fonts
By default, Superset uses Inter and Fira Code fonts which are bundled with the application via `@fontsource` packages. These fonts work offline and require no external network calls.
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
### Configuring Custom Fonts
To use custom fonts, add font URLs to your theme configuration using the `fontUrls` token:
Add font URLs to your `superset_config.py`:
```python
THEME_DEFAULT = {
"token": {
# Load fonts from external sources (e.g., Google Fonts, Adobe Fonts)
"fontUrls": [
"https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap",
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
],
# Reference the loaded fonts
"fontFamily": "Roboto, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
# ... other theme tokens
}
}
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
CUSTOM_FONT_URLS = [
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
]
# Update CSP to allow font sources
TALISMAN_CONFIG = {
@@ -144,24 +132,31 @@ TALISMAN_CONFIG = {
}
```
### Using Custom Fonts in Themes
Once configured, reference the fonts in your theme configuration:
```python
THEME_DEFAULT = {
"token": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
# ... other theme tokens
}
}
```
Or in the CRUD interface theme JSON:
```json
{
"token": {
"fontUrls": [
"https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap"
],
"fontFamily": "Roboto, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
}
}
```
:::note
Font URLs are validated against a configurable allowlist. By default, fonts from `fonts.googleapis.com`, `fonts.gstatic.com`, and `use.typekit.net` are allowed. Configure `THEME_FONT_URL_ALLOWED_DOMAINS` to customize the allowed domains.
:::
### Font Sources
- **Google Fonts**: Free, CDN-hosted fonts with wide variety

View File

@@ -914,20 +914,6 @@ 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

File diff suppressed because it is too large Load Diff

View File

@@ -92,7 +92,7 @@ dependencies = [
"pyarrow>=16.1.0, <19", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyyaml>=6.0.0, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=5.0.0, <6.0",
"redis>=4.6.0, <5.0",
"selenium>=4.14.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.3, <2.0",
"sshtunnel>=0.4.0, <0.5",
@@ -123,11 +123,6 @@ bigquery = [
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.40.1, <1"]
d1 = [
"superset-engine-d1>=0.1.0",
"sqlalchemy-d1>=0.1.0",
"dbapi-d1>=0.1.0",
]
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
databricks = [
"databricks-sql-connector==4.1.2",
@@ -144,7 +139,7 @@ solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
excel = ["xlrd>=1.2.0, <1.3"]
fastmcp = ["fastmcp>=2.14.0"]
fastmcp = ["fastmcp>=2.13.0.2"]
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]

View File

@@ -16,7 +16,7 @@
# specific language governing permissions and limitations
# under the License.
#
urllib3>=2.6.0,<3.0.0
urllib3==2.5.0
werkzeug>=3.0.1
numexpr>=2.9.0

View File

@@ -310,7 +310,6 @@ pyjwt==2.10.1
# apache-superset (pyproject.toml)
# flask-appbuilder
# flask-jwt-extended
# redis
pynacl==1.5.0
# via paramiko
pyopenssl==25.1.0
@@ -343,7 +342,7 @@ pyyaml==6.0.2
# via
# apache-superset (pyproject.toml)
# apispec
redis==5.3.1
redis==4.6.0
# via apache-superset (pyproject.toml)
referencing==0.36.2
# via
@@ -436,7 +435,7 @@ tzdata==2025.2
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.6.0
urllib3==2.5.0
# via
# -r requirements/base.in
# requests

View File

@@ -132,7 +132,6 @@ click==8.2.1
# click-repl
# flask
# flask-appbuilder
# typer
# uvicorn
click-didyoumean==0.3.1
# via
@@ -150,8 +149,6 @@ click-repl==0.3.0
# via
# -c requirements/base-constraint.txt
# celery
cloudpickle==3.1.2
# via pydocket
cmdstanpy==1.1.0
# via prophet
colorama==0.4.6
@@ -231,9 +228,7 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp
fakeredis==2.32.1
# via pydocket
fastmcp==2.14.0
fastmcp==2.13.1
# via apache-superset
filelock==3.12.2
# via virtualenv
@@ -424,9 +419,7 @@ idna==3.10
# trio
# url-normalize
importlib-metadata==8.7.0
# via
# keyring
# opentelemetry-api
# via keyring
importlib-resources==6.5.2
# via prophet
iniconfig==2.0.0
@@ -478,7 +471,7 @@ jsonschema-specifications==2025.4.1
# -c requirements/base-constraint.txt
# jsonschema
# openapi-schema-validator
keyring==25.7.0
keyring==25.6.0
# via py-key-value-aio
kiwisolver==1.4.7
# via matplotlib
@@ -492,8 +485,6 @@ limits==5.1.0
# via
# -c requirements/base-constraint.txt
# flask-limiter
lupa==2.6
# via fakeredis
mako==1.3.10
# via
# -c requirements/base-constraint.txt
@@ -533,7 +524,7 @@ matplotlib==3.9.0
# via prophet
mccabe==0.7.0
# via pylint
mcp==1.24.0
mcp==1.20.0
# via fastmcp
mdurl==0.1.2
# via
@@ -590,18 +581,6 @@ openpyxl==3.1.5
# via
# -c requirements/base-constraint.txt
# pandas
opentelemetry-api==1.39.1
# via
# opentelemetry-exporter-prometheus
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pydocket
opentelemetry-exporter-prometheus==0.60b1
# via pydocket
opentelemetry-sdk==1.39.1
# via opentelemetry-exporter-prometheus
opentelemetry-semantic-conventions==0.60b1
# via opentelemetry-sdk
ordered-set==4.1.0
# via
# -c requirements/base-constraint.txt
@@ -689,10 +668,6 @@ prison==0.2.1
# flask-appbuilder
progress==1.6
# via apache-superset
prometheus-client==0.23.1
# via
# opentelemetry-exporter-prometheus
# pydocket
prompt-toolkit==3.0.51
# via
# -c requirements/base-constraint.txt
@@ -714,11 +689,9 @@ psutil==6.1.0
# via apache-superset
psycopg2-binary==2.9.6
# via apache-superset
py-key-value-aio==0.3.0
# via
# fastmcp
# pydocket
py-key-value-shared==0.3.0
py-key-value-aio==0.2.8
# via fastmcp
py-key-value-shared==0.2.8
# via py-key-value-aio
pyarrow==16.1.0
# via
@@ -758,8 +731,6 @@ pydantic-settings==2.10.1
# via mcp
pydata-google-auth==1.9.0
# via pandas-gbq
pydocket==0.15.4
# via fastmcp
pydruid==0.6.9
# via apache-superset
pyfakefs==5.3.5
@@ -779,7 +750,6 @@ pyjwt==2.10.1
# flask-appbuilder
# flask-jwt-extended
# mcp
# redis
pylint==3.3.7
# via apache-superset
pynacl==1.5.0
@@ -843,8 +813,6 @@ python-geohash==0.8.5
# via
# -c requirements/base-constraint.txt
# apache-superset
python-json-logger==4.0.0
# via pydocket
python-ldap==3.4.4
# via apache-superset
python-multipart==0.0.20
@@ -867,13 +835,10 @@ pyyaml==6.0.2
# apispec
# jsonschema-path
# pre-commit
redis==5.3.1
redis==4.6.0
# via
# -c requirements/base-constraint.txt
# apache-superset
# fakeredis
# py-key-value-aio
# pydocket
referencing==0.36.2
# via
# -c requirements/base-constraint.txt
@@ -909,9 +874,7 @@ rich==13.9.4
# cyclopts
# fastmcp
# flask-limiter
# pydocket
# rich-rst
# typer
rich-rst==1.3.1
# via cyclopts
rpds-py==0.25.0
@@ -925,7 +888,7 @@ rsa==4.9.1
# google-auth
ruff==0.9.7
# via apache-superset
secretstorage==3.5.0
secretstorage==3.4.1
# via keyring
selenium==4.32.0
# via
@@ -941,8 +904,6 @@ setuptools==80.9.0
# pydata-google-auth
# zope-event
# zope-interface
shellingham==1.5.4
# via typer
shillelagh==1.4.3
# via
# -c requirements/base-constraint.txt
@@ -970,7 +931,6 @@ sniffio==1.3.1
sortedcontainers==2.4.0
# via
# -c requirements/base-constraint.txt
# fakeredis
# trio
sqlalchemy==1.4.54
# via
@@ -1031,8 +991,6 @@ trio-websocket==0.12.2
# via
# -c requirements/base-constraint.txt
# selenium
typer==0.20.0
# via pydocket
typing-extensions==4.15.0
# via
# -c requirements/base-constraint.txt
@@ -1043,25 +1001,18 @@ typing-extensions==4.15.0
# cattrs
# exceptiongroup
# limits
# mcp
# opentelemetry-api
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# py-key-value-shared
# pydantic
# pydantic-core
# pydocket
# pyopenssl
# referencing
# selenium
# shillelagh
# starlette
# typer
# typing-inspection
typing-inspection==0.4.1
# via
# -c requirements/base-constraint.txt
# mcp
# pydantic
# pydantic-settings
tzdata==2025.2
@@ -1075,7 +1026,7 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.6.0
urllib3==2.5.0
# via
# -c requirements/base-constraint.txt
# docker

View File

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

View File

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

View File

@@ -7,8 +7,8 @@
"frontend": {
"contributions": {
"commands": [],
"views": {},
"menus": {}
"views": [],
"menus": []
},
"moduleFederation": {
"exposes": ["./index"]

View File

@@ -226,7 +226,7 @@ def test_extension_json_content_is_correct(
frontend = content["frontend"]
assert "contributions" in frontend
assert "moduleFederation" in frontend
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
assert frontend["contributions"] == {"commands": [], "views": [], "menus": []}
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
# Verify backend section exists and has correct structure

View File

@@ -75,7 +75,7 @@ def test_extension_json_template_renders_with_both_frontend_and_backend(
frontend = parsed["frontend"]
assert "contributions" in frontend
assert "moduleFederation" in frontend
assert frontend["contributions"] == {"commands": [], "views": {}, "menus": {}}
assert frontend["contributions"] == {"commands": [], "views": [], "menus": []}
assert frontend["moduleFederation"] == {"exposes": ["./index"]}
# Verify backend section exists

View File

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

View File

@@ -20,57 +20,6 @@ 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)',
@@ -92,19 +41,13 @@ module.exports = {
...config,
module: {
...config.module,
rules: disableDevModeInRules(customConfig.module.rules),
rules: 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, ...filteredPlugins],
plugins: [...config.plugins, ...customConfig.plugins],
}),
typescript: {

View File

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

View File

@@ -1,38 +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 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;

View File

@@ -206,20 +206,12 @@ describe('Charts list', () => {
// edits in list-view
setGridMode('list');
// 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('edit-alt').eq(1).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').contains('1 - Sample chart').should('exist');
cy.getBySel('table-row').eq(1).contains('1 - Sample chart');
});
});
});

View File

@@ -38,13 +38,9 @@ describe('Download Chart > Bar chart', () => {
cy.visitChartByParams(formData);
cy.get('.header-with-actions .ant-dropdown-trigger').click();
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click();
cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(1) > .ant-dropdown-menu-submenu-title',
).click();
cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)',
).click();
cy.verifyDownload('.jpg', {
contains: true,
timeout: 25000,

View File

@@ -59,7 +59,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
'node_modules/(?!d3-(interpolate|color|time|scale)|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|@rjsf/*.|sinon|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es)',
],
preset: 'ts-jest',
transform: {

File diff suppressed because it is too large Load Diff

View File

@@ -79,8 +79,6 @@
"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%",
@@ -138,16 +136,16 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.3.1",
"ag-grid-react": "34.3.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.26.0",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
"content-disposition": "^1.0.1",
"content-disposition": "^0.5.4",
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.19",
"dom-to-image-more": "^3.7.2",
"dayjs": "^1.11.18",
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
@@ -160,8 +158,8 @@
"geostyler-openlayers-parser": "^4.3.0",
"geostyler-style": "7.5.0",
"geostyler-wfs-parser": "^2.0.3",
"googleapis": "^168.0.0",
"immer": "^11.0.1",
"googleapis": "^154.1.0",
"immer": "^10.1.1",
"interweave": "^13.1.1",
"jquery": "^3.7.1",
"js-levenshtein": "^1.1.6",
@@ -169,7 +167,8 @@
"json-bigint": "^1.0.0",
"json-stringify-pretty-compact": "^2.0.0",
"lodash": "^4.17.21",
"mapbox-gl": "^3.17.0",
"luxon": "^3.7.1",
"mapbox-gl": "^3.13.0",
"markdown-to-jsx": "^7.7.4",
"match-sorter": "^6.3.4",
"memoize-one": "^5.2.1",
@@ -177,6 +176,7 @@
"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",
@@ -223,13 +223,13 @@
"@babel/cli": "^7.28.3",
"@babel/compat-data": "^7.28.4",
"@babel/core": "^7.28.3",
"@babel/eslint-parser": "^7.28.5",
"@babel/node": "^7.28.0",
"@babel/eslint-parser": "^7.28.4",
"@babel/node": "^7.22.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
"@babel/plugin-transform-runtime": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/plugin-transform-runtime": "^7.28.3",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.26.0",
"@babel/register": "^7.23.7",
@@ -238,40 +238,38 @@
"@babel/types": "^7.26.9",
"@cypress/react": "^8.0.2",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/jest": "^11.14.2",
"@emotion/jest": "^11.13.0",
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.56.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.17",
"@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",
"@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",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.14.0",
"@swc/plugin-emotion": "^12.0.0",
"@swc/plugin-transform-imports": "^10.0.0",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@types/content-disposition": "^0.5.9",
"@types/dom-to-image": "^2.6.7",
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.14",
"@types/js-levenshtein": "^1.1.3",
"@types/json-bigint": "^1.0.4",
"@types/math-expression-evaluator": "^2.0.0",
"@types/math-expression-evaluator": "^1.3.3",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.0.2",
"@types/node": "^24.8.1",
"@types/react": "^17.0.83",
"@types/react-dom": "^17.0.26",
"@types/react-json-tree": "^0.13.0",
@@ -286,6 +284,7 @@
"@types/redux-mock-store": "^1.0.6",
"@types/rison": "0.1.0",
"@types/sinon": "^17.0.3",
"@types/testing-library__jest-dom": "^5.14.9",
"@types/tinycolor2": "^1.4.3",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
@@ -295,13 +294,11 @@
"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",
"css-minimizer-webpack-plugin": "^7.0.4",
"css-minimizer-webpack-plugin": "^7.0.2",
"eslint": "^8.56.0",
"eslint-config-prettier": "^7.2.0",
"eslint-import-resolver-alias": "^1.1.2",
@@ -317,58 +314,56 @@
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-prefer-function-component": "^3.3.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^0.5.1",
"eslint-plugin-storybook": "^0.8.0",
"eslint-plugin-testing-library": "^7.14.0",
"eslint-plugin-testing-library": "^6.4.0",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^11.1.5",
"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": "^30.0.2",
"jest-environment-jsdom": "^29.7.0",
"jest-html-reporter": "^4.3.0",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^27.0.0",
"lerna": "^8.2.3",
"lightningcss": "^1.30.2",
"mini-css-extract-plugin": "^2.9.4",
"mini-css-extract-plugin": "^2.9.0",
"open-cli": "^8.0.0",
"oxlint": "^1.32.0",
"oxlint": "^1.16.0",
"po2json": "^0.4.5",
"prettier": "3.7.4",
"prettier-plugin-packagejson": "^2.5.20",
"prettier": "3.6.2",
"prettier-plugin-packagejson": "^2.5.19",
"process": "^0.11.10",
"react-refresh": "^0.18.0",
"react-refresh": "^0.14.2",
"react-resizable": "^3.0.5",
"redux-mock-store": "^1.5.4",
"sinon": "^18.0.0",
"source-map": "^0.7.4",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.5.0",
"storybook": "8.6.14",
"storybook": "8.1.11",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.6",
"terser-webpack-plugin": "^5.3.16",
"terser-webpack-plugin": "^5.3.14",
"thread-loader": "^4.0.4",
"ts-jest": "^29.4.6",
"ts-jest": "^29.4.5",
"ts-loader": "^9.5.1",
"tscw-config": "^1.1.2",
"tsx": "^4.21.0",
"tsx": "^4.20.3",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.3",
"webpack": "^5.103.0",
"webpack": "^5.102.1",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.3.3",
"webpack-visualizer-plugin2": "^2.0.0"
"webpack-visualizer-plugin2": "^1.2.0"
},
"peerDependencies": {
"ace-builds": "^1.41.0",
@@ -387,20 +382,7 @@
"puppeteer": "^22.4.1",
"underscore": "^1.13.7",
"jspdf": "^3.0.1",
"nwsapi": "^2.2.13",
"@deck.gl/aggregation-layers": "~9.2.2",
"@deck.gl/core": "~9.2.2",
"@deck.gl/extensions": "~9.2.2",
"@deck.gl/geo-layers": "~9.2.2",
"@deck.gl/layers": "~9.2.2",
"@deck.gl/mesh-layers": "~9.2.2",
"@deck.gl/react": "~9.2.2",
"@deck.gl/widgets": "~9.2.2",
"@luma.gl/constants": "~9.2.2",
"@luma.gl/core": "~9.2.2",
"@luma.gl/engine": "~9.2.2",
"@luma.gl/shadertools": "~9.2.2",
"@luma.gl/webgl": "~9.2.2"
"nwsapi": "^2.2.13"
},
"readme": "ERROR: No README data found!",
"scarfSettings": {

View File

@@ -37,7 +37,7 @@
"cross-env": "^10.1.0",
"fs-extra": "^11.3.2",
"jest": "^30.2.0",
"yeoman-test": "^11.2.0"
"yeoman-test": "^10.1.1"
},
"engines": {
"npm": ">= 4.0.0",

View File

@@ -13,14 +13,14 @@
"devDependencies": {
"@babel/cli": "^7.28.3",
"@babel/core": "^7.28.3",
"@babel/preset-env": "^7.28.5",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"install": "^0.13.0",
"npm": "^11.1.0",
"typescript": "^5.0.0",
"@emotion/styled": "^11.14.1",
"@types/lodash": "^4.17.21",
"@types/lodash": "^4.17.20",
"@testing-library/dom": "^8.20.1",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^12.1.5",
@@ -35,13 +35,13 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"nanoid": "^5.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"lodash": "^4.17.21",
"antd": "^5.26.0"
},

View File

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

View File

@@ -129,28 +129,6 @@ export interface SupersetSpecificTokens {
brandSpinnerUrl?: string;
brandSpinnerSvg?: string;
// Font loading
/**
* Array of font URLs to load for this theme.
* Supports multiple URLs for loading different font families or mixing providers.
* Each URL is injected as a CSS @import when the theme is applied.
*
* @example
* Multiple font families from Google Fonts
* fontUrls: [
* "https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap",
* "https://fonts.googleapis.com/css2?family=Fira+Code&display=swap"
* ]
*
* @example
* Mix Google Fonts and Adobe Fonts
* fontUrls: [
* "https://fonts.googleapis.com/css2?family=Open+Sans&display=swap",
* "https://use.typekit.net/abc123.css"
* ]
*/
fontUrls?: string[];
// ECharts-related
/** Global ECharts configuration overrides applied to all chart types */
echartsOptionsOverrides?: any;

View File

@@ -250,7 +250,7 @@ const order_desc: SharedControlConfig<'CheckboxControl'> = {
visibility: ({ controls }) =>
Boolean(
controls?.timeseries_limit_metric.value &&
!isEmpty(controls?.timeseries_limit_metric.value),
!isEmpty(controls?.timeseries_limit_metric.value),
),
};

View File

@@ -27,33 +27,35 @@
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.28.4",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"ace-builds": "^1.43.5",
"ag-grid-community": "34.3.1",
"ag-grid-react": "34.3.1",
"ace-builds": "^1.43.4",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"csstype": "^3.1.3",
"core-js": "^3.38.1",
"d3-format": "^1.3.2",
"dayjs": "^1.11.19",
"dayjs": "^1.11.18",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dompurify": "^3.3.1",
"dompurify": "^3.2.4",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.8",
"jed": "^1.1.1",
"lodash": "^4.17.21",
"math-expression-evaluator": "^2.0.7",
"math-expression-evaluator": "^2.0.6",
"pretty-ms": "^9.3.0",
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-js-cron": "^5.2.0",
"react-draggable": "^4.5.0",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.0",
"react-syntax-highlighter": "^15.6.6",
"react-ultimate-pagination": "^1.3.2",
"react-error-boundary": "^6.0.0",
"react-markdown": "^8.0.7",
@@ -77,9 +79,9 @@
"@types/react-table": "^7.7.20",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/jquery": "^3.5.33",
"@types/lodash": "^4.17.21",
"@types/math-expression-evaluator": "^2.0.0",
"@types/node": "^25.0.2",
"@types/lodash": "^4.17.20",
"@types/math-expression-evaluator": "^1.3.3",
"@types/node": "^24.8.1",
"@types/prop-types": "^15.7.15",
"@types/rison": "0.1.0",
"@types/seedrandom": "^3.0.8",

View File

@@ -31,8 +31,7 @@ const defaultProps = {
};
export interface LoadableRenderer<Props>
extends
ComponentClass<Props & LoadableRendererProps>,
extends ComponentClass<Props & LoadableRendererProps>,
Loadable.LoadableComponent {}
export default function createLoadableRenderer<

View File

@@ -121,12 +121,15 @@ export function AsyncAceEditor(
return AsyncEsmComponent(async () => {
const reactAcePromise = import('react-ace');
const aceBuildsConfigPromise = import('ace-builds');
const cssWorkerUrlPromise =
import('ace-builds/src-min-noconflict/worker-css');
const javascriptWorkerUrlPromise =
import('ace-builds/src-min-noconflict/worker-javascript');
const htmlWorkerUrlPromise =
import('ace-builds/src-min-noconflict/worker-html');
const cssWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-css'
);
const javascriptWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-javascript'
);
const htmlWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-html'
);
const acequirePromise = import('ace-builds/src-min-noconflict/ace');
const [

View File

@@ -47,10 +47,8 @@ export type CodeEditorMode =
export type CodeEditorTheme = 'light' | 'dark';
export interface CodeEditorProps extends Omit<
IAceEditorProps,
'mode' | 'theme'
> {
export interface CodeEditorProps
extends Omit<IAceEditorProps, 'mode' | 'theme'> {
mode?: CodeEditorMode;
theme?: CodeEditorTheme;
name?: string;

View File

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

View File

@@ -0,0 +1,331 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { EmojiTextArea, type EmojiItem } from '.';
const meta: Meta<typeof EmojiTextArea> = {
title: 'Components/EmojiTextArea',
component: EmojiTextArea,
parameters: {
docs: {
description: {
component: `
A TextArea component with Slack-like emoji autocomplete.
## Features
- **Colon prefix trigger**: Type \`:sm\` to see smile emoji suggestions
- **Minimum 2 characters**: Popup only shows after typing 2+ characters (configurable)
- **Smart trigger detection**: Colon must be preceded by whitespace, start of line, or another emoji
- **Prevents accidental selection**: Quick Enter keypress creates newline instead of selecting
## Usage
\`\`\`tsx
import { EmojiTextArea } from '@superset-ui/core/components';
<EmojiTextArea
placeholder="Type :smile: to add emojis..."
onChange={(text) => console.log(text)}
onEmojiSelect={(emoji) => console.log('Selected:', emoji)}
/>
\`\`\`
## Trigger Behavior (Slack-like)
The emoji picker triggers in these scenarios:
- \`:sm\` - at the start of text
- \`hello :sm\` - after a space
- \`😀:sm\` - after another emoji
It does NOT trigger in:
- \`hello:sm\` - no space before colon
- \`http://example.com\` - colon preceded by letter
Try it out below!
`,
},
},
},
argTypes: {
minCharsBeforePopup: {
control: { type: 'number', min: 1, max: 5 },
description: 'Minimum characters after colon before showing popup',
defaultValue: 2,
},
maxSuggestions: {
control: { type: 'number', min: 1, max: 20 },
description: 'Maximum number of emoji suggestions to show',
defaultValue: 10,
},
placeholder: {
control: 'text',
description: 'Placeholder text',
},
rows: {
control: { type: 'number', min: 1, max: 20 },
description: 'Number of visible rows',
},
},
};
export default meta;
type Story = StoryObj<typeof EmojiTextArea>;
export const Default: Story = {
args: {
placeholder: 'Type :smile: or :thumbsup: to add emojis...',
rows: 4,
style: { width: '100%', maxWidth: 500 },
},
};
export const WithMinChars: Story = {
args: {
...Default.args,
minCharsBeforePopup: 3,
placeholder: 'Requires 3 characters after colon (e.g., :smi)',
},
};
export const WithMaxSuggestions: Story = {
args: {
...Default.args,
maxSuggestions: 5,
placeholder: 'Shows max 5 suggestions',
},
};
export const Controlled: Story = {
render: function ControlledStory() {
const [value, setValue] = useState('');
const [selectedEmojis, setSelectedEmojis] = useState<EmojiItem[]>([]);
return (
<div style={{ maxWidth: 500 }}>
<EmojiTextArea
value={value}
onChange={setValue}
onEmojiSelect={emoji => setSelectedEmojis(prev => [...prev, emoji])}
placeholder="Type :smile: or :heart: to add emojis..."
rows={4}
style={{ width: '100%' }}
/>
<div style={{ marginTop: 16 }}>
<strong>Current value:</strong>
<pre
style={{
background: 'var(--ant-color-bg-container)',
padding: 8,
borderRadius: 4,
border: '1px solid var(--ant-color-border)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{value || '(empty)'}
</pre>
</div>
{selectedEmojis.length > 0 && (
<div style={{ marginTop: 16 }}>
<strong>Selected emojis:</strong>
<div style={{ fontSize: 24, marginTop: 8 }}>
{selectedEmojis.map((e, i) => (
<span key={i} title={`:${e.shortcode}:`}>
{e.emoji}
</span>
))}
</div>
</div>
)}
</div>
);
},
};
export const SlackBehaviorDemo: Story = {
render: function SlackBehaviorDemoStory() {
const examples = [
{ input: ':sm', works: true, desc: 'Start of text' },
{ input: 'hello :sm', works: true, desc: 'After space' },
{
input: '😀:sm',
works: true,
desc: 'After emoji',
needsEmoji: true,
},
{ input: 'hello:sm', works: false, desc: 'No space before colon' },
{ input: ':s', works: false, desc: 'Only 1 character' },
];
return (
<div style={{ maxWidth: 600 }}>
<h3>Slack-like Trigger Behavior</h3>
<p style={{ color: 'var(--ant-color-text-secondary)' }}>
The emoji picker mimics Slack&apos;s behavior. Try these examples:
</p>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
marginBottom: 24,
}}
>
<thead>
<tr>
<th
style={{
textAlign: 'left',
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
Input
</th>
<th
style={{
textAlign: 'left',
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
Shows Popup?
</th>
<th
style={{
textAlign: 'left',
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
Reason
</th>
</tr>
</thead>
<tbody>
{examples.map((ex, i) => (
<tr key={i}>
<td
style={{
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
fontFamily: 'monospace',
}}
>
{ex.input}
</td>
<td
style={{
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
{ex.works ? '✅ Yes' : '❌ No'}
</td>
<td
style={{
padding: 8,
borderBottom: '1px solid var(--ant-color-border)',
}}
>
{ex.desc}
</td>
</tr>
))}
</tbody>
</table>
<EmojiTextArea
placeholder="Try the examples above..."
rows={4}
style={{ width: '100%' }}
/>
</div>
);
},
};
export const InForm: Story = {
render: function InFormStory() {
const [description, setDescription] = useState('');
const [title, setTitle] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// eslint-disable-next-line no-alert
alert(`Title: ${title}\nDescription: ${description}`);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: 500 }}>
<div style={{ marginBottom: 16 }}>
<label htmlFor="title" style={{ display: 'block', marginBottom: 4 }}>
Title
</label>
<input
id="title"
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Enter a title"
style={{
width: '100%',
padding: 8,
borderRadius: 4,
border: '1px solid var(--ant-color-border)',
}}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label
htmlFor="description"
style={{ display: 'block', marginBottom: 4 }}
>
Description (with emoji support)
</label>
<EmojiTextArea
id="description"
value={description}
onChange={setDescription}
placeholder="Add a description... use :smile: for emojis!"
rows={4}
style={{ width: '100%' }}
/>
</div>
<button
type="submit"
style={{
padding: '8px 16px',
background: 'var(--ant-color-primary)',
color: 'white',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
}}
>
Submit
</button>
</form>
);
},
};

View File

@@ -0,0 +1,170 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { EmojiTextArea } from '.';
import { filterEmojis, EMOJI_DATA } from './emojiData';
test('renders EmojiTextArea with placeholder', () => {
render(<EmojiTextArea placeholder="Type something..." />);
expect(screen.getByPlaceholderText('Type something...')).toBeInTheDocument();
});
test('renders EmojiTextArea as textarea element', () => {
render(<EmojiTextArea placeholder="Type here" />);
const textarea = screen.getByPlaceholderText('Type here');
expect(textarea.tagName.toLowerCase()).toBe('textarea');
});
test('allows typing in the textarea', async () => {
render(<EmojiTextArea placeholder="Type here" />);
const textarea = screen.getByPlaceholderText('Type here');
await userEvent.type(textarea, 'Hello world');
expect(textarea).toHaveValue('Hello world');
});
test('calls onChange when typing', async () => {
const onChange = jest.fn();
render(<EmojiTextArea placeholder="Type here" onChange={onChange} />);
const textarea = screen.getByPlaceholderText('Type here');
await userEvent.type(textarea, 'Hi');
expect(onChange).toHaveBeenCalled();
});
test('passes through rows prop', () => {
render(<EmojiTextArea placeholder="Type here" rows={5} />);
const textarea = screen.getByPlaceholderText('Type here');
expect(textarea).toHaveAttribute('rows', '5');
});
test('forwards ref to underlying component', () => {
const ref = { current: null };
render(<EmojiTextArea ref={ref} placeholder="Type here" />);
expect(ref.current).not.toBeNull();
});
test('renders controlled component with value prop', () => {
render(<EmojiTextArea value="Hello" onChange={() => {}} />);
expect(screen.getByDisplayValue('Hello')).toBeInTheDocument();
});
// ============================================
// Unit tests for filterEmojis utility function
// ============================================
test('filterEmojis returns matching emojis by shortcode', () => {
const results = filterEmojis('smile');
expect(results.length).toBeGreaterThan(0);
expect(results[0].shortcode).toBe('smile');
});
test('filterEmojis returns matching emojis by partial shortcode', () => {
const results = filterEmojis('sm');
expect(results.length).toBeGreaterThan(0);
// Should include smile, smirk, etc.
expect(results.some(e => e.shortcode.includes('sm'))).toBe(true);
});
test('filterEmojis returns matching emojis by keyword', () => {
const results = filterEmojis('happy');
expect(results.length).toBeGreaterThan(0);
// Should include emojis with 'happy' keyword
expect(results.some(e => e.keywords?.includes('happy'))).toBe(true);
});
test('filterEmojis is case insensitive', () => {
const results1 = filterEmojis('SMILE');
const results2 = filterEmojis('smile');
expect(results1.length).toBe(results2.length);
expect(results1[0].shortcode).toBe(results2[0].shortcode);
});
test('filterEmojis respects limit parameter', () => {
const results = filterEmojis('a', 5);
expect(results.length).toBeLessThanOrEqual(5);
});
test('filterEmojis returns empty array for empty search', () => {
const results = filterEmojis('');
expect(results).toEqual([]);
});
test('filterEmojis returns empty array for no matches', () => {
const results = filterEmojis('zzzznotanemoji');
expect(results).toEqual([]);
});
// ============================================
// Unit tests for EMOJI_DATA
// ============================================
test('EMOJI_DATA contains expected smileys', () => {
const smile = EMOJI_DATA.find(e => e.shortcode === 'smile');
expect(smile).toBeDefined();
expect(smile?.emoji).toBe('😄');
const joy = EMOJI_DATA.find(e => e.shortcode === 'joy');
expect(joy).toBeDefined();
expect(joy?.emoji).toBe('😂');
});
test('EMOJI_DATA contains expected gestures', () => {
const thumbsup = EMOJI_DATA.find(e => e.shortcode === 'thumbsup');
expect(thumbsup).toBeDefined();
expect(thumbsup?.emoji).toBe('👍');
const clap = EMOJI_DATA.find(e => e.shortcode === 'clap');
expect(clap).toBeDefined();
expect(clap?.emoji).toBe('👏');
});
test('EMOJI_DATA contains expected symbols', () => {
const heart = EMOJI_DATA.find(e => e.shortcode === 'heart');
expect(heart).toBeDefined();
expect(heart?.emoji).toBe('❤️');
const fire = EMOJI_DATA.find(e => e.shortcode === 'fire');
expect(fire).toBeDefined();
expect(fire?.emoji).toBe('🔥');
const checkmark = EMOJI_DATA.find(e => e.shortcode === 'white_check_mark');
expect(checkmark).toBeDefined();
expect(checkmark?.emoji).toBe('✅');
});
test('EMOJI_DATA items have required properties', () => {
EMOJI_DATA.forEach(item => {
expect(item).toHaveProperty('shortcode');
expect(item).toHaveProperty('emoji');
expect(typeof item.shortcode).toBe('string');
expect(typeof item.emoji).toBe('string');
expect(item.shortcode.length).toBeGreaterThan(0);
expect(item.emoji.length).toBeGreaterThan(0);
});
});
test('EMOJI_DATA shortcodes are unique', () => {
const shortcodes = EMOJI_DATA.map(e => e.shortcode);
const uniqueShortcodes = new Set(shortcodes);
expect(uniqueShortcodes.size).toBe(shortcodes.length);
});
test('EMOJI_DATA has a reasonable number of emojis', () => {
// Ensure we have a substantial emoji set
expect(EMOJI_DATA.length).toBeGreaterThan(100);
});

View File

@@ -0,0 +1,569 @@
/**
* 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 interface EmojiItem {
shortcode: string;
emoji: string;
keywords?: string[];
}
/**
* Common emoji data with shortcodes.
* This is a curated subset of emojis commonly used in Slack-like applications.
* Can be extended or replaced with a more comprehensive emoji library.
*/
export const EMOJI_DATA: EmojiItem[] = [
// Smileys & Emotion
{ shortcode: 'smile', emoji: '😄', keywords: ['happy', 'joy', 'glad'] },
{ shortcode: 'smiley', emoji: '😃', keywords: ['happy', 'joy'] },
{ shortcode: 'grinning', emoji: '😀', keywords: ['happy', 'smile'] },
{ shortcode: 'blush', emoji: '😊', keywords: ['happy', 'shy', 'smile'] },
{ shortcode: 'wink', emoji: '😉', keywords: ['flirt'] },
{
shortcode: 'heart_eyes',
emoji: '😍',
keywords: ['love', 'crush', 'adore'],
},
{ shortcode: 'kissing_heart', emoji: '😘', keywords: ['love', 'kiss'] },
{ shortcode: 'laughing', emoji: '😆', keywords: ['happy', 'haha', 'lol'] },
{ shortcode: 'sweat_smile', emoji: '😅', keywords: ['nervous', 'phew'] },
{ shortcode: 'joy', emoji: '😂', keywords: ['tears', 'laugh', 'lol', 'lmao'] },
{
shortcode: 'rofl',
emoji: '🤣',
keywords: ['rolling', 'laugh', 'lol', 'lmao'],
},
{ shortcode: 'relaxed', emoji: '☺️', keywords: ['calm', 'peace'] },
{ shortcode: 'yum', emoji: '😋', keywords: ['tasty', 'delicious'] },
{ shortcode: 'relieved', emoji: '😌', keywords: ['calm', 'peaceful'] },
{ shortcode: 'sunglasses', emoji: '😎', keywords: ['cool', 'awesome'] },
{ shortcode: 'smirk', emoji: '😏', keywords: ['sly', 'confident'] },
{ shortcode: 'neutral_face', emoji: '😐', keywords: ['meh', 'blank'] },
{ shortcode: 'expressionless', emoji: '😑', keywords: ['blank', 'meh'] },
{ shortcode: 'unamused', emoji: '😒', keywords: ['bored', 'meh'] },
{ shortcode: 'sweat', emoji: '😓', keywords: ['nervous', 'worried'] },
{ shortcode: 'pensive', emoji: '😔', keywords: ['sad', 'thoughtful'] },
{ shortcode: 'confused', emoji: '😕', keywords: ['puzzled', 'unsure'] },
{ shortcode: 'upside_down', emoji: '🙃', keywords: ['silly', 'sarcasm'] },
{ shortcode: 'thinking', emoji: '🤔', keywords: ['ponder', 'hmm'] },
{ shortcode: 'zipper_mouth', emoji: '🤐', keywords: ['secret', 'quiet'] },
{ shortcode: 'raised_eyebrow', emoji: '🤨', keywords: ['skeptical', 'doubt'] },
{ shortcode: 'rolling_eyes', emoji: '🙄', keywords: ['annoyed', 'whatever'] },
{ shortcode: 'grimacing', emoji: '😬', keywords: ['awkward', 'nervous'] },
{ shortcode: 'lying_face', emoji: '🤥', keywords: ['liar', 'pinocchio'] },
{ shortcode: 'shushing', emoji: '🤫', keywords: ['quiet', 'secret'] },
{ shortcode: 'hand_over_mouth', emoji: '🤭', keywords: ['oops', 'giggle'] },
{ shortcode: 'face_vomiting', emoji: '🤮', keywords: ['sick', 'gross'] },
{ shortcode: 'exploding_head', emoji: '🤯', keywords: ['mind', 'blown'] },
{ shortcode: 'cowboy', emoji: '🤠', keywords: ['western', 'yeehaw'] },
{ shortcode: 'partying', emoji: '🥳', keywords: ['party', 'celebration'] },
{ shortcode: 'star_struck', emoji: '🤩', keywords: ['excited', 'amazed'] },
{ shortcode: 'sleeping', emoji: '😴', keywords: ['zzz', 'tired'] },
{ shortcode: 'drooling', emoji: '🤤', keywords: ['hungry', 'want'] },
{ shortcode: 'sleepy', emoji: '😪', keywords: ['tired', 'zzz'] },
{ shortcode: 'mask', emoji: '😷', keywords: ['sick', 'covid'] },
{ shortcode: 'nerd', emoji: '🤓', keywords: ['geek', 'smart'] },
{ shortcode: 'monocle', emoji: '🧐', keywords: ['curious', 'inspect'] },
{ shortcode: 'worried', emoji: '😟', keywords: ['concerned', 'anxious'] },
{ shortcode: 'frowning', emoji: '🙁', keywords: ['sad', 'unhappy'] },
{ shortcode: 'open_mouth', emoji: '😮', keywords: ['surprised', 'wow'] },
{ shortcode: 'hushed', emoji: '😯', keywords: ['surprised', 'quiet'] },
{ shortcode: 'astonished', emoji: '😲', keywords: ['shocked', 'wow'] },
{ shortcode: 'flushed', emoji: '😳', keywords: ['embarrassed', 'shy'] },
{ shortcode: 'pleading', emoji: '🥺', keywords: ['puppy', 'please'] },
{ shortcode: 'cry', emoji: '😢', keywords: ['sad', 'tear'] },
{ shortcode: 'sob', emoji: '😭', keywords: ['crying', 'sad', 'tears'] },
{ shortcode: 'scream', emoji: '😱', keywords: ['scared', 'horror'] },
{ shortcode: 'confounded', emoji: '😖', keywords: ['frustrated'] },
{ shortcode: 'persevere', emoji: '😣', keywords: ['struggling'] },
{ shortcode: 'disappointed', emoji: '😞', keywords: ['sad', 'let down'] },
{ shortcode: 'fearful', emoji: '😨', keywords: ['scared', 'afraid'] },
{ shortcode: 'cold_sweat', emoji: '😰', keywords: ['nervous', 'anxious'] },
{ shortcode: 'weary', emoji: '😩', keywords: ['tired', 'exhausted'] },
{ shortcode: 'tired_face', emoji: '😫', keywords: ['exhausted'] },
{ shortcode: 'angry', emoji: '😠', keywords: ['mad', 'grumpy'] },
{ shortcode: 'rage', emoji: '😡', keywords: ['angry', 'furious'] },
{ shortcode: 'triumph', emoji: '😤', keywords: ['proud', 'huffing'] },
{ shortcode: 'skull', emoji: '💀', keywords: ['dead', 'death'] },
{ shortcode: 'poop', emoji: '💩', keywords: ['crap', 'shit'] },
{ shortcode: 'clown', emoji: '🤡', keywords: ['funny', 'circus'] },
{ shortcode: 'imp', emoji: '👿', keywords: ['devil', 'evil'] },
{ shortcode: 'ghost', emoji: '👻', keywords: ['boo', 'spooky'] },
{ shortcode: 'alien', emoji: '👽', keywords: ['ufo', 'space'] },
{ shortcode: 'robot', emoji: '🤖', keywords: ['bot', 'machine'] },
{ shortcode: 'cat', emoji: '😺', keywords: ['kitty', 'meow'] },
{ shortcode: 'heart_eyes_cat', emoji: '😻', keywords: ['love', 'cat'] },
{ shortcode: 'joy_cat', emoji: '😹', keywords: ['laugh', 'cat'] },
{ shortcode: 'crying_cat', emoji: '😿', keywords: ['sad', 'cat'] },
{ shortcode: 'pouting_cat', emoji: '😾', keywords: ['angry', 'cat'] },
{ shortcode: 'see_no_evil', emoji: '🙈', keywords: ['monkey', 'shy'] },
{ shortcode: 'hear_no_evil', emoji: '🙉', keywords: ['monkey'] },
{ shortcode: 'speak_no_evil', emoji: '🙊', keywords: ['monkey', 'secret'] },
// Gestures & Body
{ shortcode: 'wave', emoji: '👋', keywords: ['hello', 'bye', 'hi'] },
{ shortcode: 'raised_hand', emoji: '✋', keywords: ['stop', 'high five'] },
{ shortcode: 'ok_hand', emoji: '👌', keywords: ['perfect', 'nice'] },
{ shortcode: 'pinching_hand', emoji: '🤏', keywords: ['small', 'tiny'] },
{ shortcode: 'v', emoji: '✌️', keywords: ['peace', 'victory'] },
{ shortcode: 'crossed_fingers', emoji: '🤞', keywords: ['luck', 'hope'] },
{ shortcode: 'love_you', emoji: '🤟', keywords: ['ily', 'sign'] },
{ shortcode: 'metal', emoji: '🤘', keywords: ['rock', 'horns'] },
{ shortcode: 'call_me', emoji: '🤙', keywords: ['phone', 'shaka'] },
{ shortcode: 'point_left', emoji: '👈', keywords: ['direction'] },
{ shortcode: 'point_right', emoji: '👉', keywords: ['direction'] },
{ shortcode: 'point_up', emoji: '👆', keywords: ['direction'] },
{ shortcode: 'point_down', emoji: '👇', keywords: ['direction'] },
{ shortcode: 'middle_finger', emoji: '🖕', keywords: ['flip', 'rude'] },
{ shortcode: 'thumbsup', emoji: '👍', keywords: ['yes', 'good', '+1'] },
{ shortcode: 'thumbsdown', emoji: '👎', keywords: ['no', 'bad', '-1'] },
{ shortcode: 'fist', emoji: '✊', keywords: ['power', 'punch'] },
{ shortcode: 'punch', emoji: '👊', keywords: ['fist', 'bump'] },
{ shortcode: 'clap', emoji: '👏', keywords: ['applause', 'bravo'] },
{ shortcode: 'raised_hands', emoji: '🙌', keywords: ['celebration', 'yay'] },
{ shortcode: 'open_hands', emoji: '👐', keywords: ['hug', 'open'] },
{ shortcode: 'palms_up', emoji: '🤲', keywords: ['prayer', 'request'] },
{ shortcode: 'handshake', emoji: '🤝', keywords: ['deal', 'agreement'] },
{ shortcode: 'pray', emoji: '🙏', keywords: ['please', 'thanks', 'namaste'] },
{ shortcode: 'writing', emoji: '✍️', keywords: ['write', 'pen'] },
{ shortcode: 'nail_care', emoji: '💅', keywords: ['nails', 'fabulous'] },
{ shortcode: 'selfie', emoji: '🤳', keywords: ['photo', 'camera'] },
{ shortcode: 'muscle', emoji: '💪', keywords: ['strong', 'flex', 'bicep'] },
{ shortcode: 'leg', emoji: '🦵', keywords: ['kick'] },
{ shortcode: 'foot', emoji: '🦶', keywords: ['kick', 'step'] },
{ shortcode: 'ear', emoji: '👂', keywords: ['listen', 'hear'] },
{ shortcode: 'nose', emoji: '👃', keywords: ['smell', 'sniff'] },
{ shortcode: 'brain', emoji: '🧠', keywords: ['think', 'smart'] },
{ shortcode: 'eyes', emoji: '👀', keywords: ['look', 'see', 'watch'] },
{ shortcode: 'eye', emoji: '👁️', keywords: ['look', 'see'] },
{ shortcode: 'tongue', emoji: '👅', keywords: ['taste', 'lick'] },
{ shortcode: 'lips', emoji: '👄', keywords: ['mouth', 'kiss'] },
{ shortcode: 'baby', emoji: '👶', keywords: ['child', 'infant'] },
{ shortcode: 'person', emoji: '🧑', keywords: ['human', 'adult'] },
{ shortcode: 'man', emoji: '👨', keywords: ['male', 'guy'] },
{ shortcode: 'woman', emoji: '👩', keywords: ['female', 'lady'] },
{ shortcode: 'older_person', emoji: '🧓', keywords: ['senior', 'elderly'] },
// Hearts & Love
{ shortcode: 'heart', emoji: '❤️', keywords: ['love', 'red'] },
{ shortcode: 'orange_heart', emoji: '🧡', keywords: ['love'] },
{ shortcode: 'yellow_heart', emoji: '💛', keywords: ['love'] },
{ shortcode: 'green_heart', emoji: '💚', keywords: ['love'] },
{ shortcode: 'blue_heart', emoji: '💙', keywords: ['love'] },
{ shortcode: 'purple_heart', emoji: '💜', keywords: ['love'] },
{ shortcode: 'black_heart', emoji: '🖤', keywords: ['love', 'dark'] },
{ shortcode: 'white_heart', emoji: '🤍', keywords: ['love', 'pure'] },
{ shortcode: 'brown_heart', emoji: '🤎', keywords: ['love'] },
{ shortcode: 'broken_heart', emoji: '💔', keywords: ['sad', 'heartbreak'] },
{ shortcode: 'heartbeat', emoji: '💓', keywords: ['love', 'pulse'] },
{ shortcode: 'heartpulse', emoji: '💗', keywords: ['love', 'growing'] },
{ shortcode: 'two_hearts', emoji: '💕', keywords: ['love', 'romance'] },
{ shortcode: 'revolving_hearts', emoji: '💞', keywords: ['love'] },
{ shortcode: 'cupid', emoji: '💘', keywords: ['love', 'arrow'] },
{ shortcode: 'sparkling_heart', emoji: '💖', keywords: ['love', 'sparkle'] },
{ shortcode: 'gift_heart', emoji: '💝', keywords: ['love', 'valentine'] },
{ shortcode: 'heart_decoration', emoji: '💟', keywords: ['love'] },
{ shortcode: 'kiss', emoji: '💋', keywords: ['love', 'lips'] },
{ shortcode: 'love_letter', emoji: '💌', keywords: ['email', 'message'] },
// Symbols & Objects
{ shortcode: 'fire', emoji: '🔥', keywords: ['hot', 'lit', 'flame'] },
{ shortcode: 'star', emoji: '⭐', keywords: ['favorite', 'rating'] },
{ shortcode: 'sparkles', emoji: '✨', keywords: ['shiny', 'new', 'magic'] },
{ shortcode: 'zap', emoji: '⚡', keywords: ['lightning', 'power'] },
{ shortcode: 'boom', emoji: '💥', keywords: ['explosion', 'collision'] },
{ shortcode: 'dizzy', emoji: '💫', keywords: ['star', 'dazed'] },
{ shortcode: 'speech_balloon', emoji: '💬', keywords: ['talk', 'chat'] },
{ shortcode: 'thought_balloon', emoji: '💭', keywords: ['think', 'idea'] },
{ shortcode: 'zzz', emoji: '💤', keywords: ['sleep', 'tired'] },
{ shortcode: 'wave_emoji', emoji: '🌊', keywords: ['ocean', 'water'] },
{ shortcode: 'droplet', emoji: '💧', keywords: ['water', 'sweat'] },
{ shortcode: 'sweat_drops', emoji: '💦', keywords: ['water', 'splash'] },
{ shortcode: 'dash', emoji: '💨', keywords: ['wind', 'running'] },
{ shortcode: 'hole', emoji: '🕳️', keywords: ['empty', 'void'] },
{ shortcode: 'bomb', emoji: '💣', keywords: ['explosive', 'danger'] },
{ shortcode: 'money', emoji: '💰', keywords: ['bag', 'cash', 'dollar'] },
{ shortcode: 'dollar', emoji: '💵', keywords: ['money', 'cash'] },
{ shortcode: 'gem', emoji: '💎', keywords: ['diamond', 'jewel'] },
{ shortcode: 'bulb', emoji: '💡', keywords: ['idea', 'light'] },
{ shortcode: 'bell', emoji: '🔔', keywords: ['notification', 'alert'] },
{ shortcode: 'loudspeaker', emoji: '📢', keywords: ['announce'] },
{ shortcode: 'mega', emoji: '📣', keywords: ['megaphone', 'announce'] },
{ shortcode: 'lock', emoji: '🔒', keywords: ['secure', 'closed'] },
{ shortcode: 'unlock', emoji: '🔓', keywords: ['open', 'access'] },
{ shortcode: 'key', emoji: '🔑', keywords: ['password', 'access'] },
{ shortcode: 'magnifying_glass', emoji: '🔍', keywords: ['search', 'find'] },
{ shortcode: 'link', emoji: '🔗', keywords: ['chain', 'url'] },
{ shortcode: 'paperclip', emoji: '📎', keywords: ['attach'] },
{ shortcode: 'scissors', emoji: '✂️', keywords: ['cut', 'snip'] },
{ shortcode: 'hammer', emoji: '🔨', keywords: ['tool', 'build'] },
{ shortcode: 'wrench', emoji: '🔧', keywords: ['tool', 'fix'] },
{ shortcode: 'gear', emoji: '⚙️', keywords: ['settings', 'cog'] },
{ shortcode: 'shield', emoji: '🛡️', keywords: ['protect', 'security'] },
{ shortcode: 'trophy', emoji: '🏆', keywords: ['win', 'first', 'award'] },
{ shortcode: 'medal', emoji: '🏅', keywords: ['award', 'sports'] },
{ shortcode: 'first_place', emoji: '🥇', keywords: ['gold', 'winner'] },
{ shortcode: 'second_place', emoji: '🥈', keywords: ['silver'] },
{ shortcode: 'third_place', emoji: '🥉', keywords: ['bronze'] },
{ shortcode: 'soccer', emoji: '⚽', keywords: ['football', 'sports'] },
{ shortcode: 'basketball', emoji: '🏀', keywords: ['sports', 'ball'] },
{ shortcode: 'football', emoji: '🏈', keywords: ['sports', 'american'] },
{ shortcode: 'baseball', emoji: '⚾', keywords: ['sports', 'ball'] },
{ shortcode: 'tennis', emoji: '🎾', keywords: ['sports', 'ball'] },
{ shortcode: 'dart', emoji: '🎯', keywords: ['target', 'bullseye'] },
{ shortcode: 'video_game', emoji: '🎮', keywords: ['gaming', 'controller'] },
{ shortcode: 'slot_machine', emoji: '🎰', keywords: ['gambling', 'casino'] },
{ shortcode: 'game_die', emoji: '🎲', keywords: ['dice', 'random'] },
{ shortcode: 'jigsaw', emoji: '🧩', keywords: ['puzzle', 'piece'] },
{ shortcode: 'art', emoji: '🎨', keywords: ['palette', 'paint'] },
{ shortcode: 'performing_arts', emoji: '🎭', keywords: ['theater', 'drama'] },
{ shortcode: 'microphone', emoji: '🎤', keywords: ['sing', 'karaoke'] },
{ shortcode: 'headphones', emoji: '🎧', keywords: ['music', 'audio'] },
{ shortcode: 'musical_note', emoji: '🎵', keywords: ['music', 'song'] },
{ shortcode: 'notes', emoji: '🎶', keywords: ['music', 'melody'] },
{ shortcode: 'guitar', emoji: '🎸', keywords: ['music', 'rock'] },
{ shortcode: 'piano', emoji: '🎹', keywords: ['music', 'keys'] },
{ shortcode: 'drum', emoji: '🥁', keywords: ['music', 'beat'] },
{ shortcode: 'trumpet', emoji: '🎺', keywords: ['music', 'brass'] },
{ shortcode: 'violin', emoji: '🎻', keywords: ['music', 'string'] },
{ shortcode: 'movie_camera', emoji: '🎥', keywords: ['film', 'video'] },
{ shortcode: 'camera', emoji: '📷', keywords: ['photo', 'picture'] },
{ shortcode: 'tv', emoji: '📺', keywords: ['television', 'watch'] },
{ shortcode: 'computer', emoji: '💻', keywords: ['laptop', 'pc'] },
{ shortcode: 'keyboard', emoji: '⌨️', keywords: ['type', 'computer'] },
{ shortcode: 'phone', emoji: '📱', keywords: ['mobile', 'cell'] },
{ shortcode: 'email', emoji: '📧', keywords: ['mail', 'message'] },
{ shortcode: 'inbox', emoji: '📥', keywords: ['mail', 'receive'] },
{ shortcode: 'outbox', emoji: '📤', keywords: ['mail', 'send'] },
{ shortcode: 'package', emoji: '📦', keywords: ['box', 'delivery'] },
{ shortcode: 'memo', emoji: '📝', keywords: ['note', 'write'] },
{ shortcode: 'page', emoji: '📄', keywords: ['document', 'file'] },
{ shortcode: 'bookmark', emoji: '🔖', keywords: ['save', 'tag'] },
{ shortcode: 'book', emoji: '📖', keywords: ['read', 'open'] },
{ shortcode: 'books', emoji: '📚', keywords: ['library', 'study'] },
{ shortcode: 'newspaper', emoji: '📰', keywords: ['news', 'article'] },
{ shortcode: 'calendar', emoji: '📅', keywords: ['date', 'schedule'] },
{ shortcode: 'chart', emoji: '📈', keywords: ['graph', 'increase'] },
{ shortcode: 'chart_down', emoji: '📉', keywords: ['graph', 'decrease'] },
{ shortcode: 'bar_chart', emoji: '📊', keywords: ['graph', 'stats'] },
{ shortcode: 'clipboard', emoji: '📋', keywords: ['list', 'todo'] },
{ shortcode: 'pushpin', emoji: '📌', keywords: ['pin', 'location'] },
{ shortcode: 'round_pushpin', emoji: '📍', keywords: ['pin', 'location'] },
{ shortcode: 'triangular_ruler', emoji: '📐', keywords: ['math', 'measure'] },
{ shortcode: 'straight_ruler', emoji: '📏', keywords: ['math', 'measure'] },
{ shortcode: 'pencil', emoji: '✏️', keywords: ['write', 'draw'] },
{ shortcode: 'pen', emoji: '🖊️', keywords: ['write', 'sign'] },
{ shortcode: 'crayon', emoji: '🖍️', keywords: ['draw', 'color'] },
{ shortcode: 'paintbrush', emoji: '🖌️', keywords: ['art', 'paint'] },
{ shortcode: 'folder', emoji: '📁', keywords: ['file', 'directory'] },
{ shortcode: 'open_folder', emoji: '📂', keywords: ['file', 'directory'] },
// Nature & Animals
{ shortcode: 'dog', emoji: '🐶', keywords: ['puppy', 'pet', 'woof'] },
{ shortcode: 'cat_face', emoji: '🐱', keywords: ['kitty', 'pet', 'meow'] },
{ shortcode: 'mouse', emoji: '🐭', keywords: ['rodent'] },
{ shortcode: 'hamster', emoji: '🐹', keywords: ['pet', 'rodent'] },
{ shortcode: 'rabbit', emoji: '🐰', keywords: ['bunny', 'pet'] },
{ shortcode: 'fox', emoji: '🦊', keywords: ['animal'] },
{ shortcode: 'bear', emoji: '🐻', keywords: ['animal'] },
{ shortcode: 'panda', emoji: '🐼', keywords: ['animal', 'cute'] },
{ shortcode: 'koala', emoji: '🐨', keywords: ['animal', 'australia'] },
{ shortcode: 'tiger', emoji: '🐯', keywords: ['animal', 'cat'] },
{ shortcode: 'lion', emoji: '🦁', keywords: ['animal', 'king'] },
{ shortcode: 'cow', emoji: '🐮', keywords: ['animal', 'farm'] },
{ shortcode: 'pig', emoji: '🐷', keywords: ['animal', 'farm'] },
{ shortcode: 'frog', emoji: '🐸', keywords: ['animal', 'toad'] },
{ shortcode: 'monkey_face', emoji: '🐵', keywords: ['animal', 'ape'] },
{ shortcode: 'chicken', emoji: '🐔', keywords: ['animal', 'farm', 'hen'] },
{ shortcode: 'penguin', emoji: '🐧', keywords: ['animal', 'bird'] },
{ shortcode: 'bird', emoji: '🐦', keywords: ['animal', 'fly'] },
{ shortcode: 'eagle', emoji: '🦅', keywords: ['animal', 'bird'] },
{ shortcode: 'duck', emoji: '🦆', keywords: ['animal', 'bird', 'quack'] },
{ shortcode: 'owl', emoji: '🦉', keywords: ['animal', 'bird', 'night'] },
{ shortcode: 'bat', emoji: '🦇', keywords: ['animal', 'night', 'vampire'] },
{ shortcode: 'wolf', emoji: '🐺', keywords: ['animal'] },
{ shortcode: 'horse', emoji: '🐴', keywords: ['animal'] },
{ shortcode: 'unicorn', emoji: '🦄', keywords: ['animal', 'magic'] },
{ shortcode: 'bee', emoji: '🐝', keywords: ['insect', 'honey'] },
{ shortcode: 'bug', emoji: '🐛', keywords: ['insect', 'caterpillar'] },
{ shortcode: 'butterfly', emoji: '🦋', keywords: ['insect', 'pretty'] },
{ shortcode: 'snail', emoji: '🐌', keywords: ['slow'] },
{ shortcode: 'lady_beetle', emoji: '🐞', keywords: ['insect', 'bug'] },
{ shortcode: 'ant', emoji: '🐜', keywords: ['insect', 'bug'] },
{ shortcode: 'spider', emoji: '🕷️', keywords: ['insect', 'scary'] },
{ shortcode: 'turtle', emoji: '🐢', keywords: ['animal', 'slow'] },
{ shortcode: 'snake', emoji: '🐍', keywords: ['animal', 'reptile'] },
{ shortcode: 'dragon', emoji: '🐲', keywords: ['animal', 'mythical'] },
{ shortcode: 'dinosaur', emoji: '🦕', keywords: ['animal', 'extinct'] },
{ shortcode: 't_rex', emoji: '🦖', keywords: ['animal', 'dinosaur'] },
{ shortcode: 'whale', emoji: '🐳', keywords: ['animal', 'ocean'] },
{ shortcode: 'dolphin', emoji: '🐬', keywords: ['animal', 'ocean'] },
{ shortcode: 'fish', emoji: '🐟', keywords: ['animal', 'ocean'] },
{ shortcode: 'tropical_fish', emoji: '🐠', keywords: ['animal', 'ocean'] },
{ shortcode: 'shark', emoji: '🦈', keywords: ['animal', 'ocean'] },
{ shortcode: 'octopus', emoji: '🐙', keywords: ['animal', 'ocean'] },
{ shortcode: 'crab', emoji: '🦀', keywords: ['animal', 'ocean'] },
{ shortcode: 'lobster', emoji: '🦞', keywords: ['animal', 'ocean'] },
{ shortcode: 'shrimp', emoji: '🦐', keywords: ['animal', 'ocean'] },
// Plants & Nature
{ shortcode: 'bouquet', emoji: '💐', keywords: ['flowers', 'gift'] },
{ shortcode: 'cherry_blossom', emoji: '🌸', keywords: ['flower', 'spring'] },
{ shortcode: 'rose', emoji: '🌹', keywords: ['flower', 'love'] },
{ shortcode: 'tulip', emoji: '🌷', keywords: ['flower', 'spring'] },
{ shortcode: 'sunflower', emoji: '🌻', keywords: ['flower', 'summer'] },
{ shortcode: 'hibiscus', emoji: '🌺', keywords: ['flower', 'tropical'] },
{ shortcode: 'seedling', emoji: '🌱', keywords: ['plant', 'grow'] },
{ shortcode: 'evergreen_tree', emoji: '🌲', keywords: ['tree', 'pine'] },
{ shortcode: 'deciduous_tree', emoji: '🌳', keywords: ['tree'] },
{ shortcode: 'palm_tree', emoji: '🌴', keywords: ['tree', 'tropical'] },
{ shortcode: 'cactus', emoji: '🌵', keywords: ['plant', 'desert'] },
{ shortcode: 'herb', emoji: '🌿', keywords: ['plant', 'leaf'] },
{ shortcode: 'shamrock', emoji: '☘️', keywords: ['clover', 'irish'] },
{ shortcode: 'four_leaf_clover', emoji: '🍀', keywords: ['luck', 'irish'] },
{ shortcode: 'maple_leaf', emoji: '🍁', keywords: ['fall', 'autumn'] },
{ shortcode: 'fallen_leaf', emoji: '🍂', keywords: ['fall', 'autumn'] },
{ shortcode: 'leaves', emoji: '🍃', keywords: ['leaf', 'wind'] },
{ shortcode: 'mushroom', emoji: '🍄', keywords: ['fungus'] },
// Food & Drink
{ shortcode: 'apple', emoji: '🍎', keywords: ['fruit', 'red'] },
{ shortcode: 'green_apple', emoji: '🍏', keywords: ['fruit'] },
{ shortcode: 'pear', emoji: '🍐', keywords: ['fruit'] },
{ shortcode: 'orange', emoji: '🍊', keywords: ['fruit', 'citrus'] },
{ shortcode: 'lemon', emoji: '🍋', keywords: ['fruit', 'citrus'] },
{ shortcode: 'banana', emoji: '🍌', keywords: ['fruit'] },
{ shortcode: 'watermelon', emoji: '🍉', keywords: ['fruit', 'summer'] },
{ shortcode: 'grapes', emoji: '🍇', keywords: ['fruit', 'wine'] },
{ shortcode: 'strawberry', emoji: '🍓', keywords: ['fruit', 'berry'] },
{ shortcode: 'cherries', emoji: '🍒', keywords: ['fruit'] },
{ shortcode: 'peach', emoji: '🍑', keywords: ['fruit'] },
{ shortcode: 'mango', emoji: '🥭', keywords: ['fruit', 'tropical'] },
{ shortcode: 'pineapple', emoji: '🍍', keywords: ['fruit', 'tropical'] },
{ shortcode: 'coconut', emoji: '🥥', keywords: ['fruit', 'tropical'] },
{ shortcode: 'avocado', emoji: '🥑', keywords: ['fruit', 'guacamole'] },
{ shortcode: 'tomato', emoji: '🍅', keywords: ['vegetable', 'red'] },
{ shortcode: 'eggplant', emoji: '🍆', keywords: ['vegetable', 'purple'] },
{ shortcode: 'potato', emoji: '🥔', keywords: ['vegetable', 'spud'] },
{ shortcode: 'carrot', emoji: '🥕', keywords: ['vegetable', 'orange'] },
{ shortcode: 'corn', emoji: '🌽', keywords: ['vegetable', 'maize'] },
{ shortcode: 'hot_pepper', emoji: '🌶️', keywords: ['spicy', 'chili'] },
{ shortcode: 'broccoli', emoji: '🥦', keywords: ['vegetable', 'green'] },
{ shortcode: 'bread', emoji: '🍞', keywords: ['food', 'toast'] },
{ shortcode: 'croissant', emoji: '🥐', keywords: ['food', 'french'] },
{ shortcode: 'pretzel', emoji: '🥨', keywords: ['food', 'snack'] },
{ shortcode: 'bagel', emoji: '🥯', keywords: ['food', 'breakfast'] },
{ shortcode: 'cheese', emoji: '🧀', keywords: ['food', 'dairy'] },
{ shortcode: 'egg', emoji: '🥚', keywords: ['food', 'breakfast'] },
{ shortcode: 'bacon', emoji: '🥓', keywords: ['food', 'breakfast'] },
{ shortcode: 'pancakes', emoji: '🥞', keywords: ['food', 'breakfast'] },
{ shortcode: 'waffle', emoji: '🧇', keywords: ['food', 'breakfast'] },
{ shortcode: 'steak', emoji: '🥩', keywords: ['food', 'meat'] },
{ shortcode: 'poultry_leg', emoji: '🍗', keywords: ['food', 'chicken'] },
{ shortcode: 'hamburger', emoji: '🍔', keywords: ['food', 'burger'] },
{ shortcode: 'fries', emoji: '🍟', keywords: ['food', 'fast'] },
{ shortcode: 'pizza', emoji: '🍕', keywords: ['food', 'italian'] },
{ shortcode: 'hot_dog', emoji: '🌭', keywords: ['food', 'fast'] },
{ shortcode: 'sandwich', emoji: '🥪', keywords: ['food', 'lunch'] },
{ shortcode: 'taco', emoji: '🌮', keywords: ['food', 'mexican'] },
{ shortcode: 'burrito', emoji: '🌯', keywords: ['food', 'mexican'] },
{ shortcode: 'sushi', emoji: '🍣', keywords: ['food', 'japanese'] },
{ shortcode: 'ramen', emoji: '🍜', keywords: ['food', 'noodles'] },
{ shortcode: 'spaghetti', emoji: '🍝', keywords: ['food', 'pasta'] },
{ shortcode: 'curry', emoji: '🍛', keywords: ['food', 'rice'] },
{ shortcode: 'rice', emoji: '🍚', keywords: ['food', 'white'] },
{ shortcode: 'salad', emoji: '🥗', keywords: ['food', 'healthy'] },
{ shortcode: 'popcorn', emoji: '🍿', keywords: ['food', 'movie'] },
{ shortcode: 'cake', emoji: '🎂', keywords: ['food', 'birthday'] },
{ shortcode: 'cupcake', emoji: '🧁', keywords: ['food', 'sweet'] },
{ shortcode: 'pie', emoji: '🥧', keywords: ['food', 'dessert'] },
{ shortcode: 'cookie', emoji: '🍪', keywords: ['food', 'sweet'] },
{ shortcode: 'chocolate', emoji: '🍫', keywords: ['food', 'sweet'] },
{ shortcode: 'candy', emoji: '🍬', keywords: ['food', 'sweet'] },
{ shortcode: 'lollipop', emoji: '🍭', keywords: ['food', 'sweet'] },
{ shortcode: 'donut', emoji: '🍩', keywords: ['food', 'sweet'] },
{ shortcode: 'ice_cream', emoji: '🍨', keywords: ['food', 'dessert'] },
{ shortcode: 'icecream', emoji: '🍦', keywords: ['food', 'dessert', 'cone'] },
{ shortcode: 'coffee', emoji: '☕', keywords: ['drink', 'caffeine'] },
{ shortcode: 'tea', emoji: '🍵', keywords: ['drink', 'green'] },
{ shortcode: 'beer', emoji: '🍺', keywords: ['drink', 'alcohol'] },
{ shortcode: 'beers', emoji: '🍻', keywords: ['drink', 'cheers'] },
{ shortcode: 'wine_glass', emoji: '🍷', keywords: ['drink', 'alcohol'] },
{ shortcode: 'cocktail', emoji: '🍸', keywords: ['drink', 'alcohol'] },
{ shortcode: 'tropical_drink', emoji: '🍹', keywords: ['drink', 'vacation'] },
{ shortcode: 'champagne', emoji: '🍾', keywords: ['drink', 'celebrate'] },
{ shortcode: 'milk', emoji: '🥛', keywords: ['drink', 'dairy'] },
{ shortcode: 'baby_bottle', emoji: '🍼', keywords: ['drink', 'infant'] },
{ shortcode: 'juice', emoji: '🧃', keywords: ['drink', 'box'] },
{ shortcode: 'cup_with_straw', emoji: '🥤', keywords: ['drink', 'soda'] },
// Weather & Nature
{ shortcode: 'sun', emoji: '☀️', keywords: ['weather', 'sunny', 'bright'] },
{ shortcode: 'moon', emoji: '🌙', keywords: ['night', 'sleep'] },
{ shortcode: 'full_moon', emoji: '🌕', keywords: ['night', 'lunar'] },
{ shortcode: 'new_moon', emoji: '🌑', keywords: ['night', 'dark'] },
{ shortcode: 'star2', emoji: '🌟', keywords: ['glow', 'sparkle'] },
{ shortcode: 'milky_way', emoji: '🌌', keywords: ['galaxy', 'space'] },
{ shortcode: 'cloud', emoji: '☁️', keywords: ['weather', 'sky'] },
{ shortcode: 'sun_behind_cloud', emoji: '⛅', keywords: ['weather'] },
{ shortcode: 'cloud_with_rain', emoji: '🌧️', keywords: ['weather', 'rainy'] },
{ shortcode: 'thunder', emoji: '⛈️', keywords: ['weather', 'storm'] },
{ shortcode: 'snowflake', emoji: '❄️', keywords: ['weather', 'cold'] },
{ shortcode: 'snowman', emoji: '☃️', keywords: ['winter', 'snow'] },
{ shortcode: 'wind_blowing', emoji: '🌬️', keywords: ['weather', 'air'] },
{ shortcode: 'tornado', emoji: '🌪️', keywords: ['weather', 'storm'] },
{ shortcode: 'fog', emoji: '🌫️', keywords: ['weather', 'mist'] },
{ shortcode: 'umbrella', emoji: '☂️', keywords: ['rain', 'weather'] },
{ shortcode: 'rainbow', emoji: '🌈', keywords: ['weather', 'pride'] },
{ shortcode: 'earth', emoji: '🌍', keywords: ['world', 'planet'] },
{ shortcode: 'earth_americas', emoji: '🌎', keywords: ['world', 'planet'] },
{ shortcode: 'earth_asia', emoji: '🌏', keywords: ['world', 'planet'] },
{ shortcode: 'rocket', emoji: '🚀', keywords: ['space', 'launch'] },
{ shortcode: 'satellite', emoji: '🛰️', keywords: ['space', 'orbit'] },
{ shortcode: 'ufo', emoji: '🛸', keywords: ['alien', 'space'] },
// Checkmarks & Common Symbols
{ shortcode: 'white_check_mark', emoji: '✅', keywords: ['done', 'yes', 'ok'] },
{ shortcode: 'check', emoji: '✔️', keywords: ['done', 'yes'] },
{ shortcode: 'x', emoji: '❌', keywords: ['no', 'wrong', 'cancel'] },
{ shortcode: 'cross_mark', emoji: '❎', keywords: ['no', 'wrong'] },
{ shortcode: 'plus', emoji: '', keywords: ['add', 'math'] },
{ shortcode: 'minus', emoji: '', keywords: ['subtract', 'math'] },
{ shortcode: 'divide', emoji: '➗', keywords: ['math', 'division'] },
{ shortcode: 'multiply', emoji: '✖️', keywords: ['math', 'times'] },
{ shortcode: 'infinity', emoji: '♾️', keywords: ['forever', 'endless'] },
{ shortcode: 'question', emoji: '❓', keywords: ['ask', 'what'] },
{ shortcode: 'grey_question', emoji: '❔', keywords: ['ask', 'what'] },
{ shortcode: 'exclamation', emoji: '❗', keywords: ['alert', 'important'] },
{ shortcode: 'grey_exclamation', emoji: '❕', keywords: ['alert'] },
{ shortcode: 'warning', emoji: '⚠️', keywords: ['alert', 'caution'] },
{ shortcode: 'no_entry', emoji: '⛔', keywords: ['stop', 'forbidden'] },
{ shortcode: 'prohibited', emoji: '🚫', keywords: ['stop', 'banned'] },
{ shortcode: 'recycle', emoji: '♻️', keywords: ['environment', 'green'] },
{ shortcode: 'arrow_up', emoji: '⬆️', keywords: ['direction', 'north'] },
{ shortcode: 'arrow_down', emoji: '⬇️', keywords: ['direction', 'south'] },
{ shortcode: 'arrow_left', emoji: '⬅️', keywords: ['direction', 'west'] },
{ shortcode: 'arrow_right', emoji: '➡️', keywords: ['direction', 'east'] },
{
shortcode: 'arrow_upper_right',
emoji: '↗️',
keywords: ['direction', 'northeast'],
},
{
shortcode: 'arrow_lower_right',
emoji: '↘️',
keywords: ['direction', 'southeast'],
},
{
shortcode: 'arrow_lower_left',
emoji: '↙️',
keywords: ['direction', 'southwest'],
},
{
shortcode: 'arrow_upper_left',
emoji: '↖️',
keywords: ['direction', 'northwest'],
},
{
shortcode: 'left_right_arrow',
emoji: '↔️',
keywords: ['direction', 'horizontal'],
},
{
shortcode: 'up_down_arrow',
emoji: '↕️',
keywords: ['direction', 'vertical'],
},
{ shortcode: 'arrows_clockwise', emoji: '🔃', keywords: ['refresh', 'sync'] },
{
shortcode: 'arrows_counterclockwise',
emoji: '🔄',
keywords: ['refresh', 'sync'],
},
{ shortcode: 'back', emoji: '🔙', keywords: ['return', 'previous'] },
{ shortcode: 'end', emoji: '🔚', keywords: ['finish', 'last'] },
{ shortcode: 'on', emoji: '🔛', keywords: ['active'] },
{ shortcode: 'soon', emoji: '🔜', keywords: ['coming', 'future'] },
{ shortcode: 'top', emoji: '🔝', keywords: ['best', 'first'] },
{ shortcode: 'new', emoji: '🆕', keywords: ['fresh', 'latest'] },
{ shortcode: 'free', emoji: '🆓', keywords: ['gratis', 'cost'] },
{ shortcode: 'up', emoji: '🆙', keywords: ['increase', 'level'] },
{ shortcode: 'cool', emoji: '🆒', keywords: ['nice', 'awesome'] },
{ shortcode: 'ok', emoji: '🆗', keywords: ['yes', 'approve'] },
{ shortcode: 'sos', emoji: '🆘', keywords: ['help', 'emergency'] },
{ shortcode: 'stop_sign', emoji: '🛑', keywords: ['halt', 'cease'] },
{ shortcode: 'a', emoji: '🅰️', keywords: ['letter', 'blood'] },
{ shortcode: 'b', emoji: '🅱️', keywords: ['letter', 'blood'] },
{ shortcode: 'o', emoji: '🅾️', keywords: ['letter', 'blood'] },
{ shortcode: 'information', emoji: '', keywords: ['info', 'help'] },
{ shortcode: 'copyright', emoji: '©️', keywords: ['legal', 'ip'] },
{ shortcode: 'registered', emoji: '®️', keywords: ['legal', 'brand'] },
{ shortcode: 'tm', emoji: '™️', keywords: ['legal', 'trademark'] },
{ shortcode: 'one', emoji: '1⃣', keywords: ['number', 'first'] },
{ shortcode: 'two', emoji: '2⃣', keywords: ['number', 'second'] },
{ shortcode: 'three', emoji: '3⃣', keywords: ['number', 'third'] },
{ shortcode: 'four', emoji: '4⃣', keywords: ['number'] },
{ shortcode: 'five', emoji: '5⃣', keywords: ['number'] },
{ shortcode: 'six', emoji: '6⃣', keywords: ['number'] },
{ shortcode: 'seven', emoji: '7⃣', keywords: ['number'] },
{ shortcode: 'eight', emoji: '8⃣', keywords: ['number'] },
{ shortcode: 'nine', emoji: '9⃣', keywords: ['number'] },
{ shortcode: 'zero', emoji: '0⃣', keywords: ['number'] },
{ shortcode: 'keycap_ten', emoji: '🔟', keywords: ['number', 'ten'] },
{ shortcode: 'hash', emoji: '#️⃣', keywords: ['number', 'pound', 'hashtag'] },
{ shortcode: 'asterisk', emoji: '*️⃣', keywords: ['star', 'symbol'] },
{ shortcode: 'eject', emoji: '⏏️', keywords: ['media', 'remove'] },
{ shortcode: 'play', emoji: '▶️', keywords: ['media', 'start'] },
{ shortcode: 'pause', emoji: '⏸️', keywords: ['media', 'wait'] },
{ shortcode: 'stop', emoji: '⏹️', keywords: ['media', 'end'] },
{ shortcode: 'record', emoji: '⏺️', keywords: ['media', 'red'] },
{ shortcode: 'fast_forward', emoji: '⏩', keywords: ['media', 'skip'] },
{ shortcode: 'rewind', emoji: '⏪', keywords: ['media', 'back'] },
{ shortcode: 'next_track', emoji: '⏭️', keywords: ['media', 'skip'] },
{ shortcode: 'previous_track', emoji: '⏮️', keywords: ['media', 'back'] },
{ shortcode: 'cinema', emoji: '🎦', keywords: ['movie', 'film'] },
{ shortcode: 'low_brightness', emoji: '🔅', keywords: ['dim', 'light'] },
{ shortcode: 'high_brightness', emoji: '🔆', keywords: ['bright', 'light'] },
{ shortcode: 'signal_strength', emoji: '📶', keywords: ['wifi', 'bars'] },
{ shortcode: 'vibration', emoji: '📳', keywords: ['phone', 'mode'] },
{ shortcode: 'mobile_off', emoji: '📴', keywords: ['phone', 'silent'] },
{ shortcode: 'female', emoji: '♀️', keywords: ['woman', 'gender'] },
{ shortcode: 'male', emoji: '♂️', keywords: ['man', 'gender'] },
{ shortcode: 'medical', emoji: '⚕️', keywords: ['health', 'doctor'] },
{ shortcode: 'atom', emoji: '⚛️', keywords: ['science', 'physics'] },
];
/**
* Filter emojis by search text (checks shortcode and keywords)
*/
export function filterEmojis(
searchText: string,
limit: number = 10,
): EmojiItem[] {
if (!searchText) return [];
const lowerSearch = searchText.toLowerCase();
return EMOJI_DATA.filter(
item =>
item.shortcode.toLowerCase().includes(lowerSearch) ||
item.keywords?.some(keyword =>
keyword.toLowerCase().includes(lowerSearch),
),
).slice(0, limit);
}

View File

@@ -0,0 +1,247 @@
/**
* 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 { forwardRef, useCallback, useMemo, useState, useRef } from 'react';
import { Mentions } from 'antd';
import type { MentionsRef, MentionsProps } from 'antd/es/mentions';
import { filterEmojis, type EmojiItem } from './emojiData';
const MIN_CHARS_BEFORE_POPUP = 2;
// Regex to match emoji characters (simplified, covers most common emojis)
const EMOJI_REGEX =
/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]/u;
export interface EmojiTextAreaProps
extends Omit<MentionsProps, 'prefix' | 'options' | 'onSelect'> {
/**
* Minimum characters after colon before showing popup.
* @default 2 (Slack-like behavior)
*/
minCharsBeforePopup?: number;
/**
* Maximum number of emoji suggestions to show.
* @default 10
*/
maxSuggestions?: number;
/**
* Called when an emoji is selected from the popup.
*/
onEmojiSelect?: (emoji: EmojiItem) => void;
}
/**
* A TextArea component with Slack-like emoji autocomplete.
*
* Features:
* - Triggers on `:` prefix (like Slack)
* - Only shows popup after 2+ characters are typed (configurable)
* - Colon must be preceded by a space, start of line, or another emoji
* - Prevents accidental Enter key selection when typing quickly
*
* @example
* ```tsx
* <EmojiTextArea
* placeholder="Type :sm to see emoji suggestions..."
* onChange={(text) => console.log(text)}
* />
* ```
*/
export const EmojiTextArea = forwardRef<MentionsRef, EmojiTextAreaProps>(
(
{
minCharsBeforePopup = MIN_CHARS_BEFORE_POPUP,
maxSuggestions = 10,
onEmojiSelect,
onChange,
onKeyDown,
...restProps
},
ref,
) => {
const [options, setOptions] = useState<
Array<{ value: string; label: React.ReactNode }>
>([]);
const [isPopupVisible, setIsPopupVisible] = useState(false);
const lastSearchRef = useRef<string>('');
const lastKeyPressTimeRef = useRef<number>(0);
/**
* Validates whether the colon trigger should activate the popup.
* Implements Slack-like behavior:
* - Colon must be preceded by whitespace, start of text, or emoji
* - At least minCharsBeforePopup characters must be typed after colon
*/
const validateSearch = useCallback(
(text: string, props: MentionsProps): boolean => {
// Get the full value to check what precedes the colon
const fullValue = (props.value as string) || '';
// Find where this search text starts in the full value
// The search text is what comes after the `:` prefix
const colonIndex = fullValue.lastIndexOf(`:${text}`);
if (colonIndex === -1) {
setIsPopupVisible(false);
return false;
}
// Check what precedes the colon
if (colonIndex > 0) {
const charBefore = fullValue[colonIndex - 1];
// Must be preceded by whitespace, newline, or emoji
const isWhitespace = /\s/.test(charBefore);
const isEmoji = EMOJI_REGEX.test(charBefore);
if (!isWhitespace && !isEmoji) {
setIsPopupVisible(false);
return false;
}
}
// Check minimum character requirement
if (text.length < minCharsBeforePopup) {
setIsPopupVisible(false);
return false;
}
setIsPopupVisible(true);
return true;
},
[minCharsBeforePopup],
);
/**
* Handles search and filters emoji suggestions.
*/
const handleSearch = useCallback(
(searchText: string) => {
lastSearchRef.current = searchText;
if (searchText.length < minCharsBeforePopup) {
setOptions([]);
return;
}
const filteredEmojis = filterEmojis(searchText, maxSuggestions);
const newOptions = filteredEmojis.map(item => ({
value: item.emoji,
label: (
<span>
<span style={{ marginRight: 8 }}>{item.emoji}</span>
<span style={{ color: 'var(--ant-color-text-secondary)' }}>
:{item.shortcode}:
</span>
</span>
),
// Store the full item for onSelect callback
data: item,
}));
setOptions(newOptions);
},
[minCharsBeforePopup, maxSuggestions],
);
/**
* Handles emoji selection from the popup.
*/
const handleSelect = useCallback(
(option: { value: string; data?: EmojiItem }) => {
if (option.data && onEmojiSelect) {
onEmojiSelect(option.data);
}
setIsPopupVisible(false);
},
[onEmojiSelect],
);
/**
* Handles key down events to prevent accidental selection on Enter.
* If the user presses Enter very quickly after typing (< 100ms),
* we treat it as a newline intent rather than selection.
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const now = Date.now();
const timeSinceLastKey = now - lastKeyPressTimeRef.current;
// If Enter is pressed and popup is visible
if (e.key === 'Enter' && isPopupVisible) {
// If typed very quickly (< 100ms since last keypress) and
// there's meaningful search text, allow the Enter to create newline
// This prevents accidental selection when typing something like:
// "let me show you an example:[Enter]"
if (timeSinceLastKey < 100 && lastSearchRef.current.length === 0) {
// Let the default behavior (newline) happen
setIsPopupVisible(false);
return;
}
}
lastKeyPressTimeRef.current = now;
// Call original onKeyDown if provided
onKeyDown?.(e);
},
[isPopupVisible, onKeyDown],
);
const handleChange = useCallback(
(text: string) => {
lastKeyPressTimeRef.current = Date.now();
onChange?.(text);
},
[onChange],
);
// Memoize the Mentions component props
const mentionsProps = useMemo(
() => ({
prefix: ':',
split: '',
options,
validateSearch,
onSearch: handleSearch,
onSelect: handleSelect,
onKeyDown: handleKeyDown,
onChange: handleChange,
notFoundContent: null, // Don't show "Not Found" message
...restProps,
}),
[
options,
validateSearch,
handleSearch,
handleSelect,
handleKeyDown,
handleChange,
restProps,
],
);
return <Mentions ref={ref} {...mentionsProps} />;
},
);
EmojiTextArea.displayName = 'EmojiTextArea';
export type { EmojiItem };
export { filterEmojis, EMOJI_DATA } from './emojiData';

View File

@@ -36,10 +36,8 @@ const ImageContainer = styled.div<ImageContainerProps>`
width: calc(100% - 2px);
margin: 1px 1px 0 1px;
`;
interface ImageLoaderProps extends DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> {
interface ImageLoaderProps
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
fallback: string;
src: string;
isLoading?: boolean;

View File

@@ -16,29 +16,80 @@
* 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' },
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 },
},
};
export const SupersetListViewCard = ({
loading = false,
loading,
imgURL,
imgFallbackURL,
isStarred,
}: {
loading?: boolean;
loading: boolean;
imgURL: string;
imgFallbackURL: string;
isStarred: boolean;
}) => (
<ListViewCard
title="Superset Card Title"
loading={loading}
url="/superset/dashboard/births/"
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"
imgURL={imgURL}
imgFallbackURL={imgFallbackURL}
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit..."
coverLeft="Left Section"
coverRight="Right Section"
actions={
<ListViewCard.Actions>
<FaveStar
itemId={0}
fetchFaveStar={action('fetchFaveStar')}
saveFaveStar={action('saveFaveStar')}
isStarred={isStarred}
/>
<Dropdown
menu={{
items: [
{
key: 'delete',
label: 'Delete',
icon: <Icons.DeleteOutlined />,
onClick: action('Delete'),
},
{
key: 'edit',
label: 'Edit',
icon: <Icons.EditOutlined />,
onClick: action('Edit'),
},
],
}}
>
<Icons.EllipsisOutlined />
</Dropdown>
</ListViewCard.Actions>
}
/>
);

View File

@@ -31,9 +31,8 @@ import { useTheme, styled } from '@apache-superset/core/ui';
import { TableSize, ETableAction } from './index';
export interface VirtualTableProps<
RecordType,
> extends AntTableProps<RecordType> {
export interface VirtualTableProps<RecordType>
extends AntTableProps<RecordType> {
height?: number;
allowHTML?: boolean;
}

View File

@@ -102,6 +102,13 @@ export {
type DynamicEditableTitleProps,
} from './DynamicEditableTitle';
export { EditableTitle, type EditableTitleProps } from './EditableTitle';
export {
EmojiTextArea,
type EmojiTextAreaProps,
type EmojiItem,
filterEmojis,
EMOJI_DATA,
} from './EmojiTextArea';
export { EmptyState, type EmptyStateProps } from './EmptyState';
export { Empty, type EmptyProps } from './EmptyState/Empty';
export { FaveStar, type FaveStarProps } from './FaveStar';

View File

@@ -146,19 +146,20 @@ export interface ClientConfig {
unauthorizedHandler?: () => void;
}
export interface SupersetClientInterface extends Pick<
SupersetClientClass,
| 'delete'
| 'get'
| 'post'
| 'postForm'
| 'put'
| 'request'
| 'init'
| 'isAuthenticated'
| 'reAuthenticate'
| 'getGuestToken'
> {
export interface SupersetClientInterface
extends Pick<
SupersetClientClass,
| 'delete'
| 'get'
| 'post'
| 'postForm'
| 'put'
| 'request'
| 'init'
| 'isAuthenticated'
| 'reAuthenticate'
| 'getGuestToken'
> {
configure: (config?: ClientConfig) => SupersetClientInterface;
reset: () => void;
getCSRFToken: () => CsrfPromise;

View File

@@ -18,10 +18,8 @@
*/
import { ChartDataResponseResult } from '../../types';
export interface LegacyChartDataResponse extends Omit<
ChartDataResponseResult,
'data'
> {
export interface LegacyChartDataResponse
extends Omit<ChartDataResponseResult, 'data'> {
data: Record<string, unknown>[] | Record<string, unknown>;
}

View File

@@ -92,7 +92,9 @@ export type ResidualQueryObjectData = {
* and `transformProps`.
*/
export interface QueryObject
extends QueryFields, TimeRange, ResidualQueryObjectData {
extends QueryFields,
TimeRange,
ResidualQueryObjectData {
/**
* Definition for annotation layers.
*/

View File

@@ -75,7 +75,8 @@ export interface ChartDataResponseResult {
applied_filters?: any[];
}
export interface TimeseriesChartDataResponseResult extends ChartDataResponseResult {
export interface TimeseriesChartDataResponseResult
extends ChartDataResponseResult {
data: TimeseriesDataRecord[];
label_map: Record<string, string[]>;
}

View File

@@ -68,8 +68,7 @@ import {
declare module 'react-table' {
type ColumnSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export interface TableOptions<D extends object>
extends
UseExpandedOptions<D>,
extends UseExpandedOptions<D>,
UseFiltersOptions<D>,
UseGlobalFiltersOptions<D>,
UseGroupByOptions<D>,
@@ -84,15 +83,13 @@ declare module 'react-table' {
Record<string, any> {}
export interface Hooks<D extends object = {}>
extends
UseExpandedHooks<D>,
extends UseExpandedHooks<D>,
UseGroupByHooks<D>,
UseRowSelectHooks<D>,
UseSortByHooks<D> {}
export interface TableInstance<D extends object = {}>
extends
UseColumnOrderInstanceProps<D>,
extends UseColumnOrderInstanceProps<D>,
UseExpandedInstanceProps<D>,
UseFiltersInstanceProps<D>,
UseGlobalFiltersInstanceProps<D>,
@@ -103,8 +100,7 @@ declare module 'react-table' {
UseSortByInstanceProps<D> {}
export interface TableState<D extends object = {}>
extends
UseColumnOrderState<D>,
extends UseColumnOrderState<D>,
UseExpandedState<D>,
UseFiltersState<D>,
UseGlobalFiltersState<D>,
@@ -116,8 +112,7 @@ declare module 'react-table' {
UseSortByState<D> {}
export interface Column<D extends object = {}>
extends
UseFiltersColumnOptions<D>,
extends UseFiltersColumnOptions<D>,
UseGroupByColumnOptions<D>,
UseResizeColumnsColumnOptions<D>,
UseSortByColumnOptions<D> {
@@ -127,8 +122,7 @@ declare module 'react-table' {
}
export interface ColumnInstance<D extends object = {}>
extends
UseFiltersColumnProps<D>,
extends UseFiltersColumnProps<D>,
UseGroupByColumnProps<D>,
UseResizeColumnsColumnProps<D>,
UseSortByColumnProps<D> {
@@ -138,11 +132,11 @@ declare module 'react-table' {
}
export interface Cell<D extends object = {}>
extends UseGroupByCellProps<D>, UseRowStateCellProps<D> {}
extends UseGroupByCellProps<D>,
UseRowStateCellProps<D> {}
export interface Row<D extends object = {}>
extends
UseExpandedRowProps<D>,
extends UseExpandedRowProps<D>,
UseGroupByRowProps<D>,
UseRowSelectRowProps<D>,
UseRowStateRowProps<D> {}

View File

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

View File

@@ -36,11 +36,11 @@
"@emotion/styled": "^11.14.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@react-icons/all-files": "^4.1.0",
"@storybook/addon-actions": "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",
"@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",
"@types/react-loadable": "^5.5.11",
"core-js": "3.40.0",
"gh-pages": "^6.3.0",
@@ -53,14 +53,14 @@
},
"devDependencies": {
"@babel/core": "^7.28.3",
"@babel/preset-env": "^7.28.5",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.23.3",
"@storybook/react-webpack5": "8.6.14",
"@storybook/react-webpack5": "8.2.9",
"babel-loader": "^10.0.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"ts-loader": "^9.5.2",
"typescript": "^5.9.3"
"typescript": "^5.7.2"
},
"peerDependencies": {
"@encodable/color": "=1.1.1",

View File

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

View File

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

View File

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

View File

@@ -1,102 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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();
}
}

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