Compare commits

..

11 Commits

Author SHA1 Message Date
Maxime Beauchemin
9a82c2015c trying further 2025-08-14 13:17:43 -07:00
Maxime Beauchemin
489d8a4b83 refactor: Consolidate applyDatasetChartDefaults tests into hydrateExplore.test.ts
Move all 15 test cases for applyDatasetChartDefaults from separate test file
into the main hydrateExplore.test.ts file where the function is defined.

This follows standard practice of keeping tests close to their source code
for better maintainability and discoverability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:19 -07:00
Maxime Beauchemin
cb993639ce feat(explore): Add temporal column support and comprehensive test coverage for chart defaults
## Changes
- Added default_temporal_column property to backend model for X-axis defaults
- Added temporal column selector in Dataset Editor UI (datetime columns only)
- Integrated temporal column application in chart creation flow
- Added comprehensive test coverage for all new functionality

## Test Coverage Added

### Backend Tests (7 new tests)
- Properties correctly parse JSON from extra field
- Invalid/null JSON handling
- set_default_chart_metadata functionality
- Preservation of other extra field data

### Frontend Tests (15+ new tests)
- applyDatasetChartDefaults function unit tests
- Validation of metrics, dimensions, and temporal columns
- Error handling for malformed data
- DatasourceEditor component tests
- Integration with existing hydrateExplore tests

## Test Results
 All backend tests passing (pytest)
 All frontend tests passing (Jest)
 Pre-commit checks passing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:19 -07:00
Maxime Beauchemin
031938edb8 refactor(explore): Extract chart defaults logic into testable function
- Extract applyDatasetChartDefaults function for better testability
- Add comprehensive test coverage for all edge cases
- Handle extra field as both string and object types
- Ensure malformed JSON doesn't break the application

This refactoring makes the chart defaults logic easier to test in isolation
and improves code maintainability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:00 -07:00
Maxime Beauchemin
6e735d40a8 feat(datasets): Replace chart defaults with Explore controls
- Replace time range Select with DateFilterControl for rich time selection UI
- Make time grains database-aware, fetching options from database engine
- Add AdhocFilterControl for setting default filters on datasets
- Update hydrateExplore to apply default filters for new charts

These changes give users the same powerful controls they use in Explore
when setting chart defaults, ensuring consistency and familiarity.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:00 -07:00
Maxime Beauchemin
377bccd464 fix: Move Chart Defaults outside Fieldset to prevent onChange override
CRITICAL FIX: The Fieldset component was also overriding our onChange
handlers, preventing chart defaults from working.

Root cause: Fieldset uses recurseReactClone to inject its own onChange
handler that expects simple fields like item.fieldKey, but our chart
defaults are nested inside item.extra.default_chart_metadata.

Solution:
- Moved Chart Defaults section outside of Fieldset component
- Used Form.Item with Typography.Title for the section header
- This allows Field components to use our custom onChange handlers

Now the data flow works correctly:
Field onChange → onChartDefaultChange → parse/update extra JSON →
onDatasourcePropChange → state update → modal notification

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:00 -07:00
Maxime Beauchemin
8bdb41674c fix: Chart Defaults onChange handlers not being called
MAJOR BUG FIX: The Field component was overriding our manual onChange
handlers, preventing chart defaults from being saved.

Root cause: Field component uses cloneElement to override the control's
onChange prop with its own onControlChange handler.

Solution:
- Added onChartDefaultChange helper method to handle chart defaults logic
- Updated Default Metric field to use proper Field pattern:
  - Set value prop on Field (not control)
  - Set onChange prop on Field (not control)
  - Let Field component handle the control binding

This allows the Field component to properly call our chart defaults
update logic when users interact with the form controls.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:00 -07:00
Maxime Beauchemin
51b887901f debug: Add comprehensive logging for Chart Defaults data flow
Added debug logs to trace how chart defaults flow through the system:

DatasourceEditor.jsx:
- Log onDatasourcePropChange calls with attr and value
- Log updated datasource.extra after setState
- Log what gets sent to modal via onDatasourceChange

DatasourceModal.tsx:
- Log what data.extra is received in onDatasourceChange callback
- Log what currentDatasource.extra gets set to
- Log currentDatasource.extra right before save

This will help identify exactly where chart defaults data gets lost
in the DatasourceEditor → DatasourceModal → API save flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:00 -07:00
Maxime Beauchemin
46924ad89e refactor: Simplify Chart Defaults extra field parsing
Replaced complex IIFE pattern with a clean helper function for parsing
the datasource.extra field in Chart Defaults form controls.

Changes:
- Added parseExtra() helper function to handle string/object parsing
- Simplified all value props to use parseExtra(datasource.extra).default_chart_metadata?.field
- Streamlined onChange handlers to use the same helper consistently
- Improved code readability and maintainability
- Fixed issue with saved defaults not displaying when reopening DatasetEditor

The helper function handles all edge cases (null, string, object) and provides
safe fallbacks, making the Chart Defaults section more robust.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:00 -07:00
Maxime Beauchemin
1d3c186fec fix: DatasetEditor Chart Defaults not displaying saved values
Fixed an issue where the Chart Defaults section in the DatasetEditor
would not display previously saved values when reopening the editor.

The problem was that the component assumed `datasource.extra` would
always be a string that needed JSON parsing, but it could also be an
already-parsed object depending on the component's state.

Changes:
- Added proper type checking for `datasource.extra` (string vs object)
- Used IIFE with try-catch for safe value extraction in Select components
- Fixed onChange handlers to handle both string and object types
- Ensured saved chart defaults now properly display on editor reload

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:43:00 -07:00
Maxime Beauchemin
03ba5b6949 feat(dataset): Add chart creation defaults to improve UX
## Summary
Introduces dataset-level chart defaults that automatically pre-populate common settings when creating new charts, significantly improving the user experience by reducing repetitive configuration steps.

## Features Added

### Backend
- Added computed properties to `SqlaTable` model for reading/writing chart defaults from the `extra` JSON field
- Properties: `default_metric`, `default_dimension`, `default_time_grain`, `default_time_range`, `default_row_limit`
- Added `set_default_chart_metadata()` method for updating defaults

### Frontend UI
- Added new "Chart Defaults" section in Dataset Editor (Settings tab)
- Includes dropdowns for:
  - Default Metric (from available metrics)
  - Default Dimension (from groupable columns)
  - Default Time Grain (common intervals)
  - Default Time Range (common presets)
  - Default Row Limit (numeric input)

### Chart Creation Integration
- Modified `hydrateExplore` action to apply defaults when creating new charts
- Only applies to new charts (no existing slice_id)
- Validates that referenced metrics/columns exist before applying
- Falls back gracefully if defaults can't be applied

### Metadata Sync Protection
- Added `cleanupChartDefaults()` to handle column removal during metadata refresh
- Shows warning if default dimension is removed
- Preserves all other defaults safely

## Technical Details

### Storage
- Uses existing `extra` JSON column - no database migration needed
- Structure: `{"default_chart_metadata": {"default_metric": "count", ...}}`

### Best-Effort Resolution
- String-based references (not foreign keys) for flexibility
- Graceful degradation if referenced objects are deleted
- No cascading failures

### Validation
- Backend: Type hints and property methods ensure data consistency
- Frontend: Dropdowns only show valid options
- Chart creation: Validates existence before applying

## Benefits
- **Improved UX**: Users don't need to repeatedly select the same metric/dimension
- **Time Savings**: Especially helpful for datasets with many metrics/columns
- **Consistency**: Encourages use of primary metrics across charts
- **Flexibility**: Optional and can be overridden per chart

## Future Opportunities
- SQL Lab → Explore flow integration
- Dashboard quick chart creation
- API exposure for external tools
- Viz-type specific defaults

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 10:42:59 -07:00
710 changed files with 10931 additions and 32624 deletions

View File

@@ -44,8 +44,4 @@ under the License.
- [4.0.1](./CHANGELOG/4.0.1.md)
- [4.0.2](./CHANGELOG/4.0.2.md)
- [4.1.0](./CHANGELOG/4.1.0.md)
- [4.1.1](./CHANGELOG/4.1.1.md)
- [4.1.2](./CHANGELOG/4.1.2.md)
- [4.1.3](./CHANGELOG/4.1.3.md)
- [5.0.0](./CHANGELOG/5.0.0.md)
- [6.0.0](./CHANGELOG/6.0.0.md)

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,7 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
### Frontend Modernization
- **NO `any` types** - Use proper TypeScript types
- **NO JavaScript files** - Convert to TypeScript (.ts/.tsx)
- **Use @superset-ui/core** - Don't import Ant Design directly, prefer Ant Design component wrappers from @superset-ui/core/components
- **Use antd theming tokens** - Prefer antd tokens over legacy theming tokens
- **Avoid custom css and styles** - Follow antd best practices and avoid styling and custom CSS whenever possible
- **Use @superset-ui/core** - Don't import Ant Design directly
### Testing Strategy Migration
- **Prefer unit tests** over integration tests

View File

