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([]); + }); +});