feat(themes): add enhanced validation and error handling with fallback mechanisms (#37378)

Co-authored-by: Rafael Benitez <rebenitez1802@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-02-12 14:06:58 -08:00
committed by GitHub
parent 58d245c6b0
commit 0d5ddb3674
10 changed files with 1566 additions and 60 deletions

View File

@@ -17,11 +17,12 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import ThemeModal from './ThemeModal';
import { ThemeObject } from './types';
import { validateTheme } from 'src/theme/utils/themeStructureValidation';
const mockThemeContext = {
setTemporaryTheme: jest.fn(),
@@ -37,6 +38,27 @@ jest.mock('src/dashboard/util/permissionUtils', () => ({
isUserAdmin: jest.fn(() => true),
}));
// Mock JsonEditor to avoid direct DOM manipulation in tests
jest.mock('@superset-ui/core/components/AsyncAceEditor', () => ({
...jest.requireActual('@superset-ui/core/components/AsyncAceEditor'),
JsonEditor: ({
onChange,
value,
readOnly,
}: {
onChange: (value: string) => void;
value: string;
readOnly?: boolean;
}) => (
<textarea
data-test="json-editor"
value={value}
onChange={e => onChange(e.target.value)}
readOnly={readOnly}
/>
),
}));
const mockTheme: ThemeObject = {
id: 1,
theme_name: 'Test Theme',
@@ -89,6 +111,31 @@ afterEach(() => {
jest.clearAllMocks();
});
// Helper to add valid JSON data to the theme
// Uses the mocked JsonEditor textarea for testing
const addValidJsonData = async () => {
const validJson = JSON.stringify(
{ token: { colorPrimary: '#1890ff' } },
null,
2,
);
const jsonEditor = screen.getByTestId('json-editor');
await userEvent.clear(jsonEditor);
await userEvent.type(jsonEditor, validJson);
};
// Helper to add JSON with unknown tokens (triggers warnings but not errors)
const addJsonWithUnknownToken = async () => {
const jsonWithUnknown = JSON.stringify(
{ token: { colorPrimary: '#1890ff', unknownTokenName: 'value' } },
null,
2,
);
const jsonEditor = screen.getByTestId('json-editor');
await userEvent.clear(jsonEditor);
await userEvent.type(jsonEditor, jsonWithUnknown);
};
test('renders modal with add theme dialog when show is true', () => {
render(
<ThemeModal
@@ -283,10 +330,16 @@ test('enables save button when theme name is entered', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'My New Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
// Wait for validation to complete and button to become enabled
await waitFor(
() => {
const saveButton = screen.getByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
},
{ timeout: 10000 },
);
});
test('validates JSON format and enables save button', async () => {
@@ -304,10 +357,52 @@ test('validates JSON format and enables save button', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'Test Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
// Wait for validation to complete and button to become enabled
await waitFor(
() => {
const saveButton = screen.getByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
},
{ timeout: 10000 },
);
});
expect(saveButton).toBeEnabled();
test('warnings do not block save - unknown tokens allow save with warnings', async () => {
// First verify the test data actually produces warnings (not errors)
const testTheme = {
token: { colorPrimary: '#1890ff', unknownTokenName: 'value' },
};
const validationResult = validateTheme(testTheme);
expect(validationResult.valid).toBe(true); // No errors
expect(validationResult.warnings.length).toBeGreaterThan(0); // Has warnings
expect(validationResult.warnings[0].tokenName).toBe('unknownTokenName');
render(
<ThemeModal
addDangerToast={jest.fn()}
addSuccessToast={jest.fn()}
onThemeAdd={jest.fn()}
onHide={jest.fn()}
show
canDevelop={false}
/>,
{ useRedux: true, useRouter: true },
);
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'Theme With Unknown Token');
await addJsonWithUnknownToken();
// Wait for validation to complete - button should still be enabled despite warnings
await waitFor(
() => {
const saveButton = screen.getByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
},
{ timeout: 10000 },
);
});
test('shows unsaved changes alert when closing modal with modifications', async () => {
@@ -418,6 +513,19 @@ test('saves changes when clicking Save button in unsaved changes alert', async (
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'Modified Theme');
await addValidJsonData();
// Wait for validation to complete before canceling
await waitFor(
() => {
const addButton = screen.getByRole('button', { name: 'Add' });
expect(addButton).toBeEnabled();
},
{ timeout: 10000 },
);
// Give extra time for all state updates to complete
await new Promise(resolve => setTimeout(resolve, 500));
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await userEvent.click(cancelButton);
@@ -426,13 +534,26 @@ test('saves changes when clicking Save button in unsaved changes alert', async (
await screen.findByText('You have unsaved changes'),
).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: 'Save' });
// Wait for the Save button in the alert to be enabled
const saveButton = await waitFor(
() => {
const button = screen.getByRole('button', { name: 'Save' });
expect(button).toBeEnabled();
return button;
},
{ timeout: 10000 },
);
await userEvent.click(saveButton);
// Wait for API call to complete
await screen.findByRole('dialog');
expect(fetchMock.callHistory.called()).toBe(true);
});
await waitFor(
() => {
expect(fetchMock.callHistory.called()).toBe(true);
},
{ timeout: 15000 },
);
}, 30000);
test('discards changes when clicking Discard button in unsaved changes alert', async () => {
const onHide = jest.fn();
@@ -483,12 +604,23 @@ test('creates new theme when saving', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'New Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
// Wait for validation to complete and button to become enabled
const saveButton = await waitFor(
() => {
const button = screen.getByRole('button', { name: 'Add' });
expect(button).toBeEnabled();
return button;
},
{ timeout: 10000 },
);
await userEvent.click(saveButton);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(fetchMock.callHistory.called(postThemeMockName)).toBe(true);
await waitFor(() => {
expect(fetchMock.callHistory.called(postThemeMockName)).toBe(true);
});
});
test('updates existing theme when saving', async () => {
@@ -536,14 +668,24 @@ test('handles API errors gracefully', async () => {
const nameInput = screen.getByPlaceholderText('Enter theme name');
await userEvent.type(nameInput, 'New Theme');
await addValidJsonData();
const saveButton = await screen.findByRole('button', { name: 'Add' });
expect(saveButton).toBeEnabled();
// Wait for validation to complete and button to become enabled
const saveButton = await waitFor(
() => {
const button = screen.getByRole('button', { name: 'Add' });
expect(button).toBeEnabled();
return button;
},
{ timeout: 10000 },
);
await userEvent.click(saveButton);
await screen.findByRole('dialog');
expect(fetchMock.callHistory.called()).toBe(true);
await waitFor(() => {
expect(fetchMock.callHistory.called()).toBe(true);
});
});
test('applies theme locally when clicking Apply button', async () => {

View File

@@ -43,10 +43,10 @@ import {
Space,
Tooltip,
} from '@superset-ui/core/components';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import type { editors } from '@apache-superset/core';
import { EditorHost } from 'src/core/editors';
import { Typography } from '@superset-ui/core/components/Typography';
import { useThemeValidation } from 'src/theme/hooks/useThemeValidation';
import { OnlyKeyWithType } from 'src/utils/types';
import { ThemeObject } from './types';
@@ -147,10 +147,9 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
SupersetText?.THEME_MODAL?.DOCUMENTATION_URL ||
'https://superset.apache.org/docs/configuration/theming/';
// JSON validation annotations using reusable hook
const jsonAnnotations = useJsonValidation(currentTheme?.json_data, {
enabled: !isReadOnly,
errorPrefix: 'Invalid JSON syntax',
// Theme validation (structure + token names)
const validation = useThemeValidation(currentTheme?.json_data || '', {
enabled: !isReadOnly && Boolean(currentTheme?.json_data),
});
// theme fetch logic
@@ -178,6 +177,15 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
}, [onHide]);
const onSave = useCallback(() => {
// Synchronous JSON guard to catch invalid JSON before API call
// This handles the race condition where debounced validation hasn't updated yet
try {
JSON.parse(currentTheme?.json_data || '');
} catch {
addDangerToast(t('Invalid JSON configuration'));
return;
}
if (isEditMode) {
// Edit
if (currentTheme?.id) {
@@ -211,6 +219,7 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
createResource,
onThemeAdd,
hide,
addDangerToast,
]);
const handleCancel = useCallback(() => {
@@ -306,27 +315,22 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
[currentTheme],
);
const validate = useCallback(() => {
if (isReadOnly) {
const validate = () => {
if (isReadOnly || !currentTheme) {
setDisableSave(true);
return;
}
if (
currentTheme?.theme_name.length &&
currentTheme?.json_data?.length &&
isValidJson(currentTheme.json_data)
) {
setDisableSave(false);
} else {
setDisableSave(true);
}
}, [
currentTheme?.theme_name,
currentTheme?.json_data,
isReadOnly,
isValidJson,
]);
const hasValidName = Boolean(currentTheme?.theme_name?.trim());
const hasValidJsonData = Boolean(currentTheme?.json_data?.trim());
// Block save only on ERRORS (not warnings)
// Errors: JSON syntax errors, empty themes
// Warnings: Unknown tokens, null values (non-blocking)
const canSave = hasValidName && hasValidJsonData && !validation.hasErrors;
setDisableSave(!canSave);
};
// Initialize
useEffect(() => {
@@ -360,7 +364,12 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
// Validation
useEffect(() => {
validate();
}, [validate]);
}, [
currentTheme ? currentTheme.theme_name : '',
currentTheme ? currentTheme.json_data : '',
isReadOnly,
validation.hasErrors,
]);
// Show/hide
useEffect(() => {
@@ -490,7 +499,10 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
>
{t('documentation')}
</a>
{t(' for details.')}
{t(' for details.')}{' '}
<Typography.Text type="secondary">
{t('Unknown tokens will be highlighted as warnings.')}
</Typography.Text>
</span>
}
/>
@@ -506,7 +518,7 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
lineNumbers
width="100%"
height="250px"
annotations={toEditorAnnotations(jsonAnnotations)}
annotations={toEditorAnnotations(validation.annotations)}
/>
</StyledEditorWrapper>
{canDevelopThemes && (
@@ -520,7 +532,8 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
onClick={onApply}
disabled={
!currentTheme?.json_data ||
!isValidJson(currentTheme.json_data)
!isValidJson(currentTheme.json_data) ||
validation.hasErrors
}
buttonStyle="secondary"
>

View File

@@ -143,8 +143,26 @@ export class ThemeController {
// Setup change callback
if (onChange) this.onChangeCallbacks.add(onChange);
// Apply initial theme and persist mode
this.applyTheme(initialTheme);
// Apply initial theme with recovery for corrupted stored themes
try {
this.applyTheme(initialTheme);
} catch (error) {
// Corrupted dev override or CRUD theme in storage - clear and retry with defaults
console.warn(
'Failed to apply stored theme, clearing invalid overrides:',
error,
);
this.devThemeOverride = null;
this.crudThemeId = null;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
// Retry with clean default theme
this.currentMode = ThemeMode.DEFAULT;
const safeTheme = this.defaultTheme || {};
this.applyTheme(safeTheme);
}
this.persistMode();
}
@@ -229,14 +247,8 @@ export class ThemeController {
return this.dashboardThemes.get(themeId)!;
}
// Fetch theme config from API using SupersetClient for proper auth
const getTheme = makeApi<void, { result: { json_data: string } }>({
method: 'GET',
endpoint: `/api/v1/theme/${themeId}`,
});
const { result } = await getTheme();
const themeConfig = JSON.parse(result.json_data);
// Use the enhanced fetchCrudTheme method which includes validation if feature flag is enabled
const themeConfig = await this.fetchCrudTheme(themeId);
if (themeConfig) {
// Controller creates and owns the dashboard theme
@@ -329,7 +341,6 @@ export class ThemeController {
const theme: AnyThemeConfig | null = this.getThemeForMode(mode);
if (!theme) {
console.warn(`Theme for mode ${mode} not found, falling back to default`);
this.fallbackToDefaultMode();
return;
}
@@ -535,7 +546,7 @@ export class ThemeController {
* Updates the theme.
* @param theme - The new theme to apply
*/
private updateTheme(theme?: AnyThemeConfig): void {
private async updateTheme(theme?: AnyThemeConfig): Promise<void> {
try {
// If no config provided, use current mode to get theme
if (!theme) {
@@ -551,18 +562,41 @@ export class ThemeController {
this.persistMode();
this.notifyListeners();
} catch (error) {
console.error('Failed to update theme:', error);
this.fallbackToDefaultMode();
// Clear potentially corrupted overrides before fallback
// This mirrors the constructor's recovery logic to prevent
// repeated failures from a malformed devThemeOverride or crudThemeId
this.devThemeOverride = null;
this.crudThemeId = null;
this.storage.removeItem(STORAGE_KEYS.DEV_THEME_OVERRIDE);
this.storage.removeItem(STORAGE_KEYS.CRUD_THEME_ID);
this.storage.removeItem(STORAGE_KEYS.APPLIED_THEME_ID);
await this.fallbackToDefaultMode();
}
}
/**
* Fallback to default mode with error recovery.
* Fallback to default mode with runtime error recovery.
* Tries to fetch a fresh system default theme from the API.
*/
private fallbackToDefaultMode(): void {
private async fallbackToDefaultMode(): Promise<void> {
this.currentMode = ThemeMode.DEFAULT;
// Get the default theme which will have the correct algorithm
// Try to fetch fresh system default theme from server
const freshSystemTheme = await this.fetchSystemDefaultTheme();
if (freshSystemTheme) {
try {
await this.applyThemeWithRecovery(freshSystemTheme);
this.persistMode();
this.notifyListeners();
return;
} catch (error) {
// Fresh theme also failed, continue to final fallback
}
}
// Final fallback: use cached default theme or built-in theme
const defaultTheme: AnyThemeConfig =
this.getThemeForMode(ThemeMode.DEFAULT) || this.defaultTheme || {};
@@ -796,10 +830,25 @@ export class ThemeController {
this.loadFonts(fontUrls);
} catch (error) {
console.error('Failed to apply theme:', error);
this.fallbackToDefaultMode();
// Re-throw the error so updateTheme can handle fallback logic
throw error;
}
}
private async applyThemeWithRecovery(theme: AnyThemeConfig): Promise<void> {
// Note: This method re-throws errors to the caller instead of calling
// fallbackToDefaultMode directly, to avoid infinite recursion since
// fallbackToDefaultMode calls this method. The caller's try/catch
// handles the fallback flow.
const normalizedConfig = normalizeThemeConfig(theme);
this.globalTheme.setConfig(normalizedConfig);
// Load custom fonts if specified, mirroring applyTheme() behavior
const fontUrls = (normalizedConfig?.token as Record<string, unknown>)
?.fontUrls as string[] | undefined;
this.loadFonts(fontUrls);
}
/**
* Loads custom fonts from theme configuration.
* Injects CSS @import statements for font URLs that haven't been loaded yet.
@@ -896,14 +945,17 @@ export class ThemeController {
/**
* 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
* @returns The theme configuration or null if fetch fails
*/
private async fetchCrudTheme(
themeId: string,
): Promise<AnyThemeConfig | null> {
try {
// Use SupersetClient for proper authentication handling
const getTheme = makeApi<void, { result: { json_data: string } }>({
const getTheme = makeApi<
void,
{ result: { json_data: string; theme_name?: string } }
>({
method: 'GET',
endpoint: `/api/v1/theme/${themeId}`,
});
@@ -911,10 +963,65 @@ export class ThemeController {
const { result } = await getTheme();
const themeConfig = JSON.parse(result.json_data);
if (!themeConfig || typeof themeConfig !== 'object') {
console.error(`Invalid theme configuration for theme ${themeId}`);
return null;
}
// Return theme as-is
// Invalid tokens will be handled by Ant Design at runtime
// Runtime errors will be caught by applyThemeWithRecovery()
return themeConfig;
} catch (error) {
console.error('Failed to fetch CRUD theme:', error);
return null;
}
}
/**
* Fetches a fresh system default theme from the API for runtime recovery.
* Tries multiple fallback strategies to find a valid theme.
*
* Note: Uses raw fetch() instead of SupersetClient because ThemeController
* initializes early in the app lifecycle, before SupersetClient is fully
* configured. This avoids boot-time circular dependencies.
*
* @returns The system default theme configuration or null if not found
*/
private async fetchSystemDefaultTheme(): Promise<AnyThemeConfig | null> {
try {
// Try to fetch theme marked as system default (is_system_default=true)
const defaultResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:is_system_default,opr:eq,value:!t)))',
);
if (defaultResponse.ok) {
const data = await defaultResponse.json();
if (data.result?.length > 0) {
const themeConfig = JSON.parse(data.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
}
// Fallback: Try to fetch system theme named 'THEME_DEFAULT'
const fallbackResponse = await fetch(
'/api/v1/theme/?q=(filters:!((col:theme_name,opr:eq,value:THEME_DEFAULT),(col:is_system,opr:eq,value:!t)))',
);
if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json();
if (fallbackData.result?.length > 0) {
const themeConfig = JSON.parse(fallbackData.result[0].json_data);
if (themeConfig && typeof themeConfig === 'object') {
return themeConfig;
}
}
}
} catch (error) {
// Log for debugging but don't fail - fallback to cached theme will be used
console.warn('Failed to fetch system default theme:', error);
}
return null;
}
}

View File

@@ -0,0 +1,133 @@
/**
* 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 { renderHook } from '@testing-library/react-hooks';
import { useThemeValidation } from './useThemeValidation';
test('useThemeValidation validates valid theme with standard tokens', () => {
const validTheme = JSON.stringify({
token: {
colorPrimary: '#1890ff',
fontSize: 14,
},
});
const { result } = renderHook(() => useThemeValidation(validTheme));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation shows warnings for unknown tokens', () => {
const themeWithUnknownToken = JSON.stringify({
token: {
colorPrimary: '#1890ff',
unknownToken: 'value',
},
});
const { result } = renderHook(() =>
useThemeValidation(themeWithUnknownToken),
);
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(true);
expect(result.current.annotations.length).toBeGreaterThan(0);
expect(result.current.annotations[0].type).toBe('warning');
});
test('useThemeValidation shows error for empty theme', () => {
const emptyTheme = JSON.stringify({});
const { result } = renderHook(() => useThemeValidation(emptyTheme));
expect(result.current.hasErrors).toBe(true);
expect(result.current.annotations.length).toBeGreaterThan(0);
expect(result.current.annotations[0].type).toBe('error');
expect(result.current.annotations[0].text).toContain('cannot be empty');
});
test('useThemeValidation shows error for invalid JSON syntax', () => {
const invalidJson = '{invalid json}';
const { result } = renderHook(() => useThemeValidation(invalidJson));
expect(result.current.hasErrors).toBe(true);
expect(result.current.annotations.length).toBeGreaterThan(0);
expect(result.current.annotations[0].type).toBe('error');
});
test('useThemeValidation skips validation for empty string', () => {
const { result } = renderHook(() => useThemeValidation(''));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation validates Superset custom tokens', () => {
const themeWithCustomToken = JSON.stringify({
token: {
brandLogoUrl: '/static/logo.png',
brandSpinnerSvg: '<svg></svg>',
},
});
const { result } = renderHook(() => useThemeValidation(themeWithCustomToken));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation allows theme with only algorithm', () => {
const themeWithAlgorithm = JSON.stringify({
algorithm: 'dark',
});
const { result } = renderHook(() => useThemeValidation(themeWithAlgorithm));
expect(result.current.hasErrors).toBe(false);
expect(result.current.annotations).toHaveLength(0);
});
test('useThemeValidation shows warning for null token value', () => {
const themeWithNullValue = JSON.stringify({
token: {
colorPrimary: null,
},
});
const { result } = renderHook(() => useThemeValidation(themeWithNullValue));
expect(result.current.hasErrors).toBe(false);
expect(result.current.hasWarnings).toBe(true);
expect(result.current.annotations[0].type).toBe('warning');
expect(result.current.annotations[0].text).toContain('null/undefined');
});
test('useThemeValidation respects enabled option', () => {
const invalidJson = '{invalid}';
const { result } = renderHook(() =>
useThemeValidation(invalidJson, { enabled: false }),
);
expect(result.current.annotations).toHaveLength(0);
});

View File

@@ -0,0 +1,155 @@
/**
* 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 { useMemo, useState, useEffect } from 'react';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import type { JsonValidationAnnotation } from '@superset-ui/core/components/AsyncAceEditor';
import type { AnyThemeConfig } from '@apache-superset/core/ui';
import { validateTheme } from '../utils/themeStructureValidation';
/**
* Find the line number where a specific token appears in JSON string.
* Uses improved logic to handle nested objects and avoid false positives.
*/
function findTokenLineInJson(jsonString: string, tokenName: string): number {
if (!jsonString || !tokenName) {
return 0;
}
// Handle special _root token for structural errors
if (tokenName === '_root') {
return 0;
}
const lines = jsonString.split('\n');
// Look for the token name as a JSON property key
// Pattern: "tokenName" followed by : (with possible whitespace)
const propertyPattern = new RegExp(
`"${tokenName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*:`,
);
for (let i = 0; i < lines.length; i += 1) {
if (propertyPattern.test(lines[i].trim())) {
return i; // Return 0-based line number for AceEditor
}
}
// Fallback: simple string search for edge cases
const searchPattern = `"${tokenName}"`;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].includes(searchPattern)) {
return i;
}
}
// If token not found, return line 0
return 0;
}
export interface ThemeValidationResult {
annotations: JsonValidationAnnotation[];
hasErrors: boolean; // true if errors exist (blocks save)
hasWarnings: boolean; // true if warnings exist (non-blocking)
}
export interface UseThemeValidationOptions {
/** Whether to enable validation. Default: true */
enabled?: boolean;
/** Debounce delay in milliseconds for validation. Default: 300 */
debounceMs?: number;
}
/**
* Theme validation hook with live feedback.
* - Errors (JSON syntax, empty theme) block save/apply
* - Warnings (unknown tokens, null values) allow save/apply
*
* This hook validates structure and token names only.
* Token values are validated by Ant Design at runtime.
*/
export function useThemeValidation(
jsonValue?: string,
options: UseThemeValidationOptions = {},
): ThemeValidationResult {
const { enabled = true, debounceMs = 300 } = options;
const [debouncedValue, setDebouncedValue] = useState(jsonValue);
// Debounce for performance
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(jsonValue), debounceMs);
return () => clearTimeout(timer);
}, [jsonValue, debounceMs]);
// JSON syntax validation (ERRORS)
const jsonAnnotations = useJsonValidation(debouncedValue, {
enabled,
errorPrefix: 'Invalid JSON',
});
// Theme structure validation (ERRORS + WARNINGS)
const themeAnnotations = useMemo(() => {
// Skip if disabled or JSON is invalid
if (!enabled || jsonAnnotations.length > 0 || !debouncedValue?.trim()) {
return [];
}
try {
const config: AnyThemeConfig = JSON.parse(debouncedValue);
const result = validateTheme(config);
const annotations: JsonValidationAnnotation[] = [];
// Convert errors to annotations (blocks save)
result.errors.forEach(issue => {
annotations.push({
type: 'error',
row: findTokenLineInJson(debouncedValue, issue.tokenName),
column: 0,
text: issue.message,
});
});
// Convert warnings to annotations (non-blocking)
result.warnings.forEach(issue => {
annotations.push({
type: 'warning',
row: findTokenLineInJson(debouncedValue, issue.tokenName),
column: 0,
text: issue.message,
});
});
return annotations;
} catch {
// JSON parsing error already caught by jsonAnnotations
return [];
}
}, [enabled, debouncedValue, jsonAnnotations]);
return useMemo(() => {
const allAnnotations = [...jsonAnnotations, ...themeAnnotations];
return {
annotations: allAnnotations,
hasErrors: allAnnotations.some(a => a.type === 'error'),
hasWarnings: allAnnotations.some(a => a.type === 'warning'),
};
}, [jsonAnnotations, themeAnnotations]);
}

View File

@@ -833,6 +833,202 @@ test('ThemeController handles theme application errors', () => {
fallbackSpy.mockRestore();
});
test('ThemeController constructor recovers from corrupted stored theme', () => {
// Simulate corrupted dev theme override in storage
const corruptedTheme = { token: { colorPrimary: '#ff0000' } };
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'superset-dev-theme-override') {
return JSON.stringify(corruptedTheme);
}
return null;
});
// Mock Theme.fromConfig to return object with toSerializedConfig
mockThemeFromConfig.mockReturnValue({
...mockThemeObject,
toSerializedConfig: () => corruptedTheme,
});
// First call throws (corrupted theme), second call succeeds (fallback)
let callCount = 0;
mockSetConfig.mockImplementation(() => {
callCount += 1;
if (callCount === 1) {
throw new Error('Invalid theme configuration');
}
});
// Should not throw - constructor should recover
const controller = createController();
// Verify recovery happened - use shared consoleSpy to avoid interfering with other tests
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to apply stored theme, clearing invalid overrides:',
expect.any(Error),
);
// Verify invalid overrides were cleared from storage
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
// Verify controller is in a valid state
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
});
test('recovery flow: fetchSystemDefaultTheme returns theme → applies fetched theme', async () => {
// Test: fallbackToDefaultMode fetches theme from API and applies it
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme → applyThemeWithRecovery
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to return a system default theme from API
const systemTheme = { token: { colorPrimary: '#recovery-theme' } };
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(systemTheme) }],
}),
});
global.fetch = mockFetch;
// Track setConfig calls to verify the fetched theme is applied
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
});
// Trigger fallbackToDefaultMode (simulates what happens after applyTheme fails)
await (controller as any).fallbackToDefaultMode();
// Verify API was called to fetch system default theme
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
);
// Verify the fetched theme was applied via applyThemeWithRecovery
expect(setConfigCalls.length).toBe(1);
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({ colorPrimary: '#recovery-theme' }),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
test('recovery flow: both API fetches fail → falls back to cached default theme', async () => {
// Test: When fetchSystemDefaultTheme fails, fallbackToDefaultMode uses cached theme
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme (fails) → applyTheme(cached)
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to fail for both API endpoints
const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
global.fetch = mockFetch;
// Track setConfig calls
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
});
// Trigger fallbackToDefaultMode
await (controller as any).fallbackToDefaultMode();
// Verify fetch was attempted
expect(mockFetch).toHaveBeenCalled();
// Verify fallback to cached default theme was applied via applyTheme
expect(setConfigCalls.length).toBe(1);
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed', // From DEFAULT_THEME in test setup
}),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
test('recovery flow: fetched theme fails to apply → falls back to cached default', async () => {
// Test: When applyThemeWithRecovery fails, fallbackToDefaultMode uses cached theme
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme → applyThemeWithRecovery (fails) → applyTheme(cached)
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to return a theme
const systemTheme = { token: { colorPrimary: '#bad-theme' } };
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(systemTheme) }],
}),
});
global.fetch = mockFetch;
// First setConfig call (applyThemeWithRecovery) fails, second (applyTheme) succeeds
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
if (setConfigCalls.length === 1) {
throw new Error('Fetched theme failed to apply');
}
});
// Trigger fallbackToDefaultMode
await (controller as any).fallbackToDefaultMode();
// Verify fetch was called
expect(mockFetch).toHaveBeenCalled();
// Verify both attempts were made: fetched theme (failed) then cached default
expect(setConfigCalls.length).toBe(2);
// First call was the fetched theme (which failed)
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({ colorPrimary: '#bad-theme' }),
}),
);
// Second call was the cached default theme
expect(setConfigCalls[1]).toEqual(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed', // From DEFAULT_THEME
}),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
// Cleanup tests
test('ThemeController cleans up listeners on destroy', () => {
const mockMediaQueryInstance = {

View File

@@ -0,0 +1,108 @@
/**
* 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 {
isValidTokenName,
isSupersetCustomToken,
getAllValidTokenNames,
} from './antdTokenNames';
test('isValidTokenName recognizes standard Ant Design tokens', () => {
expect(isValidTokenName('colorPrimary')).toBe(true);
expect(isValidTokenName('fontSize')).toBe(true);
expect(isValidTokenName('padding')).toBe(true);
expect(isValidTokenName('borderRadius')).toBe(true);
});
test('isValidTokenName recognizes Superset custom tokens', () => {
expect(isValidTokenName('brandLogoUrl')).toBe(true);
expect(isValidTokenName('brandSpinnerSvg')).toBe(true);
expect(isValidTokenName('fontSizeXS')).toBe(true);
expect(isValidTokenName('echartsOptionsOverrides')).toBe(true);
});
test('isValidTokenName rejects unknown tokens', () => {
expect(isValidTokenName('fooBarBaz')).toBe(false);
expect(isValidTokenName('colrPrimary')).toBe(false);
expect(isValidTokenName('invalidToken')).toBe(false);
});
test('isValidTokenName handles edge cases', () => {
expect(isValidTokenName('')).toBe(false);
expect(isValidTokenName(' ')).toBe(false);
});
test('isSupersetCustomToken identifies Superset-specific tokens', () => {
expect(isSupersetCustomToken('brandLogoUrl')).toBe(true);
expect(isSupersetCustomToken('brandSpinnerSvg')).toBe(true);
expect(isSupersetCustomToken('fontSizeXS')).toBe(true);
expect(isSupersetCustomToken('fontUrls')).toBe(true);
});
test('isSupersetCustomToken returns false for Ant Design tokens', () => {
expect(isSupersetCustomToken('colorPrimary')).toBe(false);
expect(isSupersetCustomToken('fontSize')).toBe(false);
});
test('isSupersetCustomToken returns false for unknown tokens', () => {
expect(isSupersetCustomToken('fooBar')).toBe(false);
});
test('getAllValidTokenNames returns categorized token names', () => {
const result = getAllValidTokenNames();
expect(result).toHaveProperty('antdTokens');
expect(result).toHaveProperty('supersetTokens');
expect(result).toHaveProperty('total');
});
test('getAllValidTokenNames has reasonable token counts', () => {
const result = getAllValidTokenNames();
// Ant Design tokens should exist (avoid brittle exact count that breaks on upgrades)
expect(result.antdTokens.length).toBeGreaterThan(0);
expect(result.antdTokens).toContain('colorPrimary');
expect(result.antdTokens).toContain('fontSize');
expect(result.antdTokens).toContain('borderRadius');
// Superset custom tokens should exist
expect(result.supersetTokens.length).toBeGreaterThan(0);
expect(result.supersetTokens).toContain('brandLogoUrl');
expect(result.supersetTokens).toContain('fontUrls');
// Total should be sum of both
expect(result.total).toBe(
result.antdTokens.length + result.supersetTokens.length,
);
});
test('getAllValidTokenNames includes known Superset tokens', () => {
const result = getAllValidTokenNames();
expect(result.supersetTokens).toContain('brandLogoUrl');
expect(result.supersetTokens).toContain('brandSpinnerSvg');
expect(result.supersetTokens).toContain('fontSizeXS');
});
test('getAllValidTokenNames includes known Ant Design tokens', () => {
const result = getAllValidTokenNames();
expect(result.antdTokens).toContain('colorPrimary');
expect(result.antdTokens).toContain('fontSize');
expect(result.antdTokens).toContain('padding');
});

View File

@@ -0,0 +1,115 @@
/**
* 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 { theme } from 'antd';
/**
* Superset-specific custom tokens that extend Ant Design's token system.
* These keys are derived from the SupersetSpecificTokens interface to ensure consistency.
*/
const SUPERSET_CUSTOM_TOKENS: Set<string> = new Set([
// Font extensions (fontWeightStrong is an Ant Design token, not Superset-specific)
'fontSizeXS',
'fontSizeXXL',
'fontWeightNormal',
'fontWeightLight',
// Brand tokens
'brandIconMaxWidth',
'brandLogoAlt',
'brandLogoUrl',
'brandLogoMargin',
'brandLogoHref',
'brandLogoHeight',
// Spinner tokens
'brandSpinnerUrl',
'brandSpinnerSvg',
// ECharts tokens
'echartsOptionsOverrides',
'echartsOptionsOverridesByChartType',
// Font loading
'fontUrls',
]);
/**
* Lazy-loaded cache of valid token names.
* Combines Ant Design tokens (extracted at runtime) + Superset custom tokens.
*/
let validTokenNamesCache: Set<string> | undefined;
/**
* Get all valid token names (Ant Design + Superset custom).
* Uses lazy loading and caching for performance.
*/
function getValidTokenNames(): Set<string> {
if (validTokenNamesCache === undefined) {
// Extract all token names from Ant Design's default theme
const antdTokens = theme.getDesignToken();
const antdTokenNames = Object.keys(antdTokens);
// Combine with Superset custom tokens
validTokenNamesCache = new Set([
...antdTokenNames,
...SUPERSET_CUSTOM_TOKENS,
]);
}
return validTokenNamesCache;
}
/**
* Check if a token name is valid (recognized by Ant Design OR Superset).
* @param tokenName - The token name to validate
* @returns true if the token is recognized, false otherwise
*/
export function isValidTokenName(tokenName: string): boolean {
return getValidTokenNames().has(tokenName);
}
/**
* Check if a token is a Superset custom token (not from Ant Design).
* @param tokenName - The token name to check
* @returns true if it's a Superset-specific token
*/
export function isSupersetCustomToken(tokenName: string): boolean {
return SUPERSET_CUSTOM_TOKENS.has(tokenName);
}
/**
* Get all valid token names, categorized by source.
* Useful for debugging and testing.
*/
export function getAllValidTokenNames(): {
antdTokens: string[];
supersetTokens: string[];
total: number;
} {
const allTokens = getValidTokenNames();
const antdTokens = Array.from(allTokens).filter(
t => !isSupersetCustomToken(t),
);
const supersetTokens: string[] = Array.from(SUPERSET_CUSTOM_TOKENS);
return {
antdTokens,
supersetTokens,
total: allTokens.size,
};
}

View File

@@ -0,0 +1,346 @@
/**
* 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 } from '@apache-superset/core/ui';
import { validateTheme } from './themeStructureValidation';
test('validateTheme validates a valid theme with standard tokens', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
fontSize: 14,
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
test('validateTheme validates a theme with Superset custom tokens', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
brandLogoUrl: '/static/logo.png',
brandSpinnerSvg: '<svg></svg>',
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(0);
});
test('validateTheme warns about unknown token names', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
fooBarBaz: 'invalid',
colrPrimary: '#ff0000', // Typo
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true); // Warnings don't block
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(2);
expect(result.warnings[0].tokenName).toBe('fooBarBaz');
expect(result.warnings[1].tokenName).toBe('colrPrimary');
expect(result.warnings[0].severity).toBe('warning');
});
test('validateTheme warns about null/undefined token values', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: null,
fontSize: undefined,
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.warnings).toHaveLength(2);
expect(result.warnings[0].message).toContain('null/undefined');
expect(result.warnings[1].message).toContain('null/undefined');
});
test('validateTheme errors on empty theme object', () => {
const theme: AnyThemeConfig = {};
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('cannot be empty');
});
test('validateTheme errors on null theme config', () => {
const result = validateTheme(null as any);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('must be a valid object');
});
test('validateTheme allows theme with only algorithm', () => {
const theme: AnyThemeConfig = {
algorithm: 'dark' as any,
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('validateTheme allows theme with only components', () => {
const theme: AnyThemeConfig = {
components: {
Button: {
colorPrimary: '#1890ff',
},
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('validateTheme errors on theme with empty token object but no algorithm or components', () => {
const theme: AnyThemeConfig = {
token: {},
};
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('cannot be empty');
});
test('validateTheme combines errors and warnings correctly', () => {
const theme: AnyThemeConfig = {
token: {
colorPrimary: '#1890ff',
unknownToken: 'value',
nullToken: null,
},
};
const result = validateTheme(theme);
expect(result.valid).toBe(true); // No errors, just warnings
expect(result.errors).toHaveLength(0);
expect(result.warnings.length).toBeGreaterThan(0);
});
test('validateTheme errors when token is an array instead of object', () => {
const theme = {
token: ['colorPrimary', '#1890ff'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('must be an object');
});
test('validateTheme errors when token is a string instead of object', () => {
const theme = {
token: 'colorPrimary',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('must be an object');
});
test('validateTheme errors when components is an array instead of object', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
components: ['Button', 'Input'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Components configuration');
expect(result.errors[0].message).toContain('must be an object');
});
test('validateTheme errors when components is a primitive', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
components: 'Button',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Components configuration');
});
test('validateTheme errors when algorithm is a number', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: 123,
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Algorithm must be a string');
});
test('validateTheme errors when algorithm is an object', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: { type: 'dark' },
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Algorithm must be a string');
});
test('validateTheme allows algorithm as array of strings', () => {
const theme = {
algorithm: ['dark', 'compact'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
test('validateTheme errors when algorithm array contains non-strings', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: ['dark', 123, 'compact'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Algorithm must be a string');
});
test('validateTheme errors when token is explicitly null', () => {
const theme = {
token: null,
algorithm: 'dark',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('must be an object');
expect(result.errors[0].message).toContain('not null');
});
test('validateTheme errors when components is explicitly null', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
components: null,
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Components configuration');
expect(result.errors[0].message).toContain('not null');
});
test('validateTheme errors when algorithm is explicitly null', () => {
const theme = {
token: { colorPrimary: '#1890ff' },
algorithm: null,
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Algorithm cannot be null');
});
test('validateTheme errors when algorithm string is not a valid value', () => {
const theme = {
algorithm: 'invalid-algorithm',
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].tokenName).toBe('_root');
expect(result.errors[0].message).toContain('Invalid algorithm value');
expect(result.errors[0].message).toContain('invalid-algorithm');
expect(result.errors[0].message).toContain('default, dark, system, compact');
});
test('validateTheme errors when algorithm array contains invalid values', () => {
const theme = {
algorithm: ['dark', 'invalid-mode', 'compact'],
} as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].message).toContain('Invalid algorithm value');
expect(result.errors[0].message).toContain('invalid-mode');
});
test('validateTheme allows all valid algorithm values', () => {
const validAlgorithms = ['default', 'dark', 'system', 'compact'];
validAlgorithms.forEach(algo => {
const theme = { algorithm: algo } as unknown as AnyThemeConfig;
const result = validateTheme(theme);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,191 @@
/**
* 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 } from '@apache-superset/core/ui';
import { isValidTokenName } from './antdTokenNames';
/**
* Valid algorithm values that match backend ThemeMode enum.
* These correspond to Ant Design's built-in theme algorithms.
*/
const VALID_ALGORITHM_VALUES = new Set([
'default',
'dark',
'system',
'compact',
]);
export interface ValidationIssue {
tokenName: string;
severity: 'error' | 'warning';
message: string;
}
export interface ValidationResult {
valid: boolean; // false if ANY errors exist (warnings don't affect this)
errors: ValidationIssue[];
warnings: ValidationIssue[];
}
/**
* Validates theme structure and token names.
* - ERRORS block save/apply (invalid structure, empty themes)
* - WARNINGS allow save/apply but show in editor (unknown tokens, null values)
*
* This validation does NOT check token values - Ant Design handles that at runtime.
*/
export function validateTheme(themeConfig: AnyThemeConfig): ValidationResult {
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
// ERROR: Null/invalid config
if (!themeConfig || typeof themeConfig !== 'object') {
errors.push({
tokenName: '_root',
severity: 'error',
message: 'Theme configuration must be a valid object',
});
return { valid: false, errors, warnings };
}
// ERROR: Empty theme (no tokens, no algorithm, no components)
const hasTokens =
themeConfig.token && Object.keys(themeConfig.token).length > 0;
const hasAlgorithm = Boolean(themeConfig.algorithm);
const hasComponents =
themeConfig.components && Object.keys(themeConfig.components).length > 0;
if (!hasTokens && !hasAlgorithm && !hasComponents) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Theme cannot be empty. Add at least one token, algorithm, or component override.',
});
return { valid: false, errors, warnings };
}
// ERROR: token must be an object if present (null is also rejected by backend)
const rawToken = themeConfig.token;
if (rawToken !== undefined) {
if (
rawToken === null ||
typeof rawToken !== 'object' ||
Array.isArray(rawToken)
) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Token configuration must be an object, not null, array, or primitive',
});
return { valid: false, errors, warnings };
}
}
const tokens = rawToken ?? {};
// ERROR: components must be an object if present (null is also rejected by backend)
const rawComponents = themeConfig.components;
if (rawComponents !== undefined) {
if (
rawComponents === null ||
typeof rawComponents !== 'object' ||
Array.isArray(rawComponents)
) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Components configuration must be an object, not null, array, or primitive',
});
return { valid: false, errors, warnings };
}
}
// ERROR: algorithm must be a valid string or array of valid strings if present
// Valid values: "default", "dark", "system", "compact" (matches backend ThemeMode)
const rawAlgorithm = themeConfig.algorithm;
if (rawAlgorithm !== undefined) {
// Null is rejected by backend
if (rawAlgorithm === null) {
errors.push({
tokenName: '_root',
severity: 'error',
message: 'Algorithm cannot be null',
});
return { valid: false, errors, warnings };
}
// Must be string or array of strings
const isString = typeof rawAlgorithm === 'string';
const isStringArray =
Array.isArray(rawAlgorithm) &&
rawAlgorithm.every(a => typeof a === 'string');
if (!isString && !isStringArray) {
errors.push({
tokenName: '_root',
severity: 'error',
message:
'Algorithm must be a string or array of strings (e.g., "dark" or ["dark", "compact"])',
});
return { valid: false, errors, warnings };
}
// Validate algorithm values against allowed set
const algorithms = isString ? [rawAlgorithm] : (rawAlgorithm as string[]);
const invalidAlgorithms = algorithms.filter(
a => !VALID_ALGORITHM_VALUES.has(a),
);
if (invalidAlgorithms.length > 0) {
errors.push({
tokenName: '_root',
severity: 'error',
message: `Invalid algorithm value(s): "${invalidAlgorithms.join('", "')}". Valid values are: default, dark, system, compact`,
});
return { valid: false, errors, warnings };
}
}
Object.entries(tokens).forEach(([name, value]) => {
// Null/undefined check
if (value === null || value === undefined) {
warnings.push({
tokenName: name,
severity: 'warning',
message: `Token '${name}' has null/undefined value`,
});
return;
}
// Token name validation
if (!isValidTokenName(name)) {
warnings.push({
tokenName: name,
severity: 'warning',
message: `Unknown token '${name}' - may be ignored by Ant Design`,
});
}
});
return {
valid: errors.length === 0,
errors,
warnings,
};
}