mirror of
https://github.com/apache/superset.git
synced 2026-04-29 21:14:22 +00:00
Compare commits
9 Commits
fix/postgr
...
feat/granu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1917ed0895 | ||
|
|
052724a578 | ||
|
|
4b81028f3b | ||
|
|
b704532120 | ||
|
|
cb8956b19b | ||
|
|
90873458f5 | ||
|
|
9e5233eda2 | ||
|
|
eb77167bae | ||
|
|
f0af6cc812 |
672
docs/GRANULAR_THEMING_PLAN.md
Normal file
672
docs/GRANULAR_THEMING_PLAN.md
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,4 +60,8 @@ export interface SliceHeaderControlsProps {
|
||||
supersetCanCSV?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
|
||||
// Theme-related props
|
||||
currentThemeId?: number | null;
|
||||
onApplyTheme?: (themeId: number | null) => void;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
{/*
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -310,4 +310,5 @@ export enum MenuKeys {
|
||||
ManageEmailReports = 'manage_email_reports',
|
||||
ExportPivotXlsx = 'export_pivot_xlsx',
|
||||
EmbedCode = 'embed_code',
|
||||
ApplyTheme = 'apply_theme',
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user