Compare commits

...

9 Commits

Author SHA1 Message Date
Evan Rusackas
1917ed0895 fix: update import paths for @apache-superset/core/ui migration
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:11:25 -08:00
Evan Rusackas
052724a578 fix: import jest-dom for toBeInTheDocument matcher
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:57:35 -08:00
Evan Rusackas
4b81028f3b fix: add license header and fix ComponentThemeProvider tests
- Add Apache license header to GRANULAR_THEMING_PLAN.md
- Fix ComponentThemeProvider tests to use data-test attribute
- Configure testing-library testIdAttribute for Superset convention
- Fix test assertions for fallback behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:57:35 -08:00
Evan Rusackas
b704532120 feat(dashboard): add ComponentThemeProvider for granular theming
Implements component-level theme application for dashboard components:

- Add ComponentThemeProvider wrapper that loads and applies themes
- Integrate theming into Row, Column, Tabs, Markdown, and ChartHolder
- Wire up theme selector from SliceHeaderControls through Chart hierarchy
- Fix re-render issues with stable callback dependencies using ref pattern
- Add comprehensive tests for ComponentThemeProvider

Theme inheritance now works: Instance → Dashboard → Tab → Row/Column → Chart

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:57:35 -08:00
Evan Rusackas
cb8956b19b 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 <noreply@anthropic.com>
2026-02-05 21:55:59 -08:00
Evan Rusackas
90873458f5 fix(dashboard): use HoverMenu for Markdown controls visibility
The custom MarkdownControlsWrapper styled component wasn't showing
because its CSS selectors weren't integrated with the existing
DashboardWrapper hover rules. Using HoverMenu instead leverages
the existing CSS that shows menus on hover:

  div:hover > .hover-menu-container .hover-menu { opacity: 1; }

Also updated tests to work with the new menu pattern:
- Tests now click "More Options" button to open dropdown
- Tests look for "Preview" (not "Edit") when in edit mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:55:59 -08:00
Evan Rusackas
9e5233eda2 feat(dashboard): integrate ComponentHeaderControls into Markdown
Replace the old Markdown component UI controls with the new standardized
ComponentHeaderControls menu:

Changes:
- Add ComponentHeaderControls to Markdown component (top-right position)
- Remove MarkdownModeDropdown from WithPopoverMenu
- Remove HoverMenu with DeleteComponentButton
- Add MarkdownControlsWrapper for positioning (shows on hover)

New menu includes:
- Edit/Preview toggle (replaces MarkdownModeDropdown)
- Apply theme (placeholder for theme selector modal)
- Delete (replaces DeleteComponentButton)

This brings Markdown in line with the chart component menu pattern,
providing a consistent UI across all dashboard components.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:55:48 -08:00
Evan Rusackas
eb77167bae feat(dashboard): add ComponentHeaderControls for consistent component menus
Create a generic menu component for dashboard components (Markdown, Row,
Column, Tab) that provides a consistent vertical dots menu pattern,
matching the existing SliceHeaderControls used for charts.

New files:
- ComponentHeaderControls/index.tsx: Main component with:
  - NoAnimationDropdown + Menu pattern from @superset-ui/core
  - ComponentMenuKeys enum for standard actions (Delete, ApplyTheme, etc.)
  - Configurable visibility (editMode, showInViewMode)

- ComponentHeaderControls/useComponentMenuItems.tsx: Helper hook that:
  - Builds standard menu items (theme selection, delete)
  - Supports custom items before/after standard items
  - Shows applied theme name in menu

This is the foundation for adding granular theming to all dashboard
components with a consistent UI pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:55:30 -08:00
Evan Rusackas
f0af6cc812 docs: add granular theming feature plan
Planning document for extending Superset's theming system to support
per-component themes with full cascade hierarchy:

Instance → Dashboard → Tab → Row/Column → Chart/Component

Key decisions:
- Modal with live preview for theme selection
- Color scheme (data viz) remains separate from Theme (UI elements)
- Standardized ComponentHeaderControls menu for all dashboard components
- Tab themes apply to content area, overridable by child components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:55:30 -08:00
25 changed files with 2874 additions and 458 deletions

View File

