[MERGE] web, mrp: OWL Gantt View Conversion - Part 1 (Community)

Conversion of the gantt view to OWL.

Along the way the UI has been changed:
- hover a pill does not make a popover appear anymore. It is now
  necessary to click on the pill to make it appear. A button "Edit" in
  the popover still allows to edit the pill (if edition is available).

The unused arch attribute collapse_first_level has been removed.

Technically:
- complete revamp of the DOM and class names to trim down dead code and
  needless nesting;
- the gantt view now makes use of the CSS display "grid" property in
  several places (most importantly in the main row container), further
  simplifying the DOM and allowing for list virtualization;
- this virtualization process ensures that only visible rows (and
  their related elements) are effectively rendered in the DOM. This is
  done using the newly added "useVirtual" hook.

The rendering process of the new gantt view should thus be a lot quicker
as a result.

Note that the legacy gantt view is still available since its extensions
are not yet converted (through the js_class legacy_gantt).
Its class name is now o_legacy_gantt_view.

Enterprise counterpart PR: odoo/enterprise#36202
Task Id: 3105022

closes odoo/odoo#110819

Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-02-14 17:09:06 +01:00
committed by GitHub
co-authored by Bruno Boi Julien Mougenot Mathieu Duckerts-Antoine
25 changed files with 1462 additions and 638 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
}
}
.o_mrp_workorder_gantt .o_gantt_view .o_gantt_row_container .o_gantt_row .o_gantt_cell .o_gantt_pill_wrapper {
.o_mrp_workorder_gantt .o_legacy_gantt_view .o_gantt_row_container .o_gantt_row .o_gantt_cell .o_gantt_pill_wrapper {
div.o_gantt_pill.decoration-success {
@include gantt-decoration-color($success);
}
+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;
},
}
);
+109
View File
@@ -0,0 +1,109 @@
/** @odoo-module **/
import {
onWillRender,
onWillStart,
toRaw,
useEffect,
useExternalListener,
useState,
} from "@odoo/owl";
import { shallowEqual } from "@web/core/utils/arrays";
import { throttleForAnimation } from "@web/core/utils/timing";
/**
* @template T
* @typedef VirtualHookParams
* @property {() => T[]} getItems
* a getter for the array of all items
* @property {(item: T) => number} getItemHeight
* a getter for the height of a given item.
* The height should be a number of pixels.
* @property {ReturnType<typeof import("@odoo/owl").useRef>} scrollableRef
* a ref to the scrollable element
* @property {ScrollPosition} [initialScroll={ top: 0 }]
* the initial scroll position of the scrollable element
*/
/**
* @typedef ScrollPosition
* @property {number} top
*/
/**
* Calculates the displayed items in a virtual list.
*
* Requirements:
* - the scrollable area has a fixed height
* - the items are rendered with a proper offset inside the scrollable area.
* This can be achieved e.g. with a css grid or an absolute positioning.
*
* @template T
* @param {VirtualHookParams<T>} params
* @returns {ReturnType<useState<T>>}
*/
export function useVirtual({ getItems, scrollableRef, initialScroll, getItemHeight }) {
const computeVirtualItems = () => {
const { items, scroll } = current;
const yStart = scroll.top - window.innerHeight;
const yEnd = scroll.top + 2 * window.innerHeight;
let [startIndex, endIndex] = [0, 0];
let currentTop = 0;
for (const item of items) {
const height = getItemHeight(item);
if (currentTop + height < yStart) {
startIndex++;
endIndex++;
} else if (currentTop + height <= yEnd + height) {
endIndex++;
} else {
break;
}
currentTop += height;
}
const prevItems = toRaw(virtualItems);
const newItems = items.slice(startIndex, endIndex);
if (!shallowEqual(prevItems, newItems)) {
virtualItems.length = 0;
virtualItems.push(...newItems);
}
};
const current = {
items: getItems(),
scroll: { top: 0, ...initialScroll },
};
const virtualItems = useState([]);
onWillStart(computeVirtualItems);
onWillRender(() => {
const previousItems = current.items;
current.items = getItems();
if (!shallowEqual(previousItems, current.items)) {
computeVirtualItems();
}
});
const throttledCompute = throttleForAnimation(computeVirtualItems);
const scrollListener = (/** @type {Event & { target: Element }} */ ev) => {
current.scroll.top = ev.target.scrollTop;
throttledCompute();
};
useExternalListener(window, "resize", throttledCompute);
useEffect(
(el) => {
if (el) {
el.addEventListener("scroll", scrollListener);
return () => el.removeEventListener("scroll", scrollListener);
}
},
() => [scrollableRef.el]
);
return virtualItems;
}
@@ -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"
@@ -21,7 +21,14 @@ import { session } from "@web/session";
import { FormArchParser } from "@web/views/form/form_arch_parser";
import { ListConfirmationDialog } from "@web/views/list/list_confirmation_dialog";
import { Model } from "@web/views/model";
import { archParseBoolean, evalDomain, isNumeric, isRelational, isX2Many } from "@web/views/utils";
import {
archParseBoolean,
evalDomain,
isNumeric,
isRelational,
isX2Many,
orderByToString,
} from "@web/views/utils";
const { DateTime } = luxon;
import { markRaw, markup, toRaw } from "@odoo/owl";
@@ -66,20 +73,6 @@ export function isAllowedDateField(groupByField) {
);
}
/**
* @typedef {Object} OrderTerm ?
* @property {string} name
* @property {boolean} asc
*/
/**
* @param {OrderTerm[]} orderBy
* @returns {string}
*/
function orderByToString(orderBy) {
return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", ");
}
/**
* @param {RawContext} rawContext
* @param {Context} defaultContext
@@ -771,6 +771,7 @@ export function buildSampleORM(resModel, fields, user) {
return sampleServer.mockRpc({ method, model, args, ...kwargs, groupBy });
};
const sampleORM = new ORM(fakeRPC, user);
sampleORM.isSample = true;
sampleORM.setGroups = (groups) => sampleServer.setExistingGroups(groups);
return sampleORM;
}
+14
View File
@@ -241,6 +241,20 @@ export function processMeasure(measure) {
return measure === "__count__" ? "__count" : measure;
}
/**
* @typedef {Object} OrderTerm ?
* @property {string} name
* @property {boolean} asc
*/
/**
* @param {OrderTerm[]} orderBy
* @returns {string}
*/
export function orderByToString(orderBy) {
return orderBy.map((o) => `${o.name} ${o.asc ? "ASC" : "DESC"}`).join(", ");
}
/**
* @param {any} string
* @return {OrderTerm[]}
@@ -2,7 +2,7 @@
import { onMounted, useComponent, useEffect, useExternalListener } from "@odoo/owl";
const scrollSymbol = Symbol("scroll");
export const scrollSymbol = Symbol("scroll");
export class CallbackRecorder {
constructor() {
@@ -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");
});
});
@@ -0,0 +1,155 @@
/** @odoo-module **/
import { Component, useRef, xml } from "@odoo/owl";
import { useVirtual } from "@web/core/virtual_hook";
import { getFixture, mount, patchWithCleanup, triggerEvent } from "../helpers/utils";
/**
* @typedef ItemType
* @property {number} id
*
* @typedef {import("@web/core/virtual_hook").VirtualHookParams<ItemType>} TestComponentProps
*/
function objectToStyle(obj) {
return Object.entries(obj)
.map(([k, v]) => `${k}: ${v};`)
.join("");
}
/** @type {ItemType[]} */
const ITEMS = Array.from({ length: 200 }, (_, i) => ({ id: i + 1 }));
const ITEM_HEIGHT = 50;
const ITEM_STYLE = objectToStyle({
height: `${ITEM_HEIGHT}px`,
width: "100%",
border: "1px solid black",
position: "absolute",
"background-color": "white",
});
const CONTAINER_SIZE = 5 * ITEM_HEIGHT;
const CONTAINER_STYLE = objectToStyle({
height: `${CONTAINER_SIZE}px`,
width: "100%",
overflow: "auto",
position: "relative",
"background-color": "lightblue",
});
const MAX_SCROLL_TOP = ITEMS.length * ITEM_HEIGHT - CONTAINER_SIZE;
/**
* @param {HTMLElement} [target]
* @param {TestComponentProps} [props]
*/
async function mountTestComponent(target, props) {
/** @extends {Component<ItemType, any>} **/
class Item extends Component {
static props = ["id"];
static template = xml`
<div class="item" t-att-data-id="props.id" t-att-style="style" t-esc="props.id"/>
`;
get style() {
return `top: ${(this.props.id - 1) * ITEM_HEIGHT}px; ${ITEM_STYLE}`;
}
}
/** @extends {Component<TestComponentProps, any>} **/
class TestComponent extends Component {
static props = ["getItems?", "scrollableRef?", "initialScroll?", "getItemHeight?"];
static components = { Item };
static template = xml`
<div class="scrollable" t-ref="scrollable" style="${CONTAINER_STYLE}">
<div class="inner" t-att-style="innerStyle">
<t t-foreach="items" t-as="item" t-key="item.id">
<Item id="item.id"/>
</t>
</div>
</div>
`;
setup() {
const scrollableRef = useRef("scrollable");
this.items = useVirtual({
getItems: () => ITEMS,
getItemHeight: () => ITEM_HEIGHT,
scrollableRef,
...this.props,
});
}
get innerStyle() {
return `height: ${ITEMS.length * ITEM_HEIGHT}px;`;
}
}
await mount(TestComponent, target, { props });
}
function scroll(target, scrollTop) {
target.querySelector(".scrollable").scrollTop = scrollTop;
return triggerEvent(target, ".scrollable", "scroll");
}
let target;
QUnit.module("useVirtual hook", {
async beforeEach() {
target = getFixture();
// In this test suite, we trick the hook by setting the window size to the size
// of the scrollable, so that it is a measurable size and this suite can run
// in a window of any size.
patchWithCleanup(window, { innerHeight: CONTAINER_SIZE });
},
});
QUnit.test("basic usage", async (assert) => {
await mountTestComponent(target);
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "11");
// scroll to the middle
await scroll(target, MAX_SCROLL_TOP / 2);
assert.containsN(target, ".item", 16);
assert.strictEqual(target.querySelector(".item").dataset.id, "93");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "108");
// scroll to the end
await scroll(target, MAX_SCROLL_TOP);
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "190");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "200");
});
QUnit.test("updates on resize", async (assert) => {
await mountTestComponent(target);
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "11");
// resize the window
patchWithCleanup(window, { innerHeight: CONTAINER_SIZE / 2 });
await triggerEvent(window, null, "resize");
assert.containsN(target, ".item", 6);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "6");
// resize the window
patchWithCleanup(window, { innerHeight: CONTAINER_SIZE * 2 });
await triggerEvent(window, null, "resize");
assert.containsN(target, ".item", 21);
assert.strictEqual(target.querySelector(".item").dataset.id, "1");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "21");
});
QUnit.test("initialScroll: middle", async (assert) => {
const initialScroll = { top: MAX_SCROLL_TOP / 2 };
await mountTestComponent(target, { initialScroll });
assert.containsN(target, ".item", 16);
assert.strictEqual(target.querySelector(".item").dataset.id, "93");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "108");
});
QUnit.test("initialScroll: bottom", async (assert) => {
const initialScroll = { top: MAX_SCROLL_TOP };
await mountTestComponent(target, { initialScroll });
assert.containsN(target, ".item", 11);
assert.strictEqual(target.querySelector(".item").dataset.id, "190");
assert.strictEqual(target.querySelector(".item:last-child").dataset.id, "200");
});
+112 -48
View File
@@ -1,13 +1,8 @@
/** @odoo-module **/
import { assets } from "@web/core/assets";
import { browser } from "@web/core/browser/browser";
import { Domain } from "@web/core/domain";
import { evaluateExpr } from "@web/core/py_js/py";
import { deepCopy } from "@web/core/utils/objects";
import { intersection } from "@web/core/utils/arrays";
import { registry } from "@web/core/registry";
import { makeFakeRPCService, makeMockFetch } from "./mock_services";
import { patchWithCleanup } from "./utils";
import {
deserializeDate,
deserializeDateTime,
@@ -15,7 +10,12 @@ import {
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { assets } from "@web/core/assets";
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { intersection } from "@web/core/utils/arrays";
import { deepCopy } from "@web/core/utils/objects";
import { makeFakeRPCService, makeMockFetch } from "./mock_services";
import { patchWithCleanup } from "./utils";
const serviceRegistry = registry.category("services");
@@ -35,11 +35,61 @@ const VALID_AGGREGATE_FUNCTIONS = [
"avg",
"sum",
];
let logId = 1;
// -----------------------------------------------------------------------------
// Utils
// -----------------------------------------------------------------------------
/**
* @param {string} prefix
* @param {string} title
*/
function makeLogger(prefix, title) {
const log = (bullet, colorValue, ...data) => {
const color = `color: ${colorValue}`;
const styles = [[color, "font-weight: bold"].join(";")];
let msg = `${bullet} %c[${prefix}:${id}]`;
if (title) {
msg += `%c ${title}`;
styles.push(color);
}
console.log(msg, ...styles, ...data);
};
/**
* Request logger: color is blue-ish.
* @param {...any} data
*/
const request = (...data) => {
hasCalledRequest = true;
log("->", "#66e", ...data);
};
/**
* Response logger: color is orange.
* @param {...any} data
*/
const response = (...data) => {
if (!hasCalledRequest) {
console.warn(`Response logged before request.`);
}
log("<-", "#f80", ...data);
hasCalledRequest = false;
};
const id = logId++;
let hasCalledRequest = false;
return { request, response };
}
/**
* @param {Element} tree
* @param {(node: Element) => any} cb
*/
function traverseElementTree(tree, cb) {
if (cb(tree)) {
Array.from(tree.children).forEach((c) => traverseElementTree(c, cb));
@@ -107,10 +157,10 @@ export class MockServer {
* server errors.
*/
async performRPC(route, args) {
const logger = makeLogger("RPC", route);
args = JSON.parse(JSON.stringify(args));
if (this.debug) {
console.log("%c[rpc] request " + route, "color: #66e; font-weight: bold;", args);
args = JSON.parse(JSON.stringify(args));
logger.request(args);
}
const result = await this._performRPC(route, args);
// try {
@@ -120,21 +170,17 @@ export class MockServer {
// const event = result && result.event;
// const errorString = JSON.stringify(message || false);
// console.warn(
// "%c[rpc] response (error) " + route,
// "%c[RPC] response (error) " + route,
// "color: orange; font-weight: bold;",
// JSON.parse(errorString)
// );
// return Promise.reject({ message: errorString, event });
// }
const resultString = JSON.stringify(result !== undefined ? result : false);
const actualResult = JSON.parse(JSON.stringify(result !== undefined ? result : false));
if (this.debug) {
console.log(
"%c[rpc] response" + route,
"color: #66e; font-weight: bold;",
JSON.parse(resultString)
);
logger.response(actualResult);
}
return JSON.parse(resultString);
return actualResult;
// TODO?
// var abort = def.abort || def.reject;
// if (abort) {
@@ -581,7 +627,7 @@ export class MockServer {
case "action_unarchive":
return this.mockWrite(args.model, [args.args[0], { active: true }]);
case "copy":
return this.mockCopy(args.model, args.args[0]);
return this.mockCopy(args.model, args.args);
case "create":
return this.mockCreate(args.model, args.args[0], args.kwargs);
case "fields_get":
@@ -635,14 +681,14 @@ export class MockServer {
*
* @private
* @param {string} modelName
* @param {integer} id the ID of a valid record
* @returns {integer} the ID of the duplicated record
* @param {[number, Record<string, any>]} params the ID of a valid record
* @returns {number} the ID of the duplicated record
*/
mockCopy(modelName, id) {
mockCopy(modelName, [id, recordData]) {
const model = this.models[modelName];
const newID = this.getUnusedID(modelName);
const originalRecord = model.records.find((record) => record.id === id);
const duplicatedRecord = { ...originalRecord, id: newID };
const duplicatedRecord = { ...originalRecord, ...recordData, id: newID };
duplicatedRecord.display_name = `${originalRecord.display_name} (copy)`;
model.records.push(duplicatedRecord);
return newID;
@@ -882,28 +928,50 @@ export class MockServer {
fields = Object.keys(model.fields);
}
const ids = Array.isArray(args[0]) ? args[0] : [args[0]];
const records = ids.reduce((records, id) => {
const records = [];
// Mapping of model records used in the current mockRead call.
const modelMap = {
[modelName]: {},
};
for (const record of model.records) {
modelMap[modelName][record.id] = record;
}
for (const fieldName of fields) {
const field = model.fields[fieldName];
if (!field) {
continue; // the field doesn't exist on the model, so skip it
}
const { relation, type } = field;
if (type === "many2one" && !modelMap[relation]) {
modelMap[relation] = {};
for (const record of this.models[relation].records) {
modelMap[relation][record.id] = record;
}
}
}
for (const id of ids) {
if (!id) {
throw new Error(
"mock read: falsy value given as id, would result in an access error in actual server !"
);
}
const record = model.records.find((r) => r.id === id);
return record ? records.concat(record) : records;
}, []);
return records.map((record) => {
const record = modelMap[modelName][id];
if (!record) {
continue;
}
const result = { id: record.id };
for (const fieldName of fields) {
const field = model.fields[fieldName];
if (!field) {
continue; // the field doens't exist on the model, so skip it
continue; // the field doesn't exist on the model, so skip it
}
if (["float", "integer", "monetary"].includes(field.type)) {
// read should return 0 for unset numeric fields
result[fieldName] = record[fieldName] || 0;
} else if (field.type === "many2one") {
const CoModel = this.models[field.relation];
const relRecord = CoModel.records.find((r) => r.id === record[fieldName]);
const relRecord = modelMap[field.relation][record[fieldName]];
if (relRecord) {
result[fieldName] = [record[fieldName], relRecord.display_name];
} else {
@@ -915,8 +983,10 @@ export class MockServer {
result[fieldName] = record[fieldName] || false;
}
}
return result;
});
records.push(result);
}
return records;
}
mockReadGroup(modelName, kwargs) {
@@ -1465,7 +1535,7 @@ export class MockServer {
* (this parameter is used in _search_panel_range)
* @param {boolean} [kwargs.enable_counters] whether to count records by value
* @param {Array[]} [kwargs.filter_domain] domain generated by filters
* @param {integer} [kwargs.limit] maximal number of values to fetch
* @param {number} [kwargs.limit] maximal number of values to fetch
* @param {Array[]} [kwargs.search_domain] base domain of search (this parameter
* is used in _search_panel_range)
* @returns {Object}
@@ -1615,7 +1685,7 @@ export class MockServer {
* @param {Array[]} [kwargs.group_domain] dict, one domain for each activated
* group for the group_by (if any). Those domains are used to fech accurate
* counters for values in each group
* @param {integer} [kwargs.limit] maximal number of values to fetch
* @param {number} [kwargs.limit] maximal number of values to fetch
* @param {Array[]} [kwargs.search_domain] base domain of search
* @returns {Object}
*/
@@ -2345,27 +2415,21 @@ export async function makeMockServer(serverData, mockRPC) {
if (mockRPC) {
const { loadJS, loadCSS } = assets;
patchWithCleanup(assets, {
loadJS: async function (ressource) {
let res = await mockRPC(ressource, {});
loadJS: async function (resource) {
let res = await mockRPC(resource, {});
if (res === undefined) {
res = await loadJS(ressource);
res = await loadJS(resource);
} else {
console.log(
"%c[assets] fetch (mock) JS ressource " + ressource,
"color: #66e; font-weight: bold;"
);
makeLogger("ASSETS", "fetch (mock) JS resource").request(resource);
}
return res;
},
loadCSS: async function (ressource) {
let res = await mockRPC(ressource, {});
loadCSS: async function (resource) {
let res = await mockRPC(resource, {});
if (res === undefined) {
res = await loadCSS(ressource);
res = await loadCSS(resource);
} else {
console.log(
"%c[assets] fetch (mock) CSS ressource " + ressource,
"color: #66e; font-weight: bold;"
);
makeLogger("ASSETS", "fetch (mock) CSS resource").request(resource);
}
return res;
},
+150 -72
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";
@@ -138,7 +138,7 @@ export function patchWithCleanup(obj, patchValue, options) {
}
/**
* @returns {HTMLElement}
* @returns {Element}
*/
export function getFixture() {
if (QUnit.config.debug) {
@@ -316,7 +316,7 @@ export async function triggerEvents(el, querySelector, events, options) {
* the end of the scrollable area, the event can be transmitted
* to its nearest parent until it can be triggered
*
* @param {HTMLElement} target target of the scroll event
* @param {Element} target target of the scroll event
* @param {Object} coordinates
* @param {Number} coordinates[left] coordinates to scroll horizontally
* @param {Number} coordinates[top] coordinates to scroll vertically
@@ -423,7 +423,7 @@ export async function clickDiscard(htmlElement) {
* coordinates are given, the event is located by default
* in the middle of the target to simplify the test process
*
* @param {HTMLElement} el
* @param {Element} el
* @param {string} selector
* @param {Object} coordinates position of the mouseenter event
*/
@@ -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
@@ -714,14 +730,13 @@ function getDifferentParents(n1, n2) {
* Note that only the last event is awaited, since all the others are
* considered to be synchronous.
*
* @param {Element|string} from
* @param {Element|string} to
* @param {string} [position] "top" | "bottom" | "left" | "right"
* @returns {Promise<void>}
* @param {Element | string} from
* @param {Element | string} to
* @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);
}
/**
@@ -729,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>}
*
* @param {Element | string} from
*/
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) {
+11 -11
View File
@@ -62,7 +62,7 @@ export function setupQUnit() {
}
$el = $(target.el);
} else {
$el = target instanceof HTMLElement ? $(target) : target;
$el = target instanceof Element ? $(target) : target;
}
msg = msg || `Selector '${selector}' should have exactly ${n} matches inside the target`;
QUnit.assert.strictEqual($el.find(selector).length, n, msg);
@@ -71,7 +71,7 @@ export function setupQUnit() {
/**
* Checks that the target contains exactly 0 match for the selector.
*
* @param {HTMLElement} el
* @param {Element} el
* @param {string} selector
* @param {string} [msg]
*/
@@ -82,7 +82,7 @@ export function setupQUnit() {
/**
* Checks that the target contains exactly 1 match for the selector.
*
* @param {HTMLElement} el
* @param {Element} el
* @param {string} selector
* @param {string} [msg]
*/
@@ -94,7 +94,7 @@ export function setupQUnit() {
* Helper function, to check if a given element has (or has not) classnames.
*
* @private
* @param {HTMLElement|jQuery|Widget} el
* @param {Element | jQuery | Widget} el
* @param {string} classNames
* @param {boolean} shouldHaveClass
* @param {string} [msg]
@@ -103,7 +103,7 @@ export function setupQUnit() {
if (el) {
if (el._widgetRenderAndInsert) {
el = el.el; // legacy widget
} else if (!(el instanceof HTMLElement)) {
} else if (!(el instanceof Element)) {
el = el[0];
}
}
@@ -120,7 +120,7 @@ export function setupQUnit() {
/**
* Checks that the target element has the given classnames.
*
* @param {HTMLElement} el
* @param {Element} el
* @param {string} classNames
* @param {string} [msg]
*/
@@ -131,7 +131,7 @@ export function setupQUnit() {
/**
* Checks that the target element does not have the given classnames.
*
* @param {HTMLElement} el
* @param {Element} el
* @param {string} classNames
* @param {string} [msg]
*/
@@ -145,7 +145,7 @@ export function setupQUnit() {
* - is unique
* - has the given attribute with the proper value
*
* @param {Widget|jQuery|HTMLElement|Component} w
* @param {Component | Element | Widget | jQuery} w
* @param {string} attr
* @param {string} value
* @param {string} [msg]
@@ -162,7 +162,7 @@ export function setupQUnit() {
}
$el = $(target.el);
} else {
$el = target instanceof HTMLElement ? $(target) : target;
$el = target instanceof Element ? $(target) : target;
}
if ($el.length !== 1) {
@@ -183,7 +183,7 @@ export function setupQUnit() {
* - is (or not) visible
*
* @private
* @param {HTMLElement|jQuery|Widget} el
* @param {Element | jQuery | Widget} el
* @param {boolean} shouldBeVisible
* @param {string} [msg]
*/
@@ -191,7 +191,7 @@ export function setupQUnit() {
if (el) {
if (el._widgetRenderAndInsert) {
el = el.el; // legacy widget
} else if (!(el instanceof HTMLElement)) {
} else if (!(el instanceof Element)) {
el = el[0];
}
}
@@ -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);
});
+9
View File
@@ -60,6 +60,15 @@ addons/base_import/static/src/legacy/**/*
web_cohort/static/src/legacy/**/*
web_cohort/static/tests/legacy/**/*
# web_gantt
# whitelist new code
!web_gantt
!web_gantt/**/*
# blacklist legacy
web_gantt/static/src/legacy/**/*
web_gantt/static/tests/legacy/**/*
# web_map
# whitelist new code
!web_map