This is an automated email from the ASF dual-hosted git repository. sophieyou pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push: new dea9068647 fix(DashboardEditor): CSS template selector UI in dashboard properties modal restored (#35106) dea9068647 is described below commit dea906864728807ca61b3aeef4bffdb03a0212fa Author: Rafael Benitez <rebenitez1...@gmail.com> AuthorDate: Thu Sep 11 19:34:16 2025 -0300 fix(DashboardEditor): CSS template selector UI in dashboard properties modal restored (#35106) --- superset-frontend/package-lock.json | 5 +- .../dashboard/components/PropertiesModal/index.tsx | 1 + .../sections/StylingSection.test.tsx | 110 ++++++++++- .../PropertiesModal/sections/StylingSection.tsx | 214 ++++++++++++++++----- 4 files changed, 274 insertions(+), 56 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index d2f9ca5b31..d4c0646fbb 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -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", diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index 33a398e8a8..040bc6ee2e 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -715,6 +715,7 @@ const PropertiesModal = ({ onThemeChange={handleThemeChange} onColorSchemeChange={onColorSchemeChange} onCustomCssChange={setCustomCss} + addDangerToast={addDangerToast} /> ), }, diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx index 1dc7f12dfd..13de2a78d5 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.test.tsx @@ -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(); + }); +}); diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx index 0c1c7f8820..1931fc692b 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/StylingSection.tsx @@ -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", - )} - > - <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.', + {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" + /> )} - 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;