[IMP] web: add useSortableList hook to utils

Purpose:
-
The `useSortableList` hook introduced to replace the usage of the jquery library "nestedSortable" in knowledge's sidebar is moved to web so that it can be used in documents to allow the reordering of folders in the search panel (see the [related enterprise PR](https://github.com/odoo/enterprise/pull/45729))

Some tests have been added, as well as small fixes 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.

Task-3422012

closes odoo/odoo#131769

Related: odoo/enterprise#45729
Signed-off-by: Stéphane Debauche (std) <std@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-09-04 10:56:05 +00:00
committed by Odoo Online
3 changed files with 1348 additions and 0 deletions
@@ -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,
};
},
});
@@ -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%;
}
@@ -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`
<div t-ref="root">
<ul class="sortable_list">
<li t-foreach="[1,2,3]" t-as="i" t-key="i" class="item">
<span t-out="i"/>
<ul class="sub_list">
<li t-foreach="[1,2,3]" t-as="j" t-key="j" class="item">
<span t-out="j"/>
</li>
</ul>
</li>
</ul>
</div>
`;
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`
<div t-ref="root">
<ul class="sortable_list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" class="item" t-att-id="i">
<span t-out="i"/>
<ul class="sub_list">
<li t-foreach="[1, 2]" t-as="j" t-key="j" class="item" t-attf-id="{i},{j}">
<span t-out="i + '.' + j"/>
</li>
</ul>
</li>
</ul>
</div>
`;
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`
<div t-ref="root">
<section t-foreach="[1,2,3]" t-as="l" t-key="l" t-att-id="l" class="pb-1">
<ul class="sortable_list">
<li t-foreach="[1,2]" t-as="i" t-key="i" class="item" t-attf-id="#{l}.#{i}">
<span t-out="l + '.' + i"/>
<ul class="sub_list">
<li t-foreach="[1,2]" t-as="j" t-key="j" class="item" t-attf-id="#{l}.#{i}.#{j}">
<span t-out="l + '.' + i + '.' + j"/>
</li>
</ul>
</li>
</ul>
</section>
</div>
`;
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`
<div t-ref="root">
<ul class="sortable_list">
<li t-foreach="[1,2,3]" t-as="i" t-key="i" class="item" t-att-id="i">
<span t-out="i"/>
<ul class="sub_list">
<li t-attf-id="sub#{i}" class="item">
<span t-out="'sub' + i"/>
</li>
</ul>
</li>
</ul>
</div>
`;
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`
<div t-ref="root">
<ul class="sortable_list">
<li class="item" id="parent">
<span>parent</span>
<ul>
<li class="item" id="sub1">
<span>sub1</span>
<ul>
<li class="item" id="dragged">
<span>dragged</span>
</li>
</ul>
</li>
<li class="item" id="sub2">
<span>sub2</span>
</li>
</ul>
</li>
</ul>
</div>
`;
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`
<div t-ref="root">
<ul class="sortable_list">
<li class="item" id="dragged">
<span>dragged</span>
</li>
<li class="item" id="noChild">
<span>noChild</span>
</li>
<li class="item" id="parent">
<span>parent</span>
<ul>
<li class="item" id="child">
<span>item</span>
</li>
</ul>
</li>
</ul>
</div>
`;
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`
<div t-ref="root">
<ul class="sortable_list">
<li class="item" id="parent">
<span>parent</span>
<ul>
<li class="item" id="dragged">
<span>dragged</span>
</li>
<li class="item" id="child">
<span>item</span>
</li>
</ul>
</li>
<li class="item" id="noChild">
<span>noChild</span>
</li>
</ul>
</div>
`;
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`
<div t-ref="root">
<ul class="sortable_list">
<li class="item" id="parent">
<span>parent</span>
<ul>
<li class="item" id="child">
<span>child</span>
</li>
</ul>
</li>
<li class="item" id="noChild">
<span>noChild</span>
</li>
<li class="item" id="dragged">
<span>dragged</span>
</li>
</ul>
</div>
`;
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`
<div t-ref="root">
<ul class="sortable_list">
<li class="item" id="parent">
<span>parent</span>
<ul>
<li class="item" id="child">
<span>child</span>
</li>
<li class="item" id="dragged">
<span>dragged</span>
</li>
</ul>
</li>
</ul>
</div>
`;
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`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>
`;
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`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>
`;
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`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" class="item">
<span class="ignored" t-esc="i" />
<span class="not-ignored" t-esc="i" />
</li>
</ul>
</div>
`;
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([]);
});
});