feat(theming): Align embedded sdk with theme configs (#34273)

This commit is contained in:
Gabriel Torres Ruiz
2025-07-28 23:26:17 -03:00
committed by GitHub
parent ff76ab647f
commit bb572983cd
16 changed files with 988 additions and 186 deletions

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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),
);
});
});
});

View File

@@ -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';