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;