@@ -0,0 +1,672 @@
<!--
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.
-->
# Granular Theming Feature Plan
## Overview
Extend Superset's theming system to support theme application at granular levels beyond instance and dashboard. This includes charts, dashboard components (Markdown, Rows, Columns, Tabs), and improved UI consistency across all dashboard elements.
## Current State
### Theme Hierarchy (Existing)
1. **Instance Level** - Global theme via ThemeController, affects entire Superset
2. **Dashboard Level** - Override via Dashboard Properties modal, stored in `theme_id`
### Current Limitations
- Charts inherit dashboard/instance theme - no per-chart override
- Dashboard components (Markdown, Row, Column, Tab) have no theme controls
- Inconsistent UI patterns across components:
- Charts have `SliceHeaderControls` menu (vertical dots)
- Markdown has `MarkdownModeDropdown` (Edit/Preview toggle) - no standard menu
- Row/Column have `BackgroundStyleDropdown` via gear icon
- Tabs have editable title only
---
## Proposed Theme Hierarchy
```
Instance Theme (global default)
└── Dashboard Theme (override)
├── Chart Theme (per-chart override)
├── Markdown Theme (per-component override)
├── Row Theme (per-component override)
├── Column Theme (per-component override)
└── Tab Theme (per-component override)
```
Each level inherits from parent and can selectively override tokens.
---
## Feature Breakdown
### Phase 1: Chart-Level Theming
#### 1.1 Chart Theme in Explore View
**Location:** Chart controls panel (alongside color palette picker)
**Tasks:**
- [ ] Add `ThemeControl` component to explore controls
- [ ] Create theme selector UI (dropdown with theme previews)
- [ ] Store theme selection in chart `form_data` / `params`
- [ ] Update chart rendering to apply theme override
- [ ] Add to relevant viz control panels (sections.tsx)
**Files to modify:**
- `superset-frontend/src/explore/components/controls/` - New ThemeControl
- `superset-frontend/src/explore/controlPanels/sections.tsx` - Add to panels
- `superset-frontend/packages/superset-ui-core/src/chart/` - Theme application
#### 1.2 Chart Theme in Dashboard Context
**Location:** SliceHeaderControls menu (top-right vertical dots)
**Tasks:**
- [ ] Add "Apply Theme" menu item to SliceHeaderControls
- [ ] Create theme selection modal/popover
- [ ] Store per-chart theme in dashboard layout metadata
- [ ] Update chart rendering in dashboard to check for theme override
- [ ] Handle theme inheritance (chart theme → dashboard theme → instance theme)
**Files to modify:**
- `superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx`
- `superset-frontend/src/dashboard/reducers/` - Store chart themes
- `superset-frontend/src/dashboard/actions/` - Theme actions
---
### Phase 2: UI Consistency Improvements
#### 2.1 Standardized Component Menu
Create a consistent menu pattern for ALL dashboard components.
**Proposed Pattern:**
- All components get a `ComponentHeaderControls` menu (vertical dots like charts)
- Menu appears on hover/focus in both view and edit modes
- Contains context-appropriate actions
**Tasks:**
- [ ] Create generic `ComponentHeaderControls` component
- [ ] Define standard menu structure with extensible items
- [ ] Implement for each component type
#### 2.2 Markdown Component Menu
**Current:** `MarkdownModeDropdown` (Edit/Preview) at top of card in edit mode
**Proposed:** Standard vertical dots menu with:
- Edit content (opens editor modal or inline)
- Apply theme
- View source (preview mode)
- Delete component
**Tasks:**
- [ ] Add `ComponentHeaderControls` to Markdown
- [ ] Move Edit/Preview toggle into menu or make it a toggle button
- [ ] Remove old `MarkdownModeDropdown` UI
- [ ] Add theme selection option
**Files to modify:**
- `superset-frontend/src/dashboard/components/gridComponents/Markdown/`
- `superset-frontend/src/dashboard/components/menu/MarkdownModeDropdown.tsx` - Deprecate
#### 2.3 Row/Column Component Menu
**Current:** Gear icon for `BackgroundStyleDropdown` (Transparent/Solid)
**Proposed:** Standard vertical dots menu with:
- Background style (submenu or modal)
- Apply theme
- Delete component
**Tasks:**
- [ ] Add `ComponentHeaderControls` to Row/Column
- [ ] Move background style into menu
- [ ] Remove standalone gear icon
- [ ] Add theme selection option
**Files to modify:**
- `superset-frontend/src/dashboard/components/gridComponents/Row/`
- `superset-frontend/src/dashboard/components/gridComponents/Column/`
- `superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx`
#### 2.4 Tab Component Menu
**Current:** Editable title only
**Proposed:** Standard vertical dots menu on each tab with:
- Rename tab
- Apply theme (to tab content area)
- Delete tab
**Tasks:**
- [ ] Add `ComponentHeaderControls` to Tab headers
- [ ] Integrate with existing editable title
- [ ] Add theme selection option
**Files to modify:**
- `superset-frontend/src/dashboard/components/gridComponents/Tab/`
- `superset-frontend/src/dashboard/components/gridComponents/Tabs/`
---
### Phase 3: Theme Storage & API
#### 3.1 Backend Storage
**Tasks:**
- [ ] Extend dashboard `json_metadata` schema to include component themes
- [ ] Add chart theme field to chart model or store in dashboard layout
- [ ] Create API endpoints for component theme CRUD (or use existing dashboard save)
**Schema proposal:**
```json
{
"json_metadata": {
"theme_id": 123,
"component_themes": {
"CHART-abc123": { "theme_id": 456 },
"MARKDOWN-def456": { "theme_id": 789 },
"ROW-ghi789": { "theme_id": 101 }
}
}
}
```
#### 3.2 Frontend State Management
**Tasks:**
- [ ] Add component themes to dashboard Redux state
- [ ] Create actions for setting/clearing component themes
- [ ] Update selectors to resolve theme hierarchy
- [ ] Cache resolved themes for performance
---
### Phase 4: Theme Application & Rendering
#### 4.1 Theme Resolution Logic
Full cascade order: **Instance → Dashboard → Tab → Row/Column → Chart/Component**
Each level overrides the one above it. Components inherit from their structural parent.
```typescript
function resolveComponentTheme(
componentId: string,
parentChain: string[] // [dashboardId, tabId?, rowId?, columnId?]
): Theme {
const instanceTheme = getInstanceTheme();
const dashboardTheme = getDashboardTheme();
// Walk up the parent chain, collecting theme overrides
let mergedTheme = mergeThemes(instanceTheme, dashboardTheme);
for (const parentId of parentChain) {
const parentTheme = getComponentTheme(parentId);
if (parentTheme) {
mergedTheme = mergeThemes(mergedTheme, parentTheme);
}
}
// Finally apply this component's theme
const componentTheme = getComponentTheme(componentId);
return componentTheme
? mergeThemes(mergedTheme, componentTheme)
: mergedTheme;
}
```
#### 4.2 Theme Provider Hierarchy
**Tasks:**
- [ ] Create `ComponentThemeProvider` wrapper
- [ ] Wrap each dashboard component with theme context
- [ ] Ensure proper theme inheritance and override behavior
- [ ] Handle theme changes without full re-render
---
## UI/UX Design Considerations
### Menu Placement
- **Charts:** Keep existing top-right position (SliceHeaderControls)
- **Markdown/Row/Column:** Top-right corner, visible on hover
- **Tabs:** In tab header, right side of tab label
### Theme Selector UI
**Decision:** Modal with live preview rendering
The theme selector will open a modal that shows:
- List of available themes (left panel)
- Live preview of the component with selected theme applied (right panel)
- Apply / Cancel buttons
This provides the best UX for understanding theme impact before committing.
**Implementation approach:**
- Render component in isolated ThemeProvider within modal
- Pass temporary theme to preview without affecting dashboard state
- On "Apply", persist theme selection to dashboard metadata
### Visual Indicators
- Show subtle indicator when component has theme override (e.g., small theme icon)
- Tooltip showing "Custom theme: [Theme Name]"
- In edit mode, highlight themed components differently
---
## Technical Considerations
### Performance
- Theme objects can be large - cache aggressively
- Use React.memo and useMemo for theme-dependent components
- Consider lazy loading theme data for components not in viewport
### Backwards Compatibility
- Components without theme override continue to inherit from parent
- Existing dashboards work unchanged
- Migration not required - additive feature
### Theme Merging Strategy
- Deep merge with component theme taking precedence
- Only override specified tokens, inherit rest
- Handle algorithm (light/dark) inheritance carefully
---
## Implementation Order
1. **Phase 1.2** - Chart theme in dashboard (SliceHeaderControls) - Most impactful, builds on existing menu
2. **Phase 2.1** - Create generic ComponentHeaderControls - Foundation for other components
3. **Phase 2.2** - Markdown menu - Good test case, simplest component
4. **Phase 2.3** - Row/Column menu - Similar pattern, removes old UI
5. **Phase 2.4** - Tab menu - More complex due to tab structure
6. **Phase 1.1** - Chart theme in Explore - Can be done in parallel
7. **Phase 3 & 4** - Storage and rendering - Continuous throughout
---
## Decisions Made
1. **Theme scope for Tabs:** Theme applies to tab **contents** (the content area), which can be overridden by components within the tab.
2. **Nested theming:** Full cascade - each level overrides the one above:
```
Instance → Dashboard → Tab → Row/Column → Chart/Component
```
A Chart theme overrides the Row theme it sits in.
3. **Color scheme vs Theme:** Keep **separate**:
- **Color Scheme (palette):** Categorical/sequential colors for data visualization
- **Theme:** UI elements - axes, fonts, backgrounds, borders, etc.
Both controls will exist side-by-side in chart controls.
4. **Theme selector UI:** Modal with live preview rendering.
## Open Questions
1. **Undo/Redo:** Should theme changes be undoable in dashboard edit mode?
2. **Theme inheritance indicator:** How to show which theme a component is inheriting from?
---
## Success Metrics
- [ ] Users can apply themes to individual charts in dashboards
- [ ] All dashboard components have consistent menu UI
- [ ] Old gear icon and mode dropdown removed
- [ ] Theme inheritance works correctly across all levels
- [ ] No performance regression on dashboard load
- [ ] Backwards compatible with existing dashboards
---
## Future Possibilities
Once granular theming is in place, this opens doors for:
1. **ECharts Token Overrides** - The theme system already supports `echartsOverrides` for per-chart-type styling. Future work could expose UI for:
- Rounded corners on bar charts
- Custom line styles
- Shadow effects
- Animation settings
2. **Theme Templates** - Pre-built component theme combinations (e.g., "Dark Card", "Glassmorphism", "Minimal")
3. **Theme Export/Import** - Share component themes across dashboards
4. **Conditional Theming** - Apply themes based on data values or user context
---
## Implementation Log
_Ongoing notes as we implement..._
### Session 1 - Planning
- Created initial plan document
- Established theme hierarchy: Instance → Dashboard → Tab → Row/Column → Chart/Component
- Decided on modal with live preview for theme selection
- Clarified color scheme (data) vs theme (UI) separation
### Session 1 - Implementation Started
- Created `ComponentHeaderControls` component at:
`src/dashboard/components/menu/ComponentHeaderControls/index.tsx`
- Generic vertical dots menu matching SliceHeaderControls pattern
- Uses NoAnimationDropdown + Menu from @superset-ui/core
- Configurable menu items, edit mode visibility
- Exports `ComponentMenuKeys` enum for standard actions
- Created `useComponentMenuItems` hook at:
`src/dashboard/components/menu/ComponentHeaderControls/useComponentMenuItems.tsx`
- Builds standard menu items (theme, delete)
- Supports custom items before/after standard items
- Shows "Change theme (name)" when theme applied
**Next Steps:**
1. ~~Integrate ComponentHeaderControls into Markdown component~~ DONE
2. Test with simple Edit/Preview + Theme + Delete menu
3. ~~Remove old MarkdownModeDropdown~~ DONE
### Session 1 - Markdown Integration
- Integrated `ComponentHeaderControls` into Markdown component
- Replaced old UI elements:
- Removed `DeleteComponentButton` from HoverMenu (now in ComponentHeaderControls)
- Removed `MarkdownModeDropdown` from `WithPopoverMenu.menuItems`
- New menu includes: Edit/Preview toggle, Apply Theme, Delete
- Uses existing `HoverMenu position="top"` for proper CSS integration
- Menu shows on hover in edit mode (leverages existing DashboardWrapper CSS)
**Files modified:**
- `src/dashboard/components/gridComponents/Markdown/Markdown.jsx`
**Status:** Completed - all tests pass
### Session 2 - CSS Fix and Test Updates
- Fixed CSS visibility issue: replaced custom `MarkdownControlsWrapper` with `HoverMenu` component
- The custom wrapper's CSS selectors weren't being triggered by the existing DashboardWrapper CSS rules
- Using `HoverMenu position="top"` integrates with existing CSS that shows hover menus:
```css
div:hover > .hover-menu-container .hover-menu { opacity: 1; }
```
- Updated tests to work with new menu pattern:
- Tests now click "More Options" button to open dropdown
- Tests look for "Preview" option (not "Edit") when in edit mode
- All 16 Markdown tests passing
**Files modified:**
- `src/dashboard/components/gridComponents/Markdown/Markdown.jsx`
- `src/dashboard/components/gridComponents/Markdown/Markdown.test.tsx`
**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`.
### Session 3 - Bug Fix: setState Race Condition
**Problem:** Clicking "Apply theme" menu item didn't open the modal. Debug logging showed:
- `handleOpenThemeSelector` was called and set `isThemeSelectorOpen: true`
- But the setState callback showed `isThemeSelectorOpen: false`
- ThemeSelectorModal received `show: false`
**Root Cause:** `handleChangeEditorMode` used a non-functional setState pattern:
```javascript
const nextState = { ...this.state, editorMode: mode }; // Reads stale state
this.setState(nextState); // Overwrites pending isThemeSelectorOpen: true
```
When the dropdown menu closed, it triggered `handleChangeFocus → handleChangeEditorMode`,
which copied the old state (before React applied the pending `isThemeSelectorOpen: true`)
and overwrote it.
**Fix:** Changed to functional setState:
```javascript
this.setState(prevState => ({
...prevState,
editorMode: mode,
...(mode === 'preview' ? { hasError: false } : {}),
}));
```
**Status:** Modal now opens correctly. Theme selection is stored in component metadata.
**Next Steps:**
1. ~~Phase 2.3: Add ComponentHeaderControls to Row/Column~~ DONE
2. Phase 2.4: Add ComponentHeaderControls to Tabs
3. Phase 1.2: Add theme selector to SliceHeaderControls (charts)
4. Phase 3: Backend persistence
5. Phase 4: Theme rendering/application
### Session 3 - Phase 2.3: Row/Column Integration
Updated Row and Column components to use `ComponentHeaderControls`:
**Row.tsx changes:**
- Replaced `DeleteComponentButton` and gear `IconButton` with `ComponentHeaderControls`
- Removed `BackgroundStyleDropdown` from `WithPopoverMenu.menuItems`
- Added menu items: Background toggle (Transparent/Solid), Apply theme, Delete
- Added `ThemeSelectorModal` integration
- Fixed `backgroundStyle` variable ordering (moved before hooks that use it)
**Column.jsx changes:**
- Same pattern as Row
- Replaced old UI elements with `ComponentHeaderControls`
- Added theme selector modal integration
**Test updates:**
- Updated Row.test.tsx and Column.test.jsx to use new menu pattern
- Removed mocks for `DeleteComponentButton`
- Tests now click "More Options" menu button and select "Delete" from dropdown
**Files modified:**
- `src/dashboard/components/gridComponents/Row/Row.tsx`
- `src/dashboard/components/gridComponents/Row/Row.test.tsx`
- `src/dashboard/components/gridComponents/Column/Column.jsx`
- `src/dashboard/components/gridComponents/Column/Column.test.jsx`
**Status:** All tests pass (Row, Column, Markdown)
### Session 4 - Phase 2.4: Tabs Integration
Updated Tabs and TabsRenderer components to use `ComponentHeaderControls`:
**TabsRenderer.tsx changes:**
- Replaced `DeleteComponentButton` with `ComponentHeaderControls`
- Added `handleOpenThemeSelector` prop to interface
- Added `handleMenuClick` callback for menu actions
- Added menu items: Apply theme, Delete
- Menu appears in HoverMenu alongside DragHandle
**Tabs.jsx changes:**
- Added `ThemeSelectorModal` import and integration
- Added `isThemeSelectorOpen` state
- Added `handleOpenThemeSelector`, `handleCloseThemeSelector`, `handleApplyTheme` handlers
- Passed `handleOpenThemeSelector` to TabsRenderer
- Theme stored in `tabsComponent.meta.theme_id`
**Test updates:**
- Updated TabsRenderer.test.tsx to include `handleOpenThemeSelector` in props
- Updated Tabs.test.tsx:
- Removed `DeleteComponentButton` mock (no longer used)
- Updated tests to check for `ComponentHeaderControls` via `[aria-label="More Options"]`
- Updated delete test to use menu pattern (click menu button, then "Delete" option)
**Files modified:**
- `src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx`
- `src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.test.tsx`
- `src/dashboard/components/gridComponents/Tabs/Tabs.jsx`
- `src/dashboard/components/gridComponents/Tabs/Tabs.test.tsx`
**Status:** All tests pass (108 tests across 7 test suites)
**Phase 2 Complete!** All dashboard components now have consistent `ComponentHeaderControls` menu:
- Markdown: Edit/Preview toggle, Apply theme, Delete
- Row: Background toggle, Apply theme, Delete
- Column: Background toggle, Apply theme, Delete
- Tabs: Apply theme, Delete
**Next Steps:**
1. ~~Phase 1.2: Add theme selector to SliceHeaderControls (charts)~~ DONE
2. Phase 3: Backend persistence
3. Phase 4: Theme rendering/application
### Session 4 - Phase 1.2: SliceHeaderControls (Chart) Theme Integration
Added theme selector to chart menu in dashboard view:
**types.ts changes:**
- Added `currentThemeId?: number | null` prop
- Added `onApplyTheme?: (themeId: number | null) => void` callback prop
**dashboard/types.ts changes:**
- Added `ApplyTheme = 'apply_theme'` to MenuKeys enum
**SliceHeaderControls/index.tsx changes:**
- Added `isThemeSelectorOpen` state
- Added `handleCloseThemeSelector` and `handleApplyTheme` handlers
- Added `ApplyTheme` case to handleMenuClick switch
- Added "Apply theme" menu item (only shown if `onApplyTheme` prop is provided)
- Added `ThemeSelectorModal` rendering
**Design notes:**
- Theme selector only appears if parent passes `onApplyTheme` callback
- This allows gradual adoption - parent components can enable theming when ready
- Charts in dashboard view can have themes applied once parent wiring is complete
**Files modified:**
- `src/dashboard/types.ts`
- `src/dashboard/components/SliceHeaderControls/types.ts`
- `src/dashboard/components/SliceHeaderControls/index.tsx`
**Status:** All 33 tests pass
**Remaining Work:**
1. ~~Wire up `onApplyTheme` callback in parent component (ChartHolder/SliceHeader)~~ DONE
2. Phase 3: Backend persistence for component themes
3. Phase 4: Theme rendering/application
### Session 5 - Phase 4.2: ComponentThemeProvider Implementation
Created `ComponentThemeProvider` for component-level theme application:
**ComponentThemeProvider/index.tsx:**
- Wrapper component that loads and applies theme for dashboard components
- Fetches theme from ThemeController via `createTheme(themeId, parentThemeConfig)`
- Merges component theme with parent theme for proper inheritance
- Error boundary catches missing ThemeProvider (e.g., in tests) and gracefully falls back
- Uses ref for parent theme config to avoid infinite re-render loops
**Integration:**
- Wrapped children of Row, Column, Tabs, Markdown, ChartHolder with ComponentThemeProvider
- Components pass `themeId={component.meta?.theme_id}` to the provider
- Theme inheritance works: Instance → Dashboard → Tab → Row/Column → Chart/Component
**useComponentTheme hook:**
- Helper hook to get theme info for UI display
- Returns `{ themeId, themeName, hasTheme }`
**Files created:**
- `src/dashboard/components/ComponentThemeProvider/index.tsx`
- `src/dashboard/components/ComponentThemeProvider/ComponentThemeProvider.test.tsx`
**Status:** Theme application working. Themes now visually apply to components.
### Session 5 - ChartHolder/SliceHeader Wiring Complete
Wired up `onApplyTheme` from SliceHeaderControls through the component hierarchy:
**ChartHolder.tsx changes:**
- Added `handleApplyTheme` callback that updates component.meta.theme_id
- Uses `getComponentById` pattern to avoid stale closures
- Passes `onApplyTheme` and `currentThemeId` to Chart component
- Wrapped Chart with `ComponentThemeProvider`
**Chart.jsx and SliceHeader changes:**
- Props flow: ChartHolder → Chart → SliceHeader → SliceHeaderControls
- `onApplyTheme` and `currentThemeId` passed down the hierarchy
- Theme modal now accessible from chart's menu
**Files modified:**
- `src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx`
- `src/dashboard/components/gridComponents/Chart/Chart.jsx`
- `src/dashboard/components/SliceHeader/index.tsx`
### Session 5 - Stability Fixes for handleApplyTheme
Fixed re-render issues caused by unstable callback dependencies:
**Problem:** `handleApplyTheme` callbacks in Row, Column, Tabs had the full component object
or `props` in their dependency arrays, causing unnecessary re-renders.
**Solution:** Applied ref pattern to all components:
1. Create ref: `const componentRef = useRef(component)`
2. Keep ref updated: `useEffect(() => { componentRef.current = component }, [component])`
3. Use ref in callback: `const currentComponent = componentRef.current`
4. Remove component from dependencies
**Files modified:**
- `src/dashboard/components/gridComponents/Row/Row.tsx`
- `src/dashboard/components/gridComponents/Column/Column.jsx`
- `src/dashboard/components/gridComponents/Tabs/Tabs.jsx`
- `src/dashboard/components/gridComponents/ChartHolder/ChartHolder.tsx`
**ComponentThemeProvider re-render fix:**
- Fixed infinite re-render loop caused by `parentThemeConfig` in useEffect dependencies
- Changed to use ref pattern for parent theme config
- Simplified `useComponentTheme` hook to avoid setState in effect
**Status:** All re-render issues resolved. Dashboard loads cleanly without excessive renders.
### Current Status Summary
**Completed:**
- Phase 1.2: Chart theme in dashboard (SliceHeaderControls) ✅
- Phase 2.1: ComponentHeaderControls component ✅
- Phase 2.2: Markdown menu integration ✅
- Phase 2.3: Row/Column menu integration ✅
- Phase 2.4: Tabs menu integration ✅
- Phase 4.2: ComponentThemeProvider and theme application ✅
**In Progress:**
- Phase 3: Backend persistence (themes stored in component.meta, needs API save)
**Not Started:**
- Phase 1.1: Chart theme in Explore view
- Theme visual indicators in edit mode

View File

@@ -441,6 +441,10 @@ export interface ThemeContextType {
canSetTheme: () => boolean;
canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
createTheme: (
themeId: string,
parentThemeConfig?: AnyThemeConfig,
) => Promise<Theme | null>;
getAppliedThemeId: () => number | null;
}

View File

@@ -0,0 +1,280 @@
/**
* 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 '@testing-library/jest-dom';
import { ReactNode } from 'react';
import {
render,
screen,
waitFor,
act,
configure,
} from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { Theme } from '@apache-superset/core/ui';
import ComponentThemeProvider, { useComponentTheme } from './index';
// Configure testing-library to use data-test attribute (Superset convention)
configure({ testIdAttribute: 'data-test' });
// Mock the ThemeProvider module
jest.mock('src/theme/ThemeProvider', () => ({
useThemeContext: jest.fn(),
}));
// Get reference to mocked useThemeContext
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useThemeContext } = require('src/theme/ThemeProvider');
// Mock theme object
const mockTheme = {
SupersetThemeProvider: ({ children }: { children: ReactNode }) => (
<div data-test="component-theme-provider">{children}</div>
),
toSerializedConfig: jest.fn().mockReturnValue({ colorPrimary: '#1890ff' }),
} as unknown as Theme;
const mockLoadedTheme = {
SupersetThemeProvider: ({ children }: { children: ReactNode }) => (
<div data-test="loaded-theme-provider">{children}</div>
),
} as unknown as Theme;
test('renders children directly when no themeId is provided', () => {
render(
<ComponentThemeProvider>
<div data-test="test-child">Hello World</div>
</ComponentThemeProvider>,
);
expect(screen.getByTestId('test-child')).toBeInTheDocument();
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
test('renders children directly when themeId is null', () => {
render(
<ComponentThemeProvider themeId={null}>
<div data-test="test-child">Hello World</div>
</ComponentThemeProvider>,
);
expect(screen.getByTestId('test-child')).toBeInTheDocument();
});
test('renders children when themeId is provided and theme loads successfully', async () => {
const mockCreateTheme = jest.fn().mockResolvedValue(mockLoadedTheme);
useThemeContext.mockReturnValue({
theme: mockTheme,
createTheme: mockCreateTheme,
});
await act(async () => {
render(
<ComponentThemeProvider themeId={123}>
<div data-test="test-child">Themed Content</div>
</ComponentThemeProvider>,
);
});
await waitFor(() => {
expect(screen.getByTestId('test-child')).toBeInTheDocument();
});
expect(mockCreateTheme).toHaveBeenCalledWith('123', {
colorPrimary: '#1890ff',
});
});
test('wraps children with theme provider when theme loads', async () => {
const mockCreateTheme = jest.fn().mockResolvedValue(mockLoadedTheme);
useThemeContext.mockReturnValue({
theme: mockTheme,
createTheme: mockCreateTheme,
});
await act(async () => {
render(
<ComponentThemeProvider themeId={456}>
<div data-test="test-child">Themed Content</div>
</ComponentThemeProvider>,
);
});
await waitFor(() => {
expect(screen.getByTestId('loaded-theme-provider')).toBeInTheDocument();
});
expect(screen.getByTestId('test-child')).toBeInTheDocument();
});
test('renders children without wrapper when theme loading fails', async () => {
const mockCreateTheme = jest
.fn()
.mockRejectedValue(new Error('Failed to load theme'));
useThemeContext.mockReturnValue({
theme: mockTheme,
createTheme: mockCreateTheme,
});
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
await act(async () => {
render(
<ComponentThemeProvider themeId={789}>
<div data-test="test-child">Content</div>
</ComponentThemeProvider>,
);
});
await waitFor(() => {
expect(screen.getByTestId('test-child')).toBeInTheDocument();
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load component theme 789:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
test('gracefully handles missing ThemeProvider (error boundary fallback)', () => {
// Simulate useThemeContext throwing an error
useThemeContext.mockImplementation(() => {
throw new Error('useThemeContext must be used within a ThemeProvider');
});
// Should not throw, should render children via error boundary
render(
<ComponentThemeProvider themeId={123}>
<div data-test="test-child">Fallback Content</div>
</ComponentThemeProvider>,
);
expect(screen.getByTestId('test-child')).toBeInTheDocument();
});
test('re-throws non-ThemeProvider errors', () => {
useThemeContext.mockImplementation(() => {
throw new Error('Some other error');
});
// This should throw because it's not a ThemeProvider error
expect(() => {
render(
<ComponentThemeProvider themeId={123}>
<div>Content</div>
</ComponentThemeProvider>,
);
}).toThrow('Some other error');
});
test('useComponentTheme returns theme info when themeId is provided', () => {
const { result } = renderHook(() => useComponentTheme(42));
expect(result.current.themeId).toBe(42);
expect(result.current.themeName).toBe('Theme 42');
expect(result.current.hasTheme).toBe(true);
});
test('useComponentTheme returns null values when themeId is not provided', () => {
const { result } = renderHook(() => useComponentTheme(null));
expect(result.current.themeId).toBeNull();
expect(result.current.themeName).toBeNull();
expect(result.current.hasTheme).toBe(false);
});
test('useComponentTheme returns null values when themeId is undefined', () => {
const { result } = renderHook(() => useComponentTheme(undefined));
expect(result.current.themeId).toBeUndefined();
expect(result.current.themeName).toBeNull();
expect(result.current.hasTheme).toBe(false);
});
test('shows fallback while loading when provided', async () => {
let resolveTheme: (theme: Theme) => void;
const themePromise = new Promise<Theme>(resolve => {
resolveTheme = resolve;
});
const mockCreateTheme = jest.fn().mockReturnValue(themePromise);
useThemeContext.mockReturnValue({
theme: mockTheme,
createTheme: mockCreateTheme,
});
render(
<ComponentThemeProvider
themeId={999}
fallback={<div data-test="loading">Loading...</div>}
>
<div data-test="test-child">Content</div>
</ComponentThemeProvider>,
);
// During loading, fallback or children should be shown
// The component shows children while loading (fallback || children)
expect(
screen.queryByTestId('loading') || screen.queryByTestId('test-child'),
).toBeInTheDocument();
// Resolve the theme
await act(async () => {
resolveTheme!(mockLoadedTheme);
});
await waitFor(() => {
expect(screen.getByTestId('loaded-theme-provider')).toBeInTheDocument();
});
});
test('cleans up properly when unmounted during loading', async () => {
let resolveTheme: (theme: Theme) => void;
const themePromise = new Promise<Theme>(resolve => {
resolveTheme = resolve;
});
const mockCreateTheme = jest.fn().mockReturnValue(themePromise);
useThemeContext.mockReturnValue({
theme: mockTheme,
createTheme: mockCreateTheme,
});
const { unmount } = render(
<ComponentThemeProvider themeId={111}>
<div data-test="test-child">Content</div>
</ComponentThemeProvider>,
);
// Unmount before theme loads
unmount();
// Resolve the theme after unmount - should not cause errors
await act(async () => {
resolveTheme!(mockLoadedTheme);
});
// No errors should occur - the isMounted check prevents state updates
});

View File

@@ -0,0 +1,237 @@
/**
* 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,
ReactNode,
useRef,
useMemo,
Component,
} from 'react';
import { Theme } from '@apache-superset/core/ui';
interface ComponentThemeProviderProps {
/** The theme ID to apply (from component.meta.theme_id) */
themeId?: number | null;
/** Child components to wrap with theme */
children: ReactNode;
/** Optional fallback to render while loading */
fallback?: ReactNode;
}
/**
* Error boundary that catches useThemeContext errors when no ThemeProvider exists.
* Falls back to rendering children without theme wrapping.
*/
class ThemeContextErrorBoundary extends Component<
{ children: ReactNode; fallbackChildren: ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; fallbackChildren: ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
// Only suppress "useThemeContext must be used within a ThemeProvider" errors
if (
!error.message.includes(
'useThemeContext must be used within a ThemeProvider',
)
) {
throw error;
}
}
render() {
if (this.state.hasError) {
return <>{this.props.fallbackChildren}</>;
}
return <>{this.props.children}</>;
}
}
/**
* Inner component that uses theme context - wrapped in error boundary
*/
const ComponentThemeProviderInner = ({
themeId,
children,
fallback = null,
}: ComponentThemeProviderProps) => {
// Import useThemeContext dynamically to avoid issues when ThemeProvider not available
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
const { useThemeContext } = require('src/theme/ThemeProvider');
const { theme: parentTheme, createTheme } = useThemeContext();
const [theme, setTheme] = useState<Theme | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Use a ref for parent theme config to avoid infinite re-render loops.
// The parent theme config is captured once on mount and when themeId changes,
// preventing the theme loading effect from re-running due to parent theme
// reference changes.
const parentThemeConfigRef = useRef<ReturnType<
typeof parentTheme.toSerializedConfig
> | null>(null);
// Capture parent theme config synchronously on render (before effect runs)
// This ensures we have the latest parent theme when loading the component theme
if (parentTheme?.toSerializedConfig) {
try {
parentThemeConfigRef.current = parentTheme.toSerializedConfig();
} catch {
parentThemeConfigRef.current = null;
}
}
useEffect(() => {
let isMounted = true;
const loadTheme = async () => {
if (!themeId) {
setTheme(null);
return;
}
setIsLoading(true);
setError(null);
try {
// Create component theme merged with parent theme for proper inheritance
// Use the ref value to avoid dependency on parentThemeConfig object
const loadedTheme = await createTheme(
String(themeId),
parentThemeConfigRef.current,
);
if (isMounted) {
setTheme(loadedTheme);
}
} catch (err) {
if (isMounted) {
setError(
err instanceof Error ? err : new Error('Failed to load theme'),
);
// eslint-disable-next-line no-console
console.error(`Failed to load component theme ${themeId}:`, err);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadTheme();
return () => {
isMounted = false;
};
}, [themeId, createTheme]);
// If no theme ID or error, just render children
if (!themeId || error) {
return <>{children}</>;
}
// Show fallback while loading
if (isLoading && !theme) {
return <>{fallback || children}</>;
}
// If theme loaded, wrap children with theme provider
if (theme) {
const ThemeProvider = theme.SupersetThemeProvider;
return <ThemeProvider>{children}</ThemeProvider>;
}
// Fallback: render children without wrapper
return <>{children}</>;
};
/**
* A component-level theme provider for granular theming in dashboards.
*
* This component fetches and applies a specific theme to its children,
* enabling per-component theming within the dashboard hierarchy:
* Instance → Dashboard → Tab → Row/Column → Chart/Component
*
* **Hierarchical Inheritance**: When a component has a theme, it is merged
* with the parent component's theme. This allows each level to override
* specific tokens while inheriting others from its parent.
*
* When no themeId is provided, children are rendered without any theme
* wrapper, inheriting from the parent theme context.
*
* This component gracefully handles the case where no ThemeProvider exists
* in the component tree (e.g., in tests) by simply rendering children.
*
* @example
* ```tsx
* // Dashboard with Theme A
* <ComponentThemeProvider themeId={dashboardThemeId}>
* // Row with Theme B (inherits from Theme A, overrides specific tokens)
* <ComponentThemeProvider themeId={rowThemeId}>
* // Chart inherits Theme B's tokens
* <ChartContent />
* </ComponentThemeProvider>
* </ComponentThemeProvider>
* ```
*/
const ComponentThemeProvider = ({
themeId,
children,
fallback = null,
}: ComponentThemeProviderProps) => {
// If no theme ID provided, skip all theme loading logic
if (!themeId) {
return <>{children}</>;
}
return (
<ThemeContextErrorBoundary fallbackChildren={children}>
<ComponentThemeProviderInner themeId={themeId} fallback={fallback}>
{children}
</ComponentThemeProviderInner>
</ThemeContextErrorBoundary>
);
};
export default ComponentThemeProvider;
/**
* Hook to get component theme info for debugging/display purposes
*/
export function useComponentTheme(themeId?: number | null) {
// For now, just generate a placeholder name synchronously
// In production, this could be extended to fetch the theme name from the API
return useMemo(
() => ({
themeId,
themeName: themeId ? `Theme ${themeId}` : null,
hasTheme: !!themeId,
}),
[themeId],
);
}

View File

@@ -166,6 +166,8 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
width,
height,
exportPivotExcel = () => ({}),
onApplyTheme,
currentThemeId,
},
ref,
) => {
@@ -365,6 +367,8 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
exploreUrl={exploreUrl}
crossFiltersEnabled={isCrossFiltersEnabled}
exportPivotExcel={exportPivotExcel}
onApplyTheme={onApplyTheme}
currentThemeId={currentThemeId}
/>
)}
</>

View File

@@ -23,6 +23,7 @@ import {
useState,
useRef,
RefObject,
useCallback,
} from 'react';
import { RouteComponentProps, useHistory } from 'react-router-dom';
@@ -60,6 +61,7 @@ import { usePermissions } from 'src/hooks/usePermissions';
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import ThemeSelectorModal from '../menu/ThemeSelectorModal';
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
const RefreshTooltip = styled.div`
@@ -139,6 +141,10 @@ export interface SliceHeaderControlsProps {
supersetCanCSV?: boolean;
crossFiltersEnabled?: boolean;
// Theme-related props
currentThemeId?: number | null;
onApplyTheme?: (themeId: number | null) => void;
}
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
RouteComponentProps;
@@ -156,6 +162,7 @@ const SliceHeaderControls = (
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
// setting openKeys undefined falls back to uncontrolled behaviour
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [isThemeSelectorOpen, setIsThemeSelectorOpen] = useState(false);
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
props.slice.slice_id,
);
@@ -298,12 +305,28 @@ const SliceHeaderControls = (
}
break;
}
case MenuKeys.ApplyTheme: {
setIsThemeSelectorOpen(true);
break;
}
default:
break;
}
setIsDropdownVisible(false);
};
const handleCloseThemeSelector = useCallback(() => {
setIsThemeSelectorOpen(false);
}, []);
const handleApplyTheme = useCallback(
(themeId: number | null) => {
props.onApplyTheme?.(themeId);
handleCloseThemeSelector();
},
[props.onApplyTheme, handleCloseThemeSelector],
);
const {
componentId,
dashboardId,
@@ -414,7 +437,15 @@ const SliceHeaderControls = (
});
}
if (canExplore || canEditCrossFilters) {
// Add theme selector if the callback is provided
if (props.onApplyTheme) {
newMenuItems.push({
key: MenuKeys.ApplyTheme,
label: t('Apply theme'),
});
}
if (canExplore || canEditCrossFilters || props.onApplyTheme) {
newMenuItems.push({ type: 'divider' });
}
@@ -605,6 +636,17 @@ const SliceHeaderControls = (
/>
{canEditCrossFilters && scopingModal}
{props.onApplyTheme && (
<ThemeSelectorModal
show={isThemeSelectorOpen}
onHide={handleCloseThemeSelector}
onApply={handleApplyTheme}
currentThemeId={props.currentThemeId ?? null}
componentId={componentId}
componentType="Chart"
/>
)}
</>
);
};

