Compare commits

...

11 Commits

Author SHA1 Message Date
Elizabeth Thompson
cfae8ba2fc fix(tests): update SuperChartCore test to handle Ant Design App wrapper
The test was failing because the SupersetThemeProvider wraps all content
with an Ant Design App component, which creates a div with class 'ant-app'.
The test expected the container to be completely empty when chartProps=null,
but this was never possible due to the theme provider wrapper.

Updated the test to:
- Check that no chart component (.test-component) is rendered
- Verify that only the Ant Design App wrapper (.ant-app) is present

This fix is more accurate as it tests the actual intended behavior:
when chartProps is null, no chart content should render, but the
theme provider infrastructure should still be in place.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 18:34:49 -07:00
Elizabeth Thompson
76c5b40c13 fix(embedded): fix template context for embedded views
- Use render_app_template() in EmbeddedView to provide proper template context
- Move tokens variable definition before its usage in spa.html template
- Fixes 'tokens is undefined' error in embedded dashboard views

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 18:03:23 -07:00
Elizabeth Thompson
9617b8b392 fix(modals): make Modal.confirm dialogs respect theme mode
- Add App wrapper to SupersetThemeProvider to enable theme inheritance for imperative modals
- Modal.confirm dialogs now properly inherit dark/light theme from ConfigProvider
- Simplifies theming approach by leveraging antd's built-in App context

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 17:56:32 -07:00
Elizabeth Thompson
a7b180d8dd fix(filters): remove filter bar gap, fix loading screen theming, fix filter sorting, and fix modal theming
- Remove top padding from horizontal filter bar to eliminate visual gap
- Set themed background color on loading screen to respect dark mode
- Remove fallback "Loading..." text to prevent Times New Roman display
- Use colorBgBase theme token for proper light/dark mode support
- Fix filter value sorting to preserve backend metric-based order when sortMetric is specified
- Make Modal.confirm theme-aware to respect current light/dark mode

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 17:18:53 -07:00
Gabriel Torres Ruiz
36daa2dc3f fix(ListView): implement AntD pagination for ListView component (#35057) 2025-09-12 14:15:05 -07:00
Gabriel Torres Ruiz
7fd5a7668b fix(charts): fix legend theming and hollow symbols in dark mode (#35123) 2025-09-12 23:33:29 +03:00
Mehmet Salih Yavuz
95333e34b1 fix(theming): Lighter text colors on dark mode (#35114) 2025-09-12 15:52:54 +03:00
Daniel Vaz Gaspar
a9fb853e3e fix: Bump FAB to 5.X (#33055)
Co-authored-by: Joe Li <joe@preset.io>
2025-09-12 09:21:37 +01:00
Rafael Benitez
dea9068647 fix(DashboardEditor): CSS template selector UI in dashboard properties modal restored (#35106) 2025-09-11 15:34:16 -07:00
Elizabeth Thompson
3416bd1479 chore: add codeowners (#35107) 2025-09-11 14:07:21 -07:00
Michael S. Molina
e729b2dbb4 fix: SQL Lab tab events (#35105) 2025-09-11 17:53:51 -03:00
76 changed files with 1358 additions and 2000 deletions

14
.github/CODEOWNERS vendored
View File

@@ -33,10 +33,10 @@
# Notify PMC members of changes to extension-related files
/superset-core/ @michael-s-molina @villebro
/superset-extensions-cli/ @michael-s-molina @villebro
/superset/core/ @michael-s-molina @villebro
/superset/extensions/ @michael-s-molina @villebro
/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro
/superset-frontend/src/core/ @michael-s-molina @villebro
/superset-frontend/src/extensions/ @michael-s-molina @villebro
/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-extensions-cli/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje

View File

@@ -23,7 +23,8 @@ This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
## Next
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
- [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).
- [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`:

View File

@@ -363,110 +363,6 @@ 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

@@ -363,110 +363,6 @@ 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

@@ -48,7 +48,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>=4.8.1, <5.0.0",
"flask-appbuilder>=5.0.0,<6",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",

View File

@@ -114,11 +114,9 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==4.8.1
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==2.0.0
flask-appbuilder==5.0.0
# via apache-superset (pyproject.toml)
flask-babel==3.1.0
# via flask-appbuilder
flask-caching==2.3.1
# via apache-superset (pyproject.toml)

View File

@@ -208,12 +208,11 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==4.8.1
flask-appbuilder==5.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==2.0.0
flask-babel==3.1.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder

View File

@@ -42,7 +42,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"flask-appbuilder>=4.5.3, <5.0.0",
"flask-appbuilder>=5.0.0,<6",
]
[project.urls]

View File

@@ -1,766 +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.
*/
// 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

@@ -191,7 +191,6 @@
"@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.8",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
@@ -16078,16 +16077,6 @@
"@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.8",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.8.tgz",
@@ -60642,7 +60631,7 @@
},
"packages/superset-core": {
"name": "@apache-superset/core",
"version": "0.0.1-rc2",
"version": "0.0.1-rc3",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
@@ -60652,7 +60641,8 @@
"@babel/preset-typescript": "^7.26.0",
"@types/react": "^17.0.83",
"install": "^0.13.0",
"npm": "^11.1.0"
"npm": "^11.1.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"antd": "^5.24.6",

View File

@@ -259,7 +259,6 @@
"@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.8",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",

View File

@@ -60,7 +60,7 @@ const EmptyStateContainer = styled.div`
flex-direction: column;
width: 100%;
height: 100%;
color: ${theme.colorTextQuaternary};
color: ${theme.colorTextTertiary};
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.colorTextQuaternary};
color: ${theme.colorTextTertiary};
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.colorTextQuaternary};
color: ${theme.colorTextTertiary};
margin-top: ${theme.sizeUnit * 2}px;
`}
`;

View File

@@ -368,9 +368,13 @@ const CustomModal = ({
};
CustomModal.displayName = 'Modal';
// Theme-aware confirmation modal - now inherits theme through App wrapper in SupersetThemeProvider
const themedConfirm = (props: Parameters<typeof AntdModal.confirm>[0]) =>
AntdModal.confirm(props);
export const Modal = Object.assign(CustomModal, {
error: AntdModal.error,
warning: AntdModal.warning,
confirm: AntdModal.confirm,
confirm: themedConfirm,
useModal: AntdModal.useModal,
});

View File

@@ -1,37 +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 { 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

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

@@ -1,47 +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 { 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

@@ -1,45 +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 { ReactNode } from 'react';
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
interface PaginationItemButton extends PaginationButtonProps {
active?: boolean;
children: ReactNode;
}
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

@@ -1,37 +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 { Next } from './Next';
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);
});
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

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

View File

@@ -1,37 +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 { 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

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

@@ -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 { 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

@@ -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 { 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

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

@@ -1,25 +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 { EventHandler, SyntheticEvent } from 'react';
export interface PaginationButtonProps {
disabled?: boolean;
onClick: EventHandler<SyntheticEvent<HTMLElement>>;
}

View File

@@ -100,3 +100,109 @@ 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);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { HTMLAttributes, memo, useMemo } from 'react';
import { HTMLAttributes, memo, useMemo, useCallback } from 'react';
import {
ColumnInstance,
HeaderGroup,
@@ -47,15 +47,25 @@ 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)`
${({ theme }) => `
const StyledTable = styled(Table)<{
isPaginationSticky?: boolean;
showRowCount?: boolean;
}>`
${({ theme, isPaginationSticky, showRowCount }) => `
th.ant-column-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
opacity: 0;
font-size: ${theme.fontSizeXL}px;
@@ -72,15 +82,18 @@ 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-cell {
max-width: 320px;
font-feature-settings: 'tnum' 1;
@@ -91,10 +104,37 @@ const StyledTable = styled(Table)`
padding-left: ${theme.sizeUnit * 4}px;
white-space: nowrap;
}
.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};
@@ -116,13 +156,22 @@ 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 = mapColumns<T>(
columns,
headerGroups,
columnsForWrapText,
const mappedColumns = useMemo(
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
[columns, headerGroups, columnsForWrapText],
);
const mappedRows = useMemo(
() => mapRows(rows, prepareRow),
[rows, prepareRow],
);
const mappedRows = mapRows(rows, prepareRow);
const selectedRowKeys = useMemo(
() => selectedFlatRows?.map(row => row.id) || [],
@@ -147,6 +196,68 @@ 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) {
setSortBy?.([
{
id: sorter.field,
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,
]);
return (
<StyledTable
loading={loading}
@@ -155,12 +266,15 @@ function TableCollection<T extends object>({
data={mappedRows}
size={size}
data-test="listview-table"
pagination={false}
pagination={paginationConfig}
scroll={{ x: 'max-content' }}
tableLayout="auto"
rowKey="rowId"
rowSelection={rowSelection}
locale={{ emptyText: null }}
sortDirections={['ascend', 'descend', 'ascend']}
isPaginationSticky={isPaginationSticky}
showRowCount={showRowCount}
components={{
header: {
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
@@ -176,14 +290,7 @@ function TableCollection<T extends object>({
),
},
}}
onChange={(_pagination, _filters, sorter: SorterResult) => {
setSortBy?.([
{
id: sorter.field,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}}
onChange={handleTableChange}
/>
);
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
import { TableView, TableViewProps } from '.';
const mockedProps: TableViewProps = {
@@ -30,6 +30,7 @@ const mockedProps: TableViewProps = {
{
accessor: 'age',
Header: 'Age',
sortable: true,
id: 'age',
},
{
@@ -78,10 +79,10 @@ test('should render the cells', () => {
test('should render the pagination', () => {
render(<TableView {...mockedProps} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(4);
expect(screen.getByText('«')).toBeInTheDocument();
expect(screen.getByText('»')).toBeInTheDocument();
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getByTitle('Previous Page')).toBeInTheDocument();
expect(screen.getByTitle('Next Page')).toBeInTheDocument();
});
test('should show the row count by default', () => {
@@ -104,45 +105,63 @@ test('should NOT render the pagination when disabled', () => {
withPagination: false,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should NOT render the pagination when fewer rows than page size', () => {
test('should render the pagination even when fewer rows than page size', () => {
const withoutPaginationProps = {
...mockedProps,
pageSize: 3,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('should change page when « and » buttons are clicked', async () => {
test('should change page when pagination is 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]);
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
});
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
});
});
test('should sort by initialSortBy DESC', () => {
@@ -208,3 +227,146 @@ 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,16 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useEffect, useRef } from 'react';
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import { isEqual } from 'lodash';
import { styled, t } from '@superset-ui/core';
import { styled } 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 {
@@ -96,29 +97,6 @@ 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,
@@ -133,16 +111,21 @@ const RawTableView = ({
showRowCount = true,
serverPagination = false,
columnsForWrapText,
onServerPagination = () => {},
scrollTopOnPagination = false,
onServerPagination = NOOP_SERVER_PAGINATION,
scrollTopOnPagination = true,
size = TableSize.Middle,
...props
}: TableViewProps) => {
const initialState = {
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
};
const tableRef = useRef<HTMLTableElement>(null);
const initialState = useMemo(
() => ({
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
}),
[initialPageSize, initialPageIndex, initialSortBy],
);
const {
getTableProps,
@@ -151,10 +134,9 @@ const RawTableView = ({
page,
rows,
prepareRow,
pageCount,
gotoPage,
setSortBy,
state: { pageIndex, pageSize, sortBy },
state: { pageIndex, sortBy },
} = useTable(
{
columns,
@@ -162,36 +144,94 @@ const RawTableView = ({
initialState,
manualPagination: serverPagination,
manualSortBy: serverPagination,
pageCount: Math.ceil(totalCount / initialState.pageSize),
pageCount: serverPagination
? Math.ceil(totalCount / initialState.pageSize)
: undefined,
autoResetSortBy: false,
},
useFilters,
useSortBy,
usePagination,
...(withPagination ? [usePagination] : []),
);
const content = 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 = !loading && content.length === 0;
const hasPagination = pageCount > 1 && withPagination;
const tableRef = useRef<HTMLTableElement>(null);
const handleGotoPage = (p: number) => {
if (scrollTopOnPagination) {
tableRef?.current?.scroll(0, 0);
const EmptyWrapperComponent = useMemo(() => {
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
return ({ children }: any) => <>{children}</>;
case EmptyWrapperType.Default:
default:
return ({ children }: any) => <EmptyWrapper>{children}</EmptyWrapper>;
}
gotoPage(p);
};
}, [emptyWrapperType]);
const content = useMemo(
() => (withPagination ? page : rows),
[withPagination, page, rows],
);
const isEmpty = useMemo(
() => !loading && content.length === 0,
[loading, content.length],
);
const handleScrollToTop = useCallback(() => {
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' });
}
}, [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,
]);
useEffect(() => {
if (serverPagination && pageIndex !== initialState.pageIndex) {
@@ -199,7 +239,7 @@ const RawTableView = ({
pageIndex,
});
}
}, [pageIndex]);
}, [initialState.pageIndex, onServerPagination, pageIndex, serverPagination]);
useEffect(() => {
if (serverPagination && !isEqual(sortBy, initialState.sortBy)) {
@@ -208,61 +248,38 @@ const RawTableView = ({
sortBy,
});
}
}, [sortBy]);
}, [initialState.sortBy, onServerPagination, serverPagination, 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}
/>
{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>
<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} />
)}
</PaginationStyles>
</EmptyWrapperComponent>
)}
</>
</TableViewStyles>
);
};

View File

@@ -19,7 +19,7 @@
/* eslint-disable react-prefer-function-component/react-prefer-function-component */
// eslint-disable-next-line no-restricted-syntax
import React from 'react';
import { theme as antdThemeImport, ConfigProvider } from 'antd';
import { theme as antdThemeImport, ConfigProvider, App } from 'antd';
// @fontsource/* v5.1+ doesn't play nice with eslint-import plugin v2.31+
/* eslint-disable import/extensions */
@@ -243,7 +243,7 @@ export class Theme {
<ThemeProvider theme={themeState.theme}>
<GlobalStyles />
<ConfigProvider theme={themeState.antdConfig}>
{children}
<App>{children}</App>
</ConfigProvider>
</ThemeProvider>
</EmotionCacheProvider>

View File

@@ -203,7 +203,13 @@ describe('SuperChartCore', () => {
);
await waitFor(() => {
expect(container).toBeEmptyDOMElement();
// The container will have the Ant Design App wrapper, but no chart content
const testComponent = container.querySelector('.test-component');
expect(testComponent).not.toBeInTheDocument();
// Ensure only the Ant Design App wrapper is present
const antApp = container.querySelector('.ant-app');
expect(antApp).toBeInTheDocument();
});
});
});

View File

@@ -25,6 +25,7 @@ import {
FilterState,
FormulaAnnotationLayer,
IntervalAnnotationLayer,
isThemeDark,
LegendState,
SupersetTheme,
TimeseriesAnnotationLayer,
@@ -137,6 +138,33 @@ export const getBaselineSeriesForStream = (
};
};
export function transformNegativeLabelsPosition(
series: SeriesOption,
isHorizontal: boolean,
): TimeseriesDataRecord[] {
/*
* Adjusts label position for negative values in bar series
* @param series - Array of series options
* @param isHorizontal - Whether chart is horizontal
* @returns data with adjusted label positions for negative values
*/
const transformValue = (value: any) => {
const [xValue, yValue] = Array.isArray(value) ? value : [null, null];
const axisValue = isHorizontal ? xValue : yValue;
return axisValue < 0
? {
value,
label: {
position: 'outside',
},
}
: value;
};
return (series.data as TimeseriesDataRecord[]).map(transformValue);
}
export function transformSeries(
series: SeriesOption,
colorScale: CategoricalColorScale,
@@ -246,6 +274,9 @@ export function transformSeries(
} else {
plotType = seriesType === 'bar' ? 'bar' : 'line';
}
const isDarkMode = theme ? isThemeDark(theme) : false;
/**
* if timeShiftColor is enabled the colorScaleKey forces the color to be the
* same as the original series, otherwise uses separate colors
@@ -289,6 +320,12 @@ export function transformSeries(
isConfidenceBand || (stack === StackControlsValue.Stream && area)
? { ...opts.lineStyle, opacity: OpacityEnum.Transparent }
: { ...opts.lineStyle, opacity };
// Use filled circles in dark mode to avoid the white fill issue with hollow circles
// Use emptyCircle explicitly in light mode
const symbol =
plotType === 'line' ? (isDarkMode ? 'circle' : 'emptyCircle') : undefined;
return {
...series,
...(Array.isArray(data) && seriesType === 'bar' && !stack
@@ -321,6 +358,7 @@ export function transformSeries(
: undefined,
emphasis,
showSymbol,
symbol,
symbolSize: markerSize,
label: {
show: !!showValue,
@@ -637,30 +675,3 @@ export function getPadding(
isHorizontal,
);
}
export function transformNegativeLabelsPosition(
series: SeriesOption,
isHorizontal: boolean,
): TimeseriesDataRecord[] {
/*
* Adjusts label position for negative values in bar series
* @param series - Array of series options
* @param isHorizontal - Whether chart is horizontal
* @returns data with adjusted label positions for negative values
*/
const transformValue = (value: any) => {
const [xValue, yValue] = Array.isArray(value) ? value : [null, null];
const axisValue = isHorizontal ? xValue : yValue;
return axisValue < 0
? {
value,
label: {
position: 'outside',
},
}
: value;
};
return (series.data as TimeseriesDataRecord[]).map(transformValue);
}

View File

@@ -204,6 +204,12 @@ function Echart(
},
legend: {
textStyle: { color: antdTheme.colorTextSecondary },
pageTextStyle: {
color: antdTheme.colorTextSecondary,
},
pageIconColor: antdTheme.colorTextSecondary,
pageIconInactiveColor: antdTheme.colorTextDisabled,
inactiveColor: antdTheme.colorTextDisabled,
},
tooltip: {
backgroundColor: antdTheme.colorBgContainer,

View File

@@ -395,6 +395,7 @@ export function runQueryFromSqlEditor(
dbId: qe.dbId,
sql: qe.selectedText || qe.sql,
sqlEditorId: qe.tabViewId ?? qe.id,
immutableId: qe.immutableId,
tab: qe.name,
catalog: qe.catalog,
schema: qe.schema,
@@ -537,6 +538,7 @@ export function addQueryEditor(queryEditor) {
const newQueryEditor = {
...queryEditor,
id: nanoid(11),
immutableId: nanoid(11),
loaded: true,
inLocalStorage: true,
};

View File

@@ -441,6 +441,7 @@ describe('async actions', () => {
queryLimit: undefined,
maxRow: undefined,
id: 'abcd',
immutableId: 'abcd',
templateParams: undefined,
inLocalStorage: true,
loaded: true,
@@ -570,6 +571,7 @@ describe('async actions', () => {
type: actions.ADD_QUERY_EDITOR,
queryEditor: {
...queryEditor,
immutableId: 'abcd',
inLocalStorage: true,
loaded: true,
},
@@ -597,6 +599,7 @@ describe('async actions', () => {
type: actions.ADD_QUERY_EDITOR,
queryEditor: {
id: 'abcd',
immutableId: 'abcd',
sql: expect.stringContaining('SELECT ...'),
name: `Untitled Query 7`,
dbId: defaultQueryEditor.dbId,
@@ -753,6 +756,7 @@ describe('async actions', () => {
queryEditor: {
...queryEditor,
id: 'abcd',
immutableId: 'abcd',
loaded: true,
inLocalStorage: true,
},

View File

@@ -188,6 +188,7 @@ export const table = {
export const defaultQueryEditor = {
version: LatestQueryEditorVersion,
id: 'dfsadfs',
immutableId: 'immutable-id',
autorun: false,
dbId: 1,
latestQueryId: null,
@@ -204,6 +205,7 @@ export const defaultQueryEditor = {
export const extraQueryEditor1 = {
...defaultQueryEditor,
id: 'diekd23',
immutableId: 'immutable-id',
sql: 'SELECT *\nFROM\nWHERE\nLIMIT',
name: 'Untitled Query 2',
selectedText: 'SELECT',
@@ -212,6 +214,7 @@ export const extraQueryEditor1 = {
export const extraQueryEditor2 = {
...defaultQueryEditor,
id: 'owkdi998',
immutableId: 'immutable-id',
sql: '',
name: 'Untitled Query 3',
};
@@ -219,6 +222,7 @@ export const extraQueryEditor2 = {
export const extraQueryEditor3 = {
...defaultQueryEditor,
id: 'kvk23',
immutableId: 'immutable-id',
sql: '',
name: 'Untitled Query 4',
tabViewId: 37,

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { t } from '@superset-ui/core';
import { nanoid } from 'nanoid';
import type { BootstrapData } from 'src/types/bootstrapTypes';
import type { InitialState } from 'src/hooks/apiResources/sqlLab';
import {
@@ -55,6 +56,7 @@ export default function getInitialState({
let queryEditors: Record<string, QueryEditor> = {};
const defaultQueryEditor = {
version: LatestQueryEditorVersion,
immutableId: nanoid(11),
loaded: true,
name: t('Untitled query'),
sql: '',
@@ -78,6 +80,7 @@ export default function getInitialState({
queryEditor = {
version: activeTab.extra_json?.version ?? QueryEditorVersion.V1,
id: id.toString(),
immutableId: activeTab.extra_json?.immutableId ?? nanoid(11),
loaded: true,
name: activeTab.label,
sql: activeTab.sql || '',
@@ -100,6 +103,7 @@ export default function getInitialState({
queryEditor = {
...defaultQueryEditor,
id: id.toString(),
immutableId: nanoid(11),
loaded: false,
name: label,
dbId: undefined,

View File

@@ -49,6 +49,7 @@ export interface CursorPosition {
export interface QueryEditor {
version: QueryEditorVersion;
id: string;
immutableId: string;
dbId?: number;
name: string;
title?: string; // keep it optional for backward compatibility

View File

@@ -45,6 +45,7 @@ const PERSISTENT_QUERY_EDITOR_KEYS = new Set([
'dbId',
'height',
'id',
'immutableId',
'latestQueryId',
'northPercent',
'queryLimit',

View File

@@ -356,3 +356,215 @@ describe('Embedded mode behavior', () => {
);
});
});
describe('Table view with pagination', () => {
beforeEach(() => {
// Mock a large dataset response for pagination testing
const mockLargeDataset = {
result: [
{
data: Array.from({ length: 100 }, (_, i) => ({
state: `State${i}`,
sum__num: 1000 + i,
})),
colnames: ['state', 'sum__num'],
coltypes: [1, 0],
},
],
};
fetchMock.post(CHART_DATA_ENDPOINT, mockLargeDataset, {
overwriteRoutes: true,
});
});
afterEach(() => {
fetchMock.restore();
});
test('should render table view when Table radio is selected', async () => {
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
// Switch to table view
const tableRadio = await screen.findByRole('radio', { name: /table/i });
userEvent.click(tableRadio);
// Wait for table to render
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Check that pagination is rendered (there's also a breadcrumb list)
const lists = screen.getAllByRole('list');
const paginationList = lists.find(list =>
list.className?.includes('pagination'),
);
expect(paginationList).toBeInTheDocument();
});
test('should handle pagination in table view', async () => {
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
// Switch to table view
const tableRadio = await screen.findByRole('radio', { name: /table/i });
userEvent.click(tableRadio);
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Check that first page data is shown
expect(screen.getByText('State0')).toBeInTheDocument();
// Check pagination controls exist
const nextPageButton = screen.getByTitle('Next Page');
expect(nextPageButton).toBeInTheDocument();
// Click next page
userEvent.click(nextPageButton);
// Verify page changed (State0 should not be visible on page 2)
await waitFor(() => {
expect(screen.queryByText('State0')).not.toBeInTheDocument();
});
});
test('should maintain table state when switching between Chart and Table views', async () => {
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
const chartRadio = screen.getByRole('radio', { name: /chart/i });
const tableRadio = screen.getByRole('radio', { name: /table/i });
// Switch to table view
userEvent.click(tableRadio);
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Switch back to chart view
userEvent.click(chartRadio);
await waitFor(() => {
expect(screen.getByTestId('drill-by-chart')).toBeInTheDocument();
});
// Switch back to table view - should maintain state
userEvent.click(tableRadio);
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
});
test('should not cause infinite re-renders with pagination', async () => {
// Mock console.error to catch potential infinite loop warnings
const originalError = console.error;
const consoleErrorSpy = jest.fn();
console.error = consoleErrorSpy;
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
// Switch to table view
const tableRadio = await screen.findByRole('radio', { name: /table/i });
userEvent.click(tableRadio);
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Check that no infinite loop errors were logged
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Maximum update depth exceeded'),
);
console.error = originalError;
});
test('should handle empty results in table view', async () => {
// Mock empty dataset response
fetchMock.post(
CHART_DATA_ENDPOINT,
{
result: [
{
data: [],
colnames: ['state', 'sum__num'],
coltypes: [1, 0],
},
],
},
{ overwriteRoutes: true },
);
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
// Switch to table view
const tableRadio = await screen.findByRole('radio', { name: /table/i });
userEvent.click(tableRadio);
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Should show empty state
expect(screen.getByText('No data')).toBeInTheDocument();
});
test('should handle sorting in table view', async () => {
await renderModal({
column: { column_name: 'state', verbose_name: null },
drillByConfig: {
filters: [{ col: 'gender', op: '==', val: 'boy' }],
groupbyFieldName: 'groupby',
},
});
// Switch to table view
const tableRadio = await screen.findByRole('radio', { name: /table/i });
userEvent.click(tableRadio);
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
// Find sortable column header
const sortableHeaders = screen.getAllByTestId('sort-header');
expect(sortableHeaders.length).toBeGreaterThan(0);
// Click to sort
userEvent.click(sortableHeaders[0]);
// Table should still be rendered without crashes
await waitFor(() => {
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
});
});

View File

@@ -123,7 +123,6 @@ export const FilterableTable = ({
<div className="filterable-table-container" data-test="table-container">
<GridTable
size={GridSize.Small}
usePagination={false}
height={height}
columns={columns}
data={data}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, within } from 'spec/helpers/testing-library';
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { QueryParamProvider } from 'use-query-params';
import thunk from 'redux-thunk';
@@ -172,23 +172,28 @@ describe('ListView', () => {
});
});
// Update pagination control tests to use button role
// Update pagination control tests for Ant Design pagination
it('renders pagination controls', () => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '«' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '»' })).toBeInTheDocument();
const paginationList = screen.getByRole('list');
expect(paginationList).toBeInTheDocument();
const pageOneItem = screen.getByRole('listitem', { name: '1' });
expect(pageOneItem).toBeInTheDocument();
});
it('calls fetchData on page change', async () => {
const nextButton = screen.getByRole('button', { name: '»' });
await userEvent.click(nextButton);
const pageTwoItem = screen.getByRole('listitem', { name: '2' });
await userEvent.click(pageTwoItem);
// Remove sortBy expectation since it's not part of the initial state
expect(mockedProps.fetchData).toHaveBeenCalledWith({
filters: [],
pageIndex: 1,
pageSize: 1,
sortBy: [],
await waitFor(() => {
const { calls } = mockedProps.fetchData.mock;
const pageChangeCall = calls.find(
call =>
call[0].pageIndex === 1 &&
call[0].filters.length === 0 &&
call[0].pageSize === 1,
);
expect(pageChangeCall).toBeDefined();
});
});

View File

@@ -19,7 +19,6 @@
import { t, styled } from '@superset-ui/core';
import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
import cx from 'classnames';
import Pagination from '@superset-ui/core/components/Pagination';
import TableCollection from '@superset-ui/core/components/TableCollection';
import BulkTagModal from 'src/features/tags/BulkTagModal';
import {
@@ -71,6 +70,7 @@ const ListViewStyles = styled.div`
.body {
overflow-x: auto;
overflow-y: hidden;
}
.ant-empty {
@@ -482,6 +482,12 @@ export function ListView<T extends object = any>({
}
}}
toggleAllRowsSelected={toggleAllRowsSelected}
pageIndex={pageIndex}
pageSize={pageSize}
totalCount={count}
onPageChange={newPageIndex => {
gotoPage(newPageIndex);
}}
/>
)}
</>
@@ -509,25 +515,6 @@ export function ListView<T extends object = any>({
)}
</div>
</div>
{rows.length > 0 && (
<div className="pagination-container">
<Pagination
totalPages={pageCount || 0}
currentPage={pageCount && pageIndex < pageCount ? pageIndex + 1 : 0}
onChange={(p: number) => gotoPage(p - 1)}
hideFirstAndLastPageLinks
/>
<div className="row-count-container">
{!loading &&
t(
'%s-%s of %s',
pageSize * pageIndex + (rows.length && 1),
pageSize * pageIndex + rows.length,
count,
)}
</div>
</div>
)}
</ListViewStyles>
);
}

View File

@@ -48,15 +48,22 @@ import {
// Define custom RisonParam for proper encoding/decoding; note that
// %, &, +, and # must be encoded to avoid breaking the url
const RisonParam: QueryParamConfig<string, any> = {
encode: (data?: any | null) =>
data === undefined
? undefined
: rison
.encode(data)
.replace(/%/g, '%25')
.replace(/&/g, '%26')
.replace(/\+/g, '%2B')
.replace(/#/g, '%23'),
encode: (data?: any | null) => {
if (data === undefined || data === null) return undefined;
const cleanData = JSON.parse(
JSON.stringify(data, (key, value) =>
value === undefined ? null : value,
),
);
return rison
.encode(cleanData)
.replace(/%/g, '%25')
.replace(/&/g, '%26')
.replace(/\+/g, '%2B')
.replace(/#/g, '%23');
},
decode: (dataStr?: string | string[]) =>
dataStr === undefined || Array.isArray(dataStr)
? undefined
@@ -319,7 +326,7 @@ export function useListViewState({
filters: Object.keys(filterObj).length ? filterObj : undefined,
pageIndex,
};
if (sortBy[0]) {
if (sortBy?.[0]?.id !== undefined && sortBy[0].id !== null) {
queryParams.sortColumn = sortBy[0].id;
queryParams.sortOrder = sortBy[0].desc ? 'desc' : 'asc';
}

View File

@@ -197,6 +197,16 @@ export class QueryErrorResultContext
}
}
const getActiveEditorImmutableId = () => {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState();
const { queryEditors, tabHistory } = sqlLab;
const activeEditorId = tabHistory[tabHistory.length - 1];
const activeEditor = queryEditors.find(
editor => editor.id === activeEditorId,
);
return activeEditor?.immutableId;
};
const activeEditorId = () => {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState();
const { tabHistory } = sqlLab;
@@ -225,19 +235,40 @@ const getCurrentTab: typeof sqlLabType.getCurrentTab = () => {
return undefined;
};
const predicate = (
actionType: string,
currentTabOnly: boolean = true,
): AnyListenerPredicate<RootState> => {
// Uses closure to capture the active editor ID at the time the listener is created
const id = activeEditorId();
return action =>
// Compares the original id with the current active editor ID
action.type === actionType && (!currentTabOnly || activeEditorId() === id);
const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
// Capture the immutable ID of the active editor at the time the listener is created
// This ID never changes for a tab, ensuring stable event routing
const registrationImmutableId = getActiveEditorImmutableId();
return action => {
if (action.type !== actionType) return false;
// If we don't have a registration ID, don't filter events
if (!registrationImmutableId) return true;
// For query events, use the immutableId directly from the action payload
if (action.query?.immutableId) {
return action.query.immutableId === registrationImmutableId;
}
// For tab events, we need to find the immutable ID of the affected tab
if (action.queryEditor?.id) {
const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } =
store.getState();
const { queryEditors } = sqlLab;
const queryEditor = queryEditors.find(
editor => editor.id === action.queryEditor.id,
);
return queryEditor?.immutableId === registrationImmutableId;
}
// Fallback: do not allow the event if we can't determine the source
return false;
};
};
export const onDidQueryRun: typeof sqlLabType.onDidQueryRun = (
listener: (editor: sqlLabType.QueryContext) => void,
listener: (queryContext: sqlLabType.QueryContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
@@ -272,11 +303,11 @@ export const onDidQueryRun: typeof sqlLabType.onDidQueryRun = (
);
export const onDidQuerySuccess: typeof sqlLabType.onDidQuerySuccess = (
listener: (query: sqlLabType.QueryResultContext) => void,
listener: (queryResultContext: sqlLabType.QueryResultContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(QUERY_SUCCESS, false),
predicate(QUERY_SUCCESS),
listener,
(action: ReturnType<typeof querySuccess>) => {
const { query, results } = action;
@@ -323,7 +354,7 @@ export const onDidQuerySuccess: typeof sqlLabType.onDidQuerySuccess = (
);
export const onDidQueryStop: typeof sqlLabType.onDidQueryStop = (
listener: (query: sqlLabType.QueryContext) => void,
listener: (queryContext: sqlLabType.QueryContext) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
@@ -356,11 +387,13 @@ export const onDidQueryStop: typeof sqlLabType.onDidQueryStop = (
);
export const onDidQueryFail: typeof sqlLabType.onDidQueryFail = (
listener: (query: sqlLabType.QueryErrorResultContext) => void,
listener: (
queryErrorResultContext: sqlLabType.QueryErrorResultContext,
) => void,
thisArgs?: any,
): Disposable =>
createActionListener(
predicate(QUERY_FAILED, false),
predicate(QUERY_FAILED),
listener,
(action: ReturnType<typeof createQueryFailedAction>) => {
const { query, msg: errorMessage, errors } = action;

View File

@@ -715,6 +715,7 @@ const PropertiesModal = ({
onThemeChange={handleThemeChange}
onColorSchemeChange={onColorSchemeChange}
onCustomCssChange={setCustomCss}
addDangerToast={addDangerToast}
/>
),
},

View File

@@ -16,9 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { SupersetClient, isFeatureEnabled } from '@superset-ui/core';
import StylingSection from './StylingSection';
// Mock SupersetClient
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
get: jest.fn(),
},
isFeatureEnabled: jest.fn(),
}));
const mockSupersetClient = SupersetClient as jest.Mocked<typeof SupersetClient>;
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;
// Mock ColorSchemeSelect component
jest.mock('src/dashboard/components/ColorSchemeSelect', () => ({
__esModule: true,
@@ -33,6 +53,14 @@ jest.mock('src/dashboard/components/ColorSchemeSelect', () => ({
),
}));
const mockCssTemplates = [
{ template_name: 'Corporate Blue', css: '.dashboard { background: blue; }' },
{
template_name: 'Modern Dark',
css: '.dashboard { background: black; color: white; }',
},
];
const defaultProps = {
themes: [
{ id: 1, theme_name: 'Dark Theme' },
@@ -45,10 +73,17 @@ const defaultProps = {
onThemeChange: jest.fn(),
onColorSchemeChange: jest.fn(),
onCustomCssChange: jest.fn(),
addDangerToast: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Reset mocks
mockIsFeatureEnabled.mockReturnValue(false);
mockSupersetClient.get.mockResolvedValue({
json: { result: mockCssTemplates },
response: {} as Response,
});
});
test('renders theme selection when themes are available', () => {
@@ -120,3 +155,76 @@ test('displays current color scheme value', () => {
const colorSchemeInput = screen.getByLabelText('Select color scheme');
expect(colorSchemeInput).toHaveValue('testColors');
});
// CSS Template Tests
describe('CSS Template functionality', () => {
test('does not show CSS template select when feature flag is disabled', () => {
mockIsFeatureEnabled.mockReturnValue(false);
render(<StylingSection {...defaultProps} />);
expect(
screen.queryByTestId('dashboard-css-template-field'),
).not.toBeInTheDocument();
});
test('fetches CSS templates on mount when feature enabled', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
render(<StylingSection {...defaultProps} />);
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalledWith({
endpoint: expect.stringContaining('/api/v1/css_template/'),
});
});
});
test('shows CSS template select when feature flag is enabled and templates exist', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
render(<StylingSection {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByText('Load CSS template (optional)'),
).toBeInTheDocument();
});
expect(
screen.getByTestId('dashboard-css-template-select'),
).toBeInTheDocument();
});
test('shows error toast when template fetch fails', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
const addDangerToast = jest.fn();
mockSupersetClient.get.mockRejectedValueOnce(new Error('API Error'));
render(
<StylingSection {...defaultProps} addDangerToast={addDangerToast} />,
);
await waitFor(() => {
expect(addDangerToast).toHaveBeenCalledWith(
'An error occurred while fetching available CSS templates',
);
});
});
test('does not show CSS template select when no templates available', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
mockSupersetClient.get.mockResolvedValueOnce({
json: { result: [] },
response: {} as Response,
});
render(<StylingSection {...defaultProps} />);
// Wait for fetch to complete
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalled();
});
expect(
screen.queryByTestId('dashboard-css-template-field'),
).not.toBeInTheDocument();
});
});

View File

@@ -16,8 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, styled } from '@superset-ui/core';
import { CssEditor, Select } from '@superset-ui/core/components';
import { useCallback, useEffect, useState } from 'react';
import {
t,
styled,
SupersetClient,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
import { CssEditor, Select, Alert } from '@superset-ui/core/components';
import rison from 'rison';
import ColorSchemeSelect from 'src/dashboard/components/ColorSchemeSelect';
import { ModalFormField } from 'src/components/Modal';
@@ -26,11 +34,20 @@ const StyledCssEditor = styled(CssEditor)`
border: 1px solid ${({ theme }) => theme.colorBorder};
`;
const StyledAlert = styled(Alert)`
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
`;
interface Theme {
id: number;
theme_name: string;
}
interface CssTemplate {
template_name: string;
css: string;
}
interface StylingSectionProps {
themes: Theme[];
selectedThemeId: number | null;
@@ -43,6 +60,7 @@ interface StylingSectionProps {
options?: { updateMetadata?: boolean },
) => void;
onCustomCssChange: (css: string) => void;
addDangerToast?: (message: string) => void;
}
const StylingSection = ({
@@ -54,63 +72,153 @@ const StylingSection = ({
onThemeChange,
onColorSchemeChange,
onCustomCssChange,
}: StylingSectionProps) => (
<>
{themes.length > 0 && (
addDangerToast,
}: StylingSectionProps) => {
const [cssTemplates, setCssTemplates] = useState<CssTemplate[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [originalTemplateContent, setOriginalTemplateContent] =
useState<string>('');
// Fetch CSS templates
const fetchCssTemplates = useCallback(async () => {
if (!isFeatureEnabled(FeatureFlag.CssTemplates)) return;
setIsLoadingTemplates(true);
try {
const query = rison.encode({ columns: ['template_name', 'css'] });
const response = await SupersetClient.get({
endpoint: `/api/v1/css_template/?q=${query}`,
});
setCssTemplates(response.json.result || []);
} catch (error) {
if (addDangerToast) {
addDangerToast(
t('An error occurred while fetching available CSS templates'),
);
}
} finally {
setIsLoadingTemplates(false);
}
}, [addDangerToast]);
useEffect(() => {
fetchCssTemplates();
}, [fetchCssTemplates]);
// Handle CSS template selection
const handleTemplateSelect = useCallback(
(templateName: string) => {
if (!templateName) {
setSelectedTemplate(null);
setOriginalTemplateContent('');
return;
}
const template = cssTemplates.find(t => t.template_name === templateName);
if (template) {
setSelectedTemplate(templateName);
setOriginalTemplateContent(template.css);
onCustomCssChange(template.css);
}
},
[cssTemplates, onCustomCssChange],
);
// Check if current CSS differs from original template
const hasTemplateModification =
selectedTemplate && customCss !== originalTemplateContent;
return (
<>
{themes.length > 0 && (
<ModalFormField
label={t('Theme')}
testId="dashboard-theme-field"
helperText={t(
'Clear the selection to revert to the system default theme',
)}
>
<Select
data-test="dashboard-theme-select"
value={selectedThemeId}
onChange={onThemeChange}
options={themes.map(theme => ({
value: theme.id,
label: theme.theme_name,
}))}
allowClear
placeholder={t('Select a theme')}
/>
</ModalFormField>
)}
<ModalFormField
label={t('Theme')}
testId="dashboard-theme-field"
label={t('Color scheme')}
testId="dashboard-colorscheme-field"
helperText={t(
'Clear the selection to revert to the system default theme',
"Any color palette selected here will override the colors applied to this dashboard's individual charts",
)}
>
<Select
data-test="dashboard-theme-select"
value={selectedThemeId}
onChange={onThemeChange}
options={themes.map(theme => ({
value: theme.id,
label: theme.theme_name,
}))}
allowClear
placeholder={t('Select a theme')}
<ColorSchemeSelect
data-test="dashboard-colorscheme-select"
value={colorScheme}
onChange={onColorSchemeChange}
hasCustomLabelsColor={hasCustomLabelsColor}
showWarning={hasCustomLabelsColor}
/>
</ModalFormField>
)}
<ModalFormField
label={t('Color scheme')}
testId="dashboard-colorscheme-field"
helperText={t(
"Any color palette selected here will override the colors applied to this dashboard's individual charts",
{isFeatureEnabled(FeatureFlag.CssTemplates) &&
cssTemplates.length > 0 && (
<ModalFormField
label={t('Load CSS template (optional)')}
testId="dashboard-css-template-field"
helperText={t(
'Select a predefined CSS template to apply to your dashboard',
)}
>
<Select
data-test="dashboard-css-template-select"
onChange={handleTemplateSelect}
options={cssTemplates.map(template => ({
value: template.template_name,
label: template.template_name,
}))}
placeholder={t('Select a CSS template')}
loading={isLoadingTemplates}
allowClear
value={selectedTemplate}
/>
</ModalFormField>
)}
{hasTemplateModification && (
<StyledAlert
type="warning"
message={t('Modified from "%s" template', selectedTemplate)}
showIcon
closable={false}
data-test="css-template-modified-warning"
/>
)}
>
<ColorSchemeSelect
data-test="dashboard-colorscheme-select"
value={colorScheme}
onChange={onColorSchemeChange}
hasCustomLabelsColor={hasCustomLabelsColor}
showWarning={hasCustomLabelsColor}
/>
</ModalFormField>
<ModalFormField
label={t('Custom CSS')}
testId="dashboard-css-field"
helperText={t(
'Apply custom CSS to the dashboard. Use class names or element selectors to target specific components.',
)}
bottomSpacing={false}
>
<StyledCssEditor
data-test="dashboard-css-editor"
onChange={onCustomCssChange}
value={customCss}
width="100%"
minLines={10}
maxLines={50}
editorProps={{ $blockScrolling: true }}
/>
</ModalFormField>
</>
);
<ModalFormField
label={t('CSS')}
testId="dashboard-css-field"
helperText={t(
'Apply custom CSS to the dashboard. Use class names or element selectors to target specific components.',
)}
bottomSpacing={false}
>
<StyledCssEditor
data-test="dashboard-css-editor"
onChange={onCustomCssChange}
value={customCss}
width="100%"
minLines={10}
maxLines={50}
editorProps={{ $blockScrolling: true }}
/>
</ModalFormField>
</>
);
};
export default StylingSection;

View File

@@ -32,7 +32,7 @@ import crossFiltersSelector from './CrossFilters/selectors';
const HorizontalBar = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px ${
padding: 0 ${theme.sizeUnit * 2}px ${
theme.sizeUnit * 3
}px ${theme.sizeUnit * 4}px;
background: ${theme.colorBgBase};

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { ensureIsArray, GenericDataType, styled, t } from '@superset-ui/core';
import {
TableView,
@@ -104,6 +104,11 @@ export const SamplesPane = ({
);
const filteredData = useFilteredTableData(filterText, data);
const handleInputChange = useCallback(
(input: string) => setFilterText(input),
[],
);
if (isLoading) {
return <Loading />;
}
@@ -117,7 +122,7 @@ export const SamplesPane = ({
columnTypes={coltypes}
rowcount={rowcount}
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
onInputChange={handleInputChange}
isLoading={isLoading}
canDownload={canDownload}
/>
@@ -139,7 +144,7 @@ export const SamplesPane = ({
columnTypes={coltypes}
rowcount={rowcount}
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
onInputChange={handleInputChange}
isLoading={isLoading}
canDownload={canDownload}
/>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { t } from '@superset-ui/core';
import {
TableView,
@@ -55,6 +55,11 @@ export const SingleQueryResultPane = ({
);
const filteredData = useFilteredTableData(filterText, data);
const handleInputChange = useCallback(
(input: string) => setFilterText(input),
[],
);
return (
<>
<TableControls
@@ -63,7 +68,7 @@ export const SingleQueryResultPane = ({
columnTypes={coltypes}
rowcount={rowcount}
datasourceId={datasourceId}
onInputChange={input => setFilterText(input)}
onInputChange={handleInputChange}
isLoading={false}
canDownload={canDownload}
/>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState, useEffect, ReactElement } from 'react';
import { useState, useEffect, ReactElement, useCallback } from 'react';
import {
ensureIsArray,
@@ -35,6 +35,14 @@ const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
const StyledDiv = styled.div`
${() => `
display: flex;
height: 100%;
flex-direction: column;
`}
`;
const cache = new WeakMap();
export const useResultsPane = ({
@@ -58,6 +66,8 @@ export const useResultsPane = ({
const queryCount = metadata?.queryObjectCount ?? 1;
const isQueryCountDynamic = metadata?.dynamicQueryObjectCount;
const noOpInputChange = useCallback(() => {}, []);
useEffect(() => {
// it's an invalid formData when gets a errorMessage
if (errorMessage) return;
@@ -123,7 +133,7 @@ export const useResultsPane = ({
columnTypes={[]}
rowcount={0}
datasourceId={queryFormData.datasource}
onInputChange={() => {}}
onInputChange={noOpInputChange}
isLoading={false}
canDownload={canDownload}
/>
@@ -144,16 +154,17 @@ export const useResultsPane = ({
: resultResp.slice(0, queryCount);
return resultRespToDisplay.map((result, idx) => (
<SingleQueryResultPane
data={result.data}
colnames={result.colnames}
coltypes={result.coltypes}
rowcount={result.rowcount}
dataSize={dataSize}
datasourceId={queryFormData.datasource}
key={idx}
isVisible={isVisible}
canDownload={canDownload}
/>
<StyledDiv key={idx}>
<SingleQueryResultPane
data={result.data}
colnames={result.colnames}
coltypes={result.coltypes}
rowcount={result.rowcount}
dataSize={dataSize}
datasourceId={queryFormData.datasource}
isVisible={isVisible}
canDownload={canDownload}
/>
</StyledDiv>
));
};

View File

@@ -317,13 +317,20 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const sortComparator = useCallback(
(a: LabeledValue, b: LabeledValue) => {
// When sortMetric is specified, the backend already sorted the data correctly
// Don't override the backend's metric-based sorting with frontend alphabetical sorting
if (formData.sortMetric) {
return 0; // Preserve the original order from the backend
}
// Only apply alphabetical sorting when no sortMetric is specified
const labelComparator = propertyComparator('label');
if (formData.sortAscending) {
return labelComparator(a, b);
}
return labelComparator(b, a);
},
[formData.sortAscending],
[formData.sortAscending, formData.sortMetric],
);
// Use effect for initialisation for filter plugin

View File

@@ -33,6 +33,7 @@ import {
const expectedQueryEditor = {
version: LatestQueryEditorVersion,
id: '123',
immutableId: 'immutable-id',
dbId: 456,
name: 'tab 1',
sql: 'SELECT * from example_table',

View File

@@ -350,7 +350,6 @@ FAB_API_SWAGGER_UI = True
# AUTHENTICATION CONFIG
# ----------------------------------------------------
# The authentication type
# AUTH_OID : Is for OpenID
# AUTH_DB : Is for database (username/password)
# AUTH_LDAP : Is for LDAP
# AUTH_REMOTE_USER : Is for using REMOTE_USER from web server

View File

@@ -22,7 +22,7 @@ from io import BytesIO
from typing import Any, Callable, cast
from zipfile import is_zipfile, ZipFile
from flask import g, redirect, request, Response, send_file, url_for
from flask import current_app, g, redirect, request, Response, send_file, url_for
from flask_appbuilder import permission_name
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -332,8 +332,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
"""Deterministic string representation of the API instance for etag_cache."""
# pylint: disable=consider-using-f-string
return "Superset.dashboards.api.DashboardRestApi@v{}{}".format(
self.appbuilder.app.config["VERSION_STRING"],
self.appbuilder.app.config["VERSION_SHA"],
current_app.config["VERSION_STRING"],
current_app.config["VERSION_SHA"],
)
@expose("/<id_or_slug>", methods=("GET",))

View File

@@ -24,7 +24,6 @@ from flask_wtf.csrf import same_origin
from superset import event_logger, is_feature_enabled
from superset.daos.dashboard import EmbeddedDashboardDAO
from superset.superset_typing import FlaskResponse
from superset.utils import json
from superset.views.base import BaseSupersetView, common_bootstrap_payload
@@ -76,7 +75,7 @@ class EmbeddedView(BaseSupersetView):
dashboard_version="v2",
)
bootstrap_data = {
extra_bootstrap_data = {
"config": {
"GUEST_TOKEN_HEADER_NAME": current_app.config["GUEST_TOKEN_HEADER_NAME"]
},
@@ -86,10 +85,7 @@ class EmbeddedView(BaseSupersetView):
},
}
return self.render_template(
"superset/spa.html",
return self.render_app_template(
extra_bootstrap_data=extra_bootstrap_data,
entry="embedded",
bootstrap_data=json.dumps(
bootstrap_data, default=json.pessimistic_json_iso_dttm_ser
),
)

View File

@@ -20,7 +20,8 @@ from typing import Any, Callable, Optional
import celery
from flask import Flask
from flask_appbuilder import AppBuilder, SQLA
from flask_appbuilder import AppBuilder
from flask_appbuilder.utils.legacy import get_sqla_class
from flask_caching.backends.base import BaseCache
from flask_migrate import Migrate
from flask_talisman import Talisman
@@ -123,7 +124,7 @@ async_query_manager: AsyncQueryManager = LocalProxy(
cache_manager = CacheManager()
celery_app = celery.Celery()
csrf = CSRFProtect()
db = SQLA() # pylint: disable=disallowed-name
db = get_sqla_class()()
_event_logger: dict[str, Any] = {}
encrypted_field_factory = EncryptedFieldFactory()
event_logger = LocalProxy(lambda: _event_logger.get("event_logger"))

View File

@@ -25,7 +25,7 @@ from typing import Any, Callable, TYPE_CHECKING
import wtforms_json
from colorama import Fore, Style
from deprecation import deprecated
from flask import abort, Flask, redirect, request, session, url_for
from flask import abort, current_app, Flask, redirect, request, session, url_for
from flask_appbuilder import expose, IndexView
from flask_appbuilder.api import safe
from flask_appbuilder.utils.base import get_safe_redirect
@@ -290,7 +290,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
"Home",
label=_("Home"),
href="/superset/welcome/",
cond=lambda: bool(appbuilder.app.config["LOGO_TARGET_PATH"]),
cond=lambda: bool(current_app.config["LOGO_TARGET_PATH"]),
)
appbuilder.add_view(

View File

@@ -22,7 +22,7 @@ from alembic import context
from alembic.operations.ops import MigrationScript
from alembic.runtime.migration import MigrationContext
from flask import current_app
from flask_appbuilder import Base
from flask_appbuilder import Model
from sqlalchemy import engine_from_config, pool
# this is the Alembic Config object, which provides
@@ -45,7 +45,7 @@ if "sqlite" in DATABASE_URI:
# Escape % chars in the database URI to avoid interpolation errors in ConfigParser
escaped_uri = DATABASE_URI.replace("%", "%%")
config.set_main_option("sqlalchemy.url", escaped_uri)
target_metadata = Base.metadata # pylint: disable=no-member
target_metadata = Model.metadata # pylint: disable=no-member
# other values from the config, defined by the needs of env.py,

View File

@@ -21,6 +21,7 @@ from superset.models.helpers import AuditMixinNullable
class DynamicPlugin(Model, AuditMixinNullable):
__tablename__ = "dynamic_plugin"
id = Column(Integer, primary_key=True)
name = Column(Text, unique=True, nullable=False)
# key corresponds to viz_type from static plugins

View File

@@ -805,7 +805,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
from superset.connectors.sqla.models import SqlaTable
user_datasources.update(
self.get_session.query(SqlaTable)
self.session.query(SqlaTable)
.filter(get_dataset_access_filters(SqlaTable))
.all()
)
@@ -841,7 +841,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
def user_view_menu_names(self, permission_name: str) -> set[str]:
base_query = (
self.get_session.query(self.viewmenu_model.name)
self.session.query(self.viewmenu_model.name)
.join(self.permissionview_model)
.join(self.permission_model)
.join(assoc_permissionview_role)
@@ -949,7 +949,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# datasource_access
if perms := self.user_view_menu_names("datasource_access"):
tables = (
self.get_session.query(SqlaTable.schema)
self.session.query(SqlaTable.schema)
.filter(SqlaTable.database_id == database.id)
.filter(or_(SqlaTable.perm.in_(perms)))
.distinct()
@@ -1009,7 +1009,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
# datasource_access
if perms := self.user_view_menu_names("datasource_access"):
tables = (
self.get_session.query(SqlaTable.schema)
self.session.query(SqlaTable.schema)
.filter(SqlaTable.database_id == database.id)
.filter(or_(SqlaTable.perm.in_(perms)))
.distinct()
@@ -1152,7 +1152,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
merge_pv("catalog_access", datasource.get_catalog_perm())
logger.info("Creating missing database permissions.")
databases = self.get_session.query(models.Database).all()
databases = self.session.query(models.Database).all()
for database in databases:
merge_pv("database_access", database.perm)
@@ -1162,7 +1162,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"""
logger.info("Cleaning faulty perms")
pvms = self.get_session.query(PermissionView).filter(
pvms = self.session.query(PermissionView).filter(
or_(
PermissionView.permission # pylint: disable=singleton-comparison
== None, # noqa: E711
@@ -1205,7 +1205,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
Gets list of all PVM
"""
pvms = (
self.get_session.query(self.permissionview_model)
self.session.query(self.permissionview_model)
.options(
eagerload(self.permissionview_model.permission),
eagerload(self.permissionview_model.view_menu),
@@ -1220,7 +1220,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
definition
"""
role_from_permissions_names = self.builtin_roles.get(role_name, [])
all_pvms = self.get_session.query(PermissionView).all()
all_pvms = self.session.query(PermissionView).all()
role_from_permissions = []
for pvm_regex in role_from_permissions_names:
view_name_regex = pvm_regex[0]
@@ -1237,7 +1237,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"""
Find a List of models by a list of ids, if defined applies `base_filter`
"""
query = self.get_session.query(self.role_model).filter(
query = self.session.query(self.role_model).filter(
self.role_model.id.in_(role_ids)
)
return query.all()
@@ -1510,7 +1510,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
)
# Clean database schema permissions
schema_pvms = (
self.get_session.query(self.permissionview_model)
self.session.query(self.permissionview_model)
.join(self.permission_model)
.join(self.viewmenu_model)
.filter(
@@ -1609,7 +1609,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
chart_table = Slice.__table__ # pylint: disable=no-member
new_database_name = target.database_name
datasets = (
self.get_session.query(SqlaTable)
self.session.query(SqlaTable)
.filter(SqlaTable.database_id == target.id)
.all()
)
@@ -1688,7 +1688,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
logger.warning(
"Dataset has no database will retry with database_id to set permission"
)
database = self.get_session.query(Database).get(target.database_id)
database = self.session.query(Database).get(target.database_id)
dataset_perm = self.get_dataset_perm(
target.id, target.table_name, database.database_name
)
@@ -2322,7 +2322,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
client_id=shortid()[:10],
user_id=get_user_id(),
)
self.get_session.expunge(query)
self.session.expunge(query)
if database and table or query:
if query:
@@ -2439,7 +2439,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
form_data
and (dashboard_id := form_data.get("dashboardId"))
and (
dashboard_ := self.get_session.query(Dashboard)
dashboard_ := self.session.query(Dashboard)
.filter(Dashboard.id == dashboard_id)
.one_or_none()
)
@@ -2472,7 +2472,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
form_data.get("type") != "NATIVE_FILTER"
and (slice_id := form_data.get("slice_id"))
and (
slc := self.get_session.query(Slice)
slc := self.session.query(Slice)
.filter(Slice.id == slice_id)
.one_or_none()
)
@@ -2546,7 +2546,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
need to be scoped
"""
return (
self.get_session.query(self.user_model)
self.session.query(self.user_model)
.filter(self.user_model.username == username)
.one_or_none()
)
@@ -2601,7 +2601,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
user_roles = [role.id for role in self.get_user_roles(g.user)]
regular_filter_roles = (
self.get_session.query(RLSFilterRoles.c.rls_filter_id)
self.session.query(RLSFilterRoles.c.rls_filter_id)
.join(RowLevelSecurityFilter)
.filter(
RowLevelSecurityFilter.filter_type == RowLevelSecurityFilterType.REGULAR
@@ -2609,18 +2609,18 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
.filter(RLSFilterRoles.c.role_id.in_(user_roles))
)
base_filter_roles = (
self.get_session.query(RLSFilterRoles.c.rls_filter_id)
self.session.query(RLSFilterRoles.c.rls_filter_id)
.join(RowLevelSecurityFilter)
.filter(
RowLevelSecurityFilter.filter_type == RowLevelSecurityFilterType.BASE
)
.filter(RLSFilterRoles.c.role_id.in_(user_roles))
)
filter_tables = self.get_session.query(RLSFilterTables.c.rls_filter_id).filter(
filter_tables = self.session.query(RLSFilterTables.c.rls_filter_id).filter(
RLSFilterTables.c.table_id == table.id
)
query = (
self.get_session.query(
self.session.query(
RowLevelSecurityFilter.id,
RowLevelSecurityFilter.group_key,
RowLevelSecurityFilter.clause,
@@ -2827,7 +2827,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
if self.is_admin():
return
orig_resource = self.get_session.query(resource.__class__).get(resource.id)
orig_resource = self.session.query(resource.__class__).get(resource.id)
owners = orig_resource.owners if hasattr(orig_resource, "owners") else []
if g.user.is_anonymous or g.user not in owners:

View File

@@ -17,7 +17,7 @@
import logging
from typing import Any
from flask import request, Response
from flask import current_app, request, Response
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError
@@ -146,8 +146,8 @@ class TagRestApi(BaseSupersetModelRestApi):
"""Deterministic string representation of the API instance for etag_cache."""
return (
"Superset.tags.api.TagRestApi@v"
f"{self.appbuilder.app.config['VERSION_STRING']}"
f"{self.appbuilder.app.config['VERSION_SHA']}"
f"{current_app.config['VERSION_STRING']}"
f"{current_app.config['VERSION_SHA']}"
)
@expose("/", methods=("POST",))

View File

@@ -69,11 +69,11 @@
{% endblock %}
</head>
<body {% if standalone_mode %}class="standalone"{% endif %}>
{% set tokens = theme_tokens | default({}) %}
<body {% if standalone_mode %}class="standalone"{% endif %} style="margin: 0; padding: 0; background-color: {{ tokens.get('colorBgBase', '#ffffff') }};">
{% block body %}
<div id="app" data-bootstrap="{{ bootstrap_data }}">
{% set tokens = theme_tokens | default({}) %}
{% set spinner_style = "width: 70px; height: auto; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);" %}
{% if spinner_svg %}
@@ -85,14 +85,10 @@
<!-- Custom URL from theme -->
<img
src="{{ tokens.brandSpinnerUrl }}"
alt="Loading..."
alt=""
style="{{ spinner_style }}"
/>
{% else %}
<!-- Fallback: This should rarely happen with new logic -->
<div style="{{ spinner_style }}">
Loading...
</div>
{# Remove fallback text - let React app handle loading state #}
{% endif %}
</div>
{% endblock %}

View File

@@ -69,9 +69,9 @@ import sqlalchemy as sa
from cryptography.hazmat.backends import default_backend
from cryptography.x509 import Certificate, load_pem_x509_certificate
from flask import current_app as app, g, request
from flask_appbuilder import SQLA
from flask_appbuilder.security.sqla.models import User
from flask_babel import gettext as __
from flask_sqlalchemy import SQLAlchemy
from markupsafe import Markup
from pandas.api.types import infer_dtype
from pandas.core.dtypes.common import is_numeric_dtype
@@ -613,7 +613,7 @@ def readfile(file_path: str) -> str | None:
def generic_find_constraint_name(
table: str, columns: set[str], referenced: str, database: SQLA
table: str, columns: set[str], referenced: str, database: SQLAlchemy
) -> str | None:
"""Utility to find a constraint name in alembic migrations"""
tbl = sa.Table(

View File

@@ -37,7 +37,7 @@ from flask import (
)
from flask_appbuilder import BaseView, Model, ModelView
from flask_appbuilder.actions import action
from flask_appbuilder.const import AUTH_OAUTH, AUTH_OID
from flask_appbuilder.const import AUTH_OAUTH
from flask_appbuilder.forms import DynamicForm
from flask_appbuilder.models.sqla.filters import BaseFilter
from flask_appbuilder.security.sqla.models import User
@@ -471,12 +471,6 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
)
frontend_config["AUTH_PROVIDERS"] = oauth_providers
if auth_type == AUTH_OID:
oid_providers = []
for provider in appbuilder.sm.openid_providers:
oid_providers.append(provider)
frontend_config["AUTH_PROVIDERS"] = oid_providers
bootstrap_data = {
"application_root": app.config["APPLICATION_ROOT"],
"static_assets_prefix": app.config["STATIC_ASSETS_PREFIX"],
@@ -603,9 +597,7 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
else:
view_menu = security_manager.find_view_menu(item.get_perm())
pvs = (
security_manager.get_session.query(
security_manager.permissionview_model
)
db.session.query(security_manager.permissionview_model)
.filter_by(view_menu=view_menu)
.all()
)
@@ -614,10 +606,10 @@ class DeleteMixin: # pylint: disable=too-few-public-methods
self.post_delete(item)
for pv in pvs:
security_manager.get_session.delete(pv)
db.session.delete(pv)
if view_menu:
security_manager.get_session.delete(view_menu)
db.session.delete(view_menu)
db.session.commit() # pylint: disable=consider-using-transaction

View File

@@ -657,15 +657,13 @@ class BaseSupersetModelRestApi(BaseSupersetApiMixin, ModelRestApi):
# Create generic base filters with added request filter
filters = self._get_distinct_filter(column_name, args.get("filter"))
# Make the query
query_count = self.appbuilder.get_session.query(
query_count = db.session.query(
func.count(distinct(getattr(self.datamodel.obj, column_name)))
)
count = self.datamodel.apply_filters(query_count, filters).scalar()
if count == 0:
return self.response(200, count=count, result=[])
query = self.appbuilder.get_session.query(
distinct(getattr(self.datamodel.obj, column_name))
)
query = db.session.query(distinct(getattr(self.datamodel.obj, column_name)))
# Apply generic base filters with added request filter
query = self.datamodel.apply_filters(query, filters)
# Apply sort

View File

@@ -55,10 +55,10 @@ class Model1Api(BaseSupersetModelRestApi):
}
appbuilder.add_api(Model1Api)
class TestOpenApiSpec(SupersetTestCase):
def setUp(self) -> None:
appbuilder.add_api(Model1Api)
def test_open_api_spec(self):
"""
API: Test validate OpenAPI spec

View File

@@ -29,7 +29,7 @@ from tests.integration_tests.dashboards.consts import DEFAULT_DASHBOARD_SLUG_TO_
logger = logging.getLogger(__name__)
session = appbuilder.get_session
session = appbuilder.session
def get_mock_positions(dashboard: Dashboard) -> dict[str, Any]:

View File

@@ -26,7 +26,7 @@ class TestDynamicPlugins(SupersetTestCase):
Dynamic Plugins: Responds not found when disabled
"""
self.login(ADMIN_USERNAME)
uri = "/dynamic-plugins/api"
uri = "/dynamic-plugins/list/"
rv = self.client.get(uri)
assert rv.status_code == 404
@@ -36,6 +36,6 @@ class TestDynamicPlugins(SupersetTestCase):
Dynamic Plugins: Responds successfully when enabled
"""
self.login(ADMIN_USERNAME)
uri = "/dynamic-plugins/api"
uri = "/dynamic-plugins/list/"
rv = self.client.get(uri)
assert rv.status_code == 200

View File

@@ -49,9 +49,9 @@ def create_user_and_group(
def cleanup(user, group):
security_manager.get_session.delete(user)
security_manager.get_session.delete(group)
security_manager.get_session.commit()
security_manager.session.delete(user)
security_manager.session.delete(group)
security_manager.session.commit()
@pytest.fixture

View File

@@ -18,7 +18,6 @@
"""Unit tests for Superset"""
from datetime import datetime, timedelta
from unittest import mock
import random
import string
@@ -453,21 +452,13 @@ class TestQueryApi(SupersetTestCase):
db.session.delete(updated_query)
db.session.commit()
@mock.patch("superset.sql_lab.cancel_query")
@mock.patch("superset.views.core.db.session")
def test_stop_query_not_found(
self, mock_superset_db_session, mock_sql_lab_cancel_query
):
def test_stop_query_not_found(self):
"""
Handles stop query when the DB engine spec does not
have a cancel query method (with invalid client_id).
"""
form_data = {"client_id": "foo2"}
query_mock = mock.Mock()
query_mock.return_value = None
self.login(ADMIN_USERNAME)
mock_superset_db_session.query().filter_by().one_or_none = query_mock
mock_sql_lab_cancel_query.return_value = True
rv = self.client.post(
"/api/v1/query/stop",
data=json.dumps(form_data),
@@ -478,22 +469,25 @@ class TestQueryApi(SupersetTestCase):
data = json.loads(rv.data.decode("utf-8"))
assert data["message"] == "Query with client_id foo2 not found"
@mock.patch("superset.sql_lab.cancel_query")
@mock.patch("superset.views.core.db.session")
def test_stop_query(self, mock_superset_db_session, mock_sql_lab_cancel_query):
# @mock.patch("superset.sql_lab.cancel_query")
def test_stop_query(self):
"""
Handles stop query when the DB engine spec does not
have a cancel query method.
"""
form_data = {"client_id": "foo"}
query_mock = mock.Mock()
query_mock.client_id = "foo"
query_mock.status = QueryStatus.RUNNING
self.login(ADMIN_USERNAME)
mock_superset_db_session.query().filter_by().one_or_none().return_value = (
query_mock
admin = self.get_user("admin")
example_db = get_example_database()
query1 = self.insert_query(
example_db.id,
admin.id,
form_data["client_id"],
sql="SELECT col1, col2 from table1",
select_sql="SELECT col1, col2 from table1",
executed_sql="SELECT col1, col2 from table1 LIMIT 100",
changed_on=datetime.utcnow() - timedelta(days=1),
)
mock_sql_lab_cancel_query.return_value = True
self.login(ADMIN_USERNAME)
rv = self.client.post(
"/api/v1/query/stop",
data=json.dumps(form_data),
@@ -503,3 +497,5 @@ class TestQueryApi(SupersetTestCase):
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["result"] == "OK"
db.session.delete(query1)
db.session.commit()

View File

@@ -1959,13 +1959,11 @@ class TestSecurityManager(SupersetTestCase):
class TestDatasources(SupersetTestCase):
@patch("superset.security.SupersetSecurityManager.can_access_database")
@patch("superset.security.SupersetSecurityManager.get_session")
def test_get_user_datasources_admin(
self, mock_get_session, mock_can_access_database
):
@patch("superset.security.SupersetSecurityManager.session")
def test_get_user_datasources_admin(self, mock_session, mock_can_access_database):
Datasource = namedtuple("Datasource", ["database", "schema", "name"])
mock_can_access_database.return_value = True
mock_get_session.query.return_value.filter.return_value.all.return_value = []
mock_session.query.return_value.filter.return_value.all.return_value = []
with mock.patch.object(
SqlaTable, "get_all_datasources"
@@ -1984,13 +1982,11 @@ class TestDatasources(SupersetTestCase):
]
@patch("superset.security.SupersetSecurityManager.can_access_database")
@patch("superset.security.SupersetSecurityManager.get_session")
def test_get_user_datasources_gamma(
self, mock_get_session, mock_can_access_database
):
@patch("superset.security.SupersetSecurityManager.session")
def test_get_user_datasources_gamma(self, mock_session, mock_can_access_database):
Datasource = namedtuple("Datasource", ["database", "schema", "name"])
mock_can_access_database.return_value = False
mock_get_session.query.return_value.filter.return_value.all.return_value = []
mock_session.query.return_value.filter.return_value.all.return_value = []
with mock.patch.object(
SqlaTable, "get_all_datasources"
@@ -2005,14 +2001,14 @@ class TestDatasources(SupersetTestCase):
assert datasources == []
@patch("superset.security.SupersetSecurityManager.can_access_database")
@patch("superset.security.SupersetSecurityManager.get_session")
@patch("superset.security.SupersetSecurityManager.session")
def test_get_user_datasources_gamma_with_schema(
self, mock_get_session, mock_can_access_database
self, mock_session, mock_can_access_database
):
Datasource = namedtuple("Datasource", ["database", "schema", "name"])
mock_can_access_database.return_value = False
mock_get_session.query.return_value.filter.return_value.all.return_value = [
mock_session.query.return_value.filter.return_value.all.return_value = [
Datasource("database1", "schema1", "table1"),
Datasource("database1", "schema1", "table2"),
]

View File

@@ -54,7 +54,7 @@ def get_session(mocker: MockerFixture) -> Callable[[], Session]:
# patch session
get_session = mocker.patch(
"superset.security.SupersetSecurityManager.get_session",
"superset.security.SupersetSecurityManager.session",
)
get_session.return_value = in_memory_session
# FAB calls get_session.get_bind() to get a handler to the engine

View File

@@ -67,7 +67,7 @@ def test_filter_by_uuid(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -135,7 +135,7 @@ def test_password_mask(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -191,7 +191,7 @@ def test_database_connection(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -355,7 +355,7 @@ def test_update_with_password_mask(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -502,7 +502,7 @@ def test_delete_ssh_tunnel(
from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.models.core import Database
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -580,7 +580,7 @@ def test_delete_ssh_tunnel_not_found(
from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.models.core import Database
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -658,7 +658,7 @@ def test_apply_dynamic_database_filter(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -753,7 +753,7 @@ def test_oauth2_happy_path(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database, DatabaseUserOAuth2Tokens
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -822,7 +822,7 @@ def test_oauth2_permissions(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database, DatabaseUserOAuth2Tokens
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member
@@ -888,7 +888,7 @@ def test_oauth2_multiple_tokens(
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database, DatabaseUserOAuth2Tokens
DatabaseRestApi.datamodel.session = session
DatabaseRestApi.datamodel._session = session
# create table for databases
Database.metadata.create_all(session.get_bind()) # pylint: disable=no-member

View File

@@ -450,7 +450,7 @@ def test_raise_for_access_chart_for_datasource_permission(
when the user does not have access to the chart datasource
"""
sm = SupersetSecurityManager(appbuilder)
session = sm.get_session
session = sm.session
engine = session.get_bind()
Slice.metadata.create_all(engine) # pylint: disable=no-member
@@ -510,7 +510,7 @@ def test_raise_for_access_chart_on_admin(
from superset.utils.core import override_user
sm = SupersetSecurityManager(appbuilder)
session = sm.get_session
session = sm.session
engine = session.get_bind()
Slice.metadata.create_all(engine) # pylint: disable=no-member
@@ -547,7 +547,7 @@ def test_raise_for_access_chart_owner(
when the user does not have access to the chart datasource
"""
sm = SupersetSecurityManager(appbuilder)
session = sm.get_session
session = sm.session
engine = session.get_bind()
Slice.metadata.create_all(engine) # pylint: disable=no-member