= ({
? t(
"This dataset is managed externally, and can't be edited in Superset",
)
- : ''
+ : errors.length > 0
+ ? errors.join('\n')
+ : ''
}
>
{t('Save')}
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx
new file mode 100644
index 00000000000..fbf3080cd14
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/FoldersEditor.test.tsx
@@ -0,0 +1,545 @@
+/**
+ * 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 type { ReactElement, ReactChild } from 'react';
+import {
+ render,
+ screen,
+ fireEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import { Metric, ColumnMeta } from '@superset-ui/chart-controls';
+import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types';
+import FoldersEditor from '.';
+import {
+ DEFAULT_METRICS_FOLDER_UUID,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+} from './constants';
+import { FoldersEditorItemType } from '../types';
+
+// Mock react-virtualized-auto-sizer to provide dimensions in tests
+jest.mock(
+ 'react-virtualized-auto-sizer',
+ () =>
+ ({
+ children,
+ }: {
+ children: (params: { height: number; width: number }) => ReactChild;
+ }) =>
+ children({ height: 500, width: 400 }),
+);
+
+// Mock react-window VariableSizeList to render all items for testing
+jest.mock('react-window', () => ({
+ VariableSizeList: ({
+ children: Row,
+ itemCount,
+ itemData,
+ }: {
+ children: React.ComponentType<{
+ index: number;
+ style: React.CSSProperties;
+ data: unknown;
+ }>;
+ itemCount: number;
+ itemData: unknown;
+ }) => (
+
+ {Array.from({ length: itemCount }, (_, index) => (
+
+ ))}
+
+ ),
+}));
+
+// Wrap render with useRedux: true since FoldersEditor uses useToasts which requires Redux
+const renderEditor = (ui: ReactElement, options = {}) =>
+ render(ui, { useRedux: true, ...options });
+
+const mockMetrics: Metric[] = [
+ {
+ uuid: 'metric1',
+ metric_name: 'Count',
+ expression: 'COUNT(*)',
+ description: 'Total count',
+ },
+ {
+ uuid: 'metric2',
+ metric_name: 'Sum Revenue',
+ expression: 'SUM(revenue)',
+ description: 'Total revenue',
+ },
+];
+
+const mockColumns: ColumnMeta[] = [
+ {
+ uuid: 'col1',
+ column_name: 'id',
+ type: 'INTEGER',
+ },
+ {
+ uuid: 'col2',
+ column_name: 'name',
+ type: 'VARCHAR',
+ },
+];
+
+const mockFolders: DatasourceFolder[] = [
+ {
+ uuid: DEFAULT_METRICS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder,
+ name: 'Metrics',
+ children: [
+ { type: FoldersEditorItemType.Metric, uuid: 'metric1', name: 'Count' },
+ ],
+ },
+ {
+ uuid: DEFAULT_COLUMNS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder,
+ name: 'Columns',
+ children: [
+ { type: FoldersEditorItemType.Column, uuid: 'col1', name: 'ID' },
+ ],
+ },
+];
+
+const defaultProps = {
+ folders: mockFolders,
+ metrics: mockMetrics,
+ columns: mockColumns,
+ onChange: jest.fn(),
+};
+
+test('renders FoldersEditor with folders', () => {
+ renderEditor();
+
+ expect(screen.getByText('Metrics')).toBeInTheDocument();
+ expect(screen.getByText('Columns')).toBeInTheDocument();
+});
+
+test('renders search input', () => {
+ renderEditor();
+
+ expect(
+ screen.getByPlaceholderText('Search all metrics & columns'),
+ ).toBeInTheDocument();
+});
+
+test('renders action buttons when in edit mode', () => {
+ renderEditor();
+
+ expect(screen.getByText('Add folder')).toBeInTheDocument();
+ expect(screen.getByText('Select all')).toBeInTheDocument();
+ expect(screen.getByText('Reset all folders to default')).toBeInTheDocument();
+});
+
+test('adds a new folder when Add folder button is clicked', async () => {
+ renderEditor();
+
+ const addButton = screen.getByText('Add folder');
+ fireEvent.click(addButton);
+
+ // New folder appears in the UI with an empty input and placeholder
+ await waitFor(() => {
+ const input = screen.getByPlaceholderText(
+ 'Name your folder and to edit it later, click on the folder name',
+ );
+ expect(input).toBeInTheDocument();
+ });
+});
+
+test('filters items when searching', async () => {
+ renderEditor();
+
+ const searchInput = screen.getByPlaceholderText(
+ 'Search all metrics & columns',
+ );
+ await userEvent.type(searchInput, 'Count');
+
+ await waitFor(() => {
+ expect(screen.getByText('Count')).toBeInTheDocument();
+ });
+});
+
+test('selects all items when Select all is clicked', async () => {
+ renderEditor();
+
+ const selectAllButton = screen.getByText('Select all');
+ fireEvent.click(selectAllButton);
+
+ await waitFor(() => {
+ const checkboxes = screen.getAllByRole('checkbox');
+ const nonButtonCheckboxes = checkboxes.filter(
+ checkbox => !checkbox.closest('button'),
+ );
+ expect(nonButtonCheckboxes.length).toBeGreaterThan(0);
+ nonButtonCheckboxes.forEach(checkbox => {
+ expect(checkbox).toBeChecked();
+ });
+ });
+});
+
+test('expands and collapses folders', async () => {
+ renderEditor();
+
+ // Folder should be expanded by default, so Count should be visible
+ expect(screen.getByText('Count')).toBeInTheDocument();
+
+ // Click to collapse - click on the DownOutlined icon to toggle folder
+ const downIcons = screen.getAllByRole('img', { name: 'down' });
+ fireEvent.click(downIcons[0]);
+
+ await waitFor(() => {
+ expect(screen.queryByText('Count')).not.toBeInTheDocument();
+ });
+
+ // Click to expand again - the icon should now be RightOutlined
+ const rightIcons = screen.getAllByRole('img', { name: 'right' });
+ fireEvent.click(rightIcons[0]);
+
+ await waitFor(() => {
+ expect(screen.getByText('Count')).toBeInTheDocument();
+ });
+});
+
+test('edits folder name when clicked in edit mode', async () => {
+ const onChange = jest.fn();
+ renderEditor(
+ ,
+ );
+
+ const folderName = screen.getByText('Custom Folder');
+ fireEvent.click(folderName);
+
+ const input = screen.getByDisplayValue('Custom Folder');
+ await userEvent.clear(input);
+ await userEvent.type(input, 'Updated Folder');
+ fireEvent.blur(input);
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled();
+ const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
+ const folders = lastCall[0];
+ expect(folders[0].name).toBe('Updated Folder');
+ });
+});
+
+test('creates default folders when none exist', () => {
+ renderEditor();
+
+ expect(screen.getByText('Metrics')).toBeInTheDocument();
+ expect(screen.getByText('Columns')).toBeInTheDocument();
+});
+
+test('shows confirmation modal when resetting to default', async () => {
+ renderEditor();
+
+ const resetButton = screen.getByText('Reset all folders to default');
+ fireEvent.click(resetButton);
+
+ await waitFor(() => {
+ // Modal may render multiple elements with the same text (e.g., in portal)
+ const modalTexts = screen.getAllByText('Reset to default folders?');
+ expect(modalTexts.length).toBeGreaterThan(0);
+ });
+});
+
+test('renders sortable drag handles for folders', () => {
+ renderEditor(
+ ,
+ );
+
+ // @dnd-kit adds aria-roledescription="sortable" to sortable elements
+ const sortableElements = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableElements.length).toBeGreaterThanOrEqual(2);
+});
+
+test('applies @dnd-kit dragging styles when folder is being dragged', () => {
+ renderEditor(
+ ,
+ );
+
+ // @dnd-kit adds aria-roledescription="sortable" and role="button" to sortable elements
+ const sortableElements = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableElements.length).toBeGreaterThan(0);
+
+ // Each sortable element should have @dnd-kit attributes
+ sortableElements.forEach(element => {
+ expect(element).toHaveAttribute('aria-roledescription', 'sortable');
+ expect(element).toHaveAttribute('role', 'button');
+ });
+});
+
+test('renders @dnd-kit sortable context', () => {
+ renderEditor();
+
+ // Just test that the basic DndContext is working
+ // by checking for the presence of @dnd-kit specific attributes
+ const sortableElements = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableElements.length).toBeGreaterThan(0);
+
+ // Test that sortable attributes are present
+ sortableElements.forEach(element => {
+ expect(element).toHaveAttribute('aria-roledescription', 'sortable');
+ });
+});
+
+test('folders are rendered with proper @dnd-kit integration', () => {
+ renderEditor(
+ ,
+ );
+
+ // Test that the folder appears and has drag functionality
+ expect(screen.getByText('Test Folder')).toBeInTheDocument();
+ const sortableElements = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableElements.length).toBeGreaterThan(0);
+ const sortableElement = sortableElements[0];
+ expect(sortableElement).toHaveAttribute('tabindex', '0');
+ expect(sortableElement).toHaveAttribute('role', 'button');
+});
+
+test('items are sortable with @dnd-kit', () => {
+ const testProps = {
+ ...defaultProps,
+ folders: [
+ {
+ type: FoldersEditorItemType.Folder as const,
+ uuid: DEFAULT_METRICS_FOLDER_UUID,
+ name: 'Metrics',
+ children: [
+ {
+ uuid: 'metric-1',
+ type: FoldersEditorItemType.Metric,
+ name: 'Test Metric 1',
+ },
+ ],
+ },
+ ],
+ };
+
+ renderEditor();
+
+ // Expand folder to show items
+ const metricsFolder = screen.getByText('Metrics');
+ fireEvent.click(metricsFolder);
+
+ // Check that items have checkboxes
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+
+ // Check that sortable elements with @dnd-kit attributes exist
+ // Items should have sortable attributes even without explicit drag handles
+ const sortableElements = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableElements.length).toBeGreaterThan(0);
+});
+
+test('component renders with proper drag and drop structure', () => {
+ renderEditor();
+
+ // Verify basic structure is present
+ expect(
+ screen.getByPlaceholderText('Search all metrics & columns'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Add folder')).toBeInTheDocument();
+
+ // Verify DndContext and sortable elements are working
+ const sortableElements = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableElements.length).toBeGreaterThan(0);
+
+ // Each sortable element should have @dnd-kit attributes
+ sortableElements.forEach(element => {
+ expect(element).toHaveAttribute('aria-roledescription', 'sortable');
+ expect(element).toHaveAttribute('role', 'button');
+ });
+});
+
+test('drag functionality integrates properly with selection state', () => {
+ const onChange = jest.fn();
+ const testProps = {
+ ...defaultProps,
+ onChange,
+ folders: [
+ {
+ type: FoldersEditorItemType.Folder as const,
+ uuid: DEFAULT_METRICS_FOLDER_UUID,
+ name: 'Metrics',
+ children: [
+ {
+ uuid: 'metric-1',
+ type: FoldersEditorItemType.Metric,
+ name: 'Test Metric 1',
+ },
+ {
+ uuid: 'metric-2',
+ type: FoldersEditorItemType.Metric,
+ name: 'Test Metric 2',
+ },
+ ],
+ },
+ ],
+ metrics: [
+ {
+ uuid: 'metric-1',
+ metric_name: 'Test Metric 1',
+ expression: 'COUNT(*)',
+ },
+ {
+ uuid: 'metric-2',
+ metric_name: 'Test Metric 2',
+ expression: 'SUM(amount)',
+ },
+ ],
+ };
+
+ renderEditor();
+
+ // Expand folder to show items
+ const metricsFolder = screen.getByText('Metrics');
+ fireEvent.click(metricsFolder);
+
+ // Verify that drag and drop context is properly set up
+ // Items should be wrapped in sortable context
+ const sortableItems = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableItems.length).toBeGreaterThanOrEqual(2); // At least folders are sortable
+
+ // Verify checkboxes are present and functional
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+});
+
+test('nested folders with items remain visible after drag is cancelled', async () => {
+ const onChange = jest.fn();
+ const nestedFolders: DatasourceFolder[] = [
+ {
+ uuid: 'parent-folder',
+ type: FoldersEditorItemType.Folder,
+ name: 'Parent Folder',
+ children: [
+ {
+ uuid: 'nested-folder',
+ type: FoldersEditorItemType.Folder,
+ name: 'Nested Folder',
+ children: [
+ {
+ uuid: 'metric1',
+ type: FoldersEditorItemType.Metric,
+ name: 'Count',
+ },
+ ],
+ } as DatasourceFolder,
+ ],
+ },
+ ];
+
+ renderEditor(
+ ,
+ );
+
+ expect(screen.getByText('Parent Folder')).toBeInTheDocument();
+ expect(screen.getByText('Nested Folder')).toBeInTheDocument();
+ expect(screen.getByText('Count')).toBeInTheDocument();
+
+ const sortableElements = document.querySelectorAll(
+ '[aria-roledescription="sortable"]',
+ );
+ expect(sortableElements.length).toBeGreaterThan(0);
+
+ await waitFor(() => {
+ expect(screen.getByText('Parent Folder')).toBeInTheDocument();
+ expect(screen.getByText('Nested Folder')).toBeInTheDocument();
+ expect(screen.getByText('Count')).toBeInTheDocument();
+ });
+});
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts
new file mode 100644
index 00000000000..b28fa0fe985
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.styles.ts
@@ -0,0 +1,214 @@
+/**
+ * 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 { styled, css } from '@apache-superset/core/ui';
+
+export const FOLDER_INDENTATION_WIDTH = 24;
+export const ITEM_INDENTATION_WIDTH = 4;
+
+export const TreeItemContainer = styled.div<{
+ depth: number;
+ isDragging: boolean;
+ isOver: boolean;
+ isOverlay?: boolean;
+}>`
+ ${({ theme, depth, isDragging, isOverlay }) => `
+ margin: 0 ${theme.marginMD}px;
+ margin-left: ${isOverlay ? ITEM_INDENTATION_WIDTH : (depth - 1) * FOLDER_INDENTATION_WIDTH + ITEM_INDENTATION_WIDTH}px;
+ padding-left: ${theme.paddingSM}px;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ opacity: ${isDragging ? 0.4 : 1};
+ user-select: none;
+ ${isDragging || isOverlay ? 'will-change: transform;' : ''}
+ `}
+`;
+
+export const ItemSeparator = styled.div<{
+ variant: 'visible' | 'transparent';
+}>`
+ ${({ theme, variant }) => {
+ // Use explicit height instead of margins so dnd-kit measures correctly.
+ // getBoundingClientRect doesn't include margins, causing transform mismatches during drag.
+ const verticalPadding =
+ variant === 'visible' ? theme.marginSM : theme.marginXS;
+ const totalHeight = 1 + verticalPadding * 2;
+ return `
+ height: ${totalHeight}px;
+ display: flex;
+ align-items: center;
+ margin-left: ${theme.marginSM}px;
+ margin-right: ${theme.marginMD}px;
+
+ &::after {
+ content: '';
+ display: block;
+ width: 100%;
+ height: 1px;
+ background-color: ${variant === 'visible' ? theme.colorBorderSecondary : 'transparent'};
+ }
+ `;
+ }}
+`;
+
+export const TreeFolderContainer = styled(TreeItemContainer)<{
+ isForbiddenDropTarget?: boolean;
+}>`
+ ${({ theme, depth, isForbiddenDropTarget, isOverlay }) => `
+ margin-top: 0;
+ margin-bottom: 0;
+ padding-top: ${theme.paddingSM}px;
+ padding-bottom: ${theme.paddingSM}px;
+ margin-left: ${depth * FOLDER_INDENTATION_WIDTH}px;
+ border-radius: ${theme.borderRadius}px;
+ padding-left: ${theme.paddingSM}px;
+ padding-right: ${theme.paddingSM}px;
+ margin-right: ${theme.marginMD}px;
+ transition: background-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+
+ /* Drop target styles - controlled via data attributes for performance */
+ &[data-drop-target="true"] {
+ background-color: ${theme.colorPrimaryBg};
+ box-shadow: inset 0 0 0 2px ${theme.colorPrimary};
+ }
+
+ &[data-drop-target="true"][data-forbidden-drop="true"],
+ &[data-drop-target="true"]${isForbiddenDropTarget ? '' : '[data-forbidden-drop="true"]'} {
+ background-color: ${theme.colorErrorBg};
+ box-shadow: inset 0 0 0 2px ${theme.colorError};
+ cursor: not-allowed;
+ }
+
+ /* Also support prop-based forbidden styling for initial render */
+ ${
+ isForbiddenDropTarget
+ ? `
+ &[data-drop-target="true"] {
+ background-color: ${theme.colorErrorBg};
+ box-shadow: inset 0 0 0 2px ${theme.colorError};
+ cursor: not-allowed;
+ }
+ `
+ : ''
+ }
+ `}
+`;
+
+export const DragHandle = styled.span`
+ ${({ theme }) => `
+ color: ${theme.colorTextTertiary};
+ display: inline-flex;
+ align-items: center;
+ cursor: grab;
+
+ &:hover {
+ color: ${theme.colorText};
+ }
+
+ &:active {
+ cursor: grabbing;
+ }
+ `}
+`;
+
+export const CollapseButton = styled.span`
+ ${({ theme }) => `
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 12px;
+ height: 12px;
+ cursor: pointer;
+ color: ${theme.colorTextSecondary};
+ margin-left: auto;
+
+ &:hover {
+ color: ${theme.colorText};
+ }
+ `}
+`;
+
+export const DefaultFolderIconContainer = styled.span`
+ ${({ theme }) => `
+ display: inline-flex;
+ align-items: center;
+ color: ${theme.colorTextSecondary};
+ margin-right: ${theme.marginXS}px;
+ `}
+`;
+
+export const FolderName = styled.span`
+ ${({ theme }) => `
+ margin-right: ${theme.marginMD}px;
+ font-weight: ${theme.fontWeightStrong};
+ `}
+`;
+
+export const DragHandleContainer = styled.div`
+ ${({ theme }) => `
+ height: 100%;
+ display: flex;
+ align-items: center;
+ padding: 0 ${theme.sizeUnit}px;
+ margin-left: auto;
+ cursor: grab;
+ color: ${theme.colorTextTertiary};
+
+ &:hover {
+ color: ${theme.colorText};
+ }
+
+ &:active {
+ cursor: grabbing;
+ }
+ `}
+`;
+
+export const EmptyFolderDropZone = styled.div<{
+ depth: number;
+ isOver: boolean;
+ isForbidden: boolean;
+}>`
+ ${({ theme, depth, isOver, isForbidden }) => css`
+ margin: ${theme.marginXS}px ${theme.marginMD}px 0;
+ margin-left: ${depth * FOLDER_INDENTATION_WIDTH + ITEM_INDENTATION_WIDTH}px;
+ padding: ${theme.paddingLG}px;
+ border: 2px dashed
+ ${isOver
+ ? isForbidden
+ ? theme.colorError
+ : theme.colorPrimary
+ : 'transparent'};
+ border-radius: ${theme.borderRadius}px;
+ background: ${isOver
+ ? isForbidden
+ ? theme.colorErrorBg
+ : theme.colorPrimaryBg
+ : 'transparent'};
+ text-align: center;
+ transition: all 0.2s ease-in-out;
+ cursor: ${isOver && isForbidden ? 'not-allowed' : 'default'};
+ opacity: ${isOver && isForbidden ? 0.7 : 1};
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ `}
+`;
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.tsx
new file mode 100644
index 00000000000..a941fd19474
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/TreeItem.tsx
@@ -0,0 +1,396 @@
+/**
+ * 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 { CSSProperties, useState, memo, useMemo } from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { useDroppable } from '@dnd-kit/core';
+import { CSS } from '@dnd-kit/utilities';
+import { css, t } from '@apache-superset/core/ui';
+import {
+ Checkbox,
+ Input,
+ Icons,
+ EmptyState,
+ Tooltip,
+} from '@superset-ui/core/components';
+import {
+ ColumnLabelExtendedType,
+ ColumnMeta,
+ ColumnTypeLabel,
+ Metric,
+} from '@superset-ui/chart-controls';
+import { GenericDataType } from '@apache-superset/core/api/core';
+import {
+ OptionControlContainer,
+ Label,
+} from 'src/explore/components/controls/OptionControls';
+import { FoldersEditorItemType } from '../types';
+import {
+ DEFAULT_COLUMNS_FOLDER_UUID,
+ DEFAULT_METRICS_FOLDER_UUID,
+} from './constants';
+import {
+ TreeItemContainer,
+ TreeFolderContainer,
+ DragHandle,
+ CollapseButton,
+ DefaultFolderIconContainer,
+ FolderName,
+ DragHandleContainer,
+ EmptyFolderDropZone,
+ ItemSeparator,
+} from './TreeItem.styles';
+
+const FOLDER_NAME_PLACEHOLDER = t(
+ 'Name your folder and to edit it later, click on the folder name',
+);
+
+interface TreeItemProps {
+ id: string;
+ type: FoldersEditorItemType;
+ name: string;
+ depth: number;
+ isCollapsed?: boolean;
+ isFolder?: boolean;
+ isSelected?: boolean;
+ isEditing?: boolean;
+ onToggleCollapse?: (id: string) => void;
+ onSelect?: (id: string, selected: boolean, shiftKey?: boolean) => void;
+ onStartEdit?: (id: string) => void;
+ onFinishEdit?: (id: string, newName: string) => void;
+ isDefaultFolder?: boolean;
+ showEmptyState?: boolean;
+ separatorType?: 'visible' | 'transparent';
+ isForbiddenDrop?: boolean;
+ isDropTarget?: boolean;
+ metric?: Metric;
+ column?: ColumnMeta;
+ isOverlay?: boolean;
+}
+
+function TreeItemComponent({
+ id,
+ type,
+ name,
+ depth,
+ isCollapsed = false,
+ isFolder = false,
+ isSelected = false,
+ isEditing = false,
+ onToggleCollapse,
+ onSelect,
+ onStartEdit,
+ onFinishEdit,
+ isDefaultFolder = false,
+ showEmptyState = false,
+ separatorType,
+ isForbiddenDrop = false,
+ isDropTarget = false,
+ metric,
+ column,
+ isOverlay = false,
+}: TreeItemProps) {
+ const [editValue, setEditValue] = useState(name);
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ isOver,
+ } = useSortable({
+ id,
+ data: {
+ type,
+ isFolder,
+ },
+ disabled: isOverlay,
+ });
+
+ const { setNodeRef: setDroppableRef, isOver: isOverEmpty } = useDroppable({
+ id: `${id}-empty`,
+ data: {
+ type,
+ isFolder,
+ parentId: id,
+ },
+ disabled: isOverlay,
+ });
+
+ const style: CSSProperties = isOverlay
+ ? {}
+ : {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ const handleEditKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ onFinishEdit?.(id, editValue);
+ } else if (e.key === 'Escape') {
+ setEditValue(name);
+ onFinishEdit?.(id, name);
+ }
+ };
+
+ const handleEditBlur = () => {
+ if (editValue.trim()) {
+ onFinishEdit?.(id, editValue);
+ } else {
+ setEditValue(name);
+ onFinishEdit?.(id, name);
+ }
+ };
+
+ const itemDisplayName = useMemo(() => {
+ if (type === FoldersEditorItemType.Metric && metric) {
+ return metric.verbose_name || metric.metric_name || name;
+ }
+ if (type === FoldersEditorItemType.Column && column) {
+ return column.verbose_name || column.column_name || name;
+ }
+ return name;
+ }, [type, metric, column, name]);
+
+ const columnType: ColumnLabelExtendedType | GenericDataType | undefined =
+ useMemo(() => {
+ if (type === FoldersEditorItemType.Metric) {
+ return 'metric';
+ }
+ if (type === FoldersEditorItemType.Column && column) {
+ const hasExpression =
+ column.expression && column.expression !== column.column_name;
+ return hasExpression ? 'expression' : column.type_generic;
+ }
+ return undefined;
+ }, [type, column]);
+
+ const hasEmptyName = !name || name.trim() === '';
+
+ const renderItemContent = () => {
+ if (isFolder) {
+ const isDefaultColumnsFolder =
+ id === DEFAULT_COLUMNS_FOLDER_UUID && isDefaultFolder;
+ const isDefaultMetricsFolder =
+ id === DEFAULT_METRICS_FOLDER_UUID && isDefaultFolder;
+ const folderNameContent = (
+ {
+ if (!isDefaultFolder && onStartEdit) {
+ e.stopPropagation();
+ onStartEdit(id);
+ }
+ }}
+ >
+ {name}
+
+ );
+
+ if (isDefaultColumnsFolder) {
+ return (
+
+ {folderNameContent}
+
+ );
+ }
+
+ if (isDefaultMetricsFolder) {
+ return (
+
+ {folderNameContent}
+
+ );
+ }
+
+ return folderNameContent;
+ }
+
+ return (
+
+
+
+
+
+
+ );
+ };
+
+ const containerProps = {
+ ref: setNodeRef,
+ style,
+ depth,
+ isDragging,
+ isOver,
+ isOverlay,
+ };
+
+ const containerContent = (
+ <>
+ {isFolder && (
+ css`
+ margin-right: ${theme.marginSM}px;
+ `}
+ >
+
+
+ )}
+
+ {(onSelect || (isOverlay && !isFolder)) && (
+ {
+ if (!isOverlay) {
+ e.stopPropagation();
+ onSelect?.(id, !isSelected, e.shiftKey);
+ }
+ }}
+ css={theme => css`
+ margin-right: ${theme.marginSM}px;
+ `}
+ />
+ )}
+
+ {isFolder && (
+
+ {isDefaultFolder ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {(isEditing || hasEmptyName) && !isDefaultFolder ? (
+ ) =>
+ setEditValue(e.target.value)
+ }
+ onKeyDown={handleEditKeyDown}
+ onBlur={handleEditBlur}
+ autoFocus
+ onClick={(e: React.MouseEvent) =>
+ e.stopPropagation()
+ }
+ css={theme => css`
+ padding: 0;
+ padding-right: ${theme.marginMD}px;
+ `}
+ variant="borderless"
+ />
+ ) : (
+ renderItemContent()
+ )}
+
+ {isFolder && onToggleCollapse && (
+ {
+ e.stopPropagation();
+ onToggleCollapse(id);
+ }}
+ >
+ {isCollapsed ? : }
+
+ )}
+ >
+ );
+
+ // Separator appears BELOW items (after content)
+ // Wrapped together with item so they move as one unit during drag
+ const showSeparator = !isFolder && separatorType;
+
+ // Extract transform style to apply to wrapper
+ const { style: transformStyle, ...restContainerProps } = containerProps;
+
+ return (
+ <>
+ {/* Wrapper div receives the transform so item + separator move together */}
+
+ {isFolder ? (
+
+ {containerContent}
+
+ ) : (
+
+ {containerContent}
+
+ )}
+
+ {showSeparator && }
+
+
+ {isFolder && showEmptyState && !isCollapsed && (
+
+
+
+ )}
+ >
+ );
+}
+
+export const TreeItem = memo(TreeItemComponent);
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx
new file mode 100644
index 00000000000..39fa42d19df
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeItem.tsx
@@ -0,0 +1,220 @@
+/**
+ * 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 { CSSProperties, memo } from 'react';
+import type { ListChildComponentProps } from 'react-window';
+import { useDroppable } from '@dnd-kit/core';
+import type { UniqueIdentifier } from '@dnd-kit/core';
+import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
+import { FoldersEditorItemType } from '../types';
+import type { FlattenedTreeItem } from './constants';
+import { isDefaultFolder } from './constants';
+import { TreeItem } from './TreeItem';
+
+// Invisible placeholder that keeps the droppable area for horizontal drag depth changes
+interface DragPlaceholderProps {
+ id: string;
+ style: CSSProperties;
+ type: FoldersEditorItemType;
+ isFolder: boolean;
+}
+
+const DragPlaceholder = memo(function DragPlaceholder({
+ id,
+ style,
+ type,
+ isFolder,
+}: DragPlaceholderProps) {
+ const { setNodeRef } = useDroppable({
+ id,
+ data: { type, isFolder },
+ });
+
+ return ;
+});
+
+export interface VirtualizedTreeItemData {
+ flattenedItems: FlattenedTreeItem[];
+ collapsedIds: Set;
+ selectedItemIds: Set;
+ editingFolderId: string | null;
+ folderChildCounts: Map;
+ itemSeparatorInfo: Map;
+ visibleItemIds: Set;
+ searchTerm: string;
+ metricsMap: Map;
+ columnsMap: Map;
+ activeId: UniqueIdentifier | null;
+ forbiddenDropFolderIds: Set;
+ currentDropTargetId: string | null;
+ onToggleCollapse: (id: string) => void;
+ onSelect: (id: string, selected: boolean, shiftKey?: boolean) => void;
+ onStartEdit: (id: string) => void;
+ onFinishEdit: (id: string, newName: string) => void;
+}
+
+// Inner component that receives state as props for proper memoization
+interface TreeItemWrapperProps {
+ item: FlattenedTreeItem;
+ style: CSSProperties;
+ isFolder: boolean;
+ isCollapsed: boolean;
+ isSelected: boolean;
+ isEditing: boolean;
+ showEmptyState: boolean;
+ separatorType?: 'visible' | 'transparent';
+ isForbiddenDrop: boolean;
+ isDropTarget: boolean;
+ metric?: Metric;
+ column?: ColumnMeta;
+ onToggleCollapse?: (id: string) => void;
+ onSelect?: (id: string, selected: boolean, shiftKey?: boolean) => void;
+ onStartEdit?: (id: string) => void;
+ onFinishEdit?: (id: string, newName: string) => void;
+}
+
+const TreeItemWrapper = memo(function TreeItemWrapper({
+ item,
+ style,
+ isFolder,
+ isCollapsed,
+ isSelected,
+ isEditing,
+ showEmptyState,
+ separatorType,
+ isForbiddenDrop,
+ isDropTarget,
+ metric,
+ column,
+ onToggleCollapse,
+ onSelect,
+ onStartEdit,
+ onFinishEdit,
+}: TreeItemWrapperProps) {
+ return (
+
+
+
+ );
+});
+
+function VirtualizedTreeItemComponent({
+ index,
+ style,
+ data,
+}: ListChildComponentProps) {
+ const {
+ flattenedItems,
+ collapsedIds,
+ selectedItemIds,
+ editingFolderId,
+ folderChildCounts,
+ itemSeparatorInfo,
+ visibleItemIds,
+ searchTerm,
+ metricsMap,
+ columnsMap,
+ activeId,
+ forbiddenDropFolderIds,
+ currentDropTargetId,
+ onToggleCollapse,
+ onSelect,
+ onStartEdit,
+ onFinishEdit,
+ } = data;
+
+ const item = flattenedItems[index];
+
+ if (!item) {
+ return null;
+ }
+
+ const isFolder = item.type === FoldersEditorItemType.Folder;
+
+ // Hide items that don't match search (unless they're folders)
+ if (!isFolder && searchTerm && !visibleItemIds.has(item.uuid)) {
+ return null;
+ }
+
+ // Render invisible placeholder for active dragged item - keeps droppable area
+ // for horizontal drag depth changes while visual is in DragOverlay
+ if (activeId === item.uuid) {
+ return (
+
+ );
+ }
+
+ const childCount = isFolder ? (folderChildCounts.get(item.uuid) ?? 0) : 0;
+ const showEmptyState = isFolder && childCount === 0;
+
+ // isForbiddenDrop is calculated from props (changes when dragged items change)
+ const isForbiddenDrop = isFolder && forbiddenDropFolderIds.has(item.uuid);
+
+ // isDropTarget indicates this folder is the current drop target
+ const isDropTarget = isFolder && currentDropTargetId === item.uuid;
+
+ return (
+
+ );
+}
+
+export const VirtualizedTreeItem = memo(VirtualizedTreeItemComponent);
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx
new file mode 100644
index 00000000000..1920b296198
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/VirtualizedTreeList.tsx
@@ -0,0 +1,229 @@
+/**
+ * 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 { useCallback, useEffect, useMemo, useRef } from 'react';
+import { VariableSizeList as List } from 'react-window';
+import type { UniqueIdentifier } from '@dnd-kit/core';
+import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
+import { FoldersEditorItemType } from '../types';
+import type { FlattenedTreeItem } from './constants';
+import type { ItemHeights } from './hooks/useItemHeights';
+import type { HeightCache } from './hooks/useHeightCache';
+import { useAutoScroll } from './hooks/useAutoScroll';
+import {
+ VirtualizedTreeItem,
+ VirtualizedTreeItemData,
+} from './VirtualizedTreeItem';
+
+interface VirtualizedTreeListProps {
+ width: number;
+ height: number;
+ flattenedItems: FlattenedTreeItem[];
+ itemHeights: ItemHeights;
+ heightCache: HeightCache;
+ collapsedIds: Set;
+ selectedItemIds: Set;
+ editingFolderId: string | null;
+ folderChildCounts: Map;
+ itemSeparatorInfo: Map;
+ visibleItemIds: Set;
+ searchTerm: string;
+ metricsMap: Map;
+ columnsMap: Map;
+ isDragging: boolean;
+ activeId: UniqueIdentifier | null;
+ forbiddenDropFolderIds: Set;
+ currentDropTargetId: string | null;
+ onToggleCollapse: (id: string) => void;
+ onSelect: (id: string, selected: boolean, shiftKey?: boolean) => void;
+ onStartEdit: (id: string) => void;
+ onFinishEdit: (id: string, newName: string) => void;
+}
+
+export function VirtualizedTreeList({
+ width,
+ height,
+ flattenedItems,
+ itemHeights,
+ heightCache,
+ collapsedIds,
+ selectedItemIds,
+ editingFolderId,
+ folderChildCounts,
+ itemSeparatorInfo,
+ visibleItemIds,
+ searchTerm,
+ metricsMap,
+ columnsMap,
+ isDragging,
+ activeId,
+ forbiddenDropFolderIds,
+ currentDropTargetId,
+ onToggleCollapse,
+ onSelect,
+ onStartEdit,
+ onFinishEdit,
+}: VirtualizedTreeListProps) {
+ const listRef = useRef(null);
+ const containerRef = useRef(null);
+
+ // Custom auto-scroll during drag (replaces dnd-kit's auto-scroll which conflicts with virtualization)
+ useAutoScroll({
+ listRef,
+ containerRef,
+ isDragging,
+ listHeight: height,
+ });
+
+ // Reset list cache when items structure changes, but not during drag
+ // Resetting during drag causes jumping/flickering
+ useEffect(() => {
+ if (!isDragging) {
+ listRef.current?.resetAfterIndex(0);
+ }
+ }, [
+ flattenedItems,
+ collapsedIds,
+ folderChildCounts,
+ itemSeparatorInfo,
+ visibleItemIds,
+ isDragging,
+ ]);
+
+ // Calculate item size for react-window
+ const getItemSize = useCallback(
+ (index: number): number => {
+ const item = flattenedItems[index];
+
+ if (!item) {
+ return 0;
+ }
+
+ const isFolder = item.type === FoldersEditorItemType.Folder;
+
+ // If item doesn't match search, return 0 (hidden)
+ if (!isFolder && searchTerm && !visibleItemIds.has(item.uuid)) {
+ return 0;
+ }
+
+ // Keep the slot height for the active dragged item so horizontal drag
+ // can detect "over self" for depth changes. The visual is hidden but
+ // the droppable area remains.
+
+ let totalHeight = 0;
+
+ if (isFolder) {
+ totalHeight = itemHeights.folderHeader;
+
+ // Add EmptyState height if folder is empty and expanded
+ const childCount = folderChildCounts.get(item.uuid) ?? 0;
+ const isCollapsed = collapsedIds.has(item.uuid);
+
+ if (childCount === 0 && !isCollapsed) {
+ // Use cached height for empty folder or fall back to estimate
+ totalHeight +=
+ heightCache.getHeight(item.uuid) ?? itemHeights.emptyFolderBase;
+ }
+ } else {
+ totalHeight = itemHeights.regularItem;
+ }
+
+ // Add separator height if this item has one
+ const separatorType = itemSeparatorInfo.get(item.uuid);
+ if (separatorType === 'visible') {
+ totalHeight += itemHeights.separatorVisible;
+ } else if (separatorType === 'transparent') {
+ totalHeight += itemHeights.separatorTransparent;
+ }
+
+ return totalHeight;
+ },
+ [
+ flattenedItems,
+ itemHeights,
+ heightCache,
+ collapsedIds,
+ folderChildCounts,
+ itemSeparatorInfo,
+ visibleItemIds,
+ searchTerm,
+ ],
+ );
+
+ // Prepare item data for the row renderer
+ const itemData: VirtualizedTreeItemData = useMemo(
+ () => ({
+ flattenedItems,
+ collapsedIds,
+ selectedItemIds,
+ editingFolderId,
+ folderChildCounts,
+ itemSeparatorInfo,
+ visibleItemIds,
+ searchTerm,
+ metricsMap,
+ columnsMap,
+ activeId,
+ forbiddenDropFolderIds,
+ currentDropTargetId,
+ onToggleCollapse,
+ onSelect,
+ onStartEdit,
+ onFinishEdit,
+ }),
+ [
+ flattenedItems,
+ collapsedIds,
+ selectedItemIds,
+ editingFolderId,
+ folderChildCounts,
+ itemSeparatorInfo,
+ visibleItemIds,
+ searchTerm,
+ metricsMap,
+ columnsMap,
+ activeId,
+ forbiddenDropFolderIds,
+ currentDropTargetId,
+ onToggleCollapse,
+ onSelect,
+ onStartEdit,
+ onFinishEdit,
+ ],
+ );
+
+ // Use higher overscan during drag to ensure smooth scrolling
+ const overscanCount = isDragging ? 20 : 5;
+
+ return (
+
+
+ {VirtualizedTreeItem}
+
+
+ );
+}
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx
new file mode 100644
index 00000000000..b14ab8d3344
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/DragOverlayContent.tsx
@@ -0,0 +1,75 @@
+/**
+ * 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 { memo } from 'react';
+import { Metric } from '@superset-ui/core';
+import { ColumnMeta } from '@superset-ui/chart-controls';
+import { FoldersEditorItemType } from '../../types';
+import { FlattenedTreeItem } from '../constants';
+import { TreeItem } from '../TreeItem';
+import { DragOverlayStack, DragOverlayItem } from '../styles';
+
+interface DragOverlayContentProps {
+ dragOverlayItems: FlattenedTreeItem[];
+ dragOverlayWidth: number | null;
+ selectedItemIds: Set;
+ metricsMap: Map;
+ columnsMap: Map;
+}
+
+function DragOverlayContentInner({
+ dragOverlayItems,
+ dragOverlayWidth,
+ selectedItemIds,
+ metricsMap,
+ columnsMap,
+}: DragOverlayContentProps) {
+ if (dragOverlayItems.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {[...dragOverlayItems].reverse().map((item, index) => {
+ const stackIndex = dragOverlayItems.length - 1 - index;
+ return (
+
+
+
+ );
+ })}
+
+ );
+}
+
+export const DragOverlayContent = memo(DragOverlayContentInner);
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/FoldersToolbarComponent.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/components/FoldersToolbarComponent.tsx
new file mode 100644
index 00000000000..8fe6718c254
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/FoldersToolbarComponent.tsx
@@ -0,0 +1,78 @@
+/**
+ * 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 { memo } from 'react';
+import { t } from '@apache-superset/core';
+import { Button, Input } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { FoldersToolbar, FoldersSearch, FoldersActions } from '../styles';
+
+interface FoldersToolbarComponentProps {
+ onSearch: (e: React.ChangeEvent) => void;
+ onAddFolder: () => void;
+ onSelectAll: () => void;
+ onResetToDefault: () => void;
+ allVisibleSelected: boolean;
+}
+
+function FoldersToolbarComponentInner({
+ onSearch,
+ onAddFolder,
+ onSelectAll,
+ onResetToDefault,
+ allVisibleSelected,
+}: FoldersToolbarComponentProps) {
+ return (
+
+
+ }
+ />
+
+
+ }
+ >
+ {t('Add folder')}
+
+ }
+ >
+ {allVisibleSelected ? t('Deselect all') : t('Select all')}
+
+ }
+ >
+ {t('Reset all folders to default')}
+
+
+
+ );
+}
+
+export const FoldersToolbarComponent = memo(FoldersToolbarComponentInner);
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/ResetConfirmModal.tsx b/superset-frontend/src/components/Datasource/FoldersEditor/components/ResetConfirmModal.tsx
new file mode 100644
index 00000000000..0aabc92d8ac
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/ResetConfirmModal.tsx
@@ -0,0 +1,51 @@
+/**
+ * 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 { memo } from 'react';
+import { t } from '@apache-superset/core';
+import { Modal } from '@superset-ui/core/components';
+
+interface ResetConfirmModalProps {
+ show: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+function ResetConfirmModalInner({
+ show,
+ onCancel,
+ onConfirm,
+}: ResetConfirmModalProps) {
+ return (
+
+ {t(
+ 'This will reorganize all metrics and columns into default folders. Any custom folders will be removed.',
+ )}
+
+ );
+}
+
+export const ResetConfirmModal = memo(ResetConfirmModalInner);
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/components/index.ts b/superset-frontend/src/components/Datasource/FoldersEditor/components/index.ts
new file mode 100644
index 00000000000..4576f81ac36
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/components/index.ts
@@ -0,0 +1,22 @@
+/**
+ * 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.
+ */
+
+export { FoldersToolbarComponent } from './FoldersToolbarComponent';
+export { ResetConfirmModal } from './ResetConfirmModal';
+export { DragOverlayContent } from './DragOverlayContent';
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts b/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts
new file mode 100644
index 00000000000..fc9f4d189f8
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/constants.ts
@@ -0,0 +1,60 @@
+/**
+ * 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 {
+ DatasourceFolder,
+ DatasourceFolderItem,
+} from 'src/explore/components/DatasourcePanel/types';
+import { FoldersEditorItemType } from '../types';
+
+// Default folder UUIDs
+export const DEFAULT_METRICS_FOLDER_UUID =
+ '255b537d-58c8-443d-9fc1-4e4dc75047e2';
+export const DEFAULT_COLUMNS_FOLDER_UUID =
+ '83a7ae8f-2e8a-4f2b-a8cb-ebaebef95b9b';
+
+// Drag & drop constants
+export const DRAG_INDENTATION_WIDTH = 64;
+export const MAX_DEPTH = 3;
+
+// Type definitions
+export type TreeItem = DatasourceFolder | DatasourceFolderItem;
+
+export interface FlattenedTreeItem {
+ uuid: string;
+ type: FoldersEditorItemType;
+ name: string;
+ description?: string;
+ children?: TreeItem[];
+ parentId: string | null;
+ depth: number;
+ index: number;
+ collapsed?: boolean;
+}
+
+export interface ValidationResult {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+}
+
+// Helper functions
+export const isDefaultFolder = (folderId: string): boolean =>
+ folderId === DEFAULT_METRICS_FOLDER_UUID ||
+ folderId === DEFAULT_COLUMNS_FOLDER_UUID;
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts
new file mode 100644
index 00000000000..df7e1ea9280
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.test.ts
@@ -0,0 +1,217 @@
+/**
+ * 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 { Metric } from '@superset-ui/chart-controls';
+import { ColumnObject } from 'src/features/datasets/types';
+import {
+ DEFAULT_METRICS_FOLDER_UUID,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+ isDefaultFolder,
+} from './constants';
+import {
+ createFolder,
+ resetToDefault,
+ filterItemsBySearch,
+ ensureDefaultFolders,
+} from './folderOperations';
+import { validateFolders } from './folderValidation';
+import { FoldersEditorItemType } from '../types';
+
+describe('folderUtils', () => {
+ const mockMetrics: Metric[] = [
+ {
+ id: 1,
+ uuid: 'metric-1',
+ metric_name: 'Test Metric 1',
+ metric_type: 'count',
+ expression: 'COUNT(*)',
+ } as Metric,
+ {
+ id: 2,
+ uuid: 'metric-2',
+ metric_name: 'Test Metric 2',
+ metric_type: 'sum',
+ expression: 'SUM(value)',
+ } as Metric,
+ ];
+
+ const mockColumns: (ColumnObject & { uuid: string })[] = [
+ {
+ id: 1,
+ uuid: 'column-1',
+ column_name: 'Test Column 1',
+ type: 'VARCHAR',
+ filterable: true,
+ groupby: true,
+ is_active: true,
+ is_dttm: false,
+ },
+ {
+ id: 2,
+ uuid: 'column-2',
+ column_name: 'Test Column 2',
+ type: 'INTEGER',
+ filterable: true,
+ groupby: true,
+ is_active: true,
+ is_dttm: false,
+ },
+ ];
+
+ describe('createFolder', () => {
+ test('should create a folder with correct properties', () => {
+ const folder = createFolder('Test Folder');
+
+ expect(folder.name).toBe('Test Folder');
+ expect(folder.type).toBe(FoldersEditorItemType.Folder);
+ expect(folder.children).toEqual([]);
+ expect(folder.uuid).toBeDefined();
+ });
+ });
+
+ describe('resetToDefault', () => {
+ test('should create default folders with correct structure', () => {
+ const result = resetToDefault(mockMetrics, mockColumns);
+
+ expect(result).toHaveLength(2);
+
+ const metricsFolder = result.find(
+ f => f.uuid === DEFAULT_METRICS_FOLDER_UUID,
+ );
+ const columnsFolder = result.find(
+ f => f.uuid === DEFAULT_COLUMNS_FOLDER_UUID,
+ );
+
+ expect(metricsFolder).toBeDefined();
+ expect(metricsFolder?.name).toBe('Metrics');
+ expect(metricsFolder?.children).toHaveLength(2);
+
+ expect(columnsFolder).toBeDefined();
+ expect(columnsFolder?.name).toBe('Columns');
+ expect(columnsFolder?.children).toHaveLength(2);
+ });
+ });
+
+ describe('filterItemsBySearch', () => {
+ test('should filter items by search term', () => {
+ const allItems = [...mockMetrics, ...mockColumns];
+ const result = filterItemsBySearch('Test Metric', allItems);
+
+ expect(result.size).toBe(2);
+ expect(result.has('metric-1')).toBe(true);
+ expect(result.has('metric-2')).toBe(true);
+ });
+
+ test('should return all items for empty search', () => {
+ const allItems = [...mockMetrics, ...mockColumns];
+ const result = filterItemsBySearch('', allItems);
+
+ expect(result.size).toBe(4);
+ });
+ });
+
+ describe('isDefaultFolder', () => {
+ test('should identify default folders by UUID', () => {
+ expect(isDefaultFolder(DEFAULT_METRICS_FOLDER_UUID)).toBe(true);
+ expect(isDefaultFolder(DEFAULT_COLUMNS_FOLDER_UUID)).toBe(true);
+ expect(isDefaultFolder('custom-folder-uuid')).toBe(false);
+ });
+ });
+
+ describe('validateFolders', () => {
+ test('should validate folders successfully', () => {
+ const folders = resetToDefault(mockMetrics, mockColumns);
+ const result = validateFolders(folders);
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ test('should allow empty folders without names', () => {
+ // Empty folders without names are valid (they get filtered out anyway)
+ const folders = [createFolder('')];
+ const result = validateFolders(folders);
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ test('should detect folders with content but no name', () => {
+ const folder = createFolder('');
+ folder.children = [
+ { uuid: 'metric-1', type: FoldersEditorItemType.Metric, name: 'Test' },
+ ];
+ const folders = [folder];
+ const result = validateFolders(folders);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain('Folder with content must have a name');
+ });
+
+ test('should detect duplicate folder names', () => {
+ const folder1 = createFolder('My Folder');
+ const folder2 = createFolder('My Folder');
+ const folders = [folder1, folder2];
+ const result = validateFolders(folders);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors.some(e => e.includes('my folder'))).toBe(true);
+ });
+
+ test('should detect duplicate folder names case-insensitively', () => {
+ const folder1 = createFolder('Test Folder');
+ const folder2 = createFolder('test folder');
+ const folders = [folder1, folder2];
+ const result = validateFolders(folders);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors.some(e => e.includes('test folder'))).toBe(true);
+ });
+ });
+
+ describe('ensureDefaultFolders', () => {
+ test('should create default folders when none exist', () => {
+ const result = ensureDefaultFolders([], mockMetrics, mockColumns);
+
+ expect(result).toHaveLength(2);
+
+ const metricsFolder = result.find(
+ f => f.uuid === DEFAULT_METRICS_FOLDER_UUID,
+ );
+ const columnsFolder = result.find(
+ f => f.uuid === DEFAULT_COLUMNS_FOLDER_UUID,
+ );
+
+ expect(metricsFolder).toBeDefined();
+ expect(columnsFolder).toBeDefined();
+ });
+
+ test('should preserve existing folders', () => {
+ const existingFolders = [createFolder('Custom Folder')];
+ const result = ensureDefaultFolders(
+ existingFolders,
+ mockMetrics,
+ mockColumns,
+ );
+
+ expect(result.length).toBeGreaterThan(2);
+ expect(result.find(f => f.name === 'Custom Folder')).toBeDefined();
+ });
+ });
+});
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts
new file mode 100644
index 00000000000..852de41f7f9
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/folderOperations.ts
@@ -0,0 +1,217 @@
+/**
+ * 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.
+ */
+
+/**
+ * Folder CRUD operations and data mutations.
+ * Handles creating, deleting, renaming, moving folders and items.
+ */
+
+import { Metric, ColumnMeta } from '@superset-ui/chart-controls';
+import { t } from '@apache-superset/core';
+import { v4 as uuidv4 } from 'uuid';
+import {
+ DatasourceFolder,
+ DatasourceFolderItem,
+} from 'src/explore/components/DatasourcePanel/types';
+import { FoldersEditorItemType } from '../types';
+import {
+ DEFAULT_METRICS_FOLDER_UUID,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+} from './constants';
+
+export const createFolder = (name: string): DatasourceFolder => ({
+ uuid: uuidv4(),
+ type: FoldersEditorItemType.Folder,
+ name,
+ children: [],
+});
+
+export const resetToDefault = (
+ metrics: Metric[],
+ columns: ColumnMeta[],
+): DatasourceFolder[] => {
+ const metricsFolder: DatasourceFolder = {
+ uuid: DEFAULT_METRICS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder,
+ name: t('Metrics'),
+ children: metrics.map(m => ({
+ type: FoldersEditorItemType.Metric as const,
+ uuid: m.uuid,
+ name: m.metric_name || '',
+ })),
+ };
+
+ const columnsFolder: DatasourceFolder = {
+ uuid: DEFAULT_COLUMNS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder,
+ name: t('Columns'),
+ children: columns.map(c => ({
+ type: FoldersEditorItemType.Column as const,
+ uuid: c.uuid,
+ name: c.column_name || '',
+ })),
+ };
+
+ return [metricsFolder, columnsFolder];
+};
+
+export const filterItemsBySearch = (
+ searchTerm: string,
+ items: Array,
+): Set => {
+ const lowerSearch = searchTerm.toLowerCase();
+ const matchingIds = new Set();
+
+ items.forEach(item => {
+ const name = 'metric_name' in item ? item.metric_name : item.column_name;
+ if (name?.toLowerCase().includes(lowerSearch)) {
+ matchingIds.add(item.uuid);
+ }
+ });
+
+ return matchingIds;
+};
+
+/**
+ * Enrich folder children with names from metrics/columns arrays
+ * API returns {uuid} only, we need to add {type, name} for display
+ */
+const enrichFolderChildren = (
+ folders: DatasourceFolder[],
+ metrics: Metric[],
+ columns: ColumnMeta[],
+): DatasourceFolder[] => {
+ const metricMap = new Map(metrics.map(m => [m.uuid, m]));
+ const columnMap = new Map(columns.map(c => [c.uuid, c]));
+
+ const enrichChildren = (
+ children: (DatasourceFolder | DatasourceFolderItem)[] | undefined,
+ ): (DatasourceFolder | DatasourceFolderItem)[] => {
+ if (!children) return [];
+
+ return children.map(child => {
+ // If it's a folder, recursively enrich its children
+ if (child.type === FoldersEditorItemType.Folder && 'children' in child) {
+ return {
+ ...child,
+ children: enrichChildren(child.children),
+ } as DatasourceFolder;
+ }
+
+ // If it's a metric/column that needs enrichment (missing name or type)
+ const needsEnrichment =
+ !('name' in child) || !child.name || !('type' in child);
+
+ if (needsEnrichment) {
+ // Try to find in metrics first
+ const metric = metricMap.get(child.uuid);
+ if (metric) {
+ return {
+ uuid: child.uuid,
+ type: FoldersEditorItemType.Metric,
+ name: metric.metric_name || '',
+ } as DatasourceFolderItem;
+ }
+
+ // Then try columns
+ const column = columnMap.get(child.uuid);
+ if (column) {
+ return {
+ uuid: child.uuid,
+ type: FoldersEditorItemType.Column,
+ name: column.column_name || '',
+ } as DatasourceFolderItem;
+ }
+ }
+
+ return child;
+ });
+ };
+
+ return folders.map(folder => ({
+ ...folder,
+ children: enrichChildren(folder.children),
+ }));
+};
+
+export const ensureDefaultFolders = (
+ folders: DatasourceFolder[],
+ metrics: Metric[],
+ columns: ColumnMeta[],
+): DatasourceFolder[] => {
+ if (folders.length === 0) {
+ return resetToDefault(metrics, columns);
+ }
+
+ const enrichedFolders = enrichFolderChildren(folders, metrics, columns);
+
+ const hasMetricsFolder = enrichedFolders.some(
+ f => f.uuid === DEFAULT_METRICS_FOLDER_UUID,
+ );
+ const hasColumnsFolder = enrichedFolders.some(
+ f => f.uuid === DEFAULT_COLUMNS_FOLDER_UUID,
+ );
+
+ const result = [...enrichedFolders];
+
+ // Build a Set of all assigned UUIDs in a single pass for O(1) lookups
+ const assignedIds = new Set();
+ const collectAssignedIds = (folder: DatasourceFolder) => {
+ if (!folder.children) return;
+ for (const child of folder.children) {
+ assignedIds.add(child.uuid);
+ if (child.type === FoldersEditorItemType.Folder && 'children' in child) {
+ collectAssignedIds(child as DatasourceFolder);
+ }
+ }
+ };
+ enrichedFolders.forEach(collectAssignedIds);
+
+ if (!hasMetricsFolder) {
+ const unassignedMetrics = metrics.filter(m => !assignedIds.has(m.uuid));
+
+ result.push({
+ uuid: DEFAULT_METRICS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder,
+ name: t('Metrics'),
+ children: unassignedMetrics.map(m => ({
+ type: FoldersEditorItemType.Metric,
+ uuid: m.uuid,
+ name: m.metric_name || '',
+ })),
+ });
+ }
+
+ if (!hasColumnsFolder) {
+ const unassignedColumns = columns.filter(c => !assignedIds.has(c.uuid));
+
+ result.push({
+ uuid: DEFAULT_COLUMNS_FOLDER_UUID,
+ type: FoldersEditorItemType.Folder,
+ name: t('Columns'),
+ children: unassignedColumns.map(c => ({
+ type: FoldersEditorItemType.Column,
+ uuid: c.uuid,
+ name: c.column_name || '',
+ })),
+ });
+ }
+
+ return result;
+};
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/folderValidation.ts b/superset-frontend/src/components/Datasource/FoldersEditor/folderValidation.ts
new file mode 100644
index 00000000000..a9430da322c
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/folderValidation.ts
@@ -0,0 +1,109 @@
+/**
+ * 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.
+ */
+
+/**
+ * Validation and constraint checking for folder operations.
+ * Determines what actions are allowed based on folder structure and types.
+ */
+
+import { t } from '@apache-superset/core';
+import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types';
+import {
+ ValidationResult,
+ DEFAULT_METRICS_FOLDER_UUID,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+} from './constants';
+
+export const validateFolders = (
+ folders: DatasourceFolder[],
+): ValidationResult => {
+ const errors: string[] = [];
+ const folderNames: string[] = [];
+
+ const collectFolderNames = (items: DatasourceFolder[]) => {
+ items.forEach(folder => {
+ if (folder.name?.trim()) {
+ folderNames.push(folder.name.trim().toLowerCase());
+ }
+
+ if (folder.children && folder.type === 'folder') {
+ const childFolders = folder.children.filter(
+ c => c.type === 'folder',
+ ) as DatasourceFolder[];
+ collectFolderNames(childFolders);
+ }
+ });
+ };
+
+ const validateRecursive = (items: DatasourceFolder[]) => {
+ items.forEach(folder => {
+ const hasContent = folder.children && folder.children.length > 0;
+ const hasNoTitle = !folder.name?.trim();
+
+ if (hasContent && hasNoTitle) {
+ errors.push(t('Folder with content must have a name'));
+ }
+
+ if (folder.uuid === DEFAULT_METRICS_FOLDER_UUID && folder.children) {
+ const hasColumns = folder.children.some(
+ child => child.type === 'column',
+ );
+ if (hasColumns) {
+ errors.push(t('Metrics folder can only contain metric items'));
+ }
+ }
+
+ if (folder.uuid === DEFAULT_COLUMNS_FOLDER_UUID && folder.children) {
+ const hasMetrics = folder.children.some(
+ child => child.type === 'metric',
+ );
+ if (hasMetrics) {
+ errors.push(t('Columns folder can only contain column items'));
+ }
+ }
+
+ if (folder.children && folder.type === 'folder') {
+ const childFolders = folder.children.filter(
+ c => c.type === 'folder',
+ ) as DatasourceFolder[];
+ validateRecursive(childFolders);
+ }
+ });
+ };
+
+ collectFolderNames(folders);
+
+ const nameCounts = new Map();
+ folderNames.forEach(name => {
+ nameCounts.set(name, (nameCounts.get(name) || 0) + 1);
+ });
+ nameCounts.forEach((count, name) => {
+ if (count > 1) {
+ errors.push(t('Duplicate folder name: %s', name));
+ }
+ });
+
+ validateRecursive(folders);
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings: [],
+ };
+};
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useAutoScroll.ts b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useAutoScroll.ts
new file mode 100644
index 00000000000..a9da33039d5
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useAutoScroll.ts
@@ -0,0 +1,191 @@
+/**
+ * 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 { useEffect, useRef } from 'react';
+import type { VariableSizeList as List } from 'react-window';
+
+// Distance from edge where auto-scroll activates (in pixels)
+const SCROLL_THRESHOLD = 80;
+// Scroll speed (pixels per 16ms frame at ~60fps)
+const BASE_SCROLL_SPEED = 8;
+// Maximum scroll speed multiplier when at the very edge
+const MAX_SPEED_MULTIPLIER = 3;
+
+interface UseAutoScrollOptions {
+ listRef: React.RefObject;
+ containerRef: React.RefObject;
+ isDragging: boolean;
+ listHeight: number;
+}
+
+/**
+ * Custom auto-scroll hook for virtualized lists during drag operations.
+ * This replaces dnd-kit's built-in auto-scroll which conflicts with virtualization.
+ *
+ * When the user drags near the top or bottom edge of the list container,
+ * this hook smoothly scrolls the react-window list.
+ */
+export function useAutoScroll({
+ listRef,
+ containerRef,
+ isDragging,
+ listHeight,
+}: UseAutoScrollOptions) {
+ // Use refs for all mutable state to avoid re-creating callbacks
+ const scrollStateRef = useRef({
+ direction: null as 'up' | 'down' | null,
+ speed: 0,
+ mouseY: 0,
+ rafId: null as number | null,
+ lastTime: 0,
+ isScrolling: false,
+ });
+
+ useEffect(() => {
+ if (!isDragging) {
+ // Clean up when not dragging
+ const state = scrollStateRef.current;
+ if (state.rafId !== null) {
+ cancelAnimationFrame(state.rafId);
+ state.rafId = null;
+ }
+ state.direction = null;
+ state.speed = 0;
+ return;
+ }
+
+ const state = scrollStateRef.current;
+
+ // Calculate scroll parameters based on mouse position
+ const updateScrollParams = () => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const relativeY = state.mouseY - containerRect.top;
+
+ // Near top edge - scroll up
+ if (relativeY < SCROLL_THRESHOLD && relativeY >= 0) {
+ const proximity = 1 - relativeY / SCROLL_THRESHOLD;
+ state.direction = 'up';
+ state.speed =
+ BASE_SCROLL_SPEED * (1 + proximity * (MAX_SPEED_MULTIPLIER - 1));
+ return;
+ }
+
+ // Near bottom edge - scroll down
+ if (
+ relativeY > listHeight - SCROLL_THRESHOLD &&
+ relativeY <= listHeight
+ ) {
+ const distanceFromBottom = listHeight - relativeY;
+ const proximity = 1 - distanceFromBottom / SCROLL_THRESHOLD;
+ state.direction = 'down';
+ state.speed =
+ BASE_SCROLL_SPEED * (1 + proximity * (MAX_SPEED_MULTIPLIER - 1));
+ return;
+ }
+
+ // Not in scroll zone
+ state.direction = null;
+ state.speed = 0;
+ };
+
+ // Animation frame callback - uses time-based scrolling for consistent speed
+ const scrollFrame = (currentTime: number) => {
+ const list = listRef.current;
+ const outerElement = (list as any)?._outerRef;
+
+ if (!list || !outerElement || !state.direction) {
+ // Restore pointer events when scrolling stops
+ const container = containerRef.current;
+ if (container && state.isScrolling) {
+ container.style.pointerEvents = '';
+ state.isScrolling = false;
+ }
+ state.rafId = null;
+ return;
+ }
+
+ // Disable pointer events during scroll to prevent hover recalculations
+ const container = containerRef.current;
+ if (container && !state.isScrolling) {
+ container.style.pointerEvents = 'none';
+ state.isScrolling = true;
+ }
+
+ // Calculate time delta for frame-rate independent scrolling
+ const deltaTime = state.lastTime
+ ? (currentTime - state.lastTime) / 16
+ : 1;
+ state.lastTime = currentTime;
+
+ const currentScroll = outerElement.scrollTop;
+ const maxScroll = outerElement.scrollHeight - outerElement.clientHeight;
+ const scrollAmount = state.speed * deltaTime;
+
+ let newScroll = currentScroll;
+ if (state.direction === 'up') {
+ newScroll = Math.max(0, currentScroll - scrollAmount);
+ } else if (state.direction === 'down') {
+ newScroll = Math.min(maxScroll, currentScroll + scrollAmount);
+ }
+
+ if (Math.abs(newScroll - currentScroll) > 0.5) {
+ // Use direct DOM manipulation for smoother scrolling
+ // react-window's scrollTo triggers re-renders which can cause stutter
+ outerElement.scrollTop = newScroll;
+ }
+
+ // Continue animation loop
+ state.rafId = requestAnimationFrame(scrollFrame);
+ };
+
+ // Handle mouse move - just update position, let animation loop handle scrolling
+ const handleMouseMove = (event: MouseEvent) => {
+ state.mouseY = event.clientY;
+ updateScrollParams();
+
+ // Start animation loop if entering scroll zone
+ if (state.direction && state.rafId === null) {
+ state.lastTime = 0;
+ state.rafId = requestAnimationFrame(scrollFrame);
+ }
+ };
+
+ document.addEventListener('mousemove', handleMouseMove, { passive: true });
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ if (state.rafId !== null) {
+ cancelAnimationFrame(state.rafId);
+ state.rafId = null;
+ }
+ // Restore pointer events on cleanup
+ const container = containerRef.current;
+ if (container && state.isScrolling) {
+ container.style.pointerEvents = '';
+ }
+ state.direction = null;
+ state.speed = 0;
+ state.lastTime = 0;
+ state.isScrolling = false;
+ };
+ }, [isDragging, listRef, containerRef, listHeight]);
+}
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts
new file mode 100644
index 00000000000..9da07b8a048
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useDragHandlers.ts
@@ -0,0 +1,663 @@
+/**
+ * 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 { useCallback, useMemo, useRef, useState } from 'react';
+import { t } from '@apache-superset/core';
+import {
+ UniqueIdentifier,
+ DragStartEvent,
+ DragMoveEvent,
+ DragEndEvent,
+ DragOverEvent,
+} from '@dnd-kit/core';
+import { FoldersEditorItemType } from '../../types';
+import {
+ isDefaultFolder,
+ DEFAULT_COLUMNS_FOLDER_UUID,
+ DEFAULT_METRICS_FOLDER_UUID,
+ TreeItem as TreeItemType,
+ FlattenedTreeItem,
+ DRAG_INDENTATION_WIDTH,
+ MAX_DEPTH,
+} from '../constants';
+import { buildTree, getProjection, serializeForAPI } from '../treeUtils';
+
+interface UseDragHandlersProps {
+ setItems: React.Dispatch>;
+ computeFlattenedItems: (
+ activeId: UniqueIdentifier | null,
+ ) => FlattenedTreeItem[];
+ fullFlattenedItems: FlattenedTreeItem[];
+ selectedItemIds: Set;
+ onChange: (folders: ReturnType) => void;
+ addWarningToast: (message: string) => void;
+}
+
+export function useDragHandlers({
+ setItems,
+ computeFlattenedItems,
+ fullFlattenedItems,
+ selectedItemIds,
+ onChange,
+ addWarningToast,
+}: UseDragHandlersProps) {
+ const [activeId, setActiveId] = useState(null);
+ const [overId, setOverId] = useState(null);
+ const [dragOverlayWidth, setDragOverlayWidth] = useState(null);
+ const offsetLeftRef = useRef(0);
+ // Track current drop target - use state so virtualized items can render correctly
+ const [currentDropTargetId, setCurrentDropTargetId] = useState(
+ null,
+ );
+ const [draggedItemIds, setDraggedItemIds] = useState>(new Set());
+
+ // Store the flattened items at drag start to keep them stable during drag
+ // This prevents react-window from re-rendering due to flattenedItems reference changes
+ const dragStartFlattenedItemsRef = useRef(null);
+
+ // Compute flattened items, but during drag use the stable snapshot from drag start
+ // This prevents react-window from re-rendering/re-measuring when flattenedItems changes
+ const computedFlattenedItems = useMemo(
+ () => computeFlattenedItems(activeId),
+ [computeFlattenedItems, activeId],
+ );
+
+ // Use stable items during drag to prevent scroll jumping
+ // Memoize to avoid creating new array references on every render
+ const flattenedItems = useMemo(
+ () =>
+ activeId && dragStartFlattenedItemsRef.current
+ ? dragStartFlattenedItemsRef.current
+ : computedFlattenedItems,
+ [activeId, computedFlattenedItems],
+ );
+
+ const flattenedItemsIndexMap = useMemo(() => {
+ const map = new Map();
+ flattenedItems.forEach((item, index) => {
+ map.set(item.uuid, index);
+ });
+ return map;
+ }, [flattenedItems]);
+
+ // Shared lookup maps for O(1) access - used by handleDragEnd and forbiddenDropFolderIds
+ const fullItemsByUuid = useMemo(() => {
+ const map = new Map();
+ fullFlattenedItems.forEach(item => {
+ map.set(item.uuid, item);
+ });
+ return map;
+ }, [fullFlattenedItems]);
+
+ const fullItemsIndexMap = useMemo(() => {
+ const map = new Map();
+ fullFlattenedItems.forEach((item, index) => {
+ map.set(item.uuid, index);
+ });
+ return map;
+ }, [fullFlattenedItems]);
+
+ const childrenByParentId = useMemo(() => {
+ const map = new Map();
+ fullFlattenedItems.forEach(item => {
+ if (item.parentId) {
+ const children = map.get(item.parentId) ?? [];
+ children.push(item);
+ map.set(item.parentId, children);
+ }
+ });
+ return map;
+ }, [fullFlattenedItems]);
+
+ // Shared helper to calculate max folder descendant depth
+ // Only counts folder depths, not items (columns/metrics)
+ const getMaxFolderDescendantDepth = useCallback(
+ (parentId: string, baseDepth: number): number => {
+ const children = childrenByParentId.get(parentId);
+ if (!children || children.length === 0) {
+ return baseDepth;
+ }
+ let maxDepth = baseDepth;
+ for (const child of children) {
+ if (child.type === FoldersEditorItemType.Folder) {
+ maxDepth = Math.max(maxDepth, child.depth);
+ maxDepth = Math.max(
+ maxDepth,
+ getMaxFolderDescendantDepth(child.uuid, child.depth),
+ );
+ }
+ }
+ return maxDepth;
+ },
+ [childrenByParentId],
+ );
+
+ const resetDragState = useCallback(() => {
+ setActiveId(null);
+ setOverId(null);
+ offsetLeftRef.current = 0;
+ setCurrentDropTargetId(null);
+ setDraggedItemIds(new Set());
+ setDragOverlayWidth(null);
+ // Clear the stable snapshot so next render uses fresh computed items
+ dragStartFlattenedItemsRef.current = null;
+ }, []);
+
+ const handleDragStart = ({ active }: DragStartEvent) => {
+ // Capture the current flattened items BEFORE setting activeId
+ // This ensures the list stays stable during the entire drag operation
+ dragStartFlattenedItemsRef.current = computeFlattenedItems(null);
+
+ setActiveId(active.id);
+
+ const element = active.rect.current.initial;
+ if (element) {
+ setDragOverlayWidth(element.width);
+ }
+
+ if (selectedItemIds.has(active.id as string)) {
+ setDraggedItemIds(new Set(selectedItemIds));
+ } else {
+ setDraggedItemIds(new Set([active.id as string]));
+ }
+ };
+
+ const handleDragMove = useCallback(
+ ({ delta }: DragMoveEvent) => {
+ offsetLeftRef.current = delta.x;
+
+ if (activeId && overId) {
+ if (typeof overId === 'string' && overId.endsWith('-empty')) {
+ const folderId = overId.replace('-empty', '');
+ setCurrentDropTargetId(folderId);
+ return;
+ }
+
+ const projection = getProjection(
+ flattenedItems,
+ activeId,
+ overId,
+ delta.x,
+ DRAG_INDENTATION_WIDTH,
+ flattenedItemsIndexMap,
+ );
+ const newParentId = projection?.parentId ?? null;
+ setCurrentDropTargetId(newParentId);
+ }
+ },
+ [activeId, overId, flattenedItems, flattenedItemsIndexMap],
+ );
+
+ const handleDragOver = useCallback(
+ ({ over }: DragOverEvent) => {
+ setOverId(over?.id ?? null);
+
+ if (activeId && over) {
+ if (typeof over.id === 'string' && over.id.endsWith('-empty')) {
+ const folderId = over.id.replace('-empty', '');
+ setCurrentDropTargetId(folderId);
+ return;
+ }
+
+ const projection = getProjection(
+ flattenedItems,
+ activeId,
+ over.id,
+ offsetLeftRef.current,
+ DRAG_INDENTATION_WIDTH,
+ flattenedItemsIndexMap,
+ );
+ const newParentId = projection?.parentId ?? null;
+ setCurrentDropTargetId(newParentId);
+ } else {
+ setCurrentDropTargetId(null);
+ }
+ },
+ [activeId, flattenedItems, flattenedItemsIndexMap],
+ );
+
+ const handleDragEnd = ({ active, over }: DragEndEvent) => {
+ const itemsBeingDragged = Array.from(draggedItemIds);
+ const finalOffsetLeft = offsetLeftRef.current;
+ resetDragState();
+
+ if (!over || itemsBeingDragged.length === 0) {
+ return;
+ }
+
+ let targetOverId = over.id;
+ let isEmptyDrop = false;
+ if (typeof over.id === 'string' && over.id.endsWith('-empty')) {
+ targetOverId = over.id.replace('-empty', '');
+ isEmptyDrop = true;
+
+ if (itemsBeingDragged.includes(targetOverId as string)) {
+ return;
+ }
+ }
+
+ const activeIndex = fullItemsIndexMap.get(active.id as string) ?? -1;
+ const overIndex = fullItemsIndexMap.get(targetOverId as string) ?? -1;
+
+ if (activeIndex === -1 || overIndex === -1) {
+ return;
+ }
+
+ // Use Set for O(1) lookup instead of Array.includes
+ const itemsBeingDraggedSet = new Set(itemsBeingDragged);
+ const draggedItems = fullFlattenedItems.filter((item: FlattenedTreeItem) =>
+ itemsBeingDraggedSet.has(item.uuid),
+ );
+
+ let projectedPosition = getProjection(
+ flattenedItems,
+ active.id,
+ targetOverId,
+ finalOffsetLeft,
+ DRAG_INDENTATION_WIDTH,
+ flattenedItemsIndexMap,
+ );
+
+ if (isEmptyDrop) {
+ const targetFolder = fullFlattenedItems[overIndex];
+ projectedPosition = {
+ depth: targetFolder.depth + 1,
+ maxDepth: targetFolder.depth + 1,
+ minDepth: targetFolder.depth + 1,
+ parentId: targetOverId as string,
+ };
+ }
+
+ const activeItem = fullFlattenedItems[activeIndex];
+ if (active.id === targetOverId) {
+ const newParentId = projectedPosition?.parentId ?? null;
+ const currentParentId = activeItem.parentId;
+ if (newParentId === currentParentId) {
+ return;
+ }
+ }
+
+ // Single pass to gather info about dragged items
+ let hasNonFolderItems = false;
+ let hasDraggedFolder = false;
+ let hasDraggedDefaultFolder = false;
+ for (const item of draggedItems) {
+ if (item.type === FoldersEditorItemType.Folder) {
+ hasDraggedFolder = true;
+ if (isDefaultFolder(item.uuid)) {
+ hasDraggedDefaultFolder = true;
+ }
+ } else {
+ hasNonFolderItems = true;
+ }
+ }
+
+ if (hasNonFolderItems) {
+ if (!projectedPosition || !projectedPosition.parentId) {
+ return;
+ }
+ }
+
+ if (projectedPosition && projectedPosition.parentId) {
+ const targetFolder = fullItemsByUuid.get(projectedPosition.parentId);
+
+ if (targetFolder && isDefaultFolder(targetFolder.uuid)) {
+ const isDefaultMetricsFolder =
+ targetFolder.uuid === DEFAULT_METRICS_FOLDER_UUID;
+ const isDefaultColumnsFolder =
+ targetFolder.uuid === DEFAULT_COLUMNS_FOLDER_UUID;
+
+ for (const draggedItem of draggedItems) {
+ if (draggedItem.type === FoldersEditorItemType.Folder) {
+ addWarningToast(t('Cannot nest folders in default folders'));
+ return;
+ }
+ if (
+ isDefaultMetricsFolder &&
+ draggedItem.type === FoldersEditorItemType.Column
+ ) {
+ addWarningToast(t('This folder only accepts metrics'));
+ return;
+ }
+ if (
+ isDefaultColumnsFolder &&
+ draggedItem.type === FoldersEditorItemType.Metric
+ ) {
+ addWarningToast(t('This folder only accepts columns'));
+ return;
+ }
+ }
+ }
+ }
+
+ if (hasDraggedDefaultFolder && projectedPosition?.parentId) {
+ addWarningToast(t('Default folders cannot be nested'));
+ return;
+ }
+
+ // Check max depth for folders
+ if (hasDraggedFolder && projectedPosition) {
+ for (const draggedItem of draggedItems) {
+ if (draggedItem.type === FoldersEditorItemType.Folder) {
+ const currentDepth = draggedItem.depth;
+ const maxFolderDescendantDepth = getMaxFolderDescendantDepth(
+ draggedItem.uuid,
+ currentDepth,
+ );
+ const descendantDepthOffset = maxFolderDescendantDepth - currentDepth;
+ const newDepth = projectedPosition.depth;
+ const newMaxDescendantDepth = newDepth + descendantDepthOffset;
+
+ // MAX_DEPTH is 3, meaning we allow depths 0, 1, 2 (3 levels total)
+ if (newMaxDescendantDepth >= MAX_DEPTH) {
+ addWarningToast(t('Maximum folder nesting depth reached'));
+ return;
+ }
+ }
+ }
+ }
+
+ let newItems = fullFlattenedItems;
+
+ if (projectedPosition) {
+ const depthChange = projectedPosition.depth - activeItem.depth;
+
+ const itemsToUpdate = new Map<
+ string,
+ { depth: number; parentId: string | null | undefined }
+ >();
+
+ draggedItems.forEach((item: FlattenedTreeItem) => {
+ if (item.uuid === active.id) {
+ itemsToUpdate.set(item.uuid, {
+ depth: projectedPosition.depth,
+ parentId: projectedPosition.parentId,
+ });
+ } else {
+ itemsToUpdate.set(item.uuid, {
+ depth: item.depth + depthChange,
+ parentId: projectedPosition.parentId,
+ });
+ }
+ });
+
+ const collectDescendants = (
+ parentId: string,
+ parentDepthChange: number,
+ ) => {
+ const children = childrenByParentId.get(parentId);
+ if (!children) return;
+ for (const item of children) {
+ if (!itemsToUpdate.has(item.uuid)) {
+ itemsToUpdate.set(item.uuid, {
+ depth: item.depth + parentDepthChange,
+ parentId: undefined,
+ });
+ if (item.type === FoldersEditorItemType.Folder) {
+ collectDescendants(item.uuid, parentDepthChange);
+ }
+ }
+ }
+ };
+
+ draggedItems.forEach((item: FlattenedTreeItem) => {
+ if (item.type === FoldersEditorItemType.Folder) {
+ collectDescendants(item.uuid, depthChange);
+ }
+ });
+
+ newItems = fullFlattenedItems.map((item: FlattenedTreeItem) => {
+ const update = itemsToUpdate.get(item.uuid);
+ if (update) {
+ const newParentId =
+ update.parentId === undefined ? item.parentId : update.parentId;
+ return {
+ ...item,
+ depth: update.depth,
+ parentId: newParentId,
+ };
+ }
+ return item;
+ });
+ }
+
+ const itemsToMoveIds = new Set(itemsBeingDragged);
+
+ const collectDescendantIds = (parentId: string) => {
+ const children = childrenByParentId.get(parentId);
+ if (!children) return;
+ for (const item of children) {
+ if (!itemsToMoveIds.has(item.uuid)) {
+ itemsToMoveIds.add(item.uuid);
+ if (item.type === FoldersEditorItemType.Folder) {
+ collectDescendantIds(item.uuid);
+ }
+ }
+ }
+ };
+
+ draggedItems.forEach((item: FlattenedTreeItem) => {
+ if (item.type === FoldersEditorItemType.Folder) {
+ collectDescendantIds(item.uuid);
+ }
+ });
+
+ // Indices are already in ascending order since we iterate fullFlattenedItems sequentially
+ const itemsToMoveIndices: number[] = [];
+ fullFlattenedItems.forEach((item: FlattenedTreeItem, idx: number) => {
+ if (itemsToMoveIds.has(item.uuid)) {
+ itemsToMoveIndices.push(idx);
+ }
+ });
+
+ const subtree = itemsToMoveIndices.map(idx => newItems[idx]);
+ const itemsToMoveIndicesSet = new Set(itemsToMoveIndices);
+ const remaining = newItems.filter(
+ (_: FlattenedTreeItem, idx: number) => !itemsToMoveIndicesSet.has(idx),
+ );
+
+ let insertionIndex = 0;
+
+ if (projectedPosition && projectedPosition.parentId) {
+ const parentIndex = remaining.findIndex(
+ item => item.uuid === projectedPosition.parentId,
+ );
+
+ if (parentIndex !== -1) {
+ if (isEmptyDrop) {
+ insertionIndex = parentIndex + 1;
+ } else {
+ const overItemInRemaining = remaining.findIndex(
+ item => item.uuid === targetOverId,
+ );
+
+ if (overItemInRemaining !== -1) {
+ const overItem = remaining[overItemInRemaining];
+
+ if (overItem.parentId === projectedPosition.parentId) {
+ if (activeIndex < overIndex) {
+ insertionIndex = overItemInRemaining + 1;
+ } else {
+ insertionIndex = overItemInRemaining;
+ }
+ } else if (projectedPosition.depth > overItem.depth) {
+ insertionIndex = overItemInRemaining + 1;
+ } else {
+ insertionIndex = overItemInRemaining + 1;
+ }
+ } else {
+ insertionIndex = parentIndex + 1;
+ }
+ }
+ }
+ } else {
+ let adjustedOverIndex = overIndex;
+ itemsToMoveIndices.forEach((idx: number) => {
+ if (idx < overIndex) {
+ adjustedOverIndex -= 1;
+ }
+ });
+ insertionIndex = adjustedOverIndex;
+ }
+
+ const sortedItems = [
+ ...remaining.slice(0, insertionIndex),
+ ...subtree,
+ ...remaining.slice(insertionIndex),
+ ];
+
+ // Safety check: verify all items are preserved after sorting
+ if (sortedItems.length !== fullFlattenedItems.length) {
+ // If items were lost, don't apply the change
+ return;
+ }
+
+ const newTree = buildTree(sortedItems);
+ setItems(newTree);
+ onChange(serializeForAPI(newTree));
+ };
+
+ const handleDragCancel = () => {
+ resetDragState();
+ };
+
+ const dragOverlayItems = useMemo(() => {
+ if (!activeId || draggedItemIds.size === 0) return [];
+
+ const draggedItems = fullFlattenedItems.filter((item: FlattenedTreeItem) =>
+ draggedItemIds.has(item.uuid),
+ );
+
+ return draggedItems.slice(0, 3);
+ }, [activeId, draggedItemIds, fullFlattenedItems]);
+
+ const forbiddenDropFolderIds = useMemo(() => {
+ const forbidden = new Set();
+ if (draggedItemIds.size === 0) {
+ return forbidden;
+ }
+
+ const draggedTypes = new Set();
+ let hasDraggedDefaultFolder = false;
+ let maxDraggedFolderDescendantOffset = 0;
+
+ draggedItemIds.forEach((id: string) => {
+ const item = fullItemsByUuid.get(id);
+ if (item) {
+ draggedTypes.add(item.type);
+ if (
+ item.type === FoldersEditorItemType.Folder &&
+ isDefaultFolder(item.uuid)
+ ) {
+ hasDraggedDefaultFolder = true;
+ }
+ // Track the deepest folder descendant offset for dragged folders
+ if (item.type === FoldersEditorItemType.Folder) {
+ const maxDescendantDepth = getMaxFolderDescendantDepth(
+ item.uuid,
+ item.depth,
+ );
+ const descendantOffset = maxDescendantDepth - item.depth;
+ maxDraggedFolderDescendantOffset = Math.max(
+ maxDraggedFolderDescendantOffset,
+ descendantOffset,
+ );
+ }
+ }
+ });
+
+ const hasDraggedFolder = draggedTypes.has(FoldersEditorItemType.Folder);
+
+ fullFlattenedItems.forEach((item: FlattenedTreeItem) => {
+ if (item.type !== FoldersEditorItemType.Folder) {
+ return;
+ }
+
+ const itemIsDefaultFolder = isDefaultFolder(item.uuid);
+
+ if (hasDraggedDefaultFolder && !itemIsDefaultFolder) {
+ forbidden.add(item.uuid);
+ return;
+ }
+
+ const isDefaultMetricsFolder =
+ item.uuid === DEFAULT_METRICS_FOLDER_UUID && itemIsDefaultFolder;
+ const isDefaultColumnsFolder =
+ item.uuid === DEFAULT_COLUMNS_FOLDER_UUID && itemIsDefaultFolder;
+
+ if (
+ (isDefaultMetricsFolder || isDefaultColumnsFolder) &&
+ hasDraggedFolder
+ ) {
+ forbidden.add(item.uuid);
+ return;
+ }
+
+ if (
+ isDefaultMetricsFolder &&
+ draggedTypes.has(FoldersEditorItemType.Column)
+ ) {
+ forbidden.add(item.uuid);
+ return;
+ }
+ if (
+ isDefaultColumnsFolder &&
+ draggedTypes.has(FoldersEditorItemType.Metric)
+ ) {
+ forbidden.add(item.uuid);
+ return;
+ }
+
+ // Check max depth for folders: dropping into this folder would put the item at depth + 1
+ // If that would exceed MAX_DEPTH - 1 (accounting for descendants), forbid it
+ if (hasDraggedFolder) {
+ const newFolderDepth = item.depth + 1;
+ const newMaxDescendantDepth =
+ newFolderDepth + maxDraggedFolderDescendantOffset;
+ if (newMaxDescendantDepth >= MAX_DEPTH) {
+ forbidden.add(item.uuid);
+ }
+ }
+ });
+
+ return forbidden;
+ }, [
+ draggedItemIds,
+ fullFlattenedItems,
+ fullItemsByUuid,
+ getMaxFolderDescendantDepth,
+ ]);
+
+ return {
+ isDragging: activeId !== null,
+ activeId,
+ draggedItemIds,
+ dragOverlayWidth,
+ flattenedItems,
+ dragOverlayItems,
+ forbiddenDropFolderIds,
+ currentDropTargetId,
+ fullItemsByUuid,
+ handleDragStart,
+ handleDragMove,
+ handleDragOver,
+ handleDragEnd,
+ handleDragCancel,
+ };
+}
diff --git a/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useHeightCache.ts b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useHeightCache.ts
new file mode 100644
index 00000000000..e95a4215f05
--- /dev/null
+++ b/superset-frontend/src/components/Datasource/FoldersEditor/hooks/useHeightCache.ts
@@ -0,0 +1,81 @@
+/**
+ * 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 { useCallback, useRef } from 'react';
+
+export interface HeightCache {
+ /** Get the cached height for an item, or undefined if not cached */
+ getHeight: (id: string) => number | undefined;
+ /** Set the cached height for an item */
+ setHeight: (id: string, height: number) => void;
+ /** Check if a height is cached for an item */
+ hasHeight: (id: string) => boolean;
+ /** Clear all cached heights */
+ clearCache: () => void;
+ /** Get the current version number (increments on cache changes) */
+ getVersion: () => number;
+}
+
+/**
+ * Hook that provides a cache for dynamically measured item heights.
+ * Used for items like EmptyState folders where height cannot be pre-calculated.
+ *
+ * The cache uses a ref to avoid re-renders when heights are updated.
+ * Call getVersion() to get a version number that changes when cache updates,
+ * which can be used to trigger re-renders when needed.
+ */
+export function useHeightCache(): HeightCache {
+ const cacheRef = useRef