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