mirror of
https://github.com/apache/superset.git
synced 2026-06-18 14:09:16 +00:00
Compare commits
1 Commits
showtime-m
...
docs/fix-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014d6acd9a |
1
.github/workflows/codeql-analysis.yml
vendored
1
.github/workflows/codeql-analysis.yml
vendored
@@ -47,7 +47,6 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
|
||||
@@ -297,7 +297,7 @@ pre-commit run eslint # Frontend linting
|
||||
## Platform-Specific Instructions
|
||||
|
||||
- **[CLAUDE.md](CLAUDE.md)** - For Claude/Anthropic tools
|
||||
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
|
||||
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
|
||||
- **[GEMINI.md](GEMINI.md)** - For Google Gemini tools
|
||||
- **[GPT.md](GPT.md)** - For OpenAI/ChatGPT tools
|
||||
- **[.cursor/rules/dev-standard.mdc](.cursor/rules/dev-standard.mdc)** - For Cursor editor
|
||||
|
||||
@@ -22,31 +22,24 @@ level dependencies.
|
||||
|
||||
**Debian and Ubuntu**
|
||||
|
||||
Ubuntu **24.04** uses python 3.12 per default, which currently is not supported by Superset. You need to add a second python installation of 3.11 and install the required additional dependencies.
|
||||
```bash
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt update
|
||||
sudo apt install python3.11 python3.11-dev python3.11-venv build-essential libssl-dev libffi-dev libsasl2-dev libldap2-dev default-libmysqlclient-dev
|
||||
```
|
||||
|
||||
In Ubuntu **20.04 and 22.04** the following command will ensure that the required dependencies are installed:
|
||||
The following command will ensure that the required dependencies are installed (tested on Ubuntu 20.04, 22.04, and 24.04):
|
||||
|
||||
```bash
|
||||
sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev
|
||||
sudo apt-get install build-essential libssl-dev libffi-dev python3-dev python3-pip python3-venv libsasl2-dev libldap2-dev libpq-dev default-libmysqlclient-dev pkg-config
|
||||
```
|
||||
|
||||
In Ubuntu **before 20.04** the following command will ensure that the required dependencies are installed:
|
||||
|
||||
```bash
|
||||
sudo apt-get install build-essential libssl-dev libffi-dev python-dev python-pip libsasl2-dev libldap2-dev default-libmysqlclient-dev
|
||||
```
|
||||
Refer to the
|
||||
[pyproject.toml](https://github.com/apache/superset/blob/master/pyproject.toml) file for the list of
|
||||
Python versions officially supported by Superset, and install a matching `python3` interpreter for
|
||||
your distribution. The `libpq-dev` package is only needed if you intend to connect to (or use) a
|
||||
PostgreSQL database; you can omit it otherwise.
|
||||
|
||||
**Fedora and RHEL-derivative Linux distributions**
|
||||
|
||||
Install the following packages using the `yum` package manager:
|
||||
|
||||
```bash
|
||||
sudo yum install gcc gcc-c++ libffi-devel python-devel python-pip python-wheel openssl-devel cyrus-sasl-devel openldap-devel
|
||||
sudo yum install gcc gcc-c++ libffi-devel python3-devel python3-pip python3-wheel openssl-devel cyrus-sasl-devel openldap-devel
|
||||
```
|
||||
|
||||
In more recent versions of CentOS and Fedora, you may need to install a slightly different set of packages using `dnf`:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"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
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# Change Log
|
||||
|
||||
8
superset-frontend/package-lock.json
generated
8
superset-frontend/package-lock.json
generated
@@ -221,7 +221,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.35",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -14474,9 +14474,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.35",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
|
||||
"integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
|
||||
"version": "2.10.34",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
||||
"integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.35",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
@@ -52,13 +52,6 @@ const SupersetClient: SupersetClientInterface = {
|
||||
request: request => getInstance().request(request),
|
||||
getCSRFToken: () => getInstance().getCSRFToken(),
|
||||
getUrl: (...args) => getInstance().getUrl(...args),
|
||||
get guestTokenHeaderName() {
|
||||
try {
|
||||
return getInstance().guestTokenHeaderName;
|
||||
} catch {
|
||||
return 'X-GuestToken';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default SupersetClient;
|
||||
|
||||
@@ -163,7 +163,6 @@ export interface SupersetClientInterface extends Pick<
|
||||
configure: (config?: ClientConfig) => SupersetClientInterface;
|
||||
reset: () => void;
|
||||
getCSRFToken: () => CsrfPromise;
|
||||
guestTokenHeaderName?: string;
|
||||
}
|
||||
|
||||
export type SupersetClientResponse = Response | JsonResponse | TextResponse;
|
||||
|
||||
@@ -172,15 +172,4 @@ describe('SupersetClient', () => {
|
||||
const token = await SupersetClient.getCSRFToken();
|
||||
expect(token).toBe('my_token');
|
||||
});
|
||||
|
||||
test('guestTokenHeaderName returns the configured header name when instance exists', () => {
|
||||
SupersetClient.configure({ guestTokenHeaderName: 'X-Custom-Guest' });
|
||||
expect(SupersetClient.guestTokenHeaderName).toBe('X-Custom-Guest');
|
||||
});
|
||||
|
||||
test('guestTokenHeaderName returns default X-GuestToken when instance is not configured', () => {
|
||||
// Ensure instance is reset (afterEach calls SupersetClient.reset())
|
||||
// Access the property without calling configure() first
|
||||
expect(SupersetClient.guestTokenHeaderName).toBe('X-GuestToken');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1420,6 +1420,39 @@ describe('async actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('syncTable', () => {
|
||||
test('updates the table schema state in the backend', () => {
|
||||
expect.assertions(4);
|
||||
|
||||
const tableName = 'table';
|
||||
const schemaName = 'schema';
|
||||
const store = mockStore(initialState);
|
||||
const expectedActionTypes = [
|
||||
actions.MERGE_TABLE, // syncTable
|
||||
];
|
||||
const request = actions.syncTable(
|
||||
query as any,
|
||||
tableName as any,
|
||||
schemaName,
|
||||
);
|
||||
return request(store.dispatch, store.getState, undefined).then(() => {
|
||||
expect(store.getActions().map(a => a.type)).toEqual(
|
||||
expectedActionTypes,
|
||||
);
|
||||
expect(store.getActions()[0].prepend).toBeFalsy();
|
||||
expect(
|
||||
fetchMock.callHistory.calls(updateTableSchemaEndpoint),
|
||||
).toHaveLength(1);
|
||||
|
||||
// tab state is not updated, since no query was run
|
||||
expect(
|
||||
fetchMock.callHistory.calls(updateTabStateEndpoint),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('runTablePreviewQuery', () => {
|
||||
const results = {
|
||||
|
||||
@@ -1346,6 +1346,52 @@ export function runTablePreviewQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export interface TableMetaData {
|
||||
columns?: unknown[];
|
||||
selectStar?: string;
|
||||
primaryKey?: unknown;
|
||||
foreignKeys?: unknown[];
|
||||
indexes?: unknown[];
|
||||
}
|
||||
|
||||
export function syncTable(
|
||||
table: Table,
|
||||
tableMetadata: TableMetaData,
|
||||
finalQueryEditorId?: string,
|
||||
): SqlLabThunkAction<Promise<unknown>> {
|
||||
return function (dispatch: AppDispatch) {
|
||||
const finalTable = { ...table, queryEditorId: finalQueryEditorId };
|
||||
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
|
||||
? SupersetClient.post({
|
||||
endpoint: encodeURI('/tableschemaview/'),
|
||||
postPayload: { table: { ...tableMetadata, ...finalTable } },
|
||||
})
|
||||
: Promise.resolve({ json: { id: table.id } });
|
||||
|
||||
return sync
|
||||
.then(({ json: resultJson }) => {
|
||||
const newTable = { ...table, id: `${resultJson.id}` };
|
||||
dispatch(
|
||||
mergeTable({
|
||||
...newTable,
|
||||
expanded: true,
|
||||
initialized: true,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() =>
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching table metadata. ' +
|
||||
'Please contact your administrator.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function changeDataPreviewId(
|
||||
oldQueryId: string,
|
||||
newQuery: Query,
|
||||
|
||||
@@ -310,7 +310,12 @@ test('ignores non-array fontUrls in theme config without throwing', () => {
|
||||
});
|
||||
|
||||
test('skips the dashboard theme when an SDK theme config override is active', () => {
|
||||
const themeConfig = { token: { colorPrimary: '#ff0000' } };
|
||||
const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
fontUrls: ['https://fonts.example.com/dashboard.css'],
|
||||
},
|
||||
};
|
||||
render(
|
||||
<ThemeContext.Provider
|
||||
value={{ hasThemeConfigOverride: true } as unknown as ThemeContextType}
|
||||
@@ -332,6 +337,8 @@ test('skips the dashboard theme when an SDK theme config override is active', ()
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-theme-provider'),
|
||||
).not.toBeInTheDocument();
|
||||
// The override fully owns theming, so dashboard fonts must not be injected.
|
||||
expect(document.querySelector('style[data-superset-fonts]')).toBeNull();
|
||||
});
|
||||
|
||||
test('applies the dashboard theme when no SDK theme config override is active', () => {
|
||||
@@ -372,52 +379,3 @@ test('does not inject font style element when no fontUrls in config', () => {
|
||||
const fontStyle = document.querySelector('style[data-superset-fonts]');
|
||||
expect(fontStyle).toBeNull();
|
||||
});
|
||||
|
||||
test('prevents font injection and cleans up fonts when hasThemeConfigOverride becomes active', () => {
|
||||
const fontUrl = 'https://fonts.example.com/custom.css';
|
||||
const themeConfig = {
|
||||
token: { colorPrimary: '#ff0000', fontUrls: [fontUrl] },
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<ThemeContext.Provider
|
||||
value={{ hasThemeConfigOverride: false } as unknown as ThemeContextType}
|
||||
>
|
||||
<CrudThemeProvider
|
||||
theme={{
|
||||
id: 1,
|
||||
theme_name: 'Font Theme',
|
||||
json_data: JSON.stringify(themeConfig),
|
||||
}}
|
||||
>
|
||||
<div>Dashboard Content</div>
|
||||
</CrudThemeProvider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
// Assert font is injected
|
||||
let fontStyle = document.querySelector('style[data-superset-fonts]');
|
||||
expect(fontStyle).not.toBeNull();
|
||||
expect(fontStyle?.textContent).toContain(`@import url("${fontUrl}")`);
|
||||
|
||||
// Switch hasThemeConfigOverride dynamically to true
|
||||
rerender(
|
||||
<ThemeContext.Provider
|
||||
value={{ hasThemeConfigOverride: true } as unknown as ThemeContextType}
|
||||
>
|
||||
<CrudThemeProvider
|
||||
theme={{
|
||||
id: 1,
|
||||
theme_name: 'Font Theme',
|
||||
json_data: JSON.stringify(themeConfig),
|
||||
}}
|
||||
>
|
||||
<div>Dashboard Content</div>
|
||||
</CrudThemeProvider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
// Assert font is cleaned up and removed
|
||||
fontStyle = document.querySelector('style[data-superset-fonts]');
|
||||
expect(fontStyle).toBeNull();
|
||||
});
|
||||
|
||||
@@ -78,9 +78,7 @@ export default function CrudThemeProvider({
|
||||
}, [theme?.json_data, hasThemeConfigOverride]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasThemeConfigOverride || !dashboardTheme || !fontUrls?.length) {
|
||||
return undefined;
|
||||
}
|
||||
if (!dashboardTheme || !fontUrls?.length) return undefined;
|
||||
|
||||
// JSON.stringify provides safe escaping to prevent CSS injection
|
||||
const css = fontUrls
|
||||
@@ -94,7 +92,7 @@ export default function CrudThemeProvider({
|
||||
return () => {
|
||||
style.remove();
|
||||
};
|
||||
}, [dashboardTheme, fontUrls, hasThemeConfigOverride]);
|
||||
}, [dashboardTheme, fontUrls]);
|
||||
|
||||
if (!dashboardTheme || hasThemeConfigOverride) {
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
themeObject as supersetThemeObject,
|
||||
normalizeThemeConfig,
|
||||
} from '@apache-superset/core/theme';
|
||||
import { makeApi, SupersetClient } from '@superset-ui/core';
|
||||
import { makeApi } from '@superset-ui/core';
|
||||
import type {
|
||||
BootstrapThemeData,
|
||||
BootstrapThemeDataConfig,
|
||||
@@ -188,12 +188,6 @@ export class ThemeController {
|
||||
);
|
||||
|
||||
this.onChangeCallbacks.clear();
|
||||
|
||||
// Clean up injected font styles
|
||||
document
|
||||
.querySelectorAll('style[data-superset-fonts]')
|
||||
.forEach(el => el.remove());
|
||||
this.loadedFontUrls.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,7 +447,6 @@ export class ThemeController {
|
||||
this.devThemeOverride = null;
|
||||
this.crudThemeId = null;
|
||||
this.dashboardCrudTheme = null;
|
||||
this.themeConfigOverride = false;
|
||||
|
||||
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
|
||||
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
|
||||
@@ -1025,105 +1018,44 @@ export class ThemeController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the guest token authorization header using the configured
|
||||
* header name from SupersetClient or bootstrap config, falling back to 'X-GuestToken'.
|
||||
*/
|
||||
private getGuestTokenHeader(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
try {
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
if (guestToken) {
|
||||
let headerName = 'X-GuestToken';
|
||||
try {
|
||||
if (SupersetClient.guestTokenHeaderName) {
|
||||
headerName = SupersetClient.guestTokenHeaderName;
|
||||
}
|
||||
} catch {
|
||||
const bootstrapData = getBootstrapData();
|
||||
headerName =
|
||||
bootstrapData.config?.GUEST_TOKEN_HEADER_NAME || 'X-GuestToken';
|
||||
}
|
||||
headers[headerName] = guestToken;
|
||||
}
|
||||
} catch (tokenError) {
|
||||
// Ignore token retrieval error
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a fresh system default theme from the API for runtime recovery.
|
||||
* Tries multiple fallback strategies to find a valid theme.
|
||||
*
|
||||
* Note: First tries to use SupersetClient. If SupersetClient is not yet
|
||||
* fully configured/initialized or if the request fails (e.g. in embedded
|
||||
* guest-token environments where SupersetClient bootstrap is still in progress),
|
||||
* it falls back to using raw fetch() with custom guest token headers.
|
||||
* Note: Uses raw fetch() instead of SupersetClient because ThemeController
|
||||
* initializes early in the app lifecycle, before SupersetClient is fully
|
||||
* configured. This avoids boot-time circular dependencies.
|
||||
*
|
||||
* @returns The system default theme configuration or null if not found
|
||||
*/
|
||||
private async fetchSystemDefaultTheme(): Promise<AnyThemeConfig | null> {
|
||||
try {
|
||||
// Try to use SupersetClient first if it has been configured
|
||||
try {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint:
|
||||
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
|
||||
});
|
||||
if (response.json?.result?.length > 0) {
|
||||
const themeConfig = JSON.parse(response.json.result[0].json_data);
|
||||
// Try to fetch theme marked as system default (is_system_default=true)
|
||||
const defaultResponse = await fetch(
|
||||
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
|
||||
);
|
||||
if (defaultResponse.ok) {
|
||||
const data = await defaultResponse.json();
|
||||
if (data.result?.length > 0) {
|
||||
const themeConfig = JSON.parse(data.result[0].json_data);
|
||||
if (themeConfig && typeof themeConfig === 'object') {
|
||||
return themeConfig;
|
||||
}
|
||||
}
|
||||
} catch (clientError) {
|
||||
// If SupersetClient is not configured yet or request fails, fall back to native fetch
|
||||
const headers = this.getGuestTokenHeader();
|
||||
|
||||
const defaultResponse = await fetch(
|
||||
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
|
||||
{ headers },
|
||||
);
|
||||
if (defaultResponse.ok) {
|
||||
const data = await defaultResponse.json();
|
||||
if (data.result?.length > 0) {
|
||||
const themeConfig = JSON.parse(data.result[0].json_data);
|
||||
if (themeConfig && typeof themeConfig === 'object') {
|
||||
return themeConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try to fetch system theme named 'THEME_DEFAULT'
|
||||
try {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint:
|
||||
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
|
||||
});
|
||||
if (response.json?.result?.length > 0) {
|
||||
const themeConfig = JSON.parse(response.json.result[0].json_data);
|
||||
const fallbackResponse = await fetch(
|
||||
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
|
||||
);
|
||||
if (fallbackResponse.ok) {
|
||||
const fallbackData = await fallbackResponse.json();
|
||||
if (fallbackData.result?.length > 0) {
|
||||
const themeConfig = JSON.parse(fallbackData.result[0].json_data);
|
||||
if (themeConfig && typeof themeConfig === 'object') {
|
||||
return themeConfig;
|
||||
}
|
||||
}
|
||||
} catch (clientError) {
|
||||
const headers = this.getGuestTokenHeader();
|
||||
|
||||
const fallbackResponse = await fetch(
|
||||
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
|
||||
{ headers },
|
||||
);
|
||||
if (fallbackResponse.ok) {
|
||||
const fallbackData = await fallbackResponse.json();
|
||||
if (fallbackData.result?.length > 0) {
|
||||
const themeConfig = JSON.parse(fallbackData.result[0].json_data);
|
||||
if (themeConfig && typeof themeConfig === 'object') {
|
||||
return themeConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log for debugging but don't fail - fallback to cached theme will be used
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { theme as antdThemeImport } from 'antd';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import {} from '@superset-ui/core';
|
||||
import {
|
||||
type AnyThemeConfig,
|
||||
type SupersetThemeConfig,
|
||||
@@ -915,7 +915,6 @@ test('recovery flow: fetchSystemDefaultTheme returns theme → applies fetched t
|
||||
// Verify API was called to fetch system default theme
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/theme/'),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Verify the fetched theme was applied via applyThemeWithRecovery
|
||||
@@ -1855,263 +1854,3 @@ test('getResolvedThemeMode returns dark when default theme is dark but mode is D
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
|
||||
expect(controller.getCurrentModeResolved()).toBe('dark');
|
||||
});
|
||||
|
||||
test('fallback fetch: uses custom guest token header from SupersetClient when client.get fails', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const mockGet = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('Client not configured'));
|
||||
const mockGetGuestToken = jest
|
||||
.spyOn(SupersetClient, 'getGuestToken')
|
||||
.mockReturnValue('custom-guest-token-123');
|
||||
|
||||
// Define getter for guestTokenHeaderName
|
||||
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
|
||||
value: 'X-Custom-Guest-Header',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result: [
|
||||
{
|
||||
json_data: JSON.stringify({
|
||||
token: { colorPrimary: '#custom-header-theme' },
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
try {
|
||||
const controller = createController();
|
||||
const result = await (controller as any).fetchSystemDefaultTheme();
|
||||
|
||||
expect(mockGet).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/theme/'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-Custom-Guest-Header': 'custom-guest-token-123',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ token: { colorPrimary: '#custom-header-theme' } });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
mockGet.mockRestore();
|
||||
mockGetGuestToken.mockRestore();
|
||||
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('fallback fetch: uses bootstrap config for guest token header when SupersetClient is not configured', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const mockGet = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('Client not configured'));
|
||||
const mockGetGuestToken = jest
|
||||
.spyOn(SupersetClient, 'getGuestToken')
|
||||
.mockReturnValue('bootstrap-guest-token');
|
||||
|
||||
// Ensure SupersetClient.guestTokenHeaderName is undefined or throws
|
||||
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
|
||||
get: () => {
|
||||
throw new Error('Not configured');
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock bootstrapData.config?.GUEST_TOKEN_HEADER_NAME
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
...createMockBootstrapData(),
|
||||
config: {
|
||||
GUEST_TOKEN_HEADER_NAME: 'X-Bootstrap-Custom-Header',
|
||||
},
|
||||
} as any);
|
||||
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result: [
|
||||
{
|
||||
json_data: JSON.stringify({
|
||||
token: { colorPrimary: '#bootstrap-theme' },
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
try {
|
||||
const controller = createController();
|
||||
const result = await (controller as any).fetchSystemDefaultTheme();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/theme/'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-Bootstrap-Custom-Header': 'bootstrap-guest-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ token: { colorPrimary: '#bootstrap-theme' } });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
mockGet.mockRestore();
|
||||
mockGetGuestToken.mockRestore();
|
||||
mockGetBootstrapData.mockReturnValue(createMockBootstrapData());
|
||||
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('SDK override toggling and dynamic transitions', () => {
|
||||
const controller = createController();
|
||||
|
||||
expect(controller.hasThemeConfigOverride()).toBe(false);
|
||||
|
||||
// Set theme config override
|
||||
const sdkThemeConfig: SupersetThemeConfig = {
|
||||
theme_default: { token: { colorPrimary: '#sdk-default' } },
|
||||
theme_dark: { token: { colorPrimary: '#sdk-dark' } },
|
||||
};
|
||||
controller.setThemeConfig(sdkThemeConfig);
|
||||
expect(controller.hasThemeConfigOverride()).toBe(true);
|
||||
|
||||
// Clear local overrides (which should reset override flag)
|
||||
controller.clearLocalOverrides();
|
||||
expect(controller.hasThemeConfigOverride()).toBe(false);
|
||||
});
|
||||
|
||||
test('ThemeController cleans up injected fonts on destroy', () => {
|
||||
const controller = createController();
|
||||
|
||||
// Inject some fonts
|
||||
(controller as any).loadFonts(['https://fonts.example.com/font-test.css']);
|
||||
|
||||
let fontStyle = document.querySelector('style[data-superset-fonts]');
|
||||
expect(fontStyle).not.toBeNull();
|
||||
|
||||
controller.destroy();
|
||||
|
||||
fontStyle = document.querySelector('style[data-superset-fonts]');
|
||||
expect(fontStyle).toBeNull();
|
||||
});
|
||||
|
||||
test('fallback fetch: uses bootstrap GUEST_TOKEN_HEADER_NAME when guestTokenHeaderName getter throws', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
// SupersetClient.get throws so we fall through to native fetch
|
||||
const mockGet = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('Client not configured'));
|
||||
|
||||
// getGuestToken succeeds (we have a guest token)
|
||||
const mockGetGuestToken = jest
|
||||
.spyOn(SupersetClient, 'getGuestToken')
|
||||
.mockReturnValue('my-guest-token');
|
||||
|
||||
// guestTokenHeaderName getter throws → should fall back to bootstrap config
|
||||
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
|
||||
get: () => {
|
||||
throw new Error('Not configured');
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Return a bootstrap config with a custom header name
|
||||
mockGetBootstrapData.mockReturnValue({
|
||||
...createMockBootstrapData(),
|
||||
config: {
|
||||
GUEST_TOKEN_HEADER_NAME: 'X-Bootstrap-Header',
|
||||
},
|
||||
} as any);
|
||||
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result: [
|
||||
{
|
||||
json_data: JSON.stringify({
|
||||
token: { colorPrimary: '#bootstrap-fallback' },
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
try {
|
||||
const controller = createController();
|
||||
const result = await (controller as any).fetchSystemDefaultTheme();
|
||||
|
||||
// Verify the bootstrap header was used instead of SupersetClient.guestTokenHeaderName
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/theme/'),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-Bootstrap-Header': 'my-guest-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ token: { colorPrimary: '#bootstrap-fallback' } });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
mockGet.mockRestore();
|
||||
mockGetGuestToken.mockRestore();
|
||||
mockGetBootstrapData.mockReturnValue(createMockBootstrapData());
|
||||
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('fetchSystemDefaultTheme: second named-theme fallback fetch succeeds when first API calls fail', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
// SupersetClient.get always throws (not configured)
|
||||
const mockGet = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockRejectedValue(new Error('Client not configured'));
|
||||
|
||||
const namedTheme = { token: { colorPrimary: '#named-theme' } };
|
||||
|
||||
// First fetch call (is_system_default) returns empty result; second (THEME_DEFAULT name) succeeds
|
||||
const mockFetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ result: [] }), // first path: no results
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
result: [{ json_data: JSON.stringify(namedTheme) }],
|
||||
}),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
try {
|
||||
const controller = createController();
|
||||
const result = await (controller as any).fetchSystemDefaultTheme();
|
||||
|
||||
// Both fetches should have been called
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The result should be from the second (named-theme) fallback fetch
|
||||
expect(result).toEqual(namedTheme);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
mockGet.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
14
superset-websocket/package-lock.json
generated
14
superset-websocket/package-lock.json
generated
@@ -23,7 +23,7 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
@@ -1798,9 +1798,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7883,9 +7883,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "25.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
|
||||
@@ -147,24 +147,6 @@ class Theme(AuditMixinNullable, ImportExportMixin, Model):
|
||||
export_fields = ["theme_name", "json_data"]
|
||||
|
||||
|
||||
# Event listeners to clear the memoized bootstrap data cache when a theme is modified
|
||||
@sqla.event.listens_for(Theme, "after_insert")
|
||||
@sqla.event.listens_for(Theme, "after_update")
|
||||
@sqla.event.listens_for(Theme, "after_delete")
|
||||
def clear_bootstrap_cache(
|
||||
_mapper: sqla.orm.Mapper,
|
||||
_connection: sqla.engine.Connection,
|
||||
_target: Theme,
|
||||
) -> None:
|
||||
from superset.extensions import cache_manager
|
||||
from superset.views.base import cached_common_bootstrap_data
|
||||
|
||||
try:
|
||||
cache_manager.cache.delete_memoized(cached_common_bootstrap_data)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
logger.warning("Failed to clear theme bootstrap cache: %s", ex)
|
||||
|
||||
|
||||
class ConfigurationMethod(StrEnum):
|
||||
SQLALCHEMY_FORM = "sqlalchemy_form"
|
||||
DYNAMIC_FORM = "dynamic_form"
|
||||
|
||||
@@ -1556,44 +1556,3 @@ def test_database_execute_async_without_options(mocker: MockerFixture) -> None:
|
||||
mock_executor_class.assert_called_once_with(database)
|
||||
mock_executor.execute_async.assert_called_once_with("SELECT 1", None)
|
||||
assert result == mock_handle
|
||||
|
||||
|
||||
def test_clear_bootstrap_cache_logs_warning_on_failure(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Test that clear_bootstrap_cache logs a warning when cache invalidation fails.
|
||||
|
||||
Exercises the ``except Exception`` branch in the event listener so that
|
||||
Codecov registers it as covered. The function must not re-raise the
|
||||
exception — callers (SQLAlchemy event dispatch) should be unaffected.
|
||||
"""
|
||||
from superset.models.core import clear_bootstrap_cache
|
||||
|
||||
# Patch cache_manager so delete_memoized raises
|
||||
mock_cache = mocker.MagicMock()
|
||||
mock_cache.delete_memoized.side_effect = RuntimeError("Redis unavailable")
|
||||
|
||||
mock_cache_manager = mocker.patch("superset.models.core.cache_manager")
|
||||
mock_cache_manager.cache = mock_cache
|
||||
|
||||
# Patch cached_common_bootstrap_data so the local import inside
|
||||
# clear_bootstrap_cache resolves to our mock.
|
||||
mocker.patch(
|
||||
"superset.views.base.cached_common_bootstrap_data",
|
||||
new=mocker.MagicMock(__name__="cached_common_bootstrap_data"),
|
||||
)
|
||||
|
||||
mock_logger = mocker.patch("superset.models.core.logger")
|
||||
|
||||
# Should not raise even though delete_memoized raises
|
||||
clear_bootstrap_cache(
|
||||
_mapper=mocker.MagicMock(),
|
||||
_connection=mocker.MagicMock(),
|
||||
_target=mocker.MagicMock(),
|
||||
)
|
||||
|
||||
# Verify logger.warning was called with the correct message format
|
||||
mock_logger.warning.assert_called_once()
|
||||
call_args = mock_logger.warning.call_args
|
||||
assert call_args[0][0] == "Failed to clear theme bootstrap cache: %s"
|
||||
|
||||
@@ -886,32 +886,3 @@ class TestGetDefaultSpinnerSvg:
|
||||
assert result is not None
|
||||
assert "<svg" in result
|
||||
assert "morphPath" in result
|
||||
|
||||
|
||||
class TestThemeCacheInvalidation:
|
||||
"""Test theme cache invalidation event listeners"""
|
||||
|
||||
@patch("superset.extensions.cache_manager.cache.delete_memoized")
|
||||
def test_clear_bootstrap_cache_event(self, mock_delete_memoized):
|
||||
"""Test that the event listener triggers delete_memoized"""
|
||||
from superset.models.core import clear_bootstrap_cache
|
||||
from superset.views.base import cached_common_bootstrap_data
|
||||
|
||||
# Call clear_bootstrap_cache with dummy mapper, connection, and Theme
|
||||
clear_bootstrap_cache(MagicMock(), MagicMock(), MagicMock())
|
||||
|
||||
mock_delete_memoized.assert_called_once_with(cached_common_bootstrap_data)
|
||||
|
||||
@patch("superset.extensions.cache_manager.cache.delete_memoized")
|
||||
@patch("superset.models.core.logger")
|
||||
def test_clear_bootstrap_cache_event_error(self, mock_logger, mock_delete_memoized):
|
||||
"""Test that the event listener handles errors gracefully and logs them"""
|
||||
from superset.models.core import clear_bootstrap_cache
|
||||
|
||||
mock_delete_memoized.side_effect = Exception("Cache error")
|
||||
clear_bootstrap_cache(MagicMock(), MagicMock(), MagicMock())
|
||||
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
"Failed to clear theme bootstrap cache: %s",
|
||||
mock_delete_memoized.side_effect,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user