Compare commits

...

5 Commits

Author SHA1 Message Date
Maxime Beauchemin
19669a3f91 fix / improve types 2025-06-24 19:14:27 -07:00
Maxime Beauchemin
175b696d68 docs 2025-06-24 13:52:42 -07:00
Maxime Beauchemin
7ace5f1737 adjust the ThemeController 2025-06-24 13:49:26 -07:00
Maxime Beauchemin
312f6c5ed6 move theme loading from bootstrapData in the ThemeController 2025-06-24 13:44:45 -07:00
Maxime Beauchemin
79eddd4807 feat(theming): support user OS-level dark/light mode configuration 2025-06-24 13:24:54 -07:00
6 changed files with 95 additions and 22 deletions

View File

@@ -24,6 +24,7 @@ import {
ClientErrorObject,
} from '@superset-ui/core';
import setupErrorMessages from 'src/setup/setupErrorMessages';
import parseCookie from 'src/utils/parseCookie';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
declare global {
@@ -59,6 +60,22 @@ function toggleCheckbox(apiUrlPrefix: string, selector: string) {
);
}
function syncBrowserThemePreferenceWithCookie() {
try {
const currentPreference = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
const cookies = parseCookie();
if (cookies.superset_theme !== currentPreference) {
document.cookie = `superset_theme=${currentPreference}; path=/; SameSite=Lax; secure`;
}
} catch (err) {
console.warn('Failed to sync theme preference', err);
}
}
export default function setupApp() {
$(document).ready(function () {
$(':checkbox[data-checkbox-api-prefix]').change(function (
@@ -94,6 +111,9 @@ export default function setupApp() {
window.$ = $;
window.jQuery = $;
// set up the OS-level preference around light/dark mode
syncBrowserThemePreferenceWithCookie();
// set up app wide custom error messages
setupErrorMessages();
}

View File

@@ -25,6 +25,7 @@ import {
themeObject,
} from '@superset-ui/core';
import { ThemeMode } from '@superset-ui/core/theme/types';
import getBootstrapData from 'src/utils/getBootstrapData';
export class LocalStorageAdapter implements ThemeStorage {
getItem(key: string): string | null {
@@ -70,7 +71,13 @@ export class ThemeController {
this.storageKey = options.storageKey || 'superset-theme';
this.modeStorageKey = options.modeStorageKey || `${this.storageKey}-mode`;
this.defaultTheme = options.defaultTheme || {};
this.themeObject = options.themeObject;
// Load themeObject — either passed in or auto-loaded from bootstrap
if (options.themeObject) {
this.themeObject = options.themeObject;
} else {
this.themeObject = ThemeController.getThemeFromBootstrapData();
}
// Load customizations from storage
const savedThemeJson = this.storage.getItem(this.storageKey);
@@ -103,8 +110,32 @@ export class ThemeController {
}
/**
* Cleans up listeners. Should be called when the controller is no longer needed.
* Load theme object directly from bootstrapData if not provided explicitly
* Note that there is special logic/handling to support getting the first request
* where the backend doesn't know about user preferences yet.
* In that case, since the backend doesn't know about the user preferences,
* it will return both themes to the back as part of the bootstrap data, leaving
* the decision to the frontend to pick the right one under `bootstrapData.themes`
* once the backend knows about the user preferences, it will return only `bootstrapData.theme`
* which takes precedence over `bootstrapData.themes` (not available in this case)
*/
private static getThemeFromBootstrapData(): Theme {
const bootstrapData = getBootstrapData().common;
let themeConfig: AnyThemeConfig = {};
if (bootstrapData.theme) {
themeConfig = bootstrapData.theme;
} else if (bootstrapData.themes) {
const systemMode = ThemeController.getSystemMode();
const preferred = systemMode;
if (bootstrapData.themes[preferred]) {
themeConfig = bootstrapData.themes[preferred];
}
}
return Theme.fromConfig(themeConfig);
}
public destroy(): void {
this.mediaQuery.removeEventListener('change', this.handleSystemThemeChange);
}
@@ -138,7 +169,6 @@ export class ThemeController {
if (!newCustomizations.algorithm) {
this.currentMode = ThemeMode.LIGHT;
}
if (newCustomizations?.algorithm) {
this.currentMode = newCustomizations.algorithm as ThemeMode;
}
@@ -184,7 +214,6 @@ export class ThemeController {
if (this.systemMode === newSystemMode) return;
this.systemMode = newSystemMode;
// If the current mode is SYSTEM, we need to re-apply the theme
if (this.currentMode === ThemeMode.SYSTEM) {
this.applyTheme();
this.notifyListeners();
@@ -221,7 +250,6 @@ export class ThemeController {
private persist(): void {
this.storage.setItem(this.modeStorageKey, this.currentMode);
const { algorithm, ...persistedCustomizations } = this.customizations;
this.storage.setItem(
this.storageKey,

View File

@@ -11,6 +11,7 @@ import { TimeLocaleDefinition } from 'd3-time-format';
import { isPlainObject } from 'lodash';
import { Languages } from 'src/features/home/LanguagePicker';
import type { FlashMessage } from 'src/components';
import type { SerializableThemeConfig } from '@superset-ui/core/theme/types';
/**
* Licensed to the Apache Software Foundation (ASF) under one
@@ -153,7 +154,12 @@ export interface CommonBootstrapData {
language_pack: LanguagePack;
extra_categorical_color_schemes: ColorSchemeConfig[];
extra_sequential_color_schemes: SequentialSchemeConfig[];
theme: JsonObject;
// Depending on if the cookie is set, either theme or themes will be defined.
theme?: SerializableThemeConfig;
themes?: {
light: SerializableThemeConfig;
dark: SerializableThemeConfig;
};
menu_data: MenuData;
d3_format: Partial<FormatLocaleDefinition>;
d3_time_format: Partial<TimeLocaleDefinition>;

View File

@@ -18,7 +18,7 @@
*/
import { Route } from 'react-router-dom';
import { getExtensionsRegistry, themeObject } from '@superset-ui/core';
import { getExtensionsRegistry } from '@superset-ui/core';
import { Provider as ReduxProvider } from 'react-redux';
import { QueryParamProvider } from 'use-query-params';
import { DndProvider } from 'react-dnd';
@@ -33,10 +33,7 @@ import '../preamble';
const { common } = getBootstrapData();
if (Object.keys(common?.theme || {}).length > 0) {
themeObject.setConfig(common.theme);
}
const themeController = new ThemeController({ themeObject });
const themeController = new ThemeController();
const extensionsRegistry = getExtensionsRegistry();
export const RootContextProviders: React.FC = ({ children }) => {

View File

@@ -563,12 +563,12 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Adds a switch to the navbar to easily switch between light and dark themes.
# This is intended to use for development, visual review, and theming-debugging
# purposes.
"THEME_ENABLE_DARK_THEME_SWITCH": False,
"THEME_ENABLE_DARK_THEME_SWITCH": True,
# Adds a theme editor as a modal dialog in the navbar. Allows people to type in JSON
# and see the changes applied to the current theme.
# This is intended to use for theme creation, visual review and theming-debugging
# purposes.
"THEME_ALLOW_THEME_EDITOR_BETA": False,
"THEME_ALLOW_THEME_EDITOR_BETA": True,
# Allow users to optionally specify date formats in email subjects, which will
# be parsed if enabled
"DATE_FORMAT_IN_EMAIL_SUBJECT": False,
@@ -669,17 +669,17 @@ COMMON_BOOTSTRAP_OVERRIDES_FUNC: Callable[ # noqa: E731
# This is merely a default
EXTRA_CATEGORICAL_COLOR_SCHEMES: list[dict[str, Any]] = []
# THEME is used for setting a custom theme to Superset, it follows the ant design
# theme structure
# THEME and THEME_DARK are used for setting a custom theme to Superset,
# it follows the ant design theme structure.
# You can use the AntDesign theme editor to generate a theme structure
# https://ant.design/theme-editor
# To expose a JSON theme editor modal that can be triggered from the navbar
# set the `ENABLE_THEME_EDITOR` feature flag to True.
#
# To set up the dark theme:
# THEME = {"algorithm": "dark"}
# The config are set as a callable returning an antd-compatible theme object
# so that themes can be hot-swapped by fetching a theme object definition remotely
THEME: dict[str, Any] = {}
# Whether to respect the user's OS dark mode setting. If True, THEME_DARK must be set
THEME_RESPECT_USER_OS_DARK_SETTING = False
THEME: dict[str, Any] = lambda: {} # NOQA
THEME_DARK: dict[str, Any] = lambda: {"algorithm": "dark"} # NOQA
# EXTRA_SEQUENTIAL_COLOR_SCHEMES is used for adding custom sequential color schemes
# EXTRA_SEQUENTIAL_COLOR_SCHEMES = [

View File

@@ -30,6 +30,7 @@ from flask import (
g,
get_flashed_messages,
redirect,
request,
Response,
session,
url_for,
@@ -292,6 +293,27 @@ def menu_data(user: User) -> dict[str, Any]:
}
def get_theme_bootstrap_data() -> dict[str, Any]:
"""
On frst call, the cookie related to light/dark may not be set,
so we send both the dark and light themes
in the "themes" key. Once the cookie is set, we simply use the `theme` key.
Logic on the frontend looks for `theme`, and if it's not available, falls back
to `themes` and picks the right one
"""
theme_cookie = request.cookies.get("superset_theme")
if conf["THEME_RESPECT_USER_OS_DARK_SETTING"] or theme_cookie == "light":
return {"theme": conf["THEME"]()}
elif theme_cookie == "dark":
return {"theme": conf["THEME_DARK"]()}
return {
"themes": {
"light": conf["THEME"](),
"dark": conf["THEME_DARK"](),
}
}
@cache_manager.cache.memoize(timeout=60)
def cached_common_bootstrap_data( # pylint: disable=unused-argument
user_id: int | None, locale: Locale | None
@@ -371,10 +393,10 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
"feature_flags": get_feature_flags(),
"extra_sequential_color_schemes": conf["EXTRA_SEQUENTIAL_COLOR_SCHEMES"],
"extra_categorical_color_schemes": conf["EXTRA_CATEGORICAL_COLOR_SCHEMES"],
"theme": conf["THEME"],
"menu_data": menu_data(g.user),
}
bootstrap_data.update(conf["COMMON_BOOTSTRAP_OVERRIDES_FUNC"](bootstrap_data))
bootstrap_data.update(get_theme_bootstrap_data())
return bootstrap_data