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

rusackas 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 c9965c7bf41 feat(folders): drag and drop visible elements 
(columns/metrics) of folders in explore view (#42483)
c9965c7bf41 is described below

commit c9965c7bf41986d59b14e400eeab0774c9515618
Author: Grégoire Gailly <[email protected]>
AuthorDate: Wed Sep 9 06:36:55 2026 +0200

    feat(folders): drag and drop visible elements (columns/metrics) of folders 
in explore view (#42483)
---
 .../DatasourcePanelDragOption.test.tsx             |  17 ++
 .../DatasourcePanelDragOption/index.tsx            |  33 +---
 .../DatasourcePanel/DatasourcePanelItem.test.tsx   |  78 ++++++++-
 .../DatasourcePanel/DatasourcePanelItem.tsx        | 128 ++++++++++++--
 .../DatasourcePanel/compatibility.test.ts          |  57 ++++++
 .../components/DatasourcePanel/compatibility.ts    |  66 +++++++
 .../components/DatasourcePanel/folderDrag.test.ts  |  88 ++++++++++
 .../components/DatasourcePanel/folderDrag.ts       |  68 ++++++++
 .../src/explore/components/DndItemType.ts          |   3 +
 .../ExploreContainer/ExploreDndContext.test.tsx    | 138 +++++++++++++++
 .../ExploreContainer/ExploreDndContext.tsx         | 129 ++++++++++++--
 .../DndColumnMetricSelect.test.tsx                 | 104 +++++++++++
 .../DndColumnMetricSelect.tsx                      |  34 +++-
 .../DndColumnSelect.test.tsx                       | 131 ++++++++++++++
 .../DndColumnSelectControl/DndColumnSelect.tsx     |  30 +++-
 .../DndMetricSelect.test.tsx                       | 192 +++++++++++++++++++++
 .../DndColumnSelectControl/DndMetricSelect.tsx     | 108 ++++++++++--
 .../DndColumnSelectControl/DndSelectLabel.test.tsx | 114 ++++++++++++
 .../DndColumnSelectControl/DndSelectLabel.tsx      |  53 +++++-
 .../createAdhocMetricFromColumn.test.ts            |  49 ++++++
 .../DndColumnSelectControl/dndTestUtils.ts         |  18 ++
 21 files changed, 1548 insertions(+), 90 deletions(-)

diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx
 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx
index fbdea30af63..2a09143d48f 100644
--- 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx
+++ 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/DatasourcePanelDragOption.test.tsx
@@ -34,3 +34,20 @@ test('should render', async () => {
   ).toBeInTheDocument();
   expect(screen.getByText('test')).toBeInTheDocument();
 });
+
+test('is faded and not draggable when excluded by compatibleMetrics', async () 
=> {
+  render(
+    <DatasourcePanelDragOption
+      value={{ metric_name: 'test', uuid: '1' }}
+      type={DndItemType.Metric}
+    />,
+    {
+      useDndKit: true,
+      useRedux: true,
+      initialState: { explore: { compatibleMetrics: ['other_metric'] } },
+    },
+  );
+
+  const option = await screen.findByTestId('DatasourcePanelDragOption');
+  expect(option).toHaveStyle({ cursor: 'not-allowed' });
+});
diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx
 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx
index cf8458e453c..2d41913f04d 100644
--- 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx
+++ 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelDragOption/index.tsx
@@ -18,7 +18,6 @@
  */
 import { RefObject, useMemo } from 'react';
 import { useDraggable } from '@dnd-kit/core';
-import { useSelector } from 'react-redux';
 import { Metric } from '@superset-ui/core';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
 import { ColumnMeta } from '@superset-ui/chart-controls';
@@ -28,8 +27,8 @@ import {
   StyledMetricOption,
 } from 'src/explore/components/optionRenderers';
 import { Icons } from '@superset-ui/core/components/Icons';
-import { ExplorePageState } from 'src/explore/types';
 
+import { isCompatibleItem, useDatasourceCompatibility } from 
'../compatibility';
 import { DatasourcePanelDndItem } from '../types';
 
 const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
@@ -75,30 +74,14 @@ export default function DatasourcePanelDragOption(
   const { labelRef, showTooltip, type, value } = props;
   const theme = useTheme();
 
-  // Read compatibility lists from Redux.
-  // `null` means no filtering is active (SQL datasets, or no selection yet).
-  const compatibleMetrics = useSelector<
-    ExplorePageState,
-    string[] | null | undefined
-  >(state => state.explore.compatibleMetrics);
-  const compatibleDimensions = useSelector<
-    ExplorePageState,
-    string[] | null | undefined
-  >(state => state.explore.compatibleDimensions);
+  const { compatibleMetrics, compatibleDimensions } =
+    useDatasourceCompatibility();
 
-  // An item is compatible when the list is null (no filter) or when its
-  // name explicitly appears in the list returned by the backend.
-  const isCompatible = useMemo(() => {
-    if (type === DndItemType.Metric) {
-      if (!compatibleMetrics) return true;
-      return compatibleMetrics.includes((value as Metric).metric_name);
-    }
-    if (type === DndItemType.Column) {
-      if (!compatibleDimensions) return true;
-      return compatibleDimensions.includes((value as ColumnMeta).column_name);
-    }
-    return true;
-  }, [type, value, compatibleMetrics, compatibleDimensions]);
+  const isCompatible = useMemo(
+    () =>
+      isCompatibleItem(type, value, compatibleMetrics, compatibleDimensions),
+    [type, value, compatibleMetrics, compatibleDimensions],
+  );
 
   // Create a unique ID for this draggable item
   const draggableId = useMemo(() => {
diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx
 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx
index f3859a9f768..1820eec2d33 100644
--- 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx
+++ 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.test.tsx
@@ -16,6 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { useDraggable } from '@dnd-kit/core';
 import {
   columns,
   metrics,
@@ -25,8 +26,17 @@ import DatasourcePanelItem, {
   DatasourcePanelItemRowProps,
 } from './DatasourcePanelItem';
 import { FoldersEditorItemType } from 'src/components/Datasource/types';
+import { DndItemType } from '../DndItemType';
 import { MetricItem, ColumnItem } from './types';
 
+jest.mock('@dnd-kit/core', () => ({
+  ...jest.requireActual('@dnd-kit/core'),
+  useDraggable: jest.fn(),
+}));
+
+const mockUseDraggable = useDraggable as jest.Mock;
+const actualUseDraggable = jest.requireActual('@dnd-kit/core').useDraggable;
+
 const mockData: DatasourcePanelItemRowProps = {
   flattenedItems: [
     { type: 'header', depth: 0, folderId: '1', height: 50 },
@@ -82,7 +92,15 @@ const mockData: DatasourcePanelItemRowProps = {
   collapsedFolderIds: new Set(),
 };
 
-const setup = (data: DatasourcePanelItemRowProps = mockData) =>
+beforeEach(() => {
+  mockUseDraggable.mockReset();
+  mockUseDraggable.mockImplementation(actualUseDraggable);
+});
+
+const setup = (
+  data: DatasourcePanelItemRowProps = mockData,
+  initialState: Record<string, unknown> = { explore: {} },
+) =>
   render(
     <>
       {data.flattenedItems.map((_, index) => (
@@ -100,7 +118,7 @@ const setup = (data: DatasourcePanelItemRowProps = 
mockData) =>
         />
       ))}
     </>,
-    { useDnd: true, useRedux: true, initialState: { explore: {} } },
+    { useDnd: true, useRedux: true, initialState },
   );
 
 test('renders each item accordingly', () => {
@@ -123,3 +141,59 @@ test('can collapse metrics and columns', () => {
   userEvent.click(screen.getAllByRole('button')[0]);
   expect(mockData.onToggleCollapse).toHaveBeenCalled();
 });
+
+test('folder drag handle is a separate element from the collapse toggle', () 
=> {
+  setup();
+
+  const toggleButtons = screen
+    .getAllByRole('button', { name: /Metrics/ })
+    .filter(el => el.tagName === 'BUTTON');
+  expect(toggleButtons).toHaveLength(1);
+  const [toggleButton] = toggleButtons;
+  const dragHandle = screen.getByRole('button', {
+    name: 'Drag Metrics folder',
+  });
+
+  expect(toggleButton).not.toBe(dragHandle);
+  expect(toggleButton.tagName).toBe('BUTTON');
+
+  userEvent.click(toggleButton);
+  expect(mockData.onToggleCollapse).toHaveBeenCalledWith('1');
+});
+
+test('folder drag payload excludes columns filtered out by 
compatibleDimensions', () => {
+  setup(mockData, {
+    explore: { compatibleDimensions: [columns[0].column_name] },
+  });
+
+  const folderHeaderCalls = mockUseDraggable.mock.calls.filter(
+    ([opts]) => opts.data.type === DndItemType.Folder,
+  );
+  const columnsFolderCall = folderHeaderCalls.find(
+    ([opts]) => opts.data.name === 'Columns',
+  );
+
+  expect(columnsFolderCall![0].data.items).toEqual([
+    expect.objectContaining({
+      type: DndItemType.Column,
+      value: expect.objectContaining({ column_name: columns[0].column_name }),
+    }),
+  ]);
+  expect(columnsFolderCall![0].disabled).toBe(false);
+});
+
+test('folder header is not draggable when every item is filtered out', () => {
+  setup(mockData, {
+    explore: { compatibleDimensions: ['non-existent-column'] },
+  });
+
+  const folderHeaderCalls = mockUseDraggable.mock.calls.filter(
+    ([opts]) => opts.data.type === DndItemType.Folder,
+  );
+  const columnsFolderCall = folderHeaderCalls.find(
+    ([opts]) => opts.data.name === 'Columns',
+  );
+
+  expect(columnsFolderCall![0].data.items).toEqual([]);
+  expect(columnsFolderCall![0].disabled).toBe(true);
+});
diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx
 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx
index fed70cdab93..8356c783259 100644
--- 
a/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx
+++ 
b/superset-frontend/src/explore/components/DatasourcePanel/DatasourcePanelItem.tsx
@@ -16,8 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { ReactNode, useCallback } from 'react';
 import type { RowComponentProps } from 'react-window';
+import { ReactNode, useCallback, useMemo } from 'react';
+import { useDraggable } from '@dnd-kit/core';
 
 import { t } from '@apache-superset/core/translation';
 import { useCSSTextTruncation } from '@superset-ui/core';
@@ -28,6 +29,9 @@ import { Tooltip } from 
'@superset-ui/core/components/Tooltip';
 import { Typography } from '@superset-ui/core/components';
 import DatasourcePanelDragOption from './DatasourcePanelDragOption';
 import { DndItemType } from '../DndItemType';
+import { useActiveDrag } from '../ExploreContainer/ExploreDndContext';
+import { collectFolderDragItems, collectFolderIds } from './folderDrag';
+import { isCompatibleItem, useDatasourceCompatibility } from './compatibility';
 import { DndItemValue, FlattenedItem, Folder } from './types';
 
 const LabelWrapper = styled.div`
@@ -77,14 +81,40 @@ const LabelWrapper = styled.div`
   `}
 `;
 
+const SectionHeaderRow = styled.div`
+  display: flex;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+`;
+
 const SectionHeaderButton = styled.button`
   border: none;
   background: transparent;
-  width: 100%;
+  flex: 1;
+  min-width: 0;
   height: 100%;
   padding-inline: 0;
 `;
 
+const FolderDragHandle = styled.div<{ isDraggable: boolean }>`
+  ${({ theme, isDraggable }) => css`
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    width: ${theme.sizeUnit * 6}px;
+    height: 100%;
+    cursor: ${isDraggable ? 'grab' : 'not-allowed'};
+    opacity: ${isDraggable ? 1 : 0.35};
+    color: ${theme.colorFill};
+
+    &:hover {
+      color: ${isDraggable ? theme.colorIcon : theme.colorFill};
+    }
+  `}
+`;
+
 const SectionHeaderTextContainer = styled.div`
   display: flex;
   justify-content: space-between;
@@ -172,9 +202,60 @@ const DatasourcePanelItem = ({
     [labelIsTruncated],
   );
 
-  if (!item) return null;
+  // Folder headers double as a drag source: dragging the label picks up every
+  // column/metric in the folder (and its subfolders). Hooks must run on every
+  // row regardless of type, so compute the folder up front and disable the
+  // draggable for non-header rows / empty folders.
+  const isFolderHeader = item?.type === 'header';
+  const folder = item ? folderMap.get(item.folderId) : undefined;
+  const { compatibleMetrics, compatibleDimensions } =
+    useDatasourceCompatibility();
+  const folderDragItems = useMemo(
+    () =>
+      isFolderHeader && folder
+        ? collectFolderDragItems(folder).filter(({ type, value }) =>
+            isCompatibleItem(
+              type,
+              value,
+              compatibleMetrics,
+              compatibleDimensions,
+            ),
+          )
+        : [],
+    [isFolderHeader, folder, compatibleMetrics, compatibleDimensions],
+  );
+  const folderDragIds = useMemo(
+    () => (isFolderHeader && folder ? collectFolderIds(folder) : []),
+    [isFolderHeader, folder],
+  );
+  const {
+    attributes: folderDragAttributes,
+    listeners: folderDragListeners,
+    setNodeRef: setFolderDragRef,
+  } = useDraggable({
+    // Keyed by the flattened row index so every row (header, item, divider…)
+    // gets a unique draggable id — a folder's header and its child rows would
+    // otherwise collide on the shared folder id.
+    id: `datasource-folder-row-${index}`,
+    data: {
+      type: DndItemType.Folder,
+      name: folder?.name,
+      items: folderDragItems,
+      folderIds: folderDragIds,
+    },
+    disabled: !isFolderHeader || folderDragItems.length === 0,
+  });
+
+  // Fade every row of the folder currently being dragged (header + its items,
+  // subtitle, divider, and any subfolder rows). Each flattened row carries its
+  // folder id, so a row is in flight when its id is in the drag's folderIds.
+  const activeDrag = useActiveDrag();
+  const isRowInDraggedFolder =
+    activeDrag?.type === DndItemType.Folder &&
+    !!item &&
+    !!activeDrag.folderIds?.includes(item.folderId);
 
-  const folder = folderMap.get(item.folderId);
+  if (!item) return null;
   if (!folder) return null;
 
   const indentation = item.depth * theme.sizeUnit * 4;
@@ -185,21 +266,36 @@ const DatasourcePanelItem = ({
         ...style,
         paddingLeft: theme.sizeUnit * 4 + indentation,
         paddingRight: theme.sizeUnit * 4,
+        opacity: isRowInDraggedFolder ? 0.5 : undefined,
       }}
     >
       {item.type === 'header' && (
-        <SectionHeaderButton onClick={() => onToggleCollapse(folder.id)}>
-          <Tooltip title={getTooltipNode(folder)}>
-            <SectionHeaderTextContainer>
-              <SectionHeader ref={labelRef}>{folder.name}</SectionHeader>
-              {collapsedFolderIds.has(folder.id) ? (
-                <Icons.DownOutlined iconSize="s" iconColor={theme.colorText} />
-              ) : (
-                <Icons.UpOutlined iconSize="s" iconColor={theme.colorText} />
-              )}
-            </SectionHeaderTextContainer>
-          </Tooltip>
-        </SectionHeaderButton>
+        <SectionHeaderRow>
+          <SectionHeaderButton onClick={() => onToggleCollapse(folder.id)}>
+            <Tooltip title={getTooltipNode(folder)}>
+              <SectionHeaderTextContainer>
+                <SectionHeader ref={labelRef}>{folder.name}</SectionHeader>
+                {collapsedFolderIds.has(folder.id) ? (
+                  <Icons.DownOutlined
+                    iconSize="s"
+                    iconColor={theme.colorText}
+                  />
+                ) : (
+                  <Icons.UpOutlined iconSize="s" iconColor={theme.colorText} />
+                )}
+              </SectionHeaderTextContainer>
+            </Tooltip>
+          </SectionHeaderButton>
+          <FolderDragHandle
+            ref={setFolderDragRef}
+            isDraggable={folderDragItems.length > 0}
+            {...folderDragAttributes}
+            {...folderDragListeners}
+            aria-label={t('Drag %s folder', folder.name)}
+          >
+            <Icons.Drag iconSize="xl" />
+          </FolderDragHandle>
+        </SectionHeaderRow>
       )}
 
       {item.type === 'subtitle' && (
diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/compatibility.test.ts
 
b/superset-frontend/src/explore/components/DatasourcePanel/compatibility.test.ts
new file mode 100644
index 00000000000..5d641e1f7ce
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/DatasourcePanel/compatibility.test.ts
@@ -0,0 +1,57 @@
+/**
+ * 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 { DndItemType } from '../DndItemType';
+import { isCompatibleItem } from './compatibility';
+import { DndItemValue } from './types';
+
+const metric = { metric_name: 'sum__value' } as unknown as DndItemValue;
+const column = { column_name: 'dt' } as unknown as DndItemValue;
+
+test('metric is compatible when no compatibility filter is active', () => {
+  expect(isCompatibleItem(DndItemType.Metric, metric, null, null)).toBe(true);
+  expect(
+    isCompatibleItem(DndItemType.Metric, metric, undefined, undefined),
+  ).toBe(true);
+});
+
+test('metric is compatible only when listed in compatibleMetrics', () => {
+  expect(
+    isCompatibleItem(DndItemType.Metric, metric, ['sum__value'], null),
+  ).toBe(true);
+  expect(isCompatibleItem(DndItemType.Metric, metric, ['other'], null)).toBe(
+    false,
+  );
+});
+
+test('column is compatible when no compatibility filter is active', () => {
+  expect(isCompatibleItem(DndItemType.Column, column, null, null)).toBe(true);
+});
+
+test('column is compatible only when listed in compatibleDimensions', () => {
+  expect(isCompatibleItem(DndItemType.Column, column, null, 
['dt'])).toBe(true);
+  expect(isCompatibleItem(DndItemType.Column, column, null, ['other'])).toBe(
+    false,
+  );
+});
+
+test('unknown item types are always compatible', () => {
+  expect(
+    isCompatibleItem('unknown' as DndItemType, metric, ['other'], ['other']),
+  ).toBe(true);
+});
diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/compatibility.ts 
b/superset-frontend/src/explore/components/DatasourcePanel/compatibility.ts
new file mode 100644
index 00000000000..7b9b32c616e
--- /dev/null
+++ b/superset-frontend/src/explore/components/DatasourcePanel/compatibility.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 { useSelector } from 'react-redux';
+import { Metric } from '@superset-ui/core';
+import { ColumnMeta } from '@superset-ui/chart-controls';
+import { ExplorePageState } from 'src/explore/types';
+import { DndItemType } from '../DndItemType';
+import { DndItemValue } from './types';
+
+export type CompatibleNames = string[] | null | undefined;
+
+/**
+ * An item is compatible when the corresponding list is null/undefined (no
+ * filter active, e.g. SQL datasets or no selection yet) or when its name
+ * explicitly appears in the list returned by the backend.
+ */
+export function isCompatibleItem(
+  type: DndItemType,
+  value: DndItemValue,
+  compatibleMetrics: CompatibleNames,
+  compatibleDimensions: CompatibleNames,
+): boolean {
+  if (type === DndItemType.Metric) {
+    if (!compatibleMetrics) return true;
+    return compatibleMetrics.includes((value as Metric).metric_name);
+  }
+  if (type === DndItemType.Column) {
+    if (!compatibleDimensions) return true;
+    return compatibleDimensions.includes((value as ColumnMeta).column_name);
+  }
+  return true;
+}
+
+/**
+ * Reads the semantic-layer compatibility lists from Redux. Shared by every
+ * drag source in the datasource panel so folder-level and item-level drags
+ * apply the same filtering.
+ */
+export function useDatasourceCompatibility(): {
+  compatibleMetrics: CompatibleNames;
+  compatibleDimensions: CompatibleNames;
+} {
+  const compatibleMetrics = useSelector<ExplorePageState, CompatibleNames>(
+    state => state.explore.compatibleMetrics,
+  );
+  const compatibleDimensions = useSelector<ExplorePageState, CompatibleNames>(
+    state => state.explore.compatibleDimensions,
+  );
+  return { compatibleMetrics, compatibleDimensions };
+}
diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/folderDrag.test.ts 
b/superset-frontend/src/explore/components/DatasourcePanel/folderDrag.test.ts
new file mode 100644
index 00000000000..3b4dd3d9b42
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/DatasourcePanel/folderDrag.test.ts
@@ -0,0 +1,88 @@
+/**
+ * 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 { FoldersEditorItemType } from 'src/components/Datasource/types';
+import { DndItemType } from '../DndItemType';
+import { collectFolderDragItems, collectFolderIds } from './folderDrag';
+import { ColumnItem, Folder, MetricItem } from './types';
+
+const col = (name: string): ColumnItem =>
+  ({
+    type: FoldersEditorItemType.Column,
+    uuid: name,
+    name,
+    column_name: name,
+  }) as ColumnItem;
+
+const met = (name: string): MetricItem =>
+  ({
+    type: FoldersEditorItemType.Metric,
+    uuid: name,
+    name,
+    metric_name: name,
+  }) as unknown as MetricItem;
+
+const folder = (items: Folder['items'], subFolders?: Folder[]): Folder => ({
+  id: 'f',
+  name: 'F',
+  isCollapsed: false,
+  items,
+  subFolders,
+  totalItems: items.length,
+  showingItems: items.length,
+});
+
+test('maps columns and metrics to their DnD item types', () => {
+  const a = col('a');
+  const m = met('m');
+  expect(collectFolderDragItems(folder([a, m]))).toEqual([
+    { type: DndItemType.Column, value: a },
+    { type: DndItemType.Metric, value: m },
+  ]);
+});
+
+test('descends into subfolders, preserving order (parent items first)', () => {
+  const sub = folder([col('b'), met('n')]);
+  const result = collectFolderDragItems(folder([col('a')], [sub]));
+  expect(result.map(item => item.type)).toEqual([
+    DndItemType.Column,
+    DndItemType.Column,
+    DndItemType.Metric,
+  ]);
+  expect(
+    result.map(item =>
+      item.type === DndItemType.Column
+        ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
+          (item.value as any).column_name
+        : // eslint-disable-next-line @typescript-eslint/no-explicit-any
+          (item.value as any).metric_name,
+    ),
+  ).toEqual(['a', 'b', 'n']);
+});
+
+test('returns an empty list for an empty folder', () => {
+  expect(collectFolderDragItems(folder([]))).toEqual([]);
+});
+
+test('collectFolderIds gathers the folder id and every subfolder id', () => {
+  const sub = { ...folder([col('b')]), id: 'sub' };
+  const nested = { ...folder([col('c')]), id: 'nested' };
+  sub.subFolders = [nested];
+  const parent = { ...folder([col('a')]), id: 'parent', subFolders: [sub] };
+  expect(collectFolderIds(parent)).toEqual(['parent', 'sub', 'nested']);
+});
diff --git 
a/superset-frontend/src/explore/components/DatasourcePanel/folderDrag.ts 
b/superset-frontend/src/explore/components/DatasourcePanel/folderDrag.ts
new file mode 100644
index 00000000000..6c8bf0dd584
--- /dev/null
+++ b/superset-frontend/src/explore/components/DatasourcePanel/folderDrag.ts
@@ -0,0 +1,68 @@
+/**
+ * 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 { ColumnMeta, Metric } from '@superset-ui/chart-controls';
+import { FoldersEditorItemType } from 'src/components/Datasource/types';
+import { DndItemType } from '../DndItemType';
+import { DatasourcePanelDndItem, Folder, FolderItem } from './types';
+
+/**
+ * Map a single folder entry to the drag-item shape used by the control drop
+ * targets. Metrics become `DndItemType.Metric`, everything else a column.
+ */
+function toDndItem(item: FolderItem): DatasourcePanelDndItem {
+  return {
+    type:
+      item.type === FoldersEditorItemType.Metric
+        ? DndItemType.Metric
+        : DndItemType.Column,
+    // The runtime object carries the full ColumnMeta/Metric (see
+    // transformDatasourceWithFolders spreading `...column`/`...metric`), which
+    // is what the drop handlers expect.
+    value: item as ColumnMeta | Metric,
+  };
+}
+
+/**
+ * Flatten a folder into the list of draggable columns/metrics it contains,
+ * descending into subfolders so dragging a parent folder brings everything
+ * nested under it.
+ */
+export function collectFolderDragItems(
+  folder: Folder,
+): DatasourcePanelDndItem[] {
+  const items = folder.items.map(toDndItem);
+  folder.subFolders?.forEach(subFolder => {
+    items.push(...collectFolderDragItems(subFolder));
+  });
+  return items;
+}
+
+/**
+ * Collect the folder's own id plus every (recursive) subfolder id. Used to 
fade
+ * all rows belonging to a folder while it is being dragged: every flattened 
row
+ * carries its folder's id, so a row is part of the drag when its folderId is 
in
+ * this set.
+ */
+export function collectFolderIds(folder: Folder): string[] {
+  const ids = [folder.id];
+  folder.subFolders?.forEach(subFolder => {
+    ids.push(...collectFolderIds(subFolder));
+  });
+  return ids;
+}
diff --git a/superset-frontend/src/explore/components/DndItemType.ts 
b/superset-frontend/src/explore/components/DndItemType.ts
index 9fe3aa556cd..185f281d077 100644
--- a/superset-frontend/src/explore/components/DndItemType.ts
+++ b/superset-frontend/src/explore/components/DndItemType.ts
@@ -37,4 +37,7 @@ export enum DndItemType {
 
   // an adhoc filter option
   FilterOption = 'filterOption',
+
+  // a folder of columns/metrics dragged from the DatasourcePanel
+  Folder = 'folder',
 }
diff --git 
a/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.test.tsx
 
b/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.test.tsx
index 300f7ff1914..7ffaafbfc0d 100644
--- 
a/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.test.tsx
+++ 
b/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.test.tsx
@@ -17,10 +17,12 @@
  * under the License.
  */
 import { closestCenter, pointerWithin, rectIntersection } from '@dnd-kit/core';
+import { render, screen } from 'spec/helpers/testing-library';
 import {
   ActiveDragData,
   DroppableData,
   exploreCollisionDetection,
+  renderDragOverlayContent,
   resolveDragEnd,
 } from './ExploreDndContext';
 
@@ -44,6 +46,7 @@ beforeEach(() => {
 
 const COLUMN = 'column';
 const METRIC = 'metric';
+const FOLDER = 'folder';
 
 const active = (data: ActiveDragData, id = 'drag-source') => ({
   id,
@@ -157,6 +160,70 @@ test('no-op when dropping onto itself', () => {
   expect(onDrop).not.toHaveBeenCalled();
 });
 
+// --- folder drops ----------------------------------------------------------
+// Dragging a whole folder expands into its columns/metrics; the droppable's
+// bulk `onDropFolder` receives only the items it accepts and that pass 
canDrop.
+
+type DndItem = NonNullable<ActiveDragData['items']>[number];
+const columnItem = (name: string) =>
+  ({ type: COLUMN, value: { column_name: name } }) as unknown as DndItem;
+const metricItem = (name: string) =>
+  ({ type: METRIC, value: { metric_name: name } }) as unknown as DndItem;
+
+test('folder drop passes all accepted items to onDropFolder', () => {
+  const onDropFolder = jest.fn();
+  const items = [columnItem('a'), metricItem('m')];
+  resolveDragEnd(
+    active({ type: FOLDER, items }, 'datasource-folder-row-0'),
+    over({
+      accept: [COLUMN, METRIC, FOLDER],
+      canDrop: () => true,
+      onDropFolder,
+    }),
+  );
+  expect(onDropFolder).toHaveBeenCalledWith(items);
+});
+
+test('folder drop drops items whose type the droppable does not accept', () => 
{
+  const onDropFolder = jest.fn();
+  const col = columnItem('a');
+  resolveDragEnd(
+    active({ type: FOLDER, items: [col, metricItem('m')] }),
+    over({ accept: [COLUMN, FOLDER], canDrop: () => true, onDropFolder }),
+  );
+  expect(onDropFolder).toHaveBeenCalledWith([col]);
+});
+
+test('folder drop drops items rejected by canDrop (already present)', () => {
+  const onDropFolder = jest.fn();
+  const b = columnItem('b');
+  const canDrop = (item: DndItem) =>
+    (item.value as { column_name?: string }).column_name !== 'a'; // 'a' 
selected
+  resolveDragEnd(
+    active({ type: FOLDER, items: [columnItem('a'), b] }),
+    over({ accept: [COLUMN, FOLDER], canDrop, onDropFolder }),
+  );
+  expect(onDropFolder).toHaveBeenCalledWith([b]);
+});
+
+test('folder drop is a no-op when no item is accepted', () => {
+  const onDropFolder = jest.fn();
+  resolveDragEnd(
+    active({ type: FOLDER, items: [columnItem('a')] }),
+    over({ accept: [COLUMN, FOLDER], canDrop: () => false, onDropFolder }),
+  );
+  expect(onDropFolder).not.toHaveBeenCalled();
+});
+
+test('folder drop is ignored by droppables without an onDropFolder handler', 
() => {
+  const onDrop = jest.fn();
+  resolveDragEnd(
+    active({ type: FOLDER, items: [columnItem('a')] }),
+    over({ accept: [COLUMN], canDrop: () => true, onDrop }),
+  );
+  expect(onDrop).not.toHaveBeenCalled();
+});
+
 // --- exploreCollisionDetection ---------------------------------------------
 // These lock in the @dnd-kit migration regression fix: a reorder drag must
 // collide only with pills in the SAME control (its SortableContext), never the
@@ -310,3 +377,74 @@ test('a drag with no active data routes to the external 
dropzone branch', () =>
   expect(mockPointerWithin).toHaveBeenCalledTimes(1);
   expect(result).toEqual([{ id: 'dropzone-cols' }]);
 });
+
+// --- renderDragOverlayContent -----------------------------------------------
+// The DragOverlay preview content is extracted to a pure function so it can
+// be rendered directly with a given activeData, since @dnd-kit's
+// PointerSensor needs real pointer events/layout that jsdom cannot provide.
+
+test('renders nothing when there is no active drag', () => {
+  const { container } = render(<>{renderDragOverlayContent(null)}</>);
+  expect(container).toBeEmptyDOMElement();
+});
+
+test('renders nothing for a reorder drag (no value, not a folder)', () => {
+  const { container } = render(
+    <>{renderDragOverlayContent({ type: COLUMN, dragIndex: 0 })}</>,
+  );
+  expect(container).toBeEmptyDOMElement();
+});
+
+test('renders the folder name and a singular field count for one item', () => {
+  render(
+    <>
+      {renderDragOverlayContent({
+        type: FOLDER,
+        name: 'My Folder',
+        items: [columnItem('a')],
+      })}
+    </>,
+  );
+  expect(screen.getByText('My Folder')).toBeInTheDocument();
+  expect(screen.getByText('1 field')).toBeInTheDocument();
+});
+
+test('renders a plural field count for a folder with multiple items, including 
metrics', () => {
+  render(
+    <>
+      {renderDragOverlayContent({
+        type: FOLDER,
+        name: 'Mixed Folder',
+        items: [columnItem('a'), metricItem('m')],
+      })}
+    </>,
+  );
+  expect(screen.getByText('Mixed Folder')).toBeInTheDocument();
+  // The count reflects all folder items regardless of whether they are
+  // columns or metrics - it is intentionally not labeled "columns".
+  expect(screen.getByText('2 fields')).toBeInTheDocument();
+});
+
+test('renders a column preview for a plain column drag', () => {
+  render(
+    <>
+      {renderDragOverlayContent({
+        type: COLUMN,
+        value: { column_name: 'a', verbose_name: 'Column A' },
+      })}
+    </>,
+  );
+  expect(screen.getByText('Column A')).toBeInTheDocument();
+});
+
+test('renders a metric preview for a plain metric drag', () => {
+  render(
+    <>
+      {renderDragOverlayContent({
+        type: METRIC,
+        value: { metric_name: 'm', verbose_name: 'Metric M' },
+      })}
+    </>,
+  );
+  expect(screen.getByText('Metric M')).toBeInTheDocument();
+});
diff --git 
a/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
 
b/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
index cebd240e42c..5746b256b82 100644
--- 
a/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
+++ 
b/superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
@@ -23,6 +23,7 @@ import {
   useCallback,
   FC,
   Dispatch,
+  ReactNode,
   useReducer,
 } from 'react';
 import {
@@ -42,6 +43,7 @@ import {
 } from '@dnd-kit/core';
 import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
 import { css, styled } from '@apache-superset/core/theme';
+import { tn } from '@apache-superset/core/translation';
 import {
   ColumnOptionProps,
   MetricOptionProps,
@@ -62,6 +64,12 @@ export interface ActiveDragData {
   type: string;
   value?: unknown;
   dragIndex?: number;
+  // For a folder drag (DndItemType.Folder): the columns/metrics it holds, the
+  // folder's display name (shown in the drag overlay), and the ids of the
+  // folder and its subfolders (used to fade every row of the folder in 
flight).
+  items?: DatasourcePanelDndItem[];
+  name?: string;
+  folderIds?: string[];
   // For sortable items - callback to handle reorder
   onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
   onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
@@ -92,6 +100,30 @@ const DragOverlayContainer = styled(Flex)`
   `}
 `;
 
+/**
+ * Folder-specific overlay container. The folder drag is now activated from a
+ * small dedicated handle (not the full-width header row)
+ */
+const FolderDragOverlayContainer = styled(DragOverlayContainer)`
+  width: max-content;
+  min-width: 220px;
+`;
+
+/**
+ * Pill shown in the folder drag overlay with the count of items in flight.
+ */
+const FolderDragBadge = styled.span`
+  ${({ theme }) => css`
+    flex-shrink: 0;
+    padding: 0 ${theme.sizeUnit}px;
+    border-radius: ${theme.borderRadius}px;
+    background-color: ${theme.colorPrimary};
+    color: ${theme.colorTextLightSolid};
+    font-size: ${theme.fontSizeSM}px;
+    font-weight: ${theme.fontWeightStrong};
+  `}
+`;
+
 /**
  * Context to track if something is being dragged (for visual feedback)
  */
@@ -138,6 +170,10 @@ export interface DroppableData {
   canDrop?: (item: DatasourcePanelDndItem) => boolean;
   onDrop?: (item: DatasourcePanelDndItem) => void;
   onDropValue?: (value: DatasourcePanelDndItem['value']) => void;
+  // Bulk handler for a folder drop. A control opts into folder drops by
+  // exposing this; it receives the folder's items already filtered to those
+  // this control accepts and that pass `canDrop` (e.g. not already selected).
+  onDropFolder?: (items: DatasourcePanelDndItem[]) => void;
 }
 
 /**
@@ -177,6 +213,29 @@ export function resolveDragEnd(
     return;
   }
 
+  // Folder drop: expand the folder into its individual columns/metrics and
+  // hand the accepted subset to the droppable's bulk handler. Only controls
+  // that opt in via `onDropFolder` react (filters, for instance, don't). Each
+  // item is gated by the droppable's own `accept`/`canDrop`, so duplicates and
+  // unsupported types are dropped — satisfying "only add columns not already
+  // present".
+  if (activeData?.type === DndItemType.Folder) {
+    const onDropFolder = overData?.onDropFolder;
+    const items = Array.isArray(activeData.items) ? activeData.items : [];
+    if (!onDropFolder || items.length === 0) {
+      return;
+    }
+    const { accept, canDrop } = overData;
+    const accepted = items.filter(folderItem => {
+      const typeAccepted = !accept || accept.includes(folderItem.type);
+      return typeAccepted && (canDrop?.(folderItem) ?? true);
+    });
+    if (accepted.length > 0) {
+      onDropFolder(accepted);
+    }
+    return;
+  }
+
   // External drop onto a droppable that exposes an onDrop handler.
   if (activeData && overData?.onDrop) {
     const { accept, canDrop, onDrop, onDropValue } = overData;
@@ -278,6 +337,57 @@ export const exploreCollisionDetection: CollisionDetection 
= args => {
     : rectIntersection({ ...args, droppableContainers: dropzoneContainers });
 };
 
+/**
+ * Content shown in the drag preview that follows the cursor. Extracted as a
+ * pure function (rather than inlined JSX) so it can be unit-tested directly:
+ * @dnd-kit's PointerSensor only reacts to real pointer events, which jsdom
+ * cannot meaningfully dispatch, so a real drag can't be simulated to reach it.
+ * Reorder drags leave activeData null on purpose, so only external
+ * DatasourcePanel drags (which carry a value, or a folder's items) get a
+ * preview.
+ */
+export function renderDragOverlayContent(
+  activeData: ActiveDragData | null,
+): ReactNode {
+  if (activeData?.type === DndItemType.Folder) {
+    return (
+      <FolderDragOverlayContainer align="center" justify="space-between">
+        <Flex align="center" gap={4}>
+          <Icons.FolderOutlined iconSize="l" />
+          <span>{activeData.name}</span>
+        </Flex>
+        <FolderDragBadge>
+          {tn(
+            '%s field',
+            '%s fields',
+            activeData.items?.length ?? 0,
+            activeData.items?.length ?? 0,
+          )}
+        </FolderDragBadge>
+      </FolderDragOverlayContainer>
+    );
+  }
+  if (activeData?.value) {
+    return (
+      <DragOverlayContainer align="center" justify="space-between">
+        {activeData.type === DndItemType.Column ? (
+          <StyledColumnOption
+            column={activeData.value as ColumnOptionProps['column']}
+            showType
+          />
+        ) : (
+          <StyledMetricOption
+            metric={activeData.value as MetricOptionProps['metric']}
+            showType
+          />
+        )}
+        <Icons.Drag iconSize="xl" />
+      </DragOverlayContainer>
+    );
+  }
+  return null;
+}
+
 interface ExploreDndContextProps {
   children: React.ReactNode;
 }
@@ -359,26 +469,9 @@ export const ExploreDndContextProvider: 
FC<ExploreDndContextProps> = ({
       {/*
         @dnd-kit has no native drag image (unlike react-dnd's HTML5 backend),
         so the item following the cursor must be rendered explicitly here.
-        Reorder drags leave activeData null on purpose, so only external
-        DatasourcePanel drags (which carry a value) get a preview.
       */}
       <DragOverlay dropAnimation={null}>
-        {activeData?.value ? (
-          <DragOverlayContainer align="center" justify="space-between">
-            {activeData.type === DndItemType.Column ? (
-              <StyledColumnOption
-                column={activeData.value as ColumnOptionProps['column']}
-                showType
-              />
-            ) : (
-              <StyledMetricOption
-                metric={activeData.value as MetricOptionProps['metric']}
-                showType
-              />
-            )}
-            <Icons.Drag iconSize="xl" />
-          </DragOverlayContainer>
-        ) : null}
+        {renderDragOverlayContent(activeData)}
       </DragOverlay>
     </DndContext>
   );
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.test.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.test.tsx
index 3940a2c7001..66059aaa3e6 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.test.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.test.tsx
@@ -27,6 +27,7 @@ import {
   captureDroppableData,
   captureSortableData,
   simulateDrop,
+  simulateFolderDrop,
   simulateReorder,
 } from './dndTestUtils';
 
@@ -344,3 +345,106 @@ test('handles mixed value types correctly', () => {
   expect(screen.getByText('column_a')).toBeVisible();
   expect(screen.getByText('metric_a')).toBeVisible();
 });
+
+// --- folder drops -----------------------------------------------------
+// Dragging a whole folder from the DatasourcePanel expands into its
+// columns/metrics, handled in bulk by onDropFolder. Driven through the
+// production `resolveDragEnd` dispatcher since jsdom cannot simulate real
+// @dnd-kit pointer drags.
+
+test('folder drop appends all accepted columns and metrics for a multi 
control', () => {
+  const onChange = jest.fn();
+  render(
+    <DndColumnMetricSelect
+      {...defaultProps}
+      value={['column_a']}
+      onChange={onChange}
+      multi
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: { column_name: 'column_b' } as any },
+    { type: DndItemType.Metric, value: { metric_name: 'metric_a' } as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledWith(['column_a', 'column_b', 'metric_a']);
+});
+
+test('folder drop replaces (not appends) the existing value for a single-value 
control', () => {
+  // Regression test: onDropFolder used to append the dropped items and then
+  // send the stale first (pre-drop) value to onChange for non-multi
+  // controls, silently ignoring the drop. It must send the first newly
+  // dropped item instead, matching the single-item onDrop's replace
+  // behavior.
+  const onChange = jest.fn();
+  render(
+    <DndColumnMetricSelect
+      {...defaultProps}
+      value={['column_a']}
+      onChange={onChange}
+      multi={false}
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Metric, value: { metric_name: 'metric_a' } as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledWith('metric_a');
+});
+
+test('folder drop deduplicates a column and same-named metric within the 
batch', () => {
+  // Regression test: canDrop gates each folder item against the pre-drop
+  // value, so a column and a same-named metric both pass individually. If
+  // onDropFolder doesn't dedupe as it builds the batch, the value ends up
+  // with two identical strings, both rendered as the column.
+  const onChange = jest.fn();
+  render(
+    <DndColumnMetricSelect
+      {...defaultProps}
+      selectedMetrics={[
+        ...defaultProps.selectedMetrics,
+        {
+          metric_name: 'column_a',
+          expression: 'expression_column_a',
+          verbose_name: 'column_a',
+        },
+      ]}
+      value={[]}
+      onChange={onChange}
+      multi
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: { column_name: 'column_a' } as any },
+    { type: DndItemType.Metric, value: { metric_name: 'column_a' } as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledWith(['column_a']);
+});
+
+test('folder drop is a no-op when no item is accepted', () => {
+  const onChange = jest.fn();
+  render(
+    <DndColumnMetricSelect
+      {...defaultProps}
+      value={['column_a', 'metric_a']}
+      onChange={onChange}
+      multi
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  // Both items already selected -> canDrop rejects them both.
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: { column_name: 'column_a' } as any },
+    { type: DndItemType.Metric, value: { metric_name: 'metric_a' } as any },
+  ]);
+
+  expect(onChange).not.toHaveBeenCalled();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
index 0c3f3999a7e..d75eb443253 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
@@ -85,7 +85,11 @@ function fieldHasMultipleValues(
   return false;
 }
 
-const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
+const DND_ACCEPTED_TYPES = [
+  DndItemType.Column,
+  DndItemType.Metric,
+  DndItemType.Folder,
+];
 
 type ColumnMetricValue =
   | string
@@ -257,6 +261,33 @@ function DndColumnMetricSelect(props: 
DndColumnMetricSelectProps) {
     [combinedOptionsMap, coercedValue, isMetricSelected],
   );
 
+  const onDropFolder = useCallback(
+    (items: DatasourcePanelDndItem[]) => {
+      // Items are gated against `canDrop` before the whole batch is added, so
+      // a column and a same-named metric can both pass individually. Track
+      // names added so far in this batch to avoid adding the same string 
twice.
+      const seen = new Set(coercedValue.filter(isString));
+      const additions: string[] = [];
+      items.forEach(item => {
+        let itemName: string | undefined;
+        if (item.type === DndItemType.Column) {
+          itemName = (item.value as ColumnMeta).column_name;
+        } else if (item.type === DndItemType.Metric) {
+          itemName = (item.value as Metric).metric_name;
+        }
+        if (itemName && !seen.has(itemName)) {
+          seen.add(itemName);
+          additions.push(itemName);
+        }
+      });
+      if (additions.length === 0) {
+        return;
+      }
+      onChange(multi ? [...coercedValue, ...additions] : additions[0]);
+    },
+    [onChange, coercedValue, multi],
+  );
+
   const onClickClose = useCallback(
     (index: number) => {
       const newValues = [...coercedValue];
@@ -437,6 +468,7 @@ function DndColumnMetricSelect(props: 
DndColumnMetricSelectProps) {
       <DndSelectLabel
         onDrop={onDrop}
         canDrop={canDrop}
+        onDropFolder={onDropFolder}
         valuesRenderer={valuesRenderer}
         accept={DND_ACCEPTED_TYPES}
         displayGhostButton={multi || coercedValue.length === 0}
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx
index a6a121106cd..1042d91fbf5 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.test.tsx
@@ -25,10 +25,18 @@ import {
 } from 'spec/helpers/testing-library';
 import configureMockStore from 'redux-mock-store';
 import thunk from 'redux-thunk';
+import { useDroppable } from '@dnd-kit/core';
+import { useSortable } from '@dnd-kit/sortable';
 import {
   DndColumnSelect,
   DndColumnSelectProps,
 } from 
'src/explore/components/controls/DndColumnSelectControl/DndColumnSelect';
+import { DndItemType } from 'src/explore/components/DndItemType';
+import {
+  CapturedDroppable,
+  captureDroppableData,
+  simulateFolderDrop,
+} from './dndTestUtils';
 
 // Mock SQLEditorWithValidation to enable Custom SQL testing in JSDOM
 jest.mock('src/components/SQLEditorWithValidation', () => ({
@@ -48,6 +56,37 @@ jest.mock('src/components/SQLEditorWithValidation', () => ({
   ),
 }));
 
+jest.mock('@dnd-kit/core', () => ({
+  ...jest.requireActual('@dnd-kit/core'),
+  useDroppable: jest.fn(),
+}));
+
+// useSortable (for the reorderable pills) internally calls @dnd-kit/core's
+// useDroppable too; left unmocked, its calls clobber the dropzone's own
+// captured data since captureDroppableData just records the latest call.
+jest.mock('@dnd-kit/sortable', () => ({
+  ...jest.requireActual('@dnd-kit/sortable'),
+  useSortable: jest.fn(),
+}));
+
+const captured: CapturedDroppable = { current: undefined };
+
+beforeEach(() => {
+  captured.current = undefined;
+  (useDroppable as jest.Mock).mockImplementation(
+    captureDroppableData(captured),
+  );
+  (useSortable as jest.Mock).mockReturnValue({
+    attributes: {},
+    listeners: {},
+    setNodeRef: () => {},
+    transform: null,
+    transition: undefined,
+    isDragging: false,
+    setActivatorNodeRef: () => {},
+  });
+});
+
 const middlewares = [thunk];
 const mockStore = configureMockStore(middlewares);
 
@@ -492,3 +531,95 @@ test('should create adhoc column via Custom SQL tab 
workflow', async () => {
 
   // Preserves Custom SQL workflow from original Cypress test
 });
+
+// --- folder drops ------------------------------------------------------
+// Dragging a whole folder from the DatasourcePanel expands into its columns;
+// only columns are added (metrics are filtered out, this control is
+// columns-only). Drop is driven through the production `resolveDragEnd`
+// dispatcher since jsdom cannot simulate real @dnd-kit pointer drags.
+
+test('folder drop adds all accepted columns for a multi-value control', () => {
+  const onChange = jest.fn();
+  render(
+    <DndColumnSelect
+      {...defaultProps}
+      onChange={onChange}
+      multi
+      options={[
+        { column_name: 'state' },
+        { column_name: 'city' },
+        { column_name: 'country' },
+      ]}
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: { column_name: 'state' } as any },
+    { type: DndItemType.Column, value: { column_name: 'city' } as any },
+    // Metrics in the folder are not columns and must be filtered out.
+    { type: DndItemType.Metric, value: { metric_name: 'count' } as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledWith(['state', 'city']);
+});
+
+test('folder drop replaces the existing value for a single-value control', () 
=> {
+  const onChange = jest.fn();
+  render(
+    <DndColumnSelect
+      {...defaultProps}
+      onChange={onChange}
+      multi={false}
+      value="state"
+      options={[{ column_name: 'state' }, { column_name: 'city' }]}
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: { column_name: 'city' } as any },
+  ]);
+
+  // Only the first accepted item replaces the existing value, matching the
+  // single-item onDrop's replace behavior.
+  expect(onChange).toHaveBeenCalledWith('city');
+});
+
+test('folder drop adds the first item for an empty single-value control', () 
=> {
+  const onChange = jest.fn();
+  render(
+    <DndColumnSelect
+      {...defaultProps}
+      onChange={onChange}
+      multi={false}
+      options={[{ column_name: 'state' }, { column_name: 'city' }]}
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: { column_name: 'state' } as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledWith('state');
+});
+
+test('folder drop is a no-op when the folder has no column items', () => {
+  const onChange = jest.fn();
+  render(
+    <DndColumnSelect
+      {...defaultProps}
+      onChange={onChange}
+      multi
+      options={[{ column_name: 'state' }]}
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Metric, value: { metric_name: 'count' } as any },
+  ]);
+
+  expect(onChange).not.toHaveBeenCalled();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
index f269691792a..822736c32e3 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
@@ -97,6 +97,33 @@ function DndColumnSelect(props: DndColumnSelectProps) {
     [optionSelector],
   );
 
+  const onDropFolder = useCallback(
+    (items: DatasourcePanelDndItem[]) => {
+      // Items already passed `canDrop` (in options, not already selected).
+      const columnNames = items
+        .filter(item => item.type === DndItemType.Column)
+        .map(item => (item.value as ColumnMeta).column_name);
+      if (columnNames.length === 0) {
+        return;
+      }
+      if (!optionSelector.multi) {
+        if (!isEmpty(optionSelector.values)) {
+          optionSelector.replace(0, columnNames[0]);
+        } else {
+          optionSelector.add(columnNames[0]);
+        }
+      } else {
+        columnNames.forEach(columnName => {
+          if (!optionSelector.has(columnName)) {
+            optionSelector.add(columnName);
+          }
+        });
+      }
+      onChange(optionSelector.getValues());
+    },
+    [onChange, optionSelector],
+  );
+
   const onClickClose = useCallback(
     (index: number) => {
       optionSelector.del(index);
@@ -211,8 +238,9 @@ function DndColumnSelect(props: DndColumnSelectProps) {
       <DndSelectLabel
         onDrop={onDrop}
         canDrop={canDrop}
+        onDropFolder={onDropFolder}
         valuesRenderer={valuesRenderer}
-        accept={DndItemType.Column}
+        accept={[DndItemType.Column, DndItemType.Folder]}
         displayGhostButton={multi || optionSelector.values.length === 0}
         ghostButtonText={labelGhostButtonText}
         onClickGhostButton={openPopover}
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
index 3b8e64db03e..aac90207db4 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
@@ -25,6 +25,7 @@ import {
   within,
 } from 'spec/helpers/testing-library';
 import { Metric } from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
 import { useDroppable } from '@dnd-kit/core';
 import { useSortable } from '@dnd-kit/sortable';
 import {
@@ -40,6 +41,7 @@ import {
   captureDroppableData,
   captureSortableData,
   simulateDrop,
+  simulateFolderDrop,
   simulateReorder,
 } from './dndTestUtils';
 
@@ -585,3 +587,193 @@ test('title changes on custom SQL text change', async () 
=> {
     'New metric',
   );
 });
+
+// --- folder drops -----------------------------------------------------
+// Dragging a whole folder from the DatasourcePanel expands into its
+// columns/metrics, handled in bulk by onDropFolder: saved metrics are added
+// as-is, columns become adhoc metrics with a default aggregation (no
+// popover, since a folder can drop many at once). Driven through the
+// production `resolveDragEnd` dispatcher since jsdom cannot simulate real
+// @dnd-kit pointer drags.
+
+const numericColumn = {
+  column_name: 'numeric_col',
+  type_generic: GenericDataType.Numeric,
+};
+const stringColumn = {
+  column_name: 'string_col',
+  type_generic: GenericDataType.String,
+};
+const unknowTypeColumn = {
+  column_name: 'unknown_type_col',
+  type_generic: 'not_a_real_type',
+};
+const multiValueColumn = {
+  column_name: 'multi_value_col',
+  type_generic: GenericDataType.MultiValue,
+};
+
+test('folder drop appends a saved metric as-is and columns as adhoc metrics 
with default aggregation', () => {
+  const onChange = jest.fn();
+  render(
+    <DndMetricSelect
+      {...defaultProps}
+      columns={[numericColumn, stringColumn, unknowTypeColumn]}
+      value={['metric_b']}
+      onChange={onChange}
+      multi
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Metric, value: { metric_name: 'metric_a' } as any },
+    { type: DndItemType.Column, value: numericColumn as any },
+    { type: DndItemType.Column, value: stringColumn as any },
+    { type: DndItemType.Column, value: unknowTypeColumn as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledTimes(1);
+  const committed = onChange.mock.calls[0][0];
+  expect(committed[0]).toBe('metric_b');
+  expect(committed[1]).toBe('metric_a');
+  // Numeric columns default to SUM.
+  expect(committed[2]).toBeInstanceOf(AdhocMetric);
+  expect(committed[2].column.column_name).toBe('numeric_col');
+  expect(committed[2].aggregate).toBe(AGGREGATES.SUM);
+  // Text columns default to COUNT_DISTINCT.
+  expect(committed[3]).toBeInstanceOf(AdhocMetric);
+  expect(committed[3].column.column_name).toBe('string_col');
+  expect(committed[3].aggregate).toBe(AGGREGATES.COUNT_DISTINCT);
+  // Untyped columns have no explicit supported aggregation and are skipped
+  // entirely rather than defaulting to an unsupported COUNT_DISTINCT.
+  expect(committed).toHaveLength(4);
+});
+
+test('folder drop skips MultiValue columns, which have no supported default 
aggregation', () => {
+  const onChange = jest.fn();
+  render(
+    <DndMetricSelect
+      {...defaultProps}
+      columns={[numericColumn, multiValueColumn]}
+      value={[]}
+      onChange={onChange}
+      multi
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: multiValueColumn as any },
+    { type: DndItemType.Column, value: numericColumn as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledTimes(1);
+  const committed = onChange.mock.calls[0][0];
+  expect(committed).toHaveLength(1);
+  expect(committed[0]).toBeInstanceOf(AdhocMetric);
+  expect(committed[0].column.column_name).toBe('numeric_col');
+});
+
+test('folder drop is a no-op when only MultiValue/untyped columns are 
dropped', () => {
+  const onChange = jest.fn();
+  render(
+    <DndMetricSelect
+      {...defaultProps}
+      columns={[multiValueColumn, unknowTypeColumn]}
+      value={['metric_a']}
+      onChange={onChange}
+      multi
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: multiValueColumn as any },
+    { type: DndItemType.Column, value: unknowTypeColumn as any },
+  ]);
+
+  expect(onChange).not.toHaveBeenCalled();
+});
+
+test('folder drop replaces (not appends) the existing value for a single-value 
control', () => {
+  const onChange = jest.fn();
+  render(
+    <DndMetricSelect
+      {...defaultProps}
+      columns={[numericColumn]}
+      value={['metric_a']}
+      onChange={onChange}
+      multi={false}
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: numericColumn as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledTimes(1);
+  const committed = onChange.mock.calls[0][0];
+  expect(committed).toBeInstanceOf(AdhocMetric);
+  expect(committed.column.column_name).toBe('numeric_col');
+});
+
+test('folder drop skips columns already present as adhoc metrics, keeping new 
ones', () => {
+  const onChange = jest.fn();
+  const existingAdhocMetric = {
+    expressionType: EXPRESSION_TYPES.SIMPLE,
+    column: numericColumn,
+    aggregate: AGGREGATES.SUM,
+    optionName: 'existing_numeric',
+  };
+  render(
+    <DndMetricSelect
+      {...defaultProps}
+      columns={[numericColumn, stringColumn]}
+      value={[existingAdhocMetric]}
+      onChange={onChange}
+      multi
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  // numeric_col is already an adhoc metric in value, so re-dropping it must
+  // not add a duplicate; string_col is new and should still be appended.
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: numericColumn as any },
+    { type: DndItemType.Column, value: stringColumn as any },
+  ]);
+
+  expect(onChange).toHaveBeenCalledTimes(1);
+  const committed = onChange.mock.calls[0][0];
+  expect(committed).toHaveLength(2);
+  expect(committed[0]).toBeInstanceOf(AdhocMetric);
+  expect(committed[0].column.column_name).toBe('numeric_col');
+  expect(committed[1]).toBeInstanceOf(AdhocMetric);
+  expect(committed[1].column.column_name).toBe('string_col');
+  expect(committed[1].aggregate).toBe(AGGREGATES.COUNT_DISTINCT);
+});
+
+test('folder drop is a no-op when no item is accepted', () => {
+  const onChange = jest.fn();
+  render(
+    <DndMetricSelect
+      {...defaultProps}
+      value={['metric_a']}
+      onChange={onChange}
+      multi
+      datasource={{ extra: '{ "disallow_adhoc_metrics": true }' } as any}
+    />,
+    { useDndKit: true, useRedux: true },
+  );
+
+  // Columns are rejected outright when adhoc metrics are disallowed, and
+  // metric_a is already selected.
+  simulateFolderDrop(captured, [
+    { type: DndItemType.Column, value: { column_name: 'column_a' } as any },
+    { type: DndItemType.Metric, value: { metric_name: 'metric_a' } as any },
+  ]);
+
+  expect(onChange).not.toHaveBeenCalled();
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
index 52a7d5d0d59..c4bcd2efd42 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
@@ -46,7 +46,53 @@ import { AGGREGATES } from 'src/explore/constants';
 import { datasetLabelLower } from 'src/features/semanticLayers/label';
 
 const EMPTY_OBJECT = {};
-const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
+const DND_ACCEPTED_TYPES = [
+  DndItemType.Column,
+  DndItemType.Metric,
+  DndItemType.Folder,
+];
+
+// Types that get a sensible default aggregation. MultiValue and unknown/
+// untyped columns are deliberately excluded.
+const COUNT_DISTINCT_ELIGIBLE_TYPES = [
+  GenericDataType.String,
+  GenericDataType.Boolean,
+  GenericDataType.Temporal,
+];
+
+export const isColumnSupportedForMetricAggregation = (
+  column: ColumnMeta,
+): boolean =>
+  column.type_generic === GenericDataType.Numeric ||
+  COUNT_DISTINCT_ELIGIBLE_TYPES.includes(
+    column.type_generic as GenericDataType,
+  );
+
+/**
+ * Build an adhoc metric from a dropped column, picking a sensible default
+ * aggregation from the column's data type: SUM for numeric columns,
+ * COUNT_DISTINCT for string/boolean/temporal ones. Columns outside these
+ * explicit supported types (e.g. MultiValue, untyped) are left without a
+ * default aggregate.
+ */
+export const createAdhocMetricFromColumn = (
+  column: ColumnMeta,
+): AdhocMetric => {
+  // Cast config to handle ColumnMeta/ColumnType mismatch
+  const config = {
+    column,
+  } as Partial<AdhocMetric>;
+  if (column.type_generic === GenericDataType.Numeric) {
+    config.aggregate = AGGREGATES.SUM;
+  } else if (
+    COUNT_DISTINCT_ELIGIBLE_TYPES.includes(
+      column.type_generic as GenericDataType,
+    )
+  ) {
+    config.aggregate = AGGREGATES.COUNT_DISTINCT;
+  }
+  return new AdhocMetric(config);
+};
 
 const isDictionaryForAdhocMetric = (value: QueryFormMetric) =>
   value &&
@@ -228,8 +274,19 @@ const DndMetricSelect = (props: any) => {
       }
 
       const isMetricAlreadyInValues =
-        item.type === 'metric' ? value.includes(item.value.metric_name) : 
false;
-      return !isMetricAlreadyInValues;
+        item.type === DndItemType.Metric
+          ? value.includes(item.value.metric_name)
+          : false;
+      const isColumnAlreadyInValues =
+        item.type === DndItemType.Column
+          ? value.some(
+              currentValue =>
+                isAdhocMetricSimple(currentValue) &&
+                currentValue.column?.column_name ===
+                  (item.value as ColumnMeta).column_name,
+            )
+          : false;
+      return !isMetricAlreadyInValues && !isColumnAlreadyInValues;
     },
     [value, extra, savedMetricSet],
   );
@@ -385,6 +442,34 @@ const DndMetricSelect = (props: any) => {
     [onNewMetric, togglePopover],
   );
 
+  const onDropFolder = useCallback(
+    (items: DatasourcePanelDndItem[]) => {
+      // Items already passed `canDrop`. Saved metrics are added as-is; columns
+      // become adhoc metrics with a default aggregation (no popover, since a
+      // folder can drop many at once). Columns without an explicit supported
+      // type (e.g. MultiValue, untyped) are skipped instead of silently
+      // committing an unsupported COUNT_DISTINCT.
+      const additions = items
+        .filter(
+          item =>
+            item.type === DndItemType.Metric ||
+            isColumnSupportedForMetricAggregation(item.value as ColumnMeta),
+        )
+        .map(item =>
+          item.type === DndItemType.Metric
+            ? (item.value as Metric)
+            : createAdhocMetricFromColumn(item.value as ColumnMeta),
+        );
+      if (additions.length === 0) {
+        return;
+      }
+      const newValue = multi ? [...value, ...additions] : [additions[0]];
+      setValue(newValue);
+      handleChange(newValue);
+    },
+    [handleChange, multi, value],
+  );
+
   const handleClickGhostButton = useCallback(() => {
     setDroppedItem({});
     togglePopover(true);
@@ -395,21 +480,7 @@ const DndMetricSelect = (props: any) => {
       isDatasourcePanelDndItem(droppedItem) &&
       droppedItem.type === DndItemType.Column
     ) {
-      const itemValue = droppedItem.value as ColumnMeta;
-      // Cast config to handle ColumnMeta/ColumnType mismatch
-      const config = {
-        column: itemValue,
-      } as Partial<AdhocMetric>;
-      if (itemValue.type_generic === GenericDataType.Numeric) {
-        config.aggregate = AGGREGATES.SUM;
-      } else if (
-        itemValue.type_generic === GenericDataType.String ||
-        itemValue.type_generic === GenericDataType.Boolean ||
-        itemValue.type_generic === GenericDataType.Temporal
-      ) {
-        config.aggregate = AGGREGATES.COUNT_DISTINCT;
-      }
-      return new AdhocMetric(config);
+      return createAdhocMetricFromColumn(droppedItem.value as ColumnMeta);
     }
     return new AdhocMetric({});
   }, [droppedItem]);
@@ -428,6 +499,7 @@ const DndMetricSelect = (props: any) => {
       <DndSelectLabel
         onDrop={handleDrop}
         canDrop={canDrop}
+        onDropFolder={onDropFolder}
         valuesRenderer={valuesRenderer}
         accept={DND_ACCEPTED_TYPES}
         ghostButtonText={ghostButtonText}
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx
index 515c3e9ea33..26eb47af936 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.test.tsx
@@ -19,8 +19,10 @@
 import { useContext } from 'react';
 import { render, screen, userEvent } from 'spec/helpers/testing-library';
 import { DndItemType } from 'src/explore/components/DndItemType';
+import { DatasourcePanelDndItem } from 
'src/explore/components/DatasourcePanel/types';
 import DndSelectLabel, {
   DndSelectLabelProps,
+  resolveCanDrop,
 } from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
 import ExploreContainer, { DropzoneContext } from '../../ExploreContainer';
 
@@ -100,3 +102,115 @@ test('updates dropValidator on changes', () => {
     'true',
   );
 });
+
+// --- resolveCanDrop (folder-aware canDrop logic) ---------------------------
+// Extracted from the component's canDrop useMemo so it can be unit-tested
+// directly: @dnd-kit's PointerSensor needs real pointer events/layout, which
+// jsdom cannot provide, so an actual drag can't be simulated to reach it.
+
+describe('resolveCanDrop', () => {
+  const dropValidator = jest.fn();
+
+  beforeEach(() => {
+    dropValidator.mockReset();
+  });
+
+  test('returns false when there is no active drag', () => {
+    expect(resolveCanDrop(undefined, [DndItemType.Column], 
dropValidator)).toBe(
+      false,
+    );
+    expect(dropValidator).not.toHaveBeenCalled();
+  });
+
+  test('returns false when the dragged type is not accepted', () => {
+    expect(
+      resolveCanDrop(
+        { type: DndItemType.Metric, value: { metric_name: 'm' } },
+        [DndItemType.Column],
+        dropValidator,
+      ),
+    ).toBe(false);
+    expect(dropValidator).not.toHaveBeenCalled();
+  });
+
+  test('delegates to dropValidator for a non-folder accepted type', () => {
+    dropValidator.mockReturnValue(true);
+    const value = { column_name: 'a' };
+    expect(
+      resolveCanDrop(
+        { type: DndItemType.Column, value },
+        [DndItemType.Column],
+        dropValidator,
+      ),
+    ).toBe(true);
+    expect(dropValidator).toHaveBeenCalledWith({
+      type: DndItemType.Column,
+      value,
+    });
+  });
+
+  test('a folder can drop when at least one item is acceptable and valid', () 
=> {
+    const okColumn = {
+      type: DndItemType.Column,
+      value: { column_name: 'a' },
+    } as DatasourcePanelDndItem;
+    const badTypeItem = {
+      type: DndItemType.Metric,
+      value: { metric_name: 'm' },
+    } as DatasourcePanelDndItem;
+    dropValidator.mockImplementation(item => item === okColumn);
+
+    expect(
+      resolveCanDrop(
+        {
+          type: DndItemType.Folder,
+          items: [badTypeItem, okColumn],
+        },
+        [DndItemType.Column, DndItemType.Folder],
+        dropValidator,
+      ),
+    ).toBe(true);
+  });
+
+  test('a folder cannot drop when accept excludes Folder itself, even if items 
match', () => {
+    // The general type check runs before the folder branch: a drop zone must
+    // explicitly accept DndItemType.Folder for folder drags to be considered
+    // at all, regardless of its items.
+    dropValidator.mockReturnValue(true);
+    expect(
+      resolveCanDrop(
+        {
+          type: DndItemType.Folder,
+          items: [{ type: DndItemType.Column, value: { column_name: 'a' } }],
+        },
+        [DndItemType.Column],
+        dropValidator,
+      ),
+    ).toBe(false);
+    expect(dropValidator).not.toHaveBeenCalled();
+  });
+
+  test('a folder cannot drop when no item is accepted or valid', () => {
+    dropValidator.mockReturnValue(false);
+    expect(
+      resolveCanDrop(
+        {
+          type: DndItemType.Folder,
+          items: [{ type: DndItemType.Column, value: { column_name: 'a' } }],
+        },
+        [DndItemType.Column, DndItemType.Folder],
+        dropValidator,
+      ),
+    ).toBe(false);
+  });
+
+  test('a folder with no items array cannot drop', () => {
+    expect(
+      resolveCanDrop(
+        { type: DndItemType.Folder },
+        [DndItemType.Column, DndItemType.Folder],
+        dropValidator,
+      ),
+    ).toBe(false);
+  });
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
index e63df5bfae2..6b45f4332a1 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx
@@ -37,6 +37,39 @@ import { Icons } from '@superset-ui/core/components/Icons';
 import { DndItemType } from '../../DndItemType';
 import { DraggingContext, DropzoneContext } from '../../ExploreContainer';
 
+export type ActiveDragData = {
+  type: string;
+  value?: unknown;
+  items?: DatasourcePanelDndItem[];
+};
+
+/**
+ * Pure, folder-aware `canDrop` logic for the currently dragged item, extracted
+ * so it can be unit-tested directly: @dnd-kit's PointerSensor only reacts to
+ * real pointer events, which jsdom cannot meaningfully dispatch (see
+ * ExploreDndContext's `resolveDragEnd` for the same pattern).
+ */
+export function resolveCanDrop(
+  activeData: ActiveDragData | undefined,
+  acceptTypes: DndItemType[],
+  dropValidator: (item: DatasourcePanelDndItem) => boolean,
+): boolean {
+  if (!activeData) return false;
+  if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
+  // A folder can drop here when at least one of its items is acceptable.
+  if (activeData.type === DndItemType.Folder) {
+    const items = Array.isArray(activeData.items) ? activeData.items : [];
+    return items.some(
+      item =>
+        acceptTypes.includes(item.type as DndItemType) && dropValidator(item),
+    );
+  }
+  return dropValidator({
+    type: activeData.type as DndItemType,
+    value: activeData.value as DndItemValue,
+  });
+}
+
 export type DndSelectLabelProps = {
   name: string;
   accept: DndItemType | DndItemType[];
@@ -45,6 +78,7 @@ export type DndSelectLabelProps = {
   canDrop: (item: DatasourcePanelDndItem) => boolean;
   canDropValue?: (value: DndItemValue) => boolean;
   onDropValue?: (value: DndItemValue) => void;
+  onDropFolder?: (items: DatasourcePanelDndItem[]) => void;
   valuesRenderer: () => ReactNode;
   displayGhostButton?: boolean;
   onClickGhostButton: () => void;
@@ -87,19 +121,20 @@ export default function DndSelectLabel({
       canDrop: dropValidator,
       onDrop: props.onDrop,
       onDropValue: props.onDropValue,
+      onDropFolder: props.onDropFolder,
     },
   });
 
   // Check if the active dragged item can be dropped here
-  const canDrop = useMemo(() => {
-    if (!active?.data.current) return false;
-    const activeData = active.data.current as { type: string; value: unknown };
-    if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
-    return dropValidator({
-      type: activeData.type as DndItemType,
-      value: activeData.value as DndItemValue,
-    });
-  }, [active, acceptTypes, dropValidator]);
+  const canDrop = useMemo(
+    () =>
+      resolveCanDrop(
+        active?.data.current as ActiveDragData | undefined,
+        acceptTypes,
+        dropValidator,
+      ),
+    [active, acceptTypes, dropValidator],
+  );
 
   const [, dispatch] = useContext(DropzoneContext);
 
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/createAdhocMetricFromColumn.test.ts
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/createAdhocMetricFromColumn.test.ts
new file mode 100644
index 00000000000..8c7075c21df
--- /dev/null
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/createAdhocMetricFromColumn.test.ts
@@ -0,0 +1,49 @@
+/**
+ * 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 { GenericDataType } from '@apache-superset/core/common';
+import { ColumnMeta } from '@superset-ui/chart-controls';
+import { AGGREGATES } from 'src/explore/constants';
+import { createAdhocMetricFromColumn } from './DndMetricSelect';
+
+const column = (type_generic?: GenericDataType): ColumnMeta =>
+  ({ column_name: 'c', type_generic }) as ColumnMeta;
+
+test('numeric columns default to SUM', () => {
+  const metric = createAdhocMetricFromColumn(column(GenericDataType.Numeric));
+  expect(metric.aggregate).toBe(AGGREGATES.SUM);
+  expect(metric.column?.column_name).toBe('c');
+});
+
+test('string, boolean and temporal columns default to COUNT_DISTINCT', () => {
+  [
+    GenericDataType.String,
+    GenericDataType.Boolean,
+    GenericDataType.Temporal,
+  ].forEach(type => {
+    expect(createAdhocMetricFromColumn(column(type)).aggregate).toBe(
+      AGGREGATES.COUNT_DISTINCT,
+    );
+  });
+});
+
+test('MultiValue and untyped columns get no default aggregate', () => {
+  [GenericDataType.MultiValue, undefined].forEach(type => {
+    expect(createAdhocMetricFromColumn(column(type)).aggregate).toBeFalsy();
+  });
+});
diff --git 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/dndTestUtils.ts
 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/dndTestUtils.ts
index c8f17ad39ec..12eb4ca67d8 100644
--- 
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/dndTestUtils.ts
+++ 
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/dndTestUtils.ts
@@ -68,6 +68,24 @@ export function simulateDrop(
   );
 }
 
+/**
+ * Drives a folder drag (dragging a whole folder of columns/metrics from the
+ * DatasourcePanel) onto the captured droppable through the production
+ * `resolveDragEnd` dispatcher.
+ */
+export function simulateFolderDrop(
+  captured: CapturedDroppable,
+  items: { type: DndItemType; value: DndItemValue }[],
+) {
+  resolveDragEnd(
+    {
+      id: 'drag-source-folder',
+      data: { current: { type: DndItemType.Folder, items } },
+    },
+    { id: 'dropzone', data: { current: captured.current ?? {} } },
+  );
+}
+
 export type SortableItemData = {
   type: string;
   dragIndex: number;

Reply via email to