feat(Tabs): Rearange tabs when editing dashboard (#35156)

This commit is contained in:
Alexandru Soare
2025-11-20 20:37:37 +02:00
committed by GitHub
parent 7805666103
commit 71c015c579
7 changed files with 248 additions and 69 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -33,4 +33,5 @@ export interface EditableTitleProps {
renderLink?: (title: string) => React.ReactNode;
maxWidth?: number;
autoSize?: boolean;
onEditingChange?: (isEditing: boolean) => void;
}

View File

@@ -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 (
<TabTitleContainer
@@ -372,6 +375,7 @@ const Tab = props => {
onSaveTitle={handleChangeText}
showTooltip={false}
editing={editMode && isFocused}
onEditingChange={onTabTitleEditingChange}
/>
{!editMode && !embeddedMode && (
<AnchorLink
@@ -397,6 +401,7 @@ const Tab = props => {
props.isFocused,
props.isHighlighted,
props.dashboardId,
props.onTabTitleEditingChange,
handleChangeText,
],
);

View File

@@ -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 && (
<DropIndicator className="drop-indicator-left" pos="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: (
<CloseIconWithDropIndicator
role="button"
tabIndex={tabIndex}
@@ -446,7 +467,6 @@ const Tabs = props => {
})),
[
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,
],
);

View File

@@ -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<HTMLDivElement> {
'data-node-key': string;
disabled?: boolean;
}
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({
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<TabsRendererProps>(
handleClickTab,
handleEdit,
tabBarPaddingLeft = 0,
}) => (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
>
{editMode && renderHoverMenu && tabsDragSourceRef && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
onTabsReorder,
isEditingTabTitle = false,
onTabTitleEditingChange,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
<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
/>
</StyledTabsContainer>
),
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 (
<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>
)}
<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
sensors={[sensor]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
<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>
);
},
);
TabsRenderer.displayName = 'TabsRenderer';