Compare commits

...

1 Commits

Author SHA1 Message Date
Maxime Beauchemin
0fdc637533 feat(themes): Add UUID reference system for dynamic theme configuration
Implements a UUID-based reference system that enables Superset themes to be
dynamically configured through external systems like Split.io, overcoming
payload size limitations.

## Problem
Split.io has a 1KB limit for JSON payloads, but Superset theme configurations
typically exceed 3KB. This prevented teams from managing themes dynamically
through feature flags without deployments.

## Solution
Instead of storing full theme objects, store lightweight UUID references:
- Before: `{"algorithm": "dark", "token": {...}, ...}` (3KB+)
- After: `{"uuid": "a7f3c8e2-4d1b-4c7a-9f8e-2b5d6c8a9e1f"}` (<100 bytes)

## Implementation Details

### Backend Changes
- **ResolveAndUpsertThemeCommand**: New command that resolves UUID references
  to full theme configurations and upserts them as system themes
- **Enhanced bootstrap data**: Modified `get_theme_bootstrap_data()` to
  dynamically resolve UUID references on every page load
- **Fallback support**: Graceful degradation to safe defaults if UUID
  resolution fails (empty object for default theme, dark algorithm for dark theme)

### Frontend Changes
- **UUID display in Theme Modal**: Added read-only UUID field with copy-to-clipboard
  functionality using existing CopyToClipboard component
- **Minimal styling**: Uses Label component with monospace font, following
  existing patterns

### How It Works
1. Store themes in Superset's Theme CRUD system (each gets a UUID)
2. Reference themes by UUID in Split.io or other configuration systems
3. On page load, the system:
   - Detects UUID references in theme configuration
   - Resolves them to full theme definitions from the database
   - Upserts as system themes for consistency
   - Falls back to safe defaults on errors

### Testing
Comprehensive test coverage including:
- UUID resolution scenarios (found, not found, invalid JSON)
- System theme upsert behavior (create new, update existing)
- Fallback configurations for different theme types
- UUID precedence over inline configuration

This architecture transforms Split.io's constraint into a feature, enabling
truly dynamic theme management with instant updates and no deployment required.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 13:51:09 -07:00
5 changed files with 360 additions and 16 deletions

View File

