mirror of
https://github.com/apache/superset.git
synced 2026-08-03 20:42:30 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
133
superset-frontend/src/theme/hooks/useThemeValidation.test.ts
Normal file
133
superset-frontend/src/theme/hooks/useThemeValidation.test.ts
Normal 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);
|
||||
});
|
||||
155
superset-frontend/src/theme/hooks/useThemeValidation.ts
Normal file
155
superset-frontend/src/theme/hooks/useThemeValidation.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
108
superset-frontend/src/theme/utils/antdTokenNames.test.ts
Normal file
108
superset-frontend/src/theme/utils/antdTokenNames.test.ts
Normal 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');
|
||||
});
|
||||
115
superset-frontend/src/theme/utils/antdTokenNames.ts
Normal file
115
superset-frontend/src/theme/utils/antdTokenNames.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
191
superset-frontend/src/theme/utils/themeStructureValidation.ts
Normal file
191
superset-frontend/src/theme/utils/themeStructureValidation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user