From 484570e65b09bfc0753e55526fdc60991c819b93 Mon Sep 17 00:00:00 2001 From: Adrien Schoffeniels Date: Thu, 3 Aug 2023 14:11:17 +0200 Subject: [PATCH] [IMP] web: add tests for `useNestedSortable` hook Purpose: -------- This commit adds QUnit tests for the `useNestedSortable` hook. This commit also fixes a few issues in the hook that were pointed out by adding these tests, notably: - the moves are now triggered based on the position of the top of the dragged element instead of the position of the cursor (behavior was unclear when moving long elements) - when nesting is allowed, moving an element under another one that does not have nested elements now inserts the placeholder after the element instead of nesting it inside of it. It also renames the `listType` parameter to `listTagName` to make its purpose clearer, and moves the placeholder styling to a custom class so that it can be customized more easily. Task-3422012 Part-of: odoo/odoo#131769 --- .../static/src/core/utils/nested_sortable.js | 66 +- .../src/core/utils/nested_sortable.scss | 9 + .../tests/core/utils/nested_sortable_tests.js | 979 ++++++++++++++++++ 3 files changed, 1027 insertions(+), 27 deletions(-) create mode 100644 addons/web/static/src/core/utils/nested_sortable.scss create mode 100644 addons/web/static/tests/core/utils/nested_sortable_tests.js diff --git a/addons/web/static/src/core/utils/nested_sortable.js b/addons/web/static/src/core/utils/nested_sortable.js index 7971fc1d478..553edbcde0b 100644 --- a/addons/web/static/src/core/utils/nested_sortable.js +++ b/addons/web/static/src/core/utils/nested_sortable.js @@ -21,13 +21,13 @@ import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder"; * work on group elements during the dragging sequence. * @property {string | () => string} [handle] additional selector for when the dragging * sequence must be initiated when dragging on a certain part of the element. - * @property {string | () => string} [ignore] selector targetting elements that must + * @property {string | () => string} [ignore] selector targeting elements that must * initiate a drag. * @property {boolean | () => boolean} [connectGroups] whether elements can be dragged - * accross different parent groups. Note that it requires a `groups` param to work. + * across different parent groups. Note that it requires a `groups` param to work. * @property {string | () => string} [cursor] cursor style during the dragging sequence. * @property {boolean | () => boolean} [nest] whether elements are nested or not. - * @property {string | () => string} [lisType] type of lists ("ul" or "ol"). + * @property {string | () => string} [listTagName] type of lists ("ul" or "ol"). * @property {number | () => number} [nestInterval] Horizontal distance needed to trigger * a change in the list hierarchy (i.e. changing parent when moving horizontally) * @@ -72,7 +72,7 @@ export const useNestedSortable = makeDraggableHook({ groups: [String, Function], connectGroups: [Boolean, Function], nest: [Boolean], - listType: [String], + listTagName: [String], nestInterval: [Number], }, defaultParams: { @@ -83,7 +83,7 @@ export const useNestedSortable = makeDraggableHook({ elements: "li", groupSelector: null, nest: false, - listType: "ul", + listTagName: "ul", nestInterval: 15, }, @@ -94,12 +94,12 @@ export const useNestedSortable = makeDraggableHook({ if (ctx.groupSelector) { ctx.fullSelector = [ctx.groupSelector, ctx.fullSelector].join(" "); } - // Connection accross groups + // Connection across groups ctx.connectGroups = params.connectGroups; // Nested elements ctx.nest = params.nest; - // List type - ctx.listType = params.listType; + // List tag name + ctx.listTagName = params.listTagName; // Horizontal distance needed to trigger a change in the list hierarchy // (i.e. changing parent when moving horizontally) ctx.nestInterval = params.nestInterval; @@ -115,8 +115,12 @@ export const useNestedSortable = makeDraggableHook({ ctx.current.container = ctx.currentGroup; } } + + if (ctx.nest) { + ctx.prevNestX = ctx.pointer.x; + } ctx.current.placeHolder = ctx.current.element.cloneNode(false); - ctx.current.placeHolder.style = `display: block; width: 100%; height: 5px; background-color: deepskyblue`; + ctx.current.placeHolder.classList.add("o_nested_sortable_placeholder"); addCleanup(() => ctx.current.placeHolder.remove()); }, @@ -127,11 +131,11 @@ export const useNestedSortable = makeDraggableHook({ // Horizontal position which will be used to detect row changes when moving vertically, so that // we do not need to be on the row to trigger row changes (only the vertical position matters). // Nested rows are shorter than "root" rows, and do not start at the same horizontal position. - // However, every row spans until the end of the container. Therefore, we use the end of the - // container - 1 as horizontal position. + // However, every row ends at the same horizontal position. Therefore, we use the end of the + // current element - 1 as horizontal position. ctx.selectorX = ctx.isRTL - ? ctx.current.containerRect.left + 1 - : ctx.current.containerRect.right - 1; + ? ctx.current.elementRect.left + 1 + : ctx.current.elementRect.right - 1; // Placeholder is initially added right after the current element. ctx.current.element.after(ctx.current.placeHolder); @@ -145,8 +149,6 @@ export const useNestedSortable = makeDraggableHook({ addStyle(document.querySelector(".o_action_manager"), { "pointer-events": "none" }); addStyle(ctx.current.container, { "pointer-events": "auto" }); - ctx.prevNestX = ctx.pointer.x; - // Calls "onDragStart" handler return { element: ctx.current.element, @@ -179,9 +181,9 @@ export const useNestedSortable = makeDraggableHook({ * @return {HTMLElement} list */ const getChildList = (el) => { - let list = el.querySelector(ctx.listType); + let list = el.querySelector(ctx.listTagName); if (!list) { - list = document.createElement(ctx.listType); + list = document.createElement(ctx.listTagName); el.appendChild(list); } return list; @@ -229,7 +231,7 @@ export const useNestedSortable = makeDraggableHook({ */ if (ctx.nest) { const xInterval = ctx.prevNestX - ctx.pointer.x; - if (ctx.nestInterval - (-1) ** ctx.isRTL * xInterval < 0) { + if (ctx.nestInterval - (-1) ** ctx.isRTL * xInterval < 1) { // Place placeholder after its parent in its parent's list only // if the placeholder is the last child of its parent // (ignoring the current element which is in the dom) @@ -246,22 +248,25 @@ export const useNestedSortable = makeDraggableHook({ } // Recenter the pointer coordinates to this step ctx.prevNestX = ctx.pointer.x; - } else if (ctx.nestInterval + (-1) ** ctx.isRTL * xInterval < 0) { + return; + } else if (ctx.nestInterval + (-1) ** ctx.isRTL * xInterval < 1) { // Place placeholder as the last child of its previous sibling, // (ignoring the current element which is in the dom) let parent = position.previous; if (parent === ctx.current.element) { parent = parent.previousElementSibling; } - if (parent) { + if (parent && parent.matches(ctx.elementSelector)) { getChildList(parent).appendChild(ctx.current.placeHolder); onMove(position); } // Recenter the pointer coordinates to this step ctx.prevNestX = ctx.pointer.x; + return; } } - const closestEl = document.elementFromPoint(ctx.selectorX, ctx.pointer.y); + const currentTop = ctx.pointer.y - ctx.current.offset.y; + const closestEl = document.elementFromPoint(ctx.selectorX, currentTop); if (!closestEl) { // Cursor outside of viewport return; @@ -277,23 +282,30 @@ export const useNestedSortable = makeDraggableHook({ // element. If the position is not allowed but nesting is allowed, // place the placeholder as the last child of the previous sibling // instead. - if (ctx.pointer.y - eRect.y < 10) { + if (currentTop - eRect.y < 10) { if (pos === 2 || pos === 10) { element.before(ctx.current.placeHolder); onMove(position); // Recenter the pointer coordinates to this step ctx.prevNestX = ctx.pointer.x; } - } else if (ctx.pointer.y - eRect.y > 15 && pos === 4) { + } else if (currentTop - eRect.y > 15 && pos === 4) { // Place placeholder after the hovered element in its parent's // list if the cursor is not in the upper part of the // element and if the placeholder is currently before the // hovered element. - // If nesting is allowed, place the placeholder as the first - // child of the hovered element instead. + // If nesting is allowed and if the element has at least one + // child, place the placeholder above the first child of the + // hovered element instead. if (ctx.nest) { - getChildList(element).prepend(ctx.current.placeHolder); - onMove(position); + const elementChildList = getChildList(element); + if (elementChildList.querySelector(ctx.elementSelector)) { + elementChildList.prepend(ctx.current.placeHolder); + onMove(position); + } else { + element.after(ctx.current.placeHolder); + onMove(position); + } // Recenter the pointer coordinates to this step ctx.prevNestX = ctx.pointer.x; } else { diff --git a/addons/web/static/src/core/utils/nested_sortable.scss b/addons/web/static/src/core/utils/nested_sortable.scss new file mode 100644 index 00000000000..de8d7c27d70 --- /dev/null +++ b/addons/web/static/src/core/utils/nested_sortable.scss @@ -0,0 +1,9 @@ +.o_nested_sortable_placeholder { + background-clip: content-box; + background-color: deepskyblue; + display: block; + height: 5px; + padding-top: 0 !important; + padding-bottom: 0 !important; + width: 100%; +} diff --git a/addons/web/static/tests/core/utils/nested_sortable_tests.js b/addons/web/static/tests/core/utils/nested_sortable_tests.js new file mode 100644 index 00000000000..2f54294d0a5 --- /dev/null +++ b/addons/web/static/tests/core/utils/nested_sortable_tests.js @@ -0,0 +1,979 @@ +/** @odoo-module **/ + +import { drag, getFixture, mount, nextTick } from "@web/../tests/helpers/utils"; +import { registerCleanup } from "@web/../tests/helpers/cleanup"; +import { useNestedSortable } from "@web/core/utils/nested_sortable"; + +import { Component, reactive, useRef, useState, xml } from "@odoo/owl"; + +/** + * Dragging methods taking into account the fact that it's the top of the + * dragged element that triggers the moves (not the position of the cursor), + * and the fact that during the first move, the dragged element is replaced by + * a placeholder that does not have the same height. The moves are done with + * the same x position to prevent triggering horizontal moves. + * @param {string} from + */ +const sortableDrag = async (from) => { + const fixture = getFixture(); + const fromEl = fixture.querySelector(from); + const fromRect = fromEl.getBoundingClientRect(); + const { drop, moveTo } = await drag(from); + let isFirstMove = true; + + /** + * @param {string} [targetSelector] + */ + const moveAbove = async (targetSelector) => { + const el = fixture.querySelector(targetSelector); + await moveTo(el, { + x: fromRect.x - el.getBoundingClientRect().x + fromRect.width / 2, + y: fromRect.height / 2 + 5, + }); + isFirstMove = false; + }; + + /** + * @param {string} [targetSelector] + */ + const moveUnder = async (targetSelector) => { + const el = fixture.querySelector(targetSelector); + const elRect = el.getBoundingClientRect(); + let firstMoveBelow = false; + if (isFirstMove && elRect.y > fromRect.y) { + // Need to consider that the moved element will be replaced by a + // placeholder with a height of 5px + firstMoveBelow = true; + } + await moveTo(el, { + x: fromRect.x - elRect.x + fromRect.width / 2, + y: + ((firstMoveBelow ? -1 : 1) * fromRect.height) / 2 + + elRect.height + + (firstMoveBelow ? 4 : -1), + }); + isFirstMove = false; + }; + + return { moveAbove, moveUnder, drop }; +}; + +const dragAndDrop = async (from, to) => { + const { drop, moveUnder } = await sortableDrag(from); + await moveUnder(to); + await drop(); +}; + +let target; +QUnit.module("Draggable", ({ beforeEach }) => { + beforeEach(() => { + target = getFixture(); + // make fixture in visible range, so that document.elementFromPoint + // work as expected + target.style.position = "absolute"; + target.style.top = "0"; + target.style.left = "0"; + target.style.height = "100%"; + target.style.opacity = QUnit.config.debug ? "" : "0"; + registerCleanup(async () => { + target.style.position = ""; + target.style.top = ""; + target.style.left = ""; + target.style.height = ""; + target.style.opacity = ""; + }); + }); + + QUnit.module("NestedSortable hook"); + + QUnit.test("Parameters error handling", async (assert) => { + assert.expect(9); + + const mountNestedSortableAndAssert = async (setupList, shouldThrow) => { + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + setupList(); + } + } + + await mount(NestedSortable, target).catch(() => assert.step("thrown")); + + assert.verifySteps(shouldThrow ? ["thrown"] : []); + }; + + // Incorrect params + await mountNestedSortableAndAssert(() => { + useNestedSortable({}); + }, true); + await mountNestedSortableAndAssert(() => { + useNestedSortable({ + elements: ".item", + }); + }, true); + await mountNestedSortableAndAssert(() => { + useNestedSortable({ + elements: ".item", + groups: ".list", + }); + }, true); + + // Correct params + await mountNestedSortableAndAssert(() => { + useNestedSortable({ + ref: useRef("root"), + }); + }, false); + await mountNestedSortableAndAssert(() => { + useNestedSortable({ + ref: {}, + elements: ".item", + groups: ".list", + enable: false, + }); + }, false); + await mountNestedSortableAndAssert(() => { + useNestedSortable({ + ref: useRef("root"), + groups: ".list", + connectGroups: true, + nest: true, + listTagName: "ol", + nestIndent: 20, + }); + }, false); + }); + + QUnit.test("Sorting in a single group without nesting", async (assert) => { + assert.expect(34); + + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".sortable_list > li", + onDragStart({ element, group }) { + assert.step("start"); + assert.strictEqual(element.id, "1"); + assert.notOk(group); + }, + onMove({ + element, + previous, + next, + parent, + group, + newGroup, + prevPos, + placeholder, + }) { + assert.step("move"); + assert.strictEqual(element.id, "1"); + assert.strictEqual(previous.id, "2"); + assert.strictEqual(next.id, "3"); + assert.notOk(parent); + assert.notOk(group); + assert.notOk(newGroup); + assert.strictEqual(prevPos.previous.id, "1"); + assert.strictEqual(prevPos.next.id, "2"); + assert.notOk(prevPos.parent); + assert.notOk(prevPos.group); + assert.strictEqual(placeholder.previousElementSibling.id, "2"); + }, + onDrop({ element, previous, next, parent, group, newGroup, placeholder }) { + assert.step("drop"); + assert.strictEqual(element.id, "1"); + assert.strictEqual(previous.id, "2"); + assert.strictEqual(next.id, "3"); + assert.notOk(parent); + assert.notOk(group); + assert.notOk(newGroup); + assert.strictEqual(placeholder.previousElementSibling.id, "2"); + }, + onDragEnd({ element, group }) { + assert.step("end"); + assert.strictEqual(element.id, "1"); + assert.notOk(group); + assert.containsN(target, ".sortable_list > .item", 4); + }, + }); + } + } + + await mount(NestedSortable, target); + + assert.containsN(target, ".sortable_list > .item", 3); + assert.containsNone(target, ".o_dragged"); + assert.verifySteps([]); + + // Move first item after second item + const { drop, moveUnder } = await sortableDrag(".sortable_list > .item:first-child"); + await moveUnder(".sortable_list > .item:nth-child(2)"); + + assert.hasClass(target.querySelector(".sortable_list > .item"), "o_dragged"); + + await drop(); + + assert.containsN(target, ".sortable_list > .item", 3); + assert.containsNone(target, ".o_dragged"); + assert.verifySteps(["start", "move", "drop", "end"]); + }); + + QUnit.test("Sorting in groups without nesting", async (assert) => { + assert.expect(38); + class NestedSortable extends Component { + static template = xml` +
+
+
    +
  • + +
      +
    • + +
    • +
    +
  • +
