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;