fix(Themes): Local label inconsistent behaviors (#35663)

This commit is contained in:
Geidō
2025-10-21 20:09:33 +02:00
committed by GitHub
parent 47e82b02ed
commit fcfafebb29
6 changed files with 435 additions and 31 deletions

View File

@@ -37,6 +37,7 @@ const STORAGE_KEYS = {
THEME_MODE: 'superset-theme-mode',
CRUD_THEME_ID: 'superset-crud-theme-id',
DEV_THEME_OVERRIDE: 'superset-dev-theme-override',
APPLIED_THEME_ID: 'superset-applied-theme-id',
} as const;
const MEDIA_QUERY_DARK_SCHEME = '(prefers-color-scheme: dark)';
@@ -303,7 +304,12 @@ export class ThemeController {
public setThemeMode(mode: ThemeMode): void {
this.validateModeUpdatePermission(mode);
if (this.currentMode === mode) return;
if (
this.currentMode === mode &&
!this.devThemeOverride &&
!this.crudThemeId
)
return;
// Clear any local overrides when explicitly selecting a theme mode
// This ensures the selected mode takes effect and provides clear UX
@@ -367,8 +373,12 @@ export class ThemeController {
* Sets a temporary theme override for development purposes.
* This does not persist the theme but allows live preview.
* @param theme - The theme configuration to apply temporarily
* @param themeId - Optional theme ID to track which theme was applied (for UI display)
*/
public setTemporaryTheme(theme: AnyThemeConfig): void {
public setTemporaryTheme(
theme: AnyThemeConfig,
themeId?: number | null,
): void {
this.validateThemeUpdatePermission();
this.devThemeOverride = theme;
@@ -377,6 +387,11 @@ export class ThemeController {
JSON.stringify(theme),
);
// Store the theme ID if provided
if (themeId !== undefined) {
this.setAppliedThemeId(themeId);
}
const mergedTheme = this.getThemeForMode(this.currentMode);
if (mergedTheme) this.updateTheme(mergedTheme);
}
@@ -392,6 +407,7 @@ export class ThemeController {
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
// Clear dashboard themes cache
this.dashboardThemes.clear();
@@ -413,6 +429,34 @@ export class ThemeController {
return this.devThemeOverride !== null;
}
/**
* Gets the applied theme ID (for UI display purposes).
*/
public getAppliedThemeId(): number | null {
try {
const storedId = this.storage.getItem(STORAGE_KEYS.APPLIED_THEME_ID);
return storedId ? parseInt(storedId, 10) : null;
} catch (error) {
console.warn('Failed to get applied theme ID:', error);
return null;
}
}
/**
* Sets the applied theme ID (for UI display purposes).
*/
public setAppliedThemeId(themeId: number | null): void {
try {
if (themeId !== null) {
this.storage.setItem(STORAGE_KEYS.APPLIED_THEME_ID, themeId.toString());
} else {
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
}
} catch (error) {
console.warn('Failed to set applied theme ID:', error);
}
}
/**
* Checks if OS preference detection is allowed.
* Allowed when dark theme is available (including base dark theme)

View File

@@ -117,6 +117,11 @@ export function SupersetThemeProvider({
[themeController],
);
const getAppliedThemeId = useCallback(
() => themeController.getAppliedThemeId(),
[themeController],
);
const contextValue = useMemo(
() => ({
theme: currentTheme,
@@ -132,6 +137,7 @@ export function SupersetThemeProvider({
canSetTheme,
canDetectOSPreference,
createDashboardThemeProvider,
getAppliedThemeId,
}),
[
currentTheme,
@@ -147,6 +153,7 @@ export function SupersetThemeProvider({
canSetTheme,
canDetectOSPreference,
createDashboardThemeProvider,
getAppliedThemeId,
],
);

View File

@@ -1137,4 +1137,236 @@ describe('ThemeController', () => {
);
});
});
test('setThemeMode clears dev override and crud theme from storage', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
// Simulate having overrides after initialization using Reflect to access private properties
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
Reflect.set(controller, 'crudThemeId', '123');
jest.clearAllMocks();
// Change theme mode - should clear the overrides
controller.setThemeMode(ThemeMode.DARK);
// Verify both storage keys were removed
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
});
test('setThemeMode can be called with same mode when overrides exist', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
// Simulate having dev override after initialization using Reflect
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
// Call setThemeMode with DEFAULT mode - should clear override
controller.setThemeMode(ThemeMode.DEFAULT);
// Verify override was removed even though mode is the same
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
// Theme should still be updated to clear the override
expect(mockSetConfig).toHaveBeenCalled();
});
test('setThemeMode with no override and same mode does not trigger update', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
// Set mode to DEFAULT
controller.setThemeMode(ThemeMode.DEFAULT);
jest.clearAllMocks();
// Call again with same mode and no override - should skip
controller.setThemeMode(ThemeMode.DEFAULT);
// Should not trigger any updates
expect(mockSetConfig).not.toHaveBeenCalled();
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
});
test('hasDevOverride returns true when dev override is set', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
// Simulate dev override after initialization using Reflect
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
expect(controller.hasDevOverride()).toBe(true);
});
test('hasDevOverride returns false when no dev override in storage', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.hasDevOverride()).toBe(false);
});
test('clearLocalOverrides removes dev override, crud theme, and applied theme ID', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
// Clear overrides
controller.clearLocalOverrides();
// Verify all storage keys are removed
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
// Should reset to default theme
expect(mockSetConfig).toHaveBeenCalled();
});
test('getAppliedThemeId returns stored theme ID', () => {
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'superset-applied-theme-id') {
return '42';
}
return null;
});
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.getAppliedThemeId()).toBe(42);
});
test('getAppliedThemeId returns null when no theme ID is stored', () => {
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.getAppliedThemeId()).toBeNull();
});
test('setAppliedThemeId stores theme ID in storage', () => {
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
controller.setAppliedThemeId(123);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
'123',
);
});
test('setAppliedThemeId removes theme ID when null is passed', () => {
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
controller.setAppliedThemeId(null);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
});
});