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 <[email protected]> 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 <[email protected]> --- 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> ); }, );