+
+
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".sortable_list > li", + groups: "section", + connectGroups: true, + onDragStart({ element, group }) { + assert.step("start"); + assert.strictEqual(element.id, "2.2"); + assert.strictEqual(group.id, "2"); + }, + onMove({ + element, + previous, + next, + parent, + group, + newGroup, + prevPos, + placeholder, + }) { + assert.step("move"); + assert.strictEqual(element.id, "2.2"); + assert.strictEqual(previous.id, "1.2"); + assert.notOk(next); + assert.notOk(parent); + assert.strictEqual(group.id, "2"); + assert.strictEqual(newGroup.id, "1"); + assert.strictEqual(prevPos.previous.id, "2.2"); + assert.notOk(prevPos.next); + assert.notOk(prevPos.parent); + assert.strictEqual(prevPos.group.id, "2"); + assert.strictEqual(placeholder.previousElementSibling.id, "1.2"); + }, + onGroupEnter({ element, group }) { + assert.step("enter"); + assert.strictEqual(element.id, "2.2"); + assert.strictEqual(group.id, "1"); + }, + onGroupLeave({ element, group }) { + assert.step("leave"); + assert.strictEqual(element.id, "2.2"); + assert.strictEqual(group.id, "2"); + }, + onDrop({ element, previous, next, parent, group, newGroup, placeholder }) { + assert.step("drop"); + assert.strictEqual(element.id, "2.2"); + assert.strictEqual(previous.id, "1.2"); + assert.notOk(next); + assert.notOk(parent); + assert.strictEqual(group.id, "2"); + assert.strictEqual(newGroup.id, "1"); + assert.strictEqual(placeholder.previousElementSibling.id, "1.2"); + }, + onDragEnd({ element, group }) { + assert.step("end"); + assert.strictEqual(element.id, "2.2"); + assert.strictEqual(group.id, "2"); + }, + }); + } + } + + await mount(NestedSortable, target); + + assert.containsN(target, ".sortable_list", 3); + assert.containsN(target, ".item", 18); + assert.verifySteps([]); + + // Append second item of second list to first list + await dragAndDrop("section:nth-child(2) > ul > .item:nth-child(2)", "section:first-child"); + + assert.containsN(target, ".sortable_list", 3); + assert.containsN(target, ".item", 18); + assert.verifySteps(["start", "move", "enter", "leave", "drop", "end"]); + }); + + QUnit.test("Sorting with nesting - move right", async (assert) => { + assert.expect(29); + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + nest: true, + onDragStart({ element }) { + assert.step("start"); + assert.strictEqual(element.id, "2"); + this.firstMove = true; + }, + onMove({ element, previous, next, parent, prevPos }) { + if (this.firstMove) { + assert.step("move 1"); + assert.strictEqual(element.id, "2"); + assert.strictEqual(previous.id, "sub1"); + assert.notOk(next); + assert.strictEqual(parent.id, "1"); + assert.strictEqual(prevPos.previous.id, "2"); + assert.strictEqual(prevPos.next.id, "3"); + assert.notOk(prevPos.parent); + this.firstMove = false; + } else { + assert.step("move 2"); + assert.strictEqual(element.id, "2"); + assert.notOk(previous); + assert.notOk(next); + assert.strictEqual(parent.id, "sub1"); + assert.strictEqual(prevPos.previous.id, "sub1"); + assert.notOk(prevPos.next); + assert.strictEqual(prevPos.parent.id, "1"); + } + }, + onDrop({ element, previous, next, parent }) { + assert.step("drop"); + assert.strictEqual(element.id, "2"); + assert.notOk(previous); + assert.notOk(next); + assert.strictEqual(parent.id, "sub1"); + }, + onDragEnd({ element }) { + assert.step("end"); + assert.strictEqual(element.id, "2"); + }, + }); + } + } + + await mount(NestedSortable, target); + + assert.containsN(target, ".item", 6); + assert.verifySteps([]); + + const movedEl = target.querySelector(".sortable_list > .item:nth-child(2)"); + const { drop, moveTo } = await drag(movedEl); + await moveTo(movedEl, { x: movedEl.getBoundingClientRect().width / 2 + 15 }); + await moveTo(movedEl, { x: movedEl.getBoundingClientRect().width / 2 + 30 }); + // No move if row is already child + await drop(movedEl, { x: movedEl.getBoundingClientRect().width / 2 + 45 }); + + assert.containsN(target, ".item", 6); + assert.verifySteps(["start", "move 1", "move 2", "drop", "end"]); + }); + + QUnit.test("Sorting with nesting - move left", async (assert) => { + assert.expect(21); + + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + nest: true, + nestInterval: 20, + onDragStart({ element }) { + assert.step("start"); + assert.strictEqual(element.id, "dragged"); + }, + onMove({ element, previous, next, parent, prevPos }) { + assert.step("move"); + assert.strictEqual(element.id, "dragged"); + assert.strictEqual(previous.id, "sub1"); + assert.strictEqual(next.id, "sub2"); + assert.strictEqual(parent.id, "parent"); + assert.strictEqual(prevPos.previous.id, "dragged"); + assert.notOk(prevPos.next); + assert.strictEqual(prevPos.parent.id, "sub1"); + }, + onDrop({ element, previous, next, parent }) { + assert.step("drop"); + assert.strictEqual(element.id, "dragged"); + assert.strictEqual(previous.id, "sub1"); + assert.strictEqual(next.id, "sub2"); + assert.strictEqual(parent.id, "parent"); + }, + onDragEnd({ element }) { + assert.step("end"); + assert.strictEqual(element.id, "dragged"); + }, + }); + } + } + + await mount(NestedSortable, target); + + assert.containsN(target, ".item", 4); + assert.verifySteps([]); + + const movedEl = target.querySelector(".item#dragged"); + const { drop, moveTo } = await drag(movedEl); + // No move if distance traveled is smaller than the nest interval + await moveTo(movedEl, { x: movedEl.getBoundingClientRect().width / 2 - 10 }); + await moveTo(movedEl, { x: movedEl.getBoundingClientRect().width / 2 - 20 }); + // No move if there is one element before and one after + await drop(movedEl, { x: movedEl.getBoundingClientRect().width / 2 - 40 }); + + assert.containsN(target, ".item", 4); + assert.verifySteps(["start", "move", "drop", "end"]); + }); + + QUnit.test("Sorting with nesting - move root down", async (assert) => { + assert.expect(28); + + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + nest: true, + onDragStart({ element }) { + assert.step("start"); + assert.strictEqual(element.id, "dragged"); + this.firstMove = true; + }, + onMove({ element, previous, next, parent, prevPos }) { + if (this.firstMove) { + assert.step("move 1"); + assert.strictEqual(element.id, "dragged"); + assert.strictEqual(previous.id, "noChild"); + assert.strictEqual(next.id, "parent"); + assert.notOk(parent); + assert.strictEqual(prevPos.previous.id, "dragged"); + assert.strictEqual(prevPos.next.id, "noChild"); + assert.notOk(prevPos.parent); + this.firstMove = false; + } else { + assert.step("move 2"); + assert.strictEqual(element.id, "dragged"); + assert.notOk(previous); + assert.strictEqual(next.id, "child"); + assert.strictEqual(parent.id, "parent"); + assert.strictEqual(prevPos.previous.id, "noChild"); + assert.strictEqual(prevPos.next.id, "parent"); + assert.notOk(prevPos.parent); + } + }, + onDrop({ element, previous, next, parent }) { + assert.step("drop"); + assert.strictEqual(element.id, "dragged"); + assert.notOk(previous); + assert.strictEqual(next.id, "child"); + assert.strictEqual(parent.id, "parent"); + }, + onDragEnd({ element }) { + assert.step("end"); + assert.strictEqual(element.id, "dragged"); + }, + }); + } + } + + await mount(NestedSortable, target); + assert.verifySteps([]); + + const { drop, moveUnder } = await sortableDrag(".item#dragged"); + await moveUnder(".item#noChild"); + // Move under the content of the row, not under the rows nested inside the row + await moveUnder(".item#parent > span"); + await drop(); + + assert.containsN(target, ".item", 4); + assert.verifySteps(["start", "move 1", "move 2", "drop", "end"]); + }); + + QUnit.test("Sorting with nesting - move child down", async (assert) => { + assert.expect(28); + + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + nest: true, + onDragStart({ element }) { + assert.step("start"); + assert.strictEqual(element.id, "dragged"); + this.firstMove = true; + }, + onMove({ element, previous, next, parent, prevPos }) { + if (this.firstMove) { + assert.step("move 1"); + assert.strictEqual(element.id, "dragged"); + assert.strictEqual(previous.id, "child"); + assert.notOk(next); + assert.strictEqual(parent.id, "parent"); + assert.strictEqual(prevPos.previous.id, "dragged"); + assert.strictEqual(prevPos.next.id, "child"); + assert.strictEqual(prevPos.parent.id, "parent"); + this.firstMove = false; + } else { + assert.step("move 2"); + assert.strictEqual(element.id, "dragged"); + assert.strictEqual(previous.id, "noChild"); + assert.notOk(next); + assert.notOk(parent); + assert.strictEqual(prevPos.previous.id, "child"); + assert.notOk(prevPos.next); + assert.strictEqual(prevPos.parent.id, "parent"); + } + }, + onDrop({ element, previous, next, parent }) { + assert.step("drop"); + assert.strictEqual(element.id, "dragged"); + assert.strictEqual(previous.id, "noChild"); + assert.notOk(next); + assert.notOk(parent); + }, + onDragEnd({ element }) { + assert.step("end"); + assert.strictEqual(element.id, "dragged"); + }, + }); + } + } + + await mount(NestedSortable, target); + assert.verifySteps([]); + + const { drop, moveUnder } = await sortableDrag(".item#dragged"); + await moveUnder(".item#child"); + await moveUnder(".item#noChild"); + await drop(); + + assert.containsN(target, ".item", 4); + assert.verifySteps(["start", "move 1", "move 2", "drop", "end"]); + }); + + QUnit.test("Sorting with nesting - move root up", async (assert) => { + assert.expect(28); + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + nest: true, + onDragStart({ element }) { + assert.step("start"); + assert.strictEqual(element.id, "dragged"); + this.firstMove = true; + }, + onMove({ element, previous, next, parent, prevPos }) { + if (this.firstMove) { + assert.step("move 1"); + assert.strictEqual(element.id, "dragged"); + assert.strictEqual(previous.id, "parent"); + assert.strictEqual(next.id, "noChild"); + assert.notOk(parent); + assert.strictEqual(prevPos.previous.id, "dragged"); + assert.notOk(prevPos.next); + assert.notOk(prevPos.parent); + this.firstMove = false; + } else { + assert.step("move 2"); + assert.strictEqual(element.id, "dragged"); + assert.notOk(previous); + assert.strictEqual(next.id, "child"); + assert.strictEqual(parent.id, "parent"); + assert.strictEqual(prevPos.previous.id, "parent"); + assert.strictEqual(prevPos.next.id, "noChild"); + assert.notOk(prevPos.parent); + } + }, + onDrop({ element, previous, next, parent }) { + assert.step("drop"); + assert.strictEqual(element.id, "dragged"); + assert.notOk(previous); + assert.strictEqual(next.id, "child"); + assert.strictEqual(parent.id, "parent"); + }, + onDragEnd({ element }) { + assert.step("end"); + assert.strictEqual(element.id, "dragged"); + }, + }); + } + } + + await mount(NestedSortable, target); + assert.verifySteps([]); + + const { drop, moveAbove } = await sortableDrag(".item#dragged"); + await moveAbove(".item#noChild"); + await moveAbove(".item#child"); + await drop(); + + assert.containsN(target, ".item", 4); + assert.verifySteps(["start", "move 1", "move 2", "drop", "end"]); + }); + + QUnit.test("Sorting with nesting - move child up", async (assert) => { + assert.expect(28); + + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + nest: true, + onDragStart({ element }) { + assert.step("start"); + assert.strictEqual(element.id, "dragged"); + this.firstMove = true; + }, + onMove({ element, previous, next, parent, prevPos }) { + if (this.firstMove) { + assert.step("move 1"); + assert.strictEqual(element.id, "dragged"); + assert.notOk(previous); + assert.strictEqual(next.id, "child"); + assert.strictEqual(parent.id, "parent"); + assert.strictEqual(prevPos.previous.id, "dragged"); + assert.notOk(prevPos.next); + assert.strictEqual(prevPos.parent.id, "parent"); + this.firstMove = false; + } else { + assert.step("move 2"); + assert.strictEqual(element.id, "dragged"); + assert.notOk(previous); + assert.strictEqual(next.id, "parent"); + assert.notOk(parent); + assert.notOk(prevPos.previous); + assert.strictEqual(prevPos.next.id, "child"); + assert.strictEqual(prevPos.parent.id, "parent"); + } + }, + onDrop({ element, previous, next, parent }) { + assert.step("drop"); + assert.strictEqual(element.id, "dragged"); + assert.notOk(previous); + assert.strictEqual(next.id, "parent"); + assert.notOk(parent); + }, + onDragEnd({ element }) { + assert.step("end"); + assert.strictEqual(element.id, "dragged"); + }, + }); + } + } + + await mount(NestedSortable, target); + assert.verifySteps([]); + + const { drop, moveAbove } = await sortableDrag(".item#dragged"); + await moveAbove(".item#child"); + await moveAbove(".item#parent"); + await drop(); + + assert.containsN(target, ".item", 3); + assert.verifySteps(["start", "move 1", "move 2", "drop", "end"]); + }); + + QUnit.test("Dynamically disable NestedSortable feature", async (assert) => { + assert.expect(4); + + const state = reactive({ enableNestedSortable: true }); + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + this.state = useState(state); + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + enable: () => this.state.enableNestedSortable, + onDragStart() { + assert.step("start"); + }, + }); + } + } + + await mount(NestedSortable, target); + + assert.verifySteps([]); + + await dragAndDrop(".item:first-child", ".item:last-child"); + // Drag should have occurred + assert.verifySteps(["start"]); + + state.enableNestedSortable = false; + await nextTick(); + + await dragAndDrop(".item:first-child", ".item:last-child"); + + // Drag shouldn't have occurred + assert.verifySteps([]); + }); + + QUnit.test( + "Drag has a default tolerance of 10 pixels before initiating the dragging", + async (assert) => { + assert.expect(3); + + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + onDragStart() { + assert.step("Initiation of the drag sequence"); + }, + }); + } + } + + await mount(NestedSortable, target); + + // Move the element from only 5 pixels + const listItem = target.querySelector(".item:first-child"); + const { drop, moveTo } = await drag(listItem); + await moveTo(listItem, { + x: listItem.getBoundingClientRect().width / 2, + y: listItem.getBoundingClientRect().height / 2 + 5, + }); + assert.verifySteps([], "No drag sequence should have been initiated"); + + // Move the element from more than 10 pixels + await moveTo(listItem, { + x: listItem.getBoundingClientRect().width / 2, + y: listItem.getBoundingClientRect().height / 2 + 10, + }); + assert.verifySteps( + ["Initiation of the drag sequence"], + "A drag sequence should have been initiated", + ); + await drop(); + }, + ); + + QUnit.test("Ignore specified elements", async (assert) => { + assert.expect(6); + + class NestedSortable extends Component { + static template = xml` +
+ +
+ `; + + setup() { + useNestedSortable({ + ref: useRef("root"), + elements: ".item", + ignore: ".ignored", + onDragStart() { + assert.step("drag"); + }, + }); + } + } + + await mount(NestedSortable, target); + + assert.verifySteps([]); + + // Drag root item element + await dragAndDrop(".item:first-child", ".item:nth-child(2)"); + + assert.verifySteps(["drag"]); + + // Drag ignored element + await dragAndDrop(".item:first-child .not-ignored", ".item:nth-child(2)"); + + assert.verifySteps(["drag"]); + + // Drag non-ignored element + await dragAndDrop(".item:first-child .ignored", ".item:nth-child(2)"); + + assert.verifySteps([]); + }); +});