[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:
Bruno Boi
2023-02-14 17:09:05 +01:00
co-authored by Julien Mougenot
parent 1ac6ae1801
commit 24fb77d457
15 changed files with 1034 additions and 554 deletions
+11 -23
View File
@@ -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;
}
+66 -65
View File
@@ -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());
},
});
+22 -4
View File
@@ -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([]);
});
});
@@ -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");
});
});
+143 -64
View File
@@ -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);
});