@@ -22,10 +22,7 @@ under the License.
This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
## 6.0.0
- [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.
- [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.
- [34782](https://github.com/apache/superset/pull/34782): Dataset exports now include the dataset ID in their file name (similar to charts and dashboards). If managing assets as code, make sure to rename existing dataset YAMLs to include the ID (and avoid duplicated files).
## Next
- [34536](https://github.com/apache/superset/pull/34536): The `ENVIRONMENT_TAG_CONFIG` color values have changed to support only Ant Design semantic colors. Update your `superset_config.py`:
- Change `"error.base"` to just `"error"` after this PR
- Change any hex color values to one of: `"success"`, `"processing"`, `"error"`, `"warning"`, `"default"`

View File

@@ -26,7 +26,7 @@ gunicorn \
--workers ${SERVER_WORKER_AMOUNT:-1} \
--worker-class ${SERVER_WORKER_CLASS:-gthread} \
--threads ${SERVER_THREADS_AMOUNT:-20} \
--log-level "${GUNICORN_LOGLEVEL:-info}" \
--log-level "${GUNICORN_LOGLEVEL:info}" \
--timeout ${GUNICORN_TIMEOUT:-60} \
--keep-alive ${GUNICORN_KEEPALIVE:-2} \
--max-requests ${WORKER_MAX_REQUESTS:-0} \

View File

@@ -67,22 +67,6 @@ To send alerts and reports to Slack channels, you need to create a new Slack App
Note: when you configure an alert or a report, the Slack channel list takes channel names without the leading '#' e.g. use `alerts` instead of `#alerts`.
#### Large Slack Workspaces (10k+ channels)
For workspaces with many channels, fetching the complete channel list can take several minutes and may encounter Slack API rate limits. Add the following to your `superset_config.py`:
```python
from datetime import timedelta
# Increase cache timeout to reduce API calls
# Default: 1 day (86400 seconds)
SLACK_CACHE_TIMEOUT = int(timedelta(days=2).total_seconds())
# Increase retry count for rate limit errors
# Default: 2
SLACK_API_RATE_LIMIT_RETRY_COUNT = 5
```
### Kubernetes-specific
- You must have a `celery beat` pod running. If you're using the chart included in the GitHub repository under [helm/superset](https://github.com/apache/superset/tree/master/helm/superset), you need to put `supersetCeleryBeat.enabled = true` in your values override.

View File

@@ -363,6 +363,110 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
]
```
### Keycloak-Specific Configuration using Flask-OIDC
If you are using Keycloak as OpenID Connect 1.0 Provider, the above configuration based on [`Authlib`](https://authlib.org/) might not work. In this case using [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is a viable option.
Make sure the pip package [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is installed on the webserver. This was successfully tested using version 2.2.0. This package requires [`Flask-OpenID`](https://pypi.org/project/Flask-OpenID/) as a dependency.
The following code defines a new security manager. Add it to a new file named `keycloak_security_manager.py`, placed in the same directory as your `superset_config.py` file.
```python
from flask_appbuilder.security.manager import AUTH_OID
from superset.security import SupersetSecurityManager
from flask_oidc import OpenIDConnect
from flask_appbuilder.security.views import AuthOIDView
from flask_login import login_user
from urllib.parse import quote
from flask_appbuilder.views import ModelView, SimpleFormView, expose
from flask import (
redirect,
request
)
import logging
class OIDCSecurityManager(SupersetSecurityManager):
def __init__(self, appbuilder):
super(OIDCSecurityManager, self).__init__(appbuilder)
if self.auth_type == AUTH_OID:
self.oid = OpenIDConnect(self.appbuilder.get_app)
self.authoidview = AuthOIDCView
class AuthOIDCView(AuthOIDView):
@expose('/login/', methods=['GET', 'POST'])
def login(self, flag=True):
sm = self.appbuilder.sm
oidc = sm.oid
@self.appbuilder.sm.oid.require_login
def handle_login():
user = sm.auth_user_oid(oidc.user_getfield('email'))
if user is None:
info = oidc.user_getinfo(['preferred_username', 'given_name', 'family_name', 'email'])
user = sm.add_user(info.get('preferred_username'), info.get('given_name'), info.get('family_name'),
info.get('email'), sm.find_role('Gamma'))
login_user(user, remember=False)
return redirect(self.appbuilder.get_url_for_index)
return handle_login()
@expose('/logout/', methods=['GET', 'POST'])
def logout(self):
oidc = self.appbuilder.sm.oid
oidc.logout()
super(AuthOIDCView, self).logout()
redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
return redirect(
oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url))
```
Then add to your `superset_config.py` file:
```python
from keycloak_security_manager import OIDCSecurityManager
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
import os
AUTH_TYPE = AUTH_OID
SECRET_KEY: 'SomethingNotEntirelySecret'
OIDC_CLIENT_SECRETS = '/path/to/client_secret.json'
OIDC_ID_TOKEN_COOKIE_SECURE = False
OIDC_OPENID_REALM: '<myRealm>'
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
# Will allow user self registration, allowing to create Flask users from Authorized User
AUTH_USER_REGISTRATION = True
# The default user self registration role
AUTH_USER_REGISTRATION_ROLE = 'Public'
```
Store your client-specific OpenID information in a file called `client_secret.json`. Create this file in the same directory as `superset_config.py`:
```json
{
"<myOpenIDProvider>": {
"issuer": "https://<myKeycloakDomain>/realms/<myRealm>",
"auth_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/auth",
"client_id": "https://<myKeycloakDomain>",
"client_secret": "<myClientSecret>",
"redirect_uris": [
"https://<SupersetWebserver>/oauth-authorized/<myOpenIDProvider>"
],
"userinfo_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/userinfo",
"token_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token",
"token_introspection_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token/introspect"
}
}
```
## LDAP Authentication
FAB supports authenticating user credentials against an LDAP server.

View File

@@ -747,26 +747,6 @@ To run a single test file:
npm run test -- path/to/file.js
```
#### Known Issues and Workarounds
**Jest Test Hanging (MessageChannel Issue)**
If Jest tests hang with "Jest did not exit one second after the test run has completed", this is likely due to the MessageChannel issue from rc-overflow (Ant Design v5 components).
**Root Cause**: `rc-overflow@1.4.1` creates MessageChannel handles for responsive overflow detection that remain open after test completion.
**Current Workaround**: MessageChannel is mocked as undefined in `spec/helpers/jsDomWithFetchAPI.ts`, forcing rc-overflow to use requestAnimationFrame fallback.
**To verify if still needed**: Remove the MessageChannel mocking lines and run `npm test -- --shard=4/8`. If tests hang, the workaround is still required.
**Future removal conditions**: This workaround can be removed when:
- rc-overflow updates to properly clean up MessagePorts in test environments
- Jest updates to handle MessageChannel/MessagePort cleanup better
- Ant Design switches away from rc-overflow
- We switch away from Ant Design v5
**See**: [PR #34871](https://github.com/apache/superset/pull/34871) for full technical details.
### Debugging Server App
#### Local

View File

@@ -344,7 +344,7 @@ const config: Config = {
'data-project-name': 'Apache Superset',
'data-project-color': '#FFFFFF',
'data-project-logo':
'https://superset.apache.org/img/superset-logo-icon-only.png',
'https://images.seeklogo.com/logo-png/50/2/superset-icon-logo-png_seeklogo-500354.png',
'data-modal-override-open-id': 'ask-ai-input',
'data-modal-override-open-class': 'search-input',
'data-modal-disclaimer':

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -46,7 +46,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=5.0.0,<6",
"flask-appbuilder>=4.8.0, <5.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -58,8 +58,8 @@ dependencies = [
"greenlet>=3.0.3, <=3.1.1",
"gunicorn>=22.0.0; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
"holidays>=0.45, <1",
# known issue with holidays 0.26.0 and above related to prophet lib #25017
"holidays>=0.25, <0.26",
"humanize",
"isodate",
"jsonpath-ng>=1.6.1, <2",
@@ -73,7 +73,7 @@ dependencies = [
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
"pandas[excel]>=2.1.4, <2.2",
"pandas[excel]>=2.0.3, <2.1",
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",
@@ -85,18 +85,18 @@ dependencies = [
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
"python-geohash",
"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
"pyarrow>=18.1.0, <19",
"pyyaml>=6.0.0, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=4.6.0, <5.0",
"selenium>=4.14.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.3, <2.0",
"shillelagh[gsheetsapi]>=1.2.18, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=3.15.0",
"slack_sdk>=3.19.0, <4",
"sqlalchemy>=1.4, <2",
"sqlalchemy-utils>=0.38.3, <0.39",
"sqlglot>=27.15.2, <28",
"sqlglot>=27.3.0, <28",
# newer pandas needs 0.9+
"tabulate>=0.9.0, <1.0",
"typing-extensions>=4, <5",
@@ -128,7 +128,7 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.4, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
duckdb = ["duckdb-engine>=0.12.1, <0.13"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
@@ -137,7 +137,7 @@ excel = ["xlrd>=1.2.0, <1.3"]
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.3, <2"]
gsheets = ["shillelagh[gsheetsapi]>=1.2.18, <2"]
hana = ["hdbcli==2.4.162", "sqlalchemy_hana==0.4.0"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
@@ -165,10 +165,10 @@ playwright = ["playwright>=1.37.0, <2"]
postgres = ["psycopg2-binary==2.9.6"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.328.0"]
prophet = ["prophet>=1.1.6, <2"]
prophet = ["prophet>=1.1.5, <2"]
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
risingwave = ["sqlalchemy-risingwave"]
shillelagh = ["shillelagh[all]>=1.4.3, <2"]
shillelagh = ["shillelagh[all]>=1.2.18, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.1.1, <2"]
snowflake = ["snowflake-sqlalchemy>=1.2.4, <2"]
spark = [

View File

@@ -112,9 +112,9 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.0.0
flask-appbuilder==4.8.0
# via apache-superset (pyproject.toml)
flask-babel==3.1.0
flask-babel==2.0.0
# via flask-appbuilder
flask-caching==2.3.1
# via apache-superset (pyproject.toml)
@@ -154,14 +154,13 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
# via wsproto
hashids==1.3.1
# via apache-superset (pyproject.toml)
holidays==0.82
holidays==0.25
# via apache-superset (pyproject.toml)
humanize==4.12.3
# via apache-superset (pyproject.toml)
@@ -193,6 +192,8 @@ jsonschema-specifications==2025.4.1
# openapi-schema-validator
kombu==5.5.3
# via celery
korean-lunar-calendar==0.3.1
# via holidays
limits==5.1.0
# via flask-limiter
mako==1.3.10
@@ -255,7 +256,7 @@ packaging==25.0
# limits
# marshmallow
# shillelagh
pandas==2.1.4
pandas==2.0.3
# via apache-superset (pyproject.toml)
paramiko==3.5.1
# via
@@ -277,7 +278,7 @@ prison==0.2.1
# via flask-appbuilder
prompt-toolkit==3.0.51
# via click-repl
pyarrow==16.1.0
pyarrow==18.1.0
# via apache-superset (pyproject.toml)
pyasn1==0.6.1
# via
@@ -350,7 +351,7 @@ rsa==4.9.1
# via google-auth
selenium==4.32.0
# via apache-superset (pyproject.toml)
shillelagh==1.4.3
shillelagh==1.3.5
# via apache-superset (pyproject.toml)
simplejson==3.20.1
# via apache-superset (pyproject.toml)
@@ -370,7 +371,6 @@ sqlalchemy==1.4.54
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# flask-appbuilder
# flask-sqlalchemy
# marshmallow-sqlalchemy
@@ -379,12 +379,9 @@ sqlalchemy==1.4.54
sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==27.15.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
sqlglot==27.3.0
# via apache-superset (pyproject.toml)
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
@@ -399,7 +396,6 @@ typing-extensions==4.14.0
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# cattrs
# limits
# pyopenssl

View File

@@ -195,11 +195,11 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.0.0
flask-appbuilder==4.8.0
# via
# -c requirements/base.txt
# apache-superset
flask-babel==3.1.0
flask-babel==2.0.0
# via
# -c requirements/base.txt
# flask-appbuilder
@@ -313,7 +313,6 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -333,7 +332,7 @@ hashids==1.3.1
# via
# -c requirements/base.txt
# apache-superset
holidays==0.82
holidays==0.25
# via
# -c requirements/base.txt
# apache-superset
@@ -394,6 +393,10 @@ kombu==5.5.3
# via
# -c requirements/base.txt
# celery
korean-lunar-calendar==0.3.1
# via
# -c requirements/base.txt
# holidays
lazy-object-proxy==1.10.0
# via openapi-spec-validator
limits==5.1.0
@@ -466,7 +469,6 @@ numpy==1.26.4
# pandas
# pandas-gbq
# prophet
# pyarrow
oauthlib==3.2.2
# via requests-oauthlib
odfpy==1.4.1
@@ -508,7 +510,7 @@ packaging==25.0
# pytest
# shillelagh
# sqlalchemy-bigquery
pandas==2.1.4
pandas==2.0.3
# via
# -c requirements/base.txt
# apache-superset
@@ -537,7 +539,6 @@ pgsanity==0.2.9
# apache-superset
pillow==11.3.0
# via
# -c requirements/base.txt
# apache-superset
# matplotlib
pip==25.1.1
@@ -570,7 +571,7 @@ prompt-toolkit==3.0.51
# via
# -c requirements/base.txt
# click-repl
prophet==1.2.0
prophet==1.1.5
# via apache-superset
proto-plus==1.25.0
# via
@@ -587,7 +588,7 @@ psutil==6.1.0
# via apache-superset
psycopg2-binary==2.9.6
# via apache-superset
pyarrow==16.1.0
pyarrow==18.1.0
# via
# -c requirements/base.txt
# apache-superset
@@ -758,7 +759,7 @@ setuptools==80.7.1
# pydata-google-auth
# zope-event
# zope-interface
shillelagh==1.4.3
shillelagh==1.3.5
# via
# -c requirements/base.txt
# apache-superset
@@ -803,7 +804,7 @@ sqlalchemy-utils==0.38.3
# -c requirements/base.txt
# apache-superset
# flask-appbuilder
sqlglot==27.15.2
sqlglot==27.3.0
# via
# -c requirements/base.txt
# apache-superset

View File

@@ -0,0 +1,766 @@
/**
* 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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { Interception } from 'cypress/types/net-stubbing';
import { waitForChartLoad } from 'cypress/utils';
import { SUPPORTED_CHARTS_DASHBOARD } from 'cypress/utils/urls';
import {
openTopLevelTab,
SUPPORTED_TIER1_CHARTS,
SUPPORTED_TIER2_CHARTS,
} from './utils';
import {
interceptExploreJson,
interceptV1ChartData,
interceptFormDataKey,
} from '../explore/utils';
const interceptDrillInfo = () => {
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
statusCode: 200,
body: {
result: {
id: 1,
changed_on_humanized: '2 days ago',
created_on_humanized: 'a week ago',
table_name: 'birth_names',
changed_by: {
first_name: 'Admin',
last_name: 'User',
},
created_by: {
first_name: 'Admin',
last_name: 'User',
},
owners: [
{
first_name: 'Admin',
last_name: 'User',
},
],
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'state',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{
column_name: 'ds',
verbose_name: null,
},
],
},
},
}).as('drillInfo');
};
const closeModal = () => {
cy.get('body').then($body => {
if ($body.find('[data-test="close-drill-by-modal"]').length) {
cy.getBySel('close-drill-by-modal').click({ force: true });
}
});
};
const openTableContextMenu = (
cellContent: string,
tableSelector = "[data-test-viz-type='table']",
) => {
cy.get(tableSelector).scrollIntoView();
cy.get(tableSelector).contains(cellContent).first().rightclick();
};
const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
if (isLegacy) {
interceptExploreJson('legacyData');
} else {
interceptV1ChartData();
}
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)', { timeout: 15000 })
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill by$/)
.trigger('mouseover', { force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
{ timeout: 15000 },
)
.should('be.visible')
.find('[role="menuitem"]')
.contains(new RegExp(`^${targetDrillByColumn}$`))
.click();
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).should('not.exist');
if (isLegacy) {
return cy.wait('@legacyData');
}
return cy.wait('@v1Data');
};
const verifyExpectedFormData = (
interceptedRequest: Interception,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectedFormData: Record<string, any>,
) => {
const actualFormData = interceptedRequest.request.body?.form_data;
Object.entries(expectedFormData).forEach(([key, val]) => {
expect(actualFormData?.[key]).to.eql(val);
});
};
const testEchart = (
vizType: string,
chartName: string,
drillClickCoordinates: [[number, number], [number, number]],
furtherDrillDimension = 'name',
) => {
cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel(`"Drill by: ${chartName}-modal"`).as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', chartName);
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click 'other'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
drillBy(furtherDrillDimension).then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: [furtherDrillDimension],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'other',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (other)')
.and('contain', furtherDrillDimension)
.contains('state (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (other)')
.and('not.contain', furtherDrillDimension)
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
};
describe('Drill by modal', () => {
beforeEach(() => {
closeModal();
});
before(() => {
interceptDrillInfo();
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
});
describe('Modal actions + Table', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it.only('opens the modal from the context menu', () => {
openTableContextMenu('boy');
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Table');
cy.get('@drillByModal')
.find('[data-test="metadata-bar"]')
.should('be.visible');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'sum__num');
// further drilling
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
interceptFormDataKey();
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (CA)')
.and('contain', 'name')
.contains('state (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'name')
.and('contain', 'state')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (CA)')
.and('not.contain', 'name')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-display-toggle"]')
.contains('Table')
.click();
cy.getBySel('drill-by-chart').should('not.exist');
cy.get('@drillByModal')
.find('[data-test="drill-by-results-table"]')
.should('be.visible');
cy.wait('@formDataKey').then(intercept => {
cy.get('@drillByModal')
.contains('Edit chart')
.should('have.attr', 'href')
.and(
'contain',
`/explore/?form_data_key=${intercept.response?.body?.key}`,
);
});
});
});
describe('Tier 1 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it('Pivot Table', () => {
openTableContextMenu('boy', "[data-test-viz-type='pivot_table_v2']");
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Pivot Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Pivot Table');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num')
.and('not.contain', 'Gender');
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('ds').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyColumns: ['name'],
groupbyRows: ['ds'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'name')
.and('contain', 'ds')
.and('contain', 'sum__num')
.and('not.contain', 'state');
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (CA)')
.and('contain', 'ds')
.contains('name (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'ds')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (CA)')
.and('not.contain', 'ds')
.and('contain', 'name');
});
it('Line chart', () => {
testEchart('echarts_timeseries_line', 'Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Area Chart', () => {
testEchart('echarts_area', 'Area Chart', [
[85, 93],
[85, 93],
]);
});
it('Scatter Chart', () => {
testEchart('echarts_timeseries_scatter', 'Scatter Chart', [
[85, 93],
[85, 93],
]);
});
it.skip('Bar Chart', () => {
testEchart('echarts_timeseries_bar', 'Bar Chart', [
[85, 94],
[490, 68],
]);
});
it('Pie Chart', () => {
testEchart('pie', 'Pie Chart', [
[243, 167],
[534, 248],
]);
});
});
describe('Tier 2 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 2');
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
});
it('Box Plot Chart', () => {
testEchart(
'box_plot',
'Box Plot Chart',
[
[139, 277],
[787, 441],
],
'ds',
);
});
it('Generic Chart', () => {
testEchart('echarts_timeseries', 'Generic Chart', [
[85, 93],
[85, 93],
]);
});
it('Smooth Line Chart', () => {
testEchart('echarts_timeseries_smooth', 'Smooth Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Step Line Chart', () => {
testEchart('echarts_timeseries_step', 'Step Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Funnel Chart', () => {
testEchart('funnel', 'Funnel Chart', [
[154, 80],
[421, 39],
]);
});
it('Gauge Chart', () => {
testEchart('gauge_chart', 'Gauge Chart', [
[151, 95],
[300, 143],
]);
});
it.skip('Radar Chart', () => {
testEchart('radar', 'Radar Chart', [
[182, 49],
[423, 91],
]);
});
it('Treemap V2 Chart', () => {
testEchart('treemap_v2', 'Treemap V2 Chart', [
[145, 84],
[220, 105],
]);
});
it.skip('Mixed Chart', () => {
cy.get('[data-test-viz-type="mixed_timeseries"] canvas').then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 85, 93);
cy.wrap($canvas).rightclick(85, 93);
drillBy('name').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.getBySel('"Drill by: Mixed Chart-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Mixed Chart');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click second query
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 261, 114);
cy.wrap($canvas).rightclick(261, 114);
drillBy('ds').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['ds']);
expect(queries[1].filters).to.eql([
{ col: 'state', op: '==', val: 'other' },
]);
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (other)')
.and('contain', 'ds')
.contains('name (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (other)')
.and('not.contain', 'ds')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
});
});
});

View File

@@ -179,13 +179,13 @@ describe.skip('Drill to detail modal', () => {
cy.on('uncaught:exception', () => false);
cy.wait('@samples');
cy.get('.virtual-table-cell').should($rows => {
expect($rows).to.contain('Kimberly');
expect($rows).to.contain('Kelly');
});
// verify scroll top on pagination
cy.getBySelLike('Number-modal').find('.virtual-grid').scrollTo(0, 200);
cy.get('.virtual-grid').contains('Kim').should('not.be.visible');
cy.get('.virtual-grid').contains('Juan').should('not.be.visible');
cy.get('.ant-pagination-item').eq(0).click();

View File

@@ -160,57 +160,6 @@ describe('Native filters', () => {
);
});
it('user cannot create bi-directional dependencies between filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },
{ name: 'country_name', column: 'country_name', datasetId: 2 },
{ name: 'country_code', column: 'country_code', datasetId: 2 },
{ name: 'year', column: 'year', datasetId: 2 },
]);
enterNativeFilterEditModal();
// First, make country_name dependent on region
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
// Second, make country_code dependent on country_name
selectFilter(2);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumn);
// Now select region filter and try to add dependency
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
// Verify that only 'year' is available as dependency for region
// 'country_name' and 'country_code' should not be available (would create circular dependency)
cy.get('input[aria-label^="Limit type"]').click({ force: true });
cy.get('[role="listbox"]').should('be.visible');
cy.get('[role="listbox"]').should('contain', 'year');
cy.get('[role="listbox"]').should('not.contain', 'country_name');
cy.get('[role="listbox"]').should('not.contain', 'country_code');
cy.get('[role="listbox"]').contains('year').click();
},
);
});
it('Dependent filter selects first item based on parent filter selection', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },

View File

@@ -1,12 +1,12 @@
{
"name": "superset",
"version": "6.0.0",
"version": "0.0.0-dev",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "superset",
"version": "6.0.0",
"version": "0.0.0-dev",
"license": "Apache-2.0",
"workspaces": [
"packages/*",
@@ -62,6 +62,7 @@
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -96,6 +97,7 @@
"re-resizable": "^6.10.1",
"react": "^17.0.2",
"react-checkbox-tree": "^1.8.0",
"react-color": "^2.13.8",
"react-diff-viewer-continued": "^3.4.0",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
@@ -186,6 +188,7 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
@@ -263,7 +266,7 @@
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
"webpack-dev-server": "^5.2.1",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.3.3",
"webpack-visualizer-plugin2": "^1.2.0"
@@ -5233,6 +5236,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@icons/material": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz",
"integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@inquirer/external-editor": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.0.tgz",
@@ -16102,6 +16114,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-ultimate-pagination": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/react-ultimate-pagination/-/react-ultimate-pagination-1.2.4.tgz",
"integrity": "sha512-1y9jLt3KEFGzFD+99qVpJUI/Eu4cEx48sClB957eGoepWRLVVi+r1UBj0157Mg7HYZcIF4I1/qGZYaBBQWhaqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.4.tgz",
@@ -24489,6 +24511,12 @@
"node": ">= 4"
}
},
"node_modules/emotion-rgba": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.12.tgz",
"integrity": "sha512-lvtZ52BWisYDtis+HctQMkxcHwmFbzTiZhgMJGFfWXLsBYEzthfKE7nlysOiUwmmAdTM/8YBAPfwQ4MEDwiaWw==",
"license": "MIT"
},
"node_modules/encodable": {
"version": "0.7.8",
"resolved": "https://registry.npmjs.org/encodable/-/encodable-0.7.8.tgz",
@@ -37976,6 +38004,12 @@
"remove-accents": "0.5.0"
}
},
"node_modules/material-colors": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
"integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==",
"license": "ISC"
},
"node_modules/math-expression-evaluator": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz",
@@ -47852,6 +47886,24 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/react-color": {
"version": "2.19.3",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
"integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==",
"license": "MIT",
"dependencies": {
"@icons/material": "^0.2.4",
"lodash": "^4.17.15",
"lodash-es": "^4.17.15",
"material-colors": "^1.2.1",
"prop-types": "^15.5.10",
"reactcss": "^1.2.0",
"tinycolor2": "^1.4.1"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
@@ -48668,6 +48720,15 @@
"react": "* || ^0.14.0"
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
"integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.0.1"
}
},
"node_modules/read": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/read/-/read-3.0.1.tgz",
@@ -57084,9 +57145,9 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz",
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
"integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -57106,7 +57167,7 @@
"connect-history-api-fallback": "^2.0.0",
"express": "^4.21.2",
"graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.9",
"http-proxy-middleware": "^2.0.7",
"ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1",
"open": "^10.0.3",

View File

@@ -1,6 +1,6 @@
{
"name": "superset",
"version": "6.0.0",
"version": "0.0.0-dev",
"description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.",
"keywords": [
"big",
@@ -130,6 +130,7 @@
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -164,6 +165,7 @@
"re-resizable": "^6.10.1",
"react": "^17.0.2",
"react-checkbox-tree": "^1.8.0",
"react-color": "^2.13.8",
"react-diff-viewer-continued": "^3.4.0",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
@@ -254,6 +256,7 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.4",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
@@ -331,7 +334,7 @@
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2",
"webpack-dev-server": "^5.2.1",
"webpack-manifest-plugin": "^5.0.1",
"webpack-sources": "^3.3.3",
"webpack-visualizer-plugin2": "^1.2.0"

View File

@@ -25,7 +25,7 @@ import {
export interface <%= packageLabel %>StylesProps {
height: number;
width: number;
headerFontSize: 'fontSizeSM' | 'fontSize' | 'fontSizeLG' | 'fontSizeXL' | 'fontSizeHeading1' | 'fontSizeHeading2' | 'fontSizeHeading3' | 'fontSizeHeading4' | 'fontSizeHeading5';
headerFontSize: keyof typeof supersetTheme.typography.sizes;
boldText: boolean;
}

View File

@@ -16,17 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
Popover,
type PopoverProps,
SQLEditor,
} from '@superset-ui/core/components';
import { useEffect, useState } from 'react';
import { Popover, type PopoverProps } from '@superset-ui/core/components';
import type ReactAce from 'react-ace';
import { CalculatorOutlined } from '@ant-design/icons';
import { css, styled, useTheme, t } from '@superset-ui/core';
const StyledCalculatorIcon = styled(CalculatorOutlined)`
${({ theme }) => css`
color: ${theme.colorIcon};
color: ${theme.colors.grayscale.base};
font-size: ${theme.fontSizeSM}px;
& svg {
margin-left: ${theme.sizeUnit}px;
@@ -37,10 +35,24 @@ const StyledCalculatorIcon = styled(CalculatorOutlined)`
export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
const theme = useTheme();
const [AceEditor, setAceEditor] = useState<typeof ReactAce | null>(null);
useEffect(() => {
Promise.all([
import('react-ace'),
import('ace-builds/src-min-noconflict/mode-sql'),
]).then(([reactAceModule]) => {
setAceEditor(() => reactAceModule.default);
});
}, []);
if (!AceEditor) {
return null;
}
return (
<Popover
content={
<SQLEditor
<AceEditor
mode="sql"
value={props.sqlExpression}
editorProps={{ $blockScrolling: true }}
setOptions={{
@@ -53,6 +65,7 @@ export const SQLPopover = (props: PopoverProps & { sqlExpression: string }) => {
wrapEnabled
style={{
border: `1px solid ${theme.colorBorder}`,
background: theme.colorPrimaryBg,
maxWidth: theme.sizeUnit * 100,
}}
/>

View File

@@ -43,17 +43,15 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
// remove or rename top level of column name(metric name) in the MultiIndex when
// 1) at least 1 metric
// 2) xAxis exist
// 3a) isTimeComparisonValue
// 3b-1) dimension exist or multiple time shift metrics exist
// 3b-2) truncate_metric in form_data and truncate_metric is true
// 2) dimension exist or multiple time shift metrics exist
// 3) xAxis exist
// 4) truncate_metric in form_data and truncate_metric is true
if (
metrics.length > 0 &&
(columns.length > 0 || timeOffsets.length > 1) &&
xAxisLabel &&
(isTimeComparisonValue ||
((columns.length > 0 || timeOffsets.length > 1) &&
truncate_metric !== undefined &&
!!truncate_metric))
truncate_metric !== undefined &&
!!truncate_metric
) {
const renamePairs: [string, string | null][] = [];
if (

View File

@@ -32,30 +32,21 @@ const MIN_OPACITY_BOUNDED = 0.05;
const MIN_OPACITY_UNBOUNDED = 0;
const MAX_OPACITY = 1;
export const getOpacity = (
value: number | string,
cutoffPoint: number | string,
extremeValue: number | string,
value: number,
cutoffPoint: number,
extremeValue: number,
minOpacity = MIN_OPACITY_BOUNDED,
maxOpacity = MAX_OPACITY,
) => {
if (extremeValue === cutoffPoint || typeof value !== 'number') {
if (extremeValue === cutoffPoint) {
return maxOpacity;
}
const numCutoffPoint =
typeof cutoffPoint === 'string' ? parseFloat(cutoffPoint) : cutoffPoint;
const numExtremeValue =
typeof extremeValue === 'string' ? parseFloat(extremeValue) : extremeValue;
if (Number.isNaN(numCutoffPoint) || Number.isNaN(numExtremeValue)) {
return maxOpacity;
}
return Math.min(
maxOpacity,
round(
Math.abs(
((maxOpacity - minOpacity) / (numExtremeValue - numCutoffPoint)) *
(value - numCutoffPoint),
((maxOpacity - minOpacity) / (extremeValue - cutoffPoint)) *
(value - cutoffPoint),
) + minOpacity,
2,
),
@@ -200,21 +191,10 @@ export const getColorFormatters = memoizeOne(
(
columnConfig: ConditionalFormattingConfig[] | undefined,
data: DataRecord[],
theme?: Record<string, any>,
alpha?: boolean,
) =>
columnConfig?.reduce(
(acc: ColorFormatters, config: ConditionalFormattingConfig) => {
let resolvedColorScheme = config.colorScheme;
if (
theme &&
typeof config.colorScheme === 'string' &&
config.colorScheme.startsWith('color') &&
theme[config.colorScheme]
) {
resolvedColorScheme = theme[config.colorScheme] as string;
}
if (
config?.column !== undefined &&
(config?.operator === Comparator.None ||
@@ -227,7 +207,7 @@ export const getColorFormatters = memoizeOne(
acc.push({
column: config?.column,
getColorFromValue: getColorFunction(
{ ...config, colorScheme: resolvedColorScheme },
config,
data.map(row => row[config.column!] as number),
alpha,
),

View File

@@ -160,44 +160,6 @@ test('should add renameOperator if exists derived metrics', () => {
});
});
test('should add renameOperator if isTimeComparisonValue without columns', () => {
[
ComparisonType.Difference,
ComparisonType.Ratio,
ComparisonType.Percentage,
].forEach(type => {
expect(
renameOperator(
{
...formData,
...{
comparison_type: type,
time_compare: ['1 year ago'],
},
},
{
...queryObject,
...{
columns: [],
metrics: ['sum(val)', 'avg(val2)'],
},
},
),
).toEqual({
operation: 'rename',
options: {
columns: {
[`${type}__avg(val2)__avg(val2)__1 year ago`]:
'avg(val2), 1 year ago',
[`${type}__sum(val)__sum(val)__1 year ago`]: 'sum(val), 1 year ago',
},
inplace: true,
level: 0,
},
});
});
});
test('should add renameOperator if x_axis does not exist', () => {
expect(
renameOperator(

View File

@@ -32,373 +32,360 @@ const mockData = [
];
const countValues = mockData.map(row => row.count);
test('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
describe('round', () => {
it('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
});
});
test('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
expect(getOpacity('100', 100, 100)).toEqual(1);
expect(getOpacity('75', 50, 100)).toEqual(1);
expect(getOpacity('50', '100', '100')).toEqual(1);
expect(getOpacity('50', '75', '100')).toEqual(1);
expect(getOpacity('50', NaN, '100')).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, 100)).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, NaN)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(100, 50, 100)).toEqual(1);
expect(getOpacity(75, '50', 100)).toEqual(0.53);
expect(getOpacity(75, 50, '100')).toEqual(0.53);
expect(getOpacity(75, '50', '100')).toEqual(0.53);
expect(getOpacity(50, NaN, NaN)).toEqual(1);
expect(getOpacity(50, NaN, 100)).toEqual(1);
expect(getOpacity(50, NaN, '100')).toEqual(1);
expect(getOpacity(50, '75', NaN)).toEqual(1);
expect(getOpacity(50, 75, NaN)).toEqual(1);
describe('getOpacity', () => {
it('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
});
});
test('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
describe('getColorFunction()', () => {
it('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
it('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
it('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
it('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
});
test('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
test('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
test('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
test('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
test('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
describe('getColorFormatters()', () => {
it('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
it('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
});
});

View File

@@ -27,8 +27,8 @@ export default function FallbackComponent({ error, height, width }: Props) {
return (
<div
css={(theme: SupersetTheme) => ({
backgroundColor: theme.colorBgContainer,
color: theme.colorText,
backgroundColor: theme.colors.grayscale.dark2,
color: theme.colors.grayscale.light5,
overflow: 'auto',
padding: 32,
})}

View File

@@ -1,90 +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 { render, waitFor } from '@testing-library/react';
import {
ChartPlugin,
ChartMetadata,
DatasourceType,
getChartComponentRegistry,
} from '@superset-ui/core';
import SuperChartCore from './SuperChartCore';
const props = {
chartType: 'line',
};
const FakeChart = () => <span>test</span>;
beforeEach(() => {
const metadata = new ChartMetadata({
name: 'test-chart',
thumbnail: '',
});
const buildQuery = () => ({
datasource: { id: 1, type: DatasourceType.Table },
queries: [{ granularity: 'day' }],
force: false,
result_format: 'json',
result_type: 'full',
});
const controlPanel = { abc: 1 };
const plugin = new ChartPlugin({
metadata,
Chart: FakeChart,
buildQuery,
controlPanel,
});
plugin.configure({ key: props.chartType }).register();
});
test('should return the result from cache unless transformProps has changed', async () => {
const pre = jest.fn(x => x);
const transform = jest.fn(x => x);
const post = jest.fn(x => x);
expect(getChartComponentRegistry().get(props.chartType)).toBe(FakeChart);
expect(pre).toHaveBeenCalledTimes(0);
const { rerender } = render(
<SuperChartCore
{...props}
preTransformProps={pre}
overrideTransformProps={transform}
postTransformProps={post}
/>,
);
await waitFor(() => expect(pre).toHaveBeenCalledTimes(1));
expect(transform).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
const updatedPost = jest.fn(x => x);
rerender(
<SuperChartCore
{...props}
preTransformProps={pre}
overrideTransformProps={transform}
postTransformProps={updatedPost}
/>,
);
await waitFor(() => expect(updatedPost).toHaveBeenCalledTimes(1));
expect(transform).toHaveBeenCalledTimes(1);
expect(pre).toHaveBeenCalledTimes(1);
});

View File

@@ -85,74 +85,31 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
container?: HTMLElement | null;
/**
* memoized function so it will not recompute and return previous value
* memoized function so it will not recompute
* and return previous value
* unless one of
* - preTransformProps
* - transformProps
* - postTransformProps
* - chartProps
* is changed.
*/
preSelector = createSelector(
processChartProps = createSelector(
[
(input: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
}) => input.chartProps,
input => input.preTransformProps,
],
(chartProps, pre = IDENTITY) => pre(chartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
transformSelector = createSelector(
[
(input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
input.chartProps,
input => input.transformProps,
],
(preprocessedChartProps, transform = IDENTITY) =>
transform(preprocessedChartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
postSelector = createSelector(
[
(input: {
chartProps: ChartProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) => input.chartProps,
input => input.preTransformProps,
input => input.transformProps,
input => input.postTransformProps,
],
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
(chartProps, pre = IDENTITY, transform = IDENTITY, post = IDENTITY) =>
post(transform(pre(chartProps))),
);
/**
* Using each memoized function to retrieve the computed chartProps
*/
processChartProps = ({
chartProps,
preTransformProps,
transformProps,
postTransformProps,
}: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) =>
this.postSelector({
chartProps: this.transformSelector({
chartProps: this.preSelector({ chartProps, preTransformProps }),
transformProps,
}),
postTransformProps,
});
/**
* memoized function so it will not recompute
* and return previous value

View File

@@ -1,108 +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 { render, screen, userEvent } from '@superset-ui/core/spec';
import { Icons } from '@superset-ui/core/components/Icons';
import { ActionButton } from '.';
const defaultProps = {
label: 'test-action',
icon: <Icons.EditOutlined />,
onClick: jest.fn(),
};
test('renders action button with icon', () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('data-test', 'test-action');
expect(button).toHaveClass('action-button');
});
test('calls onClick when clicked', async () => {
const onClick = jest.fn();
render(<ActionButton {...defaultProps} onClick={onClick} />);
const button = screen.getByRole('button');
userEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
test('renders with tooltip when tooltip prop is provided', async () => {
const tooltipText = 'This is a tooltip';
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveTextContent(tooltipText);
});
test('renders without tooltip when tooltip prop is not provided', async () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = screen.queryByRole('tooltip');
expect(tooltip).not.toBeInTheDocument();
});
test('supports ReactElement tooltip', async () => {
const tooltipElement = <div>Custom tooltip content</div>;
render(<ActionButton {...defaultProps} tooltip={tooltipElement} />);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(tooltip).toHaveTextContent('Custom tooltip content');
});
test('renders different icons correctly', () => {
render(<ActionButton {...defaultProps} icon={<Icons.DeleteOutlined />} />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
test('renders with custom placement for tooltip', async () => {
const tooltipText = 'Tooltip with custom placement';
render(
<ActionButton {...defaultProps} tooltip={tooltipText} placement="bottom" />,
);
const button = screen.getByRole('button');
userEvent.hover(button);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
test('has proper accessibility attributes', () => {
render(<ActionButton {...defaultProps} />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('tabIndex', '0');
expect(button).toHaveAttribute('role', 'button');
});

View File

@@ -1,75 +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 { ReactElement } from 'react';
import {
Tooltip,
type TooltipPlacement,
type IconType,
} from '@superset-ui/core/components';
import { css, useTheme } from '@superset-ui/core';
export interface ActionProps {
label: string;
tooltip?: string | ReactElement;
placement?: TooltipPlacement;
icon: IconType;
onClick: () => void;
}
export const ActionButton = ({
label,
tooltip,
placement,
icon,
onClick,
}: ActionProps) => {
const theme = useTheme();
const actionButton = (
<span
role="button"
tabIndex={0}
css={css`
cursor: pointer;
color: ${theme.colorIcon};
margin-right: ${theme.sizeUnit}px;
&:hover {
path {
fill: ${theme.colorPrimary};
}
}
`}
className="action-button"
data-test={label}
onClick={onClick}
>
{icon}
</span>
);
const tooltipId = `${label.replaceAll(' ', '-').toLowerCase()}-tooltip`;
return tooltip ? (
<Tooltip id={tooltipId} title={tooltip} placement={placement}>
{actionButton}
</Tooltip>
) : (
actionButton
);
};

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef } from 'react';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import type AceEditor from 'react-ace';
import {
AsyncAceEditor,
SQLEditor,
@@ -101,259 +99,3 @@ test('renders a custom placeholder', () => {
expect(screen.getByRole('paragraph')).toBeInTheDocument();
});
test('registers afterExec event listener for command handling', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Verify the commands object has the 'on' method (confirms event listener capability)
expect(editorInstance.commands).toHaveProperty('on');
expect(typeof editorInstance.commands.on).toBe('function');
});
test('moves autocomplete popup to parent container when triggered', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the editor container
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with insertstring command using _emit
// Note: Using _emit is necessary here to test internal event handling behavior
// since there's no public API to trigger the afterExec event directly
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
// eslint-disable-next-line no-underscore-dangle
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'insertstring' },
args: ['SELECT'],
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('moves autocomplete popup on startAutocomplete command event', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with startAutocomplete command
// Note: Using _emit is necessary here to test internal event handling behavior
// since there's no public API to trigger the afterExec event directly
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
// eslint-disable-next-line no-underscore-dangle
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'startAutocomplete' },
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('does not move autocomplete popup on unrelated commands', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the body
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
const originalParent = mockAutocompletePopup.parentElement;
// Simulate an unrelated command (e.g., 'selectall')
editorInstance.commands.exec('selectall', editorInstance, {});
// Wait a bit to ensure no movement happens
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should remain in its original location
expect(mockAutocompletePopup.parentElement).toBe(originalParent);
// Cleanup
document.body.removeChild(mockAutocompletePopup);
});
test('revalidates cached autocomplete popup when detached from DOM', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create first autocomplete popup
const firstPopup = document.createElement('div');
firstPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(firstPopup);
// Trigger command to cache the first popup
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await waitFor(() => {
expect(firstPopup.dataset.aceAutocomplete).toBe('true');
});
// Remove the first popup from DOM (simulating ACE editor replacing it)
firstPopup.remove();
// Create a new autocomplete popup
const secondPopup = document.createElement('div');
secondPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(secondPopup);
// Trigger command again - should find and move the new popup
editorInstance.commands.exec('insertstring', editorInstance, ' ');
await waitFor(() => {
expect(secondPopup.dataset.aceAutocomplete).toBe('true');
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
expect(parentContainer?.contains(secondPopup)).toBe(true);
});
});
test('cleans up event listeners on unmount', async () => {
const ref = createRef<AceEditor>();
const { container, unmount } = render(
<SQLEditor ref={ref as React.Ref<never>} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Spy on the commands.off method
const offSpy = jest.spyOn(editorInstance.commands, 'off');
// Unmount the component
unmount();
// Verify that the event listener was removed
expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
offSpy.mockRestore();
});
test('does not move autocomplete popup if target container is document.body', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
// Mock the closest method to return null (simulating no #ace-editor parent)
const originalClosest = editorInstance.container?.closest;
if (editorInstance.container) {
editorInstance.container.closest = jest.fn(() => null);
}
// Mock parentElement to be document.body
Object.defineProperty(editorInstance.container, 'parentElement', {
value: document.body,
configurable: true,
});
const initialParent = mockAutocompletePopup.parentElement;
// Trigger command
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should NOT be moved because target container is document.body
expect(mockAutocompletePopup.parentElement).toBe(initialParent);
// Cleanup
if (editorInstance.container && originalClosest) {
editorInstance.container.closest = originalClosest;
}
document.body.removeChild(mockAutocompletePopup);
});

View File

@@ -26,7 +26,6 @@ import type {
} from 'brace';
import type AceEditor from 'react-ace';
import type { IAceEditorProps } from 'react-ace';
import type { Ace } from 'ace-builds';
import {
AsyncEsmComponent,
@@ -196,70 +195,6 @@ export function AsyncAceEditor(
}
}, [keywords, setCompleters]);
// Move autocomplete popup to the nearest parent container with data-ace-container
useEffect(() => {
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
?.editor;
if (!editorInstance) return undefined;
const editorContainer = editorInstance.container;
if (!editorContainer) return undefined;
// Cache DOM elements to avoid repeated queries on every command execution
let cachedAutocompletePopup: HTMLElement | null = null;
let cachedTargetContainer: Element | null = null;
const moveAutocompleteToContainer = () => {
// Revalidate cached popup if missing or detached from DOM
if (
!cachedAutocompletePopup ||
!document.body.contains(cachedAutocompletePopup)
) {
cachedAutocompletePopup =
editorContainer.querySelector<HTMLElement>(
'.ace_autocomplete',
) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
}
// Revalidate cached container if missing or detached
if (
!cachedTargetContainer ||
!document.body.contains(cachedTargetContainer)
) {
cachedTargetContainer =
editorContainer.closest('#ace-editor') ??
editorContainer.parentElement;
}
if (
cachedAutocompletePopup &&
cachedTargetContainer &&
cachedTargetContainer !== document.body
) {
cachedTargetContainer.appendChild(cachedAutocompletePopup);
cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
}
};
const handleAfterExec = (e: Ace.Operation) => {
const name: string | undefined = e?.command?.name;
if (name === 'insertstring' || name === 'startAutocomplete') {
moveAutocompleteToContainer();
}
};
const { commands } = editorInstance;
commands.on('afterExec', handleAfterExec);
// Cleanup function to remove event listener and clear cached references
return () => {
commands.off('afterExec', handleAfterExec);
// Clear cached references to avoid memory leaks
cachedAutocompletePopup = null;
cachedTargetContainer = null;
};
}, [ref]);
return (
<>
<Global
@@ -341,24 +276,14 @@ export function AsyncAceEditor(
border: 1px solid ${token.colorBorderSecondary};
box-shadow: ${token.boxShadow};
border-radius: ${token.borderRadius}px;
padding: ${token.paddingXS}px ${token.paddingXS}px;
}
.ace_tooltip.ace_doc-tooltip {
display: flex !important;
}
&&& .tooltip-detail {
display: flex;
justify-content: center;
flex-direction: row;
gap: ${token.paddingXXS}px;
align-items: center;
& .tooltip-detail {
background-color: ${token.colorBgContainer};
white-space: pre-wrap;
word-break: break-all;
min-width: ${token.sizeXXL * 5}px;
max-width: ${token.sizeXXL * 10}px;
font-size: ${token.fontSize}px;
& .tooltip-detail-head {
background-color: ${token.colorBgElevated};
@@ -381,9 +306,7 @@ export function AsyncAceEditor(
& .tooltip-detail-head,
& .tooltip-detail-body {
background-color: ${token.colorBgLayout};
padding: 0px ${token.paddingXXS}px;
border: 1px ${token.colorSplit} solid;
padding: ${token.padding}px ${token.paddingLG}px;
}
& .tooltip-detail-footer {
@@ -470,7 +393,10 @@ export const FullSQLEditor = AsyncAceEditor(
},
);
export const MarkdownEditor = AsyncAceEditor(['mode/markdown', 'theme/github']);
export const MarkdownEditor = AsyncAceEditor([
'mode/markdown',
'theme/textmate',
]);
export const TextAreaEditor = AsyncAceEditor([
'mode/markdown',

View File

@@ -52,7 +52,7 @@ export const CheckboxHalfChecked = () => {
>
<path
d="M16 0H2C0.9 0 0 0.9 0 2V16C0 17.1 0.9 18 2 18H16C17.1 18 18 17.1 18 16V2C18 0.9 17.1 0 16 0Z"
fill={theme.colorFill}
fill={theme.colors.grayscale.light1}
/>
<path d="M14 10H4V8H14V10Z" fill="white" />
</svg>
@@ -71,7 +71,7 @@ export const CheckboxUnchecked = () => {
>
<path
d="M16 0H2C0.9 0 0 0.9 0 2V16C0 17.1 0.9 18 2 18H16C17.1 18 18 17.1 18 16V2C18 0.9 17.1 0 16 0Z"
fill={theme.colorFillSecondary}
fill={theme.colors.grayscale.light2}
/>
<path d="M16 2V16H2V2H16V2Z" fill="white" />
</svg>

View File

@@ -48,7 +48,10 @@ export const CollapseLabelInModal: React.FC<CollapseLabelInModalProps> = ({
{title}{' '}
{validateCheckStatus !== undefined &&
(validateCheckStatus ? (
<Icons.CheckCircleOutlined iconColor={theme.colorSuccess} />
<Icons.CheckCircleOutlined
iconColor={theme.colorSuccess}
aria-label="check-circle"
/>
) : (
<span
css={css`

View File

@@ -0,0 +1,66 @@
/**
* 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 { fireEvent, render, waitFor } from '@superset-ui/core/spec';
import { Button } from '../Button';
import { ConfirmStatusChange } from '.';
const mockedProps = {
title: 'please confirm',
description: 'are you sure?',
onConfirm: jest.fn(),
};
test('opens a confirm modal', () => {
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<>
<Button data-test="btn1" onClick={confirm} />
</>
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('btn1'));
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('calls the function on confirm', async () => {
const { getByTestId, getByRole } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<>
<Button data-test="btn1" onClick={() => confirm('foo')} />
</>
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('btn1'));
const confirmInput = getByTestId('delete-modal-input');
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
const confirmButton = getByRole('button', { name: 'Delete' });
fireEvent.click(confirmButton);
await waitFor(() => expect(mockedProps.onConfirm).toHaveBeenCalledTimes(1));
expect(mockedProps.onConfirm).toHaveBeenCalledWith('foo');
});

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.
*/
import { fireEvent, render, waitFor } from '@superset-ui/core/spec';
import { Button } from '../Button';
import { ConfirmStatusChange } from '.';
import type { ConfirmStatusChangeProps } from './types';
const mockedProps: Omit<ConfirmStatusChangeProps, 'children'> = {
title: 'please confirm',
description: 'are you sure?',
onConfirm: jest.fn(),
};
test('renders children with showConfirm function', () => {
const childrenSpy = jest.fn().mockReturnValue(<div>test content</div>);
render(
<ConfirmStatusChange {...mockedProps}>{childrenSpy}</ConfirmStatusChange>,
);
expect(childrenSpy).toHaveBeenCalledWith(expect.any(Function));
});
test('opens modal when showConfirm is called', () => {
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => <Button data-test="trigger" onClick={confirm} />}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('stores and passes arguments to onConfirm callback', async () => {
const testArgs = ['arg1', { data: 'test' }, 42];
const { getByTestId, getByRole } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button data-test="trigger" onClick={() => confirm(...testArgs)} />
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
const confirmInput = getByTestId('delete-modal-input');
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
const confirmButton = getByRole('button', { name: 'Delete' });
fireEvent.click(confirmButton);
await waitFor(() => expect(mockedProps.onConfirm).toHaveBeenCalledTimes(1));
expect(mockedProps.onConfirm).toHaveBeenCalledWith(...testArgs);
});
test('calls preventDefault on event-like arguments', () => {
const mockEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
};
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button data-test="trigger" onClick={() => confirm(mockEvent)} />
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.stopPropagation).toHaveBeenCalled();
});
test('skips event handling on non-event arguments', () => {
const regularArg = { someData: 'value' };
const mockFunc = jest.fn();
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button
data-test="trigger"
onClick={() => confirm(regularArg, mockFunc)}
/>
)}
</ConfirmStatusChange>,
);
// Should not throw when processing non-event arguments
expect(() => {
fireEvent.click(getByTestId('trigger'));
}).not.toThrow();
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('ignores null and undefined arguments', () => {
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button
data-test="trigger"
onClick={() => confirm(null, undefined, 'valid')}
/>
)}
</ConfirmStatusChange>,
);
expect(() => {
fireEvent.click(getByTestId('trigger'));
}).not.toThrow();
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('handles partial event objects gracefully', () => {
const partialEvent1 = { preventDefault: jest.fn() }; // Only preventDefault
const partialEvent2 = { stopPropagation: jest.fn() }; // Only stopPropagation
const { getByTestId } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => (
<Button
data-test="trigger"
onClick={() => confirm(partialEvent1, partialEvent2)}
/>
)}
</ConfirmStatusChange>,
);
fireEvent.click(getByTestId('trigger'));
expect(partialEvent1.preventDefault).toHaveBeenCalled();
expect(partialEvent2.stopPropagation).toHaveBeenCalled();
expect(getByTestId(`${mockedProps.title}-modal`)).toBeInTheDocument();
});
test('closes modal when onHide is called', () => {
const { getByTestId, getByRole } = render(
<ConfirmStatusChange {...mockedProps}>
{confirm => <Button data-test="trigger" onClick={confirm} />}
</ConfirmStatusChange>,
);
// Open modal
fireEvent.click(getByTestId('trigger'));
const modal = getByTestId(`${mockedProps.title}-modal`);
expect(modal).toBeInTheDocument();
expect(modal).toBeVisible();
// Close modal
const cancelButton = getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
// Modal should be hidden (not visible)
expect(modal).not.toBeVisible();
});

View File

@@ -31,11 +31,14 @@ export function ConfirmStatusChange({
const [currentCallbackArgs, setCurrentCallbackArgs] = useState<any[]>([]);
const showConfirm = (...callbackArgs: any[]) => {
// check if any args are DOM events, if so, handle them
// check if any args are DOM events, if so, call persist
callbackArgs.forEach(arg => {
if (!arg) {
return;
}
if (typeof arg.persist === 'function') {
arg.persist();
}
if (typeof arg.preventDefault === 'function') {
arg.preventDefault();
}

View File

@@ -27,7 +27,7 @@ const StyledDiv = styled.div`
padding-top: 8px;
width: 50%;
label {
color: ${({ theme }) => theme.colorTextLabel};
color: ${({ theme }) => theme.colors.grayscale.base};
}
`;

View File

@@ -31,7 +31,7 @@ const MenuDots = styled.div`
width: ${({ theme }) => theme.sizeUnit * 0.75}px;
height: ${({ theme }) => theme.sizeUnit * 0.75}px;
border-radius: 50%;
background-color: ${({ theme }) => theme.colorFill};
background-color: ${({ theme }) => theme.colors.grayscale.light1};
font-weight: ${({ theme }) => theme.fontWeightNormal};
display: inline-flex;
@@ -53,7 +53,7 @@ const MenuDots = styled.div`
width: ${({ theme }) => theme.sizeUnit * 0.75}px;
height: ${({ theme }) => theme.sizeUnit * 0.75}px;
border-radius: 50%;
background-color: ${({ theme }) => theme.colorFill};
background-color: ${({ theme }) => theme.colors.grayscale.light1};
}
&::before {

View File

@@ -325,13 +325,13 @@ export const DropdownContainer = forwardRef(
}
::-webkit-scrollbar-thumb {
border-radius: 9px;
background-color: ${theme.colorFillSecondary};
background-color: ${theme.colors.grayscale.light1};
border: 3px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-track {
background-color: ${theme.colorFillQuaternary};
border-left: 1px solid ${theme.colorFillTertiary};
background-color: ${theme.colors.grayscale.light4};
border-left: 1px solid ${theme.colors.grayscale.light2};
}
}
`}
@@ -368,7 +368,7 @@ export const DropdownContainer = forwardRef(
color={
(dropdownTriggerCount ?? overflowingCount) > 0
? theme.colorPrimary
: theme.colorTextSecondary
: theme.colors.grayscale.light1
}
showZero
css={css`
@@ -377,7 +377,7 @@ export const DropdownContainer = forwardRef(
/>
<Icons.DownOutlined
iconSize="m"
iconColor={theme.colorIcon}
iconColor={theme.colors.grayscale.light1}
css={css`
.anticon {
display: flex;

View File

@@ -34,10 +34,8 @@ const StyledEditableTitle = styled.span<{
canEdit: boolean;
}>`
&.editable-title {
display: inline;
&.editable-title--editing {
width: 100%;
}
display: inline-block;
width: 100%;
input,
textarea {
@@ -54,6 +52,7 @@ const StyledEditableTitle = styled.span<{
input[type='text'],
textarea {
border: 1px solid ${({ theme }) => theme.colorSplit};
color: ${({ theme }) => theme.colorTextTertiary};
border-radius: ${({ theme }) => theme.sizeUnit}px;
font-size: ${({ theme }) => theme.fontSizeLG}px;

View File

@@ -60,7 +60,7 @@ const EmptyStateContainer = styled.div`
flex-direction: column;
width: 100%;
height: 100%;
color: ${theme.colorTextTertiary};
color: ${theme.colorTextQuaternary};
align-items: center;
justify-content: center;
padding: ${theme.sizeUnit * 4}px;
@@ -84,7 +84,7 @@ const EmptyStateContainer = styled.div`
const Title = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSizeLG : theme.fontSize}px;
color: ${theme.colorTextTertiary};
color: ${theme.colorTextQuaternary};
margin-top: ${size === 'large' ? theme.sizeUnit * 4 : theme.sizeUnit * 2}px;
font-weight: ${theme.fontWeightStrong};
`}
@@ -93,7 +93,7 @@ const Title = styled.p<{ size: EmptyStateSize }>`
const Description = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSize : theme.fontSizeSM}px;
color: ${theme.colorTextTertiary};
color: ${theme.colorTextQuaternary};
margin-top: ${theme.sizeUnit * 2}px;
`}
`;

View File

@@ -50,7 +50,17 @@ const IconButton: React.FC<IconButtonProps> = ({
};
const renderIcon = () => {
const iconContent = (
const iconContent = icon ? (
<img
src={icon as string}
alt={altText || buttonText}
css={css`
width: 100%;
object-fit: contain;
height: 100px;
`}
/>
) : (
<div
css={css`
display: flex;
@@ -59,19 +69,12 @@ const IconButton: React.FC<IconButtonProps> = ({
height: 100px;
`}
>
{icon ? (
<img
src={icon as string}
alt={altText || buttonText}
css={css`
width: 100%;
object-fit: contain;
height: 48px;
`}
/>
) : (
<Icons.DatabaseOutlined iconSize="xxl" aria-label="default-icon" />
)}
<Icons.DatabaseOutlined
css={css`
font-size: 48px;
`}
aria-label="default-icon"
/>
</div>
);

View File

@@ -27,8 +27,6 @@ export const IconTooltip = ({
placement = 'top',
style = {},
tooltip = null,
mouseEnterDelay = 0.3,
mouseLeaveDelay = 0.15,
}: IconTooltipProps) => {
const iconTooltip = (
<Button
@@ -49,8 +47,8 @@ export const IconTooltip = ({
id="tooltip"
title={tooltip}
placement={placement}
mouseEnterDelay={mouseEnterDelay}
mouseLeaveDelay={mouseLeaveDelay}
mouseEnterDelay={0.3}
mouseLeaveDelay={0.15}
>
{iconTooltip}
</Tooltip>

View File

@@ -37,6 +37,4 @@ export interface IconTooltipProps {
| 'rightBottom';
style?: object;
tooltip?: string | null;
mouseEnterDelay?: number;
mouseLeaveDelay?: number;
}

View File

@@ -146,7 +146,6 @@ import {
ExportOutlined,
CompressOutlined,
HistoryOutlined,
SlackOutlined,
} from '@ant-design/icons';
import { FC } from 'react';
import { IconType } from './types';
@@ -282,7 +281,6 @@ const AntdIcons = {
ExportOutlined,
CompressOutlined,
HistoryOutlined,
SlackOutlined,
} as const;
type AntdIconNames = keyof typeof AntdIcons;

View File

@@ -25,8 +25,7 @@ import { BaseIconComponent } from './BaseIcon';
const AsyncIcon = (props: IconType) => {
const [, setLoaded] = useState(false);
const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>>>();
const { fileName, customIcons, iconSize, iconColor, viewBox, ...restProps } =
props;
const { fileName, ...restProps } = props;
useEffect(() => {
let cancelled = false;
@@ -47,11 +46,6 @@ const AsyncIcon = (props: IconType) => {
return (
<BaseIconComponent
component={ImportedSVG.current || TransparentIcon}
fileName={fileName}
customIcons={customIcons}
iconSize={iconSize}
iconColor={iconColor}
viewBox={viewBox}
{...restProps}
/>
);

View File

@@ -22,7 +22,7 @@ import { AntdIconType, BaseIconProps, CustomIconType, IconType } from './types';
const genAriaLabel = (fileName: string) => {
const name = fileName.replace(/_/g, '-'); // Replace underscores with dashes
const words = name.split(/(?<=[a-z])(?=[A-Z])/); // Split at lowercase-to-uppercase transitions
const words = name.split(/(?=[A-Z])/); // Split at uppercase letters
if (words.length === 2) {
return words[0].toLowerCase();

View File

@@ -28,17 +28,14 @@ export default {
component: BaseIconComponent,
};
const palette: Record<string, string | null> = {
Default: null,
Primary: supersetTheme.colorPrimary,
Success: supersetTheme.colorSuccess,
Warning: supersetTheme.colorWarning,
Error: supersetTheme.colorError,
Info: supersetTheme.colorInfo,
Text: supersetTheme.colorText,
'Text Secondary': supersetTheme.colorTextSecondary,
Icon: supersetTheme.colorIcon,
};
const palette: Record<string, string | null> = { Default: null };
Object.entries(supersetTheme.colors).forEach(([familyName, family]) => {
Object.entries(family as Record<string, string>).forEach(
([colorName, colorValue]) => {
palette[`${familyName} / ${colorName}`] = colorValue;
},
);
});
const IconSet = styled.div`
display: grid;

View File

@@ -25,7 +25,7 @@ import type { LabelProps } from './types';
export function Label(props: LabelProps) {
const theme = useTheme();
// Use Ant Design's motion duration instead of deprecated transitionTiming
const { transitionTiming } = theme;
const {
type = 'default',
monospace = false,
@@ -46,7 +46,7 @@ export function Label(props: LabelProps) {
const borderColorHover = onClick ? baseColor.borderHover : borderColor;
const labelStyles = css`
transition: background-color ${theme.motionDurationMid};
transition: background-color ${transitionTiming}s;
white-space: nowrap;
cursor: ${onClick ? 'pointer' : 'default'};
overflow: hidden;

View File

@@ -40,14 +40,7 @@ export const PublishedLabel: React.FC<PublishedLabelProps> = ({
const labelType = isPublished ? 'success' : 'primary';
return (
<Label
type={labelType}
icon={icon}
onClick={onClick}
style={{
color: isPublished ? theme.colorSuccessText : theme.colorPrimaryText,
}}
>
<Label type={labelType} icon={icon} onClick={onClick}>
{label}
</Label>
);

View File

@@ -53,7 +53,7 @@ const StyledCard = styled(Card)`
const Cover = styled.div`
height: 264px;
border-bottom: 1px solid ${({ theme }) => theme.colorSplit};
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
overflow: hidden;
.cover-footer {

View File

@@ -34,9 +34,8 @@ const LoaderImg = styled.img`
}
&.inline-centered {
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 30px;
display: block;
}
&.floating {
padding: 0;

View File

@@ -53,7 +53,7 @@ const StyledMenuItem = styled(AntdMenu.Item)`
justify-content: space-between;
}
a {
transition: background-color ${theme.motionDurationMid};
transition: background-color ${theme.motionDurationMid}s;
&:after {
content: '';
position: absolute;
@@ -63,7 +63,7 @@ const StyledMenuItem = styled(AntdMenu.Item)`
height: 3px;
opacity: 0;
transform: translateX(-50%);
transition: translate ${theme.motionDurationMid};
transition: translate ${theme.motionDurationMid}s;
}
&:focus {
@media (max-width: 767px) {
@@ -140,7 +140,7 @@ const StyledSubMenu = styled(AntdMenu.SubMenu)`
height: 3px;
opacity: 0;
transform: translateX(-50%);
transition: all ${theme.motionDurationMid};
transition: all ${theme.transitionTiming}s;
}
}
`}

View File

@@ -30,7 +30,7 @@ const MetadataWrapper = styled.div`
const MetadataText = styled.span`
font-size: ${({ theme }) => theme.fontSizeXS}px;
color: ${({ theme }) => theme.colorTextSecondary};
color: ${({ theme }) => theme.colors.grayscale.light1};
font-weight: ${({ theme }) => theme.fontWeightStrong};
`;

View File

@@ -0,0 +1,37 @@
/**
* 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 { Ellipsis } from './Ellipsis';
test('Ellipsis - click when the button is enabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Ellipsis - click when the button is disabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -17,10 +17,22 @@
* under the License.
*/
declare module 'mustache' {
interface MustacheStatic {
render(template: string, view: any, partials?: any, config?: any): string;
}
const Mustache: MustacheStatic;
export = Mustache;
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
export function Ellipsis({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
</span>
</li>
);
}

View File

@@ -0,0 +1,47 @@
/**
* 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 { Item } from './Item';
test('Item - click when the item is not active', async () => {
const click = jest.fn();
render(
<Item onClick={click}>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Item - click when the item is active', async () => {
const click = jest.fn();
render(
<Item onClick={click} active>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
expect(screen.getByTestId('test')).toBeInTheDocument();
});

View File

@@ -16,27 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
ColorPicker as AntdColorPicker,
type ColorPickerProps as AntdColorPickerProps,
} from 'antd';
// Re-export the AntD ColorPicker as-is for themeable usage
export type ColorPickerProps = AntdColorPickerProps;
export const ColorPicker = AntdColorPicker;
import { ReactNode } from 'react';
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
// Export RGB color type for backward compatibility
export type RGBColor = {
r: number;
g: number;
b: number;
a?: number;
};
// Export type for AntD Color object interface
export interface ColorValue {
toRgb(): RGBColor;
toHexString(): string;
interface PaginationItemButton extends PaginationButtonProps {
active?: boolean;
children: ReactNode;
}
export default ColorPicker;
export function Item({ active, children, onClick }: PaginationItemButton) {
return (
<li className={classNames({ active })}>
<span
role="button"
tabIndex={0}
aria-current={active ? 'page' : undefined}
onClick={e => {
e.preventDefault();
if (!active) onClick(e);
}}
>
{children}
</span>
</li>
);
}

View File

@@ -16,20 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { TimeTableData, Entry } from '../../types';
/**
* Converts raw time table data into sorted entries
*/
export function processTimeTableData(data: TimeTableData): {
entries: Entry[];
reversedEntries: Entry[];
} {
const entries: Entry[] = Object.keys(data)
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
.map(time => ({ ...data[time], time }));
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { Next } from './Next';
const reversedEntries = [...entries].reverse();
test('Next - click when the button is enabled', async () => {
const click = jest.fn();
render(<Next onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
return { entries, reversedEntries };
}
test('Next - click when the button is disabled', async () => {
const click = jest.fn();
render(<Next onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -16,14 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useSelector } from 'react-redux';
import { useMemo } from 'react';
import { RootState } from 'src/dashboard/types';
import getChartIdsFromLayout from '../getChartIdsFromLayout';
export const useAllChartIds = () => {
const layout = useSelector(
(state: RootState) => state.dashboardLayout.present,
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
export function Next({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
»
</span>
</li>
);
return useMemo(() => getChartIdsFromLayout(layout), [layout]);
};
}

View File

@@ -0,0 +1,37 @@
/**
* 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 { Prev } from './Prev';
test('Prev - click when the button is enabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Prev - click when the button is disabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -17,15 +17,22 @@
* under the License.
*/
/**
* Safely parses a value to a numeric type
* @param value - The value to parse (string, number, or null)
* @returns The numeric value or 0 if invalid
*/
export function parseToNumber(value?: string | number | null): number {
const displayValue = value ?? 0;
const numericValue =
typeof displayValue === 'string' ? parseFloat(displayValue) : displayValue;
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
return Number.isNaN(numericValue) ? 0 : numericValue;
export function Prev({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
«
</span>
</li>
);
}

View File

@@ -0,0 +1,75 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, cleanup } from '@superset-ui/core/spec';
import Wrapper from './Wrapper';
// Add cleanup after each test
afterEach(async () => {
cleanup();
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
jest.mock('./Next', () => ({
Next: () => <div data-test="next" />,
}));
jest.mock('./Prev', () => ({
Prev: () => <div data-test="prev" />,
}));
jest.mock('./Item', () => ({
Item: () => <div data-test="item" />,
}));
jest.mock('./Ellipsis', () => ({
Ellipsis: () => <div data-test="ellipsis" />,
}));
test('Pagination rendering correctly', async () => {
render(
<Wrapper>
<li data-test="test" />
</Wrapper>,
);
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Next attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Prev attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Item attribute', async () => {
render(
<Wrapper.Item onClick={jest.fn()}>
<></>
</Wrapper.Item>,
);
expect(screen.getByTestId('item')).toBeInTheDocument();
});
test('Ellipsis attribute', async () => {
render(<Wrapper.Ellipsis onClick={jest.fn()} />);
expect(screen.getByTestId('ellipsis')).toBeInTheDocument();
});

View File

@@ -0,0 +1,90 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { styled } from '@superset-ui/core';
import { Next } from './Next';
import { Prev } from './Prev';
import { Item } from './Item';
import { Ellipsis } from './Ellipsis';
interface PaginationProps {
children: JSX.Element | JSX.Element[];
}
const PaginationList = styled.ul`
${({ theme }) => `
display: inline-block;
padding: ${theme.sizeUnit * 3}px;
li {
display: inline;
margin: 0 4px;
> span {
padding: 8px 12px;
text-decoration: none;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
color: ${theme.colorText};
&:hover,
&:focus {
z-index: 2;
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
}
}
&.disabled {
span {
background-color: transparent;
cursor: default;
&:focus {
outline: none;
}
}
}
&.active {
span {
z-index: 3;
color: ${theme.colorBgLayout};
cursor: default;
background-color: ${theme.colorPrimary};
&:focus {
outline: none;
}
}
}
}
`}
`;
function Pagination({ children }: PaginationProps) {
return <PaginationList role="navigation">{children}</PaginationList>;
}
Pagination.Next = Next;
Pagination.Prev = Prev;
Pagination.Item = Item;
Pagination.Ellipsis = Ellipsis;
export default Pagination;

View File

@@ -0,0 +1,47 @@
/**
* 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 Pagination from '@superset-ui/core/components/Pagination/Wrapper';
import {
createUltimatePagination,
ITEM_TYPES,
} from 'react-ultimate-pagination';
const ListViewPagination = createUltimatePagination({
WrapperComponent: Pagination,
itemTypeToComponent: {
[ITEM_TYPES.PAGE]: ({ value, isActive, onClick }) => (
<Pagination.Item active={isActive} onClick={onClick}>
{value}
</Pagination.Item>
),
[ITEM_TYPES.ELLIPSIS]: ({ isActive, onClick }) => (
<Pagination.Ellipsis disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.PREVIOUS_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Prev disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.NEXT_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Next disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.FIRST_PAGE_LINK]: () => null,
[ITEM_TYPES.LAST_PAGE_LINK]: () => null,
},
});
export default ListViewPagination;

View File

@@ -16,6 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import Header from './Header';
export default Header;
import { EventHandler, SyntheticEvent } from 'react';
export interface PaginationButtonProps {
disabled?: boolean;
onClick: EventHandler<SyntheticEvent<HTMLElement>>;
}

View File

@@ -60,12 +60,12 @@ const menuItemStyles = (theme: any) => css`
}
&:hover {
background: ${theme.colorFillQuaternary};
background: ${theme.colors.grayscale.light3};
}
&.active {
font-weight: ${theme.fontWeightStrong};
background: ${theme.colorFillTertiary};
background: ${theme.colors.grayscale.light2};
}
}

View File

@@ -66,14 +66,16 @@ export default function PopoverSection({
<Icons.InfoCircleOutlined
role="img"
iconSize="s"
iconColor={theme.colorIcon}
iconColor={theme.colors.grayscale.light1}
/>
</Tooltip>
)}
<Icons.CheckOutlined
iconSize="s"
role="img"
iconColor={isSelected ? theme.colorPrimary : theme.colorIcon}
iconColor={
isSelected ? theme.colorPrimary : theme.colors.grayscale.base
}
/>
</div>
<div

View File

@@ -45,7 +45,7 @@ const RefreshLabel = ({
onClick={disabled ? undefined : onClick}
css={(theme: SupersetTheme) => ({
cursor: 'pointer',
color: theme.colorIcon,
color: theme.colors.grayscale.base,
'&:hover': { color: theme.colorPrimary },
})}
/>

View File

@@ -779,38 +779,6 @@ test('Renders only an overflow tag if dropdown is open in oneLine mode', async (
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
// Test for checking the issue described in: https://github.com/apache/superset/issues/35132
test('Maintains stable maxTagCount to prevent click target disappearing in oneLine mode', async () => {
render(
<Select
{...defaultProps}
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
mode="multiple"
oneLine
/>,
);
const withinSelector = within(getElementByClassName('.ant-select-selector'));
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
await userEvent.click(getSelect());
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
await waitFor(() => {
expect(
withinSelector.queryByText(OPTIONS[0].label),
).not.toBeInTheDocument();
expect(withinSelector.getByText('+ 3 ...')).toBeVisible();
});
// Close dropdown
await type('{esc}');
expect(await withinSelector.findByText(OPTIONS[0].label)).toBeVisible();
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
test('does not render "Select all" when there are 0 or 1 options', async () => {
const { rerender } = render(
<Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,

View File

@@ -24,7 +24,6 @@ import {
useMemo,
useState,
useCallback,
useRef,
ClipboardEvent,
Ref,
ReactElement,
@@ -148,32 +147,6 @@ const Select = forwardRef(
}
}, [isDropdownVisible, oneLine]);
// Prevent maxTagCount change during click events to avoid click target disappearing
const [stableMaxTagCount, setStableMaxTagCount] = useState(maxTagCount);
const isOpeningRef = useRef(false);
useEffect(() => {
if (oneLine) {
if (isDropdownVisible && !isOpeningRef.current) {
// Mark that we're in the opening process
isOpeningRef.current = true;
// Use requestAnimationFrame to ensure DOM has settled after the click
requestAnimationFrame(() => {
setStableMaxTagCount(0);
isOpeningRef.current = false;
});
return;
}
if (!isDropdownVisible) {
// When closing, immediately show the first tag
setStableMaxTagCount(1);
isOpeningRef.current = false;
}
return;
}
setStableMaxTagCount(maxTagCount);
}, [maxTagCount, isDropdownVisible, oneLine]);
const mappedMode = isSingleMode ? undefined : 'multiple';
const sortSelectedFirst = useCallback(
@@ -634,16 +607,16 @@ const Select = forwardRef(
const omittedCount = useMemo(() => {
const num_selected = ensureIsArray(selectValue).length;
const num_shown = stableMaxTagCount as number;
const num_shown = maxTagCount as number;
return num_selected - num_shown - (selectAllMode ? 1 : 0);
}, [stableMaxTagCount, selectAllMode, selectValue]);
}, [maxTagCount, selectAllMode, selectValue]);
const customMaxTagPlaceholder = () =>
`+ ${omittedCount > 0 ? omittedCount : 1} ...`;
// We can't remove the + tag so when Select All
// is the only item omitted, we subtract one from maxTagCount
let actualMaxTagCount = stableMaxTagCount;
let actualMaxTagCount = maxTagCount;
if (
actualMaxTagCount !== 'responsive' &&
omittedCount === 0 &&

View File

@@ -28,7 +28,6 @@ export const StyledHeader = styled.span<{ headerPosition: string }>`
text-overflow: ellipsis;
white-space: nowrap;
margin-right: ${headerPosition === 'left' ? theme.sizeUnit * 2 : 0}px;
font-size: ${theme.fontSizeSM}px;
`}
`;
@@ -105,17 +104,17 @@ export const StyledLoadingText = styled.div`
${({ theme }) => `
margin-left: ${theme.sizeUnit * 3}px;
line-height: ${theme.sizeUnit * 8}px;
color: ${theme.colorTextSecondary};
color: ${theme.colors.grayscale.light1};
`}
`;
export const StyledHelperText = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
color: ${theme.colorText};
color: ${theme.colors.grayscale.base};
font-size: ${theme.fontSizeSM}px;
cursor: default;
border-bottom: 1px solid ${theme.colorBorderSecondary};
border-bottom: 1px solid ${theme.colors.grayscale.light2};
`}
`;
@@ -141,6 +140,6 @@ export const StyledErrorMessage = styled.div`
export const StyledBulkActionsContainer = styled(Flex)`
${({ theme }) => `
padding: ${theme.sizeUnit}px;
border-top: 1px solid ${theme.colorSplit};
border-top: 1px solid ${theme.colors.grayscale.light3};
`}
`;

View File

@@ -20,7 +20,7 @@ import { styled } from '../../../..';
import { Constants } from '../../..';
const GrayCell = styled.span`
color: ${({ theme }) => theme.colorTextSecondary};
color: ${({ theme }) => theme.colors.grayscale.light1};
`;
function NullCell() {

View File

@@ -74,7 +74,7 @@ function HeaderWithRadioGroup(props: HeaderWithRadioGroupProps) {
>
<Icons.SettingOutlined
iconSize="m"
iconColor={theme.colorIcon}
iconColor={theme.colors.grayscale.light1}
css={css`
margin-top: ${theme.sizeUnit * 0.75}px;
margin-right: ${theme.sizeUnit}px;

View File

@@ -167,33 +167,6 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
.ant-table-body {
overflow: auto;
height: ${height ? `${height}px` : undefined};
/* Chrome/Safari/Edge webkit scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: ${theme.colorFillQuaternary};
}
&::-webkit-scrollbar-thumb {
background: ${theme.colorFillSecondary};
border-radius: ${theme.borderRadiusSM}px;
&:hover {
background: ${theme.colorFillTertiary};
}
}
&::-webkit-scrollbar-corner {
background: ${theme.colorFillQuaternary};
}
/* Firefox scrollbar styling */
scrollbar-width: thin;
scrollbar-color: ${theme.colorFillSecondary} ${theme.colorFillQuaternary};
}
.ant-spin-nested-loading .ant-spin .ant-spin-dot {
@@ -207,10 +180,6 @@ const StyledTable = styled(AntTable as FC<AntTableProps>)<{ height?: number }>(
text-overflow: ellipsis;
}
td.ant-table-cell.no-ellipsis {
text-overflow: unset;
}
.ant-table-tbody > tr > td {
white-space: nowrap;
overflow: hidden;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from '@superset-ui/core/spec';
import { render, screen } from '@superset-ui/core/spec';
import { renderHook } from '@testing-library/react-hooks';
import { TableInstance, useTable } from 'react-table';
import TableCollection from '.';
@@ -36,28 +36,19 @@ beforeEach(() => {
accessor: 'col2',
id: 'col2',
},
{
Header: 'Nested Field',
accessor: 'parent.child',
id: 'parent.child',
dataIndex: ['parent', 'child'],
},
];
const data = [
{
col1: 'Line 01 - Col 01',
col2: 'Line 01 - Col 02',
parent: { child: 'Nested Value 1' },
},
{
col1: 'Line 02 - Col 01',
col2: 'Line 02 - Col 02',
parent: { child: 'Nested Value 2' },
},
{
col1: 'Line 03 - Col 01',
col2: 'Line 03 - Col 02',
parent: { child: 'Nested Value 3' },
},
];
// @ts-ignore
@@ -109,134 +100,3 @@ test('Should the loading-indicator be visible during loading', () => {
expect(screen.getByTestId('loading-indicator')).toBeVisible();
});
test('Pagination controls should be rendered when pageSize is provided', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...paginationProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination should call onPageChange when page is changed', async () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Simulate pagination change
await screen.findByTitle('Next Page');
// Verify onPageChange would be called with correct arguments
// The actual AntD pagination will handle the click internally
expect(onPageChange).toBeDefined();
// Verify that re-rendering with new pageIndex works
rerender(<TableCollection {...paginationProps} pageIndex={1} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination callback should be stable across re-renders', () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Re-render with same props
rerender(<TableCollection {...paginationProps} />);
// onPageChange should not have been called during re-render
expect(onPageChange).not.toHaveBeenCalled();
});
test('Should display correct page info when showRowCount is true', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: true,
};
render(<TableCollection {...paginationProps} />);
// AntD pagination shows page info
expect(screen.getByText('1-2 of 3')).toBeInTheDocument();
});
test('Should not display page info when showRowCount is false', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: false,
};
render(<TableCollection {...paginationProps} />);
// Page info should not be shown
expect(screen.queryByText('1-2 of 3')).not.toBeInTheDocument();
});
test('Bulk selection should work with pagination', () => {
const toggleRowSelected = jest.fn();
const toggleAllRowsSelected = jest.fn();
const selectionProps = {
...defaultProps,
bulkSelectEnabled: true,
selectedFlatRows: [],
toggleRowSelected,
toggleAllRowsSelected,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...selectionProps} />);
// Check that selection checkboxes are rendered
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});
test('should call setSortBy when clicking sortable column header', () => {
const setSortBy = jest.fn();
const sortingProps = {
...defaultProps,
setSortBy,
};
render(<TableCollection {...sortingProps} />);
// Target the nested field column (the column that needs the array-to-dot conversion)
const nestedFieldHeader = screen.getByText('Nested Field');
expect(nestedFieldHeader).toBeInTheDocument();
// Click on the nested field column header to trigger sorting
fireEvent.click(nestedFieldHeader);
// Verify setSortBy was called with the correct arguments and dot notation conversion
expect(setSortBy).toHaveBeenCalledWith([
{
id: 'parent.child',
desc: expect.any(Boolean),
},
]);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { HTMLAttributes, memo, useMemo, useCallback } from 'react';
import { HTMLAttributes, memo, useMemo } from 'react';
import {
ColumnInstance,
HeaderGroup,
@@ -47,25 +47,15 @@ interface TableCollectionProps<T extends object> {
toggleAllRowsSelected?: (value?: boolean) => void;
sticky?: boolean;
size?: TableSize;
pageIndex?: number;
pageSize?: number;
totalCount?: number;
onPageChange?: (page: number, pageSize: number) => void;
isPaginationSticky?: boolean;
showRowCount?: boolean;
}
const StyledTable = styled(Table)<{
isPaginationSticky?: boolean;
showRowCount?: boolean;
}>`
${({ theme, isPaginationSticky, showRowCount }) => `
const StyledTable = styled(Table)`
${({ theme }) => `
th.ant-column-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
opacity: 0;
font-size: ${theme.fontSizeXL}px;
@@ -82,25 +72,16 @@ const StyledTable = styled(Table)<{
}
}
}
.ant-table-column-title {
line-height: initial;
}
.ant-table-row:hover {
.actions {
opacity: 1;
transition: opacity ease-in ${theme.motionDurationMid};
}
}
.ant-table-row.table-row-highlighted > td.ant-table-cell,
.ant-table-row.table-row-highlighted > td.ant-table-cell.ant-table-cell-row-hover {
background-color: ${theme.colorPrimaryBg};
}
.ant-table-cell {
max-width: 320px;
font-feature-settings: 'tnum' 1;
text-overflow: ellipsis;
overflow: hidden;
@@ -109,50 +90,9 @@ const StyledTable = styled(Table)<{
padding-left: ${theme.sizeUnit * 4}px;
white-space: nowrap;
}
.ant-table-tbody > tr > td {
height: ${theme.sizeUnit * 12}px;
}
.ant-table-tbody > tr > td.ant-table-cell:has(.ant-avatar-group) {
padding-top: ${theme.sizeUnit}px;
padding-bottom: ${theme.sizeUnit}px;
}
.ant-table-placeholder .ant-table-cell {
border-bottom: 0;
}
&.ant-table-wrapper .ant-table-pagination.ant-pagination {
display: flex;
justify-content: center;
margin: ${showRowCount ? theme.sizeUnit * 4 : 0}px 0 ${showRowCount ? theme.sizeUnit * 14 : 0}px 0;
position: relative;
.ant-pagination-total-text {
color: ${theme.colorTextBase};
margin-inline-end: 0;
position: absolute;
top: ${theme.sizeUnit * 12}px;
}
${
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
z-index: 1;
background-color: ${theme.colorBgElevated};
padding: ${theme.sizeUnit * 2}px 0;
`
}
}
// Hotfix - antd doesn't apply background color to overflowing cells
& table {
background-color: ${theme.colorBgContainer};
}
`}
`;
@@ -160,7 +100,6 @@ function TableCollection<T extends object>({
columns,
rows,
loading,
highlightRowId,
setSortBy,
headerGroups,
columnsForWrapText,
@@ -171,22 +110,13 @@ function TableCollection<T extends object>({
prepareRow,
sticky,
size = TableSize.Middle,
pageIndex = 0,
pageSize = 25,
totalCount = 0,
onPageChange,
isPaginationSticky = false,
showRowCount = true,
}: TableCollectionProps<T>) {
const mappedColumns = useMemo(
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
[columns, headerGroups, columnsForWrapText],
);
const mappedRows = useMemo(
() => mapRows(rows, prepareRow),
[rows, prepareRow],
const mappedColumns = mapColumns<T>(
columns,
headerGroups,
columnsForWrapText,
);
const mappedRows = mapRows(rows, prepareRow);
const selectedRowKeys = useMemo(
() => selectedFlatRows?.map(row => row.id) || [],
@@ -211,79 +141,6 @@ function TableCollection<T extends object>({
toggleRowSelected,
toggleAllRowsSelected,
]);
const handlePaginationChange = useCallback(
(page: number, size: number) => {
const validPage = Math.max(0, (page || 1) - 1);
const validSize = size || pageSize;
onPageChange?.(validPage, validSize);
},
[pageSize, onPageChange],
);
const showTotalFunc = useCallback(
(total: number, range: [number, number]) =>
`${range[0]}-${range[1]} of ${total}`,
[],
);
const handleTableChange = useCallback(
(_pagination: any, _filters: any, sorter: SorterResult) => {
if (sorter && sorter.field) {
// Convert array field back to dot notation for nested fields
const fieldId = Array.isArray(sorter.field)
? sorter.field.join('.')
: sorter.field;
setSortBy?.([
{
id: fieldId,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}
},
[setSortBy],
);
const paginationConfig = useMemo(() => {
if (totalCount === 0) return false;
const config: any = {
pageSize,
size: 'default' as const,
showSizeChanger: false,
showQuickJumper: false,
align: 'center' as const,
showTotal: showRowCount ? showTotalFunc : undefined,
};
if (onPageChange) {
config.current = pageIndex + 1;
config.total = totalCount;
config.onChange = handlePaginationChange;
} else {
if (pageIndex > 0) config.defaultCurrent = pageIndex + 1;
config.total = totalCount;
}
return config;
}, [
pageSize,
totalCount,
showRowCount,
showTotalFunc,
pageIndex,
handlePaginationChange,
onPageChange,
]);
const getRowClassName = useCallback(
(record: Record<string, unknown>) =>
record?.id === highlightRowId ? 'table-row-highlighted' : '',
[highlightRowId],
);
return (
<StyledTable
loading={loading}
@@ -292,16 +149,12 @@ function TableCollection<T extends object>({
data={mappedRows}
size={size}
data-test="listview-table"
pagination={paginationConfig}
scroll={{ x: 'max-content' }}
tableLayout="auto"
pagination={false}
tableLayout="fixed"
rowKey="rowId"
rowSelection={rowSelection}
locale={{ emptyText: null }}
sortDirections={['ascend', 'descend', 'ascend']}
isPaginationSticky={isPaginationSticky}
showRowCount={showRowCount}
rowClassName={getRowClassName}
components={{
header: {
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
@@ -317,7 +170,14 @@ function TableCollection<T extends object>({
),
},
}}
onChange={handleTableChange}
onChange={(_pagination, _filters, sorter: SorterResult) => {
setSortBy?.([
{
id: sorter.field,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}}
/>
);
}

View File

@@ -56,7 +56,6 @@ type EnhancedColumnInstance<T extends object = any> = RTColumnInstance<T> &
Partial<UseResizeColumnsColumnProps<T>> & {
hidden?: boolean;
size?: keyof typeof COLUMN_SIZE_MAP;
className?: string;
};
type EnhancedHeaderGroup<T extends object = any> = RTHeaderGroup<T> & {
@@ -95,7 +94,7 @@ export function mapColumns<T extends object>(
dataIndex: column.id?.includes('.') ? column.id.split('.') : column.id,
hidden: column.hidden,
key: column.id,
width: column.size ? COLUMN_SIZE_MAP[column.size] : undefined,
width: column.size ? COLUMN_SIZE_MAP[column.size] : COLUMN_SIZE_MAP.md,
ellipsis: !columnsForWrapText?.includes(column.id),
defaultSortOrder: (isSorted
? isSortedDesc
@@ -123,7 +122,6 @@ export function mapColumns<T extends object>(
}
return val;
},
className: column.className,
};
});
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { TableView, TableViewProps } from '.';
const mockedProps: TableViewProps = {
@@ -30,7 +30,6 @@ const mockedProps: TableViewProps = {
{
accessor: 'age',
Header: 'Age',
sortable: true,
id: 'age',
},
{
@@ -79,10 +78,10 @@ test('should render the cells', () => {
test('should render the pagination', () => {
render(<TableView {...mockedProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getByTitle('Previous Page')).toBeInTheDocument();
expect(screen.getByTitle('Next Page')).toBeInTheDocument();
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(4);
expect(screen.getByText('«')).toBeInTheDocument();
expect(screen.getByText('»')).toBeInTheDocument();
});
test('should show the row count by default', () => {
@@ -105,63 +104,45 @@ test('should NOT render the pagination when disabled', () => {
withPagination: false,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('list')).not.toBeInTheDocument();
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
test('should render the pagination even when fewer rows than page size', () => {
test('should NOT render the pagination when fewer rows than page size', () => {
const withoutPaginationProps = {
...mockedProps,
pageSize: 3,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
test('should change page when pagination is clicked', async () => {
test('should change page when « and » buttons are clicked', async () => {
render(<TableView {...mockedProps} />);
const nextBtn = screen.getByText('»');
const prevBtn = screen.getByText('«');
await userEvent.click(nextBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
await userEvent.click(prevBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
});
const page1 = screen.getByRole('listitem', { name: '1' });
await userEvent.click(page1);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
});
});
test('should sort by age', async () => {
render(<TableView {...mockedProps} />);
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
});
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
});
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
});
test('should sort by initialSortBy DESC', () => {
@@ -227,146 +208,3 @@ test('should render the right wrap content text by columnsForWrapText', () => {
'ant-table-cell-ellipsis',
);
});
test('should handle server-side pagination', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
render(<TableView {...serverPaginationProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 1,
});
});
});
test('should handle server-side sorting', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
};
render(<TableView {...serverPaginationProps} />);
// Click on sortable column
await userEvent.click(screen.getAllByTestId('sort-header')[0]);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 0,
sortBy: [{ id: 'id', desc: false }],
});
});
});
test('pagination callbacks should be stable across re-renders', () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
const { rerender } = render(<TableView {...serverPaginationProps} />);
// Re-render with same props
rerender(<TableView {...serverPaginationProps} />);
// onServerPagination should not have been called during re-render
expect(onServerPagination).not.toHaveBeenCalled();
});
test('should scroll to top when scrollTopOnPagination is true', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: true,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
scrollToSpy.mockRestore();
});
test('should NOT scroll to top when scrollTopOnPagination is false', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: false,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getByText('321')).toBeInTheDocument();
});
expect(scrollToSpy).not.toHaveBeenCalled();
scrollToSpy.mockRestore();
});
test('should handle totalCount of 0 correctly', () => {
const emptyProps = {
...mockedProps,
data: [],
totalCount: 0,
};
render(<TableView {...emptyProps} />);
// Pagination should not be shown when totalCount is 0
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should handle large datasets with pagination', () => {
const largeDataset = Array.from({ length: 100 }, (_, i) => ({
id: i,
age: 20 + i,
name: `Person ${i}`,
}));
const largeDataProps = {
...mockedProps,
data: largeDataset,
pageSize: 10,
};
render(<TableView {...largeDataProps} />);
// Should show only first page (10 items)
expect(screen.getAllByTestId('table-row')).toHaveLength(10);
// Should show pagination with correct page count
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
});

View File

@@ -16,17 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import { memo, useEffect, useRef } from 'react';
import { isEqual } from 'lodash';
import { styled } from '@superset-ui/core';
import { styled, t } from '@superset-ui/core';
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
import { Empty } from '@superset-ui/core/components';
import Pagination from '@superset-ui/core/components/Pagination';
import TableCollection from '@superset-ui/core/components/TableCollection';
import { TableSize } from '@superset-ui/core/components/Table';
import { SortByType, ServerPagination } from './types';
const NOOP_SERVER_PAGINATION = () => {};
const DEFAULT_PAGE_SIZE = 10;
export enum EmptyWrapperType {
@@ -97,6 +96,29 @@ const TableViewStyles = styled.div<{
}
`;
const PaginationStyles = styled.div<{
isPaginationSticky?: boolean;
}>`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.colorBgElevated};
${({ isPaginationSticky }) =>
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
`};
.row-count-container {
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
color: ${({ theme }) => theme.colorText};
}
`;
const RawTableView = ({
columns,
data,
@@ -111,21 +133,16 @@ const RawTableView = ({
showRowCount = true,
serverPagination = false,
columnsForWrapText,
onServerPagination = NOOP_SERVER_PAGINATION,
scrollTopOnPagination = true,
onServerPagination = () => {},
scrollTopOnPagination = false,
size = TableSize.Middle,
...props
}: TableViewProps) => {
const tableRef = useRef<HTMLTableElement>(null);
const initialState = useMemo(
() => ({
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
}),
[initialPageSize, initialPageIndex, initialSortBy],
);
const initialState = {
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
};
const {
getTableProps,
@@ -134,9 +151,10 @@ const RawTableView = ({
page,
rows,
prepareRow,
pageCount,
gotoPage,
setSortBy,
state: { pageIndex, sortBy },
state: { pageIndex, pageSize, sortBy },
} = useTable(
{
columns,
@@ -144,94 +162,36 @@ const RawTableView = ({
initialState,
manualPagination: serverPagination,
manualSortBy: serverPagination,
pageCount: serverPagination
? Math.ceil(totalCount / initialState.pageSize)
: undefined,
autoResetSortBy: false,
pageCount: Math.ceil(totalCount / initialState.pageSize),
},
useFilters,
useSortBy,
...(withPagination ? [usePagination] : []),
usePagination,
);
const EmptyWrapperComponent = useMemo(() => {
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
return ({ children }: any) => <>{children}</>;
case EmptyWrapperType.Default:
default:
return ({ children }: any) => <EmptyWrapper>{children}</EmptyWrapper>;
}
}, [emptyWrapperType]);
const content = withPagination ? page : rows;
const content = useMemo(
() => (withPagination ? page : rows),
[withPagination, page, rows],
);
let EmptyWrapperComponent;
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
EmptyWrapperComponent = ({ children }: any) => <>{children}</>;
break;
case EmptyWrapperType.Default:
default:
EmptyWrapperComponent = ({ children }: any) => (
<EmptyWrapper>{children}</EmptyWrapper>
);
}
const isEmpty = useMemo(
() => !loading && content.length === 0,
[loading, content.length],
);
const handleScrollToTop = useCallback(() => {
const isEmpty = !loading && content.length === 0;
const hasPagination = pageCount > 1 && withPagination;
const tableRef = useRef<HTMLTableElement>(null);
const handleGotoPage = (p: number) => {
if (scrollTopOnPagination) {
if (tableRef?.current) {
if (typeof tableRef.current.scrollTo === 'function') {
tableRef.current.scrollTo({ top: 0, behavior: 'smooth' });
} else if (typeof tableRef.current.scroll === 'function') {
tableRef.current.scroll(0, 0);
}
}
if (typeof window !== 'undefined' && window.scrollTo)
window.scrollTo({ top: 0, behavior: 'smooth' });
tableRef?.current?.scroll(0, 0);
}
}, [scrollTopOnPagination]);
const handlePageChange = useCallback(
(p: number) => {
if (scrollTopOnPagination) handleScrollToTop();
gotoPage(p);
},
[scrollTopOnPagination, handleScrollToTop, gotoPage],
);
const paginationProps = useMemo(() => {
if (!withPagination) {
return {
pageIndex: 0,
pageSize: data.length,
totalCount: 0,
onPageChange: undefined,
};
}
if (serverPagination) {
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount,
onPageChange: handlePageChange,
};
}
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount: data.length,
onPageChange: handlePageChange,
};
}, [
withPagination,
serverPagination,
pageIndex,
initialPageSize,
totalCount,
data.length,
handlePageChange,
]);
gotoPage(p);
};
useEffect(() => {
if (serverPagination && pageIndex !== initialState.pageIndex) {
@@ -239,7 +199,7 @@ const RawTableView = ({
pageIndex,
});
}
}, [initialState.pageIndex, onServerPagination, pageIndex, serverPagination]);
}, [pageIndex]);
useEffect(() => {
if (serverPagination && !isEqual(sortBy, initialState.sortBy)) {
@@ -248,38 +208,61 @@ const RawTableView = ({
sortBy,
});
}
}, [initialState.sortBy, onServerPagination, serverPagination, sortBy]);
}, [sortBy]);
return (
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
isPaginationSticky={props.isPaginationSticky}
showRowCount={showRowCount}
{...paginationProps}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
<>
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</EmptyWrapperComponent>
)}
</TableViewStyles>
{hasPagination && (
<PaginationStyles
className="pagination-container"
isPaginationSticky={props.isPaginationSticky}
>
<Pagination
totalPages={pageCount || 0}
currentPage={pageCount ? pageIndex + 1 : 0}
onChange={(p: number) => handleGotoPage(p - 1)}
hideFirstAndLastPageLinks
/>
{showRowCount && (
<div className="row-count-container">
{!loading &&
t(
'%s-%s of %s',
pageSize * pageIndex + (page.length && 1),
pageSize * pageIndex + page.length,
totalCount,
)}
</div>
)}
</EmptyWrapperComponent>
</PaginationStyles>
)}
</TableViewStyles>
</>
);
};

View File

@@ -1,377 +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 { fireEvent, render } from '@superset-ui/core/spec';
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
const defaultItems = [
{
key: '1',
label: 'Tab 1',
children: <div data-testid="tab1-content">Tab 1 content</div>,
},
{
key: '2',
label: 'Tab 2',
children: <div data-testid="tab2-content">Tab 2 content</div>,
},
{
key: '3',
label: 'Tab 3',
children: <div data-testid="tab3-content">Tab 3 content</div>,
},
];
describe('Tabs', () => {
describe('Basic Tabs', () => {
it('should render tabs with default props', () => {
const { getByText, container } = render(<Tabs items={defaultItems} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
expect(getByText('Tab 3')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active',
);
expect(activeTabContent).toBeDefined();
expect(
activeTabContent?.querySelector('[data-testid="tab1-content"]'),
).toBeDefined();
});
it('should render tabs component structure', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
const tabsNav = container.querySelector('.ant-tabs-nav');
const tabsContent = container.querySelector('.ant-tabs-content-holder');
expect(tabsElement).toBeDefined();
expect(tabsNav).toBeDefined();
expect(tabsContent).toBeDefined();
});
it('should apply default tabBarStyle with padding', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
// Check that tabBarStyle is applied (default padding is added)
expect(tabsNav?.style?.paddingLeft).toBeDefined();
});
it('should merge custom tabBarStyle with defaults', () => {
const customStyle = { paddingRight: '20px', backgroundColor: 'red' };
const { container } = render(
<Tabs items={defaultItems} tabBarStyle={customStyle} />,
);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
expect(tabsNav?.style?.paddingLeft).toBeDefined();
expect(tabsNav?.style?.paddingRight).toBe('20px');
expect(tabsNav?.style?.backgroundColor).toBe('red');
});
it('should handle allowOverflow prop', () => {
const { container: allowContainer } = render(
<Tabs items={defaultItems} allowOverflow />,
);
const { container: disallowContainer } = render(
<Tabs items={defaultItems} allowOverflow={false} />,
);
expect(allowContainer.querySelector('.ant-tabs')).toBeDefined();
expect(disallowContainer.querySelector('.ant-tabs')).toBeDefined();
});
it('should disable animation by default', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).not.toContain('ant-tabs-animated');
});
it('should handle tab change events', () => {
const onChangeMock = jest.fn();
const { getByText } = render(
<Tabs items={defaultItems} onChange={onChangeMock} />,
);
fireEvent.click(getByText('Tab 2'));
expect(onChangeMock).toHaveBeenCalledWith('2');
});
it('should pass through additional props to Antd Tabs', () => {
const onTabClickMock = jest.fn();
const { getByText } = render(
<Tabs
items={defaultItems}
onTabClick={onTabClickMock}
size="large"
centered
/>,
);
fireEvent.click(getByText('Tab 2'));
expect(onTabClickMock).toHaveBeenCalled();
});
});
describe('EditableTabs', () => {
it('should render with editable features', () => {
const { container } = render(<EditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should handle onEdit callback for add/remove actions', () => {
const onEditMock = jest.fn();
const itemsWithRemove = defaultItems.map(item => ({
...item,
closable: true,
}));
const { container } = render(
<EditableTabs items={itemsWithRemove} onEdit={onEditMock} />,
);
const removeButton = container.querySelector('.ant-tabs-tab-remove');
expect(removeButton).toBeDefined();
fireEvent.click(removeButton!);
expect(onEditMock).toHaveBeenCalledWith(expect.any(String), 'remove');
});
it('should have default props set correctly', () => {
expect(EditableTabs.defaultProps?.type).toBe('editable-card');
expect(EditableTabs.defaultProps?.animated).toEqual({
inkBar: true,
tabPane: false,
});
});
});
describe('LineEditableTabs', () => {
it('should render as line-style editable tabs', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should render with line-specific styling', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const inkBar = container.querySelector('.ant-tabs-ink-bar');
expect(inkBar).toBeDefined();
});
});
describe('TabPane Legacy Support', () => {
it('should support TabPane component access', () => {
expect(Tabs.TabPane).toBeDefined();
expect(EditableTabs.TabPane).toBeDefined();
expect(LineEditableTabs.TabPane).toBeDefined();
});
it('should render using legacy TabPane syntax', () => {
const { getByText, container } = render(
<Tabs>
<Tabs.TabPane tab="Legacy Tab 1" key="1">
<div data-testid="legacy-content-1">Legacy content 1</div>
</Tabs.TabPane>
<Tabs.TabPane tab="Legacy Tab 2" key="2">
<div data-testid="legacy-content-2">Legacy content 2</div>
</Tabs.TabPane>
</Tabs>,
);
expect(getByText('Legacy Tab 1')).toBeInTheDocument();
expect(getByText('Legacy Tab 2')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active [data-testid="legacy-content-1"]',
);
expect(activeTabContent).toBeDefined();
expect(activeTabContent?.textContent).toBe('Legacy content 1');
});
});
describe('Edge Cases', () => {
it('should handle empty items array', () => {
const { container } = render(<Tabs items={[]} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle undefined items', () => {
const { container } = render(<Tabs />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle tabs with no content', () => {
const itemsWithoutContent = [
{ key: '1', label: 'Tab 1' },
{ key: '2', label: 'Tab 2' },
];
const { getByText } = render(<Tabs items={itemsWithoutContent} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
});
it('should handle allowOverflow default value', () => {
const { container } = render(<Tabs items={defaultItems} />);
expect(container.querySelector('.ant-tabs')).toBeDefined();
});
});
describe('Accessibility', () => {
it('should render with proper ARIA roles', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tablist = container.querySelector('[role="tablist"]');
const tabs = container.querySelectorAll('[role="tab"]');
expect(tablist).toBeDefined();
expect(tabs.length).toBe(3);
});
it('should support keyboard navigation', () => {
const { container, getByText } = render(<Tabs items={defaultItems} />);
const firstTab = container.querySelector('[role="tab"]');
const secondTab = getByText('Tab 2');
if (firstTab) {
fireEvent.keyDown(firstTab, { key: 'ArrowRight', code: 'ArrowRight' });
}
fireEvent.click(secondTab);
expect(secondTab).toBeInTheDocument();
});
});
describe('Styling Integration', () => {
it('should accept and apply custom CSS classes', () => {
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} className="custom-tabs-class" />,
);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('custom-tabs-class');
});
it('should accept and apply custom styles', () => {
const customStyle = { minHeight: '200px' };
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} style={customStyle} />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
expect(tabsElement?.style?.minHeight).toBe('200px');
});
});
});
test('fullHeight prop renders component hierarchy correctly', () => {
const { container } = render(<Tabs items={defaultItems} fullHeight />);
const tabsElement = container.querySelector('.ant-tabs');
const contentHolder = container.querySelector('.ant-tabs-content-holder');
const content = container.querySelector('.ant-tabs-content');
const tabPane = container.querySelector('.ant-tabs-tabpane');
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
expect(content).toBeInTheDocument();
expect(tabPane).toBeInTheDocument();
expect(tabsElement?.contains(contentHolder as Node)).toBe(true);
expect(contentHolder?.contains(content as Node)).toBe(true);
expect(content?.contains(tabPane as Node)).toBe(true);
});
test('fullHeight prop maintains structure when content updates', () => {
const { container, rerender } = render(
<Tabs items={defaultItems} fullHeight />,
);
const initialTabsElement = container.querySelector('.ant-tabs');
const newItems = [
...defaultItems,
{
key: '4',
label: 'Tab 4',
children: <div data-testid="tab4-content">New tab content</div>,
},
];
rerender(<Tabs items={newItems} fullHeight />);
const updatedTabsElement = container.querySelector('.ant-tabs');
const updatedContentHolder = container.querySelector(
'.ant-tabs-content-holder',
);
expect(updatedTabsElement).toBeInTheDocument();
expect(updatedContentHolder).toBeInTheDocument();
expect(initialTabsElement).toBe(updatedTabsElement);
});
test('fullHeight prop works with allowOverflow to handle tall content', () => {
const { container } = render(
<Tabs items={defaultItems} fullHeight allowOverflow />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
const contentHolder = container.querySelector(
'.ant-tabs-content-holder',
) as HTMLElement;
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
// Verify overflow handling is not restricted
const holderStyles = window.getComputedStyle(contentHolder);
expect(holderStyles.overflow).not.toBe('hidden');
});
test('fullHeight prop handles empty items array', () => {
const { container } = render(<Tabs items={[]} fullHeight />);
expect(container.querySelector('.ant-tabs')).toBeInTheDocument();
});

View File

@@ -21,45 +21,27 @@ import { css, styled, useTheme } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-imports
import { Tabs as AntdTabs, TabsProps as AntdTabsProps } from 'antd';
import { Icons } from '@superset-ui/core/components/Icons';
import type { SerializedStyles } from '@emotion/react';
export interface TabsProps extends AntdTabsProps {
allowOverflow?: boolean;
fullHeight?: boolean;
contentStyle?: SerializedStyles;
}
const StyledTabs = ({
animated = false,
allowOverflow = true,
fullHeight = false,
tabBarStyle,
contentStyle,
...props
}: TabsProps) => {
const theme = useTheme();
const defaultTabBarStyle = { paddingLeft: theme.sizeUnit * 4 };
const mergedStyle = { ...defaultTabBarStyle, ...tabBarStyle };
return (
<AntdTabs
animated={animated}
{...props}
tabBarStyle={mergedStyle}
tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }}
css={theme => css`
overflow: ${allowOverflow ? 'visible' : 'hidden'};
${fullHeight && 'height: 100%;'}
.ant-tabs-content-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${fullHeight && 'height: 100%;'}
}
.ant-tabs-content {
${fullHeight && 'height: 100%;'}
}
.ant-tabs-tabpane {
${fullHeight && 'height: 100%;'}
${contentStyle}
}
.ant-tabs-tab {
flex: 1 1 auto;
@@ -99,10 +81,9 @@ const Tabs = Object.assign(StyledTabs, {
});
const StyledEditableTabs = styled(StyledTabs)`
${({ theme, contentStyle }) => `
${({ theme }) => `
.ant-tabs-content-holder {
background: ${theme.colorBgContainer};
${contentStyle}
background: ${theme.colors.grayscale.light5};
}
& > .ant-tabs-nav {
@@ -118,7 +99,7 @@ const StyledEditableTabs = styled(StyledTabs)`
`;
const StyledCloseOutlined = styled(Icons.CloseOutlined)`
color: ${({ theme }) => theme.colorIcon};
color: ${({ theme }) => theme.colors.grayscale.base};
`;
export const EditableTabs = Object.assign(StyledEditableTabs, {
TabPane: StyledTabPane,

View File

@@ -218,29 +218,3 @@ test('handles missing theme gracefully', () => {
// Should still render without crashing
expect(screen.getByTestId('ag-grid-react')).toBeInTheDocument();
});
test('merges theme overrides with default theme parameters', () => {
const themeOverrides = {
fontSize: 16,
headerBackgroundColor: '#custom-color',
};
render(
<ThemedAgGridReact
rowData={mockRowData}
columnDefs={mockColumnDefs}
themeOverrides={themeOverrides}
/>,
);
const agGrid = screen.getByTestId('ag-grid-react');
const theme = JSON.parse(agGrid.getAttribute('data-theme') || '{}');
// Custom overrides should be applied
expect(theme.fontSize).toBe(16);
expect(theme.headerBackgroundColor).toBe('#custom-color');
// Default theme parameters should still be present
expect(theme.foregroundColor).toBeDefined();
expect(theme.borderColor).toBeDefined();
});

View File

@@ -186,5 +186,5 @@ export {
// Re-export AgGridReact for ref types
export { AgGridReact } from 'ag-grid-react';
// Export the setup function and default modules for AG-Grid
export { setupAGGridModules, defaultModules } from './setupAGGridModules';
// Export the setup function for AG-Grid modules
export { setupAGGridModules } from './setupAGGridModules';

View File

@@ -1,116 +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 { ModuleRegistry, type Module } from 'ag-grid-community';
import { setupAGGridModules, defaultModules } from './setupAGGridModules';
jest.mock('ag-grid-community', () => ({
ModuleRegistry: {
registerModules: jest.fn(),
},
ColumnAutoSizeModule: { moduleName: 'ColumnAutoSizeModule' },
ColumnHoverModule: { moduleName: 'ColumnHoverModule' },
RowAutoHeightModule: { moduleName: 'RowAutoHeightModule' },
RowStyleModule: { moduleName: 'RowStyleModule' },
PaginationModule: { moduleName: 'PaginationModule' },
CellStyleModule: { moduleName: 'CellStyleModule' },
TextFilterModule: { moduleName: 'TextFilterModule' },
NumberFilterModule: { moduleName: 'NumberFilterModule' },
DateFilterModule: { moduleName: 'DateFilterModule' },
ExternalFilterModule: { moduleName: 'ExternalFilterModule' },
CsvExportModule: { moduleName: 'CsvExportModule' },
ColumnApiModule: { moduleName: 'ColumnApiModule' },
RowApiModule: { moduleName: 'RowApiModule' },
CellApiModule: { moduleName: 'CellApiModule' },
RenderApiModule: { moduleName: 'RenderApiModule' },
ClientSideRowModelModule: { moduleName: 'ClientSideRowModelModule' },
CustomFilterModule: { moduleName: 'CustomFilterModule' },
}));
beforeEach(() => {
jest.clearAllMocks();
});
test('defaultModules exports an array of AG Grid modules', () => {
expect(Array.isArray(defaultModules)).toBe(true);
expect(defaultModules.length).toBeGreaterThan(0);
// Verify it contains expected modules
const moduleNames = defaultModules.map((m: any) => m.moduleName);
expect(moduleNames).toContain('ColumnAutoSizeModule');
expect(moduleNames).toContain('PaginationModule');
expect(moduleNames).toContain('ClientSideRowModelModule');
});
test('setupAGGridModules registers default modules when called without arguments', () => {
setupAGGridModules();
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
expect(ModuleRegistry.registerModules).toHaveBeenCalledWith(defaultModules);
});
test('setupAGGridModules registers default + additional modules when provided', () => {
const mockEnterpriseModule1 = {
moduleName: 'MultiFilterModule',
} as unknown as Module;
const mockEnterpriseModule2 = {
moduleName: 'PivotModule',
} as unknown as Module;
const additionalModules = [mockEnterpriseModule1, mockEnterpriseModule2];
setupAGGridModules(additionalModules);
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
const registeredModules = (ModuleRegistry.registerModules as jest.Mock).mock
.calls[0][0];
// Should contain all default modules
defaultModules.forEach(module => {
expect(registeredModules).toContain(module);
});
// Should contain additional modules
expect(registeredModules).toContain(mockEnterpriseModule1);
expect(registeredModules).toContain(mockEnterpriseModule2);
// Total length should be default + additional
expect(registeredModules.length).toBe(
defaultModules.length + additionalModules.length,
);
});
test('setupAGGridModules handles empty additional modules array', () => {
setupAGGridModules([]);
expect(ModuleRegistry.registerModules).toHaveBeenCalledTimes(1);
expect(ModuleRegistry.registerModules).toHaveBeenCalledWith(defaultModules);
});
test('setupAGGridModules does not mutate defaultModules array', () => {
const originalLength = defaultModules.length;
const mockEnterpriseModule = {
moduleName: 'EnterpriseModule',
} as unknown as Module;
setupAGGridModules([mockEnterpriseModule]);
// defaultModules should remain unchanged
expect(defaultModules.length).toBe(originalLength);
expect(defaultModules).not.toContain(mockEnterpriseModule);
});

View File

@@ -19,7 +19,6 @@
import {
ModuleRegistry,
type Module,
ColumnAutoSizeModule,
ColumnHoverModule,
RowAutoHeightModule,
@@ -40,29 +39,27 @@ import {
} from 'ag-grid-community';
/**
* Default AG Grid modules that are registered by default.
* These modules provide core AG Grid functionality.
* Registers the AG-Grid modules required for Superset's table functionality.
* This should be called once during application initialization.
*/
export const defaultModules: Module[] = [
ColumnAutoSizeModule,
ColumnHoverModule,
RowAutoHeightModule,
RowStyleModule,
PaginationModule,
CellStyleModule,
TextFilterModule,
NumberFilterModule,
DateFilterModule,
ExternalFilterModule,
CsvExportModule,
ColumnApiModule,
RowApiModule,
CellApiModule,
RenderApiModule,
ClientSideRowModelModule,
CustomFilterModule,
];
export const setupAGGridModules = (additionalModules: Module[] = []) => {
ModuleRegistry.registerModules([...defaultModules, ...additionalModules]);
export const setupAGGridModules = () => {
ModuleRegistry.registerModules([
ColumnAutoSizeModule,
ColumnHoverModule,
RowAutoHeightModule,
RowStyleModule,
PaginationModule,
CellStyleModule,
TextFilterModule,
NumberFilterModule,
DateFilterModule,
ExternalFilterModule,
CsvExportModule,
ColumnApiModule,
RowApiModule,
CellApiModule,
RenderApiModule,
ClientSideRowModelModule,
CustomFilterModule,
]);
};

View File

@@ -79,7 +79,7 @@ const StyledVisibleItem = styled.span`
const StyledTooltipItem = styled.div`
.link {
color: ${({ theme }) => theme.colorTextTertiary};
color: ${({ theme }) => theme.colors.grayscale.light5};
display: block;
text-decoration: underline;
}

View File

@@ -66,12 +66,6 @@ export {
type CheckboxProps,
type CheckboxChangeEvent,
} from './Checkbox';
export {
ColorPicker,
type ColorPickerProps,
type RGBColor,
type ColorValue,
} from './ColorPicker';
export {
Collapse,
type CollapseProps,
@@ -179,6 +173,4 @@ export {
ThemedAgGridReact,
type ThemedAgGridReactProps,
setupAGGridModules,
defaultModules,
} from './ThemedAgGridReact';
export { ActionButton, type ActionProps } from './ActionButton';

View File

@@ -67,7 +67,6 @@ export function normalizeTimeColumn(
sqlExpression: formData.x_axis,
label: formData.x_axis,
expressionType: 'SQL',
isColumnReference: true,
};
}

View File

@@ -158,10 +158,31 @@ export function isTableAnnotationLayer(
return layer.sourceType === AnnotationSourceType.Table;
}
export type AnnotationResult = {
records?: DataRecord[];
export type RecordAnnotationResult = {
records: DataRecord[];
};
export type TimeseriesAnnotationResult = {
key: string;
values: { x: string | number; y?: number }[];
}[];
export type AnnotationResult =
| RecordAnnotationResult
| TimeseriesAnnotationResult;
export function isTimeseriesAnnotationResult(
result: AnnotationResult,
): result is TimeseriesAnnotationResult {
return Array.isArray(result);
}
export function isRecordAnnotationResult(
result: any,
): result is RecordAnnotationResult {
return Array.isArray(result?.records);
}
export type AnnotationData = { [key: string]: AnnotationResult };
export type Annotation = {

View File

@@ -27,7 +27,6 @@ export interface AdhocColumn {
optionName?: string;
sqlExpression: string;
expressionType: 'SQL';
isColumnReference?: boolean;
columnType?: 'BASE_AXIS' | 'SERIES';
timeGrain?: string;
datasourceWarning?: boolean;
@@ -75,10 +74,6 @@ export function isAdhocColumn(column?: any): column is AdhocColumn {
);
}
export function isAdhocColumnReference(column?: any): column is AdhocColumn {
return isAdhocColumn(column) && column?.isColumnReference === true;
}
export function isQueryFormColumn(column: any): column is QueryFormColumn {
return isPhysicalColumn(column) || isAdhocColumn(column);
}

View File

@@ -58,9 +58,8 @@ describe('Theme', () => {
// Verify default font family is set
expect(theme.theme.fontFamily).toContain('Inter');
// Verify the theme is initialized with semantic color tokens
expect(theme.theme.colorText).toBeDefined();
expect(theme.theme.colorBgBase).toBeDefined();
// Verify the theme is initialized with colors
expect(theme.theme.colors).toBeDefined();
});
it('creates a theme with custom tokens when provided', () => {

View File

@@ -43,6 +43,7 @@ import {
} from '@emotion/react';
import createCache from '@emotion/cache';
import { noop } from 'lodash';
import { isThemeDark } from './utils/themeUtils';
import { GlobalStyles } from './GlobalStyles';
import {
@@ -52,9 +53,15 @@ import {
SupersetTheme,
allowedAntdTokens,
SharedAntdTokens,
DeprecatedThemeColors,
} from './types';
import { normalizeThemeConfig, serializeThemeConfig } from './utils';
import {
normalizeThemeConfig,
serializeThemeConfig,
getSystemColors,
getDeprecatedColors,
} from './utils';
/* eslint-disable theme-colors/no-literal-colors */
@@ -159,8 +166,15 @@ export class Theme {
...Theme.defaultTokens,
...antdConfig.token, // Passing through the extra, superset-specific tokens
...tokens,
colors: {} as DeprecatedThemeColors, // Placeholder that will be filled in the second phase
};
// Second phase: Now that theme is initialized, we can determine if it's dark
// and generate the legacy colors correctly
const systemColors = getSystemColors(tokens);
const isDark = isThemeDark(this.theme); // Use utility function with theme
this.theme.colors = getDeprecatedColors(systemColors, isDark);
// Update the providers with the fully formed theme
this.updateProviders(
this.theme,

View File

@@ -108,6 +108,36 @@ export interface ColorVariants {
textActive: string;
}
export interface DeprecatedColorVariations {
base: string;
light1: string;
light2: string;
light3: string;
light4: string;
light5: string;
dark1: string;
dark2: string;
dark3: string;
dark4: string;
dark5: string;
}
export interface DeprecatedThemeColors {
primary: DeprecatedColorVariations;
error: DeprecatedColorVariations;
warning: DeprecatedColorVariations;
success: DeprecatedColorVariations;
info: DeprecatedColorVariations;
grayscale: DeprecatedColorVariations;
}
export interface LegacySupersetTheme {
// Old colors structure with light/dark semantics still heavily referenced in code base
// TODO: replace/realign with antd-type tokens
colors: DeprecatedThemeColors;
transitionTiming: number;
}
export interface SupersetSpecificTokens {
// Font-related
fontSizeXS: string;
@@ -254,7 +284,6 @@ export const allowedAntdTokens = [
'controlTmpOutline',
'fontFamily',
'fontFamilyCode',
'fontWeightStrong',
'fontHeight',
'fontHeightLG',
'fontHeightSM',
@@ -362,8 +391,10 @@ export type AllowedAntdTokenKeys = Extract<
export type SharedAntdTokens = Pick<AntdTokens, AllowedAntdTokenKeys>;
/** The final shape for our custom theme object, combining shared antd + superset specifics. */
export type SupersetTheme = SharedAntdTokens & SupersetSpecificTokens;
/** The final shape for our custom theme object, combining old theme + shared antd + superset specifics. */
export type SupersetTheme = LegacySupersetTheme &
SharedAntdTokens &
SupersetSpecificTokens;
export interface ThemeStorage {
getItem(key: string): string | null;

View File

@@ -24,6 +24,8 @@ import {
normalizeThemeConfig,
getAntdConfig,
getSystemColors,
getDeprecatedColors,
genDeprecatedColorVariations,
} from './utils';
import {
type AnyThemeConfig,
@@ -32,6 +34,17 @@ import {
ThemeAlgorithm,
} from './types';
// Mock tinycolor2 for consistent testing
jest.mock('tinycolor2', () => {
const mockMix = jest.fn().mockImplementation(() => ({
toHexString: jest.fn().mockReturnValue('#mixed-color'),
}));
return {
mix: mockMix,
};
});
describe('Theme utilities', () => {
describe('isSerializableConfig', () => {
it('returns true when algorithm is undefined', () => {
@@ -349,4 +362,43 @@ describe('Theme utilities', () => {
});
});
});
describe('genDeprecatedColorVariations', () => {
it('generates color variations for light mode', () => {
const result = genDeprecatedColorVariations('#base-color', false);
expect(result.base).toBe('#base-color');
expect(result.light1).toBe('#mixed-color');
expect(result.dark1).toBe('#mixed-color');
});
it('generates color variations for dark mode', () => {
const result = genDeprecatedColorVariations('#base-color', true);
expect(result.base).toBe('#base-color');
expect(result.light1).toBe('#mixed-color');
expect(result.dark1).toBe('#mixed-color');
});
});
describe('getDeprecatedColors', () => {
it('generates deprecated colors from system colors', () => {
const systemColors = {
colorPrimary: '#primary',
colorError: '#error',
colorWarning: '#warning',
colorSuccess: '#success',
colorInfo: '#info',
};
const result = getDeprecatedColors(systemColors, false);
expect(result.primary.base).toBe('#primary');
expect(result.error.base).toBe('#error');
expect(result.warning.base).toBe('#warning');
expect(result.success.base).toBe('#success');
expect(result.info.base).toBe('#info');
expect(result.grayscale.base).toBe('#666');
});
});
});

View File

@@ -17,10 +17,13 @@
* under the License.
*/
import { theme as antdThemeImport } from 'antd';
import tinycolor from 'tinycolor2';
import {
type AntdThemeConfig,
type AnyThemeConfig,
type SerializableThemeConfig,
type DeprecatedColorVariations,
type DeprecatedThemeColors,
type SystemColors,
type SupersetTheme,
ThemeAlgorithm,
@@ -141,6 +144,50 @@ export function getAntdConfig(
};
}
/**
* Generate deprecated color variations from a base color
*/
export function genDeprecatedColorVariations(
color: string,
isDark: boolean,
): DeprecatedColorVariations {
const bg = isDark ? '#FFF' : '#000';
const fg = isDark ? '#000' : '#FFF';
const adjustColor = (c: string, perc: number, tgt: string): string =>
tinycolor.mix(c, tgt, perc).toHexString();
return {
base: color,
light1: adjustColor(color, 20, fg),
light2: adjustColor(color, 45, fg),
light3: adjustColor(color, 70, fg),
light4: adjustColor(color, 90, fg),
light5: adjustColor(color, 95, fg),
dark1: adjustColor(color, 10, bg),
dark2: adjustColor(color, 20, bg),
dark3: adjustColor(color, 40, bg),
dark4: adjustColor(color, 60, bg),
dark5: adjustColor(color, 80, bg),
};
}
/**
* Generate deprecated theme colors from system colors
*/
export function getDeprecatedColors(
systemColors: SystemColors,
isDark: boolean,
): DeprecatedThemeColors {
const sc: SystemColors = systemColors;
return {
primary: genDeprecatedColorVariations(sc.colorPrimary, isDark),
error: genDeprecatedColorVariations(sc.colorError, isDark),
warning: genDeprecatedColorVariations(sc.colorWarning, isDark),
success: genDeprecatedColorVariations(sc.colorSuccess, isDark),
info: genDeprecatedColorVariations(sc.colorInfo, isDark),
grayscale: genDeprecatedColorVariations('#666', isDark),
};
}
/**
* Extract system colors from Ant Design tokens
*/

View File

@@ -18,11 +18,9 @@
*/
import rison from 'rison';
import { isEmpty } from 'lodash';
import {
SupersetClient,
getClientErrorObject,
ensureIsArray,
} from '@superset-ui/core';
import { SupersetClient } from '../connection';
import { getClientErrorObject } from '../query';
import { ensureIsArray } from '../utils';
export const SEPARATOR = ' : ';

View File

@@ -34,7 +34,6 @@ export enum FeatureFlag {
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardRbac = 'DASHBOARD_RBAC',
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
/** @deprecated */
DrillToDetail = 'DRILL_TO_DETAIL',
DrillBy = 'DRILL_BY',

View File

@@ -24,7 +24,6 @@ import {
removeHTMLTags,
isJsonString,
getParagraphContents,
extractTextFromHTML,
} from './html';
describe('sanitizeHtml', () => {
@@ -205,101 +204,3 @@ describe('getParagraphContents', () => {
});
});
});
describe('extractTextFromHTML', () => {
it('should extract text from HTML div tags', () => {
const htmlString = '<div>Hello World</div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Hello World');
});
it('should extract text from nested HTML tags', () => {
const htmlString = '<div><p>Hello <strong>World</strong></p></div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Hello World');
});
it('should extract text from multiple HTML elements', () => {
const htmlString = '<h1>Title</h1><p>Content</p><span>Footer</span>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('TitleContentFooter');
});
it('should return original string when input is not HTML', () => {
const plainText = 'Just plain text';
const result = extractTextFromHTML(plainText);
expect(result).toBe('Just plain text');
});
it('should return original value when input is not a string', () => {
const numberValue = 12345;
const result = extractTextFromHTML(numberValue);
expect(result).toBe(12345);
const nullValue = null;
const nullResult = extractTextFromHTML(nullValue);
expect(nullResult).toBe(null);
const booleanValue = true;
const booleanResult = extractTextFromHTML(booleanValue);
expect(booleanResult).toBe(true);
});
it('should handle empty HTML tags', () => {
const htmlString = '<div></div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('');
});
it('should handle HTML with only whitespace', () => {
const htmlString = '<div> </div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe(' ');
});
it('should extract text from HTML with attributes', () => {
const htmlString = '<div class="container" id="main">Hello World</div>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Hello World');
});
it('should handle self-closing tags', () => {
const htmlString = '<img src="image.jpg" alt="Image"><br><p>Text after</p>';
const result = extractTextFromHTML(htmlString);
expect(result).toBe('Text after');
});
it('should handle complex HTML structure', () => {
const htmlString = `
<html>
<head><title>Page Title</title></head>
<body>
<header><h1>Main Title</h1></header>
<main>
<p>First paragraph with <em>emphasis</em>.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</main>
</body>
</html>
`;
const result = extractTextFromHTML(htmlString);
expect(result).toContain('Page Title');
expect(result).toContain('Main Title');
expect(result).toContain('First paragraph with emphasis.');
expect(result).toContain('Item 1');
expect(result).toContain('Item 2');
});
it('should not extract text from strings that look like HTML but are not', () => {
const fakeHtmlString = '<abcdef:12345>';
const result = extractTextFromHTML(fakeHtmlString);
expect(result).toBe('<abcdef:12345>');
const mathExpression = 'x < 5 and y > 10';
const mathResult = extractTextFromHTML(mathExpression);
expect(mathResult).toBe('x < 5 and y > 10');
});
});

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { FilterXSS, getDefaultWhiteList } from 'xss';
import { DataRecordValue } from '../types';
const xssFilter = new FilterXSS({
whiteList: {
@@ -170,10 +169,7 @@ export function safeHtmlSpan(possiblyHtmlString: string) {
}
export function removeHTMLTags(str: string): string {
const doc = new DOMParser().parseFromString(str, 'text/html');
const bodyText = doc.body?.textContent || '';
const headText = doc.head?.textContent || '';
return headText + bodyText;
return str.replace(/<[^>]*>/g, '');
}
export function isJsonString(str: string): boolean {
@@ -208,10 +204,3 @@ export function getParagraphContents(
return paragraphContents;
}
export function extractTextFromHTML(value: DataRecordValue): DataRecordValue {
if (typeof value === 'string' && isProbablyHTML(value)) {
return removeHTMLTags(value);
}
return value;
}

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