diff --git a/addons/web/static/src/core/utils/nested_sortable.js b/addons/web/static/src/core/utils/nested_sortable.js new file mode 100644 index 00000000000..553edbcde0b --- /dev/null +++ b/addons/web/static/src/core/utils/nested_sortable.js @@ -0,0 +1,360 @@ +/** @odoo-module **/ + +import { localization } from "@web/core/l10n/localization"; +import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder"; + +/** @typedef {import("@web/core/utils/draggable_hook_builder").DraggableHandlerParams} DraggableHandlerParams */ +/** @typedef {DraggableHandlerParams & { group: HTMLElement | null }} NestedSortableHandlerParams */ + +/** + * + * MANDATORY + * + * @property {{ el: HTMLElement | null }} ref + * + * OPTIONAL + * + * @property {boolean | () => boolean} [enable] whether the sortable system should + * be enabled. + * @property {string | () => string} [groups] defines parent groups of sortable + * elements. This allows to add `onGroupEnter` and `onGroupLeave` callbacks to + * 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 targeting elements that must + * initiate a drag. + * @property {boolean | () => boolean} [connectGroups] whether elements can be dragged + * 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} [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) + * + * HANDLERS (also optional) + * + * @property {(params: NestedSortableHandlerParams) => any} [onDragStart] called when a + * dragging sequence is initiated. + * @property {(params: MoveParams) => any} [onMove] called when the element has moved + * (changed position) (@see MoveParams). + * @property {(params: NestedSortableHandlerParams) => any} [onGroupEnter] called when + * the element enters a group. + * @property {(params: NestedSortableHandlerParams) => any} [onGroupLeave] called when + * the element leaves a group. + * @property {(params: MoveParams) => any} [onDrop] called when the dragging sequence + * ends on a mouseup action AND the dragged element has been moved elsewhere. The + * callback will be given an object with any useful element regarding the new position + * of the dragged element (@see MoveParams). + * @property {(params: NestedSortableHandlerParams) => any} [onDragEnd] called when the + * dragging sequence ends, regardless of the reason. + */ + +/** + * @typedef MoveParams + * @property {HTMLElement} element + * @property {HTMLElement | null} group + * @property {HTMLElement | null} previous + * @property {HTMLElement | null} next + * @property {HTMLElement | null} newGroup + * @property {HTMLElement | null} parent + * @property {HTMLElement} placeholder + */ + +/** + * @typedef SortableState + * @property {boolean} dragging + */ + +/** @type {(params: SortableParams) => SortableState} */ +export const useNestedSortable = makeDraggableHook({ + name: "useNestedSortable", + acceptedParams: { + groups: [String, Function], + connectGroups: [Boolean, Function], + nest: [Boolean], + listTagName: [String], + nestInterval: [Number], + }, + defaultParams: { + connectGroups: false, + currentGroup: null, + cursor: "grabbing", + edgeScrolling: { speed: 20, threshold: 60 }, + elements: "li", + groupSelector: null, + nest: false, + listTagName: "ul", + nestInterval: 15, + }, + + // Set the parameters. + onComputeParams({ ctx, params }) { + // Group selector + ctx.groupSelector = params.groups || null; + if (ctx.groupSelector) { + ctx.fullSelector = [ctx.groupSelector, ctx.fullSelector].join(" "); + } + // Connection across groups + ctx.connectGroups = params.connectGroups; + // Nested elements + ctx.nest = params.nest; + // 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; + ctx.isRTL = localization.direction === "rtl"; + }, + + // Set the current group and create the placeholder row that will take the + // place of the moving row. + onWillStartDrag({ ctx, addCleanup }) { + if (ctx.groupSelector) { + ctx.currentGroup = ctx.current.element.closest(ctx.groupSelector); + if (!ctx.connectGroups) { + ctx.current.container = ctx.currentGroup; + } + } + + if (ctx.nest) { + ctx.prevNestX = ctx.pointer.x; + } + ctx.current.placeHolder = ctx.current.element.cloneNode(false); + ctx.current.placeHolder.classList.add("o_nested_sortable_placeholder"); + addCleanup(() => ctx.current.placeHolder.remove()); + }, + + // Make the placeholder take the place of the moving row, and add style on + // different elements to provide feedback that there is an ongoing dragging + // sequence. + onDragStart({ ctx, addStyle }) { + // 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 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.elementRect.left + 1 + : ctx.current.elementRect.right - 1; + + // Placeholder is initially added right after the current element. + ctx.current.element.after(ctx.current.placeHolder); + addStyle(ctx.current.element, { opacity: 0.5 }); + + // Remove pointer-events style added by draggable_hook_builder and set + // it on the view elements instead as in our case we want to show the + // ctx.cursor style on the whole screen, not only in the ref el. + addStyle(document.body, { "pointer-events": "auto" }); + addStyle(document.querySelector(".o_navbar"), { "pointer-events": "none" }); + addStyle(document.querySelector(".o_action_manager"), { "pointer-events": "none" }); + addStyle(ctx.current.container, { "pointer-events": "auto" }); + + // Calls "onDragStart" handler + return { + element: ctx.current.element, + group: ctx.currentGroup, + }; + }, + // Check if the cursor moved enough to trigger a move. If it did, move the + // placeholder accordingly. + onDrag({ ctx, callHandler }) { + const onMove = (prevPos) => { + callHandler("onMove", { + element: ctx.current.element, + previous: ctx.current.placeHolder.previousElementSibling, + next: ctx.current.placeHolder.nextElementSibling, + parent: ctx.nest + ? ctx.current.placeHolder.parentElement.closest(ctx.elementSelector) + : false, + group: ctx.currentGroup, + newGroup: ctx.connectGroups + ? ctx.current.placeHolder.closest(ctx.groupSelector) + : ctx.currentGroup, + prevPos, + placeholder: ctx.current.placeHolder, + }); + }; + /** + * Get the list element inside an element, or create one if it does not + * exists. + * @param {HTMLElement} el + * @return {HTMLElement} list + */ + const getChildList = (el) => { + let list = el.querySelector(ctx.listTagName); + if (!list) { + list = document.createElement(ctx.listTagName); + el.appendChild(list); + } + return list; + }; + + const position = { + previous: ctx.current.placeHolder.previousElementSibling, + next: ctx.current.placeHolder.nextElementSibling, + parent: ctx.nest + ? ctx.current.placeHolder.parentElement.closest(ctx.elementSelector) + : false, + group: ctx.groupSelector ? ctx.current.placeHolder.closest(ctx.groupSelector) : false, + }; + /** If nesting elements is allowed, horizontal moves may change the + * parent of the placeholder element (the placeholder does not move + * above or under an element, but it changes parent): + * + * - Moving to the left makes the placeholder a child of the previous + * element up in the nested hierarchy, only if the placeholder is the + * last child of its current parent: + * + * Allowed: + * el el + * ┣ parent ┣ parent + * ┃ ┣ child --> ┃ ┗ child + * ┃ ┗ placeholder ┣ placeholder + * ┗ el ┗ el + * + * Not Allowed: + * el el + * ┣ parent ┣ parent + * ┃ ┣ placeholder --> ┣ p┃laceholder <-- error + * ┃ ┗ child ┃ ┗ child + * ┗ el ┗ el + * + * + * - Moving to the right makes the placeholder the last child of the + * next element down in the nested hierarchy: + * + * el el + * ┣ parent ┣ parent + * ┃ ┗ child --> ┃ ┣ child + * ┣ placeholder ┃ ┗ placeholder + * ┗ el ┗ el + */ + if (ctx.nest) { + const xInterval = ctx.prevNestX - ctx.pointer.x; + 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) + let nextElement = position.next; + if (nextElement === ctx.current.element) { + nextElement = nextElement.nextElementSibling; + } + if (!nextElement) { + const newSibling = position.parent; + if (newSibling) { + newSibling.after(ctx.current.placeHolder); + onMove(position); + } + } + // Recenter the pointer coordinates to this step + ctx.prevNestX = ctx.pointer.x; + 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 && 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 currentTop = ctx.pointer.y - ctx.current.offset.y; + const closestEl = document.elementFromPoint(ctx.selectorX, currentTop); + if (!closestEl) { + // Cursor outside of viewport + return; + } + const element = closestEl.closest(ctx.elementSelector); + // Vertical moves should move the placeholder element up or down. + if (element && element !== ctx.current.placeHolder) { + const eRect = element.getBoundingClientRect(); + const pos = ctx.current.placeHolder.compareDocumentPosition(element); + // Place placeholder before the hovered element in its parent's + // list. If the cursor is in the upper part of the element and + // if the placeholder is currently after or inside the hovered + // element. If the position is not allowed but nesting is allowed, + // place the placeholder as the last child of the previous sibling + // instead. + 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 (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 and if the element has at least one + // child, place the placeholder above the first child of the + // hovered element instead. + if (ctx.nest) { + 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 { + element.after(ctx.current.placeHolder); + onMove(position); + } + } + } else { + const group = closestEl.closest(ctx.groupSelector); + if (group && group !== position.group) { + if (group.compareDocumentPosition(position.group) === 2) { + getChildList(group).prepend(ctx.current.placeHolder); + onMove(position); + } else { + getChildList(group).appendChild(ctx.current.placeHolder); + onMove(position); + } + // Recenter the pointer coordinates to this step + ctx.prevNestX = ctx.pointer.x; + callHandler("onGroupEnter", { group, element: ctx.current.placeHolder }); + callHandler("onGroupLeave", { + group: position.group, + element: ctx.current.placeHolder, + }); + } + } + }, + // If the drop position is different from the starting position, run the + // onDrop handler from the parameters. + onDrop({ ctx }) { + const previous = ctx.current.placeHolder.previousElementSibling; + const next = ctx.current.placeHolder.nextElementSibling; + if (previous !== ctx.current.element && next !== ctx.current.element) { + return { + element: ctx.current.element, + group: ctx.currentGroup, + previous, + next, + newGroup: ctx.groupSelector && ctx.current.placeHolder.closest(ctx.groupSelector), + parent: ctx.current.placeHolder.parentElement.closest(ctx.elementSelector), + placeholder: ctx.current.placeHolder, + }; + } + }, + // Run the onDragEnd handler from the parameters. + onDragEnd({ ctx }) { + return { + element: ctx.current.element, + group: ctx.currentGroup, + }; + }, +}); 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([]); + }); +});