mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
feat(Tabs): Rearange tabs when editing dashboard (#35156)
This commit is contained in:
36
superset-frontend/package-lock.json
generated
36
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,4 +33,5 @@ export interface EditableTitleProps {
|
||||
renderLink?: (title: string) => React.ReactNode;
|
||||
maxWidth?: number;
|
||||
autoSize?: boolean;
|
||||
onEditingChange?: (isEditing: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user