You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/05/14 21:00:08 UTC
(superset) 06/06: feat(dashboard): component theming for Tabs, Row, Column, Markdown — Phase 4b-4e
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch feat/granular-theming-v2
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 1be84f1769716cfd920ae1a1b7a5bba216a179e5
Author: Claude <cl...@anthropic.com>
AuthorDate: Thu May 14 13:59:43 2026 -0700
feat(dashboard): component theming for Tabs, Row, Column, Markdown — Phase 4b-4e
Same three-step recipe applied to each grid-component type:
(a) wrap body in <ComponentThemeProvider layoutId={id}>
(b) add "Apply theme" item to the component's menu via
ComponentHeaderControls
(c) mount <ThemeSelectorModal> gated on editMode
- TabsRenderer (4b): wraps StyledTabsContainer; dots menu lands in the
existing left HoverMenu next to drag/delete.
- Row (4c): wraps WithPopoverMenu body; dots menu in the left HoverMenu
next to drag/delete/setting-icon. The existing gear icon (opens the
BackgroundStyleDropdown focus popover) is preserved as-is.
- Column (4d): same recipe as Row, top-positioned HoverMenu.
- Markdown (4e): class component, so themeModalOpen lives on
this.state. Dots menu lands inside the existing WithPopoverMenu
menuItems array next to MarkdownModeDropdown; the Edit/Preview
toggle is intentionally preserved unchanged.
Note on scope: the SIP originally imagined Phase 4 would also converge
MarkdownModeDropdown and the Row/Column gear icon onto the shared dots
menu. Those user-visible UX displacements are intentionally deferred
so this phase adds the theming affordance *additively* — every existing
menu control is untouched. The menu-pattern unification can be picked
up later without coupling it to theming.
Functional outcome: every grid-component type (Chart, Markdown, Row,
Column, Tabs) now supports the full inheritance chain end-to-end:
Instance -> Dashboard -> Tab -> Row/Col -> Chart/Markdown. Setting a
themeId at any level applies to that subtree; clearing it falls
through to the parent.
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
SIP.md | 60 +++--
.../components/gridComponents/Column/Column.tsx | 267 +++++++++++----------
.../gridComponents/Markdown/Markdown.tsx | 129 ++++++----
.../components/gridComponents/Row/Row.tsx | 259 +++++++++++---------
.../gridComponents/TabsRenderer/TabsRenderer.tsx | 141 ++++++-----
5 files changed, 487 insertions(+), 369 deletions(-)
diff --git a/SIP.md b/SIP.md
index d73bafcd000..306beff3ad3 100644
--- a/SIP.md
+++ b/SIP.md
@@ -196,26 +196,46 @@ Each phase brings its own tests; the cumulative bar:
3 passing tests on `setComponentThemeId`: preserves other meta keys
+ sets numeric `themeId`; stores explicit `null` for the clear path;
no-op when the component id isn't in the layout.
-- _(Phase 4)_ — in progress.
- - **Chart (4a)**: ✅ landed locally. End-to-end demo on Chart works
- now: `SliceHeaderControls` has a new "Apply theme" item (gated on
- dashboard edit mode); clicking it opens the Phase-3
- `ThemeSelectorModal` keyed to the component's layoutId; on save the
- Phase-3 action updates `meta.themeId`; the Phase-1
- `ComponentThemeProvider` (already wrapping ChartHolder) re-resolves
- and re-renders the chart with the new theme tokens. The full
- Instance → Dashboard → Tab → Row/Col → Chart inheritance chain is
- functionally complete for Chart.
-
- Open follow-ups for the **Markdown / Row / Column / Tabs** PRs:
- - Each gets the menu-pattern conversion (`MarkdownModeDropdown`,
- gear icon, none → shared `ComponentHeaderControls`).
- - Each wraps its body in `<ComponentThemeProvider layoutId=...>`.
- - Each mounts a `<ThemeSelectorModal>` with an "Apply theme" menu
- item that opens it.
- - Each per-component PR can be reviewed in isolation for the menu/UX
- change without dragging in the theming framework changes (those
- are already merged in Phases 1-3).
+- _(Phase 4)_ — ✅ landed locally for all five grid-component types.
+ Same three-step recipe applied to each:
+ (a) wrap body in `<ComponentThemeProvider layoutId={id}>`,
+ (b) add "Apply theme" item to the component's menu via
+ `ComponentHeaderControls`,
+ (c) mount `<ThemeSelectorModal>` gated on `editMode`.
+
+ - **Chart (4a)**: `SliceHeaderControls` gets the menu item; the
+ provider was already wrapping `ChartHolder` from Phase 1.
+ - **Tabs (4b)**: `TabsRenderer` wraps `<StyledTabsContainer>` in the
+ provider; adds the dots-menu trigger inside the existing left
+ `HoverMenu` next to the drag handle and delete button.
+ - **Row (4c)**: wraps the `<WithPopoverMenu>` body; adds the
+ dots-menu trigger to the left `HoverMenu` next to drag/delete/
+ setting-icon. The existing gear icon (which opens the
+ BackgroundStyleDropdown focus popover) is preserved as-is.
+ - **Column (4d)**: same recipe as Row, wrapping its
+ `<WithPopoverMenu>` body and adding the dots menu to the top
+ `HoverMenu` next to drag/delete/setting-icon.
+ - **Markdown (4e)**: class component, so theme-modal state goes
+ through `this.state.themeModalOpen`. Adds a second
+ `ComponentHeaderControls` to the existing `<WithPopoverMenu
+ menuItems>` array next to the `MarkdownModeDropdown`
+ (Edit/Preview toggle is preserved as-is — the full menu-pattern
+ convergence onto a single dots menu is intentionally deferred so
+ Markdown's Edit/Preview UX is not changed in this phase).
+
+ Functional outcome: every grid-component type now supports the full
+ Instance → Dashboard → Tab → Row/Col → Chart/Markdown inheritance
+ chain end-to-end. Setting a `themeId` at any level applies to that
+ subtree; clearing it falls through to the parent.
+
+ Note on the broader menu-pattern unification: the SIP originally
+ imagined Phase 4 PRs would also converge `MarkdownModeDropdown`
+ (Edit/Preview popover) and the Row/Column gear icon into the shared
+ dots menu. We deferred those user-visible UX displacements so each
+ Phase-4 PR adds the theming affordance *additively* — i.e. the
+ existing menu controls are untouched, the dots menu sits alongside.
+ A follow-up SIP (or single sweep PR) can take the menu unification
+ later without coupling it to the theming work.
### Phase 1 status
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx
index 72be4b59a67..3cbc4ec5176 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Column/Column.tsx
@@ -35,6 +35,9 @@ import IconButton from 'src/dashboard/components/IconButton';
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 ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
+import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
+import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
@@ -161,6 +164,7 @@ const Column = (props: ColumnProps) => {
} = props;
const [isFocused, setIsFocused] = useState(false);
+ const [themeModalOpen, setThemeModalOpen] = useState(false);
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
@@ -216,135 +220,154 @@ const Column = (props: ColumnProps) => {
onResizeStop={onResizeStop}
editMode={editMode}
>
- <WithPopoverMenu
- isFocused={isFocused}
- onChangeFocus={handleChangeFocus}
- disableClick
- menuItems={[
- <BackgroundStyleDropdown
- key={`${columnComponent.id}-background`}
- id={`${columnComponent.id}-background`}
- value={
- (columnComponent.meta.background as string) ||
- BACKGROUND_TRANSPARENT
- }
- onChange={handleChangeBackground}
- />,
- ]}
- editMode={editMode}
- >
- {editMode && (
- <HoverMenu
- innerRef={
- dragSourceRef as unknown as React.RefObject<HTMLDivElement>
- }
- position="top"
- >
- <DragHandle position="top" />
- <DeleteComponentButton
- iconSize="m"
- onDelete={handleDeleteComponent}
- />
- <IconButton
- onClick={() => handleChangeFocus(true)}
- icon={<Icons.SettingOutlined iconSize="m" />}
- />
- </HoverMenu>
- )}
- <ColumnStyles
- className={cx('grid-column', backgroundStyle?.className)}
+ <ComponentThemeProvider layoutId={columnComponent.id}>
+ <WithPopoverMenu
+ isFocused={isFocused}
+ onChangeFocus={handleChangeFocus}
+ disableClick
+ menuItems={[
+ <BackgroundStyleDropdown
+ key={`${columnComponent.id}-background`}
+ id={`${columnComponent.id}-background`}
+ value={
+ (columnComponent.meta.background as string) ||
+ BACKGROUND_TRANSPARENT
+ }
+ onChange={handleChangeBackground}
+ />,
+ ]}
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 }: DropIndicatorChildProps) =>
- dropIndicatorProps && <div {...dropIndicatorProps} />
+ <HoverMenu
+ innerRef={
+ dragSourceRef as unknown as React.RefObject<HTMLDivElement>
}
- </Droppable>
+ position="top"
+ >
+ <DragHandle position="top" />
+ <DeleteComponentButton
+ iconSize="m"
+ onDelete={handleDeleteComponent}
+ />
+ <IconButton
+ onClick={() => handleChangeFocus(true)}
+ icon={<Icons.SettingOutlined iconSize="m" />}
+ />
+ <ComponentHeaderControls
+ items={[
+ {
+ key: 'apply-theme',
+ label: t('Apply theme'),
+ onClick: () => setThemeModalOpen(true),
+ },
+ ]}
+ ariaLabel={t('Column options')}
+ />
+ </HoverMenu>
+ )}
+ {editMode && (
+ <ThemeSelectorModal
+ layoutId={columnComponent.id}
+ show={themeModalOpen}
+ onHide={() => setThemeModalOpen(false)}
+ />
)}
- {columnItems.length === 0 ? (
- <div css={emptyColumnContentStyles}>{t('Empty column')}</div>
- ) : (
- columnItems.map((componentId: string, itemIndex: number) => (
- <Fragment key={componentId}>
- <DashboardComponent
- id={componentId}
- parentId={columnComponent.id}
- depth={depth + 1}
- index={itemIndex}
- availableColumnCount={columnComponent.meta.width ?? 0}
- columnWidth={columnWidth}
- onResizeStart={
- onResizeStart as unknown as (
- event: MouseEvent | TouchEvent,
- direction: string,
- elementRef: HTMLElement,
- ) => void
- }
- onResize={
- onResize as unknown as (
- event: MouseEvent | TouchEvent,
- direction: string,
- elementRef: HTMLElement,
- delta: { width: number; height: number },
- ) => void
- }
- onResizeStop={
- onResizeStop as unknown as (
- event: MouseEvent | TouchEvent,
- direction: string,
- elementRef: HTMLElement,
- delta: { width: number; height: number },
- id: string,
- ) => void
- }
- 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 }: DropIndicatorChildProps) =>
- dropIndicatorProps && <div {...dropIndicatorProps} />
+ <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>
- </WithPopoverMenu>
+ editMode
+ >
+ {({ dropIndicatorProps }: DropIndicatorChildProps) =>
+ dropIndicatorProps && <div {...dropIndicatorProps} />
+ }
+ </Droppable>
+ )}
+ {columnItems.length === 0 ? (
+ <div css={emptyColumnContentStyles}>{t('Empty column')}</div>
+ ) : (
+ columnItems.map((componentId: string, itemIndex: number) => (
+ <Fragment key={componentId}>
+ <DashboardComponent
+ id={componentId}
+ parentId={columnComponent.id}
+ depth={depth + 1}
+ index={itemIndex}
+ availableColumnCount={columnComponent.meta.width ?? 0}
+ columnWidth={columnWidth}
+ onResizeStart={
+ onResizeStart as unknown as (
+ event: MouseEvent | TouchEvent,
+ direction: string,
+ elementRef: HTMLElement,
+ ) => void
+ }
+ onResize={
+ onResize as unknown as (
+ event: MouseEvent | TouchEvent,
+ direction: string,
+ elementRef: HTMLElement,
+ delta: { width: number; height: number },
+ ) => void
+ }
+ onResizeStop={
+ onResizeStop as unknown as (
+ event: MouseEvent | TouchEvent,
+ direction: string,
+ elementRef: HTMLElement,
+ delta: { width: number; height: number },
+ id: string,
+ ) => void
+ }
+ 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 }: DropIndicatorChildProps) =>
+ dropIndicatorProps && <div {...dropIndicatorProps} />
+ }
+ </Droppable>
+ )}
+ </Fragment>
+ ))
+ )}
+ </ColumnStyles>
+ </WithPopoverMenu>
+ </ComponentThemeProvider>
</ResizableContainer>
),
[
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx
index 55f7426348d..6ab65e78798 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx
@@ -34,6 +34,9 @@ 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 ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
+import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
+import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import type { LayoutItem } from 'src/dashboard/types';
import type { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
import { ROW_TYPE, COLUMN_TYPE } from 'src/dashboard/util/componentTypes';
@@ -90,6 +93,7 @@ export interface MarkdownState {
undoLength: number;
redoLength: number;
hasError?: boolean;
+ themeModalOpen: boolean;
}
// TODO: localize
@@ -152,6 +156,7 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
editorMode: 'preview',
undoLength: props.undoLength,
redoLength: props.redoLength,
+ themeModalOpen: false,
};
this.renderStartTime = Logger.getTimestamp();
@@ -396,62 +401,82 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
- <WithPopoverMenu
- onChangeFocus={this.handleChangeFocus}
- shouldFocus={this.shouldFocusMarkdown}
- menuItems={[
- <MarkdownModeDropdown
- key={`${component.id}-mode`}
- 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}
+ <ComponentThemeProvider layoutId={component.id}>
+ <WithPopoverMenu
+ onChangeFocus={this.handleChangeFocus}
+ shouldFocus={this.shouldFocusMarkdown}
+ menuItems={[
+ <MarkdownModeDropdown
+ key={`${component.id}-mode`}
+ id={`${component.id}-mode`}
+ value={this.state.editorMode}
+ onChange={this.handleChangeEditorMode}
+ />,
+ <ComponentHeaderControls
+ key={`${component.id}-options`}
+ items={[
+ {
+ key: 'apply-theme',
+ label: t('Apply theme'),
+ onClick: () => this.setState({ themeModalOpen: true }),
+ },
+ ]}
+ ariaLabel={t('Markdown options')}
+ />,
+ ]}
+ editMode={editMode}
>
- <ResizableContainer
+ {editMode && (
+ <ThemeSelectorModal
+ layoutId={component.id}
+ show={this.state.themeModalOpen}
+ onHide={() => this.setState({ themeModalOpen: false })}
+ />
+ )}
+ <MarkdownStyles
+ data-test="dashboard-markdown-editor"
+ className={cx(
+ 'dashboard-markdown',
+ isEditing && 'dashboard-markdown--editing',
+ )}
id={component.id}
- adjustableWidth={parentComponent.type === ROW_TYPE}
- adjustableHeight
- widthStep={columnWidth}
- widthMultiple={widthMultiple}
- heightStep={GRID_BASE_UNIT}
- heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
- minWidthMultiple={GRID_MIN_COLUMN_COUNT}
- minHeightMultiple={GRID_MIN_ROW_UNITS}
- maxWidthMultiple={availableColumnCount + widthMultiple}
- onResizeStart={this.handleResizeStart}
- onResize={onResize}
- onResizeStop={onResizeStop}
- editMode={isFocused ? false : editMode}
>
- <div
- ref={dragSourceRef}
- className="dashboard-component dashboard-component-chart-holder"
- data-test="dashboard-component-chart-holder"
+ <ResizableContainer
+ id={component.id}
+ adjustableWidth={parentComponent.type === ROW_TYPE}
+ adjustableHeight
+ widthStep={columnWidth}
+ widthMultiple={widthMultiple}
+ heightStep={GRID_BASE_UNIT}
+ heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
+ minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+ minHeightMultiple={GRID_MIN_ROW_UNITS}
+ maxWidthMultiple={availableColumnCount + widthMultiple}
+ onResizeStart={this.handleResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ editMode={isFocused ? false : editMode}
>
- {editMode && (
- <HoverMenu position="top">
- <DeleteComponentButton
- onDelete={this.handleDeleteComponent}
- />
- </HoverMenu>
- )}
- {editMode && isEditing
- ? this.renderEditMode()
- : this.renderPreviewMode()}
- </div>
- </ResizableContainer>
- </MarkdownStyles>
- </WithPopoverMenu>
+ <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>
+ )}
+ {editMode && isEditing
+ ? this.renderEditMode()
+ : this.renderPreviewMode()}
+ </div>
+ </ResizableContainer>
+ </MarkdownStyles>
+ </WithPopoverMenu>
+ </ComponentThemeProvider>
)}
</Draggable>
);
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
index c5ec0c497f5..d4ff6f00ca8 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx
@@ -43,6 +43,9 @@ import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
import IconButton from 'src/dashboard/components/IconButton';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
+import ComponentThemeProvider from 'src/dashboard/components/ComponentThemeProvider';
+import ComponentHeaderControls from 'src/dashboard/components/menu/ComponentHeaderControls';
+import ThemeSelectorModal from 'src/dashboard/components/ThemeSelectorModal';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
@@ -158,6 +161,7 @@ const Row = memo((props: RowProps) => {
const [isInView, setIsInView] = useState(false);
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
const [containerHeight, setContainerHeight] = useState<number | null>(null);
+ const [themeModalOpen, setThemeModalOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const isComponentVisibleRef = useRef(isComponentVisible);
@@ -270,130 +274,151 @@ const Row = memo((props: RowProps) => {
const remainColumnCount = availableColumnCount - occupiedColumnCount;
const renderChild = useCallback(
({ dragSourceRef }: { dragSourceRef: RefObject<HTMLDivElement> }) => (
- <WithPopoverMenu
- isFocused={isFocused}
- onChangeFocus={handleChangeFocus}
- disableClick
- menuItems={[
- <BackgroundStyleDropdown
- id={`${rowComponent.id}-background`}
- value={backgroundStyle.value}
- onChange={handleChangeBackground}
- />,
- ]}
- editMode={editMode}
- >
- {editMode && (
- <HoverMenu
- onHover={handleMenuHover}
- innerRef={dragSourceRef}
- position="left"
- >
- <DragHandle position="left" />
- <DeleteComponentButton onDelete={handleDeleteComponent} />
- <IconButton
- onClick={() => handleChangeFocus(true)}
- icon={<Icons.SettingOutlined iconSize="l" />}
- />
- </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}
+ <ComponentThemeProvider layoutId={rowComponent.id}>
+ <WithPopoverMenu
+ isFocused={isFocused}
+ onChangeFocus={handleChangeFocus}
+ disableClick
+ menuItems={[
+ <BackgroundStyleDropdown
+ id={`${rowComponent.id}-background`}
+ value={backgroundStyle.value}
+ onChange={handleChangeBackground}
+ />,
+ ]}
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',
- )}
- editMode
- style={{
- height: rowItems.length > 0 ? containerHeight : '100%',
- ...(rowItems.length > 0 && { width: 16 }),
- }}
+ <HoverMenu
+ onHover={handleMenuHover}
+ innerRef={dragSourceRef}
+ position="left"
>
- {({ dropIndicatorProps }: { dropIndicatorProps: JsonObject }) =>
- dropIndicatorProps && <div {...dropIndicatorProps} />
- }
- </Droppable>
+ <DragHandle position="left" />
+ <DeleteComponentButton onDelete={handleDeleteComponent} />
+ <IconButton
+ onClick={() => handleChangeFocus(true)}
+ icon={<Icons.SettingOutlined iconSize="l" />}
+ />
+ <ComponentHeaderControls
+ items={[
+ {
+ key: 'apply-theme',
+ label: t('Apply theme'),
+ onClick: () => setThemeModalOpen(true),
+ },
+ ]}
+ ariaLabel={t('Row options')}
+ />
+ </HoverMenu>
)}
- {rowItems.length === 0 && (
- <div css={emptyRowContentStyles as any}>{t('Empty row')}</div>
+ {editMode && (
+ <ThemeSelectorModal
+ layoutId={rowComponent.id}
+ show={themeModalOpen}
+ onHide={() => setThemeModalOpen(false)}
+ />
)}
- {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>
- </WithPopoverMenu>
+ 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>
+ </WithPopoverMenu>
+ </ComponentThemeProvider>
),
[
backgroundStyle.className,
diff --git a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
index 6d030b06a56..ff1a38ec017 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx
@@ -45,6 +45,10 @@ import {
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
+import ComponentThemeProvider from '../../ComponentThemeProvider';
+import ComponentHeaderControls from '../../menu/ComponentHeaderControls';
+import ThemeSelectorModal from '../../ThemeSelectorModal';
+import { t } from '@apache-superset/core/translation';
const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
width: 100%;
@@ -173,6 +177,7 @@ const TabsRenderer = memo<TabsRendererProps>(
onTabTitleEditingChange,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
+ const [themeModalOpen, setThemeModalOpen] = useState(false);
// Use ref to always have access to the current tabIds in callbacks
const tabIdsRef = useRef(tabIds);
@@ -209,66 +214,86 @@ const TabsRenderer = memo<TabsRendererProps>(
const isDragging = activeId !== null;
return (
- <StyledTabsContainer
- className="dashboard-component dashboard-component-tabs"
- data-test="dashboard-component-tabs"
- isDragging={isDragging}
- >
- {editMode && renderHoverMenu && tabsDragSourceRef && (
- <HoverMenu innerRef={tabsDragSourceRef} position="left">
- <DragHandle position="left" />
- <DeleteComponentButton onDelete={handleDeleteComponent} />
- </HoverMenu>
- )}
+ <ComponentThemeProvider layoutId={tabsComponent.id}>
+ <StyledTabsContainer
+ className="dashboard-component dashboard-component-tabs"
+ data-test="dashboard-component-tabs"
+ isDragging={isDragging}
+ >
+ {editMode && renderHoverMenu && tabsDragSourceRef && (
+ <HoverMenu innerRef={tabsDragSourceRef} position="left">
+ <DragHandle position="left" />
+ <DeleteComponentButton onDelete={handleDeleteComponent} />
+ <ComponentHeaderControls
+ items={[
+ {
+ key: 'apply-theme',
+ label: t('Apply theme'),
+ onClick: () => setThemeModalOpen(true),
+ },
+ ]}
+ ariaLabel={t('Tabs options')}
+ />
+ </HoverMenu>
+ )}
+ {editMode && (
+ <ThemeSelectorModal
+ layoutId={tabsComponent.id}
+ show={themeModalOpen}
+ onHide={() => setThemeModalOpen(false)}
+ />
+ )}
- <LineEditableTabs
- id={tabsComponent.id}
- activeKey={activeKey}
- onChange={key => {
- if (typeof key === 'string') {
- const tabIndex = tabIds.indexOf(key);
- if (tabIndex !== -1) handleClickTab(tabIndex);
- }
- }}
- onEdit={handleEdit}
- data-test="nav-list"
- type={editMode ? 'editable-card' : 'card'}
- items={tabItems}
- tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
- fullHeight
- {...(editMode && {
- renderTabBar: (tabBarProps, DefaultTabBar) => (
- <DndContext
- key={tabIds.join('-')}
- sensors={[sensor]}
- onDragStart={onDragStart}
- onDragEnd={onDragEnd}
- onDragCancel={onDragCancel}
- collisionDetection={closestCenter}
- >
- <SortableContext
- items={tabIds}
- strategy={horizontalListSortingStrategy}
+ <LineEditableTabs
+ id={tabsComponent.id}
+ activeKey={activeKey}
+ onChange={key => {
+ if (typeof key === 'string') {
+ const tabIndex = tabIds.indexOf(key);
+ if (tabIndex !== -1) handleClickTab(tabIndex);
+ }
+ }}
+ onEdit={handleEdit}
+ data-test="nav-list"
+ type={editMode ? 'editable-card' : 'card'}
+ items={tabItems}
+ tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
+ fullHeight
+ {...(editMode && {
+ renderTabBar: (tabBarProps, DefaultTabBar) => (
+ <DndContext
+ key={tabIds.join('-')}
+ sensors={[sensor]}
+ onDragStart={onDragStart}
+ onDragEnd={onDragEnd}
+ onDragCancel={onDragCancel}
+ collisionDetection={closestCenter}
>
- <DefaultTabBar {...tabBarProps}>
- {(node: React.ReactElement) => (
- <DraggableTabNode
- {...(node as React.ReactElement<DraggableTabNodeProps>)
- .props}
- key={node.key}
- data-node-key={node.key as string}
- disabled={isEditingTabTitle}
- >
- {node}
- </DraggableTabNode>
- )}
- </DefaultTabBar>
- </SortableContext>
- </DndContext>
- ),
- })}
- />
- </StyledTabsContainer>
+ <SortableContext
+ items={tabIds}
+ strategy={horizontalListSortingStrategy}
+ >
+ <DefaultTabBar {...tabBarProps}>
+ {(node: React.ReactElement) => (
+ <DraggableTabNode
+ {...(
+ node as React.ReactElement<DraggableTabNodeProps>
+ ).props}
+ key={node.key}
+ data-node-key={node.key as string}
+ disabled={isEditingTabTitle}
+ >
+ {node}
+ </DraggableTabNode>
+ )}
+ </DefaultTabBar>
+ </SortableContext>
+ </DndContext>
+ ),
+ })}
+ />
+ </StyledTabsContainer>
+ </ComponentThemeProvider>
);
},
);