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;

Reply via email to