diff --git a/cypress/e2e/example-draggable-grouping-header-drop.cy.ts b/cypress/e2e/example-draggable-grouping-header-drop.cy.ts new file mode 100644 index 000000000..d88655a3a --- /dev/null +++ b/cypress/e2e/example-draggable-grouping-header-drop.cy.ts @@ -0,0 +1,40 @@ +// Characterization test for dragging a column HEADER into the grouping dropzone (currently SortableJS +// cross-list drag with `group: { name: 'shared', pull: 'clone', put: false }` on the headers and +// `group: 'shared'` on the dropzone). This is the behavior the SortableJS removal refactor must +// reproduce in the SlickDraggableGrouping plugin, and it must pass identically before and after. +describe('Example - Draggable Grouping - drop a header into the dropzone (characterization)', { retries: 1 }, () => { + const GRID_ROW_HEIGHT = 25; + + it('should load the example and clear the initial grouping', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-draggable-grouping.html`); + cy.get('[data-test="clear-grouping-btn"]').click(); + + cy.get('#myGrid .slick-placeholder') + .should('be.visible') + .should('have.text', 'Drop a column header here to group by the column :)'); + cy.get('.slick-dropped-grouping').should('have.length', 0); + }); + + it('should drag the "Duration" column header into the dropzone and group by Duration', () => { + cy.get('#myGrid .slick-dropzone').first().then(($dropzone) => { + cy.contains('#myGrid .slick-header-column', 'Duration').drag($dropzone); + }); + + // a grouping pill is created in the pre-header dropzone + cy.get('.slick-dropped-grouping').should('have.length', 1); + cy.get('.slick-dropped-grouping:nth(0) div').contains('Duration'); + cy.get('#myGrid .slick-placeholder').should('not.be.visible'); + + // the header itself is not consumed by the drag (SortableJS pulls a clone) + cy.contains('#myGrid .slick-header-column', 'Duration').should('exist'); + + // and the grid data is actually grouped by Duration + cy.get(`[style*="top: ${GRID_ROW_HEIGHT * 0}px;"] > .slick-cell:nth(0) .slick-group-title`).should('contain', 'Duration:'); + }); + + it('should clear the grouping again and expect the pill to be removed', () => { + cy.get('[data-test="clear-grouping-btn"]').click(); + cy.get('.slick-dropped-grouping').should('have.length', 0); + cy.get('#myGrid .slick-placeholder').should('be.visible'); + }); +}); diff --git a/cypress/e2e/example-frozen-columns-reorder.cy.ts b/cypress/e2e/example-frozen-columns-reorder.cy.ts new file mode 100644 index 000000000..6b1115c03 --- /dev/null +++ b/cypress/e2e/example-frozen-columns-reorder.cy.ts @@ -0,0 +1,204 @@ +import { createDragLikeEvent, pressPointer, releasePointer } from '../support/drag'; + +// Characterization tests for header column reordering on a frozen-columns grid (currently SortableJS). +// These specs pin down the observable behavior that must survive the SortableJS removal refactor: +// they are expected to pass identically before and after the drag engine is replaced. +describe('Example - Frozen Columns - Column Header Reorder (characterization)', { retries: 1 }, () => { + const LEFT_HEADERS = '#myGrid .slick-header-columns-left'; + const RIGHT_HEADERS = '#myGrid .slick-header-columns-right'; + const RIGHT_VIEWPORT = '#myGrid .slick-viewport-top.slick-viewport-right'; + + const initialLeftTitles = ['#', 'Title', 'Duration']; + const initialRightTitles = ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']; + const initialIds = ['sel', 'title', 'duration', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']; + + const expectHeaderTitles = (containerSelector: string, titles: string[]) => { + cy.get(containerSelector) + .children() + .should('have.length', titles.length) + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }; + + const expectColumnIds = (ids: string[]) => { + cy.window().then((win: any) => { + expect(win.grid.getColumns().map((c: any) => c.id)).to.deep.eq(ids); + }); + }; + + const expectReorderCallCount = (count: number) => { + cy.window().its('columnsReorderedCalls').should('have.length', count); + }; + + const getRightHeader = (win: any, title: string): HTMLElement => { + const headers = Array.from(win.document.querySelectorAll(`${RIGHT_HEADERS} .slick-header-column`)) as HTMLElement[]; + return headers.find((el) => (el.textContent ?? '').includes(title)) as HTMLElement; + }; + + it('should load the example and have the expected initial column order on both sides of the frozen boundary', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example-frozen-columns.html`); + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + + // record every onColumnsReordered payload so specs can assert exactly when and with what the event fires + cy.window().then((win: any) => { + win.columnsReorderedCalls = []; + win.grid.onColumnsReordered.subscribe((_e: any, args: any) => { + win.columnsReorderedCalls.push({ + impactedColumnIds: args.impactedColumns.map((c: any) => c.id), + previousColumnOrder: [...args.previousColumnOrder], + }); + }); + }); + }); + + it('should reorder columns within the frozen (left) section', () => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').then(($target) => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').drag($target); + }); + + expectHeaderTitles(LEFT_HEADERS, ['#', 'Duration', 'Title']); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(['sel', 'duration', 'title', '%', 'start', 'finish', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); + + expectReorderCallCount(1); + cy.window().then((win: any) => { + expect(win.columnsReorderedCalls[0].previousColumnOrder).to.deep.eq(initialIds); + }); + + // drag back (leftward) to restore the initial order + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').then(($target) => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').drag($target); + }); + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should reorder columns within the non-frozen (right) section and re-render the data cells accordingly', () => { + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/01/2009'); // Start + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/05/2009'); // Finish + + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Finish').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); + }); + + expectHeaderTitles(RIGHT_HEADERS, ['% Complete', 'Finish', 'Start', 'Effort Driven', 'Title1', 'Title2', 'Title3', 'Title4']); + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectColumnIds(['sel', 'title', 'duration', '%', 'finish', 'start', 'effort-driven', 'title1', 'title2', 'title3', 'title4']); + + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(1)').should('contain', '01/05/2009'); // Finish now first + cy.get('#myGrid .grid-canvas-right [style*="top: 0px;"] > .slick-cell:nth(2)').should('contain', '01/01/2009'); // Start now second + + expectReorderCallCount(3); + + // drag back (leftward) to restore the initial order + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Finish').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); + }); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(4); + }); + + it('should NOT allow dragging a frozen (left) column into the non-frozen (right) section', () => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').then(($target) => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Duration').drag($target); + }); + + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(4); + }); + + it('should NOT allow dragging a non-frozen (right) column into the frozen (left) section', () => { + cy.contains(`${LEFT_HEADERS} .slick-header-column`, 'Title').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Start').drag($target); + }); + + expectHeaderTitles(LEFT_HEADERS, initialLeftTitles); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(4); + }); + + it('should keep the horizontal scroll position after reordering columns in the scrolled right section', () => { + cy.get(RIGHT_VIEWPORT).scrollTo(300, 0, { ensureScrollable: false }); + cy.wait(50); + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.closeTo(300, 2)); + + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title3').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title2').drag($target); + }); + + expectHeaderTitles(RIGHT_HEADERS, ['% Complete', 'Start', 'Finish', 'Effort Driven', 'Title1', 'Title3', 'Title2', 'Title4']); + expectReorderCallCount(5); + + // without the scroll restore, setColumns() would reset the viewport back to x=0 + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.closeTo(300, 2)); + + // restore order and scroll position + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title3').then(($target) => { + cy.contains(`${RIGHT_HEADERS} .slick-header-column`, 'Title2').drag($target); + }); + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectReorderCallCount(6); + cy.get(RIGHT_VIEWPORT).scrollTo(0, 0, { ensureScrollable: false }); + }); + + it('should auto-scroll the right viewport when a header drag starts beyond the right edge of the grid', () => { + cy.get(RIGHT_VIEWPORT).scrollTo(0, 0, { ensureScrollable: false }); + cy.wait(50); + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.eq(0)); + + // start a drag on "Finish" with the pointer already past the grid's right edge, then also fire a + // document-level `drag` event at those coordinates (that is what a real browser does continuously + // during a native drag, and what the future native reorder engine listens to for auto-scrolling) + cy.window().then((win: any) => { + const finishHeader = getRightHeader(win, 'Finish'); + expect(finishHeader).to.exist; + const rect = finishHeader.getBoundingClientRect(); + const sy = rect.top + rect.height / 2; + const dragX = (win.document.querySelector('#myGrid') as HTMLElement).clientWidth + 100; + const dataTransfer = new DataTransfer(); + + pressPointer(finishHeader, rect.left + rect.width / 2, sy); + finishHeader.dispatchEvent(createDragLikeEvent('dragstart', dragX, sy, dataTransfer)); + win.document.dispatchEvent(createDragLikeEvent('drag', dragX, sy, dataTransfer)); + }); + + cy.wait(250); + cy.window().then((win: any) => { + const finishHeader = getRightHeader(win, 'Finish'); + const sy = finishHeader.getBoundingClientRect().top; + const dragX = (win.document.querySelector('#myGrid') as HTMLElement).clientWidth + 100; + win.document.dispatchEvent(createDragLikeEvent('drag', dragX, sy, new DataTransfer())); + }); + cy.wait(250); + + cy.get(RIGHT_VIEWPORT).should(($v) => expect($v[0].scrollLeft).to.be.greaterThan(10)); + + // end the drag on the source itself: no reorder, and the auto-scroll must stop + cy.window().then((win: any) => { + const finishHeader = getRightHeader(win, 'Finish'); + const rect = finishHeader.getBoundingClientRect(); + const sy = rect.top + rect.height / 2; + const dragX = (win.document.querySelector('#myGrid') as HTMLElement).clientWidth + 100; + finishHeader.dispatchEvent(createDragLikeEvent('dragend', dragX, sy, new DataTransfer())); + releasePointer(finishHeader, dragX, sy); + }); + + cy.get(RIGHT_VIEWPORT).then(($v) => { + const scrollLeftAfterDrop = $v[0].scrollLeft; + cy.wait(300); + cy.get(RIGHT_VIEWPORT).should(($v2) => expect($v2[0].scrollLeft).to.eq(scrollLeftAfterDrop)); + }); + + expectHeaderTitles(RIGHT_HEADERS, initialRightTitles); + expectColumnIds(initialIds); + expectReorderCallCount(6); + + cy.get(RIGHT_VIEWPORT).scrollTo(0, 0, { ensureScrollable: false }); + }); +}); diff --git a/cypress/e2e/example14-column-reorder.cy.ts b/cypress/e2e/example14-column-reorder.cy.ts new file mode 100644 index 000000000..6644aa086 --- /dev/null +++ b/cypress/e2e/example14-column-reorder.cy.ts @@ -0,0 +1,165 @@ +// Characterization tests for header column reordering (currently implemented with SortableJS). +// These specs pin down the observable behavior that must survive the SortableJS removal refactor: +// they are expected to pass identically before and after the drag engine is replaced. +describe('Example 14 - Column Header Reorder (characterization)', { retries: 1 }, () => { + const initialTitles = ['Server', 'CPU0', 'CPU1', 'CPU2', 'CPU3']; + const initialIds = ['server', 'cpu0', 'cpu1', 'cpu2', 'cpu3']; + + const expectHeaderTitles = (titles: string[]) => { + cy.get('#myGrid .slick-header-columns-left') + .children() + .should('have.length', titles.length) + .each(($child, index) => expect($child.text()).to.eq(titles[index])); + }; + + const expectColumnIds = (ids: string[]) => { + cy.window().then((win: any) => { + expect(win.grid.getColumns().map((c: any) => c.id)).to.deep.eq(ids); + }); + }; + + const expectReorderCallCount = (count: number) => { + cy.window().its('columnsReorderedCalls').should('have.length', count); + }; + + it('should load the example and have the expected initial column order', () => { + cy.visit(`${Cypress.config('baseUrl')}/examples/example14-highlighting.html`); + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + + // record every onColumnsReordered payload so specs can assert exactly when and with what the event fires + cy.window().then((win: any) => { + win.columnsReorderedCalls = []; + win.grid.onColumnsReordered.subscribe((_e: any, args: any) => { + win.columnsReorderedCalls.push({ + impactedColumnIds: args.impactedColumns.map((c: any) => c.id), + previousColumnOrder: [...args.previousColumnOrder], + }); + }); + }); + }); + + it('should drag CPU0 onto CPU2 and reorder it after CPU2, firing onColumnsReordered with the correct payload', () => { + cy.contains('#myGrid .slick-header-column', 'CPU2').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + expectHeaderTitles(['Server', 'CPU1', 'CPU2', 'CPU0', 'CPU3']); + expectColumnIds(['server', 'cpu1', 'cpu2', 'cpu0', 'cpu3']); + + // the data cells are re-rendered under the new column order + cy.get('#myGrid .grid-canvas [style*="top: 0px;"] > .slick-cell.l0.r0').should('contain', 'Server 0'); + + expectReorderCallCount(1); + cy.window().then((win: any) => { + const call = win.columnsReorderedCalls[0]; + expect(call.previousColumnOrder).to.deep.eq(initialIds); + expect(call.impactedColumnIds).to.deep.eq(['server', 'cpu1', 'cpu2', 'cpu0', 'cpu3']); + }); + }); + + it('should drag CPU0 back onto CPU1 (leftward) and restore the initial column order', () => { + cy.contains('#myGrid .slick-header-column', 'CPU1').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + + expectReorderCallCount(2); + cy.window().then((win: any) => { + const call = win.columnsReorderedCalls[1]; + expect(call.previousColumnOrder).to.deep.eq(['server', 'cpu1', 'cpu2', 'cpu0', 'cpu3']); + expect(call.impactedColumnIds).to.deep.eq(initialIds); + }); + }); + + it('should not reorder nor fire onColumnsReordered when a column is dropped onto itself', () => { + cy.contains('#myGrid .slick-header-column', 'CPU0').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should not allow dragging the unorderable "Server" column', () => { + cy.get('#myGrid .slick-header-column:nth(0)').should('have.class', 'unorderable'); + + cy.contains('#myGrid .slick-header-column', 'CPU1').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'Server').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should not allow dropping a column onto the unorderable "Server" column', () => { + cy.contains('#myGrid .slick-header-column', 'Server').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU1').drag($target); + }); + + expectHeaderTitles(initialTitles); + expectColumnIds(initialIds); + expectReorderCallCount(2); + }); + + it('should still allow resizing the unorderable "Server" column', () => { + cy.get('#myGrid .slick-header-column:nth(0)').then(($header) => { + const widthBefore = $header.outerWidth() as number; + + cy.get('#myGrid .slick-header-column:nth(0) .slick-resizable-handle').then(($handle) => { + const rect = $handle[0].getBoundingClientRect(); + const startX = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + + cy.wrap($handle) + .trigger('mousedown', { which: 1, button: 0, clientX: startX, clientY: y, pageX: startX, pageY: y, force: true }); + cy.get('body') + .trigger('mousemove', { clientX: startX + 40, clientY: y, pageX: startX + 40, pageY: y, force: true }) + .trigger('mousemove', { clientX: startX + 40, clientY: y, pageX: startX + 40, pageY: y, force: true }) + .trigger('mouseup', { clientX: startX + 40, clientY: y, pageX: startX + 40, pageY: y, force: true }); + }); + + cy.get('#myGrid .slick-header-column:nth(0)').should(($headerAfter) => { + expect($headerAfter.outerWidth()).to.be.greaterThan(widthBefore + 20); + }); + }); + + expectHeaderTitles(initialTitles); + expectReorderCallCount(2); + }); + + // Characterizes the CORRECT behavior, which the current SortableJS implementation does NOT have: + // today a hidden column is silently dropped from the grid after any reorder, because hidden columns + // get no header element rendered and the SortableJS toArray() read-back therefore omits them. + // The native reorder engine (PR 2 of the SortableJS removal) fixes this with column-map reconciliation. + // Enable this test when that engine lands. + it.skip('should keep hidden columns in the column set when reordering', () => { + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + cols[2].hidden = true; // hide "CPU1" + win.grid.setColumns(cols); + }); + + cy.contains('#myGrid .slick-header-column', 'CPU2').then(($target) => { + cy.contains('#myGrid .slick-header-column', 'CPU0').drag($target); + }); + + cy.window().then((win: any) => { + const ids = win.grid.getColumns().map((c: any) => c.id); + expect(ids).to.have.length(5); + expect(ids).to.include('cpu1'); // the hidden column must not be lost + }); + + // restore for any subsequent spec + cy.window().then((win: any) => { + const cols = win.grid.getColumns(); + const hiddenCol = cols.find((c: any) => c.id === 'cpu1'); + if (hiddenCol) { hiddenCol.hidden = false; } + win.grid.setColumns(cols); + }); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 5f009efa2..475e2ff49 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -25,6 +25,7 @@ // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) import '@4tw/cypress-drag-drop'; import 'cypress-real-events'; +import './drag'; // overwrites the `drag` command from "@4tw/cypress-drag-drop" with our HTML5 DnD event sequence import { convertPosition } from './common'; declare global { diff --git a/cypress/support/drag.ts b/cypress/support/drag.ts index 948811189..f59413304 100644 --- a/cypress/support/drag.ts +++ b/cypress/support/drag.ts @@ -5,6 +5,7 @@ declare global { namespace Cypress { interface Chainable { // triggerHover: (elements: NodeListOf) => void; + drag(target: string | HTMLElement | JQuery, options?: { dropSide?: DropSide; }): Chainable; dragOutside(viewport?: string, ms?: number, px?: number, options?: { parentSelector?: string, scrollbarDimension?: number; rowHeight?: number; }): Chainable; dragStart(options?: { cellWidth?: number; cellHeight?: number; }): Chainable; dragCell(addRow: number, addCell: number, options?: { cellWidth?: number; cellHeight?: number; }): Chainable; @@ -13,6 +14,96 @@ declare global { } } +export type DropSide = 'auto' | 'center' | 'left' | 'right'; + +// elements the `drag` command can pick up when given an inner child (e.g. the header name span) +const DRAGGABLE_ITEM_SELECTOR = '.slick-header-column, .slick-dropped-grouping, [draggable="true"]'; + +/** Create a drag-family event (dragstart/dragenter/dragover/drop/dragend/drag) carrying a DataTransfer and real coordinates */ +export function createDragLikeEvent(eventName: string, x: number, y: number, dataTransfer: DataTransfer): Event { + const evt = new Event(eventName, { bubbles: true, cancelable: true }); + Object.defineProperty(evt, 'dataTransfer', { value: dataTransfer }); + Object.defineProperty(evt, 'clientX', { value: x }); + Object.defineProperty(evt, 'clientY', { value: y }); + Object.defineProperty(evt, 'pageX', { value: x }); + Object.defineProperty(evt, 'pageY', { value: y }); + Object.defineProperty(evt, 'screenX', { value: x }); + Object.defineProperty(evt, 'screenY', { value: y }); + return evt; +} + +/** Dispatch the pointer/mouse press that precedes a native HTML5 drag (SortableJS only arms itself from pointerdown/mousedown) */ +export function pressPointer(el: HTMLElement, x: number, y: number): void { + const init = { bubbles: true, cancelable: true, button: 0, buttons: 1, clientX: x, clientY: y }; + el.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerId: 1, isPrimary: true, pointerType: 'mouse' })); + el.dispatchEvent(new MouseEvent('mousedown', init)); +} + +/** Dispatch the pointer/mouse release that follows a native HTML5 drag */ +export function releasePointer(el: HTMLElement, x: number, y: number): void { + const init = { bubbles: true, cancelable: true, button: 0, buttons: 0, clientX: x, clientY: y }; + el.dispatchEvent(new PointerEvent('pointerup', { ...init, pointerId: 1, isPrimary: true, pointerType: 'mouse' })); + el.dispatchEvent(new MouseEvent('mouseup', init)); +} + +// Replace the `@4tw/cypress-drag-drop` simulation with our own HTML5 DnD event sequence (ported from Slickgrid-Universal). +// It dispatches pointerdown/mousedown -> dragstart -> dragenter/dragover -> drop -> dragend -> pointerup/mouseup with a +// shared DataTransfer and real coordinates, i.e. the same sequence a browser fires for a real drag. `dropSide` controls +// where on the target the drop lands; 'auto' aims decisively past the target's midpoint (75%/25%) so the before/after +// insertion intent is unambiguous regardless of which drag engine (SortableJS or native) interprets it. +Cypress.Commands.overwrite('drag', (_originalFn: any, subject: any, target: any, options: { dropSide?: DropSide; } = {}) => { + const dropSide: DropSide = options?.dropSide ?? 'auto'; + + return cy.wrap(subject, { log: false }).then(($source: JQuery) => { + const rawSourceElm = $source?.[0] as HTMLElement | undefined; + const sourceElm = rawSourceElm?.closest(DRAGGABLE_ITEM_SELECTOR) ?? rawSourceElm; + const targetChain = typeof target === 'string' ? cy.get(target, { log: false }) : cy.wrap(target, { log: false }); + + return targetChain.then(($target: any) => { + const rawTargetElm = ($target?.[0] ?? $target) as HTMLElement | undefined; + const targetElm = rawTargetElm?.closest?.(DRAGGABLE_ITEM_SELECTOR) as HTMLElement ?? rawTargetElm; + + if (!sourceElm || !targetElm) { + return cy.wrap($source, { log: false }); + } + + const dataTransfer = new DataTransfer(); + const sourceRect = sourceElm.getBoundingClientRect(); + const sourceX = sourceRect.left + sourceRect.width / 2; + const sourceY = sourceRect.top + sourceRect.height / 2; + + pressPointer(sourceElm, sourceX, sourceY); + sourceElm.dispatchEvent(createDragLikeEvent('dragstart', sourceX, sourceY, dataTransfer)); + + // SortableJS activates the drag on the next macrotask, so yield before dragging over the target + return cy.wait(20, { log: false }).then(() => { + const targetRect = targetElm.getBoundingClientRect(); + let side = dropSide; + if (side === 'auto') { + if (sourceElm.parentElement === targetElm.parentElement && sourceRect.left !== targetRect.left) { + side = sourceRect.left < targetRect.left ? 'right' : 'left'; + } else { + side = 'center'; + } + } + const fraction = side === 'right' ? 0.75 : side === 'left' ? 0.25 : 0.5; + const targetX = targetRect.left + targetRect.width * fraction; + const targetY = targetRect.top + targetRect.height / 2; + + targetElm.dispatchEvent(createDragLikeEvent('dragenter', targetX, targetY, dataTransfer)); + targetElm.dispatchEvent(createDragLikeEvent('dragover', targetX, targetY, dataTransfer)); + + return cy.wait(20, { log: false }).then(() => { + targetElm.dispatchEvent(createDragLikeEvent('drop', targetX, targetY, dataTransfer)); + sourceElm.dispatchEvent(createDragLikeEvent('dragend', targetX, targetY, dataTransfer)); + releasePointer(sourceElm, targetX, targetY); + return cy.wrap($source, { log: false }); + }); + }); + }); + }); +}); + // @ts-ignore Cypress.Commands.add('dragStart', { prevSubject: true }, (subject, { cellWidth = 80, cellHeight = 25 } = {}) => { return cy.wrap(subject).click({ force: true })