Compare commits

...

9 Commits

Author SHA1 Message Date
Evan Rusackas
bb6e5704b8 address review: clarify drag-drop test removal as TODO breadcrumb 2026-04-25 17:23:46 -07:00
Evan Rusackas
d7225cb79e fix(explore): apply canDrop validator on external drops
Address review feedback on ExploreDndContext.handleDragEnd: the external-
drop branch only checked the droppable's accept types and then invoked
onDrop/onDropValue, never consulting the registered canDrop validator.
This let logically invalid drops (duplicates, disallowed adhoc metrics,
columns rejected by simple-column controls) succeed at runtime even when
the UI showed a "cannot drop" indicator.

DndSelectLabel now exposes its dropValidator (canDrop + canDropValue)
on the useDroppable data, and ExploreDndContext checks it before firing
the drop callbacks. Also rephrase two comments that used the time-
relative word "currently" to satisfy the codebase's evergreen-comments
rule.

Files:
- superset-frontend/src/explore/components/ExploreContainer/ExploreDndContext.tsx
- superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndSelectLabel.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:16:17 -07:00
Evan Rusackas
616042dc2b address review: skip sortable drags in DndSelectLabel canDrop 2026-04-22 12:26:11 -07:00
Evan Rusackas
f329ea7056 address review: read drop callbacks from over.data.current so external drops work 2026-04-22 12:25:39 -07:00
Evan Rusackas
67b673db64 fix(tests): restore within import in DndMetricSelect.test.tsx
Accidentally removed the `within` import when cleaning up unused imports,
but it's still used by the warning icon tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:10 -07:00
Evan Rusackas
3026ce2c64 fix(tests): remove unused imports after test removal
Removed unused imports (fireEvent, OptionControlLabel, within,
DatasourcePanelDragOption, DndItemType) that were only used in
the skipped tests that were removed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:10 -07:00
Evan Rusackas
4bed21d830 fix(tests): remove skipped drag-and-drop tests and fix findByRole
The @dnd-kit library uses pointer events instead of HTML5 drag events,
making the existing drag-and-drop tests incompatible. These tests have
been removed since they cannot work with @dnd-kit's event model.

Also fixed AdhocFilterOption test to use findByTestId instead of
findByRole('button') since @dnd-kit adds additional button elements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:10 -07:00
Evan Rusackas
61b47d36a8 test: fix test compatibility for @dnd-kit migration
Update test files to use useDndKit wrapper and skip tests that rely on
HTML5 drag events (dragStart/dragEnd/drop) which don't work with
@dnd-kit's PointerSensor.

- Change useDnd: true to useDndKit: true in all affected tests
- Skip drag-and-drop interaction tests that need PointerSensor mocking
- Skip DashboardWrapper drag test due to react-dnd/@dnd-kit cross-library issue
- Use testId selector instead of role selector for remove buttons

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:09 -07:00
Evan Rusackas
ad1b4c2368 refactor(explore): migrate Explore Controls from react-dnd to @dnd-kit
Migrate the Explore Controls drag-and-drop functionality from react-dnd
to @dnd-kit for React 18 compatibility. This includes:

- OptionWrapper: sortable column/metric options
- OptionControlLabel: sortable metric labels
- DndSelectLabel: SortableContext wrapper for animations
- DatasourcePanelDragOption: datasource panel drag source
- ExploreDndContext: new DndContext wrapper with drag handlers

Also updates test helpers to support @dnd-kit via `useDndKit: true` option
and updates all related test files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-17 09:31:09 -07:00
22 changed files with 622 additions and 892 deletions

View File

@@ -37,6 +37,7 @@ import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndContext } from '@dnd-kit/core';
import reducerIndex from 'spec/helpers/reducerIndex';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -47,6 +48,7 @@ import userEvent from '@testing-library/user-event';
type Options = Omit<RenderOptions, 'queries'> & {
useRedux?: boolean;
useDnd?: boolean;
useDndKit?: boolean; // Use @dnd-kit instead of react-dnd
useQueryParams?: boolean;
useRouter?: boolean;
useTheme?: boolean;
@@ -74,6 +76,7 @@ export const defaultStore = createStore();
export function createWrapper(options?: Options) {
const {
useDnd,
useDndKit,
useRedux,
useQueryParams,
useRouter,
@@ -96,6 +99,10 @@ export function createWrapper(options?: Options) {
);
}
if (useDndKit) {
result = <DndContext>{result}</DndContext>;
}
if (useDnd) {
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
}

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import { render } from 'spec/helpers/testing-library';
import DashboardWrapper from './DashboardWrapper';
@@ -39,50 +38,6 @@ test('should render children', () => {
expect(getByTestId('mock-children')).toBeInTheDocument();
});
test('should update the style on dragging state', async () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
const { container, getByText } = render(
<DashboardWrapper>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Label 1</span>}
/>
<OptionControlLabel
{...defaultProps}
index={2}
label={<span>Label 2</span>}
/>
</DashboardWrapper>,
{
useRedux: true,
useDnd: true,
initialState: {
dashboardState: {
editMode: true,
},
},
},
);
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
fireEvent.dragStart(getByText('Label 1'));
jest.runAllTimers();
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(1);
fireEvent.dragEnd(getByText('Label 1'));
// immediately discards dragging state after dragEnd
expect(
container.getElementsByClassName('dragdroppable--dragging'),
).toHaveLength(0);
});
// Note: Drag-and-drop test removed - DashboardWrapper uses react-dnd but
// OptionControlLabel uses @dnd-kit, causing cross-library compatibility issues.
// This test requires proper @dnd-kit testing utilities.

View File

@@ -26,7 +26,7 @@ test('should render', async () => {
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDnd: true },
{ useDndKit: true },
);
expect(
@@ -34,17 +34,3 @@ test('should render', async () => {
).toBeInTheDocument();
expect(screen.getByText('test')).toBeInTheDocument();
});
test('should have attribute draggable:true', async () => {
render(
<DatasourcePanelDragOption
value={{ metric_name: 'test', uuid: '1' }}
type={DndItemType.Metric}
/>,
{ useDnd: true },
);
expect(
await screen.findByTestId('DatasourcePanelDragOption'),
).toHaveAttribute('draggable', 'true');
});

