From 71c015c579dc1a918b0b8453c9c98146e79f106b Mon Sep 17 00:00:00 2001 From: Alexandru Soare <37236580+alexandrusoare@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:37:37 +0200 Subject: [PATCH] feat(Tabs): Rearange tabs when editing dashboard (#35156) --- superset-frontend/package-lock.json | 36 ++-- superset-frontend/package.json | 3 + .../src/components/EditableTitle/index.tsx | 4 +- .../src/components/EditableTitle/types.ts | 1 + .../components/gridComponents/Tab/Tab.jsx | 5 + .../components/gridComponents/Tabs/Tabs.jsx | 70 +++++-- .../TabsRenderer/TabsRenderer.tsx | 198 +++++++++++++++--- 7 files changed, 248 insertions(+), 69 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 9224d9cc455..a7232cc3e42 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -15,6 +15,9 @@ ], "dependencies": { "@apache-superset/core": "file:packages/superset-core", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -4291,16 +4294,16 @@ } }, "node_modules/@dnd-kit/sortable": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", - "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", "license": "MIT", "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { - "@dnd-kit/core": "^6.1.0", + "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, @@ -29400,6 +29403,20 @@ "url": "https://opencollective.com/geostyler" } }, + "node_modules/geostyler/node_modules/@dnd-kit/sortable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", + "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, "node_modules/geostyler/node_modules/geostyler-style": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz", @@ -66247,17 +66264,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "packages/superset-ui-demo/node_modules/core-js": { - "version": "3.39.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", - "integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "packages/superset-ui-demo/node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 390c9ff25aa..734c6a11eae 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -94,6 +94,9 @@ ], "dependencies": { "@apache-superset/core": "file:packages/superset-core", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx index a73c935de31..30b298f02dd 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/index.tsx @@ -86,6 +86,7 @@ export function EditableTitle({ renderLink, maxWidth, autoSize = true, + onEditingChange, ...rest }: EditableTitleProps) { const [isEditing, setIsEditing] = useState(editing); @@ -131,7 +132,8 @@ export function EditableTitle({ textArea.scrollTop = textArea.scrollHeight; } } - }, [isEditing]); + onEditingChange?.(isEditing); + }, [isEditing, onEditingChange]); function handleClick() { if (!canEdit || isEditing) return; diff --git a/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/types.ts b/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/types.ts index dc615dac644..e202fe191e1 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/EditableTitle/types.ts @@ -33,4 +33,5 @@ export interface EditableTitleProps { renderLink?: (title: string) => React.ReactNode; maxWidth?: number; autoSize?: boolean; + onEditingChange?: (isEditing: boolean) => void; } diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx index c9240a4526a..8902e84a467 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx @@ -56,6 +56,7 @@ const propTypes = { onHoverTab: PropTypes.func, editMode: PropTypes.bool.isRequired, embeddedMode: PropTypes.bool, + onTabTitleEditingChange: PropTypes.func, // grid related availableColumnCount: PropTypes.number, @@ -81,6 +82,7 @@ const defaultProps = { onResizeStart() {}, onResize() {}, onResizeStop() {}, + onTabTitleEditingChange() {}, }; const TabTitleContainer = styled.div` @@ -357,6 +359,7 @@ const Tab = props => { isHighlighted, dashboardId, embeddedMode, + onTabTitleEditingChange, } = props; return ( { onSaveTitle={handleChangeText} showTooltip={false} editing={editMode && isFocused} + onEditingChange={onTabTitleEditingChange} /> {!editMode && !embeddedMode && ( { props.isFocused, props.isHighlighted, props.dashboardId, + props.onTabTitleEditingChange, handleChangeText, ], ); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.jsx index 665977e9c83..b23f1da5674 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs/Tabs.jsx @@ -133,8 +133,8 @@ const Tabs = props => { const [selectedTabIndex, setSelectedTabIndex] = useState(initTabIndex); const [dropPosition, setDropPosition] = useState(null); const [dragOverTabIndex, setDragOverTabIndex] = useState(null); - const [draggingTabId, setDraggingTabId] = useState(null); const [tabToDelete, setTabToDelete] = useState(null); + const [isEditingTabTitle, setIsEditingTabTitle] = useState(false); const prevActiveKey = usePrevious(activeKey); const prevDashboardId = usePrevious(props.dashboardId); const prevDirectPathToChild = usePrevious(directPathToChild); @@ -214,8 +214,12 @@ const Tabs = props => { }); props.onChangeTab({ pathToTabIndex }); + setSelectedTabIndex(tabIndex); + } + // Always set activeKey to ensure it's synchronized + if (tabIds[tabIndex]) { + setActiveKey(tabIds[tabIndex]); } - setActiveKey(tabIds[tabIndex]); }, [ props.component, @@ -327,14 +331,40 @@ const Tabs = props => { } }, []); - const handleDragggingTab = useCallback(tabId => { - if (tabId) { - setDraggingTabId(tabId); - } else { - setDraggingTabId(null); - } + const handleTabTitleEditingChange = useCallback(isEditing => { + setIsEditingTabTitle(isEditing); }, []); + const handleTabsReorder = useCallback( + (oldIndex, newIndex) => { + const { component, updateComponents } = props; + const oldTabIds = component.children; + const newTabIds = [...oldTabIds]; + const [removed] = newTabIds.splice(oldIndex, 1); + newTabIds.splice(newIndex, 0, removed); + + const currentActiveTabId = oldTabIds[selectedTabIndex]; + const newActiveIndex = newTabIds.indexOf(currentActiveTabId); + + updateComponents({ + [component.id]: { + ...component, + children: newTabIds, + }, + }); + + // Update selected index to match the active tab's new position + if (newActiveIndex !== -1 && newActiveIndex !== selectedTabIndex) { + setSelectedTabIndex(newActiveIndex); + } + // Always update activeKey to ensure it stays synchronized after reorder + if (newActiveIndex !== -1) { + setActiveKey(currentActiveTabId); + } + }, + [props.component, props.updateComponents, selectedTabIndex], + ); + const { depth, component: tabsComponent, @@ -369,11 +399,6 @@ const Tabs = props => { [dragOverTabIndex, dropPosition, editMode], ); - const removeDraggedTab = useCallback( - tabID => draggingTabId === tabID, - [draggingTabId], - ); - // Extract tab highlighting logic into a hook const useTabHighlighting = useCallback(() => { const highlightedFilterId = @@ -390,9 +415,7 @@ const Tabs = props => { () => tabIds.map((tabId, tabIndex) => ({ key: tabId, - label: removeDraggedTab(tabId) ? ( - <> - ) : ( + label: ( <> {showDropIndicators(tabIndex).left && ( @@ -407,18 +430,16 @@ const Tabs = props => { columnWidth={columnWidth} onDropOnTab={handleDropOnTab} onDropPositionChange={handleGetDropPosition} - onDragTab={handleDragggingTab} onHoverTab={() => handleClickTab(tabIndex)} isFocused={activeKey === tabId} isHighlighted={ activeKey !== tabId && tabsToHighlight?.includes(tabId) } + onTabTitleEditingChange={handleTabTitleEditingChange} /> ), - closeIcon: removeDraggedTab(tabId) ? ( - <> - ) : ( + closeIcon: ( { })), [ tabIds, - removeDraggedTab, showDropIndicators, tabsComponent.id, depth, @@ -454,7 +474,6 @@ const Tabs = props => { columnWidth, handleDropOnTab, handleGetDropPosition, - handleDragggingTab, handleClickTab, activeKey, tabsToHighlight, @@ -464,6 +483,7 @@ const Tabs = props => { onResizeStop, selectedTabIndex, isCurrentTabVisible, + handleTabTitleEditingChange, ], ); @@ -481,6 +501,9 @@ const Tabs = props => { handleClickTab={handleClickTab} handleEdit={handleEdit} tabBarPaddingLeft={tabBarPaddingLeft} + onTabsReorder={handleTabsReorder} + isEditingTabTitle={isEditingTabTitle} + onTabTitleEditingChange={handleTabTitleEditingChange} /> ), [ @@ -494,6 +517,9 @@ const Tabs = props => { handleClickTab, handleEdit, tabBarPaddingLeft, + handleTabsReorder, + isEditingTabTitle, + handleTabTitleEditingChange, ], ); diff --git a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx index 11c72497185..445ddf1db31 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/TabsRenderer/TabsRenderer.tsx @@ -16,17 +16,36 @@ * specific language governing permissions and limitations * under the License. */ -import { memo, ReactElement, RefObject } from 'react'; +import { + cloneElement, + memo, + ReactElement, + RefObject, + useCallback, + useState, +} from 'react'; import { styled } from '@apache-superset/core/ui'; import { LineEditableTabs, TabsProps as AntdTabsProps, } from '@superset-ui/core/components/Tabs'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + DndContext, + PointerSensor, + useSensor, + closestCenter, +} from '@dnd-kit/core'; +import { + horizontalListSortingStrategy, + SortableContext, + useSortable, +} from '@dnd-kit/sortable'; import HoverMenu from '../../menu/HoverMenu'; import DragHandle from '../../dnd/DragHandle'; import DeleteComponentButton from '../../DeleteComponentButton'; -const StyledTabsContainer = styled.div` +const StyledTabsContainer = styled.div<{ isDragging?: boolean }>` width: 100%; background-color: ${({ theme }) => theme.colorBgContainer}; @@ -41,6 +60,16 @@ const StyledTabsContainer = styled.div` &.dragdroppable-row .dashboard-component-tabs-content { height: calc(100% - 47px); } + + /* Hide ink-bar during drag */ + ${({ isDragging }) => + isDragging && + ` + .ant-tabs-card > .ant-tabs-nav .ant-tabs-ink-bar, + .ant-tabs > .ant-tabs-nav .ant-tabs-ink-bar { + display: none !important; + } + `} `; export interface TabItem { @@ -66,8 +95,51 @@ export interface TabsRendererProps { handleClickTab: (index: number) => void; handleEdit: AntdTabsProps['onEdit']; tabBarPaddingLeft?: number; + onTabsReorder?: (oldIndex: number, newIndex: number) => void; + isEditingTabTitle?: boolean; + onTabTitleEditingChange?: (isEditing: boolean) => void; } +interface DraggableTabNodeProps extends React.HTMLAttributes { + 'data-node-key': string; + disabled?: boolean; +} + +const DraggableTabNode: React.FC> = ({ + className, + disabled = false, + ...props +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: props['data-node-key'], + disabled, + }); + + const style: React.CSSProperties = { + ...props.style, + position: 'relative', + transform: transform ? `translate3d(${transform.x}px, 0, 0)` : undefined, + transition, + cursor: disabled ? 'default' : 'move', + zIndex: isDragging ? 1000 : 'auto', + opacity: 1, + }; + + return cloneElement(props.children as React.ReactElement, { + ref: setNodeRef, + style, + ...attributes, + ...(disabled ? {} : listeners), + }); +}; + /** * TabsRenderer component handles the rendering of dashboard tabs * Extracted from the main Tabs component for better separation of concerns @@ -85,36 +157,100 @@ const TabsRenderer = memo( handleClickTab, handleEdit, tabBarPaddingLeft = 0, - }) => ( - - {editMode && renderHoverMenu && tabsDragSourceRef && ( - - - - - )} + onTabsReorder, + isEditingTabTitle = false, + onTabTitleEditingChange, + }) => { + const [activeId, setActiveId] = useState(null); - { - 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 - /> - - ), + const sensor = useSensor(PointerSensor, { + activationConstraint: { distance: 10 }, + }); + + const onDragStart = useCallback((event: any) => { + setActiveId(event.active.id); + }, []); + + const onDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (active.id !== over?.id && onTabsReorder) { + const activeIndex = tabIds.findIndex(id => id === active.id); + const overIndex = tabIds.findIndex(id => id === over?.id); + onTabsReorder(activeIndex, overIndex); + } + setActiveId(null); + }, + [onTabsReorder, tabIds], + ); + + const onDragCancel = useCallback(() => { + setActiveId(null); + }, []); + + const isDragging = activeId !== null; + + return ( + + {editMode && renderHoverMenu && tabsDragSourceRef && ( + + + + + )} + + { + 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) => ( + + + + {(node: React.ReactElement) => ( + ) + .props} + key={node.key} + data-node-key={node.key as string} + disabled={isEditingTabTitle} + > + {node} + + )} + + + + ), + })} + /> + + ); + }, ); TabsRenderer.displayName = 'TabsRenderer';