mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
fix(DashboardEditor): CSS template selector UI in dashboard properties modal restored (#35106)
This commit is contained in:
5
superset-frontend/package-lock.json
generated
5
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -715,6 +715,7 @@ const PropertiesModal = ({
|
||||
onThemeChange={handleThemeChange}
|
||||
onColorSchemeChange={onColorSchemeChange}
|
||||
onCustomCssChange={setCustomCss}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user