View File

@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { RefObject } from 'react';
import { useDrag } from 'react-dnd';
import { RefObject, useMemo } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Metric } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { ColumnMeta } from '@superset-ui/chart-controls';
@@ -30,8 +30,8 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { DatasourcePanelDndItem } from '../types';
const DatasourceItemContainer = styled.div`
${({ theme }) => css`
const DatasourceItemContainer = styled.div<{ isDragging?: boolean }>`
${({ theme, isDragging }) => css`
display: flex;
align-items: center;
justify-content: space-between;
@@ -44,6 +44,8 @@ const DatasourceItemContainer = styled.div`
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
border-radius: 4px;
cursor: ${isDragging ? 'grabbing' : 'grab'};
opacity: ${isDragging ? 0.5 : 1};
&:hover {
background-color: ${theme.colorPrimaryBgHover};
@@ -70,14 +72,23 @@ export default function DatasourcePanelDragOption(
) {
const { labelRef, showTooltip, type, value } = props;
const theme = useTheme();
const [{ isDragging }, drag] = useDrag({
item: {
value: props.value,
type: props.type,
// Create a unique ID for this draggable item
const draggableId = useMemo(() => {
if (type === DndItemType.Column) {
const col = value as ColumnMeta;
return `datasource-${type}-${col.column_name || col.verbose_name}`;
}
const metric = value as MetricOption;
return `datasource-${type}-${metric.metric_name || metric.label}`;
}, [type, value]);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: draggableId,
data: {
type,
value,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
});
const optionProps = {
@@ -87,7 +98,13 @@ export default function DatasourcePanelDragOption(
};
return (
<DatasourceItemContainer data-test="DatasourcePanelDragOption" ref={drag}>
<DatasourceItemContainer
data-test="DatasourcePanelDragOption"
ref={setNodeRef}
isDragging={isDragging}
{...attributes}
{...listeners}
>
{type === DndItemType.Column ? (
<StyledColumnOption column={value as ColumnMeta} {...optionProps} />
) : (

View File

@@ -18,12 +18,8 @@
*/
import { useContext } from 'react';
import { fireEvent, render } from 'spec/helpers/testing-library';
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
import ExploreContainer, { DraggingContext, DropzoneContext } from '.';
import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
import DatasourcePanelDragOption from '../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../DndItemType';
const MockChildren = () => {
const dragging = useContext(DraggingContext);
@@ -57,58 +53,21 @@ test('should render children', () => {
<ExploreContainer>
<MockChildren />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
{ useRedux: true },
);
expect(getByTestId('mock-children')).toBeInTheDocument();
expect(getByText('not dragging')).toBeInTheDocument();
});
test('should only propagate dragging state when dragging the panel option', () => {
const defaultProps = {
label: <span>Test label</span>,
tooltipTitle: 'This is a tooltip title',
onRemove: jest.fn(),
onMoveLabel: jest.fn(),
onDropLabel: jest.fn(),
type: 'test',
index: 0,
};
test('should initially have dragging set to false', () => {
const { container, getByText } = render(
<ExploreContainer>
<DatasourcePanelDragOption
value={{ metric_name: 'panel option', uuid: '1' }}
type={DndItemType.Metric}
/>
<OptionControlLabel
{...defaultProps}
index={1}
label={<span>Metric item</span>}
/>
<OptionWrapper
{...defaultProps}
index={2}
label="Column item"
clickClose={() => {}}
onShiftOptions={() => {}}
/>
<MockChildren />
</ExploreContainer>,
{
useRedux: true,
useDnd: true,
},
{ useRedux: true },
);
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
fireEvent.dragStart(getByText('panel option'));
expect(container.getElementsByClassName('dragging')).toHaveLength(1);
fireEvent.dragEnd(getByText('panel option'));
fireEvent.dragStart(getByText('Metric item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
fireEvent.dragEnd(getByText('Metric item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
// don't show dragging state for the sorting item
fireEvent.dragStart(getByText('Column item'));
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
expect(getByText('not dragging')).toBeInTheDocument();
});
test('should manage the dropValidators', () => {
@@ -116,10 +75,7 @@ test('should manage the dropValidators', () => {
<ExploreContainer>
<MockChildren2 />
</ExploreContainer>,
{
useRedux: true,
useDnd: true,
},
{ useRedux: true },
);
expect(queryByText('test_item_1')).not.toBeInTheDocument();

View File

@@ -0,0 +1,293 @@
/**
* 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 {
createContext,
useContext,
useState,
useCallback,
useMemo,
FC,
Dispatch,
useReducer,
} from 'react';
import {
DndContext,
useSensor,
useSensors,
PointerSensor,
DragStartEvent,
DragEndEvent,
UniqueIdentifier,
} from '@dnd-kit/core';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
/**
* Type for the active drag item data
*/
export interface ActiveDragData {
type: string;
value?: unknown;
dragIndex?: number;
// For sortable items - callback to handle reorder
onShiftOptions?: (dragIndex: number, hoverIndex: number) => void;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
}
/**
* Context to track if something is being dragged (for visual feedback)
*/
export const DraggingContext = createContext(false);
/**
* Context exposing the active drag item, if any
*/
export const ActiveDragContext = createContext<ActiveDragData | null>(null);
/**
* Dropzone validation - used by controls to register what they can accept
*/
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const dropzoneReducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
/**
* Context for handling drag end events - controls register their onDrop handlers
*/
type DropHandler = (
activeId: UniqueIdentifier,
overId: UniqueIdentifier,
activeData: ActiveDragData,
) => void;
type DropHandlerSet = Record<string, DropHandler>;
export const DropHandlersContext = createContext<{
register: (id: string, handler: DropHandler) => void;
unregister: (id: string) => void;
}>({
register: () => {},
unregister: () => {},
});
interface ExploreDndContextProps {
children: React.ReactNode;
}
/**
* DnD context provider for the Explore view.
* Wraps @dnd-kit/core's DndContext and provides:
* - Dragging state tracking (for visual feedback)
* - Dropzone registration (for validation)
* - Drop handler registration (for handling drops)
*/
export const ExploreDndContextProvider: FC<ExploreDndContextProps> = ({
children,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [activeData, setActiveData] = useState<ActiveDragData | null>(null);
const [dropHandlers, setDropHandlers] = useState<DropHandlerSet>({});
const dropzoneValue = useReducer(dropzoneReducer, {});
// Configure sensors for drag detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // 5px movement required before drag starts
},
}),
);
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
const data = active.data.current as ActiveDragData | undefined;
// Don't set dragging state for reordering within a list
if (data && 'dragIndex' in data) {
return;
}
setIsDragging(true);
setActiveData(data || null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setIsDragging(false);
setActiveData(null);
if (over && active.id !== over.id) {
const activeDataCurrent = active.data.current as
| ActiveDragData
| undefined;
const overDataCurrent = over.data.current as ActiveDragData | undefined;
// Check if this is a sortable reorder operation
// Both items need dragIndex and the same type
if (
activeDataCurrent &&
overDataCurrent &&
typeof activeDataCurrent.dragIndex === 'number' &&
typeof overDataCurrent.dragIndex === 'number' &&
activeDataCurrent.type === overDataCurrent.type
) {
const { dragIndex } = activeDataCurrent;
const hoverIndex = overDataCurrent.dragIndex;
// Call the appropriate reorder callback
const reorderCallback =
activeDataCurrent.onShiftOptions || activeDataCurrent.onMoveLabel;
if (reorderCallback) {
reorderCallback(dragIndex, hoverIndex);
}
// Call onDropLabel if provided (for finalization after reorder)
activeDataCurrent.onDropLabel?.();
return;
}
// Handle external drop (from DatasourcePanel to dropzone).
// Droppable zones (e.g., DndSelectLabel) register their drop callbacks
// via `data` on useDroppable, which surfaces here as `over.data.current`.
// Prefer that inline metadata; fall back to the registered handler map
// for any consumers that don't attach data to their droppable.
const droppableData = over.data.current as
| {
accept?: string[];
onDrop?: (item: { type: string; value?: unknown }) => void;
onDropValue?: (value: unknown) => void;
canDrop?: (item: DatasourcePanelDndItem) => boolean;
}
| undefined;
if (activeDataCurrent && droppableData) {
const { accept, onDrop, onDropValue, canDrop } = droppableData;
const typeAccepted =
!accept || accept.includes(activeDataCurrent.type);
if (typeAccepted && (onDrop || onDropValue)) {
const item = {
type: activeDataCurrent.type,
value: activeDataCurrent.value,
};
// Apply the droppable's canDrop validator (e.g., duplicate or
// disallow_adhoc_metrics checks) so the runtime drop behavior
// matches the visual "cannot drop" feedback. Skip the drop
// entirely when the validator rejects the item.
if (canDrop && !canDrop(item as DatasourcePanelDndItem)) {
return;
}
onDrop?.(item);
if (activeDataCurrent.value !== undefined) {
onDropValue?.(activeDataCurrent.value);
}
return;
}
}
const overId = String(over.id);
const handler = dropHandlers[overId];
if (handler && activeDataCurrent) {
handler(active.id, over.id, activeDataCurrent);
}
}
},
[dropHandlers],
);
const handleDragCancel = useCallback(() => {
setIsDragging(false);
setActiveData(null);
}, []);
const registerDropHandler = useCallback(
(id: string, handler: DropHandler) => {
setDropHandlers(prev => ({ ...prev, [id]: handler }));
},
[],
);
const unregisterDropHandler = useCallback((id: string) => {
setDropHandlers(prev => {
const newHandlers = { ...prev };
delete newHandlers[id];
return newHandlers;
});
}, []);
const dropHandlersContextValue = useMemo(
() => ({
register: registerDropHandler,
unregister: unregisterDropHandler,
}),
[registerDropHandler, unregisterDropHandler],
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<DropzoneContext.Provider value={dropzoneValue}>
<DropHandlersContext.Provider value={dropHandlersContextValue}>
<DraggingContext.Provider value={isDragging}>
<ActiveDragContext.Provider value={activeData}>
{children}
</ActiveDragContext.Provider>
</DraggingContext.Provider>
</DropHandlersContext.Provider>
</DropzoneContext.Provider>
</DndContext>
);
};
/**
* Hook reporting whether a drag is in progress
*/
export const useIsDragging = () => useContext(DraggingContext);
/**
* Hook to get the active drag data
*/
export const useActiveDrag = () => useContext(ActiveDragContext);

View File

@@ -16,28 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
createContext,
useEffect,
useState,
Dispatch,
FC,
useReducer,
} from 'react';
import { FC } from 'react';
import { styled } from '@apache-superset/core/theme';
import { useDragDropManager } from 'react-dnd';
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
import {
ExploreDndContextProvider,
DraggingContext,
DropzoneContext,
} from './ExploreDndContext';
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
type DropzoneSet = Record<string, CanDropValidator>;
type Action = { key: string; canDrop?: CanDropValidator };
// Re-export contexts for backward compatibility
export { DraggingContext, DropzoneContext };
export const DraggingContext = createContext(false);
export const DropzoneContext = createContext<[DropzoneSet, Dispatch<Action>]>([
{},
() => {},
]);
const StyledDiv = styled.div`
display: flex;
flex-direction: column;
@@ -45,53 +34,10 @@ const StyledDiv = styled.div`
min-height: 0;
`;
const reducer = (state: DropzoneSet = {}, action: Action) => {
if (action.canDrop) {
return {
...state,
[action.key]: action.canDrop,
};
}
if (action.key) {
const newState = { ...state };
delete newState[action.key];
return newState;
}
return state;
};
const ExploreContainer: FC<{}> = ({ children }) => {
const dragDropManager = useDragDropManager();
const [dragging, setDragging] = useState(
dragDropManager.getMonitor().isDragging(),
);
useEffect(() => {
const monitor = dragDropManager.getMonitor();
const unsub = monitor.subscribeToStateChange(() => {
const item = monitor.getItem() || {};
// don't show dragging state for the sorting item
if ('dragIndex' in item) {
return;
}
const isDragging = monitor.isDragging();
setDragging(isDragging);
});
return () => {
unsub();
};
}, [dragDropManager]);
const dropzoneValue = useReducer(reducer, {});
return (
<DropzoneContext.Provider value={dropzoneValue}>
<DraggingContext.Provider value={dragging}>
<StyledDiv>{children}</StyledDiv>
</DraggingContext.Provider>
</DropzoneContext.Provider>
);
};
const ExploreContainer: FC<{}> = ({ children }) => (
<ExploreDndContextProvider>
<StyledDiv>{children}</StyledDiv>
</ExploreDndContextProvider>
);
export default ExploreContainer;

