Skip to content

Commit d4eb799

Browse files
fix(theming): address PR comments + some fixes and improvements
1 parent 1559605 commit d4eb799

File tree

11 files changed

+765
-284
lines changed

11 files changed

+765
-284
lines changed

superset-frontend/packages/superset-ui-core/src/theme/Theme.test.tsx

Lines changed: 87 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -427,8 +427,8 @@ describe('Theme', () => {
427427
});
428428
});
429429

430-
describe('BASE_THEME integration tests', () => {
431-
it('merges BASE_THEME tokens with empty user theme', () => {
430+
describe('base theme integration tests', () => {
431+
it('merges base theme tokens with empty user theme', () => {
432432
const baseTheme: AnyThemeConfig = {
433433
token: {
434434
colorPrimary: '#2893B3',
@@ -452,7 +452,7 @@ describe('Theme', () => {
452452
expect(serialized.algorithm).toBe(ThemeAlgorithm.DEFAULT);
453453
});
454454

455-
it('allows user theme to override specific BASE_THEME tokens', () => {
455+
it('allows user theme to override specific base theme tokens', () => {
456456
const baseTheme: AnyThemeConfig = {
457457
token: {
458458
colorPrimary: '#2893B3',
@@ -481,7 +481,7 @@ describe('Theme', () => {
481481
expect(theme.theme.borderRadius).toBe(4);
482482
});
483483

484-
it('handles BASE_THEME_DARK with dark algorithm correctly', () => {
484+
it('handles base theme with dark algorithm correctly', () => {
485485
const baseTheme: AnyThemeConfig = {
486486
token: {
487487
colorPrimary: '#2893B3',
@@ -508,8 +508,8 @@ describe('Theme', () => {
508508
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
509509
});
510510

511-
it('works with real-world Superset BASE_THEME configuration', () => {
512-
// Simulate actual Superset BASE_THEME
511+
it('works with real-world Superset base theme configuration', () => {
512+
// Simulate actual Superset base theme (THEME_DEFAULT/THEME_DARK from config)
513513
const supersetBaseTheme: AnyThemeConfig = {
514514
token: {
515515
colorPrimary: '#2893B3',
@@ -548,7 +548,7 @@ describe('Theme', () => {
548548
expect(darkSerialized.algorithm).toBe(ThemeAlgorithm.DARK);
549549
});
550550

551-
it('handles component overrides in BASE_THEME', () => {
551+
it('handles component overrides in base theme', () => {
552552
const baseTheme: AnyThemeConfig = {
553553
token: {
554554
colorPrimary: '#2893B3',
@@ -704,5 +704,85 @@ describe('Theme', () => {
704704
// fontFamily reverts to Ant Design default since base theme is not reapplied
705705
expect(theme.theme.fontFamily).not.toBe('Inter');
706706
});
707+
708+
it('minimal theme preserves ALL base theme tokens except overridden ones', () => {
709+
// Simulate a comprehensive base theme with many tokens
710+
const baseTheme: AnyThemeConfig = {
711+
token: {
712+
colorPrimary: '#2893B3',
713+
colorError: '#e04355',
714+
colorWarning: '#fcc700',
715+
colorSuccess: '#5ac189',
716+
colorInfo: '#66bcfe',
717+
fontFamily: 'Inter, Helvetica',
718+
fontSize: 14,
719+
borderRadius: 4,
720+
lineWidth: 1,
721+
controlHeight: 32,
722+
// Custom Superset tokens
723+
brandLogoAlt: 'CustomLogo',
724+
menuHoverBackgroundColor: '#eeeeee',
725+
} as Record<string, any>,
726+
algorithm: antdThemeImport.defaultAlgorithm,
727+
};
728+
729+
// Minimal theme that only overrides primary color and algorithm
730+
const minimalTheme: AnyThemeConfig = {
731+
token: {
732+
colorPrimary: '#ff05dd', // Only override this
733+
},
734+
algorithm: antdThemeImport.darkAlgorithm, // Change to dark
735+
};
736+
737+
const theme = Theme.fromConfig(minimalTheme, baseTheme);
738+
739+
// User's override should apply
740+
expect(theme.theme.colorPrimary).toBe('#ff05dd');
741+
742+
// ALL base theme tokens should be preserved
743+
expect(theme.theme.colorError).toBe('#e04355');
744+
expect(theme.theme.colorWarning).toBe('#fcc700');
745+
expect(theme.theme.colorSuccess).toBe('#5ac189');
746+
expect(theme.theme.colorInfo).toBe('#66bcfe');
747+
expect(theme.theme.fontFamily).toBe('Inter, Helvetica');
748+
expect(theme.theme.fontSize).toBe(14);
749+
expect(theme.theme.borderRadius).toBe(4);
750+
expect(theme.theme.lineWidth).toBe(1);
751+
expect(theme.theme.controlHeight).toBe(32);
752+
753+
// Custom tokens should also be preserved
754+
expect((theme.theme as any).brandLogoAlt).toBe('CustomLogo');
755+
expect((theme.theme as any).menuHoverBackgroundColor).toBe('#eeeeee');
756+
757+
// Algorithm should be updated
758+
const serialized = theme.toSerializedConfig();
759+
expect(serialized.algorithm).toBe(ThemeAlgorithm.DARK);
760+
});
761+
762+
it('arrays in themes are replaced entirely, not merged by index', () => {
763+
const baseTheme: AnyThemeConfig = {
764+
token: {
765+
colorPrimary: '#2893B3',
766+
},
767+
algorithm: [
768+
antdThemeImport.compactAlgorithm,
769+
antdThemeImport.defaultAlgorithm,
770+
],
771+
};
772+
773+
const userTheme: AnyThemeConfig = {
774+
algorithm: [antdThemeImport.darkAlgorithm], // Replace with single item array
775+
};
776+
777+
const theme = Theme.fromConfig(userTheme, baseTheme);
778+
const serialized = theme.toSerializedConfig();
779+
780+
// User's array should completely replace base array
781+
expect(Array.isArray(serialized.algorithm)).toBe(true);
782+
expect(serialized.algorithm).toHaveLength(1);
783+
expect(serialized.algorithm).toContain(ThemeAlgorithm.DARK);
784+
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.COMPACT);
785+
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.DEFAULT);
786+
});
707787
});
708788
});

superset-frontend/packages/superset-ui-core/src/theme/Theme.tsx

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
CacheProvider as EmotionCacheProvider,
2626
} from '@emotion/react';
2727
import createCache from '@emotion/cache';
28-
import { noop } from 'lodash';
28+
import { noop, mergeWith } from 'lodash';
2929
import { GlobalStyles } from './GlobalStyles';
3030
import {
3131
AntdThemeConfig,
@@ -63,33 +63,9 @@ export class Theme {
6363
let mergedConfig: AnyThemeConfig | undefined = config;
6464

6565
if (baseTheme && config) {
66-
mergedConfig = {
67-
...baseTheme,
68-
...config,
69-
} as AnyThemeConfig;
70-
71-
if (baseTheme.token || config.token) {
72-
(mergedConfig as any).token = {
73-
...(baseTheme.token || {}),
74-
...(config.token || {}),
75-
};
76-
}
77-
78-
if (baseTheme.components || config.components) {
79-
const mergedComponents: any = { ...(baseTheme.components || {}) };
80-
81-
if (config.components) {
82-
const configComponents = config.components as any;
83-
Object.keys(configComponents).forEach(componentName => {
84-
mergedComponents[componentName] = {
85-
...(mergedComponents[componentName] || {}),
86-
...configComponents[componentName],
87-
};
88-
});
89-
}
90-
91-
(mergedConfig as any).components = mergedComponents;
92-
}
66+
mergedConfig = mergeWith({}, baseTheme, config, (objValue, srcValue) =>
67+
Array.isArray(srcValue) ? srcValue : undefined,
68+
);
9369
} else if (baseTheme && !config) {
9470
mergedConfig = baseTheme;
9571
}

superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import { getFontSize, getColorVariants, isThemeDark } from './themeUtils';
19+
import { theme as antdTheme } from 'antd';
20+
import {
21+
getFontSize,
22+
getColorVariants,
23+
isThemeDark,
24+
isThemeConfigDark,
25+
} from './themeUtils';
2026
import { Theme } from '../Theme';
2127
import { ThemeAlgorithm } from '../types';
2228

@@ -130,4 +136,111 @@ describe('themeUtils', () => {
130136
expect(variants.bg).toBeUndefined();
131137
});
132138
});
139+
140+
describe('isThemeConfigDark', () => {
141+
it('returns true for config with dark algorithm', () => {
142+
const config = {
143+
algorithm: antdTheme.darkAlgorithm,
144+
};
145+
expect(isThemeConfigDark(config)).toBe(true);
146+
});
147+
148+
it('returns true for config with dark algorithm in array', () => {
149+
const config = {
150+
algorithm: [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm],
151+
};
152+
expect(isThemeConfigDark(config)).toBe(true);
153+
});
154+
155+
it('returns false for config without dark algorithm', () => {
156+
const config = {
157+
algorithm: antdTheme.defaultAlgorithm,
158+
};
159+
expect(isThemeConfigDark(config)).toBe(false);
160+
});
161+
162+
it('returns false for config with no algorithm', () => {
163+
const config = {
164+
token: {
165+
colorPrimary: '#1890ff',
166+
},
167+
};
168+
expect(isThemeConfigDark(config)).toBe(false);
169+
});
170+
171+
it('detects manually-created dark theme without dark algorithm', () => {
172+
// This is the edge case: dark colors without dark algorithm
173+
const config = {
174+
token: {
175+
colorBgContainer: '#1a1a1a', // Dark background
176+
colorBgBase: '#0a0a0a', // Dark base
177+
colorText: '#ffffff', // Light text
178+
},
179+
};
180+
expect(isThemeConfigDark(config)).toBe(true);
181+
});
182+
183+
it('does not false-positive on light theme with custom colors', () => {
184+
const config = {
185+
token: {
186+
colorBgContainer: '#ffffff', // Light background
187+
colorBgBase: '#f5f5f5', // Light base
188+
colorText: '#000000', // Dark text
189+
},
190+
};
191+
expect(isThemeConfigDark(config)).toBe(false);
192+
});
193+
194+
it('handles partial color tokens gracefully', () => {
195+
// With actual theme computation, a dark colorBgContainer results in a dark theme
196+
const config = {
197+
token: {
198+
colorBgContainer: '#1a1a1a', // Dark background
199+
// Missing other color tokens
200+
},
201+
};
202+
expect(isThemeConfigDark(config)).toBe(true);
203+
});
204+
205+
it('respects colorBgContainer as the primary indicator', () => {
206+
// The computed theme uses colorBgContainer as the main background
207+
const darkConfig = {
208+
token: {
209+
colorBgContainer: '#1a1a1a', // Dark background
210+
colorText: '#000000', // Dark text (unusual but doesn't override)
211+
},
212+
};
213+
expect(isThemeConfigDark(darkConfig)).toBe(true);
214+
215+
const lightConfig = {
216+
token: {
217+
colorBgContainer: '#ffffff', // Light background
218+
colorText: '#ffffff', // Light text (unusual but doesn't override)
219+
},
220+
};
221+
expect(isThemeConfigDark(lightConfig)).toBe(false);
222+
});
223+
224+
it('handles non-string color tokens gracefully', () => {
225+
const config = {
226+
token: {
227+
colorBgContainer: undefined,
228+
colorText: null,
229+
colorBgBase: 123, // Invalid type
230+
},
231+
};
232+
expect(isThemeConfigDark(config)).toBe(false);
233+
});
234+
235+
it('returns false for empty config', () => {
236+
expect(isThemeConfigDark({})).toBe(false);
237+
});
238+
239+
it('returns false for config with empty token object', () => {
240+
const config = {
241+
token: {},
242+
};
243+
expect(isThemeConfigDark(config)).toBe(false);
244+
});
245+
});
133246
});

superset-frontend/packages/superset-ui-core/src/theme/utils/themeUtils.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@
1818
*/
1919
import tinycolor from 'tinycolor2';
2020
import { useTheme as useEmotionTheme } from '@emotion/react';
21-
import type { SupersetTheme, FontSizeKey, ColorVariants } from '../types';
21+
import { theme as antdTheme } from 'antd';
22+
import type {
23+
SupersetTheme,
24+
FontSizeKey,
25+
ColorVariants,
26+
AnyThemeConfig,
27+
} from '../types';
28+
import { normalizeThemeConfig } from '../utils';
2229

2330
const fontSizeMap: Record<FontSizeKey, keyof SupersetTheme> = {
2431
xs: 'fontSizeXS',
@@ -113,6 +120,22 @@ export function isThemeDark(theme: SupersetTheme): boolean {
113120
return tinycolor(theme.colorBgContainer).isDark();
114121
}
115122

123+
/**
124+
* Check if a theme configuration results in a dark theme
125+
* @param config - The theme configuration to check
126+
* @returns true if the config results in a dark theme, false otherwise
127+
*/
128+
export function isThemeConfigDark(config: AnyThemeConfig): boolean {
129+
try {
130+
const normalizedConfig = normalizeThemeConfig(config);
131+
const themeConfig = antdTheme.getDesignToken(normalizedConfig);
132+
133+
return tinycolor(themeConfig.colorBgContainer).isDark();
134+
} catch {
135+
return false;
136+
}
137+
}
138+
116139
/**
117140
* Hook to determine if the current theme is dark mode
118141
* @returns true if theme is dark, false if light

0 commit comments

Comments
 (0)