diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts index 1f228ac0ac..a2267a5b32 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts @@ -1,10 +1,11 @@ -import { KeyboardSensor, PointerSensor } from '@dnd-kit/react'; +import { KeyboardSensor } from '@dnd-kit/react'; +import { PointerSensorWithSourceGuard } from '@/ui/utilities/drag-and-drop/sensors/PointerSensorWithSourceGuard'; import { getDragActivationConstraints } from '@/ui/utilities/drag-and-drop/utils/getDragActivationConstraints'; import { shouldPreventDragActivation } from '@/ui/utilities/drag-and-drop/utils/shouldPreventDragActivation'; export const DND_KIT_SENSORS = [ - PointerSensor.configure({ + PointerSensorWithSourceGuard.configure({ activationConstraints: getDragActivationConstraints, preventActivation: shouldPreventDragActivation, }), diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/sensors/PointerSensorWithSourceGuard.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/sensors/PointerSensorWithSourceGuard.ts new file mode 100644 index 0000000000..97594106cc --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/sensors/PointerSensorWithSourceGuard.ts @@ -0,0 +1,19 @@ +import { type Draggable } from '@dnd-kit/dom'; +import { PointerSensor } from '@dnd-kit/react'; + +// A drag only activates once the pointer travels past the activation +// constraints, so a re-render can unregister the pressed draggable between +// pointerdown and activation: virtualized rows remounting under new sortable +// ids, a widget or tab remounting while a page loads. The base sensor then +// throws "Cannot start a drag operation without a drag source"; there is +// nothing left to drag, so the gesture is canceled instead. +export class PointerSensorWithSourceGuard extends PointerSensor { + protected handleStart(source: Draggable, event: PointerEvent): void { + if (!this.manager.registry.draggables.has(source.id)) { + this.handleCancel(event); + return; + } + + super.handleStart(source, event); + } +} diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/sensors/__tests__/PointerSensorWithSourceGuard.test.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/sensors/__tests__/PointerSensorWithSourceGuard.test.ts new file mode 100644 index 0000000000..6488105e9e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/sensors/__tests__/PointerSensorWithSourceGuard.test.ts @@ -0,0 +1,96 @@ +import { DragDropManager, Draggable } from '@dnd-kit/dom'; +import { PointerSensor } from '@dnd-kit/react'; + +import { PointerSensorWithSourceGuard } from '@/ui/utilities/drag-and-drop/sensors/PointerSensorWithSourceGuard'; + +const INITIAL_COORDINATES = { x: 0, y: 0 }; + +class TestablePointerSensorWithSourceGuard extends PointerSensorWithSourceGuard { + public wasCanceled = false; + + public startFromActivation(source: Draggable, event: PointerEvent): void { + this.initialCoordinates = INITIAL_COORDINATES; + this.handleStart(source, event); + } + + protected handleCancel(event: Event): void { + this.wasCanceled = true; + super.handleCancel(event); + } +} + +class TestablePointerSensor extends PointerSensor { + public startFromActivation(source: Draggable, event: PointerEvent): void { + this.initialCoordinates = INITIAL_COORDINATES; + this.handleStart(source, event); + } +} + +const createActivationEvent = () => new Event('pointermove') as PointerEvent; + +describe('PointerSensorWithSourceGuard', () => { + let manager: DragDropManager; + + beforeEach(() => { + manager = new DragDropManager(); + }); + + afterEach(() => { + manager.destroy(); + jest.restoreAllMocks(); + }); + + const createUnregisteredDraggable = () => + new Draggable( + { + id: 'pressed-draggable', + element: document.createElement('div'), + register: false, + }, + manager, + ); + + it('documents that the base sensor throws when the pressed draggable is no longer registered', () => { + const source = createUnregisteredDraggable(); + const sensor = new TestablePointerSensor(manager); + + expect(() => + sensor.startFromActivation(source, createActivationEvent()), + ).toThrow('Cannot start a drag operation without a drag source'); + }); + + it('should cancel the gesture when the pressed draggable is no longer registered', () => { + const source = createUnregisteredDraggable(); + const sensor = new TestablePointerSensorWithSourceGuard(manager); + + expect(() => + sensor.startFromActivation(source, createActivationEvent()), + ).not.toThrow(); + + expect(sensor.wasCanceled).toBe(true); + expect(manager.dragOperation.status.idle).toBe(true); + }); + + it('should start the drag when the pressed draggable is still registered', () => { + const source = new Draggable( + { id: 'pressed-draggable', element: document.createElement('div') }, + manager, + ); + source.register(); + + const sensor = new TestablePointerSensorWithSourceGuard(manager); + + // An already-aborted controller makes the base handleStart return before + // pointer capture, which jsdom does not implement. + const abortedController = new AbortController(); + abortedController.abort(); + const startSpy = jest + .spyOn(manager.actions, 'start') + .mockReturnValue(abortedController); + + sensor.startFromActivation(source, createActivationEvent()); + + expect(sensor.wasCanceled).toBe(false); + expect(startSpy).toHaveBeenCalledWith(expect.objectContaining({ source })); + }); +});