mirror of
https://github.com/apache/superset.git
synced 2026-05-12 19:35:17 +00:00
feat(theming): Align embedded sdk with theme configs (#34273)
This commit is contained in:
committed by
GitHub
parent
ff76ab647f
commit
bb572983cd
@@ -17,13 +17,15 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
type AnyThemeConfig,
|
||||
type SupersetTheme,
|
||||
type SupersetThemeConfig,
|
||||
type ThemeControllerOptions,
|
||||
type ThemeStorage,
|
||||
Theme,
|
||||
AnyThemeConfig,
|
||||
ThemeStorage,
|
||||
ThemeControllerOptions,
|
||||
ThemeMode,
|
||||
themeObject as supersetThemeObject,
|
||||
} from '@superset-ui/core';
|
||||
import { SupersetTheme, ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import {
|
||||
getAntdConfig,
|
||||
normalizeThemeConfig,
|
||||
@@ -94,7 +96,7 @@ export class ThemeController {
|
||||
|
||||
private currentMode: ThemeMode;
|
||||
|
||||
private readonly hasBootstrapThemes: boolean;
|
||||
private hasCustomThemes: boolean;
|
||||
|
||||
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
|
||||
|
||||
@@ -109,15 +111,13 @@ export class ThemeController {
|
||||
|
||||
private dashboardCrudTheme: AnyThemeConfig | null = null;
|
||||
|
||||
constructor(options: ThemeControllerOptions = {}) {
|
||||
const {
|
||||
storage = new LocalStorageAdapter(),
|
||||
modeStorageKey = STORAGE_KEYS.THEME_MODE,
|
||||
themeObject = supersetThemeObject,
|
||||
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
|
||||
onChange = null,
|
||||
} = options;
|
||||
|
||||
constructor({
|
||||
storage = new LocalStorageAdapter(),
|
||||
modeStorageKey = STORAGE_KEYS.THEME_MODE,
|
||||
themeObject = supersetThemeObject,
|
||||
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
|
||||
onChange = undefined,
|
||||
}: ThemeControllerOptions = {}) {
|
||||
this.storage = storage;
|
||||
this.modeStorageKey = modeStorageKey;
|
||||
|
||||
@@ -129,14 +129,14 @@ export class ThemeController {
|
||||
bootstrapDefaultTheme,
|
||||
bootstrapDarkTheme,
|
||||
bootstrapThemeSettings,
|
||||
hasBootstrapThemes,
|
||||
hasCustomThemes,
|
||||
}: BootstrapThemeData = this.loadBootstrapData();
|
||||
|
||||
this.hasBootstrapThemes = hasBootstrapThemes;
|
||||
this.hasCustomThemes = hasCustomThemes;
|
||||
this.themeSettings = bootstrapThemeSettings || {};
|
||||
|
||||
// Set themes based on bootstrap data availability
|
||||
if (this.hasBootstrapThemes) {
|
||||
if (this.hasCustomThemes) {
|
||||
this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null;
|
||||
this.defaultTheme =
|
||||
bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme;
|
||||
@@ -424,6 +424,42 @@ export class ThemeController {
|
||||
return allowOSPreference === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an entire new theme configuration, replacing all existing theme data and settings.
|
||||
* This method is designed for use cases like embedded dashboards where themes are provided
|
||||
* dynamically from external sources.
|
||||
* @param config - The complete theme configuration object
|
||||
*/
|
||||
public setThemeConfig(config: SupersetThemeConfig): void {
|
||||
this.defaultTheme = config.theme_default;
|
||||
this.darkTheme = config.theme_dark || null;
|
||||
this.hasCustomThemes = true;
|
||||
|
||||
this.themeSettings = {
|
||||
enforced: config.theme_settings?.enforced ?? false,
|
||||
allowSwitching: config.theme_settings?.allowSwitching ?? true,
|
||||
allowOSPreference: config.theme_settings?.allowOSPreference ?? true,
|
||||
};
|
||||
|
||||
let newMode: ThemeMode;
|
||||
try {
|
||||
this.validateModeUpdatePermission(this.currentMode);
|
||||
const hasRequiredTheme = this.isValidThemeMode(this.currentMode);
|
||||
newMode = hasRequiredTheme
|
||||
? this.currentMode
|
||||
: this.determineInitialMode();
|
||||
} catch {
|
||||
newMode = this.determineInitialMode();
|
||||
}
|
||||
|
||||
this.currentMode = newMode;
|
||||
|
||||
const themeToApply =
|
||||
this.getThemeForMode(this.currentMode) || this.defaultTheme;
|
||||
|
||||
this.updateTheme(themeToApply);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles system theme changes with error recovery.
|
||||
*/
|
||||
@@ -547,7 +583,7 @@ export class ThemeController {
|
||||
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
|
||||
bootstrapDarkTheme: hasValidDark ? darkTheme : null,
|
||||
bootstrapThemeSettings: hasValidSettings ? themeSettings : null,
|
||||
hasBootstrapThemes: hasValidDefault || hasValidDark,
|
||||
hasCustomThemes: hasValidDefault || hasValidDark,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -607,7 +643,7 @@ export class ThemeController {
|
||||
resolvedMode = ThemeController.getSystemPreferredMode();
|
||||
}
|
||||
|
||||
if (!this.hasBootstrapThemes) {
|
||||
if (!this.hasCustomThemes) {
|
||||
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
|
||||
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Theme, AnyThemeConfig, ThemeContextType } from '@superset-ui/core';
|
||||
import { ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import {
|
||||
type AnyThemeConfig,
|
||||
type ThemeContextType,
|
||||
Theme,
|
||||
ThemeMode,
|
||||
} from '@superset-ui/core';
|
||||
import { ThemeController } from './ThemeController';
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
@@ -17,12 +17,17 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { theme as antdThemeImport } from 'antd';
|
||||
import { Theme } from '@superset-ui/core';
|
||||
import {
|
||||
type AnyThemeConfig,
|
||||
type SupersetThemeConfig,
|
||||
Theme,
|
||||
ThemeAlgorithm,
|
||||
ThemeMode,
|
||||
} from '@superset-ui/core';
|
||||
import type {
|
||||
BootstrapThemeDataConfig,
|
||||
CommonBootstrapData,
|
||||
} from 'src/types/bootstrapTypes';
|
||||
import { ThemeAlgorithm, ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { LocalStorageAdapter, ThemeController } from '../ThemeController';
|
||||
|
||||
@@ -43,7 +48,7 @@ const mockThemeFromConfig = jest.fn();
|
||||
const mockSetConfig = jest.fn();
|
||||
|
||||
// Mock data constants
|
||||
const DEFAULT_THEME = {
|
||||
const DEFAULT_THEME: AnyThemeConfig = {
|
||||
token: {
|
||||
colorBgBase: '#ededed',
|
||||
colorTextBase: '#120f0f',
|
||||
@@ -55,7 +60,7 @@ const DEFAULT_THEME = {
|
||||
},
|
||||
};
|
||||
|
||||
const DARK_THEME = {
|
||||
const DARK_THEME: AnyThemeConfig = {
|
||||
token: {
|
||||
colorBgBase: '#141118',
|
||||
colorTextBase: '#fdc7c7',
|
||||
@@ -65,7 +70,7 @@ const DARK_THEME = {
|
||||
colorSuccess: '#3c7c1b',
|
||||
colorWarning: '#dc9811',
|
||||
},
|
||||
algorithm: ThemeMode.DARK,
|
||||
algorithm: ThemeAlgorithm.DARK,
|
||||
};
|
||||
|
||||
const THEME_SETTINGS = {
|
||||
@@ -1049,4 +1054,298 @@ describe('ThemeController', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setThemeConfig', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBootstrapData.mockReturnValue(
|
||||
createMockBootstrapData({
|
||||
default: {},
|
||||
dark: {},
|
||||
settings: {},
|
||||
}),
|
||||
);
|
||||
|
||||
controller = new ThemeController({
|
||||
themeObject: mockThemeObject,
|
||||
defaultTheme: { token: {} },
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should set complete theme configuration', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: false,
|
||||
allowSwitching: true,
|
||||
allowOSPreference: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
|
||||
expect(controller.canSetTheme()).toBe(true);
|
||||
expect(controller.canSetMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle theme_default only', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(controller.canSetTheme()).toBe(true);
|
||||
expect(controller.canSetMode()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle theme_default and theme_dark without settings', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DARK_THEME.token),
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle enforced theme settings', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: true,
|
||||
allowSwitching: false,
|
||||
allowOSPreference: false,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.canSetTheme()).toBe(false);
|
||||
expect(controller.canSetMode()).toBe(false);
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
|
||||
|
||||
expect(() => {
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
}).toThrow('User does not have permission to update the theme mode');
|
||||
});
|
||||
|
||||
it('should handle allowOSPreference: false setting', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: false,
|
||||
allowSwitching: true,
|
||||
allowOSPreference: false,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
|
||||
expect(controller.canSetMode()).toBe(true);
|
||||
|
||||
expect(() => {
|
||||
controller.setThemeMode(ThemeMode.SYSTEM);
|
||||
}).toThrow('System theme mode is not allowed');
|
||||
});
|
||||
|
||||
it('should re-determine initial mode based on new settings', () => {
|
||||
mockMatchMedia.mockReturnValue({
|
||||
matches: true,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
});
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
enforced: false,
|
||||
allowSwitching: false,
|
||||
allowOSPreference: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
|
||||
expect(controller.canSetMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply appropriate theme after configuration', () => {
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
jest.clearAllMocks();
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: {
|
||||
token: {
|
||||
colorPrimary: '#00ff00',
|
||||
},
|
||||
},
|
||||
theme_dark: {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
colorBgBase: '#000000',
|
||||
},
|
||||
algorithm: 'dark',
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig as SupersetThemeConfig);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining({
|
||||
colorPrimary: '#ff0000',
|
||||
colorBgBase: '#000000',
|
||||
}),
|
||||
algorithm: antdThemeImport.darkAlgorithm,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing theme_dark gracefully', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_settings: {
|
||||
allowSwitching: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
jest.clearAllMocks();
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
|
||||
expect(mockSetConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining(DEFAULT_THEME.token),
|
||||
algorithm: antdThemeImport.defaultAlgorithm,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing theme mode when possible', () => {
|
||||
controller.setThemeMode(ThemeMode.DARK);
|
||||
const initialMode = controller.getCurrentMode();
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
theme_settings: {
|
||||
allowSwitching: true,
|
||||
allowOSPreference: false,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.getCurrentMode()).toBe(initialMode);
|
||||
});
|
||||
|
||||
it('should trigger onChange callbacks', () => {
|
||||
const changeCallback = jest.fn();
|
||||
controller.onChange(changeCallback);
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(changeCallback).toHaveBeenCalledTimes(1);
|
||||
expect(changeCallback).toHaveBeenCalledWith(mockThemeObject);
|
||||
});
|
||||
|
||||
it('should handle partial theme_settings', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_settings: {
|
||||
enforced: true,
|
||||
},
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(controller.canSetTheme()).toBe(false);
|
||||
expect(controller.canSetMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error in theme application', () => {
|
||||
mockSetConfig.mockImplementationOnce(() => {
|
||||
throw new Error('Theme application error');
|
||||
});
|
||||
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
controller.setThemeConfig(themeConfig);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to apply theme:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update stored theme mode', () => {
|
||||
const themeConfig = {
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
};
|
||||
|
||||
controller.setThemeConfig(themeConfig);
|
||||
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'superset-theme-mode',
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
import { Theme } from '@superset-ui/core';
|
||||
import { ThemeContextType, ThemeMode } from '@superset-ui/core/theme/types';
|
||||
import { type ThemeContextType, Theme, ThemeMode } from '@superset-ui/core';
|
||||
import { act, render, screen } from '@superset-ui/core/spec';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider';
|
||||
|
||||
Reference in New Issue
Block a user