This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch feat-granular-theming in repository https://gitbox.apache.org/repos/asf/superset.git
commit cb8956b19b2f764ee1b80c7708e993e3a949c2cd Author: Evan Rusackas <[email protected]> AuthorDate: Mon Dec 15 21:05:06 2025 -0800 feat(dashboard): add ThemeSelectorModal for component-level theming Implements the theme selector modal that appears when clicking "Apply theme" on dashboard components. The modal: - Fetches available themes from /api/v1/theme/ API - Displays themes in a dropdown with Default/Dark badges - Stores selected theme ID in component metadata For Markdown components, the theme_id is stored in component.meta. Backend persistence to json_metadata.component_themes will be added in Phase 3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --- docs/GRANULAR_THEMING_PLAN.md | 25 +++ .../gridComponents/Markdown/Markdown.jsx | 176 ++++++++++----- .../components/menu/ThemeSelectorModal/index.tsx | 250 +++++++++++++++++++++ 3 files changed, 389 insertions(+), 62 deletions(-) diff --git a/docs/GRANULAR_THEMING_PLAN.md b/docs/GRANULAR_THEMING_PLAN.md index 3dcf60bf5e0..1b1153bf5cd 100644 --- a/docs/GRANULAR_THEMING_PLAN.md +++ b/docs/GRANULAR_THEMING_PLAN.md @@ -395,3 +395,28 @@ _Ongoing notes as we implement..._ **Status:** Phase 2.2 complete, ready for Phase 2.3 (Row/Column) +### Session 2 - ThemeSelectorModal Implementation +- Created `ThemeSelectorModal` component at: + `src/dashboard/components/menu/ThemeSelectorModal/index.tsx` + - Fetches themes from `/api/v1/theme/` API + - Shows dropdown with theme names and badges (Default, Dark) + - Apply/Cancel buttons + - Stores selected theme ID in component metadata + +- Wired up ThemeSelectorModal to Markdown component: + - Added `isThemeSelectorOpen` state + - Added `handleOpenThemeSelector`, `handleCloseThemeSelector`, `handleApplyTheme` methods + - `handleApplyTheme` stores `theme_id` in component.meta via `updateComponents` + - Modal opens when clicking "Apply theme" menu item + +**Files created:** +- `src/dashboard/components/menu/ThemeSelectorModal/index.tsx` + +**Files modified:** +- `src/dashboard/components/gridComponents/Markdown/Markdown.jsx` + +**Status:** ThemeSelectorModal complete, all tests pass + +**Note:** Theme selection is stored in component metadata (client-side). +Backend persistence (Phase 3) will save this to dashboard `json_metadata.component_themes`. + diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx index 1361ce7c5e6..4e34d94fd81 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.jsx @@ -29,6 +29,7 @@ import ComponentHeaderControls, { ComponentMenuKeys, } from 'src/dashboard/components/menu/ComponentHeaderControls'; import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; +import ThemeSelectorModal from 'src/dashboard/components/menu/ThemeSelectorModal'; import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils'; import { Draggable } from 'src/dashboard/components/dnd/DragDroppable'; @@ -130,6 +131,7 @@ class Markdown extends PureComponent { editorMode: 'preview', undoLength: props.undoLength, redoLength: props.redoLength, + isThemeSelectorOpen: false, }; this.renderStartTime = Logger.getTimestamp(); @@ -142,6 +144,9 @@ class Markdown extends PureComponent { this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this); this.handleMenuClick = this.handleMenuClick.bind(this); this.getMenuItems = this.getMenuItems.bind(this); + this.handleOpenThemeSelector = this.handleOpenThemeSelector.bind(this); + this.handleCloseThemeSelector = this.handleCloseThemeSelector.bind(this); + this.handleApplyTheme = this.handleApplyTheme.bind(this); } componentDidMount() { @@ -291,7 +296,7 @@ class Markdown extends PureComponent { this.handleChangeEditorMode('preview'); break; case ComponentMenuKeys.ApplyTheme: - // TODO: Open theme selector modal + this.handleOpenThemeSelector(); break; case ComponentMenuKeys.Delete: this.handleDeleteComponent(); @@ -301,6 +306,43 @@ class Markdown extends PureComponent { } } + handleOpenThemeSelector() { + this.setState({ isThemeSelectorOpen: true }); + } + + handleCloseThemeSelector() { + this.setState({ isThemeSelectorOpen: false }); + } + + handleApplyTheme(themeId) { + const { updateComponents, component, addDangerToast } = this.props; + + // Store theme ID in component metadata + // For now, just update the component meta - backend persistence comes in Phase 3 + if (themeId !== null) { + updateComponents({ + [component.id]: { + ...component, + meta: { + ...component.meta, + theme_id: themeId, + }, + }, + }); + } else { + // Clear theme + const { theme_id: _, ...metaWithoutTheme } = component.meta; + updateComponents({ + [component.id]: { + ...component, + meta: metaWithoutTheme, + }, + }); + } + + this.handleCloseThemeSelector(); + } + getMenuItems() { const { editorMode } = this.state; const isEditing = editorMode === 'edit'; @@ -403,71 +445,81 @@ class Markdown extends PureComponent { const isEditing = editorMode === 'edit'; return ( - <Draggable - component={component} - parentComponent={parentComponent} - orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'} - index={index} - depth={depth} - onDrop={handleComponentDrop} - disableDragDrop={isFocused} - editMode={editMode} - > - {({ dragSourceRef }) => ( - <WithPopoverMenu - onChangeFocus={this.handleChangeFocus} - shouldFocus={this.shouldFocusMarkdown} - menuItems={[]} - editMode={editMode} - > - <MarkdownStyles - data-test="dashboard-markdown-editor" - className={cx( - 'dashboard-markdown', - isEditing && 'dashboard-markdown--editing', - )} - id={component.id} + <> + <Draggable + component={component} + parentComponent={parentComponent} + orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'} + index={index} + depth={depth} + onDrop={handleComponentDrop} + disableDragDrop={isFocused} + editMode={editMode} + > + {({ dragSourceRef }) => ( + <WithPopoverMenu + onChangeFocus={this.handleChangeFocus} + shouldFocus={this.shouldFocusMarkdown} + menuItems={[]} + editMode={editMode} > - <ResizableContainer + <MarkdownStyles + data-test="dashboard-markdown-editor" + className={cx( + 'dashboard-markdown', + isEditing && 'dashboard-markdown--editing', + )} id={component.id} - adjustableWidth={parentComponent.type === ROW_TYPE} - adjustableHeight - widthStep={columnWidth} - widthMultiple={widthMultiple} - heightStep={GRID_BASE_UNIT} - heightMultiple={component.meta.height} - minWidthMultiple={GRID_MIN_COLUMN_COUNT} - minHeightMultiple={GRID_MIN_ROW_UNITS} - maxWidthMultiple={availableColumnCount + widthMultiple} - onResizeStart={this.handleResizeStart} - onResize={onResize} - onResizeStop={onResizeStop} - editMode={isFocused ? false : editMode} > - <div - ref={dragSourceRef} - className="dashboard-component dashboard-component-chart-holder" - data-test="dashboard-component-chart-holder" + <ResizableContainer + id={component.id} + adjustableWidth={parentComponent.type === ROW_TYPE} + adjustableHeight + widthStep={columnWidth} + widthMultiple={widthMultiple} + heightStep={GRID_BASE_UNIT} + heightMultiple={component.meta.height} + minWidthMultiple={GRID_MIN_COLUMN_COUNT} + minHeightMultiple={GRID_MIN_ROW_UNITS} + maxWidthMultiple={availableColumnCount + widthMultiple} + onResizeStart={this.handleResizeStart} + onResize={onResize} + onResizeStop={onResizeStop} + editMode={isFocused ? false : editMode} > - {editMode && ( - <HoverMenu position="top"> - <ComponentHeaderControls - componentId={component.id} - menuItems={this.getMenuItems()} - onMenuClick={this.handleMenuClick} - editMode={editMode} - /> - </HoverMenu> - )} - {editMode && isEditing - ? this.renderEditMode() - : this.renderPreviewMode()} - </div> - </ResizableContainer> - </MarkdownStyles> - </WithPopoverMenu> - )} - </Draggable> + <div + ref={dragSourceRef} + className="dashboard-component dashboard-component-chart-holder" + data-test="dashboard-component-chart-holder" + > + {editMode && ( + <HoverMenu position="top"> + <ComponentHeaderControls + componentId={component.id} + menuItems={this.getMenuItems()} + onMenuClick={this.handleMenuClick} + editMode={editMode} + /> + </HoverMenu> + )} + {editMode && isEditing + ? this.renderEditMode() + : this.renderPreviewMode()} + </div> + </ResizableContainer> + </MarkdownStyles> + </WithPopoverMenu> + )} + </Draggable> + <ThemeSelectorModal + show={this.state.isThemeSelectorOpen} + onHide={this.handleCloseThemeSelector} + onApply={this.handleApplyTheme} + currentThemeId={component.meta.theme_id || null} + componentId={component.id} + componentType="Markdown" + /> + </> ); } } diff --git a/superset-frontend/src/dashboard/components/menu/ThemeSelectorModal/index.tsx b/superset-frontend/src/dashboard/components/menu/ThemeSelectorModal/index.tsx new file mode 100644 index 00000000000..7ded1d16317 --- /dev/null +++ b/superset-frontend/src/dashboard/components/menu/ThemeSelectorModal/index.tsx @@ -0,0 +1,250 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { t, SupersetClient } from '@superset-ui/core'; +import { css, styled, useTheme } from '@apache-superset/core/ui'; +import { Modal, Select, Button } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { Typography } from '@superset-ui/core/components/Typography'; + +interface Theme { + id: number; + theme_name: string; + is_system?: boolean; + is_system_default?: boolean; + is_system_dark?: boolean; +} + +export interface ThemeSelectorModalProps { + /** Whether the modal is visible */ + show: boolean; + /** Callback when modal is closed */ + onHide: () => void; + /** Callback when a theme is applied */ + onApply: (themeId: number | null) => void; + /** Currently applied theme ID (if any) */ + currentThemeId?: number | null; + /** Component ID for context */ + componentId: string; + /** Component type for display */ + componentType?: string; +} + +const StyledModalContent = styled.div` + ${({ theme }) => css` + .theme-selector-field { + margin-bottom: ${theme.sizeUnit * 4}px; + } + + .theme-selector-label { + display: block; + margin-bottom: ${theme.sizeUnit * 2}px; + font-weight: ${theme.fontWeightMedium}; + } + + .theme-selector-help { + color: ${theme.colorTextSecondary}; + font-size: ${theme.fontSizeSM}px; + margin-top: ${theme.sizeUnit}px; + } + + .theme-badges { + display: inline-flex; + gap: ${theme.sizeUnit}px; + margin-left: ${theme.sizeUnit * 2}px; + } + + .theme-badge { + font-size: ${theme.fontSizeXS}px; + padding: 0 ${theme.sizeUnit}px; + border-radius: ${theme.borderRadiusSM}px; + background: ${theme.colorBgLayout}; + color: ${theme.colorTextSecondary}; + } + + .theme-badge--default { + background: ${theme.colorPrimaryBg}; + color: ${theme.colorPrimary}; + } + + .theme-badge--dark { + background: ${theme.colorBgBase}; + color: ${theme.colorTextBase}; + } + `} +`; + +/** + * Modal for selecting a theme to apply to a dashboard component. + * + * This modal fetches available themes from the API and allows the user + * to select one to apply to a specific component (Markdown, Row, Column, Tab, Chart). + */ +const ThemeSelectorModal = ({ + show, + onHide, + onApply, + currentThemeId = null, + componentId: _componentId, + componentType = 'component', +}: ThemeSelectorModalProps) => { + const theme = useTheme(); + const [themes, setThemes] = useState<Theme[]>([]); + const [selectedThemeId, setSelectedThemeId] = useState<number | null>( + currentThemeId, + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + // Fetch themes from API + const fetchThemes = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await SupersetClient.get({ + endpoint: '/api/v1/theme/', + }); + const themeList = response.json?.result || []; + setThemes(themeList); + } catch { + setError(t('Failed to load themes')); + } finally { + setIsLoading(false); + } + }, []); + + // Fetch themes when modal opens + useEffect(() => { + if (show) { + fetchThemes(); + setSelectedThemeId(currentThemeId); + } + }, [show, currentThemeId, fetchThemes]); + + // Build select options with badges + const themeOptions = useMemo( + () => + themes.map(theme => ({ + value: theme.id, + label: ( + <span> + {theme.theme_name} + {(theme.is_system_default || theme.is_system_dark) && ( + <span className="theme-badges"> + {theme.is_system_default && ( + <span className="theme-badge theme-badge--default"> + {t('Default')} + </span> + )} + {theme.is_system_dark && ( + <span className="theme-badge theme-badge--dark"> + {t('Dark')} + </span> + )} + </span> + )} + </span> + ), + })), + [themes], + ); + + const handleApply = useCallback(() => { + onApply(selectedThemeId); + onHide(); + }, [selectedThemeId, onApply, onHide]); + + const handleClear = useCallback(() => { + setSelectedThemeId(null); + }, []); + + const selectedTheme = useMemo( + () => themes.find(t => t.id === selectedThemeId), + [themes, selectedThemeId], + ); + + return ( + <Modal + show={show} + onHide={onHide} + title={ + <Typography.Title level={4} data-test="theme-selector-modal-title"> + <Icons.BgColorsOutlined + css={css` + margin-right: 8px; + `} + /> + {t('Apply Theme')} + </Typography.Title> + } + footer={[ + <Button key="cancel" onClick={onHide} buttonStyle="secondary"> + {t('Cancel')} + </Button>, + <Button + key="apply" + onClick={handleApply} + buttonStyle="primary" + disabled={isLoading} + > + {selectedThemeId ? t('Apply') : t('Clear Theme')} + </Button>, + ]} + width={500} + centered + > + <StyledModalContent> + <div className="theme-selector-field"> + <label className="theme-selector-label" htmlFor="theme-select"> + {t('Select a theme for this %s', componentType)} + </label> + <Select + id="theme-select" + data-test="theme-selector-select" + value={selectedThemeId} + onChange={(value: number | null) => setSelectedThemeId(value)} + options={themeOptions} + placeholder={t('Select a theme...')} + allowClear + onClear={handleClear} + loading={isLoading} + disabled={isLoading} + style={{ width: '100%' }} + notFoundContent={ + error ? ( + <span style={{ color: theme.colorError }}>{error}</span> + ) : ( + t('No themes available') + ) + } + /> + <div className="theme-selector-help"> + {selectedTheme + ? t('Selected: %s', selectedTheme.theme_name) + : currentThemeId + ? t('Clear selection to remove the current theme') + : t('Select a theme to apply custom styling to this component')} + </div> + </div> + </StyledModalContent> + </Modal> + ); +}; + +export default ThemeSelectorModal;
