diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index d2f9ca5b310..d4c0646fbbc 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 33a398e8a8e..040bc6ee2e4 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 1dc7f12dfd0..13de2a78d59 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; +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(); + + 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(); + + 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(); + + 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( + , + ); + + 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(); + + // 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 0c1c7f88208..1931fc692b0 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([]); + const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [originalTemplateContent, setOriginalTemplateContent] = + useState(''); + + // 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 && ( + + ({ - value: theme.id, - label: theme.theme_name, - }))} - allowClear - placeholder={t('Select a theme')} + - )} - 0 && ( + +