@@ -32,11 +32,13 @@ import {
Form,
Tooltip,
Alert,
Label,
} from '@superset-ui/core/components';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import { Typography } from '@superset-ui/core/components/Typography';
import { OnlyKeyWithType } from 'src/utils/types';
import { CopyToClipboard } from 'src/components/CopyToClipboard';
import { ThemeObject } from './types';
interface ThemeModalProps {
@@ -340,6 +342,27 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
/>
</Form.Item>
{currentTheme?.uuid && (
<Form.Item label={t('UUID')}>
<div
css={css`
display: flex;
align-items: center;
gap: ${supersetTheme.sizeUnit * 2}px;
`}
>
<Label monospace>{currentTheme.uuid}</Label>
<CopyToClipboard
text={currentTheme.uuid}
shouldShowText={false}
wrapped={false}
copyNode={<Icons.CopyOutlined iconSize="m" />}
tooltipText={t('Copy UUID to clipboard')}
/>
</div>
</Form.Item>
)}
<Form.Item label={t('JSON Configuration')} required={!isReadOnly}>
<Alert
type="info"

View File

@@ -0,0 +1,126 @@
# 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 logging
from typing import Any
from superset.commands.base import BaseCommand
from superset.daos.theme import ThemeDAO
from superset.extensions import db
from superset.models.core import Theme
from superset.utils import json
from superset.utils.decorators import transaction
logger = logging.getLogger(__name__)
class ResolveAndUpsertThemeCommand(BaseCommand):
"""Command to resolve theme configuration and upsert to system theme."""
def __init__(self, theme_config: dict[str, Any], theme_name: str):
self._theme_config = theme_config
self._theme_name = theme_name
self._fallback_config = self._get_fallback_config()
def run(self) -> dict[str, Any]:
"""Resolve theme configuration and upsert to system theme."""
try:
self.validate()
# First resolve the theme configuration
resolved_config = self._resolve_theme_config()
# Then upsert to system theme
self._upsert_system_theme(resolved_config)
return resolved_config
except Exception as ex:
logger.error(
"Failed to resolve and upsert theme %s: %s. Using fallback.",
self._theme_name,
ex,
)
return self._fallback_config
def _get_fallback_config(self) -> dict[str, Any]:
"""Get fallback configuration based on theme name."""
if self._theme_name == "THEME_DARK":
return {"algorithm": "dark"}
return {}
def _resolve_theme_config(self) -> dict[str, Any]:
"""Resolve theme configuration, looking up UUID references if present."""
# Check if config contains a UUID reference
if isinstance(self._theme_config, dict) and "uuid" in self._theme_config:
uuid = self._theme_config["uuid"]
referenced_theme = ThemeDAO.find_by_uuid(uuid)
if referenced_theme and referenced_theme.json_data:
try:
resolved_config = json.loads(referenced_theme.json_data)
logger.debug(
"Resolved UUID reference %s for %s to theme definition",
uuid,
self._theme_name,
)
return resolved_config
except (ValueError, TypeError) as ex:
logger.error(
"Failed to parse theme JSON for UUID %s: %s",
uuid,
ex,
)
return self._fallback_config
else:
logger.error(
"Referenced theme with UUID %s not found for %s",
uuid,
self._theme_name,
)
return self._fallback_config
# Not a UUID reference, return as-is
return self._theme_config
@transaction()
def _upsert_system_theme(self, theme_config: dict[str, Any]) -> None:
"""Upsert the resolved theme configuration as a system theme."""
existing_theme = (
db.session.query(Theme)
.filter(Theme.theme_name == self._theme_name, Theme.is_system)
.first()
)
json_data = json.dumps(theme_config)
if existing_theme:
existing_theme.json_data = json_data
logger.info(f"Updated system theme: {self._theme_name}")
else:
new_theme = Theme(
theme_name=self._theme_name,
json_data=json_data,
is_system=True,
)
db.session.add(new_theme)
logger.info(f"Created system theme: {self._theme_name}")
def validate(self) -> None:
"""Validate that the theme config is a dictionary."""
if not isinstance(self._theme_config, dict):
self._theme_config = {}
if not self._theme_name:
raise ValueError("Theme name is required")

View File

@@ -62,7 +62,6 @@ from superset.extensions import cache_manager
from superset.reports.models import ReportRecipientType
from superset.superset_typing import FlaskResponse
from superset.themes.utils import (
is_valid_theme,
is_valid_theme_settings,
)
from superset.utils import core as utils, json
@@ -307,29 +306,25 @@ def menu_data(user: User) -> dict[str, Any]:
def get_theme_bootstrap_data() -> dict[str, Any]:
"""
Returns the theme data to be sent to the client.
Resolves UUID references and upserts system themes.
"""
from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
# Get theme configs
default_theme_config = get_config_value("THEME_DEFAULT")
dark_theme_config = get_config_value("THEME_DARK")
theme_settings = get_config_value("THEME_SETTINGS")
# Validate theme configurations
default_theme = default_theme_config
if not is_valid_theme(default_theme):
logger.warning(
"Invalid THEME_DEFAULT configuration: %s, using empty theme",
default_theme_config,
)
default_theme = {}
# Resolve and upsert themes - command handles all error cases
default_theme = ResolveAndUpsertThemeCommand(
default_theme_config or {}, "THEME_DEFAULT"
).run()
dark_theme = dark_theme_config
if not is_valid_theme(dark_theme):
logger.warning(
"Invalid THEME_DARK configuration: %s, using empty theme",
dark_theme_config,
)
dark_theme = {}
dark_theme = ResolveAndUpsertThemeCommand(
dark_theme_config or {}, "THEME_DARK"
).run()
# Validate theme settings
if not is_valid_theme_settings(theme_settings):
logger.warning(
"Invalid THEME_SETTINGS configuration: %s, using defaults", theme_settings

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,184 @@
# 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.
from unittest.mock import MagicMock, patch
import pytest
from superset.commands.theme.resolve import ResolveAndUpsertThemeCommand
from superset.models.core import Theme
from superset.utils import json
@pytest.fixture
def mock_theme_dao():
with patch("superset.commands.theme.resolve.ThemeDAO") as mock:
yield mock
@pytest.fixture
def mock_db():
with patch("superset.commands.theme.resolve.db") as mock:
yield mock
class TestResolveAndUpsertThemeCommand:
def test_resolve_uuid_reference_found(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference when theme is found."""
# Setup
uuid = "test-uuid-123"
theme_config = {"uuid": uuid}
expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = json.dumps(expected_config)
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert
assert result == expected_config
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
def test_resolve_uuid_reference_not_found(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference when theme is not found."""
# Setup
uuid = "missing-uuid-123"
theme_config = {"uuid": uuid}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return empty dict fallback
assert result == {}
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)
def test_resolve_uuid_reference_invalid_json(self, mock_theme_dao, mock_db):
"""Test resolving a UUID reference with invalid JSON data."""
# Setup
uuid = "test-uuid-123"
theme_config = {"uuid": uuid}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = "invalid json"
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return empty dict fallback
assert result == {}
def test_resolve_non_uuid_config(self, mock_theme_dao, mock_db):
"""Test resolving a regular theme config (not UUID reference)."""
# Setup
theme_config = {"algorithm": "default", "token": {"colorPrimary": "#ff0000"}}
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return config as-is
assert result == theme_config
mock_theme_dao.find_by_uuid.assert_not_called()
def test_upsert_creates_new_system_theme(self, mock_theme_dao, mock_db):
"""Test upserting creates a new system theme when none exists."""
# Setup
theme_config = {"algorithm": "default"}
mock_db.session.query.return_value.filter.return_value.first.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
command.run()
# Assert
mock_db.session.add.assert_called_once()
added_theme = mock_db.session.add.call_args[0][0]
assert added_theme.theme_name == "THEME_DEFAULT"
assert added_theme.is_system is True
assert added_theme.json_data == json.dumps(theme_config)
def test_upsert_updates_existing_system_theme(self, mock_theme_dao, mock_db):
"""Test upserting updates an existing system theme."""
# Setup
theme_config = {"algorithm": "dark"}
existing_theme = MagicMock(spec=Theme)
mock_db.session.query.return_value.filter.return_value.first.return_value = (
existing_theme
)
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
command.run()
# Assert
assert existing_theme.json_data == json.dumps(theme_config)
mock_db.session.add.assert_not_called()
def test_fallback_for_theme_default(self, mock_theme_dao, mock_db):
"""Test fallback returns empty dict for THEME_DEFAULT."""
# Setup - simulate UUID lookup failure
theme_config = {"uuid": "non-existent-uuid"}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert
assert result == {}
def test_fallback_for_theme_dark(self, mock_theme_dao, mock_db):
"""Test fallback returns dark algorithm for THEME_DARK."""
# Setup - simulate UUID lookup failure
theme_config = {"uuid": "non-existent-uuid"}
mock_theme_dao.find_by_uuid.return_value = None
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DARK")
result = command.run()
# Assert
assert result == {"algorithm": "dark"}
def test_uuid_with_additional_fields(self, mock_theme_dao, mock_db):
"""Test that UUID takes precedence even with additional fields."""
# Setup
uuid = "test-uuid-123"
theme_config = {
"uuid": uuid,
"algorithm": "ignored", # This should be ignored
"token": {"ignored": True}, # This should be ignored
}
expected_config = {"algorithm": "dark", "token": {"colorPrimary": "#1890ff"}}
mock_theme = MagicMock(spec=Theme)
mock_theme.json_data = json.dumps(expected_config)
mock_theme_dao.find_by_uuid.return_value = mock_theme
# Execute
command = ResolveAndUpsertThemeCommand(theme_config, "THEME_DEFAULT")
result = command.run()
# Assert - should return the resolved config, not the input
assert result == expected_config
mock_theme_dao.find_by_uuid.assert_called_once_with(uuid)