blob: c4f297916d82cebc77683c5ec242e92e10164c64 [file] [log] [blame]
/**
* 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 {
type AnyThemeConfig,
type SupersetThemeConfig,
type ThemeControllerOptions,
type ThemeStorage,
isThemeConfigDark,
Theme,
ThemeMode,
themeObject as supersetThemeObject,
} from '@superset-ui/core';
import { normalizeThemeConfig } from '@superset-ui/core/theme/utils';
import type {
BootstrapThemeData,
BootstrapThemeDataConfig,
} from 'src/types/bootstrapTypes';
import getBootstrapData from 'src/utils/getBootstrapData';
const STORAGE_KEYS = {
THEME_MODE: 'superset-theme-mode',
CRUD_THEME_ID: 'superset-crud-theme-id',
DEV_THEME_OVERRIDE: 'superset-dev-theme-override',
} as const;
const MEDIA_QUERY_DARK_SCHEME = '(prefers-color-scheme: dark)';
export class LocalStorageAdapter implements ThemeStorage {
getItem(key: string): string | null {
try {
return localStorage.getItem(key);
} catch (error) {
console.warn('Failed to read from localStorage:', error);
return null;
}
}
setItem(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch (error) {
console.warn('Failed to write to localStorage:', error);
}
}
removeItem(key: string): void {
try {
localStorage.removeItem(key);
} catch (error) {
console.warn('Failed to remove from localStorage:', error);
}
}
}
export class ThemeController {
// The controller owns and manages Theme object lifecycles
private globalTheme: Theme;
private storage: ThemeStorage;
private modeStorageKey: string;
private defaultTheme: AnyThemeConfig | null;
private darkTheme: AnyThemeConfig | null;
private systemMode: ThemeMode.DARK | ThemeMode.DEFAULT;
private currentMode: ThemeMode;
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
private mediaQuery: MediaQueryList;
private crudThemeId: string | null = null;
private devThemeOverride: AnyThemeConfig | null = null;
// Dashboard themes managed by controller
private dashboardThemes: Map<string, Theme> = new Map();
private dashboardCrudTheme: AnyThemeConfig | null = null;
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;
// Controller creates and owns the global theme
this.globalTheme = themeObject;
// Initialize bootstrap data and themes
const { bootstrapDefaultTheme, bootstrapDarkTheme }: BootstrapThemeData =
this.loadBootstrapData();
// Set themes from bootstrap data
// These will be the THEME_DEFAULT and THEME_DARK from config
this.defaultTheme = bootstrapDefaultTheme || defaultTheme || null;
this.darkTheme = bootstrapDarkTheme;
// Initialize system theme detection
this.systemMode = ThemeController.getSystemPreferredMode();
// Only initialize media query listener if OS preference is allowed
if (this.shouldInitializeMediaQueryListener())
this.initializeMediaQueryListener();
// Load CRUD theme and dev override from storage
this.loadCrudThemeId();
this.loadDevThemeOverride();
// Initialize theme and mode
this.currentMode = this.determineInitialMode();
const initialTheme =
this.getThemeForMode(this.currentMode) || this.defaultTheme || {};
// Setup change callback
if (onChange) this.onChangeCallbacks.add(onChange);
// Apply initial theme and persist mode
this.applyTheme(initialTheme);
this.persistMode();
}
// Public Methods
/**
* Cleans up listeners and references. Should be called when the controller is no longer needed.
*/
public destroy(): void {
if (this.mediaQuery)
this.mediaQuery.removeEventListener(
'change',
this.handleSystemThemeChange,
);
this.onChangeCallbacks.clear();
}
/**
* Check if the user can update the theme.
* Always true now - theme enforcement is done via THEME_DARK = None
*/
public canSetTheme(): boolean {
return true;
}
/**
* Check if the user can update the theme mode.
* Only possible if dark theme is available
*/
public canSetMode(): boolean {
return this.darkTheme !== null;
}
/**
* Returns the current global theme object.
*/
public getTheme(): Theme {
return this.globalTheme;
}
/**
* Gets the theme configuration for a specific context (global vs dashboard).
* Dashboard themes are always merged with base theme.
* @param forDashboard - Whether to get the dashboard theme or global theme
* @returns The theme configuration for the specified context
*/
public getThemeForContext(
forDashboard: boolean = false,
): AnyThemeConfig | null {
// For dashboard context, prioritize dashboard CRUD theme
if (forDashboard && this.dashboardCrudTheme) {
// Dashboard CRUD themes should be merged with base theme
const normalizedTheme = this.normalizeTheme(this.dashboardCrudTheme);
const isDarkMode = isThemeConfigDark(normalizedTheme);
const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
if (baseTheme) {
const mergedTheme = Theme.fromConfig(normalizedTheme, baseTheme);
return mergedTheme.toSerializedConfig();
}
return normalizedTheme;
}
// For global context or when no dashboard theme, use mode-based theme
return this.getThemeForMode(this.currentMode);
}
/**
* Creates a theme provider for a specific dashboard theme.
* The controller manages dashboard theme lifecycles - creates them on demand
* and caches them for reuse.
* @param themeId - The dashboard theme ID to create provider for
* @returns A theme object configured for the dashboard theme
*/
public async createDashboardThemeProvider(
themeId: string,
): Promise<Theme | null> {
try {
// Check if we already have this dashboard theme cached
if (this.dashboardThemes.has(themeId)) {
return this.dashboardThemes.get(themeId)!;
}
// Fetch theme config from API
const response = await fetch(`/api/v1/theme/${themeId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const themeConfig = JSON.parse(data.result.json_data);
if (themeConfig) {
// Controller creates and owns the dashboard theme
const { Theme } = await import('@superset-ui/core');
const normalizedConfig = this.normalizeTheme(themeConfig);
// Determine if this is a dark theme and get appropriate base
const isDarkMode = isThemeConfigDark(normalizedConfig);
const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
const dashboardTheme = Theme.fromConfig(
normalizedConfig,
baseTheme || undefined,
);
// Cache the theme for reuse
this.dashboardThemes.set(themeId, dashboardTheme);
return dashboardTheme;
}
return null;
} catch (error) {
console.error('Failed to create dashboard theme provider:', error);
return null;
}
}
/**
* Clears a cached dashboard theme when no longer needed.
* @param themeId - The dashboard theme ID to clear
*/
public clearDashboardTheme(themeId: string): void {
this.dashboardThemes.delete(themeId);
}
/**
* Clears all cached dashboard themes.
*/
public clearAllDashboardThemes(): void {
this.dashboardThemes.clear();
}
/**
* Returns the current theme mode.
*/
public getCurrentMode(): ThemeMode {
return this.currentMode;
}
/**
* Sets new theme.
* @param theme - The new theme to apply
* @throws {Error} If the user does not have permission to update the theme
*/
public setTheme(theme: AnyThemeConfig): void {
this.validateThemeUpdatePermission();
const normalizedTheme = this.normalizeTheme(theme);
this.currentMode = this.determineInitialMode();
this.updateTheme(normalizedTheme);
}
/**
* Sets the theme mode (light, dark, or system).
* @param mode - The new theme mode to apply
* @throws {Error} If the user does not have permission to update the theme mode
*/
public setThemeMode(mode: ThemeMode): void {
this.validateModeUpdatePermission(mode);
if (this.currentMode === mode) return;
// Clear any local overrides when explicitly selecting a theme mode
// This ensures the selected mode takes effect and provides clear UX
this.devThemeOverride = null;
this.crudThemeId = null;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
const theme: AnyThemeConfig | null = this.getThemeForMode(mode);
if (!theme) {
console.warn(`Theme for mode ${mode} not found, falling back to default`);
this.fallbackToDefaultMode();
return;
}
this.currentMode = mode;
this.updateTheme(theme);
}
/**
* Resets the theme to the default theme.
*/
public resetTheme(): void {
this.currentMode = ThemeMode.DEFAULT;
const defaultTheme: AnyThemeConfig =
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
this.updateTheme(defaultTheme);
}
/**
* Sets a CRUD theme by ID. This will fetch the theme from the API and cache it for dashboard contexts.
* @param themeId - The ID of the CRUD theme to apply
*/
public async setCrudTheme(themeId: string | null): Promise<void> {
this.crudThemeId = themeId;
if (themeId) {
this.storage.setItem(STORAGE_KEYS.CRUD_THEME_ID, themeId);
try {
const themeConfig = await this.fetchCrudTheme(themeId);
if (themeConfig) {
// Cache the dashboard theme but don't apply it globally
this.dashboardCrudTheme = themeConfig;
// Notify listeners that theme data has changed
this.notifyListeners();
}
} catch (error) {
console.error('Failed to load CRUD theme:', error);
this.dashboardCrudTheme = null;
this.notifyListeners();
}
} else {
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
this.dashboardCrudTheme = null;
this.notifyListeners();
}
}
/**
* 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
*/
public setTemporaryTheme(theme: AnyThemeConfig): void {
this.validateThemeUpdatePermission();
this.devThemeOverride = theme;
this.storage.setItem(
STORAGE_KEYS.DEV_THEME_OVERRIDE,
JSON.stringify(theme),
);
const mergedTheme = this.getThemeForMode(this.currentMode);
if (mergedTheme) this.updateTheme(mergedTheme);
}
/**
* Clears all local overrides and CRUD theme selections.
* This allows developers to see what regular users see.
*/
public clearLocalOverrides(): void {
this.devThemeOverride = null;
this.crudThemeId = null;
this.dashboardCrudTheme = null;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
// Clear dashboard themes cache
this.dashboardThemes.clear();
this.resetTheme();
}
/**
* Gets the current CRUD theme ID if any is selected.
*/
public getCurrentCrudThemeId(): string | null {
return this.crudThemeId;
}
/**
* Checks if there's a development theme override active.
*/
public hasDevOverride(): boolean {
return this.devThemeOverride !== null;
}
/**
* Checks if OS preference detection is allowed.
* Allowed when dark theme is available (including base dark theme)
*/
public canDetectOSPreference(): boolean {
return this.darkTheme !== null;
}
/**
* 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;
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.
*/
private handleSystemThemeChange = (): void => {
try {
const newSystemMode: ThemeMode.DEFAULT | ThemeMode.DARK =
ThemeController.getSystemPreferredMode();
// Update systemMode regardless of current mode
const oldSystemMode: ThemeMode.DEFAULT | ThemeMode.DARK = this.systemMode;
this.systemMode = newSystemMode;
// Only update theme if currently in SYSTEM mode and the preference changed
if (
this.currentMode === ThemeMode.SYSTEM &&
oldSystemMode !== newSystemMode
) {
const newTheme: AnyThemeConfig | null = this.getThemeForMode(
ThemeMode.SYSTEM,
);
if (newTheme) this.updateTheme(newTheme);
}
} catch (error) {
console.error('Failed to handle system theme change:', error);
}
};
/**
* Updates the theme.
* @param theme - The new theme to apply
*/
private updateTheme(theme?: AnyThemeConfig): void {
try {
// If no config provided, use current mode to get theme
if (!theme) {
// No theme provided, use the current mode's theme
const modeTheme =
this.getThemeForMode(this.currentMode) || this.defaultTheme || {};
this.applyTheme(modeTheme);
} else {
// Theme provided, apply it directly
this.applyTheme(theme);
}
this.persistMode();
this.notifyListeners();
} catch (error) {
console.error('Failed to update theme:', error);
this.fallbackToDefaultMode();
}
}
/**
* Fallback to default mode with error recovery.
*/
private fallbackToDefaultMode(): void {
this.currentMode = ThemeMode.DEFAULT;
// Get the default theme which will have the correct algorithm
const defaultTheme: AnyThemeConfig =
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
this.applyTheme(defaultTheme);
this.persistMode();
this.notifyListeners();
}
/**
* Registers a callback to be called when the theme changes.
* @param callback - The callback to be called on theme change
* @returns A function to unsubscribe from the theme change events
*/
public onChange(callback: (theme: Theme) => void): () => void {
this.onChangeCallbacks.add(callback);
return () => this.onChangeCallbacks.delete(callback);
}
// Private Helper Methods
/**
* Determines whether the MediaQueryList listener for system theme changes should be initialized.
* This checks if both themes are available to enable OS preference detection.
* @returns {boolean} True if the media query listener should be initialized, false otherwise
*/
private shouldInitializeMediaQueryListener(): boolean {
return this.darkTheme !== null;
}
/**
* Initializes media query listeners if OS preference is allowed
*/
private initializeMediaQueryListener(): void {
try {
this.mediaQuery = window.matchMedia(MEDIA_QUERY_DARK_SCHEME);
this.mediaQuery.addEventListener('change', this.handleSystemThemeChange);
} catch (error) {
console.warn('Failed to initialize media query listener:', error);
}
}
/**
* Loads and validates bootstrap theme data.
*/
private loadBootstrapData(): BootstrapThemeData {
const {
common: { theme = {} as BootstrapThemeDataConfig },
} = getBootstrapData();
const { default: defaultTheme, dark: darkTheme } = theme;
const hasValidDefault: boolean = this.isNonEmptyObject(defaultTheme);
const hasValidDark: boolean = this.isNonEmptyObject(darkTheme);
// Check if themes have actual custom tokens (not just empty or algorithm-only)
const hasCustomDefault =
hasValidDefault && !this.isEmptyTheme(defaultTheme);
const hasCustomDark = hasValidDark && !this.isEmptyTheme(darkTheme);
return {
bootstrapDefaultTheme: hasCustomDefault ? defaultTheme : null,
bootstrapDarkTheme: hasCustomDark ? darkTheme : null,
hasCustomThemes: hasCustomDefault || hasCustomDark,
};
}
/**
* Checks if an object is non-empty (has at least one property).
*/
private isNonEmptyObject(
obj: Record<string, any> | undefined | null,
): boolean {
return Boolean(
obj && typeof obj === 'object' && Object.keys(obj).length > 0,
);
}
/**
* Checks if a theme is truly empty (not even an algorithm).
* A theme with just an algorithm is still valid and should be used.
*/
private isEmptyTheme(theme: AnyThemeConfig | undefined): boolean {
if (!theme) return true;
return !(
theme.algorithm ||
(theme.token && Object.keys(theme.token).length > 0) ||
(theme.components && Object.keys(theme.components).length > 0)
);
}
/**
* Normalizes the theme configuration to ensure it has a valid algorithm.
* @param theme - The theme configuration to normalize
* @returns An object with normalized mode and algorithm.
*/
private normalizeTheme(theme: AnyThemeConfig): AnyThemeConfig {
const normalizedTheme = normalizeThemeConfig(theme);
return normalizedTheme;
}
/**
* Returns the appropriate theme configuration for a given mode.
* @param mode - The theme mode to get the configuration for
* @returns The theme configuration for the specified mode or null if not available
*/
private getThemeForMode(mode: ThemeMode): AnyThemeConfig | null {
if (this.devThemeOverride) {
const normalizedOverride = this.normalizeTheme(this.devThemeOverride);
const isDarkMode = isThemeConfigDark(normalizedOverride);
const baseTheme = isDarkMode ? this.darkTheme : this.defaultTheme;
if (baseTheme) {
const mergedTheme = Theme.fromConfig(normalizedOverride, baseTheme);
return mergedTheme.toSerializedConfig();
}
return normalizedOverride;
}
let resolvedMode: ThemeMode = mode;
if (mode === ThemeMode.SYSTEM) {
if (this.darkTheme === null) return null;
resolvedMode = ThemeController.getSystemPreferredMode();
}
if (resolvedMode === ThemeMode.DARK) return this.darkTheme;
return this.defaultTheme;
}
/**
* Determines the initial theme mode with error recovery.
*/
private determineInitialMode(): ThemeMode {
// Try to restore saved mode first
const savedMode: ThemeMode | null = this.loadSavedMode();
if (savedMode && this.isValidThemeMode(savedMode)) return savedMode;
// If no dark theme is available, force default mode
if (this.darkTheme === null) {
this.storage.removeItem(this.modeStorageKey);
return ThemeMode.DEFAULT;
}
// Default to system preference when both themes are available
return ThemeMode.SYSTEM;
}
/**
* Safely loads saved theme mode from storage.
*/
private loadSavedMode(): ThemeMode | null {
try {
const stored: string | null = this.storage.getItem(this.modeStorageKey);
if (stored && Object.values(ThemeMode).includes(stored as ThemeMode))
return stored as ThemeMode;
return null;
} catch (error) {
console.warn('Failed to load saved theme mode:', error);
return null;
}
}
/**
* Validates if a theme mode is valid and supported.
* This checks if the mode is one of the known ThemeMode values.
* @param mode - The theme mode to validate
* @returns {boolean} True if the mode is valid, false otherwise
*/
private isValidThemeMode(mode: ThemeMode): boolean {
if (!Object.values(ThemeMode).includes(mode)) return false;
// Validate that we have the required theme data for the mode
switch (mode) {
case ThemeMode.DARK:
// Dark mode is valid if we have a dark theme
return !!this.darkTheme;
case ThemeMode.DEFAULT:
// Default mode is valid if we have a default theme
return !!this.defaultTheme;
case ThemeMode.SYSTEM:
// System mode is valid if dark mode is available
return !!this.darkTheme;
default:
return true;
}
}
/**
* Validates permission to update theme.
*/
private validateThemeUpdatePermission(): void {
if (!this.canSetTheme())
throw new Error('User does not have permission to update the theme');
}
/**
* Validates permission to update mode.
* @param newMode - The new mode to validate
* @throws {Error} If the user does not have permission to update the theme mode
*/
private validateModeUpdatePermission(newMode: ThemeMode): void {
// Check if user can set a new theme mode (dark theme must exist)
if (!this.canSetMode())
throw new Error(
'Theme mode changes are not allowed when only one theme is available',
);
}
/**
* Applies the current theme configuration to the global theme.
* This method sets the theme on the globalTheme and applies it to the Theme.
* It also handles any errors that may occur during the application of the theme.
* @param theme - The theme configuration to apply (may already include base theme tokens)
*/
private applyTheme(theme: AnyThemeConfig): void {
try {
const normalizedConfig = normalizeThemeConfig(theme);
// Simply apply the theme - it should already be properly merged if needed
// The merging with base theme happens in getThemeForMode() and other methods
// that prepare themes before passing them to applyTheme()
this.globalTheme.setConfig(normalizedConfig);
} catch (error) {
console.error('Failed to apply theme:', error);
this.fallbackToDefaultMode();
}
}
/**
* Persists the current theme mode to storage.
*/
private persistMode(): void {
try {
this.storage.setItem(this.modeStorageKey, this.currentMode);
} catch (error) {
console.warn('Failed to persist theme mode:', error);
}
}
/**
* Notifies all registered listeners about global theme changes.
*/
private notifyListeners(): void {
this.onChangeCallbacks.forEach(callback => {
try {
callback(this.globalTheme);
} catch (error) {
console.error('Error in theme change callback:', error);
}
});
}
/**
* Gets the system's preferred theme mode.
* @returns {ThemeMode.DARK | ThemeMode.DEFAULT} The system's preferred theme mode
*/
static getSystemPreferredMode(): ThemeMode.DARK | ThemeMode.DEFAULT {
try {
return window.matchMedia(MEDIA_QUERY_DARK_SCHEME).matches
? ThemeMode.DARK
: ThemeMode.DEFAULT;
} catch (error) {
console.warn('Failed to detect system theme preference:', error);
return ThemeMode.DEFAULT;
}
}
/**
* Loads the saved CRUD theme ID from storage.
*/
private loadCrudThemeId(): void {
try {
this.crudThemeId = this.storage.getItem(STORAGE_KEYS.CRUD_THEME_ID);
} catch (error) {
console.warn('Failed to load CRUD theme ID:', error);
this.crudThemeId = null;
}
}
/**
* Loads the saved development theme override from storage.
*/
private loadDevThemeOverride(): void {
try {
const stored = this.storage.getItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
if (stored) {
this.devThemeOverride = JSON.parse(stored);
}
} catch (error) {
console.warn('Failed to load dev theme override:', error);
this.devThemeOverride = null;
}
}
/**
* Fetches a theme configuration from the CRUD API.
* @param themeId - The ID of the theme to fetch
* @returns The theme configuration or null if not found
*/
private async fetchCrudTheme(
themeId: string,
): Promise<AnyThemeConfig | null> {
try {
const response = await fetch(`/api/v1/theme/${themeId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const themeConfig = JSON.parse(data.result.json_data);
return themeConfig;
} catch (error) {
console.error('Failed to fetch CRUD theme:', error);
return null;
}
}
}