mirror of
https://github.com/apache/superset.git
synced 2026-08-03 12:32:27 +00:00
Compare commits
1 Commits
dependabot
...
msyavuz/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b04fcf71 |
46
superset-frontend/src/features/themes/utils.test.ts
Normal file
46
superset-frontend/src/features/themes/utils.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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 { hasConflictingAlgorithm } from './utils';
|
||||
|
||||
test('flags a light theme assigned to the dark slot', () => {
|
||||
expect(hasConflictingAlgorithm('{"algorithm": "default"}', true)).toBe(true);
|
||||
expect(
|
||||
hasConflictingAlgorithm('{"algorithm": ["default", "compact"]}', true),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('flags a dark theme assigned to the light slot', () => {
|
||||
expect(hasConflictingAlgorithm('{"algorithm": "dark"}', false)).toBe(true);
|
||||
});
|
||||
|
||||
test('does not flag a theme matching its slot', () => {
|
||||
expect(hasConflictingAlgorithm('{"algorithm": "dark"}', true)).toBe(false);
|
||||
expect(hasConflictingAlgorithm('{"algorithm": "default"}', false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
hasConflictingAlgorithm('{"algorithm": ["dark", "compact"]}', true),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('does not flag a theme without a usable algorithm', () => {
|
||||
expect(hasConflictingAlgorithm(undefined, true)).toBe(false);
|
||||
expect(hasConflictingAlgorithm('{"token": {}}', true)).toBe(false);
|
||||
expect(hasConflictingAlgorithm('not json', true)).toBe(false);
|
||||
});
|
||||
48
superset-frontend/src/features/themes/utils.ts
Normal file
48
superset-frontend/src/features/themes/utils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 { ThemeAlgorithm } from '@apache-superset/core/theme';
|
||||
|
||||
const getThemeAlgorithms = (jsonData?: string): string[] => {
|
||||
if (!jsonData) return [];
|
||||
|
||||
try {
|
||||
const { algorithm } = JSON.parse(jsonData) ?? {};
|
||||
if (typeof algorithm === 'string') return [algorithm];
|
||||
if (Array.isArray(algorithm))
|
||||
return algorithm.filter(alg => typeof alg === 'string');
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a theme declares an algorithm that contradicts the system slot it is
|
||||
* about to fill. Such a theme is still served with the slot's algorithm, so the
|
||||
* colors it was authored with will not be the ones users see.
|
||||
*/
|
||||
export const hasConflictingAlgorithm = (
|
||||
jsonData: string | undefined,
|
||||
isDarkSlot: boolean,
|
||||
): boolean => {
|
||||
const algorithms = getThemeAlgorithms(jsonData);
|
||||
if (!algorithms.length) return false;
|
||||
|
||||
return algorithms.includes(ThemeAlgorithm.DARK) !== isDarkSlot;
|
||||
};
|
||||
@@ -103,7 +103,7 @@ const mockThemes = [
|
||||
is_system_default: false,
|
||||
is_system_dark: false,
|
||||
is_system: false,
|
||||
json_data: '{"colors": {"primary": "#52c41a"}}',
|
||||
json_data: '{"algorithm": "default", "colors": {"primary": "#52c41a"}}',
|
||||
created_by: { id: 2, first_name: 'Test', last_name: 'User' },
|
||||
changed_on_delta_humanized: '3 days ago',
|
||||
changed_by: {
|
||||
@@ -360,6 +360,32 @@ test('shows set dark action for non-dark themes', async () => {
|
||||
expect(setDarkButtons.length).toBe(2);
|
||||
});
|
||||
|
||||
test('warns when setting a light theme as the system dark theme', async () => {
|
||||
render(
|
||||
<ThemesList
|
||||
user={mockUser}
|
||||
addDangerToast={jest.fn()}
|
||||
addSuccessToast={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
useQueryParams: true,
|
||||
useTheme: true,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText('Custom Theme');
|
||||
|
||||
// Custom Theme declares the light algorithm; Light Theme declares none
|
||||
const setDarkButtons = await screen.findAllByTestId('set-dark-action');
|
||||
await userEvent.click(setDarkButtons[1]);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/This theme uses the light algorithm/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows unset dark action for system dark theme', async () => {
|
||||
render(
|
||||
<ThemesList
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
|
||||
import ThemeModal from 'src/features/themes/ThemeModal';
|
||||
import { ThemeObject } from 'src/features/themes/types';
|
||||
import { hasConflictingAlgorithm } from 'src/features/themes/utils';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { useConfirmModal } from 'src/hooks/useConfirmModal';
|
||||
@@ -273,9 +274,22 @@ function ThemesList({
|
||||
(theme: ThemeObject) => {
|
||||
showConfirm({
|
||||
title: t('Set System Default Theme'),
|
||||
body: t(
|
||||
'Are you sure you want to set "%s" as the system default theme? This will apply to all users who haven\'t set a personal preference.',
|
||||
theme.theme_name,
|
||||
body: (
|
||||
<Space direction="vertical">
|
||||
{t(
|
||||
'Are you sure you want to set "%s" as the system default theme? This will apply to all users who haven\'t set a personal preference.',
|
||||
theme.theme_name,
|
||||
)}
|
||||
{hasConflictingAlgorithm(theme.json_data, false) && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t(
|
||||
'This theme uses the dark algorithm. It will be rendered with the light algorithm instead, so its colors may not look as designed.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
@@ -299,9 +313,22 @@ function ThemesList({
|
||||
(theme: ThemeObject) => {
|
||||
showConfirm({
|
||||
title: t('Set System Dark Theme'),
|
||||
body: t(
|
||||
'Are you sure you want to set "%s" as the system dark theme? This will apply to all users who haven\'t set a personal preference.',
|
||||
theme.theme_name,
|
||||
body: (
|
||||
<Space direction="vertical">
|
||||
{t(
|
||||
'Are you sure you want to set "%s" as the system dark theme? This will apply to all users who haven\'t set a personal preference.',
|
||||
theme.theme_name,
|
||||
)}
|
||||
{hasConflictingAlgorithm(theme.json_data, true) && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t(
|
||||
'This theme uses the light algorithm. It will be rendered with the dark algorithm instead, so its colors may not look as designed.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
|
||||
@@ -14,15 +14,21 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Any, Dict
|
||||
from typing import Any, cast, Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import current_app
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.themes.types import ThemeMode
|
||||
from superset.themes.types import Theme, ThemeMode
|
||||
from superset.utils.core import sanitize_svg_content, sanitize_url
|
||||
|
||||
# Algorithm names Ant Design actually understands; ThemeMode also carries
|
||||
# "system", which is a mode rather than a mapping algorithm.
|
||||
ANTD_ALGORITHMS = frozenset(
|
||||
{ThemeMode.DEFAULT.value, ThemeMode.DARK.value, ThemeMode.COMPACT.value}
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_theme_mode(mode: str) -> bool:
|
||||
"""Validate if a string represents a valid theme mode.
|
||||
@@ -52,6 +58,59 @@ def _is_valid_algorithm(algorithm: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _algorithm_list(algorithm: Any) -> list[str]:
|
||||
"""Coerce a theme algorithm field into a list of Ant Design algorithm names."""
|
||||
if isinstance(algorithm, str):
|
||||
candidates = [algorithm]
|
||||
elif isinstance(algorithm, list):
|
||||
candidates = [alg for alg in algorithm if isinstance(alg, str)]
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
return [alg for alg in candidates if alg in ANTD_ALGORITHMS]
|
||||
|
||||
|
||||
def enforce_theme_algorithm(theme: Theme, mode: ThemeMode) -> Theme:
|
||||
"""Force a theme's algorithm to match the slot it is served in.
|
||||
|
||||
Admins pick which theme fills the system default (light) and system dark
|
||||
slots, and nothing stops them from picking a theme whose own algorithm
|
||||
contradicts the slot. Ant Design then derives a mix of light and dark
|
||||
tokens, which renders as a broken half-themed UI, so the algorithm is
|
||||
rewritten to match the slot while preserving modifiers such as ``compact``.
|
||||
|
||||
Args:
|
||||
theme: Theme configuration to normalize
|
||||
mode: The slot the theme is served in (``DEFAULT`` or ``DARK``)
|
||||
|
||||
Returns:
|
||||
Theme: Theme with a slot-consistent algorithm
|
||||
"""
|
||||
if not theme:
|
||||
return theme
|
||||
|
||||
wanted = ThemeMode.DARK.value if mode == ThemeMode.DARK else ThemeMode.DEFAULT.value
|
||||
conflicting = (
|
||||
ThemeMode.DEFAULT.value if mode == ThemeMode.DARK else ThemeMode.DARK.value
|
||||
)
|
||||
|
||||
algorithms = _algorithm_list(theme.get("algorithm"))
|
||||
if wanted in algorithms and conflicting not in algorithms:
|
||||
return theme
|
||||
|
||||
corrected = [wanted] + [
|
||||
alg for alg in algorithms if alg not in (wanted, conflicting)
|
||||
]
|
||||
|
||||
return cast(
|
||||
Theme,
|
||||
{
|
||||
**theme,
|
||||
"algorithm": corrected[0] if len(corrected) == 1 else corrected,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def is_valid_theme(theme: Dict[str, Any]) -> bool:
|
||||
"""Validate theme dictionary structure and types.
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ from superset.reports.models import ReportRecipientType
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.themes.types import Theme, ThemeMode
|
||||
from superset.themes.utils import (
|
||||
enforce_theme_algorithm,
|
||||
is_valid_theme,
|
||||
)
|
||||
from superset.translations.utils import get_language_pack
|
||||
@@ -428,6 +429,11 @@ def get_theme_bootstrap_data() -> dict[str, Any]:
|
||||
default_theme = _process_theme(default_theme, ThemeMode.DEFAULT)
|
||||
dark_theme = _process_theme(dark_theme, ThemeMode.DARK)
|
||||
|
||||
# Force each theme to carry the algorithm of the slot it fills, so a light
|
||||
# theme assigned to the dark slot (or vice versa) still renders consistently
|
||||
default_theme = enforce_theme_algorithm(default_theme, ThemeMode.DEFAULT)
|
||||
dark_theme = enforce_theme_algorithm(dark_theme, ThemeMode.DARK)
|
||||
|
||||
return {
|
||||
"theme": {
|
||||
"default": default_theme,
|
||||
|
||||
@@ -24,12 +24,54 @@ from superset.themes.types import ThemeMode
|
||||
from superset.themes.utils import (
|
||||
_is_valid_algorithm,
|
||||
_is_valid_theme_mode,
|
||||
enforce_theme_algorithm,
|
||||
is_valid_theme,
|
||||
sanitize_theme_tokens,
|
||||
validate_font_urls,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"algorithm, mode, expected",
|
||||
[
|
||||
# A theme authored for light mode must not stay light in the dark slot
|
||||
("default", ThemeMode.DARK, "dark"),
|
||||
(["default", "compact"], ThemeMode.DARK, ["dark", "compact"]),
|
||||
# ...and vice versa
|
||||
("dark", ThemeMode.DEFAULT, "default"),
|
||||
(["dark", "compact"], ThemeMode.DEFAULT, ["default", "compact"]),
|
||||
# A missing or unusable algorithm is filled in from the slot
|
||||
(None, ThemeMode.DARK, "dark"),
|
||||
(None, ThemeMode.DEFAULT, "default"),
|
||||
("system", ThemeMode.DARK, "dark"),
|
||||
("compact", ThemeMode.DARK, ["dark", "compact"]),
|
||||
# Matching algorithms are left untouched
|
||||
("dark", ThemeMode.DARK, "dark"),
|
||||
(["dark", "compact"], ThemeMode.DARK, ["dark", "compact"]),
|
||||
("default", ThemeMode.DEFAULT, "default"),
|
||||
],
|
||||
)
|
||||
def test_enforce_theme_algorithm(algorithm, mode, expected):
|
||||
"""Test that a theme is served with the algorithm of the slot it fills."""
|
||||
theme = {"token": {"colorPrimary": "#000"}}
|
||||
if algorithm is not None:
|
||||
theme["algorithm"] = algorithm
|
||||
|
||||
result = enforce_theme_algorithm(theme, mode)
|
||||
|
||||
assert result["algorithm"] == expected
|
||||
assert result["token"] == {"colorPrimary": "#000"}
|
||||
|
||||
|
||||
def test_enforce_theme_algorithm_keeps_empty_theme_empty():
|
||||
"""Test that an empty theme is not given an algorithm.
|
||||
|
||||
An empty theme signals "no custom theme" to the frontend, so it must stay
|
||||
empty rather than becoming a theme carrying only an algorithm.
|
||||
"""
|
||||
assert enforce_theme_algorithm({}, ThemeMode.DARK) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode, expected",
|
||||
[
|
||||
|
||||
@@ -532,6 +532,47 @@ class TestGetThemeBootstrapData:
|
||||
assert result["theme"]["default"] == {}
|
||||
assert result["theme"]["dark"] == {}
|
||||
|
||||
@patch("superset.views.base.app")
|
||||
@patch("superset.views.base.get_config_value")
|
||||
@patch("superset.views.base.ThemeDAO")
|
||||
def test_light_theme_in_dark_slot_gets_dark_algorithm(
|
||||
self,
|
||||
mock_dao,
|
||||
mock_get_config,
|
||||
mock_app,
|
||||
):
|
||||
"""Regression test for a light theme selected as the system dark theme.
|
||||
|
||||
The database theme's algorithm wins the merge against the config base,
|
||||
so without correction the dark slot would be served ``default`` and Ant
|
||||
Design would derive a mix of light and dark tokens.
|
||||
"""
|
||||
mock_app.config = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda k, d=None: {
|
||||
"ENABLE_UI_THEME_ADMINISTRATION": True,
|
||||
}.get(k, d)
|
||||
|
||||
mock_get_config.side_effect = lambda k: {
|
||||
"THEME_DEFAULT": {"token": {"colorPrimary": "#config1"}},
|
||||
"THEME_DARK": {"token": {"colorPrimary": "#config2"}},
|
||||
}.get(k)
|
||||
|
||||
# The same light theme is set as both the system default and system dark
|
||||
light_theme = MagicMock()
|
||||
light_theme.json_data = (
|
||||
'{"token": {"colorPrimary": "#db1"}, "algorithm": "default"}'
|
||||
)
|
||||
|
||||
mock_dao.find_system_default.return_value = light_theme
|
||||
mock_dao.find_system_dark.return_value = light_theme
|
||||
|
||||
result = get_theme_bootstrap_data()
|
||||
|
||||
assert result["theme"]["default"]["algorithm"] == "default"
|
||||
assert result["theme"]["dark"]["algorithm"] == "dark"
|
||||
# The theme's own tokens are still honored in both slots
|
||||
assert result["theme"]["dark"]["token"]["colorPrimary"] == "#db1"
|
||||
|
||||
@patch("superset.views.base.app")
|
||||
@patch("superset.views.base.get_config_value")
|
||||
def test_partial_theme_override_preserves_base_tokens(
|
||||
|
||||
Reference in New Issue
Block a user