This is an automated email from the ASF dual-hosted git repository.

kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 63f1d9eb98e feat(folders-editor): drag entire folder block as single 
unit (#38122)
63f1d9eb98e is described below

commit 63f1d9eb98e20f29e1c120fa046b8df8aadde114
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Fri Feb 27 20:02:21 2026 +0100

    feat(folders-editor): drag entire folder block as single unit (#38122)
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .../Datasource/FoldersEditor/TreeItem.styles.ts    |   2 +-
 .../FoldersEditor/VirtualizedTreeItem.tsx          |   8 +
 .../FoldersEditor/VirtualizedTreeList.tsx          |   4 +
 .../components/DragOverlayContent.test.tsx         | 124 ++++++++++++++++
 .../components/DragOverlayContent.tsx              |  66 ++++++++-
 .../Datasource/FoldersEditor/constants.ts          |   3 +
 .../FoldersEditor/hooks/useDragHandlers.test.ts    | 164 +++++++++++++++++++++
 .../FoldersEditor/hooks/useDragHandlers.ts         | 108 +++++++++++---
 .../FoldersEditor/hooks/useItemHeights.ts          |   7 +-
 .../components/Datasource/FoldersEditor/index.tsx  |  20 ++-
 .../Datasource/FoldersEditor/sensors.test.ts       | 120 +++++++++++++++
 .../components/Datasource/FoldersEditor/sensors.ts |  58 +++++++-
 .../components/Datasource/FoldersEditor/styles.tsx |  50 +++++++
 13 files changed, 700 insertions(+), 34 deletions(-)

diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts 
b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts
index d7d678a999a..86d7bfaba1f 100644
--- 
a/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts
@@ -30,7 +30,7 @@ export const TreeItemContainer = styled.div<{
 }>`
   ${({ theme, depth, isDragging, isOverlay }) => `
     margin: 0 ${theme.marginMD}px;
-    margin-left: ${isOverlay ? ITEM_INDENTATION_WIDTH : (depth - 1) * 
FOLDER_INDENTATION_WIDTH + ITEM_INDENTATION_WIDTH}px;
+    margin-left: ${Math.max(0, (depth - 1) * FOLDER_INDENTATION_WIDTH + 
ITEM_INDENTATION_WIDTH)}px;
     padding-left: ${theme.paddingSM}px;
     display: flex;
     align-items: center;
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx
 
b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx
index 39fa42d19df..9bfda10c8a3 100644
--- 
a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx
@@ -61,6 +61,7 @@ export interface VirtualizedTreeItemData {
   metricsMap: Map<string, Metric>;
   columnsMap: Map<string, ColumnMeta>;
   activeId: UniqueIdentifier | null;
+  draggedFolderChildIds: Set<string>;
   forbiddenDropFolderIds: Set<string>;
   currentDropTargetId: string | null;
   onToggleCollapse: (id: string) => void;
@@ -151,6 +152,7 @@ function VirtualizedTreeItemComponent({
     metricsMap,
     columnsMap,
     activeId,
+    draggedFolderChildIds,
     forbiddenDropFolderIds,
     currentDropTargetId,
     onToggleCollapse,
@@ -185,6 +187,12 @@ function VirtualizedTreeItemComponent({
     );
   }
 
+  // Hidden descendants of the dragged folder — not droppable.
+  // handleDragEnd uses lastValidOverIdRef when dropping in this dead zone.
+  if (draggedFolderChildIds.has(item.uuid)) {
+    return <div style={{ ...style, visibility: 'hidden' }} />;
+  }
+
   const childCount = isFolder ? (folderChildCounts.get(item.uuid) ?? 0) : 0;
   const showEmptyState = isFolder && childCount === 0;
 
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx
 
b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx
index 1920b296198..eeb2dad76b5 100644
--- 
a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx
@@ -48,6 +48,7 @@ interface VirtualizedTreeListProps {
   columnsMap: Map<string, ColumnMeta>;
   isDragging: boolean;
   activeId: UniqueIdentifier | null;
+  draggedFolderChildIds: Set<string>;
   forbiddenDropFolderIds: Set<string>;
   currentDropTargetId: string | null;
   onToggleCollapse: (id: string) => void;
@@ -73,6 +74,7 @@ export function VirtualizedTreeList({
   columnsMap,
   isDragging,
   activeId,
+  draggedFolderChildIds,
   forbiddenDropFolderIds,
   currentDropTargetId,
   onToggleCollapse,
@@ -180,6 +182,7 @@ export function VirtualizedTreeList({
       metricsMap,
       columnsMap,
       activeId,
+      draggedFolderChildIds,
       forbiddenDropFolderIds,
       currentDropTargetId,
       onToggleCollapse,
@@ -199,6 +202,7 @@ export function VirtualizedTreeList({
       metricsMap,
       columnsMap,
       activeId,
+      draggedFolderChildIds,
       forbiddenDropFolderIds,
       currentDropTargetId,
       onToggleCollapse,
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.test.tsx
 
b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.test.tsx
new file mode 100644
index 00000000000..ba921ef4600
--- /dev/null
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.test.tsx
@@ -0,0 +1,124 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { render, screen } from 'spec/helpers/testing-library';
+import { FlattenedTreeItem } from '../constants';
+import { FoldersEditorItemType } from '../../types';
+import { DragOverlayContent } from './DragOverlayContent';
+
+// Mock TreeItem to avoid dnd-kit hook dependencies
+jest.mock('../TreeItem', () => ({
+  TreeItem: ({ name, id }: { name: string; id: string }) => (
+    <div data-test={`tree-item-${id}`}>{name}</div>
+  ),
+}));
+
+const makeItem = (
+  uuid: string,
+  type: FoldersEditorItemType,
+  depth = 0,
+  parentId: string | null = null,
+): FlattenedTreeItem => ({
+  uuid,
+  type,
+  name: uuid,
+  depth,
+  parentId,
+  index: 0,
+});
+
+const defaultProps = {
+  dragOverlayWidth: 400,
+  selectedItemIds: new Set<string>(),
+  metricsMap: new Map(),
+  columnsMap: new Map(),
+  itemSeparatorInfo: new Map(),
+};
+
+test('returns null when dragOverlayItems is empty', () => {
+  const { container } = render(
+    <DragOverlayContent {...defaultProps} dragOverlayItems={[]} />,
+  );
+  // The wrapper div rendered by testing-library's render should be empty
+  expect(container.querySelector('[data-test]')).toBeNull();
+});
+
+test('renders folder block overlay for folder drag with children', () => {
+  const items: FlattenedTreeItem[] = [
+    makeItem('folder-1', FoldersEditorItemType.Folder, 0),
+    makeItem('metric-1', FoldersEditorItemType.Metric, 1, 'folder-1'),
+    makeItem('metric-2', FoldersEditorItemType.Metric, 1, 'folder-1'),
+  ];
+
+  render(<DragOverlayContent {...defaultProps} dragOverlayItems={items} />);
+
+  expect(screen.getByTestId('tree-item-folder-1')).toBeInTheDocument();
+  expect(screen.getByTestId('tree-item-metric-1')).toBeInTheDocument();
+  expect(screen.getByTestId('tree-item-metric-2')).toBeInTheDocument();
+  expect(screen.queryByText(/and \d+ more/)).not.toBeInTheDocument();
+});
+
+test('truncates folder overlay and shows "... and N more" for large folders', 
() => {
+  const MAX_FOLDER_OVERLAY_CHILDREN = 8;
+  const totalChildren = MAX_FOLDER_OVERLAY_CHILDREN + 5;
+  const items: FlattenedTreeItem[] = [
+    makeItem('folder-1', FoldersEditorItemType.Folder, 0),
+    ...Array.from({ length: totalChildren }, (_, i) =>
+      makeItem(`item-${i}`, FoldersEditorItemType.Metric, 1, 'folder-1'),
+    ),
+  ];
+
+  render(<DragOverlayContent {...defaultProps} dragOverlayItems={items} />);
+
+  // folder header + MAX_FOLDER_OVERLAY_CHILDREN are visible
+  expect(screen.getByTestId('tree-item-folder-1')).toBeInTheDocument();
+  for (let i = 0; i < MAX_FOLDER_OVERLAY_CHILDREN; i += 1) {
+    expect(screen.getByTestId(`tree-item-item-${i}`)).toBeInTheDocument();
+  }
+
+  // Items beyond the limit are not rendered
+  expect(
+    screen.queryByTestId(`tree-item-item-${MAX_FOLDER_OVERLAY_CHILDREN}`),
+  ).not.toBeInTheDocument();
+
+  // "... and N more" indicator shown with the remaining count
+  expect(screen.getByText(/and 5 more/)).toBeInTheDocument();
+});
+
+test('renders stacked overlay for single non-folder item drag', () => {
+  const items: FlattenedTreeItem[] = [
+    makeItem('metric-1', FoldersEditorItemType.Metric, 1, 'folder-1'),
+  ];
+
+  render(<DragOverlayContent {...defaultProps} dragOverlayItems={items} />);
+
+  expect(screen.getByTestId('tree-item-metric-1')).toBeInTheDocument();
+});
+
+test('renders stacked overlay for folder with no children', () => {
+  const items: FlattenedTreeItem[] = [
+    makeItem('folder-1', FoldersEditorItemType.Folder, 0),
+  ];
+
+  render(<DragOverlayContent {...defaultProps} dragOverlayItems={items} />);
+
+  expect(screen.getByTestId('tree-item-folder-1')).toBeInTheDocument();
+  // Single folder should use the stacked overlay, not folder block
+  expect(screen.queryByText(/and \d+ more/)).not.toBeInTheDocument();
+});
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx
 
b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx
index b14ab8d3344..1fd6a4d5251 100644
--- 
a/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx
@@ -18,12 +18,21 @@
  */
 
 import { memo } from 'react';
+import { t } from '@apache-superset/core';
 import { Metric } from '@superset-ui/core';
 import { ColumnMeta } from '@superset-ui/chart-controls';
 import { FoldersEditorItemType } from '../../types';
-import { FlattenedTreeItem } from '../constants';
+import { FlattenedTreeItem, isDefaultFolder } from '../constants';
 import { TreeItem } from '../TreeItem';
-import { DragOverlayStack, DragOverlayItem } from '../styles';
+import {
+  DragOverlayStack,
+  DragOverlayItem,
+  DragOverlayFolderBlock,
+  FolderBlockSlot,
+  MoreItemsIndicator,
+} from '../styles';
+
+const MAX_FOLDER_OVERLAY_CHILDREN = 8;
 
 interface DragOverlayContentProps {
   dragOverlayItems: FlattenedTreeItem[];
@@ -31,6 +40,7 @@ interface DragOverlayContentProps {
   selectedItemIds: Set<string>;
   metricsMap: Map<string, Metric>;
   columnsMap: Map<string, ColumnMeta>;
+  itemSeparatorInfo: Map<string, 'visible' | 'transparent'>;
 }
 
 function DragOverlayContentInner({
@@ -39,11 +49,63 @@ function DragOverlayContentInner({
   selectedItemIds,
   metricsMap,
   columnsMap,
+  itemSeparatorInfo,
 }: DragOverlayContentProps) {
   if (dragOverlayItems.length === 0) {
     return null;
   }
 
+  const firstItem = dragOverlayItems[0];
+  const isFolderDrag = firstItem.type === FoldersEditorItemType.Folder;
+
+  // Folder drag: folder header + children
+  if (isFolderDrag && dragOverlayItems.length > 1) {
+    const maxVisible = 1 + MAX_FOLDER_OVERLAY_CHILDREN; // folder header + 
children
+    const visibleItems = dragOverlayItems.slice(0, maxVisible);
+    const remainingCount = dragOverlayItems.length - maxVisible;
+    const baseDepth = firstItem.depth;
+
+    return (
+      <DragOverlayFolderBlock width={dragOverlayWidth ?? undefined}>
+        {visibleItems.map((item, index) => {
+          const isItemFolder = item.type === FoldersEditorItemType.Folder;
+          const separatorType = itemSeparatorInfo.get(item.uuid);
+          // No separator on the very last visible item
+          const isLastVisible =
+            index === visibleItems.length - 1 && remainingCount === 0;
+          const effectiveSeparator = isLastVisible ? undefined : separatorType;
+          return (
+            <FolderBlockSlot
+              key={item.uuid}
+              variant={isItemFolder ? 'folder' : 'item'}
+              separatorType={effectiveSeparator}
+            >
+              <TreeItem
+                id={item.uuid}
+                type={item.type}
+                name={item.name}
+                depth={item.depth - baseDepth}
+                isFolder={isItemFolder}
+                isDefaultFolder={isDefaultFolder(item.uuid)}
+                isOverlay
+                isSelected={selectedItemIds.has(item.uuid)}
+                metric={metricsMap.get(item.uuid)}
+                column={columnsMap.get(item.uuid)}
+                separatorType={effectiveSeparator}
+              />
+            </FolderBlockSlot>
+          );
+        })}
+        {remainingCount > 0 && (
+          <MoreItemsIndicator>
+            {t('... and %d more', remainingCount)}
+          </MoreItemsIndicator>
+        )}
+      </DragOverlayFolderBlock>
+    );
+  }
+
+  // Multi-select or single item drag: stacked card behavior
   return (
     <DragOverlayStack width={dragOverlayWidth ?? undefined}>
       {[...dragOverlayItems].reverse().map((item, index) => {
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts 
b/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts
index 913ee905fb6..599e16ca512 100644
--- a/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts
@@ -36,6 +36,9 @@ export const DEFAULT_FOLDERS_COUNT = 2;
 export const DRAG_INDENTATION_WIDTH = 64;
 export const MAX_DEPTH = 3;
 
+// Base row height for tree items
+export const ITEM_BASE_HEIGHT = 32;
+
 // Type definitions
 export type TreeItem = DatasourceFolder | DatasourceFolderItem;
 
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.test.ts
 
b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.test.ts
new file mode 100644
index 00000000000..45ed8865b49
--- /dev/null
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.test.ts
@@ -0,0 +1,164 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { renderHook, act } from '@testing-library/react-hooks';
+import type { DragStartEvent } from '@dnd-kit/core';
+import { FlattenedTreeItem } from '../constants';
+import { FoldersEditorItemType } from '../../types';
+import { useDragHandlers } from './useDragHandlers';
+
+const makeItem = (
+  uuid: string,
+  type: FoldersEditorItemType,
+  depth: number,
+  parentId: string | null = null,
+  index = 0,
+): FlattenedTreeItem => ({
+  uuid,
+  type,
+  name: uuid,
+  depth,
+  parentId,
+  index,
+});
+
+/**
+ * Flat list representing:
+ *   folder-a (depth 0)
+ *     metric-1 (depth 1)
+ *     subfolder-b (depth 1)
+ *       metric-2 (depth 2)
+ *     metric-3 (depth 1)
+ *   folder-c (depth 0)
+ *     column-1 (depth 1)
+ */
+const flatItems: FlattenedTreeItem[] = [
+  makeItem('folder-a', FoldersEditorItemType.Folder, 0, null, 0),
+  makeItem('metric-1', FoldersEditorItemType.Metric, 1, 'folder-a', 1),
+  makeItem('subfolder-b', FoldersEditorItemType.Folder, 1, 'folder-a', 2),
+  makeItem('metric-2', FoldersEditorItemType.Metric, 2, 'subfolder-b', 3),
+  makeItem('metric-3', FoldersEditorItemType.Metric, 1, 'folder-a', 4),
+  makeItem('folder-c', FoldersEditorItemType.Folder, 0, null, 5),
+  makeItem('column-1', FoldersEditorItemType.Column, 1, 'folder-c', 6),
+];
+
+function makeDragStartEvent(id: string): DragStartEvent {
+  return {
+    active: {
+      id,
+      rect: { current: { initial: null, translated: null } },
+      data: { current: {} },
+    },
+  } as unknown as DragStartEvent;
+}
+
+function setup(selectedIds: Set<string> = new Set()) {
+  return renderHook(() =>
+    useDragHandlers({
+      setItems: jest.fn(),
+      computeFlattenedItems: () => flatItems,
+      fullFlattenedItems: flatItems,
+      selectedItemIds: selectedIds,
+      onChange: jest.fn(),
+      addWarningToast: jest.fn(),
+    }),
+  );
+}
+
+test('folder drag collects all visible descendants into 
draggedFolderChildIds', () => {
+  const { result } = setup();
+
+  act(() => {
+    result.current.handleDragStart(makeDragStartEvent('folder-a'));
+  });
+
+  const childIds = result.current.draggedFolderChildIds;
+  expect(childIds.has('metric-1')).toBe(true);
+  expect(childIds.has('subfolder-b')).toBe(true);
+  expect(childIds.has('metric-2')).toBe(true);
+  expect(childIds.has('metric-3')).toBe(true);
+  // Items outside the folder are not included
+  expect(childIds.has('folder-c')).toBe(false);
+  expect(childIds.has('column-1')).toBe(false);
+});
+
+test('non-folder drag leaves draggedFolderChildIds empty', () => {
+  const { result } = setup();
+
+  act(() => {
+    result.current.handleDragStart(makeDragStartEvent('metric-1'));
+  });
+
+  expect(result.current.draggedFolderChildIds.size).toBe(0);
+});
+
+test('projectionItems (flattenedItems) excludes folder descendants during 
folder drag', () => {
+  const { result } = setup();
+
+  act(() => {
+    result.current.handleDragStart(makeDragStartEvent('folder-a'));
+  });
+
+  const itemIds = result.current.flattenedItems.map(
+    (item: FlattenedTreeItem) => item.uuid,
+  );
+  // The stable snapshot includes all items (captured before activeId is set),
+  // but projectionItems filtering is internal. We verify the hook returns
+  // the full stable snapshot since it's what the virtualized list needs.
+  expect(itemIds).toContain('folder-a');
+  expect(itemIds).toContain('folder-c');
+  expect(itemIds).toContain('column-1');
+});
+
+test('subfolder drag collects only its own descendants', () => {
+  const { result } = setup();
+
+  act(() => {
+    result.current.handleDragStart(makeDragStartEvent('subfolder-b'));
+  });
+
+  const childIds = result.current.draggedFolderChildIds;
+  expect(childIds.has('metric-2')).toBe(true);
+  // Items outside subfolder-b
+  expect(childIds.has('metric-1')).toBe(false);
+  expect(childIds.has('metric-3')).toBe(false);
+  expect(childIds.has('folder-a')).toBe(false);
+});
+
+test('draggedItemIds contains selected items when active item is selected', () 
=> {
+  const selected = new Set(['metric-1', 'metric-3']);
+  const { result } = setup(selected);
+
+  act(() => {
+    result.current.handleDragStart(makeDragStartEvent('metric-1'));
+  });
+
+  expect(result.current.draggedItemIds).toEqual(selected);
+});
+
+test('draggedItemIds contains only active item when not in selection', () => {
+  const selected = new Set(['metric-1', 'metric-3']);
+  const { result } = setup(selected);
+
+  act(() => {
+    result.current.handleDragStart(makeDragStartEvent('column-1'));
+  });
+
+  expect(result.current.draggedItemIds).toEqual(new Set(['column-1']));
+});
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts
 
b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts
index 388da41cfcd..ddeca6884e1 100644
--- 
a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts
@@ -66,6 +66,11 @@ export function useDragHandlers({
     null,
   );
   const [draggedItemIds, setDraggedItemIds] = useState<Set<string>>(new Set());
+  const [draggedFolderChildIds, setDraggedFolderChildIds] = useState<
+    Set<string>
+  >(new Set());
+  // Last non-null overId — fallback target when dropping in dead zones
+  const lastValidOverIdRef = useRef<UniqueIdentifier | null>(null);
 
   // Store the flattened items at drag start to keep them stable during drag
   // This prevents react-window from re-rendering due to flattenedItems 
reference changes
@@ -88,13 +93,19 @@ export function useDragHandlers({
     [activeId, computedFlattenedItems],
   );
 
-  const flattenedItemsIndexMap = useMemo(() => {
+  // Exclude dragged folder children — they'd skew getProjection's minDepth 
calc
+  const projectionItems = useMemo(() => {
+    if (draggedFolderChildIds.size === 0) return flattenedItems;
+    return flattenedItems.filter(item => 
!draggedFolderChildIds.has(item.uuid));
+  }, [flattenedItems, draggedFolderChildIds]);
+
+  const projectionIndexMap = useMemo(() => {
     const map = new Map<string, number>();
-    flattenedItems.forEach((item, index) => {
+    projectionItems.forEach((item, index) => {
       map.set(item.uuid, index);
     });
     return map;
-  }, [flattenedItems]);
+  }, [projectionItems]);
 
   // Shared lookup maps for O(1) access - used by handleDragEnd and 
forbiddenDropFolderIds
   const fullItemsByUuid = useMemo(() => {
@@ -152,8 +163,10 @@ export function useDragHandlers({
     setActiveId(null);
     setOverId(null);
     offsetLeftRef.current = 0;
+    lastValidOverIdRef.current = null;
     setCurrentDropTargetId(null);
     setDraggedItemIds(new Set());
+    setDraggedFolderChildIds(new Set());
     setDragOverlayWidth(null);
     // Clear the stable snapshot so next render uses fresh computed items
     dragStartFlattenedItemsRef.current = null;
@@ -162,7 +175,8 @@ export function useDragHandlers({
   const handleDragStart = ({ active }: DragStartEvent) => {
     // Capture the current flattened items BEFORE setting activeId
     // This ensures the list stays stable during the entire drag operation
-    dragStartFlattenedItemsRef.current = computeFlattenedItems(null);
+    const snapshot = computeFlattenedItems(null);
+    dragStartFlattenedItemsRef.current = snapshot;
 
     setActiveId(active.id);
 
@@ -176,6 +190,23 @@ export function useDragHandlers({
     } else {
       setDraggedItemIds(new Set([active.id as string]));
     }
+
+    // Collect descendant IDs for hiding from list / showing in overlay
+    const activeIndex = snapshot.findIndex(
+      item => item.uuid === (active.id as string),
+    );
+    const activeItem = snapshot[activeIndex];
+    if (activeItem?.type === FoldersEditorItemType.Folder) {
+      const descendantIds = new Set<string>();
+      for (let i = activeIndex + 1; i < snapshot.length; i += 1) {
+        if (snapshot[i].depth > activeItem.depth) {
+          descendantIds.add(snapshot[i].uuid);
+        } else {
+          break;
+        }
+      }
+      setDraggedFolderChildIds(descendantIds);
+    }
   };
 
   const handleDragMove = useCallback(
@@ -190,23 +221,26 @@ export function useDragHandlers({
         }
 
         const projection = getProjection(
-          flattenedItems,
+          projectionItems,
           activeId,
           overId,
           delta.x,
           DRAG_INDENTATION_WIDTH,
-          flattenedItemsIndexMap,
+          projectionIndexMap,
         );
         const newParentId = projection?.parentId ?? null;
         setCurrentDropTargetId(newParentId);
       }
     },
-    [activeId, overId, flattenedItems, flattenedItemsIndexMap],
+    [activeId, overId, projectionItems, projectionIndexMap],
   );
 
   const handleDragOver = useCallback(
     ({ over }: DragOverEvent) => {
       setOverId(over?.id ?? null);
+      if (over) {
+        lastValidOverIdRef.current = over.id;
+      }
 
       if (activeId && over) {
         if (typeof over.id === 'string' && over.id.endsWith('-empty')) {
@@ -216,12 +250,12 @@ export function useDragHandlers({
         }
 
         const projection = getProjection(
-          flattenedItems,
+          projectionItems,
           activeId,
           over.id,
           offsetLeftRef.current,
           DRAG_INDENTATION_WIDTH,
-          flattenedItemsIndexMap,
+          projectionIndexMap,
         );
         const newParentId = projection?.parentId ?? null;
         setCurrentDropTargetId(newParentId);
@@ -229,22 +263,32 @@ export function useDragHandlers({
         setCurrentDropTargetId(null);
       }
     },
-    [activeId, flattenedItems, flattenedItemsIndexMap],
+    [activeId, projectionItems, projectionIndexMap],
   );
 
   const handleDragEnd = ({ active, over }: DragEndEvent) => {
     const itemsBeingDragged = Array.from(draggedItemIds);
+    const folderChildIds = draggedFolderChildIds;
     const finalOffsetLeft = offsetLeftRef.current;
+    // Capture fallback overId before reset (for dead-zone drops)
+    const fallbackOverId = lastValidOverIdRef.current;
     resetDragState();
 
-    if (!over || itemsBeingDragged.length === 0) {
+    // Folder drags only: hidden children create dead zones where over is null.
+    // Regular drags with null over just cancel.
+    const effectiveOver =
+      over ??
+      (folderChildIds.size > 0 && fallbackOverId
+        ? { id: fallbackOverId }
+        : null);
+    if (!effectiveOver || itemsBeingDragged.length === 0) {
       return;
     }
 
-    let targetOverId = over.id;
+    let targetOverId = effectiveOver.id;
     let isEmptyDrop = false;
-    if (typeof over.id === 'string' && over.id.endsWith('-empty')) {
-      targetOverId = over.id.replace('-empty', '');
+    if (typeof targetOverId === 'string' && targetOverId.endsWith('-empty')) {
+      targetOverId = targetOverId.replace('-empty', '');
       isEmptyDrop = true;
 
       if (itemsBeingDragged.includes(targetOverId as string)) {
@@ -252,6 +296,11 @@ export function useDragHandlers({
       }
     }
 
+    // Dropping onto a descendant of the dragged folder is a no-op
+    if (folderChildIds.has(targetOverId as string)) {
+      return;
+    }
+
     const activeIndex = fullItemsIndexMap.get(active.id as string) ?? -1;
     const overIndex = fullItemsIndexMap.get(targetOverId as string) ?? -1;
 
@@ -266,12 +315,12 @@ export function useDragHandlers({
     );
 
     let projectedPosition = getProjection(
-      flattenedItems,
+      projectionItems,
       active.id,
       targetOverId,
       finalOffsetLeft,
       DRAG_INDENTATION_WIDTH,
-      flattenedItemsIndexMap,
+      projectionIndexMap,
     );
 
     if (isEmptyDrop) {
@@ -636,8 +685,6 @@ export function useDragHandlers({
               } else {
                 insertionIndex = overItemInRemaining;
               }
-            } else if (projectedPosition.depth > overItem.depth) {
-              insertionIndex = overItemInRemaining + 1;
             } else {
               insertionIndex = overItemInRemaining + 1;
             }
@@ -680,12 +727,34 @@ export function useDragHandlers({
   const dragOverlayItems = useMemo(() => {
     if (!activeId || draggedItemIds.size === 0) return [];
 
+    const activeItem = fullItemsByUuid.get(activeId as string);
+
+    // Folder drag: include folder + visible descendants
+    if (
+      activeItem?.type === FoldersEditorItemType.Folder &&
+      draggedFolderChildIds.size > 0
+    ) {
+      const activeIdStr = activeId as string;
+      return flattenedItems.filter(
+        (item: FlattenedTreeItem) =>
+          item.uuid === activeIdStr || draggedFolderChildIds.has(item.uuid),
+      );
+    }
+
+    // Multi-select / single item: stacked overlay
     const draggedItems = fullFlattenedItems.filter((item: FlattenedTreeItem) =>
       draggedItemIds.has(item.uuid),
     );
 
     return draggedItems.slice(0, 3);
-  }, [activeId, draggedItemIds, fullFlattenedItems]);
+  }, [
+    activeId,
+    draggedItemIds,
+    draggedFolderChildIds,
+    flattenedItems,
+    fullFlattenedItems,
+    fullItemsByUuid,
+  ]);
 
   const forbiddenDropFolderIds = useMemo(() => {
     const forbidden = new Set<string>();
@@ -788,6 +857,7 @@ export function useDragHandlers({
     isDragging: activeId !== null,
     activeId,
     draggedItemIds,
+    draggedFolderChildIds,
     dragOverlayWidth,
     flattenedItems,
     dragOverlayItems,
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts
 
b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts
index 4654637b292..e45382a663f 100644
--- 
a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts
+++ 
b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useItemHeights.ts
@@ -23,6 +23,7 @@ import {
   FOLDER_INDENTATION_WIDTH,
   ITEM_INDENTATION_WIDTH,
 } from '../TreeItem.styles';
+import { ITEM_BASE_HEIGHT } from '../constants';
 
 export interface ItemHeights {
   /** Height of a regular item (metric/column) including margins */
@@ -49,13 +50,13 @@ export interface ItemHeights {
  * The spacing is built into the height calculation, NOT the CSS margins,
  * to avoid double-spacing issues with absolute positioning.
  */
-function calculateItemHeights(theme: SupersetTheme): ItemHeights {
+export function calculateItemHeights(theme: SupersetTheme): ItemHeights {
   // Regular item height - just the row height, minimal spacing
   // The OptionControlContainer sets the actual content height
-  const regularItem = 32;
+  const regularItem = ITEM_BASE_HEIGHT;
 
   // Folder header - base height + vertical padding (for taller highlight) + 
bottom spacing
-  const folderHeader = 32 + theme.paddingSM + theme.marginXS;
+  const folderHeader = ITEM_BASE_HEIGHT + theme.paddingSM + theme.marginXS;
 
   // Separator visible: 1px line + vertical margins (marginSM above and below)
   const separatorVisible = 1 + theme.marginSM * 2;
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx 
b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx
index a7a775ecc3e..d10c5f9a3f0 100644
--- a/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/index.tsx
@@ -52,6 +52,7 @@ import {
   pointerSensorOptions,
   measuringConfig,
   autoScrollConfig,
+  getCollisionDetection,
 } from './sensors';
 import { FoldersContainer, FoldersContent } from './styles';
 import { FoldersEditorProps } from './types';
@@ -160,6 +161,7 @@ export default function FoldersEditor({
   const {
     isDragging,
     activeId,
+    draggedFolderChildIds,
     dragOverlayWidth,
     flattenedItems,
     dragOverlayItems,
@@ -412,9 +414,20 @@ export default function FoldersEditor({
     return separators;
   }, [flattenedItems, lastChildIds]);
 
+  // Exclude dragged folder children so SortableContext skips hidden items
   const sortableItemIds = useMemo(
-    () => flattenedItems.map(({ uuid }) => uuid),
-    [flattenedItems],
+    () =>
+      draggedFolderChildIds.size > 0
+        ? flattenedItems
+            .filter(item => !draggedFolderChildIds.has(item.uuid))
+            .map(({ uuid }) => uuid)
+        : flattenedItems.map(({ uuid }) => uuid),
+    [flattenedItems, draggedFolderChildIds],
+  );
+
+  const collisionDetection = useMemo(
+    () => getCollisionDetection(activeId),
+    [activeId],
   );
 
   const selectedMetricsCount = useMemo(() => {
@@ -465,6 +478,7 @@ export default function FoldersEditor({
       <FoldersContent ref={contentRef}>
         <DndContext
           sensors={sensors}
+          collisionDetection={collisionDetection}
           measuring={measuringConfig}
           autoScroll={autoScrollConfig}
           onDragStart={handleDragStart}
@@ -496,6 +510,7 @@ export default function FoldersEditor({
                   columnsMap={columnsMap}
                   isDragging={isDragging}
                   activeId={activeId}
+                  draggedFolderChildIds={draggedFolderChildIds}
                   forbiddenDropFolderIds={forbiddenDropFolderIds}
                   currentDropTargetId={currentDropTargetId}
                   onToggleCollapse={handleToggleCollapse}
@@ -514,6 +529,7 @@ export default function FoldersEditor({
               selectedItemIds={selectedItemIds}
               metricsMap={metricsMap}
               columnsMap={columnsMap}
+              itemSeparatorInfo={itemSeparatorInfo}
             />
           </DragOverlay>
         </DndContext>
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/sensors.test.ts 
b/superset-frontend/src/components/Datasource/FoldersEditor/sensors.test.ts
new file mode 100644
index 00000000000..b1b32b30adc
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/sensors.test.ts
@@ -0,0 +1,120 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { rectIntersection, pointerWithin, closestCenter } from '@dnd-kit/core';
+import type { CollisionDescriptor } from '@dnd-kit/core';
+import { getCollisionDetection } from './sensors';
+
+jest.mock('@dnd-kit/core', () => {
+  const actual = jest.requireActual('@dnd-kit/core');
+  return {
+    ...actual,
+    rectIntersection: jest.fn(),
+    pointerWithin: jest.fn(),
+    closestCenter: jest.fn(),
+  };
+});
+
+const mockRectIntersection = rectIntersection as jest.Mock;
+const mockPointerWithin = pointerWithin as jest.Mock;
+const mockClosestCenter = closestCenter as jest.Mock;
+
+const collision = (id: string): CollisionDescriptor => ({
+  id,
+  data: { droppableContainer: { id } as any, value: 0 },
+});
+
+const dummyArgs = {} as any;
+
+test('returns rectIntersection when activeId is null', () => {
+  const detector = getCollisionDetection(null);
+  expect(detector).toBe(rectIntersection);
+});
+
+test('returns rectIntersection result when best match is not the active item', 
() => {
+  const detector = getCollisionDetection('active-1');
+  const collisions = [collision('other-item'), collision('active-1')];
+  mockRectIntersection.mockReturnValue(collisions);
+
+  const result = detector(dummyArgs);
+
+  expect(result).toBe(collisions);
+  expect(mockPointerWithin).not.toHaveBeenCalled();
+  expect(mockClosestCenter).not.toHaveBeenCalled();
+});
+
+test('returns rectIntersection result when collisions array is empty', () => {
+  const detector = getCollisionDetection('active-1');
+  mockRectIntersection.mockReturnValue([]);
+
+  const result = detector(dummyArgs);
+
+  expect(result).toEqual([]);
+  expect(mockPointerWithin).not.toHaveBeenCalled();
+});
+
+test('falls back to pointerWithin when rectIntersection picks the active 
item', () => {
+  const detector = getCollisionDetection('active-1');
+  const activeCollision = collision('active-1');
+  const otherCollision = collision('other-item');
+  mockRectIntersection.mockReturnValue([activeCollision]);
+  mockPointerWithin.mockReturnValue([otherCollision]);
+
+  const result = detector(dummyArgs);
+
+  expect(result).toEqual([otherCollision, activeCollision]);
+  expect(mockClosestCenter).not.toHaveBeenCalled();
+});
+
+test('keeps rectIntersection result when pointerWithin also finds only the 
active item', () => {
+  const detector = getCollisionDetection('active-1');
+  const activeCollision = collision('active-1');
+  mockRectIntersection.mockReturnValue([activeCollision]);
+  mockPointerWithin.mockReturnValue([collision('active-1')]);
+
+  const result = detector(dummyArgs);
+
+  expect(result).toEqual([activeCollision]);
+  expect(mockClosestCenter).not.toHaveBeenCalled();
+});
+
+test('falls back to closestCenter when pointerWithin returns empty', () => {
+  const detector = getCollisionDetection('active-1');
+  const activeCollision = collision('active-1');
+  const centerCollision = collision('nearby-item');
+  mockRectIntersection.mockReturnValue([activeCollision]);
+  mockPointerWithin.mockReturnValue([]);
+  mockClosestCenter.mockReturnValue([centerCollision]);
+
+  const result = detector(dummyArgs);
+
+  expect(result).toEqual([centerCollision, activeCollision]);
+});
+
+test('returns rectIntersection result when all fallbacks only find the active 
item', () => {
+  const detector = getCollisionDetection('active-1');
+  const activeCollision = collision('active-1');
+  mockRectIntersection.mockReturnValue([activeCollision]);
+  mockPointerWithin.mockReturnValue([]);
+  mockClosestCenter.mockReturnValue([collision('active-1')]);
+
+  const result = detector(dummyArgs);
+
+  expect(result).toEqual([activeCollision]);
+});
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts 
b/superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts
index 3c8cdc467e9..3b5ba987b4d 100644
--- a/superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/sensors.ts
@@ -21,27 +21,71 @@ import {
   PointerSensorOptions,
   MeasuringConfiguration,
   MeasuringStrategy,
+  rectIntersection,
+  pointerWithin,
+  closestCenter,
+  CollisionDetection,
+  UniqueIdentifier,
 } from '@dnd-kit/core';
 
+/**
+ * Collision detection that deprioritizes the active (dragged) item.
+ *
+ * rectIntersection can match the DragPlaceholder at the original position,
+ * preventing repositioning. Falls back through pointerWithin (actual pointer
+ * position) then closestCenter (gaps between droppable rects).
+ */
+export function getCollisionDetection(
+  activeId: UniqueIdentifier | null,
+): CollisionDetection {
+  if (!activeId) return rectIntersection;
+
+  return args => {
+    const collisions = rectIntersection(args);
+
+    // Best match isn't the active item — use as-is
+    if (collisions.length === 0 || collisions[0]?.id !== activeId) {
+      return collisions;
+    }
+
+    // rectIntersection picked the active item — try pointer position instead
+    const pointerCollisions = pointerWithin(args);
+    const nonActivePointer = pointerCollisions.find(c => c.id !== activeId);
+    if (nonActivePointer) {
+      return [nonActivePointer, ...collisions];
+    }
+
+    // Pointer is over the DragPlaceholder — keep it for horizontal depth 
changes
+    if (pointerCollisions.length > 0) {
+      return collisions;
+    }
+
+    // Gap between droppable rects — fall back to closestCenter
+    const centerCollisions = closestCenter(args);
+    const nonActiveCenter = centerCollisions.find(c => c.id !== activeId);
+    if (nonActiveCenter) {
+      return [nonActiveCenter, ...collisions];
+    }
+
+    return collisions;
+  };
+}
+
 export const pointerSensorOptions: PointerSensorOptions = {
   activationConstraint: {
     distance: 8,
   },
 };
 
-// Use BeforeDragging strategy to measure items once at drag start rather than 
continuously.
-// This is critical for virtualized lists where items get unmounted during 
scroll.
-// MeasuringStrategy.Always causes issues because dnd-kit loses track of items
-// that are unmounted by react-window during auto-scroll.
+// Measure once at drag start — MeasuringStrategy.Always breaks with 
virtualization
+// because react-window unmounts items during scroll.
 export const measuringConfig: MeasuringConfiguration = {
   droppable: {
     strategy: MeasuringStrategy.BeforeDragging,
   },
 };
 
-// Disable auto-scroll because it conflicts with virtualization.
-// When auto-scroll moves the viewport, react-window unmounts items that 
scroll out of view,
-// which causes dnd-kit to lose track of the dragged item and reset the drag 
operation.
+// Disabled — auto-scroll + react-window unmounting causes dnd-kit to lose the 
drag.
 export const autoScrollConfig = {
   enabled: false,
 };
diff --git 
a/superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx 
b/superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx
index 7bd04b7a577..95e91a0343a 100644
--- a/superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/styles.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 import { styled, css } from '@apache-superset/core/ui';
+import { calculateItemHeights } from './hooks/useItemHeights';
 
 export const FoldersContainer = styled.div`
   display: flex;
@@ -83,6 +84,55 @@ export const DragOverlayStack = styled.div<{ width?: number 
}>`
   will-change: transform;
 `;
 
+export const DragOverlayFolderBlock = styled.div<{ width?: number }>`
+  ${({ theme, width }) => `
+    width: ${width ? `${width}px` : '100%'};
+    will-change: transform;
+    background: ${theme.colorBgContainer};
+    border-radius: ${theme.borderRadius}px;
+    box-shadow: ${theme.boxShadowSecondary};
+    pointer-events: none;
+    overflow: hidden;
+    opacity: 0.95;
+  `}
+`;
+
+// Matches react-window slot heights so the overlay lines up with the list.
+export const FolderBlockSlot = styled.div<{
+  variant: 'folder' | 'item';
+  separatorType?: 'visible' | 'transparent';
+}>`
+  ${({ theme, variant, separatorType }) => {
+    const heights = calculateItemHeights(theme);
+    let minHeight =
+      variant === 'folder' ? heights.folderHeader : heights.regularItem;
+    if (separatorType === 'visible') {
+      minHeight += heights.separatorVisible;
+    } else if (separatorType === 'transparent') {
+      minHeight += heights.separatorTransparent;
+    }
+    return `
+      min-height: ${minHeight}px;
+      display: flex;
+      align-items: stretch;
+
+      > * {
+        flex: 1;
+        min-width: 0;
+      }
+    `;
+  }}
+`;
+
+export const MoreItemsIndicator = styled.div`
+  ${({ theme }) => `
+    padding: ${theme.paddingXS}px ${theme.paddingMD}px;
+    color: ${theme.colorTextSecondary};
+    font-size: ${theme.fontSizeSM}px;
+    text-align: center;
+  `}
+`;
+
 export const DragOverlayItem = styled.div<{
   stackIndex: number;
   totalItems: number;


Reply via email to