mirror of
https://github.com/apache/superset.git
synced 2026-07-07 23:35:36 +00:00
Compare commits
11 Commits
fix/missin
...
fix/superc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfae8ba2fc | ||
|
|
76c5b40c13 | ||
|
|
9617b8b392 | ||
|
|
a7b180d8dd | ||
|
|
36daa2dc3f | ||
|
|
7fd5a7668b | ||
|
|
95333e34b1 | ||
|
|
a9fb853e3e | ||
|
|
dea9068647 | ||
|
|
3416bd1479 | ||
|
|
e729b2dbb4 |
14
.github/CODEOWNERS
vendored
14
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
16
superset-frontend/package-lock.json
generated
16
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,7 @@ const PERSISTENT_QUERY_EDITOR_KEYS = new Set([
|
||||
'dbId',
|
||||
'height',
|
||||
'id',
|
||||
'immutableId',
|
||||
'latestQueryId',
|
||||
'northPercent',
|
||||
'queryLimit',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -715,6 +715,7 @@ const PropertiesModal = ({
|
||||
onThemeChange={handleThemeChange}
|
||||
onColorSchemeChange={onColorSchemeChange}
|
||||
onCustomCssChange={setCustomCss}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
const expectedQueryEditor = {
|
||||
version: LatestQueryEditorVersion,
|
||||
id: '123',
|
||||
immutableId: 'immutable-id',
|
||||
dbId: 456,
|
||||
name: 'tab 1',
|
||||
sql: 'SELECT * from example_table',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",))
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",))
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user