[IMP] web: Draggable hook builder
This commit adds several improvements and overall changes the way draggable builders can be built, adding some helpers to temporary manipulate DOM elements during drag sequences and making some changes on the way drag callbacks are handled. The current implementations of draggable hooks built with this feature have been adapted. The drag test helpers have also been reviewed to more accurately mimick real-life scenarios and allow for more flexiblity (i.e. moving an element to a certain point during the drag sequence before being dropped). Part-of: odoo/odoo#110819 Co-authored-by: Julien Mougenot <jum@odoo.com> Co-authored-by: Bruno Boi <boi@odoo.com>
This commit is contained in:
co-authored by
Julien Mougenot
parent
1ac6ae1801
commit
24fb77d457
@@ -1,6 +1,9 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
/** @typedef {import("@web/core/utils/draggable_hook_builder").DraggableHandlerParams} DraggableHandlerParams */
|
||||
|
||||
/**
|
||||
* @typedef DraggableParams
|
||||
@@ -22,23 +25,16 @@ import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
|
||||
*
|
||||
* HANDLERS (also optional)
|
||||
*
|
||||
* @property {(DraggableHandlerParams) => any} [onDragStart]
|
||||
* @property {(params: DraggableHandlerParams) => any} [onDragStart]
|
||||
* called when a dragging sequence is initiated.
|
||||
* @property {(DraggableHandlerParams) => any} [onDrag]
|
||||
* @property {(params: DraggableHandlerParams) => any} [onDrag]
|
||||
* called on each "mousemove" during the drag sequence.
|
||||
* @property {(DraggableHandlerParams) => any} [onDragEnd]
|
||||
* @property {(params: DraggableHandlerParams) => any} [onDragEnd]
|
||||
* called when the dragging sequence ends, regardless of the reason.
|
||||
* @property {(DraggableHandlerParams) => any} [onDrop] called when the dragging sequence
|
||||
* @property {(params: DraggableHandlerParams) => any} [onDrop] called when the dragging sequence
|
||||
* ends on a mouseup action.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DraggableHandlerParams
|
||||
* @property {number} x current mouse position on the X axis
|
||||
* @property {number} y current mouse position on the Y axis
|
||||
* @property {HTMLElement} element
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DraggableState
|
||||
* @property {boolean} dragging
|
||||
@@ -47,16 +43,8 @@ import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
|
||||
/** @type {(params: DraggableParams) => DraggableState} */
|
||||
export const useDraggable = makeDraggableHook({
|
||||
name: "useDraggable",
|
||||
onDragStart({ ctx, helpers }) {
|
||||
helpers.execHandler("onDragStart", { element: ctx.currentElement });
|
||||
},
|
||||
onDrag({ ctx, helpers }) {
|
||||
helpers.execHandler("onDrag", { element: ctx.currentElement });
|
||||
},
|
||||
onDragEnd({ ctx, helpers }) {
|
||||
helpers.execHandler("onDragEnd", { element: ctx.currentElement });
|
||||
},
|
||||
onDrop({ ctx, helpers }) {
|
||||
helpers.execHandler("onDrop", { element: ctx.currentElement });
|
||||
},
|
||||
onDragStart: ({ ctx }) => pick(ctx.current, "element"),
|
||||
onDrag: ({ ctx }) => pick(ctx.current, "element"),
|
||||
onDragEnd: ({ ctx }) => pick(ctx.current, "element"),
|
||||
onDrop: ({ ctx }) => pick(ctx.current, "element"),
|
||||
});
|
||||
|
||||
@@ -1,78 +1,84 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { onWillUnmount, reactive, useEffect, useExternalListener } from "@odoo/owl";
|
||||
import { clamp } from "@web/core/utils/numbers";
|
||||
import { debounce, setRecurringAnimationFrame } from "@web/core/utils/timing";
|
||||
import { throttleForAnimation, setRecurringAnimationFrame } from "@web/core/utils/timing";
|
||||
|
||||
/**
|
||||
* @typedef Position
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef EdgeScrollingOptions
|
||||
* @property {boolean} [enabled=true]
|
||||
* @property {number} [speed=10]
|
||||
* @property {number} [threshold=20]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DraggableBuilderParams
|
||||
* @typedef CleanupManager
|
||||
* @property {(cleanupFn: Function) => void} add
|
||||
* @property {() => void} cleanup
|
||||
*
|
||||
* @typedef DOMHelpers
|
||||
* @property {(el: HTMLElement, ...classNames: string[]) => void} addClass
|
||||
* @property {(el: EventTarget, event: string, callback: (...args: any[]) => any, options?: boolean | Record<string, boolean>) => void} addListener
|
||||
* @property {(el: HTMLElement, style: Record<string, string | number>) => void} addStyle
|
||||
* @property {(el: HTMLElement, options?: { adjust?: boolean }) => DOMRect} getRect
|
||||
* @property {(el: HTMLElement, ...classNames: string[]) => void} removeClass
|
||||
* @property {(el: HTMLElement, properties: string[]) => void} removeStyle
|
||||
*
|
||||
* @typedef DraggableBuilderParams
|
||||
* Hook params
|
||||
* @property {string} [name="useAnonymousDraggable"]
|
||||
* @property {EdgeScrollingOptions} [edgeScrolling]
|
||||
* @property {Record<string, string[]>} [acceptedParams]
|
||||
* @property {Record<string, any>} [defaultParams]
|
||||
* Build hooks
|
||||
* @property {(params: DraggableBuildHandlerParams) => any} onComputeParams
|
||||
* Runtime hooks
|
||||
* @property {(params: DraggableBuildHandlerParams) => any} onDragStart
|
||||
* @property {(params: DraggableBuildHandlerParams) => any} onDrag
|
||||
* @property {(params: DraggableBuildHandlerParams) => any} onDragEnd
|
||||
* @property {(params: DraggableBuildHandlerParams) => any} onDrop
|
||||
* @property {(params: DraggableBuildHandlerParams) => any} onWillStartDrag
|
||||
*
|
||||
* Build handlers
|
||||
* @property {(params: DraggableBuilderHookParams) => any} onComputeParams
|
||||
*
|
||||
* Runtime handlers
|
||||
* @property {(params: DraggableBuilderHookParams) => any} onWillStartDrag
|
||||
* @property {(params: DraggableBuilderHookParams) => any} onDragStart
|
||||
* @property {(params: DraggableBuilderHookParams) => any} onDrag
|
||||
* @property {(params: DraggableBuilderHookParams) => any} onDragEnd
|
||||
* @property {(params: DraggableBuilderHookParams) => any} onDrop
|
||||
* @property {(params: DraggableBuilderHookParams) => any} onCleanup
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DraggableHookRunningContext
|
||||
* @typedef DraggableHookContext
|
||||
* @property {{ el: HTMLElement | null }} ref
|
||||
* @property {string | null} [elementSelector=null]
|
||||
* @property {string | null} [ignoreSelector=null]
|
||||
* @property {string | null} [fullSelector=null]
|
||||
* @property {boolean} [followCursor=true]
|
||||
* @property {string | null} [cursor=null]
|
||||
* @property {HTMLElement | null} [currentContainer=null]
|
||||
* @property {HTMLElement | null} [currentElement=null]
|
||||
* @property {DOMRect | null} [currentElementRect=null]
|
||||
* @property {HTMLElement | null} [scrollParent=null]
|
||||
* @property {boolean} [enabled=false]
|
||||
* @property {() => boolean} [enable=() => false]
|
||||
* @property {Position} [mouse={ x: 0, y: 0 }]
|
||||
* @property {Position} [offset={ x: 0, y: 0 }]
|
||||
* @property {EdgeScrollingOptions} [edgeScrolling]
|
||||
* @property {DraggableHookCurrentContext} current
|
||||
*
|
||||
* @typedef DraggableHookCurrentContext
|
||||
* @property {HTMLElement} [current.container]
|
||||
* @property {DOMRect} [current.containerRect]
|
||||
* @property {HTMLElement} [current.element]
|
||||
* @property {DOMRect} [current.elementRect]
|
||||
* @property {HTMLElement} [scrollParent]
|
||||
* @property {Position} [offset={ x: 0, y: 0 }]
|
||||
*
|
||||
* @typedef EdgeScrollingOptions
|
||||
* @property {boolean} [enabled=true]
|
||||
* @property {number} [speed=10]
|
||||
* @property {number} [threshold=20]
|
||||
*
|
||||
* @typedef Position
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
*
|
||||
* @typedef {DOMHelpers & {
|
||||
* ctx: DraggableHookContext,
|
||||
* addCleanup(cleanupFn: () => any): void,
|
||||
* addEffectCleanup(cleanupFn: () => any): void,
|
||||
* callHandler(handlerName: string, arg: Record<any, any>): void,
|
||||
* }} DraggableBuildHandlerParams
|
||||
*
|
||||
* @typedef {DOMHelpers & Position & { element: HTMLElement }} DraggableHandlerParams
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DraggableBuilderHookParams
|
||||
* @property {DraggableHookRunningContext} ctx
|
||||
* @property {Object} helpers
|
||||
* @property {Function} helpers.addListener
|
||||
* @property {Function} helpers.addStyle
|
||||
* @property {Function} helpers.execHandler
|
||||
*/
|
||||
|
||||
import { useEffect, useEnv, useExternalListener, onWillUnmount, reactive } from "@odoo/owl";
|
||||
|
||||
const DEFAULT_ACCEPTED_PARAMS = {
|
||||
enable: ["boolean", "function"],
|
||||
ref: ["object"],
|
||||
elements: ["string"],
|
||||
handle: ["string", "function"],
|
||||
ignore: ["string", "function"],
|
||||
cursor: ["string"],
|
||||
edgeScrolling: ["object", "function"],
|
||||
enable: [Boolean, Function],
|
||||
ref: [Object],
|
||||
elements: [String],
|
||||
handle: [String, Function],
|
||||
ignore: [String, Function],
|
||||
cursor: [String],
|
||||
edgeScrolling: [Object, Function],
|
||||
};
|
||||
const DEFAULT_DEFAULT_PARAMS = {
|
||||
enable: true,
|
||||
@@ -84,42 +90,39 @@ const DEFAULT_DEFAULT_PARAMS = {
|
||||
const DRAGGED_CLASS = "o_dragged";
|
||||
const LEFT_CLICK = 0;
|
||||
const MANDATORY_PARAMS = ["ref", "elements"];
|
||||
const WHITE_LISTED_KEYS = ["Alt", "Control", "Meta", "Shift"];
|
||||
|
||||
/**
|
||||
* Cancels the default behavior and propagation of a given event.
|
||||
* @param {Event} ev
|
||||
* Cache containing the elements in which an attribute has been modified by a hook.
|
||||
* It is global since multiple draggable hooks can interact with the same elements.
|
||||
* @type {Record<string, Set<HTMLElement>>}
|
||||
*/
|
||||
function cancelEvent(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
const elCache = {};
|
||||
|
||||
/**
|
||||
* Transforms a camelCased string to return its kebab-cased version.
|
||||
* Typically used to generate CSS properties from JS objects.
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function camelToKebab(str) {
|
||||
return str.replaceAll(
|
||||
/(?<=.)[A-Z]|[A-Z]/g,
|
||||
(match, prev) => `${prev ? "-" : ""}${match.toLowerCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the bounding rect of the given element. If the `adjust` option is set
|
||||
* to true, the rect will be reduced by the padding of the element.
|
||||
* @param {HTMLElement} el
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.adjust=false]
|
||||
* @returns {DOMRect}
|
||||
* @template T
|
||||
* @param {T | () => T} valueOrFn
|
||||
* @returns {T}
|
||||
*/
|
||||
function getRect(el, options = {}) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (options.adjust) {
|
||||
const style = getComputedStyle(el);
|
||||
const [pl, pr, pt, pb] = [
|
||||
"padding-left",
|
||||
"padding-right",
|
||||
"padding-top",
|
||||
"padding-bottom",
|
||||
].map((prop) => pixelValueToNumber(style.getPropertyValue(prop)));
|
||||
|
||||
rect.x += pl;
|
||||
rect.y += pt;
|
||||
rect.width -= pl + pr;
|
||||
rect.height -= pt + pb;
|
||||
function getReturnValue(valueOrFn) {
|
||||
if (typeof valueOrFn === "function") {
|
||||
return valueOrFn();
|
||||
}
|
||||
return rect;
|
||||
return valueOrFn;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +152,150 @@ function getScrollParent(el) {
|
||||
return getScrollParent(el.parentElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Function} [defaultCleanupFn]
|
||||
* @returns {CleanupManager}
|
||||
*/
|
||||
function makeCleanupManager(defaultCleanupFn) {
|
||||
/**
|
||||
* Registers the given cleanup function to be called when cleaning up hooks.
|
||||
* @param {Function} [cleanupFn]
|
||||
*/
|
||||
const add = (cleanupFn) => typeof cleanupFn === "function" && cleanups.push(cleanupFn);
|
||||
|
||||
/**
|
||||
* Runs all cleanup functions while clearing the cleanups list.
|
||||
*/
|
||||
const cleanup = () => {
|
||||
while (cleanups.length) {
|
||||
cleanups.pop()();
|
||||
}
|
||||
add(defaultCleanupFn);
|
||||
};
|
||||
|
||||
const cleanups = [];
|
||||
|
||||
add(defaultCleanupFn);
|
||||
|
||||
return { add, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CleanupManager} cleanup
|
||||
* @returns {DOMHelpers}
|
||||
*/
|
||||
function makeDOMHelpers(cleanup) {
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
* @param {...string} classNames
|
||||
*/
|
||||
const addClass = (el, ...classNames) => {
|
||||
if (!el || !classNames.length) {
|
||||
return;
|
||||
}
|
||||
cleanup.add(saveAttribute(el, "class"));
|
||||
el.classList.add(...classNames);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds an event listener to be cleaned up after the next drag sequence
|
||||
* has stopped. An additionnal `timeout` param allows the handler to be
|
||||
* delayed after a timeout.
|
||||
* @param {EventTarget} el
|
||||
* @param {string} event
|
||||
* @param {(...args: any[]) => any} callback
|
||||
* @param {boolean | Record<string, boolean>} [options]
|
||||
*/
|
||||
const addListener = (el, event, callback, options) => {
|
||||
if (!el || !event || !callback) {
|
||||
return;
|
||||
}
|
||||
el.addEventListener(event, callback, options);
|
||||
if (/pointer|mouse/.test(event)) {
|
||||
// Restore pointer events on elements listening on mouse/pointer events.
|
||||
addStyle(el, { pointerEvents: "auto" });
|
||||
}
|
||||
cleanup.add(() => el.removeEventListener(event, callback, options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds style to an element to be cleaned up after the next drag sequence has
|
||||
* stopped.
|
||||
* @param {HTMLElement} el
|
||||
* @param {Record<string, string | number>} style
|
||||
*/
|
||||
const addStyle = (el, style) => {
|
||||
if (!el || !style || !Object.keys(style).length) {
|
||||
return;
|
||||
}
|
||||
cleanup.add(saveAttribute(el, "style"));
|
||||
for (const key in style) {
|
||||
const [value, priority] = String(style[key]).split(/\s*!\s*/);
|
||||
el.style.setProperty(camelToKebab(key), value, priority);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the bounding rect of the given element. If the `adjust` option is set
|
||||
* to true, the rect will be reduced by the padding of the element.
|
||||
* @param {HTMLElement} el
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.adjust=false]
|
||||
* @returns {DOMRect}
|
||||
*/
|
||||
const getRect = (el, options = {}) => {
|
||||
if (!el) {
|
||||
return {};
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (options.adjust) {
|
||||
const style = getComputedStyle(el);
|
||||
const [pl, pr, pt, pb] = [
|
||||
"padding-left",
|
||||
"padding-right",
|
||||
"padding-top",
|
||||
"padding-bottom",
|
||||
].map((prop) => pixelValueToNumber(style.getPropertyValue(prop)));
|
||||
|
||||
rect.x += pl;
|
||||
rect.y += pt;
|
||||
rect.width -= pl + pr;
|
||||
rect.height -= pt + pb;
|
||||
}
|
||||
return rect;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
* @param {...string} classNames
|
||||
*/
|
||||
const removeClass = (el, ...classNames) => {
|
||||
if (!el || !classNames) {
|
||||
return;
|
||||
}
|
||||
cleanup.add(saveAttribute(el, "class"));
|
||||
el.classList.remove(...classNames);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds style to an element to be cleaned up after the next drag sequence has
|
||||
* stopped.
|
||||
* @param {HTMLElement} el
|
||||
* @param {...string} properties
|
||||
*/
|
||||
const removeStyle = (el, ...properties) => {
|
||||
if (!el || !properties?.length) {
|
||||
return;
|
||||
}
|
||||
cleanup.add(saveAttribute(el, "style"));
|
||||
for (const key of properties) {
|
||||
el.style.removeProperty(camelToKebab(key));
|
||||
}
|
||||
};
|
||||
|
||||
return { addClass, addListener, addStyle, getRect, removeClass, removeStyle };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a CSS pixel value to a number, removing the 'px' part.
|
||||
* @param {string} val
|
||||
@@ -158,26 +305,69 @@ function pixelValueToNumber(val) {
|
||||
return Number(val.endsWith("px") ? val.slice(0, -2) : val);
|
||||
}
|
||||
|
||||
function saveAttribute(el, attribute) {
|
||||
const restoreAttribute = () => {
|
||||
cache.delete(el);
|
||||
if (originalValue) {
|
||||
el.setAttribute(attribute, originalValue);
|
||||
} else {
|
||||
el.removeAttribute(attribute);
|
||||
}
|
||||
};
|
||||
|
||||
if (!(attribute in elCache)) {
|
||||
elCache[attribute] = new Set();
|
||||
}
|
||||
const cache = elCache[attribute];
|
||||
|
||||
if (cache.has(el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.add(el);
|
||||
const originalValue = el.getAttribute(attribute);
|
||||
|
||||
return restoreAttribute;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T | () => T} value
|
||||
* @returns {() => T}
|
||||
*/
|
||||
function toFunction(value) {
|
||||
return typeof value === "function" ? value : () => value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DraggableBuilderParams} hookParams
|
||||
* @returns {(params: Record<any, any>) => { dragging: boolean }}
|
||||
*/
|
||||
export function makeDraggableHook(hookParams = {}) {
|
||||
export function makeDraggableHook(hookParams) {
|
||||
hookParams = getReturnValue(hookParams);
|
||||
|
||||
const hookName = hookParams.name || "useAnonymousDraggable";
|
||||
const allAcceptedParams = { ...DEFAULT_ACCEPTED_PARAMS, ...hookParams.acceptedParams };
|
||||
const defaultParams = { ...DEFAULT_DEFAULT_PARAMS, ...hookParams.defaultParams };
|
||||
|
||||
/**
|
||||
* Computes the current params and converts the params definition
|
||||
* @param {SortableParams} params
|
||||
* @returns {[string, string | boolean][]}
|
||||
*/
|
||||
const computeParams = (params) => {
|
||||
const computedParams = { enable: true };
|
||||
const computedParams = { enable: () => true };
|
||||
for (const prop in allAcceptedParams) {
|
||||
if (prop in params) {
|
||||
computedParams[prop] = params[prop];
|
||||
if (typeof params[prop] === "function") {
|
||||
computedParams[prop] = computedParams[prop]();
|
||||
if (prop === "enable") {
|
||||
computedParams[prop] = toFunction(params[prop]);
|
||||
} else if (
|
||||
allAcceptedParams[prop].length === 1 &&
|
||||
allAcceptedParams[prop][0] === Function
|
||||
) {
|
||||
computedParams[prop] = params[prop];
|
||||
} else {
|
||||
computedParams[prop] = getReturnValue(params[prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,34 +384,35 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
return {
|
||||
[hookName](params) {
|
||||
/**
|
||||
* Adds an event listener to be cleaned up after the next drag sequence
|
||||
* has stopped. An additionnal `timeout` param allows the handler to be
|
||||
* delayed after a timeout.
|
||||
* @param {EventTarget} el
|
||||
* @param {string} event
|
||||
* @param {(...args: any[]) => any} callback
|
||||
* @param {boolean | Record<string, boolean>} [options]
|
||||
* Executes a handler from the `hookParams`.
|
||||
* @param {string} hookHandlerName
|
||||
* @param {Record<any, any>} arg
|
||||
*/
|
||||
const addListener = (el, event, callback, options) => {
|
||||
el.addEventListener(event, callback, options);
|
||||
cleanups.push(() => el.removeEventListener(event, callback, options));
|
||||
const callBuildHandler = (hookHandlerName, arg) => {
|
||||
if (typeof hookParams[hookHandlerName] !== "function") {
|
||||
return;
|
||||
}
|
||||
const returnValue = hookParams[hookHandlerName]({ ctx, ...helpers, ...arg });
|
||||
if (returnValue) {
|
||||
callHandler(hookHandlerName, returnValue);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds style to an element to be cleaned up after the next drag sequence has
|
||||
* stopped.
|
||||
* @param {HTMLElement} el
|
||||
* @param {Record<string, string | number>} style
|
||||
* Safely executes a handler from the `params`, so that the drag sequence can
|
||||
* be interrupted if an error occurs.
|
||||
* @param {string} handlerName
|
||||
* @param {Record<any, any>} arg
|
||||
*/
|
||||
const addStyle = (el, style) => {
|
||||
const originalStyle = el.getAttribute("style");
|
||||
cleanups.push(() =>
|
||||
originalStyle
|
||||
? el.setAttribute("style", originalStyle)
|
||||
: el.removeAttribute("style")
|
||||
);
|
||||
for (const key in style) {
|
||||
el.style[key] = style[key];
|
||||
const callHandler = (handlerName, arg) => {
|
||||
if (typeof params[handlerName] !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
params[handlerName]({ ...dom, ...ctx.mouse, ...arg });
|
||||
} catch (err) {
|
||||
dragEnd(null, true);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,109 +423,64 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
state.dragging = true;
|
||||
|
||||
// Compute scrollable parent
|
||||
ctx.scrollParent = getScrollParent(ctx.currentContainer);
|
||||
ctx.current.scrollParent = getScrollParent(ctx.current.container);
|
||||
|
||||
const [eRect] = updateRects();
|
||||
|
||||
// Binds handlers on eligible elements
|
||||
for (const siblingEl of ctx.ref.el.querySelectorAll(ctx.elementSelector)) {
|
||||
if (siblingEl !== ctx.currentElement) {
|
||||
addStyle(siblingEl, { "pointer-events": "auto" });
|
||||
}
|
||||
}
|
||||
updateRects();
|
||||
const { x, y, width, height } = ctx.current.elementRect;
|
||||
|
||||
// Adjusts the offset
|
||||
ctx.offset.x -= eRect.x;
|
||||
ctx.offset.y -= eRect.y;
|
||||
ctx.current.offset.x -= x;
|
||||
ctx.current.offset.y -= y;
|
||||
|
||||
addStyle(ctx.currentElement, {
|
||||
width: `${eRect.width}px`,
|
||||
height: `${eRect.height}px`,
|
||||
left: `${eRect.x}px`,
|
||||
top: `${eRect.y}px`,
|
||||
if (ctx.followCursor) {
|
||||
dom.addStyle(ctx.current.element, {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
position: "fixed !important",
|
||||
});
|
||||
|
||||
// First adjustment
|
||||
updateElementPosition();
|
||||
}
|
||||
|
||||
dom.addStyle(document.body, {
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
});
|
||||
|
||||
const bodyStyle = {
|
||||
"pointer-events": "none",
|
||||
"user-select": "none",
|
||||
};
|
||||
if (ctx.cursor) {
|
||||
bodyStyle.cursor = ctx.cursor;
|
||||
dom.addStyle(document.body, { cursor: ctx.cursor });
|
||||
}
|
||||
|
||||
addStyle(document.body, bodyStyle);
|
||||
|
||||
if (ctx.scrollParent && ctx.edgeScrolling.enabled) {
|
||||
if (ctx.current.scrollParent && ctx.edgeScrolling.enabled) {
|
||||
const cleanupFn = setRecurringAnimationFrame(handleEdgeScrolling);
|
||||
cleanups.push(cleanupFn);
|
||||
cleanup.add(cleanupFn);
|
||||
}
|
||||
|
||||
ctx.currentElement.classList.add(DRAGGED_CLASS);
|
||||
cleanups.push(() => ctx.currentElement.classList.remove(DRAGGED_CLASS));
|
||||
dom.addClass(ctx.current.element, DRAGGED_CLASS);
|
||||
|
||||
execBuildHandler("onDragStart");
|
||||
callBuildHandler("onDragStart");
|
||||
};
|
||||
|
||||
/**
|
||||
* Main exit function to stop a drag sequence. Note that it can be called
|
||||
* even if a drag sequence did not start yet to perform a cleanup of all
|
||||
* current context variables.
|
||||
* @param {boolean} cancelled
|
||||
* @param {HTMLElement | null} target
|
||||
* @param {boolean} [inErrorState] can be set to true when an error
|
||||
* occurred to avoid falling into an infinite loop if the error
|
||||
* originated from one of the handlers.
|
||||
*/
|
||||
const dragEnd = (cancelled, inErrorState) => {
|
||||
const dragEnd = (target, inErrorState) => {
|
||||
if (state.dragging) {
|
||||
if (!inErrorState) {
|
||||
execBuildHandler("onDragEnd");
|
||||
if (!cancelled) {
|
||||
execBuildHandler("onDrop");
|
||||
if (target) {
|
||||
callBuildHandler("onDrop", { target });
|
||||
}
|
||||
callBuildHandler("onDragEnd");
|
||||
}
|
||||
}
|
||||
|
||||
execBuildHandler("onCleanup");
|
||||
|
||||
// Performs all registered clean-ups.
|
||||
while (cleanups.length) {
|
||||
cleanups.pop()();
|
||||
}
|
||||
|
||||
ctx.currentContainer = null;
|
||||
ctx.currentElement = null;
|
||||
ctx.currentElementRect = null;
|
||||
ctx.scrollParent = null;
|
||||
|
||||
state.dragging = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes a handler from the `hookParams`.
|
||||
* @param {string} fnName
|
||||
* @param {Record<any, any>} arg
|
||||
*/
|
||||
const execBuildHandler = (fnName, arg) => {
|
||||
if (typeof hookParams[fnName] === "function") {
|
||||
hookParams[fnName]({ ctx, helpers: buildHelpers, ...arg });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely executes a handler from the `params`, so that the drag sequence can
|
||||
* be interrupted if an error occurs.
|
||||
* @param {string} callbackName
|
||||
* @param {Record<any, any>} arg
|
||||
*/
|
||||
const execHandler = (callbackName, arg) => {
|
||||
if (typeof params[callbackName] === "function") {
|
||||
try {
|
||||
params[callbackName]({ ...ctx.mouse, ...arg });
|
||||
} catch (err) {
|
||||
dragEnd(true, true);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
cleanup.cleanup();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -342,7 +488,9 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
* the edge of the container.
|
||||
*/
|
||||
const handleEdgeScrolling = (deltaTime) => {
|
||||
const [eRect, cRect] = updateRects();
|
||||
updateRects();
|
||||
const eRect = ctx.current.elementRect;
|
||||
const cRect = ctx.current.containerRect;
|
||||
|
||||
const { speed, threshold } = ctx.edgeScrolling;
|
||||
const correctedSpeed = (speed / 16) * deltaTime;
|
||||
@@ -362,6 +510,13 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
diff.y = [maxHeight - eRect.y - eRect.height, 1];
|
||||
}
|
||||
|
||||
if (diff.x && !diff.x[0]) {
|
||||
delete diff.x;
|
||||
}
|
||||
if (diff.y && !diff.y[0]) {
|
||||
delete diff.y;
|
||||
}
|
||||
|
||||
if (diff.x || diff.y) {
|
||||
const diffToScroll = ([delta, sign]) =>
|
||||
(1 - clamp(delta, 0, threshold) / threshold) * correctedSpeed * sign;
|
||||
@@ -372,7 +527,7 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
if (diff.y) {
|
||||
scrollParams.top = diffToScroll(diff.y);
|
||||
}
|
||||
ctx.scrollParent.scrollBy(scrollParams);
|
||||
ctx.current.scrollParent.scrollBy(scrollParams);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,16 +535,16 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
* Window "keydown" event handler.
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
const onKeydown = (ev) => {
|
||||
if (!ctx.enabled || !state.dragging) {
|
||||
const onKeyDown = (ev) => {
|
||||
if (!state.dragging || !ctx.enable()) {
|
||||
return;
|
||||
}
|
||||
switch (ev.key) {
|
||||
case "Escape":
|
||||
case "Tab": {
|
||||
cancelEvent(ev);
|
||||
dragEnd(true);
|
||||
}
|
||||
if (!WHITE_LISTED_KEYS.includes(ev.key)) {
|
||||
ev.stopImmediatePropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
// Cancels drag sequences on every non-whitelisted key down event.
|
||||
dragEnd(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -397,16 +552,16 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
* Global (= ref) "mousedown" event handler.
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
const onMousedown = (ev) => {
|
||||
const onMouseDown = (ev) => {
|
||||
updateMousePosition(ev);
|
||||
|
||||
// A drag sequence can still be in progress if the mouseup occurred
|
||||
// outside of the window.
|
||||
dragEnd(true);
|
||||
dragEnd(null);
|
||||
|
||||
if (
|
||||
ev.button !== LEFT_CLICK ||
|
||||
!ctx.enabled ||
|
||||
!ctx.enable() ||
|
||||
!ev.target.closest(ctx.fullSelector) ||
|
||||
(ctx.ignoreSelector && ev.target.closest(ctx.ignoreSelector))
|
||||
) {
|
||||
@@ -421,53 +576,62 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=339293
|
||||
ev.preventDefault();
|
||||
|
||||
ctx.currentContainer = ctx.ref.el;
|
||||
ctx.currentElement = ev.target.closest(ctx.elementSelector);
|
||||
ctx.current.element = ev.target.closest(ctx.elementSelector);
|
||||
ctx.current.container = ctx.ref.el;
|
||||
ctx.current.offset = { ...ctx.mouse };
|
||||
|
||||
Object.assign(ctx.offset, ctx.mouse);
|
||||
cleanup.add(() => (ctx.current = {}));
|
||||
|
||||
execBuildHandler("onWillStartDrag");
|
||||
callBuildHandler("onWillStartDrag");
|
||||
};
|
||||
|
||||
/**
|
||||
* Window "mousemove" event handler.
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
const onMousemove = (ev) => {
|
||||
const onMouseMove = (ev) => {
|
||||
updateMousePosition(ev);
|
||||
|
||||
if (!ctx.enabled || !ctx.currentElement) {
|
||||
if (!ctx.current.element || !ctx.enable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
if (!state.dragging) {
|
||||
dragStart();
|
||||
}
|
||||
if (state.dragging) {
|
||||
const [eRect, cRect] = updateRects();
|
||||
|
||||
// Updates the position of the dragged element.
|
||||
ctx.currentElement.style.left = `${clamp(
|
||||
ctx.mouse.x - ctx.offset.x,
|
||||
cRect.x,
|
||||
cRect.x + cRect.width - eRect.width
|
||||
)}px`;
|
||||
ctx.currentElement.style.top = `${clamp(
|
||||
ctx.mouse.y - ctx.offset.y,
|
||||
cRect.y,
|
||||
cRect.y + cRect.height - eRect.height
|
||||
)}px`;
|
||||
|
||||
execBuildHandler("onDrag");
|
||||
if (ctx.followCursor) {
|
||||
updateElementPosition();
|
||||
}
|
||||
|
||||
callBuildHandler("onDrag");
|
||||
};
|
||||
|
||||
/**
|
||||
* Window "mouseup" event handler.
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
const onMouseup = (ev) => {
|
||||
const onMouseUp = (ev) => {
|
||||
updateMousePosition(ev);
|
||||
dragEnd(false);
|
||||
dragEnd(ev.target);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the position of the current dragged element according to
|
||||
* the current mouse position.
|
||||
*/
|
||||
const updateElementPosition = () => {
|
||||
const { containerRect, element, elementRect, offset } = ctx.current;
|
||||
const { width: ew, height: eh } = elementRect;
|
||||
const { x: cx, y: cy, width: cw, height: ch } = containerRect;
|
||||
|
||||
// Updates the position of the dragged element.
|
||||
dom.addStyle(element, {
|
||||
left: `${clamp(ctx.mouse.x - offset.x, cx, cx + cw - ew)}px`,
|
||||
top: `${clamp(ctx.mouse.y - offset.y, cy, cy + ch - eh)}px`,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -479,71 +643,90 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
ctx.mouse.y = ev.clientY;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {DOMRect[]}
|
||||
*/
|
||||
const updateRects = () => {
|
||||
const { current } = ctx;
|
||||
const { container, element, scrollParent } = current;
|
||||
// Container rect
|
||||
const containerRect = getRect(ctx.currentContainer, { adjust: true });
|
||||
if (ctx.scrollParent) {
|
||||
current.containerRect = dom.getRect(container, { adjust: true });
|
||||
if (scrollParent) {
|
||||
// Adjust container rect according to scrollparent
|
||||
const parentRect = getRect(ctx.scrollParent, { adjust: true });
|
||||
containerRect.x = Math.max(containerRect.x, parentRect.x);
|
||||
containerRect.y = Math.max(containerRect.y, parentRect.y);
|
||||
containerRect.width = Math.min(containerRect.width, parentRect.width);
|
||||
containerRect.height = Math.min(containerRect.height, parentRect.height);
|
||||
const parentRect = dom.getRect(scrollParent, { adjust: true });
|
||||
current.containerRect.x = Math.max(current.containerRect.x, parentRect.x);
|
||||
current.containerRect.y = Math.max(current.containerRect.y, parentRect.y);
|
||||
current.containerRect.width = Math.min(
|
||||
current.containerRect.width,
|
||||
parentRect.width
|
||||
);
|
||||
current.containerRect.height = Math.min(
|
||||
current.containerRect.height,
|
||||
parentRect.height
|
||||
);
|
||||
}
|
||||
|
||||
// Element rect
|
||||
ctx.currentElementRect = getRect(ctx.currentElement);
|
||||
ctx.current.elementRect = dom.getRect(element);
|
||||
};
|
||||
|
||||
return [ctx.currentElementRect, containerRect];
|
||||
// Initialize helpers
|
||||
const cleanup = makeCleanupManager(() => (state.dragging = false));
|
||||
const effectCleanup = makeCleanupManager();
|
||||
const dom = makeDOMHelpers(cleanup);
|
||||
|
||||
const helpers = {
|
||||
...dom,
|
||||
addCleanup: cleanup.add,
|
||||
addEffectCleanup: effectCleanup.add,
|
||||
callHandler,
|
||||
};
|
||||
|
||||
// Component infos
|
||||
const env = useEnv();
|
||||
const state = reactive({ dragging: false });
|
||||
|
||||
// Basic error handling asserting that the parameters are valid.
|
||||
for (const prop in allAcceptedParams) {
|
||||
if (params[prop] && !allAcceptedParams[prop].includes(typeof params[prop])) {
|
||||
throw makeError(`invalid type for property "${prop}" in parameters`);
|
||||
} else if (!params[prop] && MANDATORY_PARAMS.includes(prop)) {
|
||||
const type = typeof params[prop];
|
||||
const acceptedTypes = allAcceptedParams[prop].map((t) => t.name.toLowerCase());
|
||||
if (params[prop]) {
|
||||
if (!acceptedTypes.includes(type)) {
|
||||
throw makeError(
|
||||
`invalid type for property "${prop}" in parameters: expected { ${acceptedTypes.join(
|
||||
", "
|
||||
)} } and got ${type}`
|
||||
);
|
||||
}
|
||||
} else if (MANDATORY_PARAMS.includes(prop)) {
|
||||
throw makeError(`missing required property "${prop}" in parameters`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build helpers
|
||||
const buildHelpers = { addListener, addStyle, execHandler };
|
||||
|
||||
/** @type {(() => any)[]} */
|
||||
const cleanups = [];
|
||||
|
||||
/** @type {DraggableHookRunningContext} */
|
||||
/** @type {DraggableHookContext} */
|
||||
const ctx = {
|
||||
enable: () => false,
|
||||
ref: params.ref,
|
||||
ignoreSelector: null,
|
||||
fullSelector: null,
|
||||
followCursor: true,
|
||||
cursor: null,
|
||||
currentContainer: null,
|
||||
currentElement: null,
|
||||
currentElementRect: null,
|
||||
scrollParent: null,
|
||||
enabled: false,
|
||||
mouse: { x: 0, y: 0 },
|
||||
offset: { x: 0, y: 0 },
|
||||
edgeScrolling: { enabled: true },
|
||||
get dragging() {
|
||||
return state.dragging;
|
||||
},
|
||||
// Current context
|
||||
current: {},
|
||||
};
|
||||
|
||||
// Effect depending on the params to update them.
|
||||
useEffect(
|
||||
(...deps) => {
|
||||
const actualParams = { ...defaultParams, ...Object.fromEntries(deps) };
|
||||
ctx.enabled = Boolean(ctx.ref.el && !env.isSmall && actualParams.enable);
|
||||
if (!ctx.enabled) {
|
||||
if (!ctx.ref.el) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable getter
|
||||
ctx.enable = actualParams.enable;
|
||||
|
||||
// Selectors
|
||||
ctx.elementSelector = actualParams.elements;
|
||||
if (!ctx.elementSelector) {
|
||||
@@ -561,9 +744,13 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
}
|
||||
ctx.fullSelector = allSelectors.join(" ");
|
||||
|
||||
// Edge scrolling
|
||||
Object.assign(ctx.edgeScrolling, actualParams.edgeScrolling);
|
||||
|
||||
execBuildHandler("onComputeParams", { params: actualParams });
|
||||
callBuildHandler("onComputeParams", { params: actualParams });
|
||||
|
||||
// Calls effect cleanup functions when preparing to re-render.
|
||||
return effectCleanup.cleanup;
|
||||
},
|
||||
() => computeParams(params)
|
||||
);
|
||||
@@ -571,18 +758,18 @@ export function makeDraggableHook(hookParams = {}) {
|
||||
useEffect(
|
||||
(el) => {
|
||||
if (el) {
|
||||
el.addEventListener("mousedown", onMousedown);
|
||||
return () => el.removeEventListener("mousedown", onMousedown);
|
||||
el.addEventListener("mousedown", onMouseDown);
|
||||
return () => el.removeEventListener("mousedown", onMouseDown);
|
||||
}
|
||||
},
|
||||
() => [ctx.ref.el]
|
||||
);
|
||||
// Other global mouse event listeners.
|
||||
const debouncedOnMouseMove = debounce(onMousemove, "animationFrame", true);
|
||||
useExternalListener(window, "mousemove", debouncedOnMouseMove);
|
||||
useExternalListener(window, "mouseup", onMouseup);
|
||||
useExternalListener(window, "keydown", onKeydown, true);
|
||||
onWillUnmount(() => dragEnd(true));
|
||||
// Other global event listeners.
|
||||
const throttledOnMouseMove = throttleForAnimation(onMouseMove);
|
||||
useExternalListener(window, "mousemove", throttledOnMouseMove);
|
||||
useExternalListener(window, "mouseup", onMouseUp);
|
||||
useExternalListener(window, "keydown", onKeyDown, true);
|
||||
onWillUnmount(() => dragEnd(null));
|
||||
|
||||
return state;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.o_dragged {
|
||||
position: fixed !important;
|
||||
z-index: 1000 !important;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
/** @typedef {import("@web/core/utils/draggable_hook_builder").DraggableHandlerParams} DraggableHandlerParams */
|
||||
/** @typedef {DraggableHandlerParams & { group: HTMLElement | null }} SortableHandlerParams */
|
||||
|
||||
/**
|
||||
* @typedef SortableParams
|
||||
@@ -27,17 +31,17 @@ import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
|
||||
*
|
||||
* HANDLERS (also optional)
|
||||
*
|
||||
* @property {(p: { element: HTMLElement, group: HTMLElement | null }) => any} [onDragStart]
|
||||
* @property {(params: SortableHandlerParams) => any} [onDragStart]
|
||||
* called when a dragging sequence is initiated.
|
||||
* @property {(p: { element: HTMLElement }) => any} [onElementEnter] called when the cursor
|
||||
* @property {(params: DraggableHandlerParams) => any} [onElementEnter] called when the cursor
|
||||
* enters another sortable element.
|
||||
* @property {(p: { element: HTMLElement }) => any} [onElementLeave] called when the cursor
|
||||
* @property {(params: DraggableHandlerParams) => any} [onElementLeave] called when the cursor
|
||||
* leaves another sortable element.
|
||||
* @property {(p: { group: HTMLElement }) => any} [onGroupEnter] (if a `groups` is specified):
|
||||
* @property {(params: SortableHandlerParams) => any} [onGroupEnter] (if a `groups` is specified):
|
||||
* will be called when the cursor enters another group element.
|
||||
* @property {(p: { group: HTMLElement }) => any} [onGroupLeave] (if a `groups` is specified):
|
||||
* @property {(params: SortableHandlerParams) => any} [onGroupLeave] (if a `groups` is specified):
|
||||
* will be called when the cursor leaves another group element.
|
||||
* @property {(p: { element: HTMLElement, group: HTMLElement | null }) => any} [onDragEnd]
|
||||
* @property {(params: SortableHandlerParams) => any} [onDragEnd]
|
||||
* called when the dragging sequence ends, regardless of the reason.
|
||||
* @property {(params: DropParams) => any} [onDrop] called when the dragging sequence
|
||||
* ends on a mouseup action AND the dragged element has been moved elsewhere. The
|
||||
@@ -63,14 +67,12 @@ import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
|
||||
export const useSortable = makeDraggableHook({
|
||||
name: "useSortable",
|
||||
acceptedParams: {
|
||||
groups: ["string", "function"],
|
||||
connectGroups: ["boolean", "function"],
|
||||
groups: [String, Function],
|
||||
connectGroups: [Boolean, Function],
|
||||
},
|
||||
defaultParams: {
|
||||
connectGroups: false,
|
||||
currentGroup: null,
|
||||
edgeScrolling: { speed: 20, threshold: 60 },
|
||||
ghostElement: null,
|
||||
groupSelector: null,
|
||||
},
|
||||
|
||||
@@ -87,7 +89,7 @@ export const useSortable = makeDraggableHook({
|
||||
},
|
||||
|
||||
// Runtime steps
|
||||
onDragStart({ ctx, helpers }) {
|
||||
onDragStart({ ctx, addListener, addStyle, callHandler }) {
|
||||
/**
|
||||
* Element "mouseenter" event handler.
|
||||
* @param {MouseEvent} ev
|
||||
@@ -95,18 +97,18 @@ export const useSortable = makeDraggableHook({
|
||||
const onElementMouseenter = (ev) => {
|
||||
const element = ev.currentTarget;
|
||||
if (
|
||||
ctx.connectGroups ||
|
||||
!ctx.groupSelector ||
|
||||
ctx.currentGroup === element.closest(ctx.groupSelector)
|
||||
connectGroups ||
|
||||
!groupSelector ||
|
||||
current.group === element.closest(groupSelector)
|
||||
) {
|
||||
const pos = ctx.ghostElement.compareDocumentPosition(element);
|
||||
const pos = current.placeHolder.compareDocumentPosition(element);
|
||||
if (pos === 2 /* BEFORE */) {
|
||||
element.before(ctx.ghostElement);
|
||||
element.before(current.placeHolder);
|
||||
} else if (pos === 4 /* AFTER */) {
|
||||
element.after(ctx.ghostElement);
|
||||
element.after(current.placeHolder);
|
||||
}
|
||||
}
|
||||
helpers.execHandler("onElementEnter", { element });
|
||||
callHandler("onElementEnter", { element });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,7 +117,7 @@ export const useSortable = makeDraggableHook({
|
||||
*/
|
||||
const onElementMouseleave = (ev) => {
|
||||
const element = ev.currentTarget;
|
||||
helpers.execHandler("onElementLeave", { element });
|
||||
callHandler("onElementLeave", { element });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,8 +126,8 @@ export const useSortable = makeDraggableHook({
|
||||
*/
|
||||
const onGroupMouseenter = (ev) => {
|
||||
const group = ev.currentTarget;
|
||||
group.appendChild(ctx.ghostElement);
|
||||
helpers.execHandler("onGroupEnter", { group });
|
||||
group.appendChild(current.placeHolder);
|
||||
callHandler("onGroupEnter", { group });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -134,72 +136,71 @@ export const useSortable = makeDraggableHook({
|
||||
*/
|
||||
const onGroupMouseleave = (ev) => {
|
||||
const group = ev.currentTarget;
|
||||
helpers.execHandler("onGroupLeave", { group });
|
||||
callHandler("onGroupLeave", { group });
|
||||
};
|
||||
|
||||
const { width, height } = ctx.currentElementRect;
|
||||
const { connectGroups, current, elementSelector, groupSelector, ref } = ctx;
|
||||
const { width, height } = current.elementRect;
|
||||
|
||||
// Ghost element is hidden and its size is frozen
|
||||
ctx.ghostElement.style = `visibility: hidden; display: block; width: ${width}px; height:${height}px;`;
|
||||
// Adjusts size for the placeholder element
|
||||
addStyle(current.placeHolder, {
|
||||
visibility: "hidden",
|
||||
display: "block",
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
});
|
||||
|
||||
// Binds handlers on eligible groups, if the elements are not confined to
|
||||
// their parents and a 'groupSelector' has been provided.
|
||||
if (ctx.connectGroups && ctx.groupSelector) {
|
||||
for (const siblingGroup of ctx.ref.el.querySelectorAll(ctx.groupSelector)) {
|
||||
helpers.addListener(siblingGroup, "mouseenter", onGroupMouseenter);
|
||||
helpers.addListener(siblingGroup, "mouseleave", onGroupMouseleave);
|
||||
helpers.addStyle(siblingGroup, { "pointer-events": "auto" });
|
||||
if (connectGroups && groupSelector) {
|
||||
for (const siblingGroup of ref.el.querySelectorAll(groupSelector)) {
|
||||
addListener(siblingGroup, "mouseenter", onGroupMouseenter);
|
||||
addListener(siblingGroup, "mouseleave", onGroupMouseleave);
|
||||
}
|
||||
}
|
||||
|
||||
// Binds handlers on eligible elements
|
||||
for (const siblingEl of ctx.ref.el.querySelectorAll(ctx.elementSelector)) {
|
||||
if (siblingEl !== ctx.currentElement && siblingEl !== ctx.ghostElement) {
|
||||
helpers.addListener(siblingEl, "mouseenter", onElementMouseenter);
|
||||
helpers.addListener(siblingEl, "mouseleave", onElementMouseleave);
|
||||
for (const siblingEl of ref.el.querySelectorAll(elementSelector)) {
|
||||
if (siblingEl !== current.element && siblingEl !== current.placeHolder) {
|
||||
addListener(siblingEl, "mouseenter", onElementMouseenter);
|
||||
addListener(siblingEl, "mouseleave", onElementMouseleave);
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost is initially added right after the current element.
|
||||
ctx.currentElement.after(ctx.ghostElement);
|
||||
// Placeholder is initially added right after the current element.
|
||||
current.element.after(current.placeHolder);
|
||||
|
||||
// Calls "onDragStart" handler
|
||||
helpers.execHandler("onDragStart", {
|
||||
element: ctx.currentElement,
|
||||
group: ctx.currentGroup,
|
||||
});
|
||||
return pick(current, "element", "group");
|
||||
},
|
||||
onDragEnd({ ctx, helpers }) {
|
||||
helpers.execHandler("onDragEnd", { element: ctx.currentElement, group: ctx.currentGroup });
|
||||
onDragEnd({ ctx }) {
|
||||
return pick(ctx.current, "element", "group");
|
||||
},
|
||||
onDrop({ ctx, helpers }) {
|
||||
const previous = ctx.ghostElement.previousElementSibling;
|
||||
const next = ctx.ghostElement.nextElementSibling;
|
||||
if (previous !== ctx.currentElement && next !== ctx.currentElement) {
|
||||
helpers.execHandler("onDrop", {
|
||||
element: ctx.currentElement,
|
||||
group: ctx.currentGroup,
|
||||
onDrop({ ctx }) {
|
||||
const { current, groupSelector } = ctx;
|
||||
const previous = current.placeHolder.previousElementSibling;
|
||||
const next = current.placeHolder.nextElementSibling;
|
||||
if (previous !== current.element && next !== current.element) {
|
||||
return {
|
||||
element: current.element,
|
||||
group: current.group,
|
||||
previous,
|
||||
next,
|
||||
parent: ctx.groupSelector && ctx.ghostElement.closest(ctx.groupSelector),
|
||||
});
|
||||
parent: groupSelector && current.placeHolder.closest(groupSelector),
|
||||
};
|
||||
}
|
||||
},
|
||||
onWillStartDrag({ ctx }) {
|
||||
if (ctx.groupSelector) {
|
||||
ctx.currentGroup = ctx.currentElement.closest(ctx.groupSelector);
|
||||
if (!ctx.connectGroups) {
|
||||
ctx.currentContainer = ctx.currentGroup;
|
||||
onWillStartDrag({ ctx, addCleanup }) {
|
||||
const { connectGroups, current, groupSelector } = ctx;
|
||||
|
||||
if (groupSelector) {
|
||||
current.group = current.element.closest(groupSelector);
|
||||
if (!connectGroups) {
|
||||
current.container = current.group;
|
||||
}
|
||||
}
|
||||
ctx.ghostElement = ctx.currentElement.cloneNode(false);
|
||||
},
|
||||
onCleanup({ ctx }) {
|
||||
if (ctx.ghostElement) {
|
||||
ctx.ghostElement.remove();
|
||||
}
|
||||
|
||||
ctx.currentGroup = null;
|
||||
ctx.ghostElement = null;
|
||||
current.placeHolder = current.element.cloneNode(false);
|
||||
|
||||
addCleanup(() => current.placeHolder.remove());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { onWillUnmount, useComponent } from "@odoo/owl";
|
||||
* Creates a version of the function where only the last call between two
|
||||
* animation frames is executed before the browser's next repaint. This
|
||||
* effectively throttles the function to the display's refresh rate.
|
||||
* NB: The first call is always called immediately (leading edge).
|
||||
*
|
||||
* @template {Function} T
|
||||
* @param {T} func the function to throttle
|
||||
@@ -15,21 +16,38 @@ import { onWillUnmount, useComponent } from "@odoo/owl";
|
||||
*/
|
||||
export function throttleForAnimation(func) {
|
||||
let handle = null;
|
||||
const calls = new Set();
|
||||
const funcName = func.name ? `${func.name} (throttleForAnimation)` : "throttleForAnimation";
|
||||
const pending = () => {
|
||||
if (calls.size) {
|
||||
handle = browser.requestAnimationFrame(pending);
|
||||
const { args, resolve } = [...calls].pop();
|
||||
calls.clear();
|
||||
Promise.resolve(func.apply(this, args)).then(resolve);
|
||||
} else {
|
||||
handle = null;
|
||||
}
|
||||
};
|
||||
return Object.assign(
|
||||
{
|
||||
/** @type {any} */
|
||||
[funcName](...args) {
|
||||
browser.cancelAnimationFrame(handle);
|
||||
handle = browser.requestAnimationFrame(() => {
|
||||
handle = null;
|
||||
func.call(this, ...args);
|
||||
return new Promise((resolve) => {
|
||||
const isNew = handle === null;
|
||||
if (isNew) {
|
||||
handle = browser.requestAnimationFrame(pending);
|
||||
Promise.resolve(func.apply(this, args)).then(resolve);
|
||||
} else {
|
||||
calls.add({ args, resolve });
|
||||
}
|
||||
});
|
||||
},
|
||||
}[funcName],
|
||||
{
|
||||
cancel() {
|
||||
browser.cancelAnimationFrame(handle);
|
||||
calls.clear();
|
||||
handle = null;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div
|
||||
role="article"
|
||||
t-att-class="getRecordClasses()"
|
||||
t-att-data-id="props.canResequence and props.record.id"
|
||||
t-att-data-id="props.record.id"
|
||||
t-att-tabindex="props.record.model.useSampleModel ? -1 : 0"
|
||||
t-on-click="onGlobalClick"
|
||||
t-ref="root">
|
||||
|
||||
@@ -50,7 +50,11 @@ export class KanbanRenderer extends Component {
|
||||
|
||||
setup() {
|
||||
this.dialogClose = [];
|
||||
/**
|
||||
* @type {{ processedIds: string[], columnQuickCreateIsFolded: boolean }}
|
||||
*/
|
||||
this.state = useState({
|
||||
processedIds: [],
|
||||
columnQuickCreateIsFolded:
|
||||
!this.props.list.isGrouped || this.props.list.groups.length > 0,
|
||||
});
|
||||
@@ -272,9 +276,14 @@ export class KanbanRenderer extends Component {
|
||||
return !this.env.isSmall && isFolded ? `${name} (${count})` : name;
|
||||
}
|
||||
|
||||
getGroupClasses(group) {
|
||||
/**
|
||||
* @param {RelationalGroup} group
|
||||
* @param {boolean} isGroupProcessing
|
||||
* @returns {string}
|
||||
*/
|
||||
getGroupClasses(group, isGroupProcessing) {
|
||||
const classes = [];
|
||||
if (this.canResequenceGroups && group.value) {
|
||||
if (!isGroupProcessing && this.canResequenceGroups && group.value) {
|
||||
classes.push("o_group_draggable");
|
||||
}
|
||||
if (!group.count) {
|
||||
@@ -326,6 +335,14 @@ export class KanbanRenderer extends Component {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isProcessing(id) {
|
||||
return this.state.processedIds.includes(id);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Permissions
|
||||
// ------------------------------------------------------------------------
|
||||
@@ -470,6 +487,20 @@ export class KanbanRenderer extends Component {
|
||||
return this.rootRef.el.querySelector(`.o_kanban_group[data-id="${groupId}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {boolean} isProcessing
|
||||
*/
|
||||
toggleProcessing(id, isProcessing) {
|
||||
if (isProcessing) {
|
||||
this.state.processedIds = [...this.state.processedIds, id];
|
||||
} else {
|
||||
this.state.processedIds = this.state.processedIds.filter(
|
||||
(processedId) => processedId !== id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ------------------------------------------------------------------------
|
||||
@@ -490,11 +521,13 @@ export class KanbanRenderer extends Component {
|
||||
* @param {HTMLElement} [params.parent]
|
||||
* @param {HTMLElement} [params.previous]
|
||||
*/
|
||||
async sortGroupDrop(dataGroupId, { element, previous }) {
|
||||
element.classList.remove("o_group_draggable");
|
||||
async sortGroupDrop(dataGroupId, { previous }) {
|
||||
this.toggleProcessing(dataGroupId, true);
|
||||
|
||||
const refId = previous ? previous.dataset.id : null;
|
||||
await this.props.list.resequence(dataGroupId, refId);
|
||||
element.classList.add("o_group_draggable");
|
||||
|
||||
this.toggleProcessing(dataGroupId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -508,21 +541,23 @@ export class KanbanRenderer extends Component {
|
||||
* @param {HTMLElement} [params.previous]
|
||||
*/
|
||||
async sortRecordDrop(dataRecordId, dataGroupId, { element, parent, previous }) {
|
||||
element.classList.remove("o_record_draggable");
|
||||
if (
|
||||
!this.props.list.isGrouped ||
|
||||
parent.classList.contains("o_kanban_hover") ||
|
||||
parent.dataset.id === element.parentElement.dataset.id
|
||||
) {
|
||||
parent && parent.classList && parent.classList.remove("o_kanban_hover");
|
||||
this.toggleProcessing(dataRecordId, true);
|
||||
|
||||
parent?.classList.remove("o_kanban_hover");
|
||||
while (previous && !previous.dataset.id) {
|
||||
previous = previous.previousElementSibling;
|
||||
}
|
||||
const refId = previous ? previous.dataset.id : null;
|
||||
const targetGroupId = parent && parent.dataset.id;
|
||||
const targetGroupId = parent?.dataset.id;
|
||||
await this.props.list.moveRecord(dataRecordId, dataGroupId, refId, targetGroupId);
|
||||
|
||||
this.toggleProcessing(dataRecordId, false);
|
||||
}
|
||||
element.classList.add("o_record_draggable");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
<t t-foreach="getGroupsOrRecords()" t-as="groupOrRecord" t-key="groupOrRecord.key">
|
||||
<t t-if="groupOrRecord.group">
|
||||
<t t-set="group" t-value="groupOrRecord.group" />
|
||||
<t t-set="isGroupProcessing" t-value="isProcessing(group.id)" />
|
||||
<div class="o_kanban_group flex-shrink-0 flex-grow-1 flex-md-grow-0"
|
||||
t-att-class="getGroupClasses(group)"
|
||||
t-att-class="getGroupClasses(group, isGroupProcessing)"
|
||||
t-att-data-id="(canResequenceRecords or canResequenceGroups) and group.id"
|
||||
t-on-click="(ev) => this.onGroupClick(group, ev)"
|
||||
>
|
||||
@@ -73,7 +74,7 @@
|
||||
<KanbanRecord
|
||||
archInfo="props.archInfo"
|
||||
Compiler="props.Compiler"
|
||||
canResequence="canResequenceRecords"
|
||||
canResequence="!isGroupProcessing and !isProcessing(record.id) and canResequenceRecords"
|
||||
forceGlobalClick="props.forceGlobalClick"
|
||||
group="group"
|
||||
list="props.list"
|
||||
@@ -100,7 +101,7 @@
|
||||
<KanbanRecord
|
||||
archInfo="props.archInfo"
|
||||
Compiler="props.Compiler"
|
||||
canResequence="canResequenceRecords"
|
||||
canResequence="!isProcessing(groupOrRecord.record.id) and canResequenceRecords"
|
||||
forceGlobalClick="props.forceGlobalClick"
|
||||
list="props.list"
|
||||
openRecord="props.openRecord"
|
||||
|
||||
@@ -129,7 +129,7 @@ QUnit.test("can change the popper reference name", async (assert) => {
|
||||
});
|
||||
|
||||
QUnit.test("has no effect when component is destroyed", async (assert) => {
|
||||
const execRegisteredCallbacks = mockAnimationFrame();
|
||||
mockAnimationFrame();
|
||||
const TestComp = getTestComponent({
|
||||
onPositioned: () => {
|
||||
assert.step("onPositioned called");
|
||||
@@ -138,16 +138,12 @@ QUnit.test("has no effect when component is destroyed", async (assert) => {
|
||||
const comp = await mount(TestComp, container);
|
||||
assert.verifySteps(["onPositioned called"], "onPositioned called when component mounted");
|
||||
|
||||
triggerEvent(document, null, "scroll");
|
||||
await nextTick();
|
||||
assert.verifySteps([]);
|
||||
execRegisteredCallbacks();
|
||||
await triggerEvent(document, null, "scroll");
|
||||
assert.verifySteps(["onPositioned called"], "onPositioned called when document scrolled");
|
||||
|
||||
triggerEvent(document, null, "scroll");
|
||||
await nextTick();
|
||||
destroy(comp);
|
||||
execRegisteredCallbacks();
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
[],
|
||||
"onPositioned not called even if scroll happened right before the component destroys"
|
||||
@@ -841,30 +837,26 @@ function getFittingTest(position, styleAttribute) {
|
||||
const TestComp = getTestComponent({ position });
|
||||
await mount(TestComp, container);
|
||||
assert.strictEqual(container.querySelector("#popper").style[styleAttribute], "50px");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
QUnit.test(
|
||||
"reposition from bottom-fit to top-fit", getRepositionTest("bottom-fit", "top-fit", "bottom")
|
||||
"reposition from bottom-fit to top-fit",
|
||||
getRepositionTest("bottom-fit", "top-fit", "bottom")
|
||||
);
|
||||
QUnit.test(
|
||||
"reposition from top-fit to bottom-fit", getRepositionTest("top-fit", "bottom-fit", "top")
|
||||
"reposition from top-fit to bottom-fit",
|
||||
getRepositionTest("top-fit", "bottom-fit", "top")
|
||||
);
|
||||
QUnit.test(
|
||||
"reposition from right-fit to left-fit", getRepositionTest("right-fit", "left-fit", "right")
|
||||
"reposition from right-fit to left-fit",
|
||||
getRepositionTest("right-fit", "left-fit", "right")
|
||||
);
|
||||
QUnit.test(
|
||||
"reposition from left-fit to right-fit", getRepositionTest("left-fit", "right-fit", "left")
|
||||
);
|
||||
QUnit.test(
|
||||
"bottom-fit has the same width as the reference", getFittingTest("bottom-fit", "width")
|
||||
);
|
||||
QUnit.test(
|
||||
"top-fit has the same width as the reference", getFittingTest("top-fit", "width")
|
||||
);
|
||||
QUnit.test(
|
||||
"left-fit has the same height as the reference", getFittingTest("left-fit", "height")
|
||||
);
|
||||
QUnit.test(
|
||||
"right-fit has the same height as the reference", getFittingTest("right-fit", "height")
|
||||
"reposition from left-fit to right-fit",
|
||||
getRepositionTest("left-fit", "right-fit", "left")
|
||||
);
|
||||
QUnit.test("bottom-fit has the same width as the reference", getFittingTest("bottom-fit", "width"));
|
||||
QUnit.test("top-fit has the same width as the reference", getFittingTest("top-fit", "width"));
|
||||
QUnit.test("left-fit has the same height as the reference", getFittingTest("left-fit", "height"));
|
||||
QUnit.test("right-fit has the same height as the reference", getFittingTest("right-fit", "height"));
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { drag, dragAndDrop, getFixture, mount, nextTick } from "@web/../tests/helpers/utils";
|
||||
import { useDraggable } from "@web/core/utils/draggable";
|
||||
|
||||
import { Component, reactive, useRef, useState, xml } from "@odoo/owl";
|
||||
|
||||
let target;
|
||||
QUnit.module("Draggable", ({ beforeEach }) => {
|
||||
beforeEach(() => (target = getFixture()));
|
||||
|
||||
QUnit.module("Draggable hook");
|
||||
|
||||
QUnit.test("Parameters error handling", async (assert) => {
|
||||
assert.expect(5);
|
||||
|
||||
const mountListAndAssert = async (setupList, shouldThrow) => {
|
||||
class List extends Component {
|
||||
setup() {
|
||||
setupList();
|
||||
}
|
||||
}
|
||||
|
||||
List.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>`;
|
||||
|
||||
let err;
|
||||
await mount(List, target).catch((e) => (err = e));
|
||||
|
||||
assert.ok(
|
||||
shouldThrow ? err : !err,
|
||||
`An error should${shouldThrow ? "" : "n't"} have been thrown when mounting.`
|
||||
);
|
||||
};
|
||||
|
||||
// Incorrect params
|
||||
await mountListAndAssert(() => {
|
||||
useDraggable({});
|
||||
}, true);
|
||||
await mountListAndAssert(() => {
|
||||
useDraggable({
|
||||
ref: useRef("root"),
|
||||
});
|
||||
}, true);
|
||||
await mountListAndAssert(() => {
|
||||
useDraggable({
|
||||
elements: ".item",
|
||||
});
|
||||
}, true);
|
||||
|
||||
// Correct params
|
||||
await mountListAndAssert(() => {
|
||||
useDraggable({
|
||||
ref: {},
|
||||
elements: ".item",
|
||||
enable: false,
|
||||
});
|
||||
}, false);
|
||||
await mountListAndAssert(() => {
|
||||
useDraggable({
|
||||
ref: useRef("root"),
|
||||
elements: ".item",
|
||||
});
|
||||
}, false);
|
||||
});
|
||||
|
||||
QUnit.test("Simple dragging in single group", async (assert) => {
|
||||
assert.expect(16);
|
||||
|
||||
class List extends Component {
|
||||
setup() {
|
||||
useDraggable({
|
||||
ref: useRef("root"),
|
||||
elements: ".item",
|
||||
onDragStart({ element }) {
|
||||
assert.step("start");
|
||||
assert.strictEqual(element.innerText, "1");
|
||||
},
|
||||
onDrag({ element }) {
|
||||
assert.step("drag");
|
||||
assert.strictEqual(element.innerText, "1");
|
||||
},
|
||||
onDragEnd({ element }) {
|
||||
assert.step("end");
|
||||
assert.strictEqual(element.innerText, "1");
|
||||
assert.containsN(target, ".item", 3);
|
||||
},
|
||||
onDrop({ element }) {
|
||||
assert.step("drop");
|
||||
assert.strictEqual(element.innerText, "1");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List.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>`;
|
||||
|
||||
await mount(List, target);
|
||||
|
||||
assert.containsN(target, ".item", 3);
|
||||
assert.containsNone(target, ".o_dragged");
|
||||
assert.verifySteps([]);
|
||||
|
||||
// First item after 2nd item
|
||||
const { drop, moveTo } = await drag(".item:first-child");
|
||||
await moveTo(".item:nth-child(2)");
|
||||
|
||||
assert.hasClass(target.querySelector(".item"), "o_dragged");
|
||||
|
||||
await drop();
|
||||
|
||||
assert.containsN(target, ".item", 3);
|
||||
assert.containsNone(target, ".o_dragged");
|
||||
assert.verifySteps(["start", "drag", "drop", "end"]);
|
||||
});
|
||||
|
||||
QUnit.test("Dynamically disable draggable feature", async (assert) => {
|
||||
assert.expect(4);
|
||||
|
||||
const state = reactive({ enableDrag: true });
|
||||
class List extends Component {
|
||||
setup() {
|
||||
this.state = useState(state);
|
||||
useDraggable({
|
||||
ref: useRef("root"),
|
||||
elements: ".item",
|
||||
enable: () => this.state.enableDrag,
|
||||
onDragStart() {
|
||||
assert.step("start");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List.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>`;
|
||||
|
||||
await mount(List, target);
|
||||
|
||||
assert.verifySteps([]);
|
||||
|
||||
// First item before last item
|
||||
await dragAndDrop(".item:first-child", ".item:last-child");
|
||||
|
||||
// Drag should have occurred
|
||||
assert.verifySteps(["start"]);
|
||||
|
||||
state.enableDrag = false;
|
||||
await nextTick();
|
||||
|
||||
// First item before last item
|
||||
await dragAndDrop(".item:first-child", ".item:last-child");
|
||||
|
||||
// Drag shouldn't have occurred
|
||||
assert.verifySteps([]);
|
||||
});
|
||||
|
||||
QUnit.test("Ignore specified elements", async (assert) => {
|
||||
assert.expect(6);
|
||||
|
||||
class List extends Component {
|
||||
setup() {
|
||||
useDraggable({
|
||||
ref: useRef("root"),
|
||||
elements: ".item",
|
||||
ignore: ".ignored",
|
||||
onDragStart() {
|
||||
assert.step("drag");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List.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>`;
|
||||
|
||||
await mount(List, 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([]);
|
||||
});
|
||||
});
|
||||
+8
-51
@@ -6,13 +6,13 @@ import { useSortable } from "@web/core/utils/sortable";
|
||||
import { Component, reactive, useRef, useState, xml } from "@odoo/owl";
|
||||
|
||||
let target;
|
||||
QUnit.module("UI", ({ beforeEach }) => {
|
||||
QUnit.module("Draggable", ({ beforeEach }) => {
|
||||
beforeEach(() => (target = getFixture()));
|
||||
|
||||
QUnit.module("Sortable hook");
|
||||
|
||||
QUnit.test("Parameters error handling", async (assert) => {
|
||||
assert.expect(8);
|
||||
assert.expect(6);
|
||||
|
||||
const mountListAndAssert = async (setupList, shouldThrow) => {
|
||||
class List extends Component {
|
||||
@@ -57,18 +57,6 @@ QUnit.module("UI", ({ beforeEach }) => {
|
||||
groups: ".list",
|
||||
});
|
||||
}, true);
|
||||
await mountListAndAssert(() => {
|
||||
useSortable({
|
||||
ref: useRef("root"),
|
||||
setup: () => ({ elements: ".item" }),
|
||||
});
|
||||
}, true);
|
||||
await mountListAndAssert(() => {
|
||||
useSortable({
|
||||
ref: useRef("root"),
|
||||
elements: () => ".item",
|
||||
});
|
||||
}, true);
|
||||
|
||||
// Correct params
|
||||
await mountListAndAssert(() => {
|
||||
@@ -105,7 +93,7 @@ QUnit.module("UI", ({ beforeEach }) => {
|
||||
assert.strictEqual(element.innerText, "2");
|
||||
},
|
||||
onDragEnd({ element, group }) {
|
||||
assert.step("stop");
|
||||
assert.step("end");
|
||||
assert.notOk(group);
|
||||
assert.strictEqual(element.innerText, "1");
|
||||
assert.containsN(target, ".item", 4);
|
||||
@@ -136,7 +124,8 @@ QUnit.module("UI", ({ beforeEach }) => {
|
||||
assert.verifySteps([]);
|
||||
|
||||
// First item after 2nd item
|
||||
const drop = await drag(".item:first-child", ".item:nth-child(2)");
|
||||
const { drop, moveTo } = await drag(".item:first-child");
|
||||
await moveTo(".item:nth-child(2)");
|
||||
|
||||
assert.hasClass(target.querySelector(".item"), "o_dragged");
|
||||
|
||||
@@ -144,7 +133,7 @@ QUnit.module("UI", ({ beforeEach }) => {
|
||||
|
||||
assert.containsN(target, ".item", 3);
|
||||
assert.containsNone(target, ".o_dragged");
|
||||
assert.verifySteps(["start", "elemententer", "stop", "drop"]);
|
||||
assert.verifySteps(["start", "elemententer", "drop", "end"]);
|
||||
});
|
||||
|
||||
QUnit.test("Simple sorting in multiple groups", async (assert) => {
|
||||
@@ -167,7 +156,7 @@ QUnit.module("UI", ({ beforeEach }) => {
|
||||
assert.hasClass(group, "list1");
|
||||
},
|
||||
onDragEnd({ element, group }) {
|
||||
assert.step("stop");
|
||||
assert.step("end");
|
||||
assert.hasClass(group, "list2");
|
||||
assert.strictEqual(element.innerText, "2 1");
|
||||
},
|
||||
@@ -201,7 +190,7 @@ QUnit.module("UI", ({ beforeEach }) => {
|
||||
|
||||
assert.containsN(target, ".list", 3);
|
||||
assert.containsN(target, ".item", 9);
|
||||
assert.verifySteps(["start", "groupenter", "stop", "drop"]);
|
||||
assert.verifySteps(["start", "groupenter", "drop", "end"]);
|
||||
});
|
||||
|
||||
QUnit.test("Dynamically disable sortable feature", async (assert) => {
|
||||
@@ -249,38 +238,6 @@ QUnit.module("UI", ({ beforeEach }) => {
|
||||
assert.verifySteps([]);
|
||||
});
|
||||
|
||||
QUnit.test("Disabled in small environment", async (assert) => {
|
||||
assert.expect(2);
|
||||
|
||||
class List extends Component {
|
||||
setup() {
|
||||
useSortable({
|
||||
ref: useRef("root"),
|
||||
elements: ".item",
|
||||
onDragStart() {
|
||||
throw new Error("Shouldn't start the sortable feature.");
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List.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>`;
|
||||
|
||||
await mount(List, target, { env: { isSmall: true } });
|
||||
|
||||
assert.containsN(target, ".item", 3);
|
||||
|
||||
// First item after 2nd item
|
||||
await dragAndDrop(".item:first-child", ".item:nth-child(2)");
|
||||
|
||||
assert.ok(true, "No drag sequence should have been initiated");
|
||||
});
|
||||
|
||||
QUnit.test("Ignore specified elements", async (assert) => {
|
||||
assert.expect(6);
|
||||
|
||||
@@ -100,7 +100,7 @@ QUnit.module("utils", () => {
|
||||
|
||||
QUnit.test("debounce with 'animationFrame' delay", async function (assert) {
|
||||
const { execRegisteredTimeouts } = mockTimeout();
|
||||
const execRegisteredAnimationFrames = mockAnimationFrame();
|
||||
const { execRegisteredAnimationFrames } = mockAnimationFrame();
|
||||
const myFunc = () => {
|
||||
assert.step("myFunc");
|
||||
};
|
||||
@@ -132,24 +132,46 @@ QUnit.module("utils", () => {
|
||||
});
|
||||
|
||||
QUnit.test("throttleForAnimation", async (assert) => {
|
||||
assert.expect(4);
|
||||
const execAnimationFrameCallbacks = mockAnimationFrame();
|
||||
const { advanceFrame, execRegisteredAnimationFrames } = mockAnimationFrame();
|
||||
const throttledFn = throttleForAnimation((val) => {
|
||||
assert.step(`throttled function called with ${val}`);
|
||||
assert.step(`${val}`);
|
||||
});
|
||||
|
||||
throttledFn(0);
|
||||
// A single call is executed immediately
|
||||
throttledFn(1);
|
||||
assert.verifySteps([], "throttled function hasn't been called yet");
|
||||
execAnimationFrameCallbacks();
|
||||
assert.verifySteps(
|
||||
["throttled function called with 1"],
|
||||
"only the last queued call was executed"
|
||||
);
|
||||
assert.verifySteps(["1"], "has been called on the leading edge");
|
||||
execRegisteredAnimationFrames();
|
||||
assert.verifySteps([], "has not been called");
|
||||
|
||||
// Successive calls
|
||||
throttledFn(1);
|
||||
assert.verifySteps(["1"], "has been called on the leading edge");
|
||||
throttledFn(2);
|
||||
throttledFn(3);
|
||||
assert.verifySteps([], "has not been called");
|
||||
execRegisteredAnimationFrames();
|
||||
assert.verifySteps(["3"], "only the last queued call was executed");
|
||||
|
||||
// Can be cancelled
|
||||
throttledFn(1);
|
||||
assert.verifySteps(["1"], "has been called on the leading edge");
|
||||
throttledFn(2);
|
||||
throttledFn(3);
|
||||
throttledFn.cancel();
|
||||
execAnimationFrameCallbacks();
|
||||
execRegisteredAnimationFrames();
|
||||
assert.verifySteps([], "queued throttled function calls were cancelled correctly");
|
||||
|
||||
// Successive calls: more precise timing case
|
||||
throttledFn(1);
|
||||
assert.verifySteps(["1"], "has been called on the leading edge");
|
||||
await advanceFrame();
|
||||
throttledFn(2);
|
||||
assert.verifySteps(["2"], "has been called on the leading edge");
|
||||
throttledFn(3);
|
||||
throttledFn(4);
|
||||
await advanceFrame();
|
||||
assert.verifySteps(["4"], "last call is executed on the trailing edge");
|
||||
execRegisteredAnimationFrames();
|
||||
assert.verifySteps([], "has not been called");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { templates } from "@web/core/assets";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { isMacOS } from "@web/core/browser/feature_detection";
|
||||
import { download } from "@web/core/network/download";
|
||||
@@ -7,7 +8,6 @@ import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { patch, unpatch } from "@web/core/utils/patch";
|
||||
import { isVisible } from "@web/core/utils/ui";
|
||||
import { registerCleanup } from "./cleanup";
|
||||
import { templates } from "@web/core/assets";
|
||||
|
||||
import { App, onMounted, onPatched, useComponent } from "@odoo/owl";
|
||||
|
||||
@@ -587,21 +587,39 @@ export function mockTimeout() {
|
||||
|
||||
export function mockAnimationFrame() {
|
||||
const callbacks = new Map();
|
||||
let currentTime = 0;
|
||||
let id = 1;
|
||||
patchWithCleanup(browser, {
|
||||
requestAnimationFrame(fn) {
|
||||
callbacks.set(id, fn);
|
||||
callbacks.set(id, { fn, scheduledFor: 16 + currentTime, id });
|
||||
return id++;
|
||||
},
|
||||
cancelAnimationFrame(id) {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
performance: { now: () => currentTime },
|
||||
});
|
||||
return function execRegisteredCallbacks() {
|
||||
for (const fn of callbacks.values()) {
|
||||
fn();
|
||||
}
|
||||
callbacks.clear();
|
||||
return {
|
||||
execRegisteredAnimationFrames() {
|
||||
for (const { fn } of callbacks.values()) {
|
||||
fn(currentTime);
|
||||
}
|
||||
callbacks.clear();
|
||||
},
|
||||
async advanceFrame(count = 1) {
|
||||
// wait here so all microtasktick scheduled in this frame can be
|
||||
// executed and possibly register their own timeout
|
||||
await nextTick();
|
||||
currentTime += 16 * count;
|
||||
for (const { fn, scheduledFor, id } of callbacks.values()) {
|
||||
if (scheduledFor <= currentTime) {
|
||||
fn(currentTime);
|
||||
callbacks.delete(id);
|
||||
}
|
||||
}
|
||||
// wait here to make sure owl can update the UI
|
||||
await nextTick();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -697,10 +715,8 @@ function getDifferentParents(n1, n2) {
|
||||
/**
|
||||
* Helper performing a drag and drop sequence.
|
||||
*
|
||||
* - the 'fromSelector' is used to determine the element on which the drag will
|
||||
* start;
|
||||
* - the 'toSelector' will determine the element on which the first one will be
|
||||
* dropped.
|
||||
* - 'from' is used to determine the element on which the drag will start;
|
||||
* - 'target' will determine the element on which the first one will be dropped.
|
||||
*
|
||||
* The first element will be dragged by its center, and will be dropped on the
|
||||
* bottom-right inner pixel of the target element. This behavior covers both
|
||||
@@ -719,8 +735,8 @@ function getDifferentParents(n1, n2) {
|
||||
* @param {"top" | "bottom" | "left" | "right"} [position]
|
||||
*/
|
||||
export async function dragAndDrop(from, to, position) {
|
||||
const dropFunction = drag(from, to, position);
|
||||
await dropFunction();
|
||||
const { drop } = await drag(from);
|
||||
await drop(to, position);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -728,67 +744,130 @@ export async function dragAndDrop(from, to, position) {
|
||||
*
|
||||
* - the 'from' selector is used to determine the element on which the drag will
|
||||
* start;
|
||||
* - the 'to' selector will determine the element on which the dragged element will be
|
||||
* - the 'target' selector will determine the element on which the dragged element will be
|
||||
* moved.
|
||||
*
|
||||
* Returns a drop function
|
||||
*
|
||||
* @param {Element | string} from
|
||||
* @param {Element | string} to
|
||||
* @param {string} [position] "top" | "bottom" | "left" | "right"
|
||||
* @returns {function: Promise<void>}
|
||||
*/
|
||||
export function drag(from, to, position) {
|
||||
export async function drag(from) {
|
||||
const assertIsDragging = (fn, endDrag) => {
|
||||
return {
|
||||
async [fn.name](...args) {
|
||||
if (dragEndReason) {
|
||||
throw new Error(
|
||||
`Cannot execute drag helper '${fn.name}': drag sequence has been ended by '${dragEndReason}'.`
|
||||
);
|
||||
}
|
||||
await fn(...args);
|
||||
if (endDrag) {
|
||||
dragEndReason = fn.name;
|
||||
}
|
||||
},
|
||||
}[fn.name];
|
||||
};
|
||||
|
||||
const cancel = assertIsDragging(async function cancel() {
|
||||
await triggerEvent(window, null, "keydown", { key: "Escape" });
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* @param {Element | string} [to]
|
||||
* @param {"top" | "bottom" | "left" | "right"} [position]
|
||||
*/
|
||||
const drop = assertIsDragging(async function drop(to, position) {
|
||||
if (to) {
|
||||
await moveTo(to, position);
|
||||
}
|
||||
await triggerEvent(target || source, null, "mouseup", targetPosition);
|
||||
}, true);
|
||||
|
||||
/**
|
||||
* @param {Element | string} selector
|
||||
*/
|
||||
const getEl = (selector) =>
|
||||
selector instanceof Element ? selector : fixture.querySelector(selector);
|
||||
|
||||
/**
|
||||
* @param {"top" | "bottom" | "left" | "right" | { x?: number, y?: number }} [position]
|
||||
*/
|
||||
const getTargetPosition = (position) => {
|
||||
const tRect = target.getBoundingClientRect();
|
||||
const tPos = { clientX: tRect.x, clientY: tRect.y };
|
||||
if (position && typeof position === "object") {
|
||||
// x and y coordinates start from the element's initial coordinates
|
||||
tPos.clientX += position.x || 0;
|
||||
tPos.clientY += position.y || 0;
|
||||
} else {
|
||||
// relative positionning starts from the center of the element
|
||||
tPos.clientX += tRect.width / 2;
|
||||
tPos.clientY += tRect.height / 2;
|
||||
switch (position) {
|
||||
case "top": {
|
||||
tPos.clientY = tRect.y - 1;
|
||||
break;
|
||||
}
|
||||
case "bottom": {
|
||||
tPos.clientY = tRect.y + tRect.height + 1;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
tPos.clientX = tRect.x - 1;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
tPos.clientX = tRect.x + tRect.width + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tPos;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Element | string} [to]
|
||||
* @param {"top" | "bottom" | "left" | "right"} [position]
|
||||
*/
|
||||
const moveTo = assertIsDragging(async function moveTo(to, position) {
|
||||
target = getEl(to);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recompute target position
|
||||
targetPosition = getTargetPosition(position);
|
||||
|
||||
// Move, enter and drop the element on the target
|
||||
await triggerEvent(window, null, "mousemove", targetPosition);
|
||||
|
||||
// "mouseenter" is fired on every parent of `target` that do not contain
|
||||
// `from` (typically: different parent lists).
|
||||
for (const parent of getDifferentParents(source, target)) {
|
||||
triggerEvent(parent, null, "mouseenter", targetPosition);
|
||||
}
|
||||
await nextTick();
|
||||
|
||||
return dragHelpers;
|
||||
}, false);
|
||||
|
||||
const dragHelpers = { cancel, drop, moveTo };
|
||||
const fixture = getFixture();
|
||||
from = from instanceof Element ? from : fixture.querySelector(from);
|
||||
to = to instanceof Element ? to : fixture.querySelector(to);
|
||||
|
||||
const source = getEl(from instanceof Element ? from : fixture.querySelector(from));
|
||||
const sourceRect = source.getBoundingClientRect();
|
||||
|
||||
let dragEndReason = null;
|
||||
let target;
|
||||
let targetPosition;
|
||||
|
||||
// Mouse down on main target
|
||||
const fromRect = from.getBoundingClientRect();
|
||||
const toRect = to.getBoundingClientRect();
|
||||
triggerEvent(from, null, "mousedown", {
|
||||
clientX: fromRect.x + fromRect.width / 2,
|
||||
clientY: fromRect.y + fromRect.height / 2,
|
||||
await triggerEvent(source, null, "mousedown", {
|
||||
clientX: sourceRect.x + sourceRect.width / 2,
|
||||
clientY: sourceRect.y + sourceRect.height / 2,
|
||||
});
|
||||
|
||||
// Find target position
|
||||
const toPos = {
|
||||
clientX: toRect.x + toRect.width / 2,
|
||||
clientY: toRect.y + toRect.height / 2,
|
||||
};
|
||||
switch (position) {
|
||||
case "top": {
|
||||
toPos.clientY = toRect.y - 1;
|
||||
break;
|
||||
}
|
||||
case "bottom": {
|
||||
toPos.clientY = toRect.y + toRect.height + 1;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
toPos.clientX = toRect.x - 1;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
toPos.clientX = toRect.x + toRect.width + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Move, enter and drop the element on the target
|
||||
triggerEvent(window, null, "mousemove", toPos);
|
||||
// "mouseenter" is fired on every parent of `to` that do not contain
|
||||
// `from` (typically: different parent lists).
|
||||
for (const target of getDifferentParents(from, to)) {
|
||||
triggerEvent(target, null, "mouseenter", toPos);
|
||||
}
|
||||
|
||||
return function () {
|
||||
return drop(from, toPos);
|
||||
};
|
||||
}
|
||||
|
||||
function drop(from, toPos) {
|
||||
return triggerEvent(from, null, "mouseup", toPos);
|
||||
return dragHelpers;
|
||||
}
|
||||
|
||||
export async function clickDropdown(target, fieldName) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getFixture,
|
||||
getNodesTextContent,
|
||||
makeDeferred,
|
||||
mockAnimationFrame,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
selectDropdownItem,
|
||||
@@ -4675,12 +4676,13 @@ QUnit.module("Views", (hooks) => {
|
||||
assert.containsN(target, ".o_kanban_group:nth-child(2) .o_kanban_record", 2);
|
||||
|
||||
// first record of first column moved to the bottom of second column
|
||||
const drop = drag(
|
||||
".o_kanban_group:first-child .o_kanban_record",
|
||||
".o_kanban_group:nth-child(2)"
|
||||
);
|
||||
const { drop, moveTo } = await drag(".o_kanban_group:first-child .o_kanban_record");
|
||||
await moveTo(".o_kanban_group:nth-child(2)");
|
||||
|
||||
assert.hasClass(target.querySelector(".o_kanban_group:nth-child(2)"), "o_kanban_hover");
|
||||
|
||||
await drop();
|
||||
|
||||
assert.containsNone(target, ".o_kanban_group:nth-child(2).o_kanban_hover");
|
||||
});
|
||||
|
||||
@@ -12304,24 +12306,7 @@ QUnit.module("Views", (hooks) => {
|
||||
});
|
||||
|
||||
QUnit.test("drag & drop: content scrolls when reaching the edges", async (assert) => {
|
||||
const nextAnimationFrame = async (timeDelta) => {
|
||||
timeStamp += timeDelta;
|
||||
animationFrameDef.resolve();
|
||||
animationFrameDef = makeDeferred();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
let animationFrameDef = makeDeferred();
|
||||
let timeStamp = 0;
|
||||
|
||||
patchWithCleanup(browser, {
|
||||
async requestAnimationFrame(handler) {
|
||||
await animationFrameDef;
|
||||
handler(timeStamp);
|
||||
},
|
||||
performance: { now: () => timeStamp },
|
||||
});
|
||||
|
||||
const { advanceFrame } = mockAnimationFrame();
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
resModel: "partner",
|
||||
@@ -12346,19 +12331,19 @@ QUnit.module("Views", (hooks) => {
|
||||
assert.containsNone(target, ".o_kanban_record.o_dragged");
|
||||
|
||||
// Drag first record of first group to the right
|
||||
await drag(".o_kanban_record", ".o_kanban_group:nth-child(3) .o_kanban_record");
|
||||
|
||||
let dragActions = await drag(".o_kanban_record");
|
||||
await dragActions.moveTo(".o_kanban_group:nth-child(3) .o_kanban_record");
|
||||
assert.strictEqual(content.scrollLeft, 0);
|
||||
|
||||
// next frame (normal time delta)
|
||||
await nextAnimationFrame(16);
|
||||
// next frame
|
||||
await advanceFrame();
|
||||
|
||||
// Default kanban speed is 20px per tick
|
||||
assert.strictEqual(content.scrollLeft, 20);
|
||||
assert.containsOnce(target, ".o_kanban_record.o_dragged");
|
||||
|
||||
// next frame (time delta x20)
|
||||
await nextAnimationFrame(16 * 20);
|
||||
// next 20 frames
|
||||
await advanceFrame(20);
|
||||
|
||||
// Should be at the end of the content
|
||||
assert.strictEqual(content.clientWidth + content.scrollLeft, content.scrollWidth);
|
||||
@@ -12370,15 +12355,16 @@ QUnit.module("Views", (hooks) => {
|
||||
assert.containsNone(target, ".o_kanban_record.o_dragged");
|
||||
|
||||
// Drag first record of last group to the left
|
||||
await drag(".o_kanban_group:nth-child(3) .o_kanban_record", ".o_kanban_record");
|
||||
dragActions = await drag(".o_kanban_group:nth-child(3) .o_kanban_record");
|
||||
await dragActions.moveTo(".o_kanban_record");
|
||||
|
||||
// next frame (normal time delta)
|
||||
await nextAnimationFrame(16);
|
||||
// next frame
|
||||
await advanceFrame();
|
||||
|
||||
assert.containsOnce(target, ".o_kanban_record.o_dragged");
|
||||
|
||||
// next frame (time delta x20)
|
||||
await nextAnimationFrame(16 * 20);
|
||||
// next 20 frames
|
||||
await advanceFrame(20);
|
||||
|
||||
assert.strictEqual(content.scrollLeft, 0);
|
||||
|
||||
|
||||
@@ -15099,11 +15099,10 @@ QUnit.module("Views", (hooks) => {
|
||||
assert.strictEqual(widthsAfterResize[1], widthsAfterReorder[1]);
|
||||
|
||||
// 3. Resize again, this time check sizes while dragging and after drop.
|
||||
const drop = drag(resizeHandle, th3);
|
||||
const { drop } = await drag(resizeHandle, th3);
|
||||
assertAlmostEqual(th2.offsetWidth, widthsAfterReorder[1] + widthsAfterReorder[2] / 2);
|
||||
|
||||
drop();
|
||||
await nextTick();
|
||||
await drop();
|
||||
assertAlmostEqual(th2.offsetWidth, widthsAfterReorder[1] + widthsAfterReorder[2] / 2);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user