View File

@@ -60,4 +60,8 @@ export interface SliceHeaderControlsProps {
supersetCanCSV?: boolean;
crossFiltersEnabled?: boolean;
// Theme-related props
currentThemeId?: number | null;
onApplyTheme?: (themeId: number | null) => void;
}

View File

@@ -87,6 +87,8 @@ const propTypes = {
isFullSize: PropTypes.bool,
extraControls: PropTypes.object,
isInView: PropTypes.bool,
onApplyTheme: PropTypes.func,
currentThemeId: PropTypes.number,
};
const RESIZE_TIMEOUT = 500;
@@ -665,6 +667,8 @@ const Chart = props => {
width={width}
height={getHeaderHeight()}
exportPivotExcel={exportPivotExcel}
onApplyTheme={props.onApplyTheme}
currentThemeId={props.currentThemeId}
/>
{/*

View File

@@ -25,6 +25,7 @@ import { css, useTheme } from '@apache-superset/core/ui';
import { LayoutItem, RootState } from 'src/dashboard/types';
import AnchorLink from 'src/dashboard/components/AnchorLink';
import Chart from 'src/dashboard/components/gridComponents/Chart';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
@@ -231,6 +232,38 @@ const ChartHolder = ({
setFullSizeChartId(isFullSize ? null : chartId);
}, [chartId, isFullSize, setFullSizeChartId]);
const handleApplyTheme = useCallback(
(themeId: number | null) => {
// Use getComponentById to get the latest component state
// This avoids having `component` in dependencies which causes re-renders
const currentComponent = getComponentById(component.id);
if (!currentComponent) return;
if (themeId !== null) {
updateComponents({
[component.id]: {
...currentComponent,
meta: {
...currentComponent.meta,
theme_id: themeId,
},
},
});
} else {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, camelcase
const { theme_id, ...metaWithoutTheme } =
currentComponent.meta as Record<string, unknown>;
updateComponents({
[component.id]: {
...currentComponent,
meta: metaWithoutTheme,
},
});
}
},
[component.id, getComponentById, updateComponents],
);
const handleExtraControl = useCallback((name: string, value: unknown) => {
setExtraControls(current => ({
...current,
@@ -238,6 +271,7 @@ const ChartHolder = ({
}));
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps -- individual meta properties are tracked
const renderChild = useCallback(
({ dragSourceRef }) => (
<ResizableContainer
@@ -283,23 +317,39 @@ const ChartHolder = ({
}`}
</style>
)}
<Chart
componentId={component.id}
id={component.meta.chartId}
dashboardId={dashboardId}
width={chartWidth}
height={chartHeight}
sliceName={
component.meta.sliceNameOverride || component.meta.sliceName || ''
<ComponentThemeProvider
themeId={
(component.meta as Record<string, unknown>).theme_id as
| number
| undefined
}
updateSliceName={handleUpdateSliceName}
isComponentVisible={isComponentVisible}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}
setControlValue={handleExtraControl}
extraControls={extraControls}
isInView={isInView}
/>
>
<Chart
componentId={component.id}
id={component.meta.chartId}
dashboardId={dashboardId}
width={chartWidth}
height={chartHeight}
sliceName={
component.meta.sliceNameOverride ||
component.meta.sliceName ||
''
}
updateSliceName={handleUpdateSliceName}
isComponentVisible={isComponentVisible}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}
setControlValue={handleExtraControl}
extraControls={extraControls}
isInView={isInView}
onApplyTheme={handleApplyTheme}
currentThemeId={
(component.meta as Record<string, unknown>).theme_id as
| number
| null
}
/>
</ComponentThemeProvider>
{editMode && (
<HoverMenu position="top">
<div data-test="dashboard-delete-component-button">
@@ -340,6 +390,7 @@ const ChartHolder = ({
extraControls,
isInView,
handleDeleteComponent,
handleApplyTheme,
],
);

View File

@@ -16,22 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Fragment, useCallback, useState, useMemo, memo } from 'react';
import {
Fragment,
useCallback,
useState,
useMemo,
useRef,
useEffect,
memo,
} from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { t, css, styled } from '@apache-superset/core/ui';
import { Icons } from '@superset-ui/core/components/Icons';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import {
Draggable,
Droppable,
} from 'src/dashboard/components/dnd/DragDroppable';
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import IconButton from 'src/dashboard/components/IconButton';
import ComponentHeaderControls, {
ComponentMenuKeys,
} from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/menu/ThemeSelectorModal';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { componentShape } from 'src/dashboard/util/propShapes';
@@ -142,6 +151,13 @@ const Column = props => {
} = props;
const [isFocused, setIsFocused] = useState(false);
const [isThemeSelectorOpen, setIsThemeSelectorOpen] = useState(false);
const columnComponentRef = useRef(columnComponent);
// Keep ref updated with latest columnComponent to avoid stale closures
useEffect(() => {
columnComponentRef.current = columnComponent;
}, [columnComponent]);
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
@@ -151,6 +167,11 @@ const Column = props => {
setIsFocused(Boolean(nextFocus));
}, []);
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value === (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
);
const handleChangeBackground = useCallback(
nextValue => {
const metaKey = 'background';
@@ -169,16 +190,102 @@ const Column = props => {
[columnComponent, updateComponents],
);
const handleOpenThemeSelector = useCallback(() => {
setIsThemeSelectorOpen(true);
}, []);
const handleCloseThemeSelector = useCallback(() => {
setIsThemeSelectorOpen(false);
}, []);
const handleApplyTheme = useCallback(
themeId => {
// Use ref to get latest component state, avoiding stale closures
const currentComponent = columnComponentRef.current;
if (themeId !== null) {
updateComponents({
[currentComponent.id]: {
...currentComponent,
meta: {
...currentComponent.meta,
theme_id: themeId,
},
},
});
} else {
// Clear theme - omit theme_id from meta
// eslint-disable-next-line @typescript-eslint/no-unused-vars, camelcase
const { theme_id, ...metaWithoutTheme } = currentComponent.meta || {};
updateComponents({
[currentComponent.id]: {
...currentComponent,
meta: metaWithoutTheme,
},
});
}
handleCloseThemeSelector();
},
[updateComponents, handleCloseThemeSelector],
);
const handleMenuClick = useCallback(
key => {
switch (key) {
case ComponentMenuKeys.BackgroundStyle:
// Toggle between transparent and solid
handleChangeBackground(
backgroundStyle.value === BACKGROUND_TRANSPARENT
? backgroundStyleOptions[1].value
: BACKGROUND_TRANSPARENT,
);
break;
case ComponentMenuKeys.ApplyTheme:
handleOpenThemeSelector();
break;
case ComponentMenuKeys.Delete:
handleDeleteComponent();
break;
default:
break;
}
},
[
backgroundStyle.value,
handleChangeBackground,
handleDeleteComponent,
handleOpenThemeSelector,
],
);
const menuItems = useMemo(
() => [
{
key: ComponentMenuKeys.BackgroundStyle,
label:
backgroundStyle.value === BACKGROUND_TRANSPARENT
? t('Background: Transparent')
: t('Background: Solid'),
},
{ type: 'divider' },
{
key: ComponentMenuKeys.ApplyTheme,
label: t('Apply theme'),
},
{ type: 'divider' },
{
key: ComponentMenuKeys.Delete,
label: t('Delete'),
danger: true,
},
],
[backgroundStyle.value],
);
const columnItems = useMemo(
() => columnComponent.children || [],
[columnComponent.children],
);
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value === (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
);
const renderChild = useCallback(
({ dragSourceRef }) => (
<ResizableContainer
@@ -200,100 +307,94 @@ const Column = props => {
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${columnComponent.id}-background`}
value={columnComponent.meta.background}
onChange={handleChangeBackground}
/>,
]}
menuItems={[]}
editMode={editMode}
>
{editMode && (
<HoverMenu innerRef={dragSourceRef} position="top">
<DragHandle position="top" />
<DeleteComponentButton
iconSize="m"
onDelete={handleDeleteComponent}
/>
<IconButton
onClick={handleChangeFocus}
icon={<Icons.SettingOutlined iconSize="m" />}
<ComponentHeaderControls
componentId={columnComponent.id}
menuItems={menuItems}
onMenuClick={handleMenuClick}
editMode={editMode}
/>
</HoverMenu>
)}
<ColumnStyles
className={cx('grid-column', backgroundStyle.className)}
editMode={editMode}
>
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
<ComponentThemeProvider themeId={columnComponent.meta?.theme_id}>
<ColumnStyles
className={cx('grid-column', backgroundStyle.className)}
editMode={editMode}
>
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
</Droppable>
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
</Fragment>
))
)}
</ColumnStyles>
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))
)}
</ColumnStyles>
</ComponentThemeProvider>
</WithPopoverMenu>
</ResizableContainer>
),
@@ -305,12 +406,12 @@ const Column = props => {
columnWidth,
depth,
editMode,
handleChangeBackground,
handleChangeFocus,
handleComponentDrop,
handleDeleteComponent,
handleMenuClick,
isComponentVisible,
isFocused,
menuItems,
minColumnWidth,
onChangeTab,
onResize,
@@ -320,17 +421,27 @@ const Column = props => {
);
return (
<Draggable
component={columnComponent}
parentComponent={parentComponent}
orientation="column"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
<>
<Draggable
component={columnComponent}
parentComponent={parentComponent}
orientation="column"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
<ThemeSelectorModal
show={isThemeSelectorOpen}
onHide={handleCloseThemeSelector}
onApply={handleApplyTheme}
currentThemeId={columnComponent.meta?.theme_id || null}
componentId={columnComponent.id}
componentType="Column"
/>
</>
);
};

View File

@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render } from 'spec/helpers/testing-library';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import IconButton from 'src/dashboard/components/IconButton';
import {
fireEvent,
render,
screen,
waitFor,
} from 'spec/helpers/testing-library';
import { getMockStore } from 'spec/fixtures/mockStore';
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
@@ -50,19 +52,6 @@ jest.mock(
() =>
({ children }) => <div data-test="mock-with-popover-menu">{children}</div>,
);
jest.mock(
'src/dashboard/components/DeleteComponentButton',
() =>
({ onDelete }) => (
<button
type="button"
data-test="mock-delete-component-button"
onClick={onDelete}
>
Delete
</button>
),
);
const columnWithoutChildren = {
...mockLayout.present.COLUMN_ID,
@@ -109,12 +98,11 @@ test('should render a Draggable', () => {
expect(queryByTestId('mock-droppable')).not.toBeInTheDocument();
});
test('should skip rendering HoverMenu and DeleteComponentButton when not in editMode', () => {
const { container, queryByTestId } = setup({
test('should skip rendering HoverMenu when not in editMode', () => {
const { container } = setup({
component: columnWithoutChildren,
});
expect(container.querySelector('.hover-menu')).not.toBeInTheDocument();
expect(queryByTestId('mock-delete-component-button')).not.toBeInTheDocument();
});
test('should render a WithPopoverMenu', () => {
@@ -147,33 +135,35 @@ test('should render a HoverMenu in editMode', () => {
);
});
test('should render a DeleteComponentButton in editMode', () => {
// we cannot set props on the Row because of the WithDragDropContext wrapper
const { getByTestId } = setup({
test('should render ComponentHeaderControls in editMode', () => {
const { container } = setup({
component: columnWithoutChildren,
editMode: true,
});
expect(getByTestId('mock-delete-component-button')).toBeInTheDocument();
// ComponentHeaderControls renders a button with "More Options" aria-label
expect(
container.querySelector('[aria-label="More Options"]'),
).toBeInTheDocument();
});
test.skip('should render a BackgroundStyleDropdown when focused', () => {
let wrapper = setup({ component: columnWithoutChildren });
expect(wrapper.find(BackgroundStyleDropdown)).toBeFalsy();
// we cannot set props on the Row because of the WithDragDropContext wrapper
wrapper = setup({ component: columnWithoutChildren, editMode: true });
wrapper
.find(IconButton)
.at(1) // first one is delete button
.simulate('click');
expect(wrapper.find(BackgroundStyleDropdown)).toBeTruthy();
});
test('should call deleteComponent when deleted', () => {
test('should call deleteComponent when Delete menu item is clicked', async () => {
const deleteComponent = jest.fn();
const { getByTestId } = setup({ editMode: true, deleteComponent });
fireEvent.click(getByTestId('mock-delete-component-button'));
const { container } = setup({
editMode: true,
deleteComponent,
component: columnWithoutChildren,
});
// Click the "More Options" menu button
const menuButton = container.querySelector('[aria-label="More Options"]');
fireEvent.click(menuButton);
// Wait for menu to open and click Delete
await waitFor(() => {
const deleteOption = screen.getByText('Delete');
fireEvent.click(deleteOption);
});
expect(deleteComponent).toHaveBeenCalledTimes(1);
});

View File

@@ -23,14 +23,18 @@ import cx from 'classnames';
import { t, css, styled } from '@apache-superset/core/ui';
import { SafeMarkdown } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { EditorHost } from 'src/core/editors';
import ComponentHeaderControls, {
ComponentMenuKeys,
} from 'src/dashboard/components/menu/ComponentHeaderControls';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
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 DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
import MarkdownModeDropdown from 'src/dashboard/components/menu/MarkdownModeDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import { componentShape } from 'src/dashboard/util/propShapes';
import { ROW_TYPE, COLUMN_TYPE } from 'src/dashboard/util/componentTypes';
@@ -128,6 +132,7 @@ class Markdown extends PureComponent {
editorMode: 'preview',
undoLength: props.undoLength,
redoLength: props.redoLength,
isThemeSelectorOpen: false,
};
this.renderStartTime = Logger.getTimestamp();
@@ -138,6 +143,11 @@ class Markdown extends PureComponent {
this.handleResizeStart = this.handleResizeStart.bind(this);
this.setEditor = this.setEditor.bind(this);
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() {
@@ -230,16 +240,17 @@ class Markdown extends PureComponent {
}
handleChangeEditorMode(mode) {
const nextState = {
...this.state,
editorMode: mode,
};
if (mode === 'preview') {
this.updateMarkdownContent();
nextState.hasError = false;
}
this.setState(nextState);
// Use functional setState to avoid overwriting concurrent state updates
// (e.g., isThemeSelectorOpen being set by handleOpenThemeSelector)
this.setState(prevState => ({
...prevState,
editorMode: mode,
...(mode === 'preview' ? { hasError: false } : {}),
}));
}
updateMarkdownContent() {
@@ -278,6 +289,88 @@ class Markdown extends PureComponent {
}
}
handleMenuClick(key) {
switch (key) {
case ComponentMenuKeys.EditContent:
this.handleChangeEditorMode('edit');
break;
case ComponentMenuKeys.PreviewContent:
this.handleChangeEditorMode('preview');
break;
case ComponentMenuKeys.ApplyTheme:
this.handleOpenThemeSelector();
break;
case ComponentMenuKeys.Delete:
this.handleDeleteComponent();
break;
default:
break;
}
}
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';
// Use stable menu item structure - avoid creating new icon instances
return [
{
key: isEditing
? ComponentMenuKeys.PreviewContent
: ComponentMenuKeys.EditContent,
label: isEditing ? t('Preview') : t('Edit'),
},
{ type: 'divider' },
{
key: ComponentMenuKeys.ApplyTheme,
label: t('Apply theme'),
},
{ type: 'divider' },
{
key: ComponentMenuKeys.Delete,
label: t('Delete'),
danger: true,
},
];
}
shouldFocusMarkdown(event, container, menuRef) {
if (container?.contains(event.target)) return true;
if (menuRef?.contains(event.target)) return true;
@@ -354,74 +447,83 @@ 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={[
<MarkdownModeDropdown
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
]}
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
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"
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
<ComponentThemeProvider themeId={component.meta.theme_id}>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
id={component.id}
>
<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}
>
<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>
</ComponentThemeProvider>
</WithPopoverMenu>
)}
</Draggable>
<ThemeSelectorModal
show={this.state.isThemeSelectorOpen}
onHide={this.handleCloseThemeSelector}
onApply={this.handleApplyTheme}
currentThemeId={component.meta.theme_id || null}
componentId={component.id}
componentType="Markdown"
/>
</>
);
}
}

View File

@@ -165,14 +165,14 @@ test('should switch between edit and preview modes', async () => {
expect(await screen.findByRole('textbox')).toBeInTheDocument();
// Find and click edit dropdown by role
const editButton = screen.getByRole('button', { name: /edit/i });
// Find and click the "More Options" menu button to open dropdown
const menuButton = screen.getByRole('button', { name: /more options/i });
await act(async () => {
fireEvent.click(editButton);
fireEvent.click(menuButton);
});
// Click preview option in dropdown
const previewOption = await screen.findByText(/preview/i);
// When in edit mode, menu shows "Preview" option (to switch TO preview mode)
const previewOption = await screen.findByText('Preview');
await act(async () => {
fireEvent.click(previewOption);
});
@@ -219,15 +219,15 @@ test('should call updateComponents when switching from edit to preview with chan
// Wait for state update
await new Promise(resolve => setTimeout(resolve, 50));
// Click the Edit dropdown button
const editDropdown = screen.getByText('Edit');
fireEvent.click(editDropdown);
// Click the "More Options" menu button to open dropdown
const menuButton = screen.getByRole('button', { name: /more options/i });
fireEvent.click(menuButton);
// Wait for dropdown to open
await new Promise(resolve => setTimeout(resolve, 50));
// Find and click preview in dropdown
const previewOption = await screen.findByText(/preview/i);
// When in edit mode, menu shows "Preview" option (to switch TO preview mode)
const previewOption = await screen.findByText('Preview');
fireEvent.click(previewOption);
// Wait for update to complete
@@ -406,12 +406,21 @@ test('shouldFocusMarkdown keeps focus when clicking on menu items', async () =>
expect(await screen.findByRole('textbox')).toBeInTheDocument();
const editButton = screen.getByText('Edit');
// The new ComponentHeaderControls menu is accessed via "More Options" button
const menuButton = screen.getByRole('button', { name: /more options/i });
userEvent.click(editButton);
userEvent.click(menuButton);
await new Promise(resolve => setTimeout(resolve, 50));
expect(screen.queryByRole('textbox')).toBeInTheDocument();
// When in edit mode, the menu shows "Preview" option (to switch TO preview mode)
const previewButton = await screen.findByText('Preview');
userEvent.click(previewButton);
await new Promise(resolve => setTimeout(resolve, 50));
// After clicking Preview, editor mode changes to preview, so textbox should NOT be present
// But focus should be maintained on the markdown component
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
test('should exit edit mode when clicking outside in same row', async () => {

View File

@@ -22,6 +22,7 @@ import {
render,
RenderResult,
screen,
waitFor,
} from 'spec/helpers/testing-library';
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
@@ -90,18 +91,6 @@ jest.mock('src/dashboard/components/menu/WithPopoverMenu', () => {
);
});
jest.mock('src/dashboard/components/DeleteComponentButton', () => {
return ({ onDelete }: { onDelete: () => void }) => (
<button
type="button"
data-test="mock-delete-component-button"
onClick={onDelete}
>
Delete
</button>
);
});
const rowWithoutChildren = {
...mockLayout.present.ROW_ID,
children: [],
@@ -174,12 +163,11 @@ test('should render a Draggable', () => {
expect(queryByTestId('mock-droppable')).not.toBeInTheDocument();
});
test('should skip rendering HoverMenu and DeleteComponentButton when not in editMode', () => {
const { container, queryByTestId } = setup({
test('should skip rendering HoverMenu when not in editMode', () => {
const { container } = setup({
component: rowWithoutChildren,
});
expect(container.querySelector('.hover-menu')).not.toBeInTheDocument();
expect(queryByTestId('mock-delete-component-button')).not.toBeInTheDocument();
});
test('should render a WithPopoverMenu', () => {
@@ -205,31 +193,35 @@ test('should render a HoverMenu in editMode', () => {
);
});
test('should render a DeleteComponentButton in editMode', () => {
const { getByTestId } = setup({
test('should render ComponentHeaderControls in editMode', () => {
const { container } = setup({
component: rowWithoutChildren,
editMode: true,
});
expect(getByTestId('mock-delete-component-button')).toBeInTheDocument();
// ComponentHeaderControls renders a button with "More Options" aria-label
expect(
container.querySelector('[aria-label="More Options"]'),
).toBeInTheDocument();
});
test.skip('should render a BackgroundStyleDropdown when focused', () => {
let { rerender } = setup({ component: rowWithoutChildren });
expect(screen.queryByTestId('background-style-dropdown')).toBeFalsy();
// we cannot set props on the Row because of the WithDragDropContext wrapper
rerender(<Row {...props} component={rowWithoutChildren} editMode={true} />);
const buttons = screen.getAllByRole('button');
const settingsButton = buttons[1];
fireEvent.click(settingsButton);
expect(screen.queryByTestId('background-style-dropdown')).toBeTruthy();
});
test('should call deleteComponent when deleted', () => {
test('should call deleteComponent when Delete menu item is clicked', async () => {
const deleteComponent = jest.fn();
const { getByTestId } = setup({ editMode: true, deleteComponent });
fireEvent.click(getByTestId('mock-delete-component-button'));
const { container } = setup({
editMode: true,
deleteComponent,
component: rowWithoutChildren,
});
// Click the "More Options" menu button
const menuButton = container.querySelector('[aria-label="More Options"]');
fireEvent.click(menuButton!);
// Wait for menu to open and click Delete
await waitFor(() => {
const deleteOption = screen.getByText('Delete');
fireEvent.click(deleteOption);
});
expect(deleteComponent).toHaveBeenCalledTimes(1);
});

View File

@@ -30,17 +30,19 @@ import cx from 'classnames';
import { t } from '@apache-superset/core';
import { FeatureFlag, isFeatureEnabled, JsonObject } from '@superset-ui/core';
import { css, styled, SupersetTheme } from '@apache-superset/core/ui';
import { Icons, Constants } from '@superset-ui/core/components';
import { Constants } from '@superset-ui/core/components';
import {
Draggable,
Droppable,
} from 'src/dashboard/components/dnd/DragDroppable';
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import IconButton from 'src/dashboard/components/IconButton';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import ComponentHeaderControls, {
ComponentMenuKeys,
} from 'src/dashboard/components/menu/ComponentHeaderControls';
import ThemeSelectorModal from 'src/dashboard/components/menu/ThemeSelectorModal';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
@@ -158,13 +160,20 @@ const Row = memo((props: RowProps) => {
const [isInView, setIsInView] = useState(false);
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const [isThemeSelectorOpen, setIsThemeSelectorOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const isComponentVisibleRef = useRef(isComponentVisible);
const rowComponentRef = useRef(rowComponent);
useEffect(() => {
isComponentVisibleRef.current = isComponentVisible;
}, [isComponentVisible]);
// Keep ref updated with latest rowComponent to avoid stale closures
useEffect(() => {
rowComponentRef.current = rowComponent;
}, [rowComponent]);
// if chart not rendered - render it if it's less than 1 view height away from current viewport
// if chart rendered - remove it if it's more than 4 view heights away from current viewport
useEffect(() => {
@@ -234,6 +243,12 @@ const Row = memo((props: RowProps) => {
setIsFocused(Boolean(nextFocus));
}, []);
const backgroundStyle =
backgroundStyleOptions.find(
opt =>
opt.value === (rowComponent.meta?.background ?? BACKGROUND_TRANSPARENT),
) ?? backgroundStyleOptions[0];
const handleChangeBackground = useCallback(
(nextValue: string) => {
const metaKey = 'background';
@@ -256,6 +271,97 @@ const Row = memo((props: RowProps) => {
deleteComponent(rowComponent.id as string, parentId);
}, [deleteComponent, rowComponent, parentId]);
const handleOpenThemeSelector = useCallback(() => {
setIsThemeSelectorOpen(true);
}, []);
const handleCloseThemeSelector = useCallback(() => {
setIsThemeSelectorOpen(false);
}, []);
const handleApplyTheme = useCallback(
(themeId: number | null) => {
// Use ref to get latest component state, avoiding stale closures
const currentComponent = rowComponentRef.current;
if (themeId !== null) {
updateComponents({
[currentComponent.id as string]: {
...currentComponent,
meta: {
...currentComponent.meta,
theme_id: themeId,
},
},
});
} else {
// Clear theme - omit theme_id from meta
// eslint-disable-next-line @typescript-eslint/no-unused-vars, camelcase
const { theme_id, ...metaWithoutTheme } = currentComponent.meta || {};
updateComponents({
[currentComponent.id as string]: {
...currentComponent,
meta: metaWithoutTheme,
},
});
}
handleCloseThemeSelector();
},
[updateComponents, handleCloseThemeSelector],
);
const handleMenuClick = useCallback(
(key: string) => {
switch (key) {
case ComponentMenuKeys.BackgroundStyle:
// Toggle between transparent and solid
handleChangeBackground(
backgroundStyle.value === BACKGROUND_TRANSPARENT
? backgroundStyleOptions[1].value
: BACKGROUND_TRANSPARENT,
);
break;
case ComponentMenuKeys.ApplyTheme:
handleOpenThemeSelector();
break;
case ComponentMenuKeys.Delete:
handleDeleteComponent();
break;
default:
break;
}
},
[
backgroundStyle.value,
handleChangeBackground,
handleDeleteComponent,
handleOpenThemeSelector,
],
);
const menuItems = useMemo(
() => [
{
key: ComponentMenuKeys.BackgroundStyle,
label:
backgroundStyle.value === BACKGROUND_TRANSPARENT
? t('Background: Transparent')
: t('Background: Solid'),
},
{ type: 'divider' as const },
{
key: ComponentMenuKeys.ApplyTheme,
label: t('Apply theme'),
},
{ type: 'divider' as const },
{
key: ComponentMenuKeys.Delete,
label: t('Delete'),
danger: true,
},
],
[backgroundStyle.value],
);
const handleMenuHover = useCallback((hover: { isHovered: boolean }) => {
setHoverMenuHovered(hover.isHovered);
}, []);
@@ -268,12 +374,6 @@ const Row = memo((props: RowProps) => {
[rowComponent.children],
);
const backgroundStyle =
backgroundStyleOptions.find(
opt =>
opt.value === (rowComponent.meta?.background ?? BACKGROUND_TRANSPARENT),
) ?? backgroundStyleOptions[0];
const remainColumnCount = availableColumnCount - occupiedColumnCount;
const renderChild = useCallback(
({ dragSourceRef }: { dragSourceRef: RefObject<HTMLDivElement> }) => (
@@ -281,13 +381,7 @@ const Row = memo((props: RowProps) => {
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={handleChangeBackground}
/>,
]}
menuItems={[]}
editMode={editMode}
>
{editMode && (
@@ -297,127 +391,133 @@ const Row = memo((props: RowProps) => {
position="left"
>
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
icon={<Icons.SettingOutlined iconSize="l" />}
<ComponentHeaderControls
componentId={rowComponent.id as string}
menuItems={menuItems}
onMenuClick={handleMenuClick}
editMode={editMode}
/>
</HoverMenu>
)}
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={containerRef}
editMode={editMode}
<ComponentThemeProvider
themeId={rowComponent.meta?.theme_id as number | undefined}
>
{editMode && (
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
)}
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{rowItems.length === 0 && (
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) => dropIndicatorProps && <div {...dropIndicatorProps} />}
</Droppable>
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={containerRef}
editMode={editMode}
>
{editMode && (
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
)}
</Fragment>
))}
</GridRow>
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
{({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{rowItems.length === 0 && (
<div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id as string}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({
dropIndicatorProps,
}: {
dropIndicatorProps: JsonObject;
}) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))}
</GridRow>
</ComponentThemeProvider>
</WithPopoverMenu>
),
[
backgroundStyle.className,
backgroundStyle.value,
columnWidth,
containerHeight,
depth,
editMode,
handleChangeBackground,
handleChangeFocus,
handleComponentDrop,
handleDeleteComponent,
handleMenuClick,
handleMenuHover,
hoverMenuHovered,
isComponentVisible,
isFocused,
isInView,
menuItems,
onChangeTab,
onResize,
onResizeStart,
@@ -429,17 +529,27 @@ const Row = memo((props: RowProps) => {
);
return (
<Draggable
component={rowComponent}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
<>
<Draggable
component={rowComponent}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
<ThemeSelectorModal
show={isThemeSelectorOpen}
onHide={handleCloseThemeSelector}
onApply={handleApplyTheme}
currentThemeId={(rowComponent.meta?.theme_id as number) || null}
componentId={rowComponent.id as string}
componentType="Row"
/>
</>
);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState, memo } from 'react';
import { useCallback, useEffect, useMemo, useState, memo, useRef } from 'react';
import PropTypes from 'prop-types';
import { usePrevious } from '@superset-ui/core';
import { t, useTheme, styled } from '@apache-superset/core/ui';
@@ -25,8 +25,10 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from 'src/logger/LogUtils';
import { Modal } from '@superset-ui/core/components';
import { DROP_LEFT, DROP_RIGHT } from 'src/dashboard/util/getDropPosition';
import ComponentThemeProvider from '../../ComponentThemeProvider';
import { Draggable } from '../../dnd/DragDroppable';
import DashboardComponent from '../../../containers/DashboardComponent';
import ThemeSelectorModal from '../../menu/ThemeSelectorModal';
import findTabIndexByComponentId from '../../../util/findTabIndexByComponentId';
import getDirectPathToTabIndex from '../../../util/getDirectPathToTabIndex';
import getLeafComponentIdFromPath from '../../../util/getLeafComponentIdFromPath';
@@ -135,11 +137,18 @@ const Tabs = props => {
const [dragOverTabIndex, setDragOverTabIndex] = useState(null);
const [tabToDelete, setTabToDelete] = useState(null);
const [isEditingTabTitle, setIsEditingTabTitle] = useState(false);
const [isThemeSelectorOpen, setIsThemeSelectorOpen] = useState(false);
const tabsComponentRef = useRef(props.component);
const prevActiveKey = usePrevious(activeKey);
const prevDashboardId = usePrevious(props.dashboardId);
const prevDirectPathToChild = usePrevious(directPathToChild);
const prevTabIds = usePrevious(props.component.children);
// Keep ref updated with latest component to avoid stale closures
useEffect(() => {
tabsComponentRef.current = props.component;
}, [props.component]);
useEffect(() => {
if (prevActiveKey) {
props.setActiveTab(activeKey, prevActiveKey);
@@ -335,6 +344,44 @@ const Tabs = props => {
setIsEditingTabTitle(isEditing);
}, []);
const handleOpenThemeSelector = useCallback(() => {
setIsThemeSelectorOpen(true);
}, []);
const handleCloseThemeSelector = useCallback(() => {
setIsThemeSelectorOpen(false);
}, []);
const handleApplyTheme = useCallback(
themeId => {
// Use ref to get latest component state, avoiding stale closures
const currentComponent = tabsComponentRef.current;
if (themeId !== null) {
props.updateComponents({
[currentComponent.id]: {
...currentComponent,
meta: {
...currentComponent.meta,
theme_id: themeId,
},
},
});
} else {
// Clear theme - omit theme_id from meta
// eslint-disable-next-line @typescript-eslint/no-unused-vars, camelcase
const { theme_id, ...metaWithoutTheme } = currentComponent.meta || {};
props.updateComponents({
[currentComponent.id]: {
...currentComponent,
meta: metaWithoutTheme,
},
});
}
handleCloseThemeSelector();
},
[props.updateComponents, handleCloseThemeSelector],
);
const handleTabsReorder = useCallback(
(oldIndex, newIndex) => {
const { component, updateComponents } = props;
@@ -489,28 +536,32 @@ const Tabs = props => {
const renderChild = useCallback(
({ dragSourceRef: tabsDragSourceRef }) => (
<TabsRenderer
tabItems={tabItems}
editMode={editMode}
renderHoverMenu={renderHoverMenu}
tabsDragSourceRef={tabsDragSourceRef}
handleDeleteComponent={handleDeleteComponent}
tabsComponent={tabsComponent}
activeKey={activeKey}
tabIds={tabIds}
handleClickTab={handleClickTab}
handleEdit={handleEdit}
tabBarPaddingLeft={tabBarPaddingLeft}
onTabsReorder={handleTabsReorder}
isEditingTabTitle={isEditingTabTitle}
onTabTitleEditingChange={handleTabTitleEditingChange}
/>
<ComponentThemeProvider themeId={tabsComponent.meta?.theme_id}>
<TabsRenderer
tabItems={tabItems}
editMode={editMode}
renderHoverMenu={renderHoverMenu}
tabsDragSourceRef={tabsDragSourceRef}
handleDeleteComponent={handleDeleteComponent}
handleOpenThemeSelector={handleOpenThemeSelector}
tabsComponent={tabsComponent}
activeKey={activeKey}
tabIds={tabIds}
handleClickTab={handleClickTab}
handleEdit={handleEdit}
tabBarPaddingLeft={tabBarPaddingLeft}
onTabsReorder={handleTabsReorder}
isEditingTabTitle={isEditingTabTitle}
onTabTitleEditingChange={handleTabTitleEditingChange}
/>
</ComponentThemeProvider>
),
[
tabItems,
editMode,
renderHoverMenu,
handleDeleteComponent,
handleOpenThemeSelector,
tabsComponent,
activeKey,
tabIds,
@@ -556,6 +607,14 @@ const Tabs = props => {
</span>
</Modal>
)}
<ThemeSelectorModal
show={isThemeSelectorOpen}
onHide={handleCloseThemeSelector}
onApply={handleApplyTheme}
currentThemeId={tabsComponent.meta?.theme_id || null}
componentId={tabsComponent.id}
componentType="Tabs"
/>
</>
);
};

View File

@@ -18,6 +18,7 @@
*/
import {
fireEvent,
render,
screen,
userEvent,
@@ -25,7 +26,6 @@ import {
} from 'spec/helpers/testing-library';
import { nativeFiltersInfo } from 'src/dashboard/fixtures/mockNativeFilters';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath';
import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout';
import Tabs from './Tabs';
@@ -44,17 +44,6 @@ jest.mock('src/dashboard/containers/DashboardComponent', () =>
)),
);
jest.mock('src/dashboard/components/DeleteComponentButton', () =>
jest.fn(props => (
<button
type="button"
data-test="DeleteComponentButton"
onClick={props.onDelete}
>
DeleteComponentButton
</button>
)),
);
jest.mock('src/dashboard/util/getLeafComponentIdFromPath', () => jest.fn());
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
@@ -124,7 +113,10 @@ beforeEach(() => {
test('Should render editMode:true', () => {
const props = createProps();
render(<Tabs {...props} />, { useRedux: true, useDnd: true });
const { container } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(
screen
.getAllByRole('tab')
@@ -133,7 +125,10 @@ test('Should render editMode:true', () => {
expect(screen.getAllByRole('tab', { name: 'remove' })).toHaveLength(3);
expect(screen.getAllByRole('button', { name: 'Add tab' })).toHaveLength(1);
expect(DashboardComponent).toHaveBeenCalledTimes(4);
expect(DeleteComponentButton).toHaveBeenCalledTimes(1);
// ComponentHeaderControls renders a button with "More Options" aria-label
expect(
container.querySelector('[aria-label="More Options"]'),
).toBeInTheDocument();
});
test('Should render HoverMenu in editMode', () => {
@@ -169,13 +164,16 @@ test('Should not render HoverMenu when renderHoverMenu is false', () => {
test('Should render editMode:false', () => {
const props = createProps();
props.editMode = false;
render(<Tabs {...props} />, {
const { container } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(screen.getAllByRole('tab')).toHaveLength(3);
expect(DashboardComponent).toHaveBeenCalledTimes(4);
expect(DeleteComponentButton).not.toHaveBeenCalled();
// ComponentHeaderControls should not be rendered
expect(
container.querySelector('[aria-label="More Options"]'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'remove' }),
).not.toBeInTheDocument();
@@ -188,26 +186,42 @@ test('Update component props', () => {
const props = createProps();
(getLeafComponentIdFromPath as jest.Mock).mockResolvedValueOnce('none');
props.editMode = false;
const { rerender } = render(<Tabs {...props} />, {
const { rerender, container } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(DeleteComponentButton).not.toHaveBeenCalled();
// ComponentHeaderControls should not be rendered
expect(
container.querySelector('[aria-label="More Options"]'),
).not.toBeInTheDocument();
props.editMode = true;
rerender(<Tabs {...props} />);
expect(DeleteComponentButton).toHaveBeenCalledTimes(1);
// ComponentHeaderControls should now be rendered
expect(
container.querySelector('[aria-label="More Options"]'),
).toBeInTheDocument();
});
test('Clicking on "DeleteComponentButton"', () => {
test('Clicking Delete menu item deletes tabs container', async () => {
const props = createProps();
render(<Tabs {...props} />, {
const { container } = render(<Tabs {...props} />, {
useRedux: true,
useDnd: true,
});
expect(props.deleteComponent).not.toHaveBeenCalled();
userEvent.click(screen.getByTestId('DeleteComponentButton'));
// Click the "More Options" menu button
const menuButton = container.querySelector('[aria-label="More Options"]');
fireEvent.click(menuButton!);
// Wait for menu to open and click Delete
await waitFor(() => {
const deleteOption = screen.getByText('Delete');
fireEvent.click(deleteOption);
});
expect(props.deleteComponent).toHaveBeenCalledWith(
'TABS-L-d9eyOE-b',
'GRID_ID',

View File

@@ -40,6 +40,7 @@ const mockProps: TabsRendererProps = {
renderHoverMenu: true,
tabsDragSourceRef: undefined,
handleDeleteComponent: jest.fn(),
handleOpenThemeSelector: jest.fn(),
tabsComponent: { id: 'test-tabs-id' },
activeKey: 'tab-1',
tabIds: ['tab-1', 'tab-2'],
@@ -166,6 +167,7 @@ describe('TabsRenderer', () => {
tabItems: mockProps.tabItems,
editMode: false,
handleDeleteComponent: mockProps.handleDeleteComponent,
handleOpenThemeSelector: mockProps.handleOpenThemeSelector,
tabsComponent: mockProps.tabsComponent,
activeKey: mockProps.activeKey,
tabIds: mockProps.tabIds,

View File

@@ -22,10 +22,11 @@ import {
ReactElement,
RefObject,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import { styled } from '@apache-superset/core/ui';
import { t, styled } from '@apache-superset/core/ui';
import {
LineEditableTabs,
TabsProps as AntdTabsProps,
@@ -44,7 +45,9 @@ import {
} from '@dnd-kit/sortable';
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
import ComponentHeaderControls, {
ComponentMenuKeys,
} from '../../menu/ComponentHeaderControls';
const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
width: 100%;
@@ -100,6 +103,7 @@ export interface TabsRendererProps {
renderHoverMenu?: boolean;
tabsDragSourceRef?: RefObject<HTMLDivElement>;
handleDeleteComponent: () => void;
handleOpenThemeSelector: () => void;
tabsComponent: TabsComponent;
activeKey: string;
tabIds: string[];
@@ -162,6 +166,7 @@ const TabsRenderer = memo<TabsRendererProps>(
renderHoverMenu = true,
tabsDragSourceRef,
handleDeleteComponent,
handleOpenThemeSelector,
tabsComponent,
activeKey,
tabIds,
@@ -206,6 +211,38 @@ const TabsRenderer = memo<TabsRendererProps>(
setActiveId(null);
}, []);
const handleMenuClick = useCallback(
(key: string) => {
switch (key) {
case ComponentMenuKeys.ApplyTheme:
handleOpenThemeSelector();
break;
case ComponentMenuKeys.Delete:
handleDeleteComponent();
break;
default:
break;
}
},
[handleDeleteComponent, handleOpenThemeSelector],
);
const menuItems = useMemo(
() => [
{
key: ComponentMenuKeys.ApplyTheme,
label: t('Apply theme'),
},
{ type: 'divider' as const },
{
key: ComponentMenuKeys.Delete,
label: t('Delete'),
danger: true,
},
],
[],
);
const isDragging = activeId !== null;
return (
@@ -217,7 +254,12 @@ const TabsRenderer = memo<TabsRendererProps>(
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<ComponentHeaderControls
componentId={tabsComponent.id}
menuItems={menuItems}
onMenuClick={handleMenuClick}
editMode={editMode}
/>
</HoverMenu>
)}

View File

@@ -0,0 +1,207 @@
/**
* 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,
useCallback,
useMemo,
Key,
MouseEvent,
KeyboardEvent,
} from 'react';
import { t, css, useTheme } from '@apache-superset/core/ui';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { NoAnimationDropdown, Button } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
/**
* Standard menu keys for dashboard components.
* Components can use these standard keys or define custom ones.
*/
export enum ComponentMenuKeys {
// Common actions
Delete = 'delete',
Edit = 'edit',
// Theme actions
ApplyTheme = 'apply-theme',
ClearTheme = 'clear-theme',
// Markdown-specific
EditContent = 'edit-content',
PreviewContent = 'preview-content',
// Row/Column-specific
BackgroundStyle = 'background-style',
// Tab-specific
RenameTab = 'rename-tab',
}
// Re-export MenuItem type for convenience - allows both keyed items and dividers
export type ComponentMenuItem = MenuItem;
export interface ComponentHeaderControlsProps {
/** Unique identifier for the component */
componentId: string;
/** Array of menu items to display */
menuItems: ComponentMenuItem[];
/** Callback when a menu item is clicked */
onMenuClick: (
key: string,
domEvent: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>,
) => void;
/** Whether the component is in edit mode */
editMode?: boolean;
/** Whether to show the menu even in view mode */
showInViewMode?: boolean;
/** Z-index for the dropdown overlay */
zIndex?: number;
/** Additional CSS class for the trigger button */
className?: string;
/** Whether the menu is disabled */
disabled?: boolean;
}
const VerticalDotsTrigger = () => {
const theme = useTheme();
return (
<Icons.EllipsisOutlined
css={css`
transform: rotate(90deg);
&:hover {
cursor: pointer;
}
`}
iconSize="l"
iconColor={theme.colorTextLabel}
className="component-menu-trigger"
/>
);
};
/**
* A standardized menu component for dashboard components (Markdown, Row, Column, Tab).
*
* Provides a consistent vertical dots menu pattern similar to SliceHeaderControls,
* but generic enough to be used across all dashboard component types.
*
* Usage:
* ```tsx
* <ComponentHeaderControls
* componentId="MARKDOWN-123"
* menuItems={[
* { key: ComponentMenuKeys.Edit, label: t('Edit') },
* { key: ComponentMenuKeys.ApplyTheme, label: t('Apply theme') },
* { type: 'divider' },
* { key: ComponentMenuKeys.Delete, label: t('Delete'), danger: true },
* ]}
* onMenuClick={(key) => handleMenuAction(key)}
* editMode={editMode}
* />
* ```
*/
const ComponentHeaderControls = ({
componentId,
menuItems,
onMenuClick,
editMode = false,
showInViewMode = false,
zIndex = 99,
className,
disabled = false,
}: ComponentHeaderControlsProps) => {
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const theme = useTheme();
// Memoize the menu click handler
const handleMenuClick = useCallback(
({
key,
domEvent,
}: {
key: Key;
domEvent: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>;
}) => {
onMenuClick(String(key), domEvent);
setIsDropdownVisible(false);
},
[onMenuClick],
);
// Memoize the overlay style
const dropdownOverlayStyle = useMemo(
() => ({
zIndex,
animationDuration: '0s',
}),
[zIndex],
);
// Don't render if not in edit mode and showInViewMode is false
if (!editMode && !showInViewMode) {
return null;
}
return (
<NoAnimationDropdown
popupRender={() => (
<Menu
onClick={handleMenuClick}
data-test={`component-menu-${componentId}`}
id={`component-menu-${componentId}`}
selectable={false}
items={menuItems}
/>
)}
overlayStyle={dropdownOverlayStyle}
trigger={['click']}
placement="bottomRight"
open={isDropdownVisible}
onOpenChange={visible => setIsDropdownVisible(visible)}
disabled={disabled}
>
<Button
id={`${componentId}-controls`}
buttonStyle="link"
aria-label={t('More Options')}
aria-haspopup="true"
className={className}
css={css`
padding: ${theme.sizeUnit}px;
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
`}
>
<VerticalDotsTrigger />
</Button>
</NoAnimationDropdown>
);
};
export default ComponentHeaderControls;

View File

@@ -0,0 +1,130 @@
/**
* 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 { useMemo } from 'react';
import { t } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
import { css } from '@apache-superset/core/ui';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { ComponentMenuKeys } from './index';
const dropdownIconsStyles = css`
&&.anticon > .anticon:first-child {
margin-right: 0;
vertical-align: 0;
}
`;
export interface UseComponentMenuItemsOptions {
/** Whether to include theme menu item */
includeTheme?: boolean;
/** Whether to include delete menu item */
includeDelete?: boolean;
/** Whether the component has a theme applied */
hasThemeApplied?: boolean;
/** Name of the applied theme (for display) */
appliedThemeName?: string;
/** Additional custom menu items to include before standard items */
customItems?: MenuItem[];
/** Additional custom menu items to include after standard items */
customItemsAfter?: MenuItem[];
}
/**
* Hook to build standard menu items for dashboard components.
*
* Provides consistent menu item structure across all component types,
* with optional theme selection and delete actions.
*/
export function useComponentMenuItems({
includeTheme = true,
includeDelete = true,
hasThemeApplied = false,
appliedThemeName,
customItems = [],
customItemsAfter = [],
}: UseComponentMenuItemsOptions = {}): MenuItem[] {
return useMemo(() => {
const items: MenuItem[] = [];
// Add custom items first
if (customItems.length > 0) {
items.push(...customItems);
}
// Add theme items
if (includeTheme) {
if (items.length > 0) {
items.push({ type: 'divider' });
}
items.push({
key: ComponentMenuKeys.ApplyTheme,
label: hasThemeApplied
? t('Change theme (%s)', appliedThemeName || t('Custom'))
: t('Apply theme'),
icon: <Icons.BgColorsOutlined css={dropdownIconsStyles} />,
});
if (hasThemeApplied) {
items.push({
key: ComponentMenuKeys.ClearTheme,
label: t('Clear theme'),
icon: <Icons.ClearOutlined css={dropdownIconsStyles} />,
});
}
}
// Add custom items after theme
if (customItemsAfter.length > 0) {
if (items.length > 0) {
items.push({ type: 'divider' });
}
items.push(...customItemsAfter);
}
// Add delete as last item
if (includeDelete) {
if (items.length > 0) {
items.push({ type: 'divider' });
}
items.push({
key: ComponentMenuKeys.Delete,
label: t('Delete'),
icon: <Icons.DeleteOutlined css={dropdownIconsStyles} />,
danger: true,
});
}
return items;
}, [
includeTheme,
includeDelete,
hasThemeApplied,
appliedThemeName,
customItems,
customItemsAfter,
]);
}
export default useComponentMenuItems;

View File

@@ -0,0 +1,254 @@
/**
* 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.fontWeightStrong};
}
.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">
<span className="theme-selector-label">
{t('Select a theme for this %s', componentType)}
</span>
<Select
ariaLabel={t('Select a theme')}
data-test="theme-selector-select"
value={selectedThemeId}
onChange={value =>
setSelectedThemeId(value === undefined ? null : (value as number))
}
options={themeOptions}
placeholder={t('Select a theme...')}
allowClear
onClear={handleClear}
loading={isLoading}
disabled={isLoading}
css={css`
width: 100%;
`}
notFoundContent={
error ? (
<span css={{ 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;

View File

@@ -310,4 +310,5 @@ export enum MenuKeys {
ManageEmailReports = 'manage_email_reports',
ExportPivotXlsx = 'export_pivot_xlsx',
EmbedCode = 'embed_code',
ApplyTheme = 'apply_theme',
}

View File

@@ -277,6 +277,93 @@ export class ThemeController {
this.dashboardThemes.delete(themeId);
}
// Cache for raw theme configs (for hierarchical component theming)
private themeConfigCache: Map<string, AnyThemeConfig> = new Map();
/**
* Fetches and caches the raw theme configuration from the API.
* Unlike createDashboardThemeProvider, this returns the raw config
* so it can be merged with any parent theme.
* @param themeId - The theme ID to fetch
* @returns The raw theme configuration or null if not found
*/
public async fetchThemeConfig(
themeId: string,
): Promise<AnyThemeConfig | null> {
try {
// Check cache first
if (this.themeConfigCache.has(themeId)) {
return this.themeConfigCache.get(themeId)!;
}
// Fetch from API
const getTheme = makeApi<void, { result: { json_data: string } }>({
method: 'GET',
endpoint: `/api/v1/theme/${themeId}`,
});
const { result } = await getTheme();
const themeConfig = JSON.parse(result.json_data);
if (themeConfig) {
const normalizedConfig = this.normalizeTheme(themeConfig);
// Load custom fonts if specified
const fontUrls = (normalizedConfig?.token as Record<string, unknown>)
?.fontUrls as string[] | undefined;
this.loadFonts(fontUrls);
// Cache the raw config
this.themeConfigCache.set(themeId, normalizedConfig);
return normalizedConfig;
}
return null;
} catch (error) {
console.error('Failed to fetch theme config:', error);
return null;
}
}
/**
* Creates a Theme by merging a theme config with an optional parent theme.
* This enables hierarchical theming where themes inherit from their parent.
*
* Use this method for component-level themes that need to inherit from
* their parent in the component tree. For dashboard-level themes that
* don't have a parent, omit the parentThemeConfig parameter.
*
* @param themeId - The theme ID to apply
* @param parentThemeConfig - Optional parent theme's configuration to use as base.
* If not provided, uses the global default/dark theme.
* @returns A Theme object merged with the parent, or null if not found
*/
public async createTheme(
themeId: string,
parentThemeConfig?: AnyThemeConfig,
): Promise<Theme | null> {
try {
const componentConfig = await this.fetchThemeConfig(themeId);
if (!componentConfig) return null;
// Import Theme dynamically
const { Theme } = await import('@apache-superset/core/ui');
// Use parent theme as base if provided, otherwise fall back to default/dark
const isDarkMode = isThemeConfigDark(componentConfig);
const fallbackBase = isDarkMode ? this.darkTheme : this.defaultTheme;
const baseTheme = parentThemeConfig || fallbackBase || undefined;
// Create theme with proper inheritance
const componentTheme = Theme.fromConfig(componentConfig, baseTheme);
return componentTheme;
} catch (error) {
console.error('Failed to create component theme:', error);
return null;
}
}
/**
* Clears all cached dashboard themes.
*/

View File

@@ -118,6 +118,12 @@ export function SupersetThemeProvider({
[themeController],
);
const createTheme = useCallback(
(themeId: string, parentThemeConfig?: AnyThemeConfig) =>
themeController.createTheme(themeId, parentThemeConfig),
[themeController],
);
const getAppliedThemeId = useCallback(
() => themeController.getAppliedThemeId(),
[themeController],
@@ -138,6 +144,7 @@ export function SupersetThemeProvider({
canSetTheme,
canDetectOSPreference,
createDashboardThemeProvider,
createTheme,
getAppliedThemeId,
}),
[
@@ -154,6 +161,7 @@ export function SupersetThemeProvider({
canSetTheme,
canDetectOSPreference,
createDashboardThemeProvider,
createTheme,
getAppliedThemeId,
],
);