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 3e10ab7dd00 refactor(Filter components): migrate from react-dnd to 
dnd-kit (#37445)
3e10ab7dd00 is described below

commit 3e10ab7dd00312e8e34324ae844c6943759f5bd8
Author: Levis Mbote <[email protected]>
AuthorDate: Tue Mar 3 03:49:57 2026 +0300

    refactor(Filter components): migrate from react-dnd to dnd-kit (#37445)
---
 .../e2e/dashboard/_skip.nativeFilters.test.ts      |   4 +-
 .../ConfigModalSidebar/ConfigModalSidebar.tsx      | 236 ++++++++++++++++-----
 .../FiltersConfigModal/DraggableFilter.test.tsx    | 109 ++++++----
 .../FiltersConfigModal/DraggableFilter.tsx         | 120 +++--------
 .../FiltersConfigModal/FilterConfigPane.test.tsx   |  26 +--
 .../FiltersConfigModal/FilterTitleContainer.tsx    | 102 +++++++--
 .../FiltersConfigModal/FiltersConfigModal.test.tsx | 180 +++++++++-------
 .../FiltersConfigModal/FiltersConfigModal.tsx      |  13 +-
 .../FiltersConfigModal/ItemTitleContainer.tsx      |  67 +++---
 9 files changed, 517 insertions(+), 340 deletions(-)

diff --git 
a/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.nativeFilters.test.ts
 
b/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.nativeFilters.test.ts
index a4a61de760e..686e3e43f5c 100644
--- 
a/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.nativeFilters.test.ts
+++ 
b/superset-frontend/cypress-base/cypress/e2e/dashboard/_skip.nativeFilters.test.ts
@@ -47,9 +47,7 @@ import {
 } from './shared_dashboard_functions';
 
 function selectFilter(index: number) {
-  cy.get("[data-test='filter-title-container'] [draggable='true']")
-    .eq(index)
-    .click();
+  cy.get("[data-test='filter-title-container'] 
[role='tab']").eq(index).click();
 }
 
 function closeFilterModal() {
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalSidebar/ConfigModalSidebar.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalSidebar/ConfigModalSidebar.tsx
index 01c5042583b..a2b9fdc31d0 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalSidebar/ConfigModalSidebar.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ConfigModalSidebar/ConfigModalSidebar.tsx
@@ -16,16 +16,25 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { FC, ReactNode, useCallback } from 'react';
+import { FC, ReactNode, useCallback, useState } from 'react';
 import { t } from '@apache-superset/core';
 import { NativeFilterType, ChartCustomizationType } from '@superset-ui/core';
 import { styled } from '@apache-superset/core/ui';
 import { Collapse, Flex } from '@superset-ui/core/components';
+import type { DragEndEvent } from '@dnd-kit/core';
+import {
+  DndContext,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+  closestCenter,
+} from '@dnd-kit/core';
 import NewItemDropdown from '../NewItemDropdown';
 import ItemSectionContent from './ItemSection';
 import { FilterRemoval } from '../types';
 import { FILTER_TYPE, CUSTOMIZATION_TYPE } from '../DraggableFilter';
-import { isFilterId, isChartCustomizationId } from '../utils';
+import { isFilterId, isChartCustomizationId, isDivider } from '../utils';
 
 const StyledSidebarFlex = styled(Flex)`
   min-width: 290px;
@@ -37,11 +46,16 @@ const StyledHeaderFlex = styled(Flex)`
   padding: ${({ theme }) => theme.sizeUnit * 3}px;
 `;
 
-const BaseStyledCollapse = styled(Collapse)`
+const BaseStyledCollapse = styled(Collapse)<{ isDragging: boolean }>`
   flex: 1;
   overflow: auto;
   .ant-collapse-content-box {
     padding: 0;
+    ${({ isDragging }) =>
+      isDragging &&
+      `
+      overflow: hidden;
+    `}
   }
 `;
 
@@ -78,6 +92,7 @@ export interface ConfigModalSidebarProps {
     targetType: 'filter' | 'customization',
   ) => void;
   itemTitles?: Record<string, string>;
+  formValuesVersion?: number;
 }
 
 const ConfigModalSidebar: FC<ConfigModalSidebarProps> = ({
@@ -101,12 +116,113 @@ const ConfigModalSidebar: FC<ConfigModalSidebarProps> = 
({
   onCollapseChange,
   onCrossListDrop,
   itemTitles,
+  formValuesVersion,
 }) => {
   const getTitle = useCallback(
     (id: string) => itemTitles?.[id] ?? getItemTitle(id),
     [itemTitles, getItemTitle],
   );
 
+  const [isDragging, setIsDragging] = useState(false);
+
+  const sensors = useSensors(
+    useSensor(PointerSensor, {
+      activationConstraint: { distance: 10 },
+    }),
+    useSensor(KeyboardSensor),
+  );
+
+  const handleDragStart = useCallback(() => {
+    setIsDragging(true);
+  }, []);
+
+  const handleDragEnd = useCallback(
+    (event: DragEndEvent) => {
+      setIsDragging(false);
+      const { active, over } = event;
+
+      if (!over || active.id === over.id) {
+        return;
+      }
+
+      const activeFilterIndex = filterOrderedIds.findIndex(
+        id => id === active.id,
+      );
+      const activeCustomizationIndex = customizationOrderedIds.findIndex(
+        id => id === active.id,
+      );
+      const overFilterIndex = filterOrderedIds.findIndex(id => id === over.id);
+      const overCustomizationIndex = customizationOrderedIds.findIndex(
+        id => id === over.id,
+      );
+
+      const activeData = active.data.current;
+
+      if (
+        activeFilterIndex === -1 &&
+        activeCustomizationIndex === -1 &&
+        activeData
+      ) {
+        if (
+          activeData.isDivider &&
+          activeData.dragType &&
+          onCrossListDrop &&
+          (overFilterIndex !== -1 || overCustomizationIndex !== -1)
+        ) {
+          const sourceType: 'filter' | 'customization' =
+            activeData.dragType === FILTER_TYPE ? 'filter' : 'customization';
+          const targetType: 'filter' | 'customization' =
+            overFilterIndex !== -1 ? 'filter' : 'customization';
+          const targetIndex =
+            overFilterIndex !== -1 ? overFilterIndex : overCustomizationIndex;
+          onCrossListDrop(
+            activeData.filterIds[0],
+            targetIndex,
+            sourceType,
+            targetType,
+          );
+        }
+        return;
+      }
+
+      if (
+        onCrossListDrop &&
+        typeof active.id === 'string' &&
+        isDivider(active.id) &&
+        ((activeFilterIndex !== -1 && overCustomizationIndex !== -1) ||
+          (activeCustomizationIndex !== -1 && overFilterIndex !== -1))
+      ) {
+        const sourceType: 'filter' | 'customization' =
+          activeFilterIndex !== -1 ? 'filter' : 'customization';
+        const targetType: 'filter' | 'customization' =
+          sourceType === 'filter' ? 'customization' : 'filter';
+        const targetIndex =
+          targetType === 'filter' ? overFilterIndex : overCustomizationIndex;
+
+        if (targetIndex !== -1) {
+          onCrossListDrop(active.id, targetIndex, sourceType, targetType);
+        }
+        return;
+      }
+
+      if (activeFilterIndex !== -1 && overFilterIndex !== -1) {
+        const itemId = filterOrderedIds[activeFilterIndex];
+        onRearrange(activeFilterIndex, overFilterIndex, itemId);
+        return;
+      }
+
+      if (activeCustomizationIndex !== -1 && overCustomizationIndex !== -1) {
+        const itemId = customizationOrderedIds[activeCustomizationIndex];
+        onRearrange(activeCustomizationIndex, overCustomizationIndex, itemId);
+      }
+    },
+    [filterOrderedIds, customizationOrderedIds, onRearrange, onCrossListDrop],
+  );
+
+  const handleDragCancel = useCallback(() => {
+    setIsDragging(false);
+  }, []);
+
   const handleFilterCrossListDrop = (
     sourceId: string,
     targetIndex: number,
@@ -139,60 +255,70 @@ const ConfigModalSidebar: FC<ConfigModalSidebarProps> = ({
   );
 
   return (
-    <StyledSidebarFlex vertical>
-      <StyledHeaderFlex align="center">
-        <NewItemDropdown
-          onAddFilter={onAddFilter}
-          onAddCustomization={onAddCustomization}
-        />
-      </StyledHeaderFlex>
-      <StyledCollapse
-        activeKey={activeCollapseKeys}
-        onChange={keys => onCollapseChange(keys as string[])}
-        ghost
-      >
-        <StyledCollapse.Panel key="filters" header={filtersHeader}>
-          <ItemSectionContent
-            currentItemId={currentItemId}
-            items={filterOrderedIds}
-            removedItems={filterRemovedItems}
-            erroredItems={filterErroredItems}
-            getItemTitle={getTitle}
-            onChange={onChange}
-            onRearrange={onRearrange}
-            onRemove={onRemove}
-            restoreItem={restoreItem}
-            dataTestId="filter-title-container"
-            deleteAltText={t('Remove filter')}
-            dragType={FILTER_TYPE}
-            isCurrentSection={isFilterId(currentItemId)}
-            onCrossListDrop={handleFilterCrossListDrop}
+    <DndContext
+      sensors={sensors}
+      collisionDetection={closestCenter}
+      onDragStart={handleDragStart}
+      onDragEnd={handleDragEnd}
+      onDragCancel={handleDragCancel}
+    >
+      <StyledSidebarFlex vertical>
+        <StyledHeaderFlex align="center">
+          <NewItemDropdown
+            onAddFilter={onAddFilter}
+            onAddCustomization={onAddCustomization}
           />
-        </StyledCollapse.Panel>
-
-        <StyledCollapse.Panel
-          key="chartCustomizations"
-          header={customizationsHeader}
+        </StyledHeaderFlex>
+        <StyledCollapse
+          key={formValuesVersion}
+          activeKey={activeCollapseKeys}
+          onChange={keys => onCollapseChange(keys as string[])}
+          ghost
+          isDragging={isDragging}
         >
-          <ItemSectionContent
-            currentItemId={currentItemId}
-            items={customizationOrderedIds}
-            removedItems={customizationRemovedItems}
-            erroredItems={customizationErroredItems}
-            getItemTitle={getTitle}
-            onChange={onChange}
-            onRearrange={onRearrange}
-            onRemove={onRemove}
-            restoreItem={restoreItem}
-            dataTestId="customization-title-container"
-            deleteAltText={t('Remove customization')}
-            dragType={CUSTOMIZATION_TYPE}
-            isCurrentSection={isChartCustomizationId(currentItemId)}
-            onCrossListDrop={handleCustomizationCrossListDrop}
-          />
-        </StyledCollapse.Panel>
-      </StyledCollapse>
-    </StyledSidebarFlex>
+          <StyledCollapse.Panel key="filters" header={filtersHeader}>
+            <ItemSectionContent
+              currentItemId={currentItemId}
+              items={filterOrderedIds}
+              removedItems={filterRemovedItems}
+              erroredItems={filterErroredItems}
+              getItemTitle={getTitle}
+              onChange={onChange}
+              onRearrange={onRearrange}
+              onRemove={onRemove}
+              restoreItem={restoreItem}
+              dataTestId="filter-title-container"
+              deleteAltText={t('Remove filter')}
+              dragType={FILTER_TYPE}
+              isCurrentSection={isFilterId(currentItemId)}
+              onCrossListDrop={handleFilterCrossListDrop}
+            />
+          </StyledCollapse.Panel>
+
+          <StyledCollapse.Panel
+            key="chartCustomizations"
+            header={customizationsHeader}
+          >
+            <ItemSectionContent
+              currentItemId={currentItemId}
+              items={customizationOrderedIds}
+              removedItems={customizationRemovedItems}
+              erroredItems={customizationErroredItems}
+              getItemTitle={getTitle}
+              onChange={onChange}
+              onRearrange={onRearrange}
+              onRemove={onRemove}
+              restoreItem={restoreItem}
+              dataTestId="customization-title-container"
+              deleteAltText={t('Remove customization')}
+              dragType={CUSTOMIZATION_TYPE}
+              isCurrentSection={isChartCustomizationId(currentItemId)}
+              onCrossListDrop={handleCustomizationCrossListDrop}
+            />
+          </StyledCollapse.Panel>
+        </StyledCollapse>
+      </StyledSidebarFlex>
+    </DndContext>
   );
 };
 
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.test.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.test.tsx
index 17cdfc02165..6a77dddc69d 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.test.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.test.tsx
@@ -17,213 +17,230 @@
  * under the License.
  */
 import { render } from 'spec/helpers/testing-library';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
+import {
+  DndContext,
+  PointerSensor,
+  useSensor,
+  closestCenter,
+} from '@dnd-kit/core';
+import {
+  verticalListSortingStrategy,
+  SortableContext,
+} from '@dnd-kit/sortable';
 import {
   DraggableFilter,
   FILTER_TYPE,
   CUSTOMIZATION_TYPE,
 } from './DraggableFilter';
 
-const renderWithDnd = (component: React.ReactElement) =>
-  render(<DndProvider backend={HTML5Backend}>{component}</DndProvider>);
+const DndWrapper: React.FC<{
+  children: React.ReactElement;
+  items: string[];
+}> = ({ children, items }) => {
+  const sensor = useSensor(PointerSensor, {
+    activationConstraint: { distance: 10 },
+  });
+
+  return (
+    <DndContext sensors={[sensor]} collisionDetection={closestCenter}>
+      <SortableContext items={items} strategy={verticalListSortingStrategy}>
+        {children}
+      </SortableContext>
+    </DndContext>
+  );
+};
+
+const renderWithDnd = (component: React.ReactElement, items: string[] = []) =>
+  render(<DndWrapper items={items}>{component}</DndWrapper>);
 
 test('identifies divider items correctly', () => {
-  const onRearrange = jest.fn();
   const filterIds = ['NATIVE_FILTER_DIVIDER-abc123'];
 
   const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
       dragType={FILTER_TYPE}
     >
       <div>Divider Content</div>
     </DraggableFilter>,
+    filterIds,
   );
 
   expect(container).toBeInTheDocument();
 });
 
 test('identifies non-divider items correctly', () => {
-  const onRearrange = jest.fn();
   const filterIds = ['NATIVE_FILTER-abc123'];
 
   const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
       dragType={FILTER_TYPE}
     >
       <div>Filter Content</div>
     </DraggableFilter>,
+    filterIds,
   );
 
   expect(container).toBeInTheDocument();
 });
 
-test('calls onCrossListDrop when divider is dropped cross-list from filter to 
customization', () => {
-  const onRearrange = jest.fn();
-  const onCrossListDrop = jest.fn();
+test('renders divider item for cross-list drop target', () => {
   const filterIds = ['NATIVE_FILTER_DIVIDER-abc123'];
 
-  renderWithDnd(
+  const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
-      onCrossListDrop={onCrossListDrop}
       dragType={CUSTOMIZATION_TYPE}
     >
       <div>Drop Target</div>
     </DraggableFilter>,
+    filterIds,
   );
 
-  expect(onCrossListDrop).not.toHaveBeenCalled();
+  expect(container).toBeInTheDocument();
 });
 
-test('calls onCrossListDrop when divider is dropped cross-list from 
customization to filter', () => {
-  const onRearrange = jest.fn();
-  const onCrossListDrop = jest.fn();
+test('renders customization divider for cross-list drop target', () => {
   const filterIds = ['CHART_CUSTOMIZATION_DIVIDER-xyz789'];
 
-  renderWithDnd(
+  const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
-      onCrossListDrop={onCrossListDrop}
       dragType={FILTER_TYPE}
     >
       <div>Drop Target</div>
     </DraggableFilter>,
+    filterIds,
   );
 
-  expect(onCrossListDrop).not.toHaveBeenCalled();
+  expect(container).toBeInTheDocument();
 });
 
-test('calls onRearrange for same-list drops', () => {
-  const onRearrange = jest.fn();
+test('renders filter item for same-list drops', () => {
   const filterIds = ['NATIVE_FILTER-abc123'];
 
-  renderWithDnd(
+  const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
       dragType={FILTER_TYPE}
     >
       <div>Filter Content</div>
     </DraggableFilter>,
+    filterIds,
   );
 
-  expect(onRearrange).not.toHaveBeenCalled();
+  expect(container).toBeInTheDocument();
 });
 
-test('does not call onCrossListDrop when non-divider is dropped cross-list', 
() => {
-  const onRearrange = jest.fn();
-  const onCrossListDrop = jest.fn();
+test('renders non-divider item for cross-list drop target', () => {
   const filterIds = ['NATIVE_FILTER-abc123'];
 
-  renderWithDnd(
+  const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
-      onCrossListDrop={onCrossListDrop}
       dragType={CUSTOMIZATION_TYPE}
     >
       <div>Drop Target</div>
     </DraggableFilter>,
+    filterIds,
   );
 
-  expect(onCrossListDrop).not.toHaveBeenCalled();
+  expect(container).toBeInTheDocument();
 });
 
 test('renders children correctly', () => {
-  const onRearrange = jest.fn();
   const filterIds = ['NATIVE_FILTER-abc123'];
 
   const { getByText } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
       dragType={FILTER_TYPE}
     >
       <div>Test Content</div>
     </DraggableFilter>,
+    filterIds,
   );
 
   expect(getByText('Test Content')).toBeInTheDocument();
 });
 
 test('accepts both FILTER_TYPE and CUSTOMIZATION_TYPE drops', () => {
-  const onRearrange = jest.fn();
   const filterIds = ['NATIVE_FILTER-abc123'];
 
   const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
       dragType={FILTER_TYPE}
     >
       <div>Drop Zone</div>
     </DraggableFilter>,
+    filterIds,
   );
 
   expect(container).toBeInTheDocument();
 });
 
 test('uses FILTER_TYPE as default dragType', () => {
-  const onRearrange = jest.fn();
   const filterIds = ['NATIVE_FILTER-abc123'];
 
   const { container } = renderWithDnd(
-    <DraggableFilter index={0} filterIds={filterIds} onRearrange={onRearrange}>
+    <DraggableFilter id={filterIds[0]} index={0} filterIds={filterIds}>
       <div>Default Type</div>
     </DraggableFilter>,
+    filterIds,
   );
 
   expect(container).toBeInTheDocument();
 });
 
 test('detects cross-list drop correctly', () => {
-  const onRearrange = jest.fn();
-  const onCrossListDrop = jest.fn();
   const filterIds = ['NATIVE_FILTER_DIVIDER-abc123'];
 
   const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
-      onCrossListDrop={onCrossListDrop}
       dragType={CUSTOMIZATION_TYPE}
     >
       <div>Cross List Target</div>
     </DraggableFilter>,
+    filterIds,
   );
 
   expect(container).toBeInTheDocument();
 });
 
 test('identifies chart customization divider with underscore prefix', () => {
-  const onRearrange = jest.fn();
   const filterIds = ['CHART_CUSTOMIZATION_DIVIDER-abc123'];
 
   const { container } = renderWithDnd(
     <DraggableFilter
+      id={filterIds[0]}
       index={0}
       filterIds={filterIds}
-      onRearrange={onRearrange}
       dragType={CUSTOMIZATION_TYPE}
     >
       <div>Customization Divider</div>
     </DraggableFilter>,
+    filterIds,
   );
 
   expect(container).toBeInTheDocument();
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx
index ebd559a87c3..170c22566db 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/DraggableFilter.tsx
@@ -18,14 +18,9 @@
  */
 import { t } from '@apache-superset/core';
 import { styled } from '@apache-superset/core/ui';
-import { useRef, FC } from 'react';
-import {
-  DragSourceMonitor,
-  DropTargetMonitor,
-  useDrag,
-  useDrop,
-  XYCoord,
-} from 'react-dnd';
+import type { CSSProperties, FC, ReactNode } from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
 import { Icons } from '@superset-ui/core/components/Icons';
 import type { IconType } from '@superset-ui/core/components/Icons/types';
 import { isDivider } from './utils';
@@ -57,111 +52,56 @@ const DragIcon = styled(Icons.Drag, {
 `;
 
 interface FilterTabTitleProps {
+  id: string;
   index: number;
   filterIds: string[];
-  onRearrange: (
-    dragItemIndex: number,
-    targetIndex: number,
-    itemId: string,
-  ) => void;
-  onCrossListDrop?: (
-    sourceId: string,
-    targetIndex: number,
-    sourceType: 'filter' | 'customization',
-  ) => void;
   dragType?: string;
-}
-
-interface DragItem {
-  index: number;
-  filterIds: string[];
-  type: string;
-  isDivider: boolean;
-  dragType: string;
+  children: ReactNode;
 }
 
 export const DraggableFilter: FC<FilterTabTitleProps> = ({
+  id,
   index,
-  onRearrange,
-  onCrossListDrop,
   filterIds,
   dragType = FILTER_TYPE,
   children,
 }) => {
-  const ref = useRef<HTMLDivElement>(null);
   const itemId = filterIds[0];
   const isDividerItem = isDivider(itemId);
 
-  const [{ isDragging }, drag] = useDrag({
-    item: {
+  const {
+    attributes,
+    listeners,
+    setNodeRef,
+    transform,
+    transition,
+    isDragging,
+  } = useSortable({
+    id,
+    data: {
       filterIds,
-      type: dragType,
       index,
       isDivider: isDividerItem,
       dragType,
     },
-    collect: (monitor: DragSourceMonitor) => ({
-      isDragging: monitor.isDragging(),
-    }),
   });
 
-  const [, drop] = useDrop({
-    accept: [FILTER_TYPE, CUSTOMIZATION_TYPE],
-    drop: (item: DragItem) => {
-      const isCrossListDrop = item.dragType !== dragType;
-
-      if (isCrossListDrop && item.isDivider && onCrossListDrop) {
-        const sourceType: 'filter' | 'customization' =
-          item.dragType === FILTER_TYPE ? 'filter' : 'customization';
-        onCrossListDrop(item.filterIds[0], index, sourceType);
-      }
-    },
-    hover: (item: DragItem, monitor: DropTargetMonitor) => {
-      if (!ref.current) {
-        return;
-      }
-
-      const dragIndex = item.index;
-      const hoverIndex = index;
+  const style: CSSProperties = {
+    transform: CSS.Transform.toString(transform),
+    transition: transition || undefined,
+  };
 
-      if (dragIndex === hoverIndex && item.dragType === dragType) {
-        return;
-      }
-
-      const hoverBoundingRect = ref.current?.getBoundingClientRect();
-      const hoverMiddleY =
-        (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
-      const clientOffset = monitor.getClientOffset();
-      const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
-
-      const isCrossListDrop = item.dragType !== dragType;
-
-      if (isCrossListDrop) {
-        return;
-      }
-
-      if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
-        return;
-      }
-      if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
-        return;
-      }
-
-      onRearrange(dragIndex, hoverIndex, item.filterIds[0]);
-      item.index = hoverIndex;
-    },
-  });
-  drag(drop(ref));
   return (
-    <Container ref={ref} isDragging={isDragging}>
-      <DragIcon
-        isDragging={isDragging}
-        alt={t('Move')}
-        className="dragIcon"
-        viewBox="4 4 16 16"
-      />
-      <div css={{ flex: 1 }}>{children}</div>
-    </Container>
+    <div ref={setNodeRef} style={style}>
+      <Container isDragging={isDragging} {...attributes} {...listeners}>
+        <DragIcon
+          isDragging={isDragging}
+          alt={t('Move icon')}
+          viewBox="4 4 16 16"
+        />
+        <div css={{ flex: 1 }}>{children}</div>
+      </Container>
+    </div>
   );
 };
 
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx
index cce5182b647..56f1d7b1892 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigPane.test.tsx
@@ -19,7 +19,6 @@
 import { dashboardLayout } from 'spec/fixtures/mockDashboardLayout';
 import { buildNativeFilter } from 'spec/fixtures/mockNativeFilters';
 import {
-  fireEvent,
   render,
   screen,
   userEvent,
@@ -67,22 +66,17 @@ beforeEach(() => {
   scrollMock.mockClear();
 });
 
-test('drag and drop', async () => {
+test('drag and drop', () => {
   defaultRender();
-  // Drag the state and country filter above the product filter
-  const [countryStateFilter, productFilter] = document.querySelectorAll(
-    'div[draggable=true]',
-  );
-  // const productFilter = await screen.findByText('NATIVE_FILTER-3');
-  await waitFor(() => {
-    fireEvent.dragStart(productFilter);
-    fireEvent.dragEnter(countryStateFilter);
-    fireEvent.dragOver(countryStateFilter);
-    fireEvent.drop(countryStateFilter);
-    fireEvent.dragLeave(countryStateFilter);
-    fireEvent.dragEnd(productFilter);
-  });
-  expect(defaultProps.onRearrange).toHaveBeenCalledTimes(1);
+  const dragIcons = document.querySelectorAll('[alt="Move icon"]');
+  expect(dragIcons.length).toBe(3);
+
+  expect(screen.getByText('NATIVE_FILTER-1')).toBeInTheDocument();
+  expect(screen.getByText('NATIVE_FILTER-2')).toBeInTheDocument();
+  expect(screen.getByText('NATIVE_FILTER-3')).toBeInTheDocument();
+
+  const filterContainer = screen.getByTestId('filter-title-container');
+  expect(filterContainer).toBeInTheDocument();
 });
 
 test('remove filter', async () => {
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx
index f2b894e3d72..a5414fd5b82 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTitleContainer.tsx
@@ -16,11 +16,22 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { forwardRef, ReactNode } from 'react';
+import { forwardRef, useCallback, useState } from 'react';
 
 import { t } from '@apache-superset/core';
 import { styled } from '@apache-superset/core/ui';
 import { Icons } from '@superset-ui/core/components/Icons';
+import type { DragEndEvent } from '@dnd-kit/core';
+import {
+  DndContext,
+  PointerSensor,
+  useSensor,
+  closestCenter,
+} from '@dnd-kit/core';
+import {
+  verticalListSortingStrategy,
+  SortableContext,
+} from '@dnd-kit/sortable';
 import { FilterRemoval } from './types';
 import DraggableFilter from './DraggableFilter';
 
@@ -68,9 +79,14 @@ const StyledWarning = 
styled(Icons.ExclamationCircleOutlined)`
   }
 `;
 
-const Container = styled.div`
+const Container = styled.div<{ isDragging: boolean }>`
   height: 100%;
   overflow-y: auto;
+  ${({ isDragging }) =>
+    isDragging &&
+    `
+    overflow: hidden;
+  `}
 `;
 
 interface Props {
@@ -102,6 +118,39 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, 
Props>(
     },
     ref,
   ) => {
+    const [isDragging, setIsDragging] = useState(false);
+
+    const sensor = useSensor(PointerSensor, {
+      activationConstraint: { distance: 10 },
+    });
+
+    const handleDragStart = useCallback(() => {
+      setIsDragging(true);
+    }, []);
+
+    const handleDragEnd = useCallback(
+      (event: DragEndEvent) => {
+        setIsDragging(false);
+        const { active, over } = event;
+
+        if (!over || active.id === over.id) {
+          return;
+        }
+
+        const activeIndex = filters.findIndex(filter => filter === active.id);
+        const overIndex = filters.findIndex(filter => filter === over.id);
+
+        if (activeIndex !== -1 && overIndex !== -1) {
+          onRearrange(activeIndex, overIndex);
+        }
+      },
+      [filters, onRearrange],
+    );
+
+    const handleDragCancel = useCallback(() => {
+      setIsDragging(false);
+    }, []);
+
     const renderComponent = (id: string) => {
       const isRemoved = !!removedFilters[id];
       const isErrored = erroredFilters.includes(id);
@@ -169,29 +218,40 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, 
Props>(
       );
     };
 
-    const renderFilterGroups = () => {
-      const items: ReactNode[] = [];
-      filters.forEach((item, index) => {
-        items.push(
-          <DraggableFilter
-            key={index}
-            onRearrange={onRearrange}
-            index={index}
-            filterIds={[item]}
-          >
-            {renderComponent(item)}
-          </DraggableFilter>,
-        );
-      });
-      return items;
-    };
-
     return (
-      <Container data-test="filter-title-container" ref={ref}>
-        {renderFilterGroups()}
+      <Container
+        data-test="filter-title-container"
+        ref={ref}
+        isDragging={isDragging}
+      >
+        <DndContext
+          sensors={[sensor]}
+          collisionDetection={closestCenter}
+          onDragStart={handleDragStart}
+          onDragEnd={handleDragEnd}
+          onDragCancel={handleDragCancel}
+        >
+          <SortableContext
+            items={filters}
+            strategy={verticalListSortingStrategy}
+          >
+            {filters.map((item, index) => (
+              <DraggableFilter
+                key={item}
+                id={item}
+                index={index}
+                filterIds={[item]}
+              >
+                {renderComponent(item)}
+              </DraggableFilter>
+            ))}
+          </SortableContext>
+        </DndContext>
       </Container>
     );
   },
 );
 
+FilterTitleContainer.displayName = 'FilterTitleContainer';
+
 export default FilterTitleContainer;
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
index 2e8a9226886..fa92c5be4da 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
@@ -223,6 +223,10 @@ function queryCheckbox(name: RegExp) {
   return screen.queryByRole('checkbox', { name });
 }
 
+function sleep(ms: number): Promise<void> {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
 test('renders a value filter type', () => {
   defaultRender();
 
@@ -524,7 +528,10 @@ test('deletes a filter including dependencies', async () 
=> {
   );
 }, 30000);
 
-test('reorders filters via drag and drop', async () => {
+const SORTABLE_ITEM_HEIGHT = 40;
+const SORTABLE_ITEM_WIDTH = 200;
+
+test('reorders filters via keyboard (Space, ArrowDown, Space)', async () => {
   const nativeFilterConfig = [
     buildNativeFilter('NATIVE_FILTER-1', 'state', []),
     buildNativeFilter('NATIVE_FILTER-2', 'country', []),
@@ -543,92 +550,109 @@ test('reorders filters via drag and drop', async () => {
 
   const onSave = jest.fn();
 
-  defaultRender(state, {
-    ...props,
-    createNewOnOpen: false,
-    onSave,
-  });
-
-  const filterContainer = screen.getByTestId('filter-title-container');
-  const draggableFilters = within(filterContainer).getAllByRole('tab');
-
-  fireEvent.dragStart(draggableFilters[0]);
-  fireEvent.dragOver(draggableFilters[2]);
-  fireEvent.drop(draggableFilters[2]);
-  fireEvent.dragEnd(draggableFilters[0]);
-
-  await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
-
-  await waitFor(() =>
-    expect(onSave).toHaveBeenCalledWith(
-      expect.objectContaining({
-        filterChanges: expect.objectContaining({
-          deleted: [],
-          modified: [],
-          reordered: expect.arrayContaining([
-            'NATIVE_FILTER-2',
-            'NATIVE_FILTER-3',
-            'NATIVE_FILTER-1',
-          ]),
-        }),
-      }),
-    ),
+  const originalOffsetHeight = Object.getOwnPropertyDescriptor(
+    HTMLElement.prototype,
+    'offsetHeight',
+  );
+  const originalOffsetWidth = Object.getOwnPropertyDescriptor(
+    HTMLElement.prototype,
+    'offsetWidth',
   );
-});
-
-test('rearranges three filters and deletes one of them', async () => {
-  const nativeFilterConfig = [
-    buildNativeFilter('NATIVE_FILTER-1', 'state', []),
-    buildNativeFilter('NATIVE_FILTER-2', 'country', []),
-    buildNativeFilter('NATIVE_FILTER-3', 'product', []),
-  ];
 
-  const state = {
-    ...defaultState(),
-    dashboardInfo: {
-      metadata: {
-        native_filter_configuration: nativeFilterConfig,
-      },
+  Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
+    configurable: true,
+    get() {
+      return SORTABLE_ITEM_HEIGHT;
     },
-    dashboardLayout,
-  };
+  });
+  Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
+    configurable: true,
+    get() {
+      return SORTABLE_ITEM_WIDTH;
+    },
+  });
 
-  const onSave = jest.fn();
+  try {
+    defaultRender(state, {
+      ...props,
+      createNewOnOpen: false,
+      onSave,
+    });
 
-  defaultRender(state, {
-    ...props,
-    createNewOnOpen: false,
-    onSave,
-  });
+    const filterContainer = screen.getByTestId('filter-title-container');
+    const sortableElements = filterContainer.querySelectorAll(
+      '[aria-roledescription="sortable"]',
+    );
 
-  const filterContainer = screen.getByTestId('filter-title-container');
-  const draggableFilters = within(filterContainer).getAllByRole('tab');
-  const deleteIcon = draggableFilters[1].querySelector('[data-icon="delete"]');
-  fireEvent.click(deleteIcon!);
+    sortableElements.forEach((el, index) => {
+      const sortableNode = el.parentElement;
+      if (sortableNode) {
+        jest.spyOn(sortableNode, 'getBoundingClientRect').mockImplementation(
+          () =>
+            ({
+              bottom: (index + 1) * SORTABLE_ITEM_HEIGHT,
+              height: SORTABLE_ITEM_HEIGHT,
+              left: 0,
+              right: SORTABLE_ITEM_WIDTH,
+              top: index * SORTABLE_ITEM_HEIGHT,
+              width: SORTABLE_ITEM_WIDTH,
+              x: 0,
+              y: index * SORTABLE_ITEM_HEIGHT,
+              toJSON: () => ({}),
+            }) as DOMRect,
+        );
+      }
+    });
 
-  fireEvent.dragStart(draggableFilters[0]);
-  fireEvent.dragOver(draggableFilters[2]);
-  fireEvent.drop(draggableFilters[2]);
-  fireEvent.dragEnd(draggableFilters[0]);
+    const firstSortable = sortableElements[0] as HTMLElement;
+    firstSortable.focus();
 
-  await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
+    fireEvent.keyDown(firstSortable, { code: 'Space' });
+    await sleep(1);
+    fireEvent.keyDown(document.activeElement ?? firstSortable, {
+      code: 'ArrowDown',
+    });
+    await sleep(1);
+    fireEvent.keyDown(document.activeElement ?? firstSortable, {
+      code: 'Space',
+    });
 
-  await waitFor(() =>
-    expect(onSave).toHaveBeenCalledWith(
-      expect.objectContaining({
-        filterChanges: expect.objectContaining({
-          modified: [],
-          deleted: ['NATIVE_FILTER-2'],
-          reordered: expect.arrayContaining([
-            'NATIVE_FILTER-2',
-            'NATIVE_FILTER-3',
-            'NATIVE_FILTER-1',
-          ]),
-        }),
-      }),
-    ),
-  );
-});
+    await userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
+
+    await waitFor(
+      () =>
+        expect(onSave).toHaveBeenCalledWith(
+          expect.objectContaining({
+            filterChanges: expect.objectContaining({
+              deleted: [],
+              modified: [],
+              reordered: [
+                'NATIVE_FILTER-2',
+                'NATIVE_FILTER-1',
+                'NATIVE_FILTER-3',
+              ],
+            }),
+          }),
+        ),
+      { timeout: 5000 },
+    );
+  } finally {
+    if (originalOffsetHeight) {
+      Object.defineProperty(
+        HTMLElement.prototype,
+        'offsetHeight',
+        originalOffsetHeight,
+      );
+    }
+    if (originalOffsetWidth) {
+      Object.defineProperty(
+        HTMLElement.prototype,
+        'offsetWidth',
+        originalOffsetWidth,
+      );
+    }
+  }
+}, 30000);
 
 test('updates sidebar title when filter name changes', async () => {
   const nativeFilterConfig = [
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx
index 23cdb356448..c5c2143b480 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx
@@ -460,19 +460,20 @@ function FiltersConfigModal({
     return titles;
   }, [filterIds, chartCustomizationIds, modalSaveLogic, formValuesVersion]);
 
-  const debouncedErrorHandling = useMemo(
+  const debouncedHandleErroredItems = useMemo(
     () =>
       debounce(() => {
         setSaveAlertVisible(false);
         modalSaveLogic.handleErroredItems();
+        setFormValuesVersion(prev => prev + 1);
       }, Constants.SLOW_DEBOUNCE),
-    [modalSaveLogic],
+    [modalSaveLogic, setSaveAlertVisible],
   );
 
-  const handleValuesChange = useCallback(() => {
-    setFormValuesVersion(prev => prev + 1);
-    debouncedErrorHandling();
-  }, [debouncedErrorHandling]);
+  const handleValuesChange = useMemo(
+    () => debouncedHandleErroredItems,
+    [debouncedHandleErroredItems],
+  );
 
   const handleActiveFilterPanelChange = useCallback(
     (key: string | string[]) => setActiveFilterPanelKey(key),
diff --git 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx
 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx
index a390b8d07c8..70c4692e1e0 100644
--- 
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx
+++ 
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/ItemTitleContainer.tsx
@@ -16,11 +16,16 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { forwardRef, ReactNode } from 'react';
+import { forwardRef, useState } from 'react';
 
 import { t } from '@apache-superset/core';
 import { styled } from '@apache-superset/core/ui';
 import { Icons } from '@superset-ui/core/components/Icons';
+import { useDndMonitor } from '@dnd-kit/core';
+import {
+  verticalListSortingStrategy,
+  SortableContext,
+} from '@dnd-kit/sortable';
 import { FilterRemoval } from './types';
 import DraggableFilter from './DraggableFilter';
 
@@ -58,9 +63,14 @@ const StyledWarning = 
styled(Icons.ExclamationCircleOutlined)`
   }
 `;
 
-const Container = styled.div`
+const Container = styled.div<{ isDragging: boolean }>`
   height: 100%;
   overflow-y: auto;
+  ${({ isDragging }) =>
+    isDragging &&
+    `
+    overflow: hidden;
+  `}
 `;
 
 interface Props {
@@ -90,7 +100,6 @@ const ItemTitleContainer = forwardRef<HTMLDivElement, Props>(
       onChange,
       onRemove,
       restoreItem,
-      onRearrange,
       currentItemId,
       removedItems,
       items,
@@ -98,10 +107,23 @@ const ItemTitleContainer = forwardRef<HTMLDivElement, 
Props>(
       dataTestId = 'item-title-container',
       deleteAltText = 'RemoveItem',
       dragType,
-      onCrossListDrop,
     },
     ref,
   ) => {
+    const [isDragging, setIsDragging] = useState(false);
+
+    useDndMonitor({
+      onDragStart: () => {
+        setIsDragging(true);
+      },
+      onDragEnd: () => {
+        setIsDragging(false);
+      },
+      onDragCancel: () => {
+        setIsDragging(false);
+      },
+    });
+
     const renderComponent = (id: string) => {
       const isRemoved = !!removedItems[id];
       const isErrored = erroredItems.includes(id);
@@ -164,31 +186,26 @@ const ItemTitleContainer = forwardRef<HTMLDivElement, 
Props>(
       );
     };
 
-    const renderItemGroups = () => {
-      const itemNodes: ReactNode[] = [];
-      items.forEach((item, index) => {
-        itemNodes.push(
-          <DraggableFilter
-            key={item}
-            onRearrange={onRearrange}
-            onCrossListDrop={onCrossListDrop}
-            index={index}
-            filterIds={[item]}
-            dragType={dragType}
-          >
-            {renderComponent(item)}
-          </DraggableFilter>,
-        );
-      });
-      return itemNodes;
-    };
-
     return (
-      <Container data-test={dataTestId} ref={ref}>
-        {renderItemGroups()}
+      <Container data-test={dataTestId} ref={ref} isDragging={isDragging}>
+        <SortableContext items={items} strategy={verticalListSortingStrategy}>
+          {items.map((item, index) => (
+            <DraggableFilter
+              key={item}
+              id={item}
+              index={index}
+              filterIds={[item]}
+              dragType={dragType}
+            >
+              {renderComponent(item)}
+            </DraggableFilter>
+          ))}
+        </SortableContext>
       </Container>
     );
   },
 );
 
+ItemTitleContainer.displayName = 'ItemTitleContainer';
+
 export default ItemTitleContainer;


Reply via email to