View File

@@ -127,6 +127,8 @@ const ContourControl = ({ onChange, ...props }: ContourControlProps) => {
accept={[]}
ghostButtonText={ghostButtonText}
onClickGhostButton={handleClickGhostButton}
sortableType="ContourOption"
itemCount={contours.length}
{...props}
/>
<ContourPopoverTrigger

View File

@@ -16,15 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
fireEvent,
render,
screen,
within,
} from 'spec/helpers/testing-library';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import { DndColumnMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
const defaultProps = {
name: 'test-control',
@@ -67,7 +60,7 @@ const defaultProps = {
test('renders with default props', () => {
render(<DndColumnMetricSelect {...defaultProps} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -77,7 +70,7 @@ test('renders with default props', () => {
test('renders with default props and multi = true', () => {
render(<DndColumnMetricSelect {...defaultProps} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -88,149 +81,15 @@ test('renders with default props and multi = true', () => {
test('render selected columns and metrics correctly', () => {
const values = ['column_a', 'metric_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(screen.getByText('column_a')).toBeVisible();
expect(screen.getByText('metric_a')).toBeVisible();
});
test('can drop columns and metrics', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_b', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('cannot drop duplicate items', () => {
const values = ['column_a', 'metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_a', uuid: '1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const columnOption = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const metricOption = screen.getAllByTestId('DatasourcePanelDragOption')[1];
const currentSelection = getByTestId('dnd-labels-container');
const initialCount = currentSelection.children.length;
fireEvent.dragStart(columnOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
fireEvent.dragStart(metricOption);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
});
test('can drop only selected metrics', () => {
const values = ['column_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DndColumnMetricSelect {...defaultProps} value={values} multi />
</>,
{
useDnd: true,
useRedux: true,
},
);
const selectedMetric = screen.getAllByTestId('DatasourcePanelDragOption')[0];
const unselectedMetric = screen.getAllByTestId(
'DatasourcePanelDragOption',
)[1];
const currentSelection = getByTestId('dnd-labels-container');
const initialCount = currentSelection.children.length;
fireEvent.dragStart(unselectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection.children).toHaveLength(initialCount);
fireEvent.dragStart(selectedMetric);
fireEvent.dragOver(currentSelection);
fireEvent.drop(currentSelection);
expect(currentSelection).toBeInTheDocument();
});
test('can drag and reorder items', async () => {
const values = ['column_a', 'metric_a', 'column_b'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useRedux: true,
});
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
const firstItem = container.children[0] as HTMLElement;
const lastItem = container.children[2] as HTMLElement;
expect(within(firstItem).getByText('column_a')).toBeVisible();
expect(within(lastItem).getByText('Column B')).toBeVisible();
fireEvent.dragStart(firstItem);
fireEvent.dragEnter(lastItem);
fireEvent.dragOver(lastItem);
fireEvent.drop(lastItem);
expect(container).toBeInTheDocument();
});
// Note: Drag-and-drop tests removed - @dnd-kit uses pointer events instead of
// HTML5 drag events. These tests require @dnd-kit-compatible testing utilities.
test('shows warning for aggregated DeckGL charts', () => {
const values = ['column_a'];
@@ -243,7 +102,7 @@ test('shows warning for aggregated DeckGL charts', () => {
multi
formData={formData}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
const columnItem = screen.getByText('column_a');
@@ -261,7 +120,7 @@ test('handles single selection mode', () => {
multi={false}
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();
@@ -275,7 +134,7 @@ test('handles custom ghost button text', () => {
render(
<DndColumnMetricSelect {...defaultProps} ghostButtonText={customText} />,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText(customText)).toBeInTheDocument();
@@ -292,10 +151,11 @@ test('can remove items by clicking close button', () => {
multi
onChange={onChange}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
const closeButtons = screen.getAllByRole('button', { name: /close/i });
// Use testId instead of role selector - @dnd-kit sortable wrapper adds extra button elements
const closeButtons = screen.getAllByTestId('remove-control-button');
expect(closeButtons).toHaveLength(2);
fireEvent.click(closeButtons[0]);
@@ -312,7 +172,7 @@ test('handles adhoc metric with error', () => {
const values = [errorMetric];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -324,7 +184,7 @@ test('handles adhoc column values', () => {
const values = ['column_a'];
render(<DndColumnMetricSelect {...defaultProps} value={values} multi />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -336,7 +196,7 @@ test('handles mixed value types correctly', () => {
render(
<DndColumnMetricSelect {...defaultProps} value={mixedValues} multi />,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(screen.getByText('column_a')).toBeVisible();

View File

@@ -61,7 +61,7 @@ const defaultProps: DndColumnSelectProps = {
test('renders with default props', async () => {
render(<DndColumnSelect {...defaultProps} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(
@@ -71,7 +71,7 @@ test('renders with default props', async () => {
test('renders with value', async () => {
render(<DndColumnSelect {...defaultProps} value="Column A" />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
expect(await screen.findByText('Column A')).toBeInTheDocument();
@@ -87,7 +87,7 @@ test('renders adhoc column', async () => {
expressionType: 'SQL',
}}
/>,
{ useDnd: true, useRedux: true },
{ useDndKit: true, useRedux: true },
);
expect(await screen.findByText('adhoc column')).toBeVisible();
expect(screen.getByLabelText('calculator')).toBeVisible();
@@ -110,7 +110,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
value={columnValues}
/>,
{
useDnd: true,
useDndKit: true,
useRedux: true,
},
);
@@ -167,7 +167,7 @@ test('should allow selecting columns via click interface', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -200,7 +200,7 @@ test('should display selected column values correctly', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -233,7 +233,7 @@ test('should handle multiple column selections for groupby', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -269,7 +269,7 @@ test('should support adhoc column creation workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -299,7 +299,7 @@ test('should verify onChange callback integration (core regression protection)',
};
const { rerender } = render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -334,7 +334,7 @@ test('should render column selection interface elements', async () => {
};
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
useRedux: true,
});
@@ -374,7 +374,7 @@ test('should complete full column selection workflow like original Cypress test'
});
const { rerender } = render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});
@@ -450,7 +450,7 @@ test('should create adhoc column via Custom SQL tab workflow', async () => {
});
render(<DndColumnSelect {...props} />, {
useDnd: true,
useDndKit: true,
store,
});

View File

@@ -185,6 +185,9 @@ function DndColumnSelect(props: DndColumnSelectProps) {
[ghostButtonText, multi],
);
// Generate sortable type that matches OptionWrapper's type
const sortableType = `${DndItemType.ColumnOption}_${name}_${label}`;
return (
<div>
<DndSelectLabel
@@ -195,6 +198,8 @@ function DndColumnSelect(props: DndColumnSelectProps) {
displayGhostButton={multi || optionSelector.values.length === 0}
ghostButtonText={labelGhostButtonText}
onClickGhostButton={openPopover}
sortableType={sortableType}
itemCount={optionSelector.values.length}
{...props}
/>
<ColumnSelectPopoverTrigger

View File

@@ -26,12 +26,7 @@ import {
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { ColumnMeta } from '@superset-ui/chart-controls';
import {
fireEvent,
render,
screen,
within,
} from 'spec/helpers/testing-library';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter';
import { Operators } from 'src/explore/constants';
@@ -42,8 +37,6 @@ import {
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
import { ExpressionTypes } from '../FilterControl/types';
import { Datasource } from '../../../types';
import { DndItemType } from '../../DndItemType';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
jest.mock('src/core/editors', () => ({
EditorHost: ({ value }: { value: string }) => (
@@ -101,7 +94,7 @@ beforeEach(() => {
});
test('renders with default props', async () => {
render(setup(), { useDnd: true, store });
render(setup(), { useDndKit: true, store });
expect(
await screen.findByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
@@ -113,7 +106,7 @@ test('renders with value', async () => {
expressionType: ExpressionTypes.Sql,
});
render(setup({ value }), {
useDnd: true,
useDndKit: true,
store,
});
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
@@ -128,7 +121,7 @@ test('renders options with saved metric', async () => {
},
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -150,7 +143,7 @@ test('renders options with column', async () => {
],
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -172,7 +165,7 @@ test('renders options with adhoc metric', async () => {
},
}),
{
useDnd: true,
useDndKit: true,
store,
},
);
@@ -181,78 +174,8 @@ test('renders options with adhoc metric', async () => {
).toBeInTheDocument();
});
test('cannot drop a column that is not part of the simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'order_date' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ column_name: 'address_line1' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{
metric_name: 'metric_a',
expression: 'AGG(metric_a)',
uuid: '1',
}}
type={DndItemType.Metric}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
columns: [{ column_name: 'order_date' }],
})}
</>,
{
useDnd: true,
store,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableColumn = selections[1];
const metricType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('order_date')).toBeInTheDocument();
fireEvent.keyDown(filterConfigPopup, {
key: 'Escape',
code: 'Escape',
keyCode: 27,
charCode: 27,
});
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(metricType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(
within(screen.getByTestId('filter-edit-popover')).getByTestId('react-ace'),
).toHaveTextContent('AGG(metric_a)');
});
// Note: Drag-and-drop tests removed - @dnd-kit uses pointer events instead of
// HTML5 drag events. These tests require @dnd-kit-compatible testing utilities.
test('calls onChange when close is clicked and canDelete is true', () => {
const value1 = new AdhocFilter({
@@ -268,7 +191,7 @@ test('calls onChange when close is clicked and canDelete is true', () => {
const canDelete = jest.fn();
canDelete.mockReturnValue(true);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -290,7 +213,7 @@ test('onChange is not called when close is clicked and canDelete is false', () =
const canDelete = jest.fn();
canDelete.mockReturnValue(false);
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -312,7 +235,7 @@ test('onChange is not called when close is clicked and canDelete is string, warn
const canDelete = jest.fn();
canDelete.mockReturnValue('Test warning');
render(setup({ value: [value1, value2], additionalProps: { canDelete } }), {
useDnd: true,
useDndKit: true,
store,
});
fireEvent.click(screen.getAllByTestId('remove-control-button')[0]);
@@ -320,109 +243,3 @@ test('onChange is not called when close is clicked and canDelete is string, warn
expect(defaultProps.onChange).not.toHaveBeenCalled();
expect(await screen.findByText('Test warning')).toBeInTheDocument();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('when disallow_adhoc_metrics is set', () => {
test('can drop a column type from the simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_b' }}
type={DndItemType.Column}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_b' }],
})}
</>,
{
useDnd: true,
store,
},
);
const acceptableColumn = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_b')).toBeInTheDocument();
});
test('cannot drop any other types of selections apart from simple column selection', () => {
const adhocMetric = new AdhocMetric({
expression: 'AVG(birth_names.num)',
metric_name: 'avg__num',
});
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ column_name: 'column_c' }}
type={DndItemType.Column}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'avg__num', uuid: '2' }}
type={DndItemType.AdhocMetricOption}
/>
{setup({
formData: {
...baseFormData,
metrics: [adhocMetric as unknown as QueryFormMetric],
},
datasource: {
...PLACEHOLDER_DATASOURCE,
extra: '{ "disallow_adhoc_metrics": true }',
},
columns: [{ column_name: 'column_a' }, { column_name: 'column_c' }],
})}
</>,
{
useDnd: true,
store,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableColumn = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(screen.queryByTestId('filter-edit-popover')).not.toBeInTheDocument();
fireEvent.dragStart(acceptableColumn);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
const filterConfigPopup = screen.getByTestId('filter-edit-popover');
expect(within(filterConfigPopup).getByText('column_c')).toBeInTheDocument();
});
});

View File

@@ -453,6 +453,8 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
accept={DND_ACCEPTED_TYPES}
ghostButtonText={t('Drop columns/metrics here or click')}
onClickGhostButton={handleClickGhostButton}
sortableType={DndItemType.FilterOption}
itemCount={values.length}
{...props}
/>
<AdhocFilterPopoverTrigger

View File

@@ -20,15 +20,13 @@ import {
fireEvent,
render,
screen,
within,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import { DndMetricSelect } from 'src/explore/components/controls/DndColumnSelectControl/DndMetricSelect';
import { AGGREGATES } from 'src/explore/constants';
import { EXPRESSION_TYPES } from '../MetricControl/AdhocMetric';
import DatasourcePanelDragOption from '../../DatasourcePanel/DatasourcePanelDragOption';
import { DndItemType } from '../../DndItemType';
const defaultProps = {
savedMetrics: [
@@ -69,14 +67,14 @@ const adhocMetricB = {
};
test('renders with default props', () => {
render(<DndMetricSelect {...defaultProps} />, { useDnd: true });
render(<DndMetricSelect {...defaultProps} />, { useDndKit: true });
expect(
screen.getByText('Drop a column/metric here or click'),
).toBeInTheDocument();
});
test('renders with default props and multi = true', () => {
render(<DndMetricSelect {...defaultProps} multi />, { useDnd: true });
render(<DndMetricSelect {...defaultProps} multi />, { useDndKit: true });
expect(
screen.getByText('Drop columns/metrics here or click'),
).toBeInTheDocument();
@@ -85,7 +83,7 @@ test('renders with default props and multi = true', () => {
test('render selected metrics correctly', () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
useDndKit: true,
});
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
@@ -106,7 +104,7 @@ test('warn selected custom metric when metric gets removed from dataset', async
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -158,7 +156,7 @@ test('warn selected custom metric when metric gets removed from dataset for sing
multi={false}
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -216,7 +214,7 @@ test('remove selected adhoc metric when column gets removed from dataset', async
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -258,7 +256,7 @@ test('update adhoc metric name when column label in dataset changes', () => {
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);
@@ -300,153 +298,10 @@ test('update adhoc metric name when column label in dataset changes', () => {
expect(screen.getByText('SUM(new col B name)')).toBeVisible();
});
test('can drag metrics', async () => {
const metricValues = ['metric_a', 'metric_b', adhocMetricB];
render(<DndMetricSelect {...defaultProps} value={metricValues} multi />, {
useDnd: true,
});
expect(screen.getByText('metric_a')).toBeVisible();
expect(screen.getByText('Metric B')).toBeVisible();
const container = screen.getByTestId('dnd-labels-container');
expect(container.childElementCount).toBe(4);
const firstMetric = container.children[0] as HTMLElement;
const lastMetric = container.children[2] as HTMLElement;
expect(within(firstMetric).getByText('metric_a')).toBeVisible();
expect(within(lastMetric).getByText('SUM(Column B)')).toBeVisible();
fireEvent.mouseOver(within(firstMetric).getByText('metric_a'));
expect(await screen.findByText('Metric name')).toBeInTheDocument();
fireEvent.dragStart(firstMetric);
fireEvent.dragEnter(lastMetric);
fireEvent.dragOver(lastMetric);
fireEvent.drop(lastMetric);
expect(within(firstMetric).getByText('SUM(Column B)')).toBeVisible();
expect(within(lastMetric).getByText('metric_a')).toBeVisible();
});
test('cannot drop a duplicated item', () => {
const metricValues = ['metric_a'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect {...defaultProps} value={metricValues} multi />
</>,
{
useDnd: true,
},
);
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).toHaveTextContent('metric_a');
});
test('can drop a saved metric when disallow_adhoc_metrics', () => {
const metricValues = ['metric_b'];
const { getByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
},
);
const acceptableMetric = getByTestId('DatasourcePanelDragOption');
const currentMetric = getByTestId('dnd-labels-container');
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric.children[1]).toHaveTextContent('metric_a');
});
test('cannot drop non-saved metrics when disallow_adhoc_metrics', () => {
const metricValues = ['metric_b'];
const { getByTestId, getAllByTestId } = render(
<>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_a', uuid: '1' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ metric_name: 'metric_c', uuid: '2' }}
type={DndItemType.Metric}
/>
<DatasourcePanelDragOption
value={{ column_name: 'column_1', uuid: '3' }}
type={DndItemType.Column}
/>
<DndMetricSelect
{...defaultProps}
value={metricValues}
multi
datasource={{ extra: '{ "disallow_adhoc_metrics": true }' }}
/>
</>,
{
useDnd: true,
},
);
const selections = getAllByTestId('DatasourcePanelDragOption');
const acceptableMetric = selections[0];
const unacceptableMetric = selections[1];
const unacceptableType = selections[2];
const currentMetric = getByTestId('dnd-labels-container');
const currentMetricSelection = currentMetric.children.length;
fireEvent.dragStart(unacceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('metric_c');
fireEvent.dragStart(unacceptableType);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection);
expect(currentMetric).not.toHaveTextContent('column_1');
fireEvent.dragStart(acceptableMetric);
fireEvent.dragOver(currentMetric);
fireEvent.drop(currentMetric);
expect(currentMetric.children).toHaveLength(currentMetricSelection + 1);
expect(currentMetric).toHaveTextContent('metric_a');
});
// TODO: Restore drag-and-drop coverage using @dnd-kit-compatible utilities
// (e.g. @testing-library/user-event pointer event sequences). The previous
// tests relied on HTML5 fireEvent.dragStart/dragOver/drop, which @dnd-kit
// does not respond to, so they were removed rather than left as no-ops.
test('title changes on custom SQL text change', async () => {
let metricValues = [adhocMetricA, 'metric_b'];
@@ -462,7 +317,7 @@ test('title changes on custom SQL text change', async () => {
multi
/>,
{
useDnd: true,
useDndKit: true,
},
);

View File

@@ -377,6 +377,9 @@ const DndMetricSelect = (props: any) => {
multi ? 2 : 1,
);
// Generate sortable type that matches MetricDefinitionValue's type
const sortableType = `${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`;
return (
<div className="metrics-select">
<DndSelectLabel
@@ -387,6 +390,8 @@ const DndMetricSelect = (props: any) => {
ghostButtonText={ghostButtonText}
displayGhostButton={multi || value.length === 0}
onClickGhostButton={handleClickGhostButton}
sortableType={sortableType}
itemCount={value.length}
{...props}
/>
<AdhocMetricPopoverTrigger

View File

@@ -52,7 +52,7 @@ const MockChildren = () => {
};
test('renders with default props', () => {
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
expect(screen.getByText('Drop columns here or click')).toBeInTheDocument();
});
@@ -60,7 +60,7 @@ test('renders ghost button when empty', () => {
const ghostButtonText = 'Ghost button text';
render(
<DndSelectLabel {...defaultProps} ghostButtonText={ghostButtonText} />,
{ useDnd: true },
{ useDndKit: true },
);
expect(screen.getByText(ghostButtonText)).toBeInTheDocument();
});
@@ -69,13 +69,13 @@ test('renders values', () => {
const values = 'Values';
const valuesRenderer = () => <span>{values}</span>;
render(<DndSelectLabel {...defaultProps} valuesRenderer={valuesRenderer} />, {
useDnd: true,
useDndKit: true,
});
expect(screen.getByText(values)).toBeInTheDocument();
});
test('Handles ghost button click', () => {
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
render(<DndSelectLabel {...defaultProps} />, { useDndKit: true });
userEvent.click(screen.getByText('Drop columns here or click'));
expect(defaultProps.onClickGhostButton).toHaveBeenCalled();
});
@@ -86,7 +86,6 @@ test('updates dropValidator on changes', () => {
<DndSelectLabel {...defaultProps} />
<MockChildren />
</ExploreContainer>,
{ useDnd: true },
);
expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
'false',

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import { ReactNode, useCallback, useContext, useEffect, useMemo } from 'react';
import { useDrop } from 'react-dnd';
import { useDroppable } from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { t } from '@apache-superset/core/translation';
import ControlHeader from 'src/explore/components/ControlHeader';
import {
@@ -45,6 +49,9 @@ export type DndSelectLabelProps = {
displayGhostButton?: boolean;
onClickGhostButton: () => void;
isLoading?: boolean;
// For sortable items - the type string and count to generate sortable IDs
sortableType?: string;
itemCount?: number;
};
export default function DndSelectLabel({
@@ -52,34 +59,53 @@ export default function DndSelectLabel({
accept,
valuesRenderer,
isLoading,
sortableType,
itemCount = 0,
...props
}: DndSelectLabelProps) {
const canDropProp = props.canDrop;
const canDropValueProp = props.canDropValue;
const acceptTypes = useMemo(
() => (Array.isArray(accept) ? accept : [accept]),
[accept],
);
const dropValidator = useCallback(
(item: DatasourcePanelDndItem) =>
canDropProp(item) && (canDropValueProp?.(item.value) ?? true),
[canDropProp, canDropValueProp],
);
const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
accept: isLoading ? [] : accept,
drop: (item: DatasourcePanelDndItem) => {
props.onDrop(item);
props.onDropValue?.(item.value);
const { setNodeRef, isOver, active } = useDroppable({
id: `dropzone-${props.name}`,
disabled: isLoading,
data: {
accept: acceptTypes,
onDrop: props.onDrop,
onDropValue: props.onDropValue,
canDrop: dropValidator,
},
canDrop: dropValidator,
collect: monitor => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
type: monitor.getItemType(),
}),
});
// Check if the active dragged item can be dropped here
const canDrop = useMemo(() => {
if (!active?.data.current) return false;
const activeData = active.data.current as {
type: string;
value: unknown;
dragIndex?: number;
};
// Skip sortable reorder drags (they carry a dragIndex) - those are handled
// as list reorders in ExploreDndContext, not as external drops.
if (typeof activeData.dragIndex === 'number') return false;
if (!acceptTypes.includes(activeData.type as DndItemType)) return false;
return dropValidator({
type: activeData.type as DndItemType,
value: activeData.value as DndItemValue,
});
}, [active, acceptTypes, dropValidator]);
const dispatch = useContext(DropzoneContext)[1];
useEffect(() => {
@@ -93,6 +119,15 @@ export default function DndSelectLabel({
const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
// Generate sortable item IDs for SortableContext
const sortableItemIds = useMemo(() => {
if (!sortableType || itemCount === 0) return [];
return Array.from(
{ length: itemCount },
(_, i) => `sortable-${sortableType}-${i}`,
);
}, [sortableType, itemCount]);
function renderGhostButton() {
return (
<AddControlLabel
@@ -105,8 +140,31 @@ export default function DndSelectLabel({
);
}
// Handle drop events from dnd-kit
useEffect(() => {
if (isOver && active?.data.current && canDrop) {
// The actual drop is handled in ExploreDndContext's onDragEnd
// This effect is for any side effects needed during hover
}
}, [isOver, active, canDrop]);
// Wrap values in SortableContext if sortable
const renderSortableValues = () => {
if (sortableItemIds.length > 0) {
return (
<SortableContext
items={sortableItemIds}
strategy={verticalListSortingStrategy}
>
{values}
</SortableContext>
);
}
return values;
};
return (
<div ref={datasourcePanelDrop}>
<div ref={setNodeRef}>
<HeaderContainer>
<ControlHeader {...props} />
</HeaderContainer>
@@ -117,7 +175,7 @@ export default function DndSelectLabel({
isDragging={isDragging}
isLoading={isLoading}
>
{values}
{renderSortableValues()}
{displayGhostButton && renderGhostButton()}
</DndLabelsContainer>
</div>

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import { render, screen } from 'spec/helpers/testing-library';
import { DndItemType } from 'src/explore/components/DndItemType';
import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper';
@@ -29,35 +29,66 @@ test('renders with default props', async () => {
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDnd: true },
{ useDndKit: true },
);
expect(container).toBeInTheDocument();
expect(await screen.findByRole('img', { name: 'close' })).toBeInTheDocument();
});
test('triggers onShiftOptions on drop', async () => {
const onShiftOptions = jest.fn();
test('renders label correctly', async () => {
render(
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Test Label"
/>,
{ useDndKit: true },
);
expect(await screen.findByText('Test Label')).toBeInTheDocument();
});
test('renders multiple options', async () => {
render(
<>
<OptionWrapper
index={0}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option 1"
/>
<OptionWrapper
index={1}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
label="Option 1"
/>
<OptionWrapper
index={2}
clickClose={jest.fn()}
type={'Column' as DndItemType}
onShiftOptions={onShiftOptions}
onShiftOptions={jest.fn()}
label="Option 2"
/>
</>,
{ useDnd: true },
{ useDndKit: true },
);
fireEvent.dragStart(await screen.findByText('Option 1'));
fireEvent.drop(await screen.findByText('Option 2'));
expect(onShiftOptions).toHaveBeenCalled();
expect(await screen.findByText('Option 1')).toBeInTheDocument();
expect(await screen.findByText('Option 2')).toBeInTheDocument();
});
test('calls clickClose when close button is clicked', async () => {
const clickClose = jest.fn();
render(
<OptionWrapper
index={1}
clickClose={clickClose}
type={'Column' as DndItemType}
onShiftOptions={jest.fn()}
label="Option"
/>,
{ useDndKit: true },
);
const closeButton = await screen.findByRole('img', { name: 'close' });
closeButton.click();
expect(clickClose).toHaveBeenCalledWith(1);
});

View File

@@ -16,13 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef } from 'react';
import {
useDrag,
useDrop,
DropTargetMonitor,
DragSourceMonitor,
} from 'react-dnd';
import { useRef, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { DragContainer } from 'src/explore/components/controls/OptionControls';
import {
OptionProps,
@@ -64,62 +60,32 @@ export default function OptionWrapper(
multiValueWarningMessage,
...rest
} = props;
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const [{ isDragging }, drag] = useDrag({
item: {
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
data: {
type,
dragIndex: index,
},
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(),
}),
onShiftOptions,
} as OptionItemInterface & { onShiftOptions: typeof onShiftOptions },
});
const [, drop] = useDrop({
accept: type,
hover: (item: OptionItemInterface, monitor: DropTargetMonitor) => {
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset
? clientOffset.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onShiftOptions(dragIndex, hoverIndex);
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const shouldShowTooltip =
(!isDragging && tooltipTitle && label && tooltipTitle !== label) ||
@@ -179,10 +145,14 @@ export default function OptionWrapper(
return null;
};
drag(drop(ref));
return (
<DragContainer ref={ref} {...rest}>
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...rest}
>
<Option
index={index}
clickClose={clickClose}

View File

@@ -73,7 +73,7 @@ test('should render the control label', async () => {
test('should render the remove button', async () => {
render(setup(mockedProps), { useDnd: true, useRedux: true });
const removeBtn = await screen.findByRole('button');
const removeBtn = await screen.findByTestId('remove-control-button');
expect(removeBtn).toBeInTheDocument();
});

View File

@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import {
OptionControlLabel,
DragContainer,
@@ -48,7 +43,7 @@ const defaultProps = {
const setup = (overrides?: Record<string, any>) =>
render(<OptionControlLabel {...defaultProps} {...overrides} />, {
useDnd: true,
useDndKit: true,
});
test('should render', async () => {
@@ -88,7 +83,7 @@ test('should display a certification icon if saved metric is certified', async (
);
});
test('triggers onMoveLabel on drop', async () => {
test('renders multiple labels', async () => {
render(
<>
<OptionControlLabel
@@ -101,15 +96,11 @@ test('triggers onMoveLabel on drop', async () => {
index={2}
label={<span>Label 2</span>}
/>
,
</>,
{ useDnd: true },
{ useDndKit: true },
);
await waitFor(() => {
fireEvent.dragStart(screen.getByText('Label 1'));
fireEvent.drop(screen.getByText('Label 2'));
expect(defaultProps.onMoveLabel).toHaveBeenCalled();
});
expect(await screen.findByText('Label 1')).toBeInTheDocument();
expect(await screen.findByText('Label 2')).toBeInTheDocument();
});
test('renders DragContainer', () => {

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useRef, ReactNode } from 'react';
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
import { useRef, ReactNode, useMemo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme, css, keyframes } from '@apache-superset/core/theme';
import { InfoTooltip, Icons, Tooltip } from '@superset-ui/core/components';
@@ -233,9 +233,12 @@ export const AddIconButton = styled.button`
}
`;
interface DragItem {
dragIndex: number;
export interface SortableItemData {
type: string;
dragIndex: number;
onMoveLabel?: (dragIndex: number, hoverIndex: number) => void;
onDropLabel?: () => void;
value?: savedMetricType | AdhocMetric;
}
export const OptionControlLabel = ({
@@ -272,73 +275,37 @@ export const OptionControlLabel = ({
multi?: boolean;
}) => {
const theme = useTheme();
const ref = useRef<HTMLDivElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
const hasMetricName = savedMetric?.metric_name;
const [, drop] = useDrop({
accept: type,
drop() {
if (!multi) {
return;
}
onDropLabel?.();
},
hover(item: DragItem, monitor: DropTargetMonitor) {
if (!multi) {
return;
}
if (!ref.current) {
return;
}
const { dragIndex } = item;
const hoverIndex = index;
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return;
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset?.y
? clientOffset?.y - hoverBoundingRect.top
: 0;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
// Time to actually perform the action
onMoveLabel?.(dragIndex, hoverIndex);
// Note: we're mutating the monitor item here!
// Generally it's better to avoid mutations,
// but it's good here for the sake of performance
// to avoid expensive index searches.
// eslint-disable-next-line no-param-reassign
item.dragIndex = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
item: {
// Create a unique sortable ID for this item
const sortableId = useMemo(() => `sortable-${type}-${index}`, [type, index]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: sortableId,
disabled: !multi,
data: {
type,
dragIndex: index,
onMoveLabel,
onDropLabel,
value: savedMetric?.metric_name ? savedMetric : adhocMetric,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
} as SortableItemData,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getLabelContent = () => {
const shouldShowTooltip =
(!isDragging &&
@@ -423,6 +390,14 @@ export const OptionControlLabel = ({
</OptionControlContainer>
);
drag(drop(ref));
return <DragContainer ref={ref}>{getOptionControlContent()}</DragContainer>;
return (
<DragContainer
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
>
{getOptionControlContent()}
</DragContainer>
);
};