fix(DashboardEditor): CSS template selector UI in dashboard properties modal restored (#35106)

This commit is contained in:
Rafael Benitez
2025-09-11 19:34:16 -03:00
committed by GitHub
parent 3416bd1479
commit dea9068647
4 changed files with 274 additions and 56 deletions

View File

@@ -60642,7 +60642,7 @@
},
"packages/superset-core": {
"name": "@apache-superset/core",
"version": "0.0.1-rc2",
"version": "0.0.1-rc3",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
@@ -60652,7 +60652,8 @@
"@babel/preset-typescript": "^7.26.0",
"@types/react": "^17.0.83",
"install": "^0.13.0",
"npm": "^11.1.0"
"npm": "^11.1.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"antd": "^5.24.6",

View File

@@ -715,6 +715,7 @@ const PropertiesModal = ({
onThemeChange={handleThemeChange}
onColorSchemeChange={onColorSchemeChange}
onCustomCssChange={setCustomCss}
addDangerToast={addDangerToast}
/>
),
},

View File

@@ -16,9 +16,29 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { SupersetClient, isFeatureEnabled } from '@superset-ui/core';
import StylingSection from './StylingSection';
// Mock SupersetClient
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
get: jest.fn(),
},
isFeatureEnabled: jest.fn(),
}));
const mockSupersetClient = SupersetClient as jest.Mocked<typeof SupersetClient>;
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;
// Mock ColorSchemeSelect component
jest.mock('src/dashboard/components/ColorSchemeSelect', () => ({
__esModule: true,
@@ -33,6 +53,14 @@ jest.mock('src/dashboard/components/ColorSchemeSelect', () => ({
),
}));
const mockCssTemplates = [
{ template_name: 'Corporate Blue', css: '.dashboard { background: blue; }' },
{
template_name: 'Modern Dark',
css: '.dashboard { background: black; color: white; }',
},
];
const defaultProps = {
themes: [
{ id: 1, theme_name: 'Dark Theme' },
@@ -45,10 +73,17 @@ const defaultProps = {
onThemeChange: jest.fn(),
onColorSchemeChange: jest.fn(),
onCustomCssChange: jest.fn(),
addDangerToast: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Reset mocks
mockIsFeatureEnabled.mockReturnValue(false);
mockSupersetClient.get.mockResolvedValue({
json: { result: mockCssTemplates },
response: {} as Response,
});
});
test('renders theme selection when themes are available', () => {
@@ -120,3 +155,76 @@ test('displays current color scheme value', () => {
const colorSchemeInput = screen.getByLabelText('Select color scheme');
expect(colorSchemeInput).toHaveValue('testColors');
});
// CSS Template Tests
describe('CSS Template functionality', () => {
test('does not show CSS template select when feature flag is disabled', () => {
mockIsFeatureEnabled.mockReturnValue(false);
render(<StylingSection {...defaultProps} />);
expect(
screen.queryByTestId('dashboard-css-template-field'),
).not.toBeInTheDocument();
});
test('fetches CSS templates on mount when feature enabled', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
render(<StylingSection {...defaultProps} />);
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalledWith({
endpoint: expect.stringContaining('/api/v1/css_template/'),
});
});
});
test('shows CSS template select when feature flag is enabled and templates exist', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
render(<StylingSection {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByText('Load CSS template (optional)'),
).toBeInTheDocument();
});
expect(
screen.getByTestId('dashboard-css-template-select'),
).toBeInTheDocument();
});
test('shows error toast when template fetch fails', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
const addDangerToast = jest.fn();
mockSupersetClient.get.mockRejectedValueOnce(new Error('API Error'));
render(
<StylingSection {...defaultProps} addDangerToast={addDangerToast} />,
);
await waitFor(() => {
expect(addDangerToast).toHaveBeenCalledWith(
'An error occurred while fetching available CSS templates',
);
});
});
test('does not show CSS template select when no templates available', async () => {
mockIsFeatureEnabled.mockImplementation(flag => flag === 'CSS_TEMPLATES');
mockSupersetClient.get.mockResolvedValueOnce({
json: { result: [] },
response: {} as Response,
});
render(<StylingSection {...defaultProps} />);
// Wait for fetch to complete
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalled();
});
expect(
screen.queryByTestId('dashboard-css-template-field'),
).not.toBeInTheDocument();
});
});

View File

@@ -16,8 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, styled } from '@superset-ui/core';
import { CssEditor, Select } from '@superset-ui/core/components';
import { useCallback, useEffect, useState } from 'react';
import {
t,
styled,
SupersetClient,
isFeatureEnabled,
FeatureFlag,
} from '@superset-ui/core';
import { CssEditor, Select, Alert } from '@superset-ui/core/components';
import rison from 'rison';
import ColorSchemeSelect from 'src/dashboard/components/ColorSchemeSelect';
import { ModalFormField } from 'src/components/Modal';
@@ -26,11 +34,20 @@ const StyledCssEditor = styled(CssEditor)`
border: 1px solid ${({ theme }) => theme.colorBorder};
`;
const StyledAlert = styled(Alert)`
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
`;
interface Theme {
id: number;
theme_name: string;
}
interface CssTemplate {
template_name: string;
css: string;
}
interface StylingSectionProps {
themes: Theme[];
selectedThemeId: number | null;
@@ -43,6 +60,7 @@ interface StylingSectionProps {
options?: { updateMetadata?: boolean },
) => void;
onCustomCssChange: (css: string) => void;
addDangerToast?: (message: string) => void;
}
const StylingSection = ({
@@ -54,63 +72,153 @@ const StylingSection = ({
onThemeChange,
onColorSchemeChange,
onCustomCssChange,
}: StylingSectionProps) => (
<>
{themes.length > 0 && (
addDangerToast,
}: StylingSectionProps) => {
const [cssTemplates, setCssTemplates] = useState<CssTemplate[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [originalTemplateContent, setOriginalTemplateContent] =
useState<string>('');
// Fetch CSS templates
const fetchCssTemplates = useCallback(async () => {
if (!isFeatureEnabled(FeatureFlag.CssTemplates)) return;
setIsLoadingTemplates(true);
try {
const query = rison.encode({ columns: ['template_name', 'css'] });
const response = await SupersetClient.get({
endpoint: `/api/v1/css_template/?q=${query}`,
});
setCssTemplates(response.json.result || []);
} catch (error) {
if (addDangerToast) {
addDangerToast(
t('An error occurred while fetching available CSS templates'),
);
}
} finally {
setIsLoadingTemplates(false);
}
}, [addDangerToast]);
useEffect(() => {
fetchCssTemplates();
}, [fetchCssTemplates]);
// Handle CSS template selection
const handleTemplateSelect = useCallback(
(templateName: string) => {
if (!templateName) {
setSelectedTemplate(null);
setOriginalTemplateContent('');
return;
}
const template = cssTemplates.find(t => t.template_name === templateName);
if (template) {
setSelectedTemplate(templateName);
setOriginalTemplateContent(template.css);
onCustomCssChange(template.css);
}
},
[cssTemplates, onCustomCssChange],
);
// Check if current CSS differs from original template
const hasTemplateModification =
selectedTemplate && customCss !== originalTemplateContent;
return (
<>
{themes.length > 0 && (
<ModalFormField
label={t('Theme')}
testId="dashboard-theme-field"
helperText={t(
'Clear the selection to revert to the system default theme',
)}
>
<Select
data-test="dashboard-theme-select"
value={selectedThemeId}
onChange={onThemeChange}
options={themes.map(theme => ({
value: theme.id,
label: theme.theme_name,
}))}
allowClear
placeholder={t('Select a theme')}
/>
</ModalFormField>
)}
<ModalFormField
label={t('Theme')}
testId="dashboard-theme-field"
label={t('Color scheme')}
testId="dashboard-colorscheme-field"
helperText={t(
'Clear the selection to revert to the system default theme',
"Any color palette selected here will override the colors applied to this dashboard's individual charts",
)}
>
<Select
data-test="dashboard-theme-select"
value={selectedThemeId}
onChange={onThemeChange}
options={themes.map(theme => ({
value: theme.id,
label: theme.theme_name,
}))}
allowClear
placeholder={t('Select a theme')}
<ColorSchemeSelect
data-test="dashboard-colorscheme-select"
value={colorScheme}
onChange={onColorSchemeChange}
hasCustomLabelsColor={hasCustomLabelsColor}
showWarning={hasCustomLabelsColor}
/>
</ModalFormField>
)}
<ModalFormField
label={t('Color scheme')}
testId="dashboard-colorscheme-field"
helperText={t(
"Any color palette selected here will override the colors applied to this dashboard's individual charts",
{isFeatureEnabled(FeatureFlag.CssTemplates) &&
cssTemplates.length > 0 && (
<ModalFormField
label={t('Load CSS template (optional)')}
testId="dashboard-css-template-field"
helperText={t(
'Select a predefined CSS template to apply to your dashboard',
)}
>
<Select
data-test="dashboard-css-template-select"
onChange={handleTemplateSelect}
options={cssTemplates.map(template => ({
value: template.template_name,
label: template.template_name,
}))}
placeholder={t('Select a CSS template')}
loading={isLoadingTemplates}
allowClear
value={selectedTemplate}
/>
</ModalFormField>
)}
{hasTemplateModification && (
<StyledAlert
type="warning"
message={t('Modified from "%s" template', selectedTemplate)}
showIcon
closable={false}
data-test="css-template-modified-warning"
/>
)}
>
<ColorSchemeSelect
data-test="dashboard-colorscheme-select"
value={colorScheme}
onChange={onColorSchemeChange}
hasCustomLabelsColor={hasCustomLabelsColor}
showWarning={hasCustomLabelsColor}
/>
</ModalFormField>
<ModalFormField
label={t('Custom CSS')}
testId="dashboard-css-field"
helperText={t(
'Apply custom CSS to the dashboard. Use class names or element selectors to target specific components.',
)}
bottomSpacing={false}
>
<StyledCssEditor
data-test="dashboard-css-editor"
onChange={onCustomCssChange}
value={customCss}
width="100%"
minLines={10}
maxLines={50}
editorProps={{ $blockScrolling: true }}
/>
</ModalFormField>
</>
);
<ModalFormField
label={t('CSS')}
testId="dashboard-css-field"
helperText={t(
'Apply custom CSS to the dashboard. Use class names or element selectors to target specific components.',
)}
bottomSpacing={false}
>
<StyledCssEditor
data-test="dashboard-css-editor"
onChange={onCustomCssChange}
value={customCss}
width="100%"
minLines={10}
maxLines={50}
editorProps={{ $blockScrolling: true }}
/>
</ModalFormField>
</>
);
};
export default StylingSection;