mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 07:32:08 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -34,7 +34,16 @@ $lato-font-path: './lato';
|
|||
font-style: $style;
|
||||
unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF;
|
||||
}
|
||||
|
||||
// Telugu: U+0C00-0C7F
|
||||
@font-face {
|
||||
font-family: 'Odoo Unicode Support Noto';
|
||||
src: url('https://fonts.odoocdn.com/fonts/noto/NotoSansTelugu-#{$type}.woff2') format('woff2'),
|
||||
url('https://fonts.odoocdn.com/fonts/noto/NotoSansTelugu-#{$type}.woff') format('woff'),
|
||||
url('https://fonts.odoocdn.com/fonts/noto/NotoSansTelugu-#{$type}.ttf') format('truetype');
|
||||
font-weight: $weight;
|
||||
font-style: $style;
|
||||
unicode-range: U+0C00-0C7F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
src: url('#{$lato-font-path}/Lato-#{$type}-webfont.eot');
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ import { waitUntil } from "./time";
|
|||
*
|
||||
* @typedef {{
|
||||
* contains?: string;
|
||||
* count?: number;
|
||||
* displayed?: boolean;
|
||||
* empty?: boolean;
|
||||
* eq?: number;
|
||||
* exact?: number;
|
||||
* first?: boolean;
|
||||
* focusable?: boolean;
|
||||
* has?: boolean;
|
||||
|
|
@ -90,6 +90,10 @@ import { waitUntil } from "./time";
|
|||
* raw?: boolean;
|
||||
* }} QueryTextOptions
|
||||
*
|
||||
* @typedef {{
|
||||
* raw?: boolean;
|
||||
* }} QueryValueOptions
|
||||
*
|
||||
* @typedef {"both" | "x" | "y"} ScrollAxis
|
||||
*
|
||||
* @typedef {import("./time").WaitOptions} WaitOptions
|
||||
|
|
@ -116,11 +120,9 @@ const {
|
|||
innerWidth,
|
||||
innerHeight,
|
||||
Map,
|
||||
MutationObserver,
|
||||
Number: { isInteger: $isInteger, isNaN: $isNaN, parseInt: $parseInt, parseFloat: $parseFloat },
|
||||
Object: { entries: $entries, keys: $keys, values: $values },
|
||||
RegExp,
|
||||
Set,
|
||||
String: { raw: $raw },
|
||||
window,
|
||||
} = globalThis;
|
||||
|
|
@ -326,6 +328,13 @@ function getFiltersDescription(modifierInfo) {
|
|||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
*/
|
||||
function getInlineNodeText(node) {
|
||||
return getNodeText(node, { inline: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @returns {NodeValue}
|
||||
|
|
@ -337,7 +346,7 @@ function getNodeContent(node) {
|
|||
case "textarea":
|
||||
return getNodeValue(node);
|
||||
case "select":
|
||||
return [...node.selectedOptions].map(getNodeValue).join(",");
|
||||
return [...node.selectedOptions].map((node) => getNodeValue(node)).join(",");
|
||||
}
|
||||
return getNodeText(node);
|
||||
}
|
||||
|
|
@ -355,6 +364,30 @@ function getNodeShadowRoot(node) {
|
|||
return node.shadowRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pseudoClass
|
||||
*/
|
||||
function getQueryFilter(pseudoClass, content) {
|
||||
const makeQueryFilter = customPseudoClasses.get(pseudoClass);
|
||||
try {
|
||||
return makeQueryFilter(content);
|
||||
} catch (err) {
|
||||
let message = `error while parsing pseudo-class ':${pseudoClass}'`;
|
||||
const cause = String(err?.message || err);
|
||||
if (cause) {
|
||||
message += `: ${cause}`;
|
||||
}
|
||||
throw new HootDomError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
*/
|
||||
function getRawValue(node) {
|
||||
return node.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} string
|
||||
*/
|
||||
|
|
@ -374,6 +407,17 @@ function getWaitForNoneMessage() {
|
|||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} count
|
||||
* @param {Parameters<NodeFilter>[0]} _node
|
||||
* @param {Parameters<NodeFilter>[1]} _i
|
||||
* @param {Parameters<NodeFilter>[2]} nodes
|
||||
*/
|
||||
function hasNodeCount(count, _node, _i, nodes) {
|
||||
return count === nodes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [char]
|
||||
*/
|
||||
|
|
@ -477,28 +521,27 @@ function isWhiteSpace(char) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {string} pseudoClass
|
||||
* @param {(node: Node) => NodeValue} getContent
|
||||
* @param {(node: Node) => string} getContent
|
||||
* @param {boolean} exact
|
||||
*/
|
||||
function makePatternBasedPseudoClass(pseudoClass, getContent) {
|
||||
return (content) => {
|
||||
let regex;
|
||||
try {
|
||||
regex = parseRegExp(content);
|
||||
} catch (err) {
|
||||
throw selectorError(pseudoClass, err.message);
|
||||
}
|
||||
function makePseudoClassMatcher(getContent, exact) {
|
||||
return function makePartialMatcher(content) {
|
||||
const regex = parseRegExp(content);
|
||||
if (isInstanceOf(regex, RegExp)) {
|
||||
return function containsRegExp(node) {
|
||||
return regex.test(String(getContent(node)));
|
||||
return function stringMatches(node) {
|
||||
return regex.test(getContent(node));
|
||||
};
|
||||
} else {
|
||||
const lowerContent = content.toLowerCase();
|
||||
return function containsString(node) {
|
||||
return getStringContent(String(getContent(node)))
|
||||
.toLowerCase()
|
||||
.includes(lowerContent);
|
||||
};
|
||||
if (exact) {
|
||||
return function stringEquals(node) {
|
||||
return getContent(node).toLowerCase() === lowerContent;
|
||||
};
|
||||
} else {
|
||||
return function stringContains(node) {
|
||||
return getContent(node).toLowerCase().includes(lowerContent);
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -709,7 +752,6 @@ function parseSelector(selector) {
|
|||
if (currentPseudo) {
|
||||
if (parens[0] === parens[1]) {
|
||||
const [pseudo, content] = currentPseudo;
|
||||
const makeFilter = customPseudoClasses.get(pseudo);
|
||||
if (pseudo === "iframe" && !currentPart[0].startsWith("iframe")) {
|
||||
// Special case: to optimise the ":iframe" pseudo class, we
|
||||
// always select actual `iframe` elements.
|
||||
|
|
@ -717,7 +759,7 @@ function parseSelector(selector) {
|
|||
// but this pseudo won't work on non-iframe elements anyway.
|
||||
currentPart[0] = `iframe${currentPart[0]}`;
|
||||
}
|
||||
const filter = makeFilter(getStringContent(content));
|
||||
const filter = getQueryFilter(pseudo, getStringContent(content));
|
||||
selectorFilterDescriptors.set(filter, [pseudo, content]);
|
||||
currentPart.push(filter);
|
||||
currentPseudo = null;
|
||||
|
|
@ -872,14 +914,6 @@ function registerQueryMessage(filteredNodes, expectedCount) {
|
|||
return invalidCount ? lastQueryMessage : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pseudoClass
|
||||
* @param {string} message
|
||||
*/
|
||||
function selectorError(pseudoClass, message) {
|
||||
return new HootDomError(`invalid selector \`:${pseudoClass}\`: ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around '_queryAll' calls to ensure global variables are properly cleaned
|
||||
* up on any thrown error.
|
||||
|
|
@ -906,7 +940,10 @@ function _guardedQueryAll(target, options) {
|
|||
function _queryAll(target, options) {
|
||||
queryAllLevel++;
|
||||
|
||||
const { exact, root, ...modifiers } = options || {};
|
||||
const { count, root, ...modifiers } = options || {};
|
||||
if (count !== null && count !== undefined && (!$isInteger(count) || count <= 0)) {
|
||||
throw new HootDomError(`invalid 'count' option: should be a positive integer`);
|
||||
}
|
||||
|
||||
/** @type {Node[]} */
|
||||
let nodes = [];
|
||||
|
|
@ -946,15 +983,14 @@ function _queryAll(target, options) {
|
|||
if (content === false || !customPseudoClasses.has(modifier)) {
|
||||
continue;
|
||||
}
|
||||
const makeFilter = customPseudoClasses.get(modifier);
|
||||
const filter = makeFilter(content);
|
||||
const filter = getQueryFilter(modifier, content);
|
||||
modifierFilters.push(filter);
|
||||
globalFilterDescriptors.set(filter, [modifier, content]);
|
||||
}
|
||||
const filteredNodes = applyFilters(modifierFilters, nodes);
|
||||
|
||||
// Register query message (if needed), and/or throw an error accordingly
|
||||
const message = registerQueryMessage(filteredNodes, exact);
|
||||
const message = registerQueryMessage(filteredNodes, count);
|
||||
if (message) {
|
||||
throw new HootDomError(message);
|
||||
}
|
||||
|
|
@ -969,7 +1005,7 @@ function _queryAll(target, options) {
|
|||
* @param {QueryOptions} options
|
||||
*/
|
||||
function _queryOne(target, options) {
|
||||
return _guardedQueryAll(target, { ...options, exact: 1 })[0];
|
||||
return _guardedQueryAll(target, { ...options, count: 1 })[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1081,13 +1117,20 @@ let queryAllLevel = 0;
|
|||
const customPseudoClasses = new Map();
|
||||
|
||||
customPseudoClasses
|
||||
.set("contains", makePatternBasedPseudoClass("contains", getNodeText))
|
||||
.set("contains", makePseudoClassMatcher(getInlineNodeText, false))
|
||||
.set("count", (strCount) => {
|
||||
const count = $parseInt(strCount);
|
||||
if (!$isInteger(count) || count <= 0) {
|
||||
throw new HootDomError(`expected count to be a positive integer (got "${strCount}")`);
|
||||
}
|
||||
return hasNodeCount.bind(null, count);
|
||||
})
|
||||
.set("displayed", () => isNodeDisplayed)
|
||||
.set("empty", () => isEmpty)
|
||||
.set("eq", (strIndex) => {
|
||||
const index = $parseInt(strIndex);
|
||||
if (!$isInteger(index)) {
|
||||
throw selectorError("eq", `expected index to be an integer (got ${strIndex})`);
|
||||
throw new HootDomError(`expected index to be an integer (got "${strIndex}")`);
|
||||
}
|
||||
return index;
|
||||
})
|
||||
|
|
@ -1103,7 +1146,8 @@ customPseudoClasses
|
|||
.set("scrollable", (axis) => isNodeScrollable.bind(null, axis))
|
||||
.set("selected", () => isNodeSelected)
|
||||
.set("shadow", () => getNodeShadowRoot)
|
||||
.set("value", makePatternBasedPseudoClass("value", getNodeValue))
|
||||
.set("text", makePseudoClassMatcher(getInlineNodeText, true))
|
||||
.set("value", makePseudoClassMatcher(getRawValue, false))
|
||||
.set("viewPort", () => isNodeInViewPort)
|
||||
.set("visible", () => isNodeVisible);
|
||||
|
||||
|
|
@ -1167,9 +1211,13 @@ export function getNodeAttribute(node, attribute) {
|
|||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @param {QueryValueOptions} [options]
|
||||
* @returns {NodeValue}
|
||||
*/
|
||||
export function getNodeValue(node) {
|
||||
export function getNodeValue(node, options) {
|
||||
if (options?.raw) {
|
||||
return getRawValue(node);
|
||||
}
|
||||
switch (node.type) {
|
||||
case "checkbox":
|
||||
case "radio":
|
||||
|
|
@ -1185,8 +1233,9 @@ export function getNodeValue(node) {
|
|||
case "time":
|
||||
case "week":
|
||||
return node.valueAsDate.toISOString();
|
||||
default:
|
||||
return node.value || "";
|
||||
}
|
||||
return node.value;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1801,51 +1850,6 @@ export function matches(target, selector) {
|
|||
return elementsMatch(_guardedQueryAll(target), selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for DOM mutations on a given target.
|
||||
*
|
||||
* This helper has 2 main advantages over directly calling the native MutationObserver:
|
||||
* - it ensures a single observer is created for a given target, even if multiple
|
||||
* callbacks are registered;
|
||||
* - it keeps track of these observers, which allows to check whether an observer
|
||||
* is still running while it should not, and to disconnect all running observers
|
||||
* at once.
|
||||
*
|
||||
* @param {HTMLElement} target
|
||||
* @param {MutationCallback} callback
|
||||
*/
|
||||
export function observe(target, callback) {
|
||||
if (observers.has(target)) {
|
||||
observers.get(target).callbacks.add(callback);
|
||||
} else {
|
||||
const callbacks = new Set([callback]);
|
||||
const observer = new MutationObserver((mutations, observer) => {
|
||||
for (const callback of callbacks) {
|
||||
callback(mutations, observer);
|
||||
}
|
||||
});
|
||||
observer.observe(target, {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
observers.set(target, { callbacks, observer });
|
||||
}
|
||||
|
||||
return function disconnect() {
|
||||
if (!observers.has(target)) {
|
||||
return;
|
||||
}
|
||||
const { callbacks, observer } = observers.get(target);
|
||||
callbacks.delete(callback);
|
||||
if (!callbacks.size) {
|
||||
observer.disconnect();
|
||||
observers.delete(target);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of nodes matching the given {@link Target}.
|
||||
* This function can either be used as a **template literal tag** (only supports
|
||||
|
|
@ -1859,12 +1863,11 @@ export function observe(target, callback) {
|
|||
* This function allows all string selectors supported by the native {@link Element.querySelector}
|
||||
* along with some additional custom pseudo-classes:
|
||||
*
|
||||
* - `:contains(text)`: matches nodes whose *content* matches the given *text*;
|
||||
* * given *text* supports regular expression syntax (e.g. `:contains(/^foo.+/)`)
|
||||
* and is case-insensitive;
|
||||
* * given *text* will be matched against:
|
||||
* - an `<input>`, `<textarea>` or `<select>` element's **value**;
|
||||
* - or any other element's **inner text**.
|
||||
* - `:contains(text)`: matches nodes whose *text content* includes the given *text*.
|
||||
* * The match is **partial** and **case-insensitive**;
|
||||
* * Given *text* also supports regular expressions (e.g. `:contains(/^foo.+/)`).
|
||||
* - `:count`: return nodes if their count match the given *count*.
|
||||
* If not matching, an error is thrown;
|
||||
* - `:displayed`: matches nodes that are "displayed" (see {@link isDisplayed});
|
||||
* - `:empty`: matches nodes that have an empty *content* (**value** or **inner text**);
|
||||
* - `:eq(n)`: matches the *nth* node (0-based index);
|
||||
|
|
@ -1880,22 +1883,23 @@ export function observe(target, callback) {
|
|||
* - `:selected`: matches nodes that are selected (e.g. `<option>` elements);
|
||||
* - `:shadow`: matches nodes that have shadow roots, and returns their shadow root;
|
||||
* - `:scrollable(axis)`: matches nodes that are scrollable (see {@link isScrollable});
|
||||
* - `:text(text)`: matches nodes whose *content* is strictly equal to the given *text*;
|
||||
* * The match is **exact**, and **case-insensitive**;
|
||||
* * Given *text* also supports regular expressions (e.g. `:text(/^foo.+/)`).
|
||||
* - `:value(value)`: matches nodes whose *value* is strictly equal to the given *value*;
|
||||
* * The match is **partial**, and **case-insensitive**;
|
||||
* * Given *value* also supports regular expressions (e.g. `:value(/^foo.+/)`).
|
||||
* - `:viewPort`: matches nodes that are contained in the current view port (see
|
||||
* {@link isInViewPort});
|
||||
* - `:visible`: matches nodes that are "visible" (see {@link isVisible});
|
||||
*
|
||||
* An `options` object can be specified to filter[1] the results:
|
||||
* - `displayed`: whether the nodes must be "displayed" (see {@link isDisplayed});
|
||||
* - `exact`: the exact number of nodes to match (throws an error if the number of
|
||||
* nodes doesn't match);
|
||||
* - `focusable`: whether the nodes must be "focusable" (see {@link isFocusable});
|
||||
* - `root`: the root node to query the selector in (defaults to the current fixture);
|
||||
* - `viewPort`: whether the nodes must be partially visible in the current viewport
|
||||
* (see {@link isInViewPort});
|
||||
* - `visible`: whether the nodes must be "visible" (see {@link isVisible}).
|
||||
* * This option implies `displayed`
|
||||
* - any of the *custom pseudo-classes* can be given as an option, with the value
|
||||
* being a boolean for standalone pseudo-classes (e.g. `{ empty: true }`), or a
|
||||
* string for the others (e.g. `{ contains: "text" }`).
|
||||
*
|
||||
* [1] these filters (except for `exact` and `root`) achieve the same result as
|
||||
* [1] these filters (except for `count` and `root`) achieve the same result as
|
||||
* using their homonym pseudo-classes on the final group of the given selector
|
||||
* string (e.g. ```queryAll`ul > li:visible`;``` = ```queryAll("ul > li", { visible: true })```).
|
||||
*
|
||||
|
|
@ -1917,10 +1921,11 @@ export function observe(target, callback) {
|
|||
* queryAll`#editor:shadow div`; // -> [div, div, ...] (inside shadow DOM)
|
||||
* @example
|
||||
* // with options
|
||||
* queryAll(`div:first`, { exact: 1 }); // -> [div]
|
||||
* queryAll(`div:first`, { count: 1 }); // -> [div]
|
||||
* queryAll(`div`, { root: queryOne`iframe` }); // -> [div, div, ...]
|
||||
* // redundant, but possible
|
||||
* queryAll(`button:visible`, { visible: true }); // -> [button, button, ...]
|
||||
* // the next 2 queries will return the same results
|
||||
* queryAll(`button:visible`); // -> [button, button, ...]
|
||||
* queryAll(`button`, { visible: true }); // -> [button, button, ...]
|
||||
*/
|
||||
export function queryAll(target, options) {
|
||||
[target, options] = parseRawArgs(arguments);
|
||||
|
|
@ -1989,12 +1994,12 @@ export function queryAllTexts(target, options) {
|
|||
* *values* of the matching nodes.
|
||||
*
|
||||
* @param {Target} target
|
||||
* @param {QueryOptions} [options]
|
||||
* @param {QueryOptions & QueryValueOptions} [options]
|
||||
* @returns {NodeValue[]}
|
||||
*/
|
||||
export function queryAllValues(target, options) {
|
||||
[target, options] = parseRawArgs(arguments);
|
||||
return _guardedQueryAll(target, options).map(getNodeValue);
|
||||
return _guardedQueryAll(target, options).map((node) => getNodeValue(node, options));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2039,20 +2044,20 @@ export function queryFirst(target, options) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Performs a {@link queryAll} with the given arguments, along with a forced `exact: 1`
|
||||
* Performs a {@link queryAll} with the given arguments, along with a forced `count: 1`
|
||||
* option to ensure only one node matches the given {@link Target}.
|
||||
*
|
||||
* The returned value is a single node instead of a list of nodes.
|
||||
*
|
||||
* @param {Target} target
|
||||
* @param {Omit<QueryOptions, "exact">} [options]
|
||||
* @param {Omit<QueryOptions, "count">} [options]
|
||||
* @returns {Element}
|
||||
*/
|
||||
export function queryOne(target, options) {
|
||||
[target, options] = parseRawArgs(arguments);
|
||||
if ($isInteger(options?.exact)) {
|
||||
if ($isInteger(options?.count)) {
|
||||
throw new HootDomError(
|
||||
`cannot call \`queryOne\` with 'exact'=${options.exact}: did you mean to use \`queryAll\`?`
|
||||
`cannot call \`queryOne\` with 'count'=${options.count}: did you mean to use \`queryAll\`?`
|
||||
);
|
||||
}
|
||||
return _queryOne(target, options);
|
||||
|
|
@ -2094,12 +2099,12 @@ export function queryText(target, options) {
|
|||
* the matching node.
|
||||
*
|
||||
* @param {Target} target
|
||||
* @param {QueryOptions} [options]
|
||||
* @param {QueryOptions & QueryValueOptions} [options]
|
||||
* @returns {NodeValue}
|
||||
*/
|
||||
export function queryValue(target, options) {
|
||||
[target, options] = parseRawArgs(arguments);
|
||||
return getNodeValue(_queryOne(target, options));
|
||||
return getNodeValue(_queryOne(target, options), options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
setDimensions,
|
||||
toSelector,
|
||||
} from "./dom";
|
||||
import { microTick } from "./time";
|
||||
|
||||
/**
|
||||
* @typedef {Target | Promise<Target>} AsyncTarget
|
||||
|
|
@ -78,6 +77,10 @@ import { microTick } from "./time";
|
|||
*
|
||||
* @typedef {EventOptions & KeyboardEventInit} KeyboardOptions
|
||||
*
|
||||
* @typedef {{
|
||||
* fullClear?: boolean;
|
||||
* }} KeyDownOptions
|
||||
*
|
||||
* @typedef {string | string[]} KeyStrokes
|
||||
*
|
||||
* @typedef {EventOptions & QueryOptions & {
|
||||
|
|
@ -101,6 +104,11 @@ import { microTick } from "./time";
|
|||
* @typedef {"bottom" | "left" | "right" | "top"} Side
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {T | () => T} Resolver
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template [T=EventInit]
|
||||
* @typedef {T & {
|
||||
|
|
@ -137,6 +145,7 @@ const {
|
|||
File,
|
||||
FocusEvent,
|
||||
HashChangeEvent,
|
||||
HTMLElement,
|
||||
KeyboardEvent,
|
||||
Math: { ceil: $ceil, max: $max, min: $min },
|
||||
MouseEvent,
|
||||
|
|
@ -160,6 +169,22 @@ const {
|
|||
const $createRange = document.createRange.bind(document);
|
||||
const $toString = Object.prototype.toString;
|
||||
|
||||
const $blur = HTMLElement.prototype.blur;
|
||||
HTMLElement.prototype.blur = function mockBlur() {
|
||||
if (runTime.changeTarget && runTime.changeTarget === this) {
|
||||
triggerChange();
|
||||
}
|
||||
return $blur.call(this, ...arguments);
|
||||
};
|
||||
|
||||
const $focus = HTMLElement.prototype.focus;
|
||||
HTMLElement.prototype.focus = function mockFocus() {
|
||||
if (runTime.changeTarget && runTime.changeTarget !== this) {
|
||||
triggerChange();
|
||||
}
|
||||
return $focus.call(this, ...arguments);
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
|
@ -266,7 +291,7 @@ function deleteSelection(target) {
|
|||
* target: T;
|
||||
* events: EventType[];
|
||||
* additionalEvents?: EventType[];
|
||||
* callback?: (target: T) => any;
|
||||
* callback?: () => any;
|
||||
* options?: EventInit;
|
||||
* }} params
|
||||
*/
|
||||
|
|
@ -274,9 +299,7 @@ async function dispatchAndIgnore({ target, events, additionalEvents = [], callba
|
|||
for (const eventType of [...events, ...additionalEvents]) {
|
||||
runTime.eventsToIgnore.push(eventType);
|
||||
}
|
||||
if (callback) {
|
||||
callback(target);
|
||||
}
|
||||
callback?.();
|
||||
for (const eventType of events) {
|
||||
await _dispatch(target, eventType, options);
|
||||
}
|
||||
|
|
@ -325,12 +348,19 @@ async function dispatchRelatedEvents(events, eventType, eventInit) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @template T, [R=T]
|
||||
* @param {MaybeIterable<T>} value
|
||||
* @param {(item: T) => R} [mapFn]
|
||||
* @returns {T[]}
|
||||
*/
|
||||
function ensureArray(value) {
|
||||
return isIterable(value) ? [...value] : [value];
|
||||
function ensureArray(value, mapFn) {
|
||||
if (Array.isArray(value)) {
|
||||
return mapFn ? value.map(mapFn) : value;
|
||||
}
|
||||
if (isIterable(value)) {
|
||||
return Array.from(value, mapFn);
|
||||
}
|
||||
return [mapFn ? mapFn(value) : value];
|
||||
}
|
||||
|
||||
function getCurrentEvents() {
|
||||
|
|
@ -344,13 +374,20 @@ function getCurrentEvents() {
|
|||
|
||||
function getDefaultRunTimeValue() {
|
||||
return {
|
||||
isComposing: false,
|
||||
|
||||
// Data transfers
|
||||
// Keyboard & data transfers
|
||||
/** @type {HTMLElement | null} */
|
||||
changeTarget: null,
|
||||
/** @type {(() => any)[]} */
|
||||
changeTargetListeners: [],
|
||||
/** @type {DataTransfer | null} */
|
||||
clipboardData: null,
|
||||
/** @type {DataTransfer | null} */
|
||||
dataTransfer: null,
|
||||
/** @type {string | null} */
|
||||
initialValue: null,
|
||||
isComposing: false,
|
||||
/** @type {string | null} */
|
||||
key: null,
|
||||
|
||||
// Drag & drop
|
||||
canStartDrag: false,
|
||||
|
|
@ -359,7 +396,6 @@ function getDefaultRunTimeValue() {
|
|||
|
||||
// Pointer
|
||||
clickCount: 0,
|
||||
key: null,
|
||||
/** @type {HTMLElement | null} */
|
||||
pointerDownTarget: null,
|
||||
pointerDownTimeout: 0,
|
||||
|
|
@ -567,18 +603,22 @@ function getFirstCommonParent(a, b) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the interactive pointer target from a given element, unless the element
|
||||
* is falsy, or the 'interactive' option is set to `false`.
|
||||
*
|
||||
* If an 'originalTarget' is given, the helper will deliberately throw an error if
|
||||
* no interactive elements are found.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {Target} originalTarget
|
||||
* @param {QueryOptions} options
|
||||
* @param {AsyncTarget} [originalTarget]
|
||||
*/
|
||||
function getPointerTarget(element, originalTarget, options) {
|
||||
if (options?.interactive === false) {
|
||||
// Explicit 'interactive: false' option
|
||||
// -> element can be a non-interactive element
|
||||
function getPointerTarget(element, options, originalTarget) {
|
||||
if (!element || options?.interactive === false) {
|
||||
return element;
|
||||
}
|
||||
const interactiveElement = getInteractiveNode(element);
|
||||
if (!interactiveElement) {
|
||||
if (!interactiveElement && originalTarget) {
|
||||
queryAny(originalTarget, { ...options, interactive: true }); // Will throw if no elements are found
|
||||
}
|
||||
return interactiveElement;
|
||||
|
|
@ -662,7 +702,7 @@ function getStringSelection(target) {
|
|||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @param {...string} tagNames
|
||||
* @param {...string} tagNames
|
||||
*/
|
||||
function hasTagName(node, ...tagNames) {
|
||||
return tagNames.includes(getTag(node));
|
||||
|
|
@ -716,7 +756,7 @@ function isPrevented(event) {
|
|||
* @returns {KeyboardEventInit}
|
||||
*/
|
||||
function parseKeyStrokes(keyStrokes, options) {
|
||||
return (isIterable(keyStrokes) ? [...keyStrokes] : [keyStrokes]).map((key) => {
|
||||
return ensureArray(keyStrokes, (key) => {
|
||||
const lower = key.toLowerCase();
|
||||
return {
|
||||
...options,
|
||||
|
|
@ -789,8 +829,9 @@ function registerButton(eventInit, toggle) {
|
|||
* @param {Event} ev
|
||||
*/
|
||||
function registerFileInput({ target }) {
|
||||
if (getTag(target) === "input" && target.type === "file") {
|
||||
runTime.fileInput = target;
|
||||
const actualTarget = target.shadowRoot ? target.shadowRoot.activeElement : target;
|
||||
if (getTag(actualTarget) === "input" && actualTarget.type === "file") {
|
||||
runTime.fileInput = actualTarget;
|
||||
} else {
|
||||
runTime.fileInput = null;
|
||||
}
|
||||
|
|
@ -798,46 +839,32 @@ function registerFileInput({ target }) {
|
|||
|
||||
/**
|
||||
* @param {EventTarget} target
|
||||
* @param {string} initialValue
|
||||
* @param {ConfirmAction} confirmAction
|
||||
*/
|
||||
async function registerForChange(target, initialValue, confirmAction) {
|
||||
function dispatchChange() {
|
||||
return target.value !== initialValue && _dispatch(target, "change");
|
||||
}
|
||||
|
||||
async function registerForChange(target, confirmAction) {
|
||||
confirmAction &&= confirmAction.toLowerCase();
|
||||
if (confirmAction === "auto") {
|
||||
confirmAction = getTag(target) === "input" ? "enter" : "blur";
|
||||
}
|
||||
if (getTag(target) === "input") {
|
||||
changeTargetListeners.push(
|
||||
on(target, "keydown", (ev) => {
|
||||
if (isPrevented(ev) || ev.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
removeChangeTargetListeners();
|
||||
afterNextDispatch = dispatchChange;
|
||||
})
|
||||
);
|
||||
} else if (confirmAction === "enter") {
|
||||
|
||||
const parentDocument = getDocument(target);
|
||||
const parentView = parentDocument.defaultView;
|
||||
if (confirmAction === "enter" && getTag(target) !== "input") {
|
||||
throw new HootInteractionError(
|
||||
`"enter" confirm action is only supported on <input/> elements`
|
||||
);
|
||||
}
|
||||
|
||||
changeTargetListeners.push(
|
||||
on(target, "blur", () => {
|
||||
removeChangeTargetListeners();
|
||||
dispatchChange();
|
||||
}),
|
||||
on(target, "change", removeChangeTargetListeners)
|
||||
runTime.changeTarget = target;
|
||||
runTime.changeTargetListeners.push(
|
||||
on(parentView, "focusout", triggerChange),
|
||||
on(parentView, "change", removeChangeTargetListeners)
|
||||
);
|
||||
|
||||
switch (confirmAction) {
|
||||
case "blur": {
|
||||
await _hover(
|
||||
getDocument(target).body,
|
||||
parentDocument.body,
|
||||
{ position: { x: 0, y: 0 } },
|
||||
{ originalTarget: target }
|
||||
);
|
||||
|
|
@ -881,19 +908,31 @@ function registerSpecialKey(eventInit, toggle) {
|
|||
}
|
||||
|
||||
function removeChangeTargetListeners() {
|
||||
while (changeTargetListeners.length) {
|
||||
changeTargetListeners.pop()();
|
||||
for (const listener of runTime.changeTargetListeners) {
|
||||
listener();
|
||||
}
|
||||
runTime.changeTarget = null;
|
||||
runTime.changeTargetListeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {Resolver<T>} resolver
|
||||
* @returns {T}
|
||||
*/
|
||||
function resolve(resolver) {
|
||||
return typeof resolver === "function" ? resolver() : resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement | null} target
|
||||
* @param {QueryOptions} [options]
|
||||
*/
|
||||
function setPointerDownTarget(target) {
|
||||
function setPointerDownTarget(target, options) {
|
||||
if (runTime.pointerDownTarget) {
|
||||
runTime.previousPointerDownTarget = runTime.pointerDownTarget;
|
||||
}
|
||||
runTime.pointerDownTarget = target;
|
||||
runTime.pointerDownTarget = getPointerTarget(target, options);
|
||||
runTime.canStartDrag = false;
|
||||
}
|
||||
|
||||
|
|
@ -991,6 +1030,16 @@ function toEventPosition(clientX, clientY, position) {
|
|||
};
|
||||
}
|
||||
|
||||
async function triggerChange() {
|
||||
const target = runTime.changeTarget;
|
||||
const hasValueChanged = runTime.initialValue !== target.value;
|
||||
runTime.initialValue = null;
|
||||
removeChangeTargetListeners();
|
||||
if (target && hasValueChanged) {
|
||||
return _dispatch(target, "change");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} target
|
||||
* @param {PointerEventInit} pointerInit
|
||||
|
|
@ -1057,10 +1106,13 @@ async function triggerFocus(target) {
|
|||
return;
|
||||
}
|
||||
if (previous !== target.ownerDocument.body) {
|
||||
if (runTime.changeTarget) {
|
||||
await triggerChange();
|
||||
}
|
||||
await dispatchAndIgnore({
|
||||
target: previous,
|
||||
events: ["blur", "focusout"],
|
||||
callback: (el) => el.blur(),
|
||||
callback: $blur.bind(previous),
|
||||
options: { relatedTarget: target },
|
||||
});
|
||||
}
|
||||
|
|
@ -1070,7 +1122,7 @@ async function triggerFocus(target) {
|
|||
target,
|
||||
events: ["focus", "focusin"],
|
||||
additionalEvents: ["select"],
|
||||
callback: (el) => el.focus(),
|
||||
callback: $focus.bind(target),
|
||||
options: { relatedTarget: previous },
|
||||
});
|
||||
if (previousSelection && previousSelection === getStringSelection(target)) {
|
||||
|
|
@ -1080,22 +1132,17 @@ async function triggerFocus(target) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} target
|
||||
* @param {Resolver<EventTarget>} targetResolver
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
async function _clear(target, options) {
|
||||
// Inputs and text areas
|
||||
const initialValue = target.value;
|
||||
|
||||
async function _clear(targetResolver, options) {
|
||||
// Simulates 2 key presses:
|
||||
// - Control + A: selects all the text
|
||||
// - Backspace: deletes the text
|
||||
fullClear = true;
|
||||
await _press(target, { ctrlKey: true, key: "a" });
|
||||
await _press(target, { key: "Backspace" });
|
||||
fullClear = false;
|
||||
await _press(targetResolver, { ctrlKey: true, key: "a" });
|
||||
await _press(targetResolver, { key: "Backspace" }, { fullClear: true });
|
||||
|
||||
await registerForChange(target, initialValue, options?.confirm);
|
||||
await registerForChange(resolve(targetResolver), options?.confirm);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1143,45 +1190,41 @@ async function _dispatch(target, type, eventInit) {
|
|||
const event = new Constructor(type, params);
|
||||
|
||||
target.dispatchEvent(event);
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
getCurrentEvents().push(event);
|
||||
|
||||
if (afterNextDispatch) {
|
||||
const callback = afterNextDispatch;
|
||||
afterNextDispatch = null;
|
||||
await microTick().then(callback);
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} target
|
||||
* @param {Resolver<EventTarget>} targetResolver
|
||||
* @param {InputValue} value
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
async function _fill(target, value, options) {
|
||||
const initialValue = target.value;
|
||||
async function _fill(targetResolver, value, options) {
|
||||
const initialTarget = resolve(targetResolver);
|
||||
|
||||
if (getTag(target) === "input") {
|
||||
switch (target.type) {
|
||||
case "color": {
|
||||
target.value = String(value);
|
||||
await _dispatch(target, "input");
|
||||
await _dispatch(target, "change");
|
||||
if (getTag(initialTarget) === "input") {
|
||||
switch (initialTarget.type) {
|
||||
case "color":
|
||||
case "time": {
|
||||
initialTarget.value = String(value);
|
||||
await _dispatch(initialTarget, "input");
|
||||
await _dispatch(initialTarget, "change");
|
||||
return;
|
||||
}
|
||||
case "file": {
|
||||
const files = ensureArray(value);
|
||||
if (files.length > 1 && !target.multiple) {
|
||||
if (files.length > 1 && !initialTarget.multiple) {
|
||||
throw new HootInteractionError(
|
||||
`input[type="file"] does not support multiple files`
|
||||
);
|
||||
}
|
||||
target.files = createDataTransfer({ files }).files;
|
||||
initialTarget.files = createDataTransfer({ files }).files;
|
||||
|
||||
await _dispatch(target, "change");
|
||||
await _dispatch(initialTarget, "change");
|
||||
return;
|
||||
}
|
||||
case "range": {
|
||||
|
|
@ -1190,9 +1233,9 @@ async function _fill(target, value, options) {
|
|||
throw new TypeError(`input[type="range"] only accept 'number' values`);
|
||||
}
|
||||
|
||||
target.value = String(numberValue);
|
||||
await _dispatch(target, "input");
|
||||
await _dispatch(target, "change");
|
||||
initialTarget.value = String(numberValue);
|
||||
await _dispatch(initialTarget, "input");
|
||||
await _dispatch(initialTarget, "change");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1201,25 +1244,25 @@ async function _fill(target, value, options) {
|
|||
if (options?.instantly) {
|
||||
// Simulates filling the clipboard with the value (can be from external source)
|
||||
globalThis.navigator.clipboard.writeText(value).catch();
|
||||
await _press(target, { ctrlKey: true, key: "v" });
|
||||
await _press(targetResolver, { ctrlKey: true, key: "v" });
|
||||
} else {
|
||||
if (options?.composition) {
|
||||
runTime.isComposing = true;
|
||||
// Simulates the start of a composition
|
||||
await _dispatch(target, "compositionstart");
|
||||
await _dispatch(initialTarget, "compositionstart");
|
||||
}
|
||||
for (const char of String(value)) {
|
||||
const key = char.toLowerCase();
|
||||
await _press(target, { key, shiftKey: key !== char });
|
||||
await _press(targetResolver, { key, shiftKey: key !== char });
|
||||
}
|
||||
if (options?.composition) {
|
||||
runTime.isComposing = false;
|
||||
// Simulates the end of a composition
|
||||
await _dispatch(target, "compositionend");
|
||||
await _dispatch(initialTarget, "compositionend");
|
||||
}
|
||||
}
|
||||
|
||||
await registerForChange(target, initialValue, options?.confirm);
|
||||
await registerForChange(resolve(targetResolver), options?.confirm);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1228,7 +1271,7 @@ async function _fill(target, value, options) {
|
|||
* @param {{ implicit?: boolean, originalTarget: AsyncTarget }} hoverOptions
|
||||
*/
|
||||
async function _hover(target, options, hoverOptions) {
|
||||
const pointerTarget = target && getPointerTarget(target, hoverOptions.originalTarget, options);
|
||||
const pointerTarget = getPointerTarget(target, options, hoverOptions.originalTarget);
|
||||
const position = target && getPosition(target, options);
|
||||
|
||||
const previousPT = runTime.pointerTarget;
|
||||
|
|
@ -1340,17 +1383,22 @@ async function _hover(target, options, hoverOptions) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} target
|
||||
* @param {Resolver<EventTarget>} targetResolver
|
||||
* @param {KeyboardEventInit} eventInit
|
||||
* @param {KeyDownOptions} [options]
|
||||
*/
|
||||
async function _keyDown(target, eventInit) {
|
||||
async function _keyDown(targetResolver, eventInit, options) {
|
||||
eventInit = { ...eventInit, ...currentEventInit.keydown };
|
||||
registerSpecialKey(eventInit, true);
|
||||
|
||||
const keyDownTarget = resolve(targetResolver);
|
||||
const repeat =
|
||||
typeof eventInit.repeat === "boolean" ? eventInit.repeat : runTime.key === eventInit.key;
|
||||
runTime.key = eventInit.key;
|
||||
const keyDownEvent = await _dispatch(target, "keydown", { ...eventInit, repeat });
|
||||
const keyDownEvent = await _dispatch(keyDownTarget, "keydown", {
|
||||
...eventInit,
|
||||
repeat,
|
||||
});
|
||||
|
||||
if (isPrevented(keyDownEvent)) {
|
||||
return;
|
||||
|
|
@ -1361,10 +1409,11 @@ async function _keyDown(target, eventInit) {
|
|||
* @param {string} type
|
||||
*/
|
||||
function insertValue(toInsert, type) {
|
||||
const { selectionStart, selectionEnd, value } = target;
|
||||
const { selectionStart, selectionEnd, value } = inputTarget;
|
||||
inputData = toInsert;
|
||||
inputType = type;
|
||||
if (isNil(selectionStart) && isNil(selectionEnd)) {
|
||||
nextValue ||= inputTarget.value;
|
||||
nextValue += toInsert;
|
||||
} else {
|
||||
nextValue = value.slice(0, selectionStart) + toInsert + value.slice(selectionEnd);
|
||||
|
|
@ -1375,21 +1424,27 @@ async function _keyDown(target, eventInit) {
|
|||
}
|
||||
|
||||
const { ctrlKey, key, shiftKey } = keyDownEvent;
|
||||
const initialValue = target.value;
|
||||
const inputTarget = resolve(targetResolver);
|
||||
let inputData = null;
|
||||
let inputType = null;
|
||||
let nextSelectionEnd = null;
|
||||
let nextSelectionStart = null;
|
||||
let nextValue = initialValue;
|
||||
let nextValue = null;
|
||||
let triggerSelect = false;
|
||||
|
||||
if (isEditable(target)) {
|
||||
if (runTime.initialValue === null || keyDownTarget !== inputTarget) {
|
||||
// If the 'keydown' event changes the target:
|
||||
// -> initial value needs to be re-evaluated from the new target
|
||||
runTime.initialValue = inputTarget.value;
|
||||
}
|
||||
|
||||
if (isEditable(inputTarget)) {
|
||||
switch (key) {
|
||||
case "ArrowDown":
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
case "ArrowRight": {
|
||||
const { selectionStart, selectionEnd, value } = target;
|
||||
const { selectionStart, selectionEnd, value } = inputTarget;
|
||||
if (isNil(selectionStart) || isNil(selectionEnd)) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -1410,9 +1465,11 @@ async function _keyDown(target, eventInit) {
|
|||
break;
|
||||
}
|
||||
case "Backspace": {
|
||||
const { selectionStart, selectionEnd, value } = target;
|
||||
if (fullClear) {
|
||||
// Remove all characters
|
||||
const { selectionStart, selectionEnd, value } = inputTarget;
|
||||
if (options?.fullClear) {
|
||||
// Remove all characters, regardless of the selection. This
|
||||
// is to be used to ensure that an input without selection properties
|
||||
// (e.g. input[type="number"]) can still be properly cleared.
|
||||
nextValue = "";
|
||||
} else if (isNil(selectionStart) || isNil(selectionEnd)) {
|
||||
// Remove last character
|
||||
|
|
@ -1422,17 +1479,14 @@ async function _keyDown(target, eventInit) {
|
|||
nextValue = value.slice(0, selectionStart - 1) + value.slice(selectionEnd);
|
||||
} else {
|
||||
// Remove current selection from target value
|
||||
nextValue = deleteSelection(target);
|
||||
nextValue = deleteSelection(inputTarget);
|
||||
}
|
||||
inputType = "deleteContentBackward";
|
||||
break;
|
||||
}
|
||||
case "Delete": {
|
||||
const { selectionStart, selectionEnd, value } = target;
|
||||
if (fullClear) {
|
||||
// Remove all characters
|
||||
nextValue = "";
|
||||
} else if (isNil(selectionStart) || isNil(selectionEnd)) {
|
||||
const { selectionStart, selectionEnd, value } = inputTarget;
|
||||
if (isNil(selectionStart) || isNil(selectionEnd)) {
|
||||
// Remove first character
|
||||
nextValue = value.slice(1);
|
||||
} else if (selectionStart === selectionEnd) {
|
||||
|
|
@ -1440,18 +1494,11 @@ async function _keyDown(target, eventInit) {
|
|||
nextValue = value.slice(0, selectionStart) + value.slice(selectionEnd + 1);
|
||||
} else {
|
||||
// Remove current selection from target value
|
||||
nextValue = deleteSelection(target);
|
||||
nextValue = deleteSelection(inputTarget);
|
||||
}
|
||||
inputType = "deleteContentForward";
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
if (target.tagName === "TEXTAREA") {
|
||||
// Insert new line
|
||||
insertValue("\n", "insertLineBreak");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (key.length === 1 && !ctrlKey) {
|
||||
// Character coming from the keystroke
|
||||
|
|
@ -1469,14 +1516,14 @@ async function _keyDown(target, eventInit) {
|
|||
case "a": {
|
||||
if (ctrlKey) {
|
||||
// Select all
|
||||
if (isEditable(target)) {
|
||||
if (isEditable(inputTarget)) {
|
||||
nextSelectionStart = 0;
|
||||
nextSelectionEnd = target.value.length;
|
||||
nextSelectionEnd = inputTarget.value.length;
|
||||
triggerSelect = true;
|
||||
} else {
|
||||
const selection = globalThis.getSelection();
|
||||
const range = $createRange();
|
||||
range.selectNodeContents(target);
|
||||
range.selectNodeContents(inputTarget);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
|
@ -1495,14 +1542,21 @@ async function _keyDown(target, eventInit) {
|
|||
globalThis.navigator.clipboard.writeText(text).catch();
|
||||
|
||||
runTime.clipboardData = createDataTransfer(eventInit);
|
||||
await _dispatch(target, "copy", { clipboardData: runTime.clipboardData });
|
||||
await _dispatch(inputTarget, "copy", { clipboardData: runTime.clipboardData });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
const tag = getTag(target);
|
||||
const parentForm = target.closest("form");
|
||||
if (parentForm && target.type !== "button") {
|
||||
if (runTime.changeTarget) {
|
||||
await triggerChange();
|
||||
}
|
||||
if (inputTarget.tagName === "TEXTAREA" && isEditable(inputTarget)) {
|
||||
// Insert new line
|
||||
insertValue("\n", "insertLineBreak");
|
||||
}
|
||||
const tag = getTag(inputTarget);
|
||||
const parentForm = inputTarget.closest("form");
|
||||
if (parentForm && inputTarget.type !== "button") {
|
||||
/**
|
||||
* Special action: <form> 'Enter'
|
||||
* On: unprevented 'Enter' keydown on any element that
|
||||
|
|
@ -1512,14 +1566,16 @@ async function _keyDown(target, eventInit) {
|
|||
await _dispatch(parentForm, "submit");
|
||||
} else if (
|
||||
!keyDownEvent.repeat &&
|
||||
(tag === "a" || tag === "button" || (tag === "input" && target.type === "button"))
|
||||
(tag === "a" ||
|
||||
tag === "button" ||
|
||||
(tag === "input" && inputTarget.type === "button"))
|
||||
) {
|
||||
/**
|
||||
* Special action: <a>, <button> or <input type="button"> 'Enter'
|
||||
* On: unprevented and unrepeated 'Enter' keydown on mentioned elements
|
||||
* Do: triggers a 'click' event on the element
|
||||
*/
|
||||
await _dispatch(target, "click", { button: btn.LEFT });
|
||||
await _dispatch(inputTarget, "click", { button: btn.LEFT });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -1548,7 +1604,7 @@ async function _keyDown(target, eventInit) {
|
|||
* Do: paste current clipboard content to current element
|
||||
*/
|
||||
case "v": {
|
||||
if (ctrlKey && isEditable(target)) {
|
||||
if (ctrlKey && isEditable(inputTarget)) {
|
||||
// Set target value (if possible)
|
||||
try {
|
||||
nextValue = await globalThis.navigator.clipboard.readText();
|
||||
|
|
@ -1557,7 +1613,7 @@ async function _keyDown(target, eventInit) {
|
|||
}
|
||||
inputType = "insertFromPaste";
|
||||
|
||||
await _dispatch(target, "paste", {
|
||||
await _dispatch(inputTarget, "paste", {
|
||||
clipboardData: runTime.clipboardData || createDataTransfer(eventInit),
|
||||
});
|
||||
runTime.clipboardData = null;
|
||||
|
|
@ -1570,59 +1626,64 @@ async function _keyDown(target, eventInit) {
|
|||
* Do: cut current selection to clipboard and remove selection
|
||||
*/
|
||||
case "x": {
|
||||
if (ctrlKey && isEditable(target)) {
|
||||
if (ctrlKey && isEditable(inputTarget)) {
|
||||
// Get selection from window
|
||||
const text = globalThis.getSelection().toString();
|
||||
globalThis.navigator.clipboard.writeText(text).catch();
|
||||
|
||||
nextValue = deleteSelection(target);
|
||||
nextValue = deleteSelection(inputTarget);
|
||||
inputType = "deleteByCut";
|
||||
|
||||
runTime.clipboardData = createDataTransfer(eventInit);
|
||||
await _dispatch(target, "cut", { clipboardData: runTime.clipboardData });
|
||||
await _dispatch(inputTarget, "cut", { clipboardData: runTime.clipboardData });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (initialValue !== nextValue) {
|
||||
target.value = nextValue;
|
||||
if (nextValue !== null) {
|
||||
inputTarget.value = nextValue;
|
||||
const inputEventInit = {
|
||||
data: inputData,
|
||||
inputType,
|
||||
};
|
||||
const beforeInputEvent = await _dispatch(target, "beforeinput", inputEventInit);
|
||||
const beforeInputEvent = await _dispatch(inputTarget, "beforeinput", inputEventInit);
|
||||
if (!isPrevented(beforeInputEvent)) {
|
||||
await _dispatch(target, "input", inputEventInit);
|
||||
await _dispatch(inputTarget, "input", inputEventInit);
|
||||
}
|
||||
}
|
||||
changeSelection(target, nextSelectionStart, nextSelectionEnd);
|
||||
changeSelection(inputTarget, nextSelectionStart, nextSelectionEnd);
|
||||
if (triggerSelect) {
|
||||
await dispatchAndIgnore({
|
||||
target,
|
||||
target: inputTarget,
|
||||
events: ["select"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} target
|
||||
* @param {Resolver<EventTarget>} targetResolver
|
||||
* @param {KeyboardEventInit} eventInit
|
||||
*/
|
||||
async function _keyUp(target, eventInit) {
|
||||
async function _keyUp(targetResolver, eventInit) {
|
||||
eventInit = { ...eventInit, ...currentEventInit.keyup };
|
||||
await _dispatch(target, "keyup", eventInit);
|
||||
await _dispatch(resolve(targetResolver), "keyup", eventInit);
|
||||
|
||||
runTime.key = null;
|
||||
registerSpecialKey(eventInit, false);
|
||||
|
||||
if (eventInit.key === " " && getTag(target) === "input" && target.type === "checkbox") {
|
||||
const finalTarget = resolve(targetResolver);
|
||||
if (
|
||||
eventInit.key === " " &&
|
||||
getTag(finalTarget) === "input" &&
|
||||
finalTarget.type === "checkbox"
|
||||
) {
|
||||
/**
|
||||
* Special action: input[type=checkbox] 'Space'
|
||||
* On: unprevented ' ' keydown on an <input type="checkbox"/>
|
||||
* Do: triggers a 'click' event on the input
|
||||
*/
|
||||
await triggerClick(target, { button: btn.LEFT });
|
||||
await triggerClick(finalTarget, { button: btn.LEFT });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1630,7 +1691,7 @@ async function _keyUp(target, eventInit) {
|
|||
* @param {DragOptions} [options]
|
||||
*/
|
||||
async function _pointerDown(options) {
|
||||
setPointerDownTarget(runTime.pointerTarget);
|
||||
setPointerDownTarget(runTime.pointerTarget, options);
|
||||
|
||||
if (options?.dataTransfer || options?.files || options?.items) {
|
||||
runTime.dataTransfer = createDataTransfer(options);
|
||||
|
|
@ -1690,6 +1751,7 @@ async function _pointerUp(options) {
|
|||
const target = runTime.pointerTarget;
|
||||
const isLongTap = globalThis.Date.now() - runTime.touchStartTimeOffset > LONG_TAP_DELAY;
|
||||
const pointerDownTarget = runTime.pointerDownTarget;
|
||||
const pointerUpTarget = getPointerTarget(target, options);
|
||||
const eventInit = {
|
||||
...runTime.position,
|
||||
...currentEventInit.pointerup,
|
||||
|
|
@ -1709,10 +1771,10 @@ async function _pointerUp(options) {
|
|||
* - On: pointer up after a prevented 'dragover' or 'dragenter'
|
||||
* - Do: triggers a 'drop' event on the target
|
||||
*/
|
||||
await _dispatch(target, "drop", eventInitWithDT);
|
||||
await _dispatch(pointerUpTarget, "drop", eventInitWithDT);
|
||||
}
|
||||
|
||||
await _dispatch(target, "dragend", eventInitWithDT);
|
||||
await _dispatch(pointerUpTarget, "dragend", eventInitWithDT);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1720,8 +1782,8 @@ async function _pointerUp(options) {
|
|||
...eventInit,
|
||||
detail: runTime.clickCount + 1,
|
||||
};
|
||||
await dispatchPointerEvent(target, "pointerup", eventInit, {
|
||||
mouse: !target.disabled && ["mouseup", mouseEventInit],
|
||||
await dispatchPointerEvent(pointerUpTarget, "pointerup", eventInit, {
|
||||
mouse: !pointerUpTarget.disabled && ["mouseup", mouseEventInit],
|
||||
touch: ["touchend"],
|
||||
});
|
||||
|
||||
|
|
@ -1737,9 +1799,9 @@ async function _pointerUp(options) {
|
|||
|
||||
let clickTarget;
|
||||
if (hasTouch()) {
|
||||
clickTarget = pointerDownTarget === target && target;
|
||||
clickTarget = pointerDownTarget === pointerUpTarget && pointerUpTarget;
|
||||
} else {
|
||||
clickTarget = getFirstCommonParent(target, pointerDownTarget);
|
||||
clickTarget = getFirstCommonParent(pointerUpTarget, pointerDownTarget);
|
||||
}
|
||||
if (clickTarget) {
|
||||
await triggerClick(clickTarget, mouseEventInit);
|
||||
|
|
@ -1751,7 +1813,7 @@ async function _pointerUp(options) {
|
|||
}
|
||||
}
|
||||
|
||||
setPointerDownTarget(null);
|
||||
setPointerDownTarget(null, options);
|
||||
if (runTime.pointerDownTimeout) {
|
||||
globalThis.clearTimeout(runTime.pointerDownTimeout);
|
||||
}
|
||||
|
|
@ -1764,12 +1826,13 @@ async function _pointerUp(options) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {EventTarget} target
|
||||
* @param {Resolver<EventTarget>} targetResolver
|
||||
* @param {KeyboardEventInit} eventInit
|
||||
* @param {KeyDownOptions} [options]
|
||||
*/
|
||||
async function _press(target, eventInit) {
|
||||
await _keyDown(target, eventInit);
|
||||
await _keyUp(target, eventInit);
|
||||
async function _press(targetResolver, eventInit, options) {
|
||||
await _keyDown(targetResolver, eventInit, options);
|
||||
await _keyUp(targetResolver, eventInit);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1777,7 +1840,7 @@ async function _press(target, eventInit) {
|
|||
* @param {string | number | (string | number)[]} value
|
||||
*/
|
||||
async function _select(target, value) {
|
||||
const values = ensureArray(value).map(String);
|
||||
const values = ensureArray(value, String);
|
||||
let found = false;
|
||||
for (const option of target.options) {
|
||||
option.selected = values.includes(option.value);
|
||||
|
|
@ -1880,13 +1943,7 @@ const currentEvents = $create(null);
|
|||
const currentEventInit = $create(null);
|
||||
/** @type {string[]} */
|
||||
const currentEventTypes = [];
|
||||
/** @type {(() => Promise<void>) | null} */
|
||||
let afterNextDispatch = null;
|
||||
let allowLogs = false;
|
||||
let fullClear = false;
|
||||
|
||||
// Keyboard global variables
|
||||
const changeTargetListeners = [];
|
||||
|
||||
// Other global variables
|
||||
const runTime = getDefaultRunTimeValue();
|
||||
|
|
@ -2063,19 +2120,19 @@ export function cleanupEvents() {
|
|||
*/
|
||||
export async function clear(options) {
|
||||
const finalizeEvents = setupEvents("clear", options);
|
||||
const element = getActiveElement();
|
||||
const activeElement = getActiveElement();
|
||||
|
||||
if (!hasTagName(element, "select") && !isEditable(element)) {
|
||||
if (!hasTagName(activeElement, "select") && !isEditable(activeElement)) {
|
||||
throw new HootInteractionError(
|
||||
`cannot call \`clear()\`: target should be editable or a <select> element`
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditable(element)) {
|
||||
await _clear(element, options);
|
||||
if (isEditable(activeElement)) {
|
||||
await _clear(getActiveElement, options);
|
||||
} else {
|
||||
// Selects
|
||||
await _select(element, "");
|
||||
await _select(activeElement, "");
|
||||
}
|
||||
|
||||
return finalizeEvents();
|
||||
|
|
@ -2311,15 +2368,15 @@ export async function drag(target, options) {
|
|||
*/
|
||||
export async function edit(value, options) {
|
||||
const finalizeEvents = setupEvents("edit", options);
|
||||
const element = getActiveElement();
|
||||
if (!isEditable(element)) {
|
||||
const activeElement = getActiveElement();
|
||||
if (!isEditable(activeElement)) {
|
||||
throw new HootInteractionError(`cannot call \`edit()\`: target should be editable`);
|
||||
}
|
||||
|
||||
if (getNodeValue(element)) {
|
||||
await _clear(element);
|
||||
if (getNodeValue(activeElement)) {
|
||||
await _clear(getActiveElement);
|
||||
}
|
||||
await _fill(element, value, options);
|
||||
await _fill(getActiveElement, value, options);
|
||||
|
||||
return finalizeEvents();
|
||||
}
|
||||
|
|
@ -2358,13 +2415,12 @@ export function enableEventLogs(toggle) {
|
|||
*/
|
||||
export async function fill(value, options) {
|
||||
const finalizeEvents = setupEvents("fill", options);
|
||||
const element = getActiveElement();
|
||||
|
||||
if (!isEditable(element)) {
|
||||
if (!isEditable(getActiveElement())) {
|
||||
throw new HootInteractionError(`cannot call \`fill()\`: target should be editable`);
|
||||
}
|
||||
|
||||
await _fill(element, value, options);
|
||||
await _fill(getActiveElement, value, options);
|
||||
|
||||
return finalizeEvents();
|
||||
}
|
||||
|
|
@ -2422,7 +2478,7 @@ export async function keyDown(keyStrokes, options) {
|
|||
const finalizeEvents = setupEvents("keyDown", options);
|
||||
const eventInits = parseKeyStrokes(keyStrokes, options);
|
||||
for (const eventInit of eventInits) {
|
||||
await _keyDown(getActiveElement(), eventInit);
|
||||
await _keyDown(getActiveElement, eventInit);
|
||||
}
|
||||
|
||||
return finalizeEvents();
|
||||
|
|
@ -2444,7 +2500,7 @@ export async function keyUp(keyStrokes, options) {
|
|||
const finalizeEvents = setupEvents("keyUp", options);
|
||||
const eventInits = parseKeyStrokes(keyStrokes, options);
|
||||
for (const eventInit of eventInits) {
|
||||
await _keyUp(getActiveElement(), eventInit);
|
||||
await _keyUp(getActiveElement, eventInit);
|
||||
}
|
||||
|
||||
return finalizeEvents();
|
||||
|
|
@ -2597,13 +2653,12 @@ export async function pointerUp(target, options) {
|
|||
export async function press(keyStrokes, options) {
|
||||
const finalizeEvents = setupEvents("press", options);
|
||||
const eventInits = parseKeyStrokes(keyStrokes, options);
|
||||
const activeElement = getActiveElement();
|
||||
|
||||
for (const eventInit of eventInits) {
|
||||
await _keyDown(activeElement, eventInit);
|
||||
await _keyDown(getActiveElement, eventInit);
|
||||
}
|
||||
for (const eventInit of eventInits.reverse()) {
|
||||
await _keyUp(activeElement, eventInit);
|
||||
await _keyUp(getActiveElement, eventInit);
|
||||
}
|
||||
|
||||
return finalizeEvents();
|
||||
|
|
@ -2729,7 +2784,7 @@ export async function scroll(target, position, options) {
|
|||
await dispatchAndIgnore({
|
||||
target: element,
|
||||
events: ["scroll", "scrollend"],
|
||||
callback: (el) => el.scrollTo(scrollTopOptions),
|
||||
callback: () => element.scrollTo(scrollTopOptions),
|
||||
});
|
||||
}
|
||||
if (initiator === "keyboard") {
|
||||
|
|
@ -2740,8 +2795,8 @@ export async function scroll(target, position, options) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Performs a selection event sequence current **active element**. This helper is
|
||||
* intended for `<select>` elements only.
|
||||
* Performs a selection event sequence on the current **active element**. This helper
|
||||
* is intended for `<select>` elements only.
|
||||
*
|
||||
* The event sequence is as follows:
|
||||
* - `change`
|
||||
|
|
@ -2755,8 +2810,8 @@ export async function scroll(target, position, options) {
|
|||
*/
|
||||
export async function select(value, options) {
|
||||
const finalizeEvents = setupEvents("select", options);
|
||||
const target = options?.target || getActiveElement();
|
||||
const element = queryAny(await target);
|
||||
const manualTarget = options?.target;
|
||||
const element = manualTarget ? queryAny(await manualTarget) : getActiveElement();
|
||||
|
||||
if (!hasTagName(element, "select")) {
|
||||
throw new HootInteractionError(
|
||||
|
|
@ -2764,12 +2819,12 @@ export async function select(value, options) {
|
|||
);
|
||||
}
|
||||
|
||||
if (options?.target) {
|
||||
await _hover(element, null, { implicit: true, originalTarget: target });
|
||||
if (manualTarget) {
|
||||
await _hover(element, null, { implicit: true, originalTarget: manualTarget });
|
||||
await _pointerDown();
|
||||
}
|
||||
await _select(element, value);
|
||||
if (options?.target) {
|
||||
if (manualTarget) {
|
||||
await _pointerUp();
|
||||
}
|
||||
|
||||
|
|
@ -2896,7 +2951,7 @@ export async function uncheck(target, options) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Triggers a "beforeunload" event the current **window**.
|
||||
* Triggers a "beforeunload" event on the current **window**.
|
||||
*
|
||||
* @param {EventOptions} [options]
|
||||
* @returns {Promise<EventList>}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ export {
|
|||
//-----------------------------------------------------------------------------
|
||||
|
||||
// DOM
|
||||
export const observe = interactor("query", dom.observe);
|
||||
export const waitFor = interactor("query", dom.waitFor);
|
||||
export const waitForNone = interactor("query", dom.waitForNone);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
* | "symbol"
|
||||
* | "undefined"} ArgumentPrimitive
|
||||
*
|
||||
* @typedef {[string, any[], any]} InteractionDetails
|
||||
* @typedef {[string, string | undefined, any[], any]} InteractionDetails
|
||||
*
|
||||
* @typedef {"interaction" | "query" | "server" | "time"} InteractionType
|
||||
*/
|
||||
|
|
@ -55,27 +55,28 @@ const $toString = Object.prototype.toString;
|
|||
* @param {InteractionType} type
|
||||
* @param {T} fn
|
||||
* @param {string} name
|
||||
* @param {string} [alias]
|
||||
* @returns {T}
|
||||
*/
|
||||
function makeInteractorFn(type, fn, name) {
|
||||
function makeInteractorFn(type, fn, name, alias) {
|
||||
return {
|
||||
[name](...args) {
|
||||
[alias || name](...args) {
|
||||
const result = fn(...args);
|
||||
if (isInstanceOf(result, Promise)) {
|
||||
if (isPromise(result)) {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (isInstanceOf(args[i], Promise)) {
|
||||
if (isPromise(args[i])) {
|
||||
// Get promise result for async arguments if possible
|
||||
args[i].then((result) => (args[i] = result));
|
||||
}
|
||||
}
|
||||
return result.then((promiseResult) =>
|
||||
dispatchInteraction(type, name, args, promiseResult)
|
||||
dispatchInteraction(type, name, alias, args, promiseResult)
|
||||
);
|
||||
} else {
|
||||
return dispatchInteraction(type, name, args, result);
|
||||
return dispatchInteraction(type, name, alias, args, result);
|
||||
}
|
||||
},
|
||||
}[name];
|
||||
}[alias || name];
|
||||
}
|
||||
|
||||
function polyfillIsError(value) {
|
||||
|
|
@ -237,20 +238,21 @@ export function addInteractionListener(types, callback) {
|
|||
/**
|
||||
* @param {InteractionType} type
|
||||
* @param {string} name
|
||||
* @param {string | undefined} alias
|
||||
* @param {any[]} args
|
||||
* @param {any} returnValue
|
||||
*/
|
||||
export function dispatchInteraction(type, name, args, returnValue) {
|
||||
export function dispatchInteraction(type, name, alias, args, returnValue) {
|
||||
interactionBus.dispatchEvent(
|
||||
new CustomEvent(type, {
|
||||
detail: [name, args, returnValue],
|
||||
detail: [name, alias, args, returnValue],
|
||||
})
|
||||
);
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {...any} helpers
|
||||
* @param {...any} helpers
|
||||
*/
|
||||
export function exposeHelpers(...helpers) {
|
||||
let nameSpaceIndex = 1;
|
||||
|
|
@ -299,7 +301,7 @@ export function getTag(node) {
|
|||
export function interactor(type, fn) {
|
||||
return $assign(makeInteractorFn(type, fn, fn.name), {
|
||||
as(alias) {
|
||||
return makeInteractorFn(type, fn, alias);
|
||||
return makeInteractorFn(type, fn, fn.name, alias);
|
||||
},
|
||||
get silent() {
|
||||
return fn;
|
||||
|
|
@ -314,6 +316,16 @@ export function isFirefox() {
|
|||
return /firefox/i.test($userAgent);
|
||||
}
|
||||
|
||||
Array.isArray;
|
||||
|
||||
/**
|
||||
* @param {any} instance
|
||||
* @returns {instance is Promise<any>}
|
||||
*/
|
||||
export function isPromise(instance) {
|
||||
return instance && typeof instance.then === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-realm equivalent to 'instanceof'.
|
||||
* Can be called with multiple constructors, and will return true if the given object
|
||||
|
|
@ -412,7 +424,7 @@ export function toSelector(node, options) {
|
|||
|
||||
export class HootDebugHelpers {
|
||||
/**
|
||||
* @param {...any} helpers
|
||||
* @param {...any} helpers
|
||||
*/
|
||||
constructor(...helpers) {
|
||||
$assign(this, ...helpers);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
S_NONE,
|
||||
strictEqual,
|
||||
} from "../hoot_utils";
|
||||
import { mockFetch } from "../mock/network";
|
||||
import { logger } from "./logger";
|
||||
import { Test } from "./test";
|
||||
|
||||
|
|
@ -134,6 +135,7 @@ const {
|
|||
Array: { isArray: $isArray },
|
||||
clearTimeout,
|
||||
Error,
|
||||
Intl: { ListFormat },
|
||||
Math: { abs: $abs, floor: $floor },
|
||||
Object: { assign: $assign, create: $create, entries: $entries, keys: $keys },
|
||||
parseFloat,
|
||||
|
|
@ -177,7 +179,7 @@ function detailsFromValues(...args) {
|
|||
* @param {...unknown} args
|
||||
*/
|
||||
function detailsFromValuesWithDiff(...args) {
|
||||
return [...detailsFromValues(...args), Markup.diff(...args)];
|
||||
return detailsFromValues(...args).concat([Markup.diff(...args)]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -446,6 +448,7 @@ export function makeExpect(params) {
|
|||
if (currentResult.currentSteps.length) {
|
||||
currentResult.registerEvent("assertion", {
|
||||
label: "step",
|
||||
docLabel: "expect.step",
|
||||
pass: false,
|
||||
failedDetails: detailsFromEntries([["Steps:", currentResult.currentSteps]]),
|
||||
reportMessage: [r`unverified steps`],
|
||||
|
|
@ -456,6 +459,7 @@ export function makeExpect(params) {
|
|||
if (!(assertionCount + queryCount)) {
|
||||
currentResult.registerEvent("assertion", {
|
||||
label: "assertions",
|
||||
docLabel: "expect.assertions",
|
||||
pass: false,
|
||||
reportMessage: [
|
||||
r`expected at least`,
|
||||
|
|
@ -469,6 +473,7 @@ export function makeExpect(params) {
|
|||
) {
|
||||
currentResult.registerEvent("assertion", {
|
||||
label: "assertions",
|
||||
docLabel: "expect.assertions",
|
||||
pass: false,
|
||||
reportMessage: [
|
||||
r`expected`,
|
||||
|
|
@ -484,6 +489,7 @@ export function makeExpect(params) {
|
|||
if (currentResult.currentErrors.length) {
|
||||
currentResult.registerEvent("assertion", {
|
||||
label: "errors",
|
||||
docLabel: "expect.errors",
|
||||
pass: false,
|
||||
reportMessage: [currentResult.currentErrors.length, r`unverified error(s)`],
|
||||
});
|
||||
|
|
@ -493,6 +499,7 @@ export function makeExpect(params) {
|
|||
if (currentResult.expectedErrors && currentResult.expectedErrors !== errorCount) {
|
||||
currentResult.registerEvent("assertion", {
|
||||
label: "errors",
|
||||
docLabel: "expect.errors",
|
||||
pass: false,
|
||||
reportMessage: [
|
||||
r`expected`,
|
||||
|
|
@ -539,6 +546,7 @@ export function makeExpect(params) {
|
|||
/** @type {import("../hoot_utils").Reporting} */
|
||||
const report = {
|
||||
assertions: assertionCount,
|
||||
duration: test.lastResults?.duration || 0,
|
||||
tests: 1,
|
||||
};
|
||||
if (!currentResult.pass) {
|
||||
|
|
@ -562,6 +570,12 @@ export function makeExpect(params) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Expects the current test to have the `expected` amount of assertions. This
|
||||
* number cannot be less than 1.
|
||||
*
|
||||
* Note that it is generally preferred to use `expect.step` and `expect.verifySteps`
|
||||
* instead as it is more reliable and allows to test more extensively.
|
||||
*
|
||||
* @param {number} expected
|
||||
*/
|
||||
function assertions(expected) {
|
||||
|
|
@ -605,10 +619,10 @@ export function makeExpect(params) {
|
|||
return false;
|
||||
}
|
||||
const { errors, options } = resolver;
|
||||
const actualErrors = currentResult.currentErrors;
|
||||
const { currentErrors } = currentResult;
|
||||
const pass =
|
||||
actualErrors.length === errors.length &&
|
||||
actualErrors.every(
|
||||
currentErrors.length === errors.length &&
|
||||
currentErrors.every(
|
||||
(error, i) =>
|
||||
match(error, errors[i]) || (error.cause && match(error.cause, errors[i]))
|
||||
);
|
||||
|
|
@ -623,12 +637,13 @@ export function makeExpect(params) {
|
|||
: "expected the following errors";
|
||||
const assertion = {
|
||||
label: "verifyErrors",
|
||||
docLabel: "expect.verifyErrors",
|
||||
message: options?.message,
|
||||
pass,
|
||||
reportMessage,
|
||||
};
|
||||
if (!pass) {
|
||||
const fActual = actualErrors.map(formatError);
|
||||
const fActual = currentErrors.map(formatError);
|
||||
const fExpected = errors.map(formatError);
|
||||
assertion.failedDetails = detailsFromValuesWithDiff(fExpected, fActual);
|
||||
assertion.stack = getStack(1);
|
||||
|
|
@ -662,6 +677,7 @@ export function makeExpect(params) {
|
|||
: "expected the following steps";
|
||||
const assertion = {
|
||||
label: "verifySteps",
|
||||
docLabel: "expect.verifySteps",
|
||||
message: options?.message,
|
||||
pass,
|
||||
reportMessage,
|
||||
|
|
@ -677,6 +693,11 @@ export function makeExpect(params) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Expects the current test to have the `expected` amount of errors.
|
||||
*
|
||||
* This also means that from the moment this function is called, the test will
|
||||
* accept that amount of errors before being considered as failed.
|
||||
*
|
||||
* @param {number} expected
|
||||
*/
|
||||
function errors(expected) {
|
||||
|
|
@ -718,6 +739,9 @@ export function makeExpect(params) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Registers a step for the current test, that can be consumed by `expect.verifySteps`.
|
||||
* Unconsumed steps will fail the test.
|
||||
*
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function step(value) {
|
||||
|
|
@ -748,6 +772,11 @@ export function makeExpect(params) {
|
|||
throw scopeError("expect.verifyErrors");
|
||||
}
|
||||
ensureArguments(arguments, "any[]", ["object", null]);
|
||||
if (errors.length > currentResult.expectedErrors) {
|
||||
throw new HootError(
|
||||
`cannot call \`expect.verifyErrors()\` without calling \`expect.errors()\` beforehand`
|
||||
);
|
||||
}
|
||||
|
||||
return checkErrors({ errors, options }, true);
|
||||
}
|
||||
|
|
@ -761,6 +790,8 @@ export function makeExpect(params) {
|
|||
* @param {VerifierOptions} [options]
|
||||
* @returns {boolean}
|
||||
* @example
|
||||
* expect.step("web_read_group");
|
||||
* expect.step([1, 2]);
|
||||
* expect.verifySteps(["web_read_group", "web_search_read"]);
|
||||
*/
|
||||
function verifySteps(steps, options) {
|
||||
|
|
@ -988,6 +1019,9 @@ export class CaseResult {
|
|||
this.counts[type]++;
|
||||
switch (type) {
|
||||
case "assertion": {
|
||||
if (value && this.headless) {
|
||||
delete value.docLabel; // Only required in UI
|
||||
}
|
||||
caseEvent = new Assertion(this.counts.assertion, value);
|
||||
this.pass &&= caseEvent.pass;
|
||||
break;
|
||||
|
|
@ -2032,13 +2066,15 @@ export class Matcher {
|
|||
* - contain file objects matching the given `files` list.
|
||||
*
|
||||
* @param {ReturnType<typeof getNodeValue>} [value]
|
||||
* @param {ExpectOptions} [options]
|
||||
* @param {ExpectOptions & { raw?: boolean }} [options]
|
||||
* @example
|
||||
* expect("input[type=email]").toHaveValue("john@doe.com");
|
||||
* expect("input[name=age]").toHaveValue(29);
|
||||
* @example
|
||||
* expect("input[type=file]").toHaveValue(new File(["foo"], "foo.txt"));
|
||||
* @example
|
||||
* expect("select[multiple]").toHaveValue(["foo", "bar"]);
|
||||
* @example
|
||||
* expect("input[name=age]").toHaveValue("29", { raw: true });
|
||||
*/
|
||||
toHaveValue(value, options) {
|
||||
this._ensureArguments(arguments, [
|
||||
|
|
@ -2055,7 +2091,7 @@ export class Matcher {
|
|||
return this._resolve(() => ({
|
||||
name: "toHaveValue",
|
||||
acceptedType: ["string", "node", "node[]"],
|
||||
mapElements: (el) => getNodeValue(el),
|
||||
mapElements: (el) => getNodeValue(el, options?.raw),
|
||||
predicate: (elValue, el) => {
|
||||
if (isCheckable(el)) {
|
||||
throw new HootError(
|
||||
|
|
@ -2201,10 +2237,17 @@ export class Matcher {
|
|||
|
||||
const types = ensureArray(acceptedType);
|
||||
if (!types.some((type) => isOfType(this._received, type))) {
|
||||
const joinedTypes =
|
||||
types.length > 1
|
||||
? new ListFormat("en-GB", {
|
||||
type: "disjunction",
|
||||
style: "long",
|
||||
}).format(types)
|
||||
: types[0];
|
||||
throw new TypeError(
|
||||
`expected received value to be of type ${listJoin(types, ",", "or").join(
|
||||
" "
|
||||
)}, got ${formatHumanReadable(this._received)}`
|
||||
`expected received value to be of type ${joinedTypes}, got ${formatHumanReadable(
|
||||
this._received
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2300,11 +2343,14 @@ export class CaseEvent {
|
|||
export class Assertion extends CaseEvent {
|
||||
/** @type {string | null | undefined} */
|
||||
additionalMessage;
|
||||
/** @type {string | undefined} */
|
||||
docLabel;
|
||||
type = CASE_EVENT_TYPES.assertion.value;
|
||||
|
||||
/**
|
||||
* @param {number} number
|
||||
* @param {Partial<Assertion & {
|
||||
* docLabel?: string;
|
||||
* message: AssertionMessage,
|
||||
* reportMessage: AssertionReportMessage,
|
||||
* }>} values
|
||||
|
|
@ -2312,6 +2358,7 @@ export class Assertion extends CaseEvent {
|
|||
constructor(number, values) {
|
||||
super();
|
||||
|
||||
this.docLabel = values.docLabel;
|
||||
this.label = values.label;
|
||||
this.flags = values.flags || 0;
|
||||
this.pass = values.pass || false;
|
||||
|
|
@ -2388,11 +2435,16 @@ export class DOMCaseEvent extends CaseEvent {
|
|||
* @param {InteractionType} type
|
||||
* @param {InteractionDetails} details
|
||||
*/
|
||||
constructor(type, [name, args, returnValue]) {
|
||||
constructor(type, [name, alias, args, returnValue]) {
|
||||
super();
|
||||
|
||||
this.type = CASE_EVENT_TYPES[type].value;
|
||||
this.label = name;
|
||||
this.label = alias || name;
|
||||
if (type === "server") {
|
||||
this.docLabel = mockFetch.name;
|
||||
} else {
|
||||
this.docLabel = name;
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== undefined && (i === 0 || typeof args[i] !== "object")) {
|
||||
this.message.push(makeLabelOrString(args[i]));
|
||||
|
|
@ -2419,12 +2471,20 @@ export class CaseError extends CaseEvent {
|
|||
this.message = error.message.split(R_WHITE_SPACE);
|
||||
/** @type {string} */
|
||||
this.stack = error.stack;
|
||||
|
||||
// Ensures that the stack contains the error name & message.
|
||||
// This can happen when setting the 'message' after creating the error.
|
||||
const errorNameAndMessage = String(error);
|
||||
if (!this.stack.startsWith(errorNameAndMessage)) {
|
||||
this.stack = errorNameAndMessage + this.stack.slice(error.name.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Step extends CaseEvent {
|
||||
type = CASE_EVENT_TYPES.step.value;
|
||||
label = "step";
|
||||
docLabel = "expect.step";
|
||||
|
||||
/**
|
||||
* @param {any} value
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ import { getViewPortHeight, getViewPortWidth } from "../mock/window";
|
|||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { customElements, document, getSelection, HTMLElement, Promise, WeakSet } = globalThis;
|
||||
const { customElements, document, getSelection, HTMLElement, MutationObserver, Promise, WeakSet } =
|
||||
globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { getColorHex } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { stringify } from "../hoot_utils";
|
||||
import { isNil, stringify } from "../hoot_utils";
|
||||
import { urlParams } from "./url";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
|
|
@ -123,13 +123,13 @@ class Logger {
|
|||
$groupEnd();
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
* @param {...any} args
|
||||
*/
|
||||
table(...args) {
|
||||
$table(...args);
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
* @param {...any} args
|
||||
*/
|
||||
trace(...args) {
|
||||
$trace(...args);
|
||||
|
|
@ -190,7 +190,7 @@ class Logger {
|
|||
`(${withArgs.shift()}`,
|
||||
...withArgs,
|
||||
"time:",
|
||||
suite.jobs.reduce((acc, job) => acc + (job.duration || 0), 0),
|
||||
suite.reporting.duration,
|
||||
"ms)"
|
||||
);
|
||||
}
|
||||
|
|
@ -203,16 +203,7 @@ class Logger {
|
|||
if (!this.canLog("tests")) {
|
||||
return;
|
||||
}
|
||||
const { fullName, lastResults } = test;
|
||||
$log(
|
||||
...styledArguments([
|
||||
`Test ${stringify(fullName)} passed (assertions:`,
|
||||
lastResults.counts.assertion || 0,
|
||||
`/ time:`,
|
||||
lastResults.duration,
|
||||
`ms)`,
|
||||
])
|
||||
);
|
||||
$log(...styledArguments([`Running test ${stringify(test.fullName)}`]));
|
||||
}
|
||||
/**
|
||||
* @param {[label: string, color: string]} prefix
|
||||
|
|
@ -285,32 +276,56 @@ let nextNetworkLogId = 1;
|
|||
*/
|
||||
export function makeNetworkLogger(prefix, title) {
|
||||
const id = nextNetworkLogId++;
|
||||
const slicedTitle =
|
||||
title.length > 128 ? title.slice(0, 128) + " (click to show full input)" : title;
|
||||
return {
|
||||
/**
|
||||
* Request logger: blue-ish.
|
||||
* @param {() => any} getData
|
||||
* Request logger: blue lotus.
|
||||
* @param {() => any[]} getData
|
||||
*/
|
||||
async logRequest(getData) {
|
||||
logRequest(getData) {
|
||||
if (!logger.canLog("debug")) {
|
||||
return;
|
||||
}
|
||||
const color = `color: #66e`;
|
||||
const styles = [`${color}; font-weight: bold;`, color];
|
||||
$groupCollapsed(`-> %c${prefix}#${id}%c<${title}>`, ...styles, await getData());
|
||||
$trace("request trace");
|
||||
const color = `color: #6960ec`;
|
||||
const args = [`${color}; font-weight: bold;`, color];
|
||||
const [dataHeader, ...otherData] = getData();
|
||||
if (!isNil(dataHeader)) {
|
||||
args.push(dataHeader);
|
||||
}
|
||||
$groupCollapsed(`-> %c${prefix}#${id}%c ${slicedTitle}`, ...args);
|
||||
if (slicedTitle !== title) {
|
||||
$log(title);
|
||||
}
|
||||
for (const data of otherData) {
|
||||
$log(data);
|
||||
}
|
||||
$trace("Request trace:");
|
||||
$groupEnd();
|
||||
},
|
||||
/**
|
||||
* Response logger: orange.
|
||||
* @param {() => any} getData
|
||||
* Response logger: dark orange.
|
||||
* @param {() => any[]} getData
|
||||
*/
|
||||
async logResponse(getData) {
|
||||
logResponse(getData) {
|
||||
if (!logger.canLog("debug")) {
|
||||
return;
|
||||
}
|
||||
const color = `color: #f80`;
|
||||
const styles = [`${color}; font-weight: bold;`, color];
|
||||
$log(`<- %c${prefix}#${id}%c<${title}>`, ...styles, await getData());
|
||||
const color = `color: #ff8c00`;
|
||||
const args = [`${color}; font-weight: bold;`, color];
|
||||
const [dataHeader, ...otherData] = getData();
|
||||
if (!isNil(dataHeader)) {
|
||||
args.push(dataHeader);
|
||||
}
|
||||
$groupCollapsed(`<- %c${prefix}#${id}%c ${slicedTitle}`, ...args);
|
||||
if (slicedTitle !== title) {
|
||||
$log(title);
|
||||
}
|
||||
for (const data of otherData) {
|
||||
$log(data);
|
||||
}
|
||||
$trace("Response trace:");
|
||||
$groupEnd();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
import { cleanupAnimations } from "../mock/animation";
|
||||
import { cleanupDate } from "../mock/date";
|
||||
import { internalRandom } from "../mock/math";
|
||||
import { cleanupNavigator, mockUserAgent } from "../mock/navigator";
|
||||
import { cleanupNavigator } from "../mock/navigator";
|
||||
import { cleanupNetwork, throttleNetwork } from "../mock/network";
|
||||
import {
|
||||
cleanupWindow,
|
||||
|
|
@ -50,8 +50,16 @@ import { Test, testError } from "./test";
|
|||
import { EXCLUDE_PREFIX, createUrlFromId, setParams } from "./url";
|
||||
|
||||
// Import all helpers for debug mode
|
||||
import * as hootDom from "@odoo/hoot-dom";
|
||||
import * as hootMock from "@odoo/hoot-mock";
|
||||
import * as _hootDom from "@odoo/hoot-dom";
|
||||
import * as _animation from "../mock/animation";
|
||||
import * as _date from "../mock/date";
|
||||
import * as _math from "../mock/math";
|
||||
import * as _navigator from "../mock/navigator";
|
||||
import * as _network from "../mock/network";
|
||||
import * as _notification from "../mock/notification";
|
||||
import * as _window from "../mock/window";
|
||||
|
||||
const { isPrevented, mockPreventDefault } = _window;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
|
|
@ -115,7 +123,7 @@ const {
|
|||
Number: { parseFloat: $parseFloat },
|
||||
Object: {
|
||||
assign: $assign,
|
||||
defineProperties: $defineProperties,
|
||||
defineProperty: $defineProperty,
|
||||
entries: $entries,
|
||||
freeze: $freeze,
|
||||
fromEntries: $fromEntries,
|
||||
|
|
@ -163,8 +171,11 @@ function formatIncludes(values) {
|
|||
*/
|
||||
function formatAssertions(assertions) {
|
||||
const lines = [];
|
||||
for (const { failedDetails, label, message, number } of assertions) {
|
||||
for (const { additionalMessage, failedDetails, label, message, number } of assertions) {
|
||||
const formattedMessage = message.map((part) => (isLabel(part) ? part[0] : String(part)));
|
||||
if (additionalMessage) {
|
||||
formattedMessage.push(`(${additionalMessage})`);
|
||||
}
|
||||
lines.push(`\n${number}. [${label}] ${formattedMessage.join(" ")}`);
|
||||
if (failedDetails) {
|
||||
for (const detail of failedDetails) {
|
||||
|
|
@ -191,15 +202,6 @@ function formatAssertions(assertions) {
|
|||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
function safePrevent(ev) {
|
||||
if (ev.cancelable) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} array
|
||||
|
|
@ -832,6 +834,7 @@ export class Runner {
|
|||
}
|
||||
|
||||
manualStart() {
|
||||
this._canStartDef ||= Promise.withResolvers();
|
||||
this._canStartDef.resolve(true);
|
||||
}
|
||||
|
||||
|
|
@ -981,6 +984,8 @@ export class Runner {
|
|||
continue;
|
||||
}
|
||||
|
||||
logger.logTest(test);
|
||||
|
||||
// Suppress console errors and warnings if test is in "todo" mode
|
||||
// (and not in debug).
|
||||
const restoreConsole = handleConsoleIssues(test, !this.debug);
|
||||
|
|
@ -1048,14 +1053,7 @@ export class Runner {
|
|||
|
||||
// Log test errors and increment counters
|
||||
this.expectHooks.after(this);
|
||||
if (lastResults.pass) {
|
||||
logger.logTest(test);
|
||||
|
||||
if (this.state.failedIds.has(test.id)) {
|
||||
this.state.failedIds.delete(test.id);
|
||||
storageSet(STORAGE.failed, [...this.state.failedIds]);
|
||||
}
|
||||
} else {
|
||||
if (!lastResults.pass) {
|
||||
this._failed++;
|
||||
|
||||
const failReasons = [];
|
||||
|
|
@ -1088,6 +1086,9 @@ export class Runner {
|
|||
this.state.failedIds.add(test.id);
|
||||
storageSet(STORAGE.failed, [...this.state.failedIds]);
|
||||
}
|
||||
} else if (this.state.failedIds.has(test.id)) {
|
||||
this.state.failedIds.delete(test.id);
|
||||
storageSet(STORAGE.failed, [...this.state.failedIds]);
|
||||
}
|
||||
|
||||
await this._callbacks.call("after-post-test", test, handleError);
|
||||
|
|
@ -1151,6 +1152,17 @@ export class Runner {
|
|||
|
||||
await this._callbacks.call("after-all", this, logger.error);
|
||||
|
||||
if (this.headless) {
|
||||
// Log root suite results in headless
|
||||
const restoreLogLevel = logger.setLogLevel("suites");
|
||||
for (const suite of this.suites.values()) {
|
||||
if (!suite.parent) {
|
||||
logger.logSuite(suite);
|
||||
}
|
||||
}
|
||||
restoreLogLevel();
|
||||
}
|
||||
|
||||
const { passed, failed, assertions } = this.reporting;
|
||||
if (failed > 0) {
|
||||
const errorMessage = ["Some tests failed: see above for details"];
|
||||
|
|
@ -1317,17 +1329,16 @@ export class Runner {
|
|||
/** @type {Configurators} */
|
||||
const configurators = { ...configuratorGetters, ...configuratorMethods };
|
||||
|
||||
const properties = {};
|
||||
for (const [key, getter] of $entries(configuratorGetters)) {
|
||||
properties[key] = { get: getter };
|
||||
$defineProperty(configurableFn, key, { get: getter });
|
||||
}
|
||||
for (const [key, getter] of $entries(configuratorMethods)) {
|
||||
properties[key] = { value: getter };
|
||||
$defineProperty(configurableFn, key, { value: getter });
|
||||
}
|
||||
|
||||
/** @type {{ tags: Tag[], [key: string]: any }} */
|
||||
let currentConfig = { tags: [] };
|
||||
return $defineProperties(configurableFn, properties);
|
||||
return configurableFn;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1395,7 +1406,7 @@ export class Runner {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {...string} tagNames
|
||||
* @param {...string} tagNames
|
||||
*/
|
||||
const addTagsToCurrent = (...tagNames) => {
|
||||
const current = getCurrent();
|
||||
|
|
@ -1425,7 +1436,6 @@ export class Runner {
|
|||
* @param {boolean} [canEraseParent]
|
||||
*/
|
||||
_erase(job, canEraseParent = false) {
|
||||
job.minimize();
|
||||
if (job instanceof Suite) {
|
||||
if (!job.reporting.failed) {
|
||||
this.suites.delete(job.id);
|
||||
|
|
@ -1435,6 +1445,7 @@ export class Runner {
|
|||
this.tests.delete(job.id);
|
||||
}
|
||||
}
|
||||
job.minimize();
|
||||
if (canEraseParent && job.parent) {
|
||||
const jobIndex = job.parent.jobs.indexOf(job);
|
||||
if (jobIndex >= 0) {
|
||||
|
|
@ -1694,9 +1705,6 @@ export class Runner {
|
|||
if (preset.tags?.length) {
|
||||
this._include(this.state.includeSpecs.tag, preset.tags, INCLUDE_LEVEL.preset);
|
||||
}
|
||||
if (preset.platform) {
|
||||
mockUserAgent(preset.platform);
|
||||
}
|
||||
if (typeof preset.touch === "boolean") {
|
||||
this.beforeEach(() => mockTouch(preset.touch));
|
||||
}
|
||||
|
|
@ -1731,7 +1739,7 @@ export class Runner {
|
|||
this._populateState = false;
|
||||
|
||||
if (!this.state.tests.length) {
|
||||
throw new HootError(`no tests to run`, { level: "critical" });
|
||||
logger.logGlobal(`no tests to run`);
|
||||
}
|
||||
|
||||
// Reduce non-included suites & tests info to a miminum
|
||||
|
|
@ -1778,7 +1786,7 @@ export class Runner {
|
|||
const error = ensureError(ev);
|
||||
if (handledErrors.has(error)) {
|
||||
// Already handled
|
||||
return safePrevent(ev);
|
||||
return ev.preventDefault();
|
||||
}
|
||||
handledErrors.add(error);
|
||||
|
||||
|
|
@ -1786,27 +1794,32 @@ export class Runner {
|
|||
ev = new ErrorEvent("error", { error });
|
||||
}
|
||||
|
||||
mockPreventDefault(ev);
|
||||
|
||||
if (error.message.includes(RESIZE_OBSERVER_MESSAGE)) {
|
||||
// Stop event
|
||||
ev.stopImmediatePropagation();
|
||||
if (ev.bubbles) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
return safePrevent(ev);
|
||||
return ev.preventDefault();
|
||||
}
|
||||
|
||||
if (this.state.currentTest && !(error instanceof HootError)) {
|
||||
if (this.state.currentTest) {
|
||||
// Handle the error in the current test
|
||||
const handled = this._handleErrorInTest(ev, error);
|
||||
if (handled) {
|
||||
return safePrevent(ev);
|
||||
if (!(error instanceof HootError)) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this._handleGlobalError(ev, error);
|
||||
}
|
||||
|
||||
// Prevent error event
|
||||
safePrevent(ev);
|
||||
ev.preventDefault();
|
||||
|
||||
// Log error
|
||||
if (error.level) {
|
||||
|
|
@ -1826,7 +1839,7 @@ export class Runner {
|
|||
_handleErrorInTest(ev, error) {
|
||||
for (const callbackRegistry of this._getCallbackChain(this.state.currentTest)) {
|
||||
callbackRegistry.callSync("error", ev, logger.error);
|
||||
if (ev.defaultPrevented) {
|
||||
if (isPrevented(ev)) {
|
||||
// Prevented in tests
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1875,7 +1888,7 @@ export class Runner {
|
|||
async _setupStart() {
|
||||
this._startTime = $now();
|
||||
if (this.config.manual) {
|
||||
this._canStartDef = Promise.withResolvers();
|
||||
this._canStartDef ||= Promise.withResolvers();
|
||||
}
|
||||
|
||||
// Config log
|
||||
|
|
@ -1903,10 +1916,21 @@ export class Runner {
|
|||
this.config.debugTest = false;
|
||||
this.debug = false;
|
||||
} else {
|
||||
const nameSpace = exposeHelpers(hootDom, hootMock, {
|
||||
destroy,
|
||||
getFixture: this.fixture.get,
|
||||
});
|
||||
const nameSpace = exposeHelpers(
|
||||
_hootDom,
|
||||
_animation,
|
||||
_date,
|
||||
_math,
|
||||
_navigator,
|
||||
_network,
|
||||
_notification,
|
||||
_window,
|
||||
{
|
||||
__debug__: this,
|
||||
destroy,
|
||||
getFixture: this.fixture.get,
|
||||
}
|
||||
);
|
||||
logger.setLogLevel("debug");
|
||||
logger.logDebug(
|
||||
`Debug mode is active: Hoot helpers available from \`window.${nameSpace}\``
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function getTagSimilarities() {
|
|||
* Used in Hoot internal tests to remove tags introduced within a test.
|
||||
*
|
||||
* @private
|
||||
* @param {Iterable<string>} tagKeys
|
||||
* @param {Iterable<string>} tagKeys
|
||||
*/
|
||||
export function undefineTags(tagKeys) {
|
||||
for (const tagKey of tagKeys) {
|
||||
|
|
|
|||
|
|
@ -1,33 +1,84 @@
|
|||
/** @odoo-module alias=@odoo/hoot-mock default=false */
|
||||
|
||||
/**
|
||||
* @typedef {import("./mock/network").ServerWebSocket} ServerWebSocket
|
||||
*/
|
||||
import * as _hootDom from "@odoo/hoot-dom";
|
||||
import * as _animation from "./mock/animation";
|
||||
import * as _date from "./mock/date";
|
||||
import * as _math from "./mock/math";
|
||||
import * as _navigator from "./mock/navigator";
|
||||
import * as _network from "./mock/network";
|
||||
import * as _notification from "./mock/notification";
|
||||
import * as _window from "./mock/window";
|
||||
|
||||
export {
|
||||
advanceFrame,
|
||||
advanceTime,
|
||||
animationFrame,
|
||||
cancelAllTimers,
|
||||
Deferred,
|
||||
delay,
|
||||
freezeTime,
|
||||
microTick,
|
||||
runAllTimers,
|
||||
setFrameRate,
|
||||
tick,
|
||||
unfreezeTime,
|
||||
} from "@odoo/hoot-dom";
|
||||
export { disableAnimations, enableTransitions } from "./mock/animation";
|
||||
export { mockDate, mockLocale, mockTimeZone, onTimeZoneChange } from "./mock/date";
|
||||
export { makeSeededRandom } from "./mock/math";
|
||||
export { mockPermission, mockSendBeacon, mockUserAgent, mockVibrate } from "./mock/navigator";
|
||||
export { mockFetch, mockLocation, mockWebSocket, mockWorker } from "./mock/network";
|
||||
export { flushNotifications } from "./mock/notification";
|
||||
export {
|
||||
mockMatchMedia,
|
||||
mockTouch,
|
||||
watchAddedNodes,
|
||||
watchKeys,
|
||||
watchListeners,
|
||||
} from "./mock/window";
|
||||
/** @deprecated use `import { advanceFrame } from "@odoo/hoot";` */
|
||||
export const advanceFrame = _hootDom.advanceFrame;
|
||||
/** @deprecated use `import { advanceTime } from "@odoo/hoot";` */
|
||||
export const advanceTime = _hootDom.advanceTime;
|
||||
/** @deprecated use `import { animationFrame } from "@odoo/hoot";` */
|
||||
export const animationFrame = _hootDom.animationFrame;
|
||||
/** @deprecated use `import { cancelAllTimers } from "@odoo/hoot";` */
|
||||
export const cancelAllTimers = _hootDom.cancelAllTimers;
|
||||
/** @deprecated use `import { Deferred } from "@odoo/hoot";` */
|
||||
export const Deferred = _hootDom.Deferred;
|
||||
/** @deprecated use `import { delay } from "@odoo/hoot";` */
|
||||
export const delay = _hootDom.delay;
|
||||
/** @deprecated use `import { freezeTime } from "@odoo/hoot";` */
|
||||
export const freezeTime = _hootDom.freezeTime;
|
||||
/** @deprecated use `import { microTick } from "@odoo/hoot";` */
|
||||
export const microTick = _hootDom.microTick;
|
||||
/** @deprecated use `import { runAllTimers } from "@odoo/hoot";` */
|
||||
export const runAllTimers = _hootDom.runAllTimers;
|
||||
/** @deprecated use `import { setFrameRate } from "@odoo/hoot";` */
|
||||
export const setFrameRate = _hootDom.setFrameRate;
|
||||
/** @deprecated use `import { tick } from "@odoo/hoot";` */
|
||||
export const tick = _hootDom.tick;
|
||||
/** @deprecated use `import { unfreezeTime } from "@odoo/hoot";` */
|
||||
export const unfreezeTime = _hootDom.unfreezeTime;
|
||||
|
||||
/** @deprecated use `import { disableAnimations } from "@odoo/hoot";` */
|
||||
export const disableAnimations = _animation.disableAnimations;
|
||||
/** @deprecated use `import { enableTransitions } from "@odoo/hoot";` */
|
||||
export const enableTransitions = _animation.enableTransitions;
|
||||
|
||||
/** @deprecated use `import { mockDate } from "@odoo/hoot";` */
|
||||
export const mockDate = _date.mockDate;
|
||||
/** @deprecated use `import { mockLocale } from "@odoo/hoot";` */
|
||||
export const mockLocale = _date.mockLocale;
|
||||
/** @deprecated use `import { mockTimeZone } from "@odoo/hoot";` */
|
||||
export const mockTimeZone = _date.mockTimeZone;
|
||||
/** @deprecated use `import { onTimeZoneChange } from "@odoo/hoot";` */
|
||||
export const onTimeZoneChange = _date.onTimeZoneChange;
|
||||
|
||||
/** @deprecated use `import { makeSeededRandom } from "@odoo/hoot";` */
|
||||
export const makeSeededRandom = _math.makeSeededRandom;
|
||||
|
||||
/** @deprecated use `import { mockPermission } from "@odoo/hoot";` */
|
||||
export const mockPermission = _navigator.mockPermission;
|
||||
/** @deprecated use `import { mockSendBeacon } from "@odoo/hoot";` */
|
||||
export const mockSendBeacon = _navigator.mockSendBeacon;
|
||||
/** @deprecated use `import { mockUserAgent } from "@odoo/hoot";` */
|
||||
export const mockUserAgent = _navigator.mockUserAgent;
|
||||
/** @deprecated use `import { mockVibrate } from "@odoo/hoot";` */
|
||||
export const mockVibrate = _navigator.mockVibrate;
|
||||
|
||||
/** @deprecated use `import { mockFetch } from "@odoo/hoot";` */
|
||||
export const mockFetch = _network.mockFetch;
|
||||
/** @deprecated use `import { mockLocation } from "@odoo/hoot";` */
|
||||
export const mockLocation = _network.mockLocation;
|
||||
/** @deprecated use `import { mockWebSocket } from "@odoo/hoot";` */
|
||||
export const mockWebSocket = _network.mockWebSocket;
|
||||
/** @deprecated use `import { mockWorker } from "@odoo/hoot";` */
|
||||
export const mockWorker = _network.mockWorker;
|
||||
|
||||
/** @deprecated use `import { flushNotifications } from "@odoo/hoot";` */
|
||||
export const flushNotifications = _notification.flushNotifications;
|
||||
|
||||
/** @deprecated use `import { mockMatchMedia } from "@odoo/hoot";` */
|
||||
export const mockMatchMedia = _window.mockMatchMedia;
|
||||
/** @deprecated use `import { mockTouch } from "@odoo/hoot";` */
|
||||
export const mockTouch = _window.mockTouch;
|
||||
/** @deprecated use `import { watchAddedNodes } from "@odoo/hoot";` */
|
||||
export const watchAddedNodes = _window.watchAddedNodes;
|
||||
/** @deprecated use `import { watchKeys } from "@odoo/hoot";` */
|
||||
export const watchKeys = _window.watchKeys;
|
||||
/** @deprecated use `import { watchListeners } from "@odoo/hoot";` */
|
||||
export const watchListeners = _window.watchListeners;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,29 @@
|
|||
import { logger } from "./core/logger";
|
||||
import { Runner } from "./core/runner";
|
||||
import { urlParams } from "./core/url";
|
||||
import { makeRuntimeHook } from "./hoot_utils";
|
||||
import { copyAndBind, makeRuntimeHook } from "./hoot_utils";
|
||||
import { setRunner } from "./main_runner";
|
||||
import { setupHootUI } from "./ui/setup_hoot_ui";
|
||||
|
||||
/**
|
||||
* @typedef {import("../hoot-dom/helpers/dom").Dimensions} Dimensions
|
||||
* @typedef {import("../hoot-dom/helpers/dom").FormatXmlOptions} FormatXmlOptions
|
||||
* @typedef {import("../hoot-dom/helpers/dom").Position} Position
|
||||
* @typedef {import("../hoot-dom/helpers/dom").QueryOptions} QueryOptions
|
||||
* @typedef {import("../hoot-dom/helpers/dom").QueryRectOptions} QueryRectOptions
|
||||
* @typedef {import("../hoot-dom/helpers/dom").QueryTextOptions} QueryTextOptions
|
||||
* @typedef {import("../hoot-dom/helpers/dom").Target} Target
|
||||
*
|
||||
* @typedef {import("../hoot-dom/helpers/events").DragHelpers} DragHelpers
|
||||
* @typedef {import("../hoot-dom/helpers/events").DragOptions} DragOptions
|
||||
* @typedef {import("../hoot-dom/helpers/events").EventType} EventType
|
||||
* @typedef {import("../hoot-dom/helpers/events").FillOptions} FillOptions
|
||||
* @typedef {import("../hoot-dom/helpers/events").InputValue} InputValue
|
||||
* @typedef {import("../hoot-dom/helpers/events").KeyStrokes} KeyStrokes
|
||||
* @typedef {import("../hoot-dom/helpers/events").PointerOptions} PointerOptions
|
||||
*
|
||||
* @typedef {import("./mock/network").ServerWebSocket} ServerWebSocket
|
||||
*
|
||||
* @typedef {{
|
||||
* runner: Runner;
|
||||
* ui: import("./ui/setup_hoot_ui").UiState
|
||||
|
|
@ -26,19 +44,12 @@ setRunner(runner);
|
|||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {...unknown} values
|
||||
*/
|
||||
export function registerDebugInfo(...values) {
|
||||
logger.logDebug(...values);
|
||||
}
|
||||
|
||||
// Main test API
|
||||
export const describe = runner.describe;
|
||||
export const expect = runner.expect;
|
||||
export const test = runner.test;
|
||||
|
||||
// Hooks
|
||||
// Test hooks
|
||||
export const after = makeRuntimeHook("after");
|
||||
export const afterEach = makeRuntimeHook("afterEach");
|
||||
export const before = makeRuntimeHook("before");
|
||||
|
|
@ -48,7 +59,7 @@ export const onError = makeRuntimeHook("onError");
|
|||
// Fixture
|
||||
export const getFixture = runner.fixture.get;
|
||||
|
||||
// Other functions
|
||||
// Other test runner functions
|
||||
export const definePreset = runner.exportFn(runner.definePreset);
|
||||
export const dryRun = runner.exportFn(runner.dryRun);
|
||||
export const getCurrent = runner.exportFn(runner.getCurrent);
|
||||
|
|
@ -61,59 +72,102 @@ export { defineTags } from "./core/tag";
|
|||
export { createJobScopedGetter } from "./hoot_utils";
|
||||
|
||||
// Constants
|
||||
export const globals = {
|
||||
AbortController: globalThis.AbortController,
|
||||
Array: globalThis.Array,
|
||||
Boolean: globalThis.Boolean,
|
||||
DataTransfer: globalThis.DataTransfer,
|
||||
Date: globalThis.Date,
|
||||
Document: globalThis.Document,
|
||||
Element: globalThis.Element,
|
||||
Error: globalThis.Error,
|
||||
ErrorEvent: globalThis.ErrorEvent,
|
||||
EventTarget: globalThis.EventTarget,
|
||||
Map: globalThis.Map,
|
||||
MutationObserver: globalThis.MutationObserver,
|
||||
Number: globalThis.Number,
|
||||
Object: globalThis.Object,
|
||||
ProgressEvent: globalThis.ProgressEvent,
|
||||
Promise: globalThis.Promise,
|
||||
PromiseRejectionEvent: globalThis.PromiseRejectionEvent,
|
||||
Proxy: globalThis.Proxy,
|
||||
RegExp: globalThis.RegExp,
|
||||
Request: globalThis.Request,
|
||||
Response: globalThis.Response,
|
||||
Set: globalThis.Set,
|
||||
SharedWorker: globalThis.SharedWorker,
|
||||
String: globalThis.String,
|
||||
TypeError: globalThis.TypeError,
|
||||
URIError: globalThis.URIError,
|
||||
URL: globalThis.URL,
|
||||
URLSearchParams: globalThis.URLSearchParams,
|
||||
WebSocket: globalThis.WebSocket,
|
||||
Window: globalThis.Window,
|
||||
Worker: globalThis.Worker,
|
||||
XMLHttpRequest: globalThis.XMLHttpRequest,
|
||||
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
console: globalThis.console,
|
||||
document: globalThis.document,
|
||||
fetch: globalThis.fetch,
|
||||
history: globalThis.history,
|
||||
JSON: globalThis.JSON,
|
||||
localStorage: globalThis.localStorage,
|
||||
location: globalThis.location,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
Math: globalThis.Math,
|
||||
navigator: globalThis.navigator,
|
||||
ontouchstart: globalThis.ontouchstart,
|
||||
performance: globalThis.performance,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
sessionStorage: globalThis.sessionStorage,
|
||||
setInterval: globalThis.setInterval,
|
||||
setTimeout: globalThis.setTimeout,
|
||||
};
|
||||
export const globals = copyAndBind(globalThis);
|
||||
export const isHootReady = setupHootUI();
|
||||
|
||||
// Mock
|
||||
export { disableAnimations, enableTransitions } from "./mock/animation";
|
||||
export { mockDate, mockLocale, mockTimeZone, onTimeZoneChange } from "./mock/date";
|
||||
export { makeSeededRandom } from "./mock/math";
|
||||
export { mockPermission, mockSendBeacon, mockUserAgent, mockVibrate } from "./mock/navigator";
|
||||
export { mockFetch, mockLocation, mockWebSocket, mockWorker, withFetch } from "./mock/network";
|
||||
export { flushNotifications } from "./mock/notification";
|
||||
export {
|
||||
mockMatchMedia,
|
||||
mockTouch,
|
||||
watchAddedNodes,
|
||||
watchKeys,
|
||||
watchListeners,
|
||||
} from "./mock/window";
|
||||
|
||||
// HOOT-DOM
|
||||
export {
|
||||
advanceFrame,
|
||||
advanceTime,
|
||||
animationFrame,
|
||||
cancelAllTimers,
|
||||
check,
|
||||
clear,
|
||||
click,
|
||||
dblclick,
|
||||
Deferred,
|
||||
delay,
|
||||
drag,
|
||||
edit,
|
||||
fill,
|
||||
formatXml,
|
||||
freezeTime,
|
||||
getActiveElement,
|
||||
getFocusableElements,
|
||||
getNextFocusableElement,
|
||||
getParentFrame,
|
||||
getPreviousFocusableElement,
|
||||
hover,
|
||||
isDisplayed,
|
||||
isEditable,
|
||||
isFocusable,
|
||||
isInDOM,
|
||||
isInViewPort,
|
||||
isScrollable,
|
||||
isVisible,
|
||||
keyDown,
|
||||
keyUp,
|
||||
leave,
|
||||
manuallyDispatchProgrammaticEvent,
|
||||
matches,
|
||||
microTick,
|
||||
middleClick,
|
||||
on,
|
||||
pointerDown,
|
||||
pointerUp,
|
||||
press,
|
||||
queryAll,
|
||||
queryAllAttributes,
|
||||
queryAllProperties,
|
||||
queryAllRects,
|
||||
queryAllTexts,
|
||||
queryAllValues,
|
||||
queryAny,
|
||||
queryAttribute,
|
||||
queryFirst,
|
||||
queryOne,
|
||||
queryRect,
|
||||
queryText,
|
||||
queryValue,
|
||||
resize,
|
||||
rightClick,
|
||||
runAllTimers,
|
||||
scroll,
|
||||
select,
|
||||
setFrameRate,
|
||||
setInputFiles,
|
||||
setInputRange,
|
||||
tick,
|
||||
uncheck,
|
||||
unfreezeTime,
|
||||
unload,
|
||||
waitFor,
|
||||
waitForNone,
|
||||
waitUntil,
|
||||
} from "@odoo/hoot-dom";
|
||||
|
||||
// Debug
|
||||
export { exposeHelpers } from "../hoot-dom/hoot_dom_utils";
|
||||
export const __debug__ = runner;
|
||||
|
||||
export const isHootReady = setupHootUI();
|
||||
/**
|
||||
* @param {...unknown} values
|
||||
*/
|
||||
export function registerDebugInfo(...values) {
|
||||
logger.logDebug(...values);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { isNode } from "@web/../lib/hoot-dom/helpers/dom";
|
|||
import {
|
||||
isInstanceOf,
|
||||
isIterable,
|
||||
isPromise,
|
||||
parseRegExp,
|
||||
R_WHITE_SPACE,
|
||||
toSelector,
|
||||
|
|
@ -114,6 +115,7 @@ const {
|
|||
TypeError,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
WeakMap,
|
||||
WeakSet,
|
||||
window,
|
||||
} = globalThis;
|
||||
|
|
@ -165,10 +167,21 @@ function getGenericSerializer(value) {
|
|||
}
|
||||
|
||||
function makeObjectCache() {
|
||||
const cache = new Set();
|
||||
const cache = new WeakSet();
|
||||
return {
|
||||
add: (...values) => values.forEach((value) => cache.add(value)),
|
||||
has: (...values) => values.every((value) => cache.has(value)),
|
||||
add: (...values) => {
|
||||
for (const value of values) {
|
||||
cache.add(value);
|
||||
}
|
||||
},
|
||||
has: (...values) => {
|
||||
for (const value of values) {
|
||||
if (!cache.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -178,11 +191,7 @@ function makeObjectCache() {
|
|||
* @returns {T}
|
||||
*/
|
||||
function resolve(value) {
|
||||
if (typeof value === "function") {
|
||||
return value();
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
return typeof value === "function" ? value() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -205,6 +214,51 @@ function truncate(value, length = MAX_HUMAN_READABLE_SIZE) {
|
|||
return strValue.length <= length ? strValue : strValue.slice(0, length) + ELLIPSIS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @param {ReturnType<makeObjectCache>} cache
|
||||
* @returns {T}
|
||||
*/
|
||||
function _deepCopy(value, cache) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
if (value.name) {
|
||||
return `<function ${value.name}>`;
|
||||
} else {
|
||||
return "<anonymous function>";
|
||||
}
|
||||
}
|
||||
if (typeof value === "object" && !Markup.isMarkup(value)) {
|
||||
if (isInstanceOf(value, String, Number, Boolean)) {
|
||||
return value;
|
||||
}
|
||||
if (isNode(value)) {
|
||||
// Nodes
|
||||
return value.cloneNode(true);
|
||||
} else if (isInstanceOf(value, Date, RegExp)) {
|
||||
// Dates & regular expressions
|
||||
return new (getConstructor(value))(value);
|
||||
} else if (isIterable(value)) {
|
||||
const isArray = $isArray(value);
|
||||
const valueArray = isArray ? value : [...value];
|
||||
// Iterables
|
||||
const values = valueArray.map((item) => _deepCopy(item, cache));
|
||||
return $isArray(value) ? values : new (getConstructor(value))(values);
|
||||
} else {
|
||||
// Other objects
|
||||
if (cache.has(value)) {
|
||||
return S_CIRCULAR;
|
||||
}
|
||||
cache.add(value);
|
||||
return $fromEntries($ownKeys(value).map((key) => [key, _deepCopy(value[key], cache)]));
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} a
|
||||
* @param {unknown} b
|
||||
|
|
@ -521,6 +575,8 @@ class QueryPartialString extends QueryString {
|
|||
compareFn = getFuzzyScore;
|
||||
}
|
||||
|
||||
const EMPTY_CONSTRUCTOR = { name: null };
|
||||
|
||||
/** @type {Map<Function, (value: unknown) => string>} */
|
||||
const GENERIC_SERIALIZERS = new Map([
|
||||
[BigInt, (v) => v.valueOf()],
|
||||
|
|
@ -551,10 +607,12 @@ const R_NAMED_FUNCTION = /^\s*(async\s+)?function/;
|
|||
const R_INVISIBLE_CHARACTERS = /[\u00a0\u200b-\u200d\ufeff]/g;
|
||||
const R_OBJECT = /^\[object ([\w-]+)\]$/;
|
||||
|
||||
const labelObjects = new WeakSet();
|
||||
const objectConstructors = new Map();
|
||||
/** @type {(KeyboardEventInit & { callback: (ev: KeyboardEvent) => any })[]} */
|
||||
const hootKeys = [];
|
||||
const labelObjects = new WeakSet();
|
||||
const objectConstructors = new Map();
|
||||
/** @type {WeakMap<unknown, unknown>} */
|
||||
const syncValues = new WeakMap();
|
||||
const windowTarget = {
|
||||
addEventListener: window.addEventListener.bind(window),
|
||||
removeEventListener: window.removeEventListener.bind(window),
|
||||
|
|
@ -597,6 +655,22 @@ export function callHootKey(ev) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} object
|
||||
* @returns {T}
|
||||
*/
|
||||
export function copyAndBind(object) {
|
||||
const copy = {};
|
||||
for (const [key, desc] of $entries($getOwnPropertyDescriptors(object))) {
|
||||
if (key !== "constructor" && typeof desc.value === "function") {
|
||||
desc.value = desc.value.bind(object);
|
||||
}
|
||||
$defineProperty(copy, key, desc);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {(previous: any, ...args: any[]) => any} T
|
||||
* @param {T} instanceGetter
|
||||
|
|
@ -666,6 +740,7 @@ export function createReporting(parentReporting) {
|
|||
|
||||
const reporting = reactive({
|
||||
assertions: 0,
|
||||
duration: 0,
|
||||
failed: 0,
|
||||
passed: 0,
|
||||
skipped: 0,
|
||||
|
|
@ -714,45 +789,6 @@ export function createMock(target, descriptors) {
|
|||
return mock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @returns {T}
|
||||
*/
|
||||
export function deepCopy(value) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
if (value.name) {
|
||||
return `<function ${value.name}>`;
|
||||
} else {
|
||||
return "<anonymous function>";
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === "object" && !Markup.isMarkup(value)) {
|
||||
if (isInstanceOf(value, String, Number, Boolean)) {
|
||||
return value;
|
||||
}
|
||||
if (isNode(value)) {
|
||||
// Nodes
|
||||
return value.cloneNode(true);
|
||||
} else if (isInstanceOf(value, Date, RegExp)) {
|
||||
// Dates & regular expressions
|
||||
return new (getConstructor(value))(value);
|
||||
} else if (isIterable(value)) {
|
||||
// Iterables
|
||||
const values = [...value].map(deepCopy);
|
||||
return $isArray(value) ? values : new (getConstructor(value))(values);
|
||||
} else {
|
||||
// Other objects
|
||||
return $fromEntries($ownKeys(value).map((key) => [key, deepCopy(value[key])]));
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {(...args: any[]) => any} T
|
||||
* @param {T} fn
|
||||
|
|
@ -801,6 +837,15 @@ export function debounce(fn, delay) {
|
|||
}[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} value
|
||||
* @returns {T}
|
||||
*/
|
||||
export function deepCopy(value) {
|
||||
return _deepCopy(value, makeObjectCache());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} a
|
||||
* @param {unknown} b
|
||||
|
|
@ -955,7 +1000,7 @@ export function generateHash(...strings) {
|
|||
export function getConstructor(value) {
|
||||
const { constructor } = value;
|
||||
if (constructor !== Object) {
|
||||
return constructor || { name: null };
|
||||
return constructor || EMPTY_CONSTRUCTOR;
|
||||
}
|
||||
const str = value.toString();
|
||||
const match = str.match(R_OBJECT);
|
||||
|
|
@ -1021,6 +1066,30 @@ export function getFuzzyScore(pattern, string) {
|
|||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value associated to the given object.
|
||||
* If 'toStringValue' is set, the result will concatenate any inner object that
|
||||
* also has an associated sync value. This is typically useful for nested Blobs.
|
||||
*
|
||||
* @param {unknown} object
|
||||
* @param {boolean} toStringValue
|
||||
*/
|
||||
export function getSyncValue(object, toStringValue) {
|
||||
const result = syncValues.get(object);
|
||||
if (!toStringValue) {
|
||||
return result;
|
||||
}
|
||||
let textResult = "";
|
||||
if (isIterable(result)) {
|
||||
for (const part of result) {
|
||||
textResult += syncValues.has(part) ? getSyncValue(part, toStringValue) : String(part);
|
||||
}
|
||||
} else {
|
||||
textResult += String(result);
|
||||
}
|
||||
return textResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {ArgumentType}
|
||||
|
|
@ -1389,6 +1458,14 @@ export async function paste() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} object
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function setSyncValue(object, value) {
|
||||
syncValues.set(object, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
*/
|
||||
|
|
@ -1591,7 +1668,7 @@ export class Callbacks {
|
|||
* @param {boolean} [once]
|
||||
*/
|
||||
add(type, callback, once) {
|
||||
if (isInstanceOf(callback, Promise)) {
|
||||
if (isPromise(callback)) {
|
||||
const promiseValue = callback;
|
||||
callback = function waitForPromise() {
|
||||
return Promise.resolve(promiseValue).then(resolve);
|
||||
|
|
@ -1994,9 +2071,12 @@ export const INCLUDE_LEVEL = {
|
|||
};
|
||||
|
||||
export const MIME_TYPE = {
|
||||
formData: "multipart/form-data",
|
||||
blob: "application/octet-stream",
|
||||
html: "text/html",
|
||||
json: "application/json",
|
||||
text: "text/plain",
|
||||
xml: "text/xml",
|
||||
};
|
||||
|
||||
export const STORAGE = {
|
||||
|
|
@ -2006,6 +2086,7 @@ export const STORAGE = {
|
|||
};
|
||||
|
||||
export const S_ANY = Symbol("any value");
|
||||
export const S_CIRCULAR = Symbol("circular object");
|
||||
export const S_NONE = Symbol("no value");
|
||||
|
||||
export const R_QUERY_EXACT = new RegExp(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ let runner;
|
|||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} funcName
|
||||
*/
|
||||
export function ensureTest(funcName) {
|
||||
if (!runner?.getCurrent().test) {
|
||||
throw new Error(`Cannot call '${funcName}' from outside a test`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getRunner() {
|
||||
return runner;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { on } from "@odoo/hoot-dom";
|
||||
import { MockEventTarget } from "../hoot_utils";
|
||||
import { ensureTest } from "../main_runner";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
|
|
@ -95,6 +96,7 @@ export function cleanupAnimations() {
|
|||
* @param {boolean} [enable=false]
|
||||
*/
|
||||
export function disableAnimations(enable = false) {
|
||||
ensureTest("disableAnimations");
|
||||
allowAnimations = enable;
|
||||
}
|
||||
|
||||
|
|
@ -105,6 +107,7 @@ export function disableAnimations(enable = false) {
|
|||
* @param {boolean} [enable=true]
|
||||
*/
|
||||
export function enableTransitions(enable = true) {
|
||||
ensureTest("enableTransitions");
|
||||
allowTransitions = enable;
|
||||
animationChangeBus.dispatchEvent(new CustomEvent("toggle-transitions"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,71 @@
|
|||
/** @odoo-module */
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Math: { random: $random, floor: $floor },
|
||||
TextEncoder,
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @type {SubtleCrypto["decrypt"]} */
|
||||
function decrypt(algorithm, key, data) {
|
||||
return Promise.resolve($encode(data.replace("encrypted data:", "")));
|
||||
}
|
||||
|
||||
/** @type {SubtleCrypto["encrypt"]} */
|
||||
function encrypt(algorithm, key, data) {
|
||||
return Promise.resolve(`encrypted data:${$decode(data)}`);
|
||||
}
|
||||
|
||||
/** @type {Crypto["getRandomValues"]} */
|
||||
function getRandomValues(array) {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = $floor($random() * 256);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/** @type {SubtleCrypto["importKey"]} */
|
||||
function importKey(format, keyData, algorithm, extractable, keyUsages) {
|
||||
if (arguments.length < 5) {
|
||||
throw new TypeError(
|
||||
`Failed to execute 'importKey' on 'SubtleCrypto': 5 arguments required, but only ${arguments.length} present.`
|
||||
);
|
||||
}
|
||||
if (!keyData || keyData.length === 0) {
|
||||
throw new TypeError(
|
||||
`Failed to execute 'importKey' on 'SubtleCrypto': The provided value cannot be converted to a sequence.`
|
||||
);
|
||||
}
|
||||
const key = Symbol([algorithm, String(extractable), "secret", ...keyUsages].join("/"));
|
||||
return Promise.resolve(key);
|
||||
}
|
||||
|
||||
function randomUUID() {
|
||||
return [4, 2, 2, 2, 6]
|
||||
.map((length) => getRandomValues(new Uint8Array(length)).toHex())
|
||||
.join("-");
|
||||
}
|
||||
|
||||
const $encode = TextEncoder.prototype.encode.bind(new TextEncoder());
|
||||
const $decode = TextDecoder.prototype.decode.bind(new TextDecoder("utf-8"));
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export const mockCrypto = {
|
||||
getRandomValues,
|
||||
randomUUID,
|
||||
subtle: {
|
||||
importKey: (_format, keyData, _algorithm, _extractable, _keyUsages) => {
|
||||
if (!keyData || keyData.length === 0) {
|
||||
throw Error(`KeyData is mandatory`);
|
||||
}
|
||||
return Promise.resolve("I'm a key");
|
||||
},
|
||||
encrypt: (_algorithm, _key, data) =>
|
||||
Promise.resolve(`encrypted data:${new TextDecoder().decode(data)}`),
|
||||
decrypt: (_algorithm, _key, data) =>
|
||||
Promise.resolve(new TextEncoder().encode(data.replace("encrypted data:", ""))),
|
||||
},
|
||||
getRandomValues: (typedArray) => {
|
||||
typedArray.forEach((_element, index) => {
|
||||
typedArray[index] = Math.round(Math.random() * 100);
|
||||
});
|
||||
return typedArray;
|
||||
decrypt,
|
||||
encrypt,
|
||||
importKey,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { getTimeOffset, isTimeFrozen, resetTimeOffset } from "@web/../lib/hoot-dom/helpers/time";
|
||||
import { createMock, HootError, isNil } from "../hoot_utils";
|
||||
import { ensureTest } from "../main_runner";
|
||||
|
||||
/**
|
||||
* @typedef DateSpecs
|
||||
|
|
@ -161,7 +162,7 @@ export function cleanupDate() {
|
|||
* @see {@link mockTimeZone} for the time zone params.
|
||||
*
|
||||
* @param {string | DateSpecs} [date]
|
||||
* @param {string | number | null} [tz]
|
||||
* @param {string | number | null} [tz]
|
||||
* @example
|
||||
* mockDate("2023-12-25T20:45:00"); // 2023-12-25 20:45:00 UTC
|
||||
* @example
|
||||
|
|
@ -170,6 +171,7 @@ export function cleanupDate() {
|
|||
* mockDate("2019-02-11 09:30:00.001", +2);
|
||||
*/
|
||||
export function mockDate(date, tz) {
|
||||
ensureTest("mockDate");
|
||||
setDateParams(date ? parseDateParams(date) : DEFAULT_DATE);
|
||||
if (!isNil(tz)) {
|
||||
setTimeZone(tz);
|
||||
|
|
@ -187,6 +189,7 @@ export function mockDate(date, tz) {
|
|||
* mockTimeZone("ja-JP"); // UTC + 9
|
||||
*/
|
||||
export function mockLocale(newLocale) {
|
||||
ensureTest("mockLocale");
|
||||
locale = newLocale;
|
||||
|
||||
if (!isNil(locale) && isNil(timeZoneName)) {
|
||||
|
|
@ -213,6 +216,7 @@ export function mockLocale(newLocale) {
|
|||
* mockTimeZone(null) // Resets to test default (+1)
|
||||
*/
|
||||
export function mockTimeZone(tz) {
|
||||
ensureTest("mockTimeZone");
|
||||
setTimeZone(tz);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { isInstanceOf } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { createMock, HootError, MIME_TYPE, MockEventTarget } from "../hoot_utils";
|
||||
import { getSyncValue, setSyncValue } from "./sync_values";
|
||||
import {
|
||||
createMock,
|
||||
getSyncValue,
|
||||
HootError,
|
||||
MIME_TYPE,
|
||||
MockEventTarget,
|
||||
setSyncValue,
|
||||
} from "../hoot_utils";
|
||||
import { ensureTest } from "../main_runner";
|
||||
|
||||
/**
|
||||
* @typedef {"android" | "ios" | "linux" | "mac" | "windows"} Platform
|
||||
|
|
@ -220,7 +227,7 @@ export class MockClipboardItem extends ClipboardItem {
|
|||
// Added synchronous methods to enhance speed in tests
|
||||
|
||||
async getType(type) {
|
||||
return getSyncValue(this)[type];
|
||||
return getSyncValue(this, false)[type];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,18 +271,137 @@ export class MockPermissionStatus extends MockEventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
export class MockServiceWorker extends MockEventTarget {
|
||||
static publicListeners = ["error", "message"];
|
||||
|
||||
/** @type {ServiceWorkerState} */
|
||||
state = "activated";
|
||||
|
||||
/**
|
||||
* @param {string} scriptURL
|
||||
*/
|
||||
constructor(scriptURL) {
|
||||
super(...arguments);
|
||||
|
||||
/** @type {string} */
|
||||
this.scriptURL = scriptURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} _message
|
||||
*/
|
||||
postMessage(_message) {}
|
||||
}
|
||||
|
||||
export class MockServiceWorkerContainer extends MockEventTarget {
|
||||
static publicListeners = ["controllerchange", "message", "messageerror"];
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_readyResolved = false;
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {Map<string, MockServiceWorkerRegistration>}
|
||||
*/
|
||||
_registrations = new Map();
|
||||
|
||||
/** @type {MockServiceWorker | null} */
|
||||
controller = null;
|
||||
|
||||
get ready() {
|
||||
return this._readyPromise;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers();
|
||||
/**
|
||||
* @type {Promise<MockServiceWorkerRegistration>}
|
||||
* @private
|
||||
*/
|
||||
this._readyPromise = promise;
|
||||
/**
|
||||
* @type {(value: MockServiceWorkerRegistration) => void}
|
||||
* @private
|
||||
*/
|
||||
this._resolveReady = resolve;
|
||||
}
|
||||
|
||||
async getRegistration(scope = "/") {
|
||||
return this._registrations.get(scope);
|
||||
}
|
||||
|
||||
async getRegistrations() {
|
||||
return Array.from(this._registrations.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} scriptURL
|
||||
* @param {{ scope?: string }} [options]
|
||||
*/
|
||||
async register(scriptURL, options = {}) {
|
||||
const scope = options.scope ?? "/";
|
||||
|
||||
const registration = new MockServiceWorkerRegistration(scriptURL, scope);
|
||||
this._registrations.set(scope, registration);
|
||||
|
||||
if (!this.controller) {
|
||||
this.controller = registration.active;
|
||||
this.dispatchEvent(new Event("controllerchange"));
|
||||
}
|
||||
|
||||
if (!this._readyResolved) {
|
||||
this._readyResolved = true;
|
||||
this._resolveReady(registration);
|
||||
}
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
startMessages() {}
|
||||
}
|
||||
|
||||
export class MockServiceWorkerRegistration {
|
||||
/** @type {MockServiceWorker | null} */
|
||||
installing = null;
|
||||
/** @type {MockServiceWorker | null} */
|
||||
waiting = null;
|
||||
|
||||
/**
|
||||
* @param {string} scriptURL
|
||||
* @param {string} scope
|
||||
*/
|
||||
constructor(scriptURL, scope) {
|
||||
/** @type {MockServiceWorker | null} */
|
||||
this.active = new MockServiceWorker(scriptURL);
|
||||
/** @type {string} */
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
async unregister() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async update() {}
|
||||
}
|
||||
|
||||
export const currentPermissions = getPermissions();
|
||||
|
||||
export const mockClipboard = new MockClipboard();
|
||||
|
||||
export const mockPermissions = new MockPermissions();
|
||||
|
||||
export const mockServiceWorker = new MockServiceWorkerContainer();
|
||||
|
||||
export const mockNavigator = createMock(navigator, {
|
||||
clipboard: { value: mockClipboard },
|
||||
maxTouchPoints: { get: () => (globalThis.ontouchstart === undefined ? 0 : 1) },
|
||||
permissions: { value: mockPermissions },
|
||||
sendBeacon: { get: () => mockValues.sendBeacon },
|
||||
serviceWorker: { get: () => undefined },
|
||||
serviceWorker: { value: mockServiceWorker },
|
||||
userAgent: { get: () => mockValues.userAgent },
|
||||
vibrate: { get: () => mockValues.vibrate },
|
||||
});
|
||||
|
|
@ -291,6 +417,7 @@ export function cleanupNavigator() {
|
|||
* @param {PermissionState} [value]
|
||||
*/
|
||||
export function mockPermission(name, value) {
|
||||
ensureTest("mockPermission");
|
||||
if (!(name in currentPermissions)) {
|
||||
throw new TypeError(
|
||||
`The provided value '${name}' is not a valid enum value of type PermissionName`
|
||||
|
|
@ -310,6 +437,7 @@ export function mockPermission(name, value) {
|
|||
* @param {Navigator["sendBeacon"]} callback
|
||||
*/
|
||||
export function mockSendBeacon(callback) {
|
||||
ensureTest("mockSendBeacon");
|
||||
mockValues.sendBeacon = callback;
|
||||
}
|
||||
|
||||
|
|
@ -317,6 +445,7 @@ export function mockSendBeacon(callback) {
|
|||
* @param {Platform} platform
|
||||
*/
|
||||
export function mockUserAgent(platform = "linux") {
|
||||
ensureTest("mockUserAgent");
|
||||
mockValues.userAgent = makeUserAgent(platform);
|
||||
}
|
||||
|
||||
|
|
@ -324,5 +453,6 @@ export function mockUserAgent(platform = "linux") {
|
|||
* @param {Navigator["vibrate"]} callback
|
||||
*/
|
||||
export function mockVibrate(callback) {
|
||||
ensureTest("mockVibrate");
|
||||
mockValues.vibrate = callback;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,22 @@ import {
|
|||
} from "@web/../lib/hoot-dom/helpers/time";
|
||||
import { isInstanceOf } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { makeNetworkLogger } from "../core/logger";
|
||||
import { ensureArray, MIME_TYPE, MockEventTarget } from "../hoot_utils";
|
||||
import { getSyncValue, MockBlob, setSyncValue } from "./sync_values";
|
||||
import {
|
||||
ensureArray,
|
||||
getSyncValue,
|
||||
isNil,
|
||||
MIME_TYPE,
|
||||
MockEventTarget,
|
||||
setSyncValue,
|
||||
} from "../hoot_utils";
|
||||
import { ensureTest } from "../main_runner";
|
||||
|
||||
/**
|
||||
* @typedef {ResponseInit & {
|
||||
* type?: ResponseType;
|
||||
* url?: string;
|
||||
* }} MockResponseInit
|
||||
*
|
||||
* @typedef {AbortController
|
||||
* | MockBroadcastChannel
|
||||
* | MockMessageChannel
|
||||
|
|
@ -28,19 +40,32 @@ import { getSyncValue, MockBlob, setSyncValue } from "./sync_values";
|
|||
|
||||
const {
|
||||
AbortController,
|
||||
Blob,
|
||||
BroadcastChannel,
|
||||
document,
|
||||
fetch,
|
||||
FormData,
|
||||
Headers,
|
||||
Map,
|
||||
Math: { floor: $floor, max: $max, min: $min, random: $random },
|
||||
Object: { assign: $assign, create: $create, entries: $entries },
|
||||
Object: {
|
||||
assign: $assign,
|
||||
create: $create,
|
||||
defineProperty: $defineProperty,
|
||||
entries: $entries,
|
||||
},
|
||||
ProgressEvent,
|
||||
ReadableStream,
|
||||
Request,
|
||||
Response,
|
||||
Set,
|
||||
TextEncoder,
|
||||
Uint8Array,
|
||||
URL,
|
||||
WebSocket,
|
||||
XMLHttpRequest,
|
||||
} = globalThis;
|
||||
const { parse: $parse, stringify: $stringify } = globalThis.JSON;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
|
|
@ -86,6 +111,34 @@ async function dispatchMessage(target, data, transfer) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{ headers?: HeadersInit } | undefined} object
|
||||
* @param {string} content
|
||||
*/
|
||||
function getHeaders(object, content) {
|
||||
/** @type {Headers} */
|
||||
let headers;
|
||||
if (isInstanceOf(object?.headers, Headers)) {
|
||||
headers = object.headers;
|
||||
} else {
|
||||
headers = new Headers(object?.headers);
|
||||
}
|
||||
|
||||
if (content && !headers.has(HEADER.contentType)) {
|
||||
if (typeof content === "string") {
|
||||
headers.set(HEADER.contentType, MIME_TYPE.text);
|
||||
} else if (isInstanceOf(content, Blob)) {
|
||||
headers.set(HEADER.contentType, MIME_TYPE.blob);
|
||||
} else if (isInstanceOf(content, FormData)) {
|
||||
headers.set(HEADER.contentType, MIME_TYPE.formData);
|
||||
} else {
|
||||
headers.set(HEADER.contentType, MIME_TYPE.json);
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {...NetworkInstance} instances
|
||||
*/
|
||||
|
|
@ -111,6 +164,37 @@ function markOpen(instance) {
|
|||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper used to parse JSON-RPC request/response parameters, and to make their
|
||||
* "jsonrpc", "id" and "method" properties non-enumerable, as to make them more
|
||||
* inconspicuous in console logs, effectively highlighting the 'params' or 'result'
|
||||
* keys.
|
||||
*
|
||||
* @param {string} stringParams
|
||||
*/
|
||||
function parseJsonRpcParams(stringParams) {
|
||||
const jsonParams = $assign($create(null), $parse(stringParams));
|
||||
if (jsonParams && "jsonrpc" in jsonParams) {
|
||||
$defineProperty(jsonParams, "jsonrpc", {
|
||||
value: jsonParams.jsonrpc,
|
||||
enumerable: false,
|
||||
});
|
||||
if ("id" in jsonParams) {
|
||||
$defineProperty(jsonParams, "id", {
|
||||
value: jsonParams.id,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
if ("method" in jsonParams) {
|
||||
$defineProperty(jsonParams, "method", {
|
||||
value: jsonParams.method,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return jsonParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} [max]
|
||||
|
|
@ -130,15 +214,50 @@ function parseNetworkDelay(min, max) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array<ArrayBuffer> | string} value
|
||||
* @returns {Uint8Array<ArrayBuffer>}
|
||||
*/
|
||||
function toBytes(value) {
|
||||
return isInstanceOf(value, Uint8Array) ? value : new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
const DEFAULT_URL = "https://www.hoot.test/";
|
||||
const ENDLESS_PROMISE = new Promise(() => {});
|
||||
const HEADER = {
|
||||
contentLength: "Content-Length",
|
||||
contentType: "Content-Type",
|
||||
};
|
||||
const R_EQUAL = /\s*=\s*/;
|
||||
const R_INTERNAL_URL = /^(blob|data|file):/;
|
||||
const R_INTERNAL_URL = /^(blob|data):/;
|
||||
const R_SEMICOLON = /\s*;\s*/;
|
||||
|
||||
const requestResponseMixin = {
|
||||
async arrayBuffer() {
|
||||
return toBytes(this._readValue("arrayBuffer", true)).buffer;
|
||||
},
|
||||
async blob() {
|
||||
const value = this._readValue("blob", false);
|
||||
return isInstanceOf(value, Blob) ? value : new MockBlob([value]);
|
||||
},
|
||||
async bytes() {
|
||||
return toBytes(this._readValue("bytes", true));
|
||||
},
|
||||
async formData() {
|
||||
const data = this._readValue("formData", false);
|
||||
if (!isInstanceOf(data, FormData)) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
return data;
|
||||
},
|
||||
async json() {
|
||||
return $parse(this._readValue("json", true));
|
||||
},
|
||||
async text() {
|
||||
return this._readValue("text", true);
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {Set<NetworkInstance>} */
|
||||
const openNetworkInstances = new Set();
|
||||
|
||||
|
|
@ -148,8 +267,8 @@ let getNetworkDelay = null;
|
|||
let mockFetchFn = null;
|
||||
/** @type {((websocket: ServerWebSocket) => any) | null} */
|
||||
let mockWebSocketConnection = null;
|
||||
/** @type {Array<(worker: MockSharedWorker | MockWorker) => any>} */
|
||||
let mockWorkerConnection = [];
|
||||
/** @type {((worker: MockSharedWorker | MockWorker) => any)[]} */
|
||||
const mockWorkerConnections = [];
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
|
|
@ -159,7 +278,7 @@ export function cleanupNetwork() {
|
|||
// Mocked functions
|
||||
mockFetchFn = null;
|
||||
mockWebSocketConnection = null;
|
||||
mockWorkerConnection = [];
|
||||
mockWorkerConnections.length = 0;
|
||||
|
||||
// Network instances
|
||||
for (const instance of openNetworkInstances) {
|
||||
|
|
@ -194,33 +313,57 @@ export function cleanupNetwork() {
|
|||
|
||||
/** @type {typeof fetch} */
|
||||
export async function mockedFetch(input, init) {
|
||||
if (R_INTERNAL_URL.test(input)) {
|
||||
// Internal URL: directly handled by the browser
|
||||
return fetch(input, init);
|
||||
}
|
||||
const strInput = String(input);
|
||||
const isInternalUrl = R_INTERNAL_URL.test(strInput);
|
||||
if (!mockFetchFn) {
|
||||
throw new Error("Can't make a request when fetch is not mocked");
|
||||
if (isInternalUrl) {
|
||||
// Internal URL without mocked 'fetch': directly handled by the browser
|
||||
return fetch(input, init);
|
||||
}
|
||||
throw new Error(
|
||||
`Could not fetch "${strInput}": cannot make a request when fetch is not mocked`
|
||||
);
|
||||
}
|
||||
init ||= {};
|
||||
const method = init.method?.toUpperCase() || (init.body ? "POST" : "GET");
|
||||
const { logRequest, logResponse } = makeNetworkLogger(method, input);
|
||||
|
||||
const controller = markOpen(new AbortController());
|
||||
init.signal = controller.signal;
|
||||
|
||||
logRequest(() => (typeof init.body === "string" ? JSON.parse(init.body) : init));
|
||||
init = { ...init };
|
||||
init.headers = getHeaders(init, init.body);
|
||||
init.method = init.method?.toUpperCase() || (isNil(init.body) ? "GET" : "POST");
|
||||
|
||||
// Allows 'signal' to not be logged with 'logRequest'.
|
||||
$defineProperty(init, "signal", {
|
||||
value: controller.signal,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
const { logRequest, logResponse } = makeNetworkLogger(init.method, strInput);
|
||||
|
||||
logRequest(() => {
|
||||
const readableInit = {
|
||||
...init,
|
||||
// Make headers easier to read in the console
|
||||
headers: new Map(init.headers),
|
||||
};
|
||||
if (init.headers.get(HEADER.contentType) === MIME_TYPE.json) {
|
||||
return [parseJsonRpcParams(init.body), readableInit];
|
||||
} else {
|
||||
return [init.body, readableInit];
|
||||
}
|
||||
});
|
||||
|
||||
if (getNetworkDelay) {
|
||||
await getNetworkDelay();
|
||||
}
|
||||
|
||||
// keep separate from 'error', as it can be null or undefined even though the
|
||||
// callback has thrown an error.
|
||||
let failed = false;
|
||||
let result;
|
||||
let error, result;
|
||||
try {
|
||||
result = await mockFetchFn(input, init);
|
||||
} catch (err) {
|
||||
result = err;
|
||||
failed = true;
|
||||
error = err;
|
||||
}
|
||||
if (isOpen(controller)) {
|
||||
markClosed(controller);
|
||||
|
|
@ -228,33 +371,34 @@ export async function mockedFetch(input, init) {
|
|||
return ENDLESS_PROMISE;
|
||||
}
|
||||
if (failed) {
|
||||
throw result;
|
||||
throw error;
|
||||
}
|
||||
|
||||
/** @type {Headers} */
|
||||
let headers;
|
||||
if (result && isInstanceOf(result.headers, Headers)) {
|
||||
headers = result.headers;
|
||||
} else if (isInstanceOf(init.headers, Headers)) {
|
||||
headers = init.headers;
|
||||
} else {
|
||||
headers = new Headers(init.headers);
|
||||
if (isInternalUrl && isNil(result)) {
|
||||
// Internal URL without mocked result: directly handled by the browser
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
let contentType = headers.get(HEADER.contentType);
|
||||
// Result can be a request or the final request value
|
||||
const responseHeaders = getHeaders(result, result);
|
||||
|
||||
if (result instanceof MockResponse) {
|
||||
// Mocked response
|
||||
logResponse(async () => {
|
||||
const textValue = getSyncValue(result);
|
||||
return contentType === MIME_TYPE.json ? JSON.parse(textValue) : textValue;
|
||||
logResponse(() => {
|
||||
const textValue = getSyncValue(result, true);
|
||||
return [
|
||||
responseHeaders.get(HEADER.contentType) === MIME_TYPE.json
|
||||
? parseJsonRpcParams(textValue)
|
||||
: textValue,
|
||||
result,
|
||||
];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (isInstanceOf(result, Response)) {
|
||||
// Actual fetch
|
||||
logResponse(() => "(go to network tab for request content)");
|
||||
logResponse(() => ["(go to network tab for request content)", result]);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -262,26 +406,28 @@ export async function mockedFetch(input, init) {
|
|||
// Determine the return type based on:
|
||||
// - the content type header
|
||||
// - or the type of the returned value
|
||||
if (!contentType) {
|
||||
if (typeof result === "string") {
|
||||
contentType = MIME_TYPE.text;
|
||||
} else if (isInstanceOf(result, Blob)) {
|
||||
contentType = MIME_TYPE.blob;
|
||||
} else {
|
||||
contentType = MIME_TYPE.json;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType === MIME_TYPE.json) {
|
||||
if (responseHeaders.get(HEADER.contentType) === MIME_TYPE.json) {
|
||||
// JSON response
|
||||
const strBody = JSON.stringify(result ?? null);
|
||||
logResponse(() => result);
|
||||
return new MockResponse(strBody, { [HEADER.contentType]: contentType });
|
||||
const strBody = $stringify(result ?? null);
|
||||
const response = new MockResponse(strBody, {
|
||||
headers: responseHeaders,
|
||||
statusText: "OK",
|
||||
type: "basic",
|
||||
url: strInput,
|
||||
});
|
||||
logResponse(() => [parseJsonRpcParams(strBody), response]);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Any other type (blob / text)
|
||||
logResponse(() => result);
|
||||
return new MockResponse(result, { [HEADER.contentType]: contentType });
|
||||
// Any other type
|
||||
const response = new MockResponse(result, {
|
||||
headers: responseHeaders,
|
||||
statusText: "OK",
|
||||
type: "basic",
|
||||
url: strInput,
|
||||
});
|
||||
logResponse(() => [result, response]);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -310,6 +456,7 @@ export async function mockedFetch(input, init) {
|
|||
* });
|
||||
*/
|
||||
export function mockFetch(fetchFn) {
|
||||
ensureTest("mockFetch");
|
||||
mockFetchFn = fetchFn;
|
||||
}
|
||||
|
||||
|
|
@ -321,6 +468,7 @@ export function mockFetch(fetchFn) {
|
|||
* @param {typeof mockWebSocketConnection} [onWebSocketConnected]
|
||||
*/
|
||||
export function mockWebSocket(onWebSocketConnected) {
|
||||
ensureTest("mockWebSocket");
|
||||
mockWebSocketConnection = onWebSocketConnected;
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +478,7 @@ export function mockWebSocket(onWebSocketConnected) {
|
|||
* (see {@link mockFetch});
|
||||
* - the `onWorkerConnected` callback will be called after a worker has been created.
|
||||
*
|
||||
* @param {typeof mockWorkerConnection} [onWorkerConnected]
|
||||
* @param {typeof mockWorkerConnections[number]} [onWorkerConnected]
|
||||
* @example
|
||||
* mockWorker((worker) => {
|
||||
* worker.addEventListener("message", (event) => {
|
||||
|
|
@ -339,7 +487,8 @@ export function mockWebSocket(onWebSocketConnected) {
|
|||
* });
|
||||
*/
|
||||
export function mockWorker(onWorkerConnected) {
|
||||
mockWorkerConnection.push(onWorkerConnected);
|
||||
ensureTest("mockWorker");
|
||||
mockWorkerConnections.push(onWorkerConnected);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -349,6 +498,42 @@ export function throttleNetwork(...args) {
|
|||
getNetworkDelay = parseNetworkDelay(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof mockFetchFn} fetchFn
|
||||
* @param {() => void} callback
|
||||
*/
|
||||
export async function withFetch(fetchFn, callback) {
|
||||
mockFetchFn = fetchFn;
|
||||
const result = await callback();
|
||||
mockFetchFn = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
export class MockBlob extends Blob {
|
||||
constructor(blobParts, options) {
|
||||
super(blobParts, options);
|
||||
|
||||
setSyncValue(this, blobParts);
|
||||
}
|
||||
|
||||
async arrayBuffer() {
|
||||
return toBytes(getSyncValue(this, true)).buffer;
|
||||
}
|
||||
|
||||
async bytes() {
|
||||
return toBytes(getSyncValue(this, true));
|
||||
}
|
||||
|
||||
async stream() {
|
||||
const value = getSyncValue(this, true);
|
||||
return isInstanceOf(value, ReadableStream) ? value : new ReadableStream(value);
|
||||
}
|
||||
|
||||
async text() {
|
||||
return getSyncValue(this, true);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockBroadcastChannel extends BroadcastChannel {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
|
@ -650,58 +835,91 @@ export class MockMessagePort extends MockEventTarget {
|
|||
}
|
||||
|
||||
export class MockRequest extends Request {
|
||||
static {
|
||||
Object.assign(this.prototype, requestResponseMixin);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RequestInfo} input
|
||||
* @param {RequestInit} [init]
|
||||
*/
|
||||
constructor(input, init) {
|
||||
super(input, init);
|
||||
super(new MockURL(input), init);
|
||||
|
||||
setSyncValue(this, init?.body ?? null);
|
||||
}
|
||||
|
||||
async arrayBuffer() {
|
||||
return new TextEncoder().encode(getSyncValue(this));
|
||||
clone() {
|
||||
const request = new this.constructor(this.url, this);
|
||||
setSyncValue(request, getSyncValue(this, false));
|
||||
return request;
|
||||
}
|
||||
|
||||
async blob() {
|
||||
return new MockBlob([getSyncValue(this)]);
|
||||
}
|
||||
|
||||
async json() {
|
||||
return JSON.parse(getSyncValue(this));
|
||||
}
|
||||
|
||||
async text() {
|
||||
return getSyncValue(this);
|
||||
/**
|
||||
* In tests, requests objects are expected to be read by multiple network handlers.
|
||||
* As such, their 'body' isn't consumed upon reading.
|
||||
*
|
||||
* @protected
|
||||
* @param {string} reader
|
||||
* @param {boolean} toStringValue
|
||||
*/
|
||||
_readValue(reader, toStringValue) {
|
||||
return getSyncValue(this, toStringValue);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockResponse extends Response {
|
||||
static {
|
||||
Object.assign(this.prototype, requestResponseMixin);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BodyInit} body
|
||||
* @param {ResponseInit} [init]
|
||||
* @param {MockResponseInit} [init]
|
||||
*/
|
||||
constructor(body, init) {
|
||||
super(body, init);
|
||||
|
||||
if (init?.type) {
|
||||
$defineProperty(this, "type", {
|
||||
value: init.type,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
if (init?.url) {
|
||||
$defineProperty(this, "url", {
|
||||
value: String(new MockURL(init.url)),
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
});
|
||||
}
|
||||
|
||||
setSyncValue(this, body ?? null);
|
||||
}
|
||||
|
||||
async arrayBuffer() {
|
||||
return new TextEncoder().encode(getSyncValue(this)).buffer;
|
||||
clone() {
|
||||
return new this.constructor(getSyncValue(this, false), this);
|
||||
}
|
||||
|
||||
async blob() {
|
||||
return new MockBlob([getSyncValue(this)]);
|
||||
}
|
||||
|
||||
async json() {
|
||||
return JSON.parse(getSyncValue(this));
|
||||
}
|
||||
|
||||
async text() {
|
||||
return getSyncValue(this);
|
||||
/**
|
||||
* Reading the 'body' of a response always consumes it, as opposed to the {@link MockRequest}
|
||||
* body.
|
||||
*
|
||||
* @protected
|
||||
* @param {string} reader
|
||||
* @param {boolean} toStringValue
|
||||
*/
|
||||
_readValue(reader, toStringValue) {
|
||||
if (this.bodyUsed) {
|
||||
throw new TypeError(
|
||||
`Failed to execute '${reader}' on '${this.constructor.name}': body stream already read`
|
||||
);
|
||||
}
|
||||
$defineProperty(this, "bodyUsed", { value: true, configurable: true, enumerable: true });
|
||||
return getSyncValue(this, toStringValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -732,7 +950,7 @@ export class MockSharedWorker extends MockEventTarget {
|
|||
// First port has to be started manually
|
||||
this._messageChannel.port2.start();
|
||||
|
||||
for (const onWorkerConnected of mockWorkerConnection) {
|
||||
for (const onWorkerConnected of mockWorkerConnections) {
|
||||
onWorkerConnected(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -745,6 +963,10 @@ export class MockURL extends URL {
|
|||
}
|
||||
|
||||
export class MockWebSocket extends MockEventTarget {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static publicListeners = ["close", "error", "message", "open"];
|
||||
|
||||
/**
|
||||
|
|
@ -788,7 +1010,7 @@ export class MockWebSocket extends MockEventTarget {
|
|||
markOpen(this);
|
||||
|
||||
this._readyState = WebSocket.OPEN;
|
||||
this._logger.logRequest(() => "connection open");
|
||||
this._logger.logRequest(() => ["connection open"]);
|
||||
|
||||
this.dispatchEvent(new Event("open"));
|
||||
});
|
||||
|
|
@ -808,7 +1030,7 @@ export class MockWebSocket extends MockEventTarget {
|
|||
if (this.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
this._logger.logRequest(() => data);
|
||||
this._logger.logRequest(() => [data]);
|
||||
dispatchMessage(this._serverWs, data);
|
||||
}
|
||||
}
|
||||
|
|
@ -839,7 +1061,7 @@ export class MockWorker extends MockEventTarget {
|
|||
this.dispatchEvent(new MessageEvent("message", { data: ev.data }));
|
||||
});
|
||||
|
||||
for (const onWorkerConnected of mockWorkerConnection) {
|
||||
for (const onWorkerConnected of mockWorkerConnections) {
|
||||
onWorkerConnected(this);
|
||||
}
|
||||
}
|
||||
|
|
@ -865,11 +1087,11 @@ export class MockWorker extends MockEventTarget {
|
|||
|
||||
export class MockXMLHttpRequest extends MockEventTarget {
|
||||
static publicListeners = ["error", "load"];
|
||||
static {
|
||||
// Assign status codes
|
||||
Object.assign(this, XMLHttpRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_headers = {};
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
|
|
@ -877,28 +1099,72 @@ export class MockXMLHttpRequest extends MockEventTarget {
|
|||
/**
|
||||
* @private
|
||||
*/
|
||||
_readyState = XMLHttpRequest.UNSENT;
|
||||
/**
|
||||
* @type {Record<string, string>}
|
||||
* @private
|
||||
*/
|
||||
_requestHeaders = Object.create(null);
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_requestUrl = "";
|
||||
/**
|
||||
* @type {Response | null}
|
||||
* @private
|
||||
*/
|
||||
_response = null;
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_status = XMLHttpRequest.UNSENT;
|
||||
_responseMimeType = "";
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_url = "";
|
||||
_responseValue = null;
|
||||
|
||||
abort() {
|
||||
markClosed(this);
|
||||
get readyState() {
|
||||
return this._readyState;
|
||||
}
|
||||
|
||||
upload = new MockXMLHttpRequestUpload();
|
||||
|
||||
get response() {
|
||||
return this._response;
|
||||
return this._responseValue;
|
||||
}
|
||||
|
||||
get responseText() {
|
||||
return String(this._responseValue);
|
||||
}
|
||||
|
||||
get responseURL() {
|
||||
return this._response.url;
|
||||
}
|
||||
|
||||
get responseXML() {
|
||||
const parser = new DOMParser();
|
||||
try {
|
||||
return parser.parseFromString(this._responseValue, this._responseMimeType);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get status() {
|
||||
return this._status;
|
||||
return this._response?.status || 0;
|
||||
}
|
||||
|
||||
get statusText() {
|
||||
return this._readyState >= XMLHttpRequest.LOADING ? "OK" : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {XMLHttpRequestResponseType}
|
||||
*/
|
||||
responseType = "";
|
||||
upload = new MockXMLHttpRequestUpload();
|
||||
|
||||
abort() {
|
||||
this._setReadyState(XMLHttpRequest.DONE);
|
||||
markClosed(this);
|
||||
}
|
||||
|
||||
/** @type {XMLHttpRequest["dispatchEvent"]} */
|
||||
|
|
@ -909,12 +1175,31 @@ export class MockXMLHttpRequest extends MockEventTarget {
|
|||
return super.dispatchEvent(event);
|
||||
}
|
||||
|
||||
getAllResponseHeaders() {
|
||||
let result = "";
|
||||
for (const [key, value] of this._response?.headers || []) {
|
||||
result += `${key}: ${value}\r\n`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @type {XMLHttpRequest["getResponseHeader"]} */
|
||||
getResponseHeader(name) {
|
||||
return this._response?.headers.get(name) || "";
|
||||
}
|
||||
|
||||
/** @type {XMLHttpRequest["open"]} */
|
||||
open(method, url) {
|
||||
markOpen(this);
|
||||
|
||||
this._method = method;
|
||||
this._url = url;
|
||||
this._requestUrl = url;
|
||||
this._setReadyState(XMLHttpRequest.OPENED);
|
||||
}
|
||||
|
||||
/** @type {XMLHttpRequest["overrideMimeType"]} */
|
||||
overrideMimeType(mime) {
|
||||
this._responseMimeType = mime;
|
||||
}
|
||||
|
||||
/** @type {XMLHttpRequest["send"]} */
|
||||
|
|
@ -922,35 +1207,56 @@ export class MockXMLHttpRequest extends MockEventTarget {
|
|||
if (!isOpen(this)) {
|
||||
return ENDLESS_PROMISE;
|
||||
}
|
||||
this._setReadyState(XMLHttpRequest.HEADERS_RECEIVED);
|
||||
|
||||
try {
|
||||
const response = await window.fetch(this._url, {
|
||||
this._response = await window.fetch(this._requestUrl, {
|
||||
method: this._method,
|
||||
body,
|
||||
headers: this._headers,
|
||||
headers: this._requestHeaders,
|
||||
});
|
||||
this._status = response.status;
|
||||
if (new URL(this._url, mockLocation.origin).protocol === "blob:") {
|
||||
this._response = await response.arrayBuffer();
|
||||
this._setReadyState(XMLHttpRequest.LOADING);
|
||||
if (!this._responseMimeType) {
|
||||
if (this._response.url.startsWith("blob:")) {
|
||||
this._responseMimeType = MIME_TYPE.blob;
|
||||
} else {
|
||||
this._responseMimeType = this._response.headers.get(HEADER.contentType);
|
||||
}
|
||||
}
|
||||
if (this._response instanceof MockResponse) {
|
||||
// Mock response: get bound value (synchronously)
|
||||
this._responseValue = getSyncValue(this._response, false);
|
||||
} else if (this._responseMimeType === MIME_TYPE.blob) {
|
||||
// Actual "blob:" response: get array buffer
|
||||
this._responseValue = await this._response.arrayBuffer();
|
||||
} else if (this._responseMimeType === MIME_TYPE.json) {
|
||||
// JSON response: get parsed JSON value
|
||||
this._responseValue = await this._response.json();
|
||||
} else {
|
||||
this._response = await response.text();
|
||||
// Anything else: parse response body as text
|
||||
this._responseValue = await this._response.text();
|
||||
}
|
||||
this.dispatchEvent(new ProgressEvent("load"));
|
||||
} catch (error) {
|
||||
this.dispatchEvent(new ProgressEvent("error", { error }));
|
||||
} catch {
|
||||
this.dispatchEvent(new ProgressEvent("error"));
|
||||
}
|
||||
|
||||
this._setReadyState(XMLHttpRequest.DONE);
|
||||
markClosed(this);
|
||||
}
|
||||
|
||||
/** @type {XMLHttpRequest["setRequestHeader"]} */
|
||||
setRequestHeader(name, value) {
|
||||
this._headers[name] = value;
|
||||
this._requestHeaders[name] = value;
|
||||
}
|
||||
|
||||
/** @type {XMLHttpRequest["getResponseHeader"]} */
|
||||
getResponseHeader(name) {
|
||||
return this._headers[name];
|
||||
/**
|
||||
* @private
|
||||
* @param {number} readyState
|
||||
*/
|
||||
_setReadyState(readyState) {
|
||||
this._readyState = readyState;
|
||||
this.dispatchEvent(new Event("readystatechange"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -989,7 +1295,6 @@ export class ServerWebSocket extends MockEventTarget {
|
|||
/**
|
||||
* @param {WebSocket} websocket
|
||||
* @param {ReturnType<typeof makeNetworkLogger>} logger
|
||||
* @param {ReturnType<typeof makeNetworkLogger>} logger
|
||||
*/
|
||||
constructor(websocket, logger) {
|
||||
super();
|
||||
|
|
@ -1001,7 +1306,7 @@ export class ServerWebSocket extends MockEventTarget {
|
|||
|
||||
this.addEventListener("close", (ev) => {
|
||||
dispatchClose(this._clientWs, ev);
|
||||
this._logger.logResponse(() => "connection closed");
|
||||
this._logger.logResponse(() => ["connection closed", ev]);
|
||||
});
|
||||
|
||||
mockWebSocketConnection?.(this);
|
||||
|
|
@ -1018,7 +1323,7 @@ export class ServerWebSocket extends MockEventTarget {
|
|||
if (!isOpen(this)) {
|
||||
return;
|
||||
}
|
||||
this._logger.logResponse(() => data);
|
||||
this._logger.logResponse(() => [data]);
|
||||
dispatchMessage(this._clientWs, data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
/** @odoo-module */
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { Blob, TextEncoder } = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const syncValues = new WeakMap();
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {any} object
|
||||
*/
|
||||
export function getSyncValue(object) {
|
||||
return syncValues.get(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} object
|
||||
* @param {any} value
|
||||
*/
|
||||
export function setSyncValue(object, value) {
|
||||
syncValues.set(object, value);
|
||||
}
|
||||
|
||||
export class MockBlob extends Blob {
|
||||
constructor(blobParts, options) {
|
||||
super(blobParts, options);
|
||||
|
||||
setSyncValue(this, blobParts);
|
||||
}
|
||||
|
||||
async arrayBuffer() {
|
||||
return new TextEncoder().encode(getSyncValue(this));
|
||||
}
|
||||
|
||||
async text() {
|
||||
return getSyncValue(this).join("");
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "@web/../lib/hoot-dom/helpers/time";
|
||||
import { interactor } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { MockEventTarget, strictEqual, waitForDocument } from "../hoot_utils";
|
||||
import { getRunner } from "../main_runner";
|
||||
import { ensureTest, getRunner } from "../main_runner";
|
||||
import {
|
||||
MockAnimation,
|
||||
mockedAnimate,
|
||||
|
|
@ -25,9 +25,11 @@ import {
|
|||
mockedWindowScrollTo,
|
||||
} from "./animation";
|
||||
import { MockConsole } from "./console";
|
||||
import { mockCrypto } from "./crypto";
|
||||
import { MockDate, MockIntl } from "./date";
|
||||
import { MockClipboardItem, mockNavigator } from "./navigator";
|
||||
import {
|
||||
MockBlob,
|
||||
MockBroadcastChannel,
|
||||
MockMessageChannel,
|
||||
MockMessagePort,
|
||||
|
|
@ -46,8 +48,6 @@ import {
|
|||
} from "./network";
|
||||
import { MockNotification } from "./notification";
|
||||
import { MockStorage } from "./storage";
|
||||
import { MockBlob } from "./sync_values";
|
||||
import { mockCrypto } from "./crypto";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
|
|
@ -61,6 +61,7 @@ const {
|
|||
Object: {
|
||||
assign: $assign,
|
||||
defineProperties: $defineProperties,
|
||||
defineProperty: $defineProperty,
|
||||
entries: $entries,
|
||||
getOwnPropertyDescriptor: $getOwnPropertyDescriptor,
|
||||
getPrototypeOf: $getPrototypeOf,
|
||||
|
|
@ -70,9 +71,11 @@ const {
|
|||
Reflect: { ownKeys: $ownKeys },
|
||||
Set,
|
||||
WeakMap,
|
||||
WeakSet,
|
||||
} = globalThis;
|
||||
|
||||
const { addEventListener, removeEventListener } = EventTarget.prototype;
|
||||
const { preventDefault } = Event.prototype;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
|
|
@ -327,6 +330,11 @@ function mockedMatchMedia(mediaQueryString) {
|
|||
return new MockMediaQueryList(mediaQueryString);
|
||||
}
|
||||
|
||||
function mockedPreventDefault() {
|
||||
preventedEvents.add(this);
|
||||
return preventDefault.call(this, ...arguments);
|
||||
}
|
||||
|
||||
/** @type {typeof removeEventListener} */
|
||||
function mockedRemoveEventListener(...args) {
|
||||
if (getRunner().dry) {
|
||||
|
|
@ -420,6 +428,18 @@ class MockMediaQueryList extends MockEventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
class MockMutationObserver extends MutationObserver {
|
||||
disconnect() {
|
||||
activeMutationObservers.delete(this);
|
||||
return super.disconnect(...arguments);
|
||||
}
|
||||
|
||||
observe() {
|
||||
activeMutationObservers.add(this);
|
||||
return super.observe(...arguments);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_MEDIA_VALUES = {
|
||||
"display-mode": "browser",
|
||||
pointer: "fine",
|
||||
|
|
@ -438,12 +458,16 @@ const R_OWL_SYNTHETIC_LISTENER = /\bnativeToSyntheticEvent\b/;
|
|||
const originalDescriptors = new WeakMap();
|
||||
const originalTouchFunctions = getTouchTargets(globalThis).map(getTouchDescriptors);
|
||||
|
||||
/** @type {Set<MockMutationObserver>} */
|
||||
const activeMutationObservers = new Set();
|
||||
/** @type {Set<MockMediaQueryList>} */
|
||||
const mediaQueryLists = new Set();
|
||||
const mockConsole = new MockConsole();
|
||||
const mockLocalStorage = new MockStorage();
|
||||
const mockMediaValues = { ...DEFAULT_MEDIA_VALUES };
|
||||
const mockSessionStorage = new MockStorage();
|
||||
/** @type {WeakSet<Event>} */
|
||||
const preventedEvents = new WeakSet();
|
||||
let mockTitle = "";
|
||||
|
||||
// Mock descriptors
|
||||
|
|
@ -493,6 +517,7 @@ const WINDOW_MOCK_DESCRIPTORS = {
|
|||
matchMedia: { value: mockedMatchMedia },
|
||||
MessageChannel: { value: MockMessageChannel },
|
||||
MessagePort: { value: MockMessagePort },
|
||||
MutationObserver: { value: MockMutationObserver },
|
||||
navigator: { value: mockNavigator },
|
||||
Notification: { value: MockNotification },
|
||||
outerHeight: { get: () => getCurrentDimensions().height },
|
||||
|
|
@ -547,6 +572,11 @@ export function cleanupWindow() {
|
|||
|
||||
// Touch
|
||||
restoreTouch(view);
|
||||
|
||||
// Mutation observers
|
||||
for (const observer of activeMutationObservers) {
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export function getTitle() {
|
||||
|
|
@ -579,19 +609,37 @@ export function getViewPortWidth() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
export function isPrevented(event) {
|
||||
return event.defaultPrevented || preventedEvents.has(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} name
|
||||
*/
|
||||
export function mockMatchMedia(values) {
|
||||
ensureTest("mockMatchMedia");
|
||||
$assign(mockMediaValues, values);
|
||||
|
||||
callMediaQueryChanges($keys(values));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
export function mockPreventDefault(event) {
|
||||
$defineProperty(event, "preventDefault", {
|
||||
value: mockedPreventDefault,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} setTouch
|
||||
*/
|
||||
export function mockTouch(setTouch) {
|
||||
ensureTest("mockTouch");
|
||||
const objects = getTouchTargets(getWindow());
|
||||
if (setTouch) {
|
||||
for (const object of objects) {
|
||||
|
|
@ -695,13 +743,13 @@ export function watchListeners(view = getWindow()) {
|
|||
* afterEach(watchKeys(window, ["odoo"]));
|
||||
*/
|
||||
export function watchKeys(target, whiteList) {
|
||||
const acceptedKeys = new Set([...$ownKeys(target), ...(whiteList || [])]);
|
||||
const acceptedKeys = new Set($ownKeys(target).concat(whiteList || []));
|
||||
|
||||
return function checkKeys() {
|
||||
const keysDiff = $ownKeys(target).filter(
|
||||
(key) => $isNaN($parseFloat(key)) && !acceptedKeys.has(key)
|
||||
);
|
||||
for (const key of keysDiff) {
|
||||
for (const key of $ownKeys(target)) {
|
||||
if (!$isNaN($parseFloat(key)) || acceptedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const descriptor = $getOwnPropertyDescriptor(target, key);
|
||||
if (descriptor.configurable) {
|
||||
delete target[key];
|
||||
|
|
|
|||
|
|
@ -228,6 +228,20 @@ describe(parseUrl(import.meta.url), () => {
|
|||
expect(testResult.events.map(({ label }) => label)).toEqual(matchers.map(([name]) => name));
|
||||
});
|
||||
|
||||
test("'expect' error handling", async () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
expect(() => customExpect(undefined).toInclude("3")).toThrow(
|
||||
"expected received value to be of type string, any[] or object, got undefined"
|
||||
);
|
||||
|
||||
const testResult = hooks.after();
|
||||
|
||||
expect(testResult.pass).toBe(false);
|
||||
});
|
||||
|
||||
test("assertions are prevented after an error", async () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
|
|
@ -414,7 +428,12 @@ describe(parseUrl(import.meta.url), () => {
|
|||
});
|
||||
|
||||
test("verifyErrors", async () => {
|
||||
expect.assertions(1);
|
||||
expect.assertions(2);
|
||||
|
||||
expect(() => expect.verifyErrors(["event", "promise", "timeout"])).toThrow(
|
||||
"cannot call `expect.verifyErrors()` without calling `expect.errors()` beforehand"
|
||||
);
|
||||
|
||||
expect.errors(3);
|
||||
|
||||
const boom = (msg) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, getFixture, test } from "@odoo/hoot";
|
||||
import {
|
||||
animationFrame,
|
||||
click,
|
||||
describe,
|
||||
expect,
|
||||
formatXml,
|
||||
getActiveElement,
|
||||
getFixture,
|
||||
getFocusableElements,
|
||||
getNextFocusableElement,
|
||||
getPreviousFocusableElement,
|
||||
|
|
@ -14,16 +16,17 @@ import {
|
|||
isFocusable,
|
||||
isInDOM,
|
||||
isVisible,
|
||||
mockTouch,
|
||||
queryAll,
|
||||
queryAllRects,
|
||||
queryAllTexts,
|
||||
queryFirst,
|
||||
queryOne,
|
||||
queryRect,
|
||||
test,
|
||||
waitFor,
|
||||
waitForNone,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { mockTouch } from "@odoo/hoot-mock";
|
||||
} from "@odoo/hoot";
|
||||
import { getParentFrame } from "@web/../lib/hoot-dom/helpers/dom";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
||||
|
|
@ -466,12 +469,16 @@ describe(parseUrl(import.meta.url), () => {
|
|||
expectSelector(".title:eq('1')").toEqualNodes(".title", { index: 1 });
|
||||
expectSelector('.title:eq("1")').toEqualNodes(".title", { index: 1 });
|
||||
|
||||
// :contains (text)
|
||||
// :contains
|
||||
expectSelector("main > .text:contains(ipsum)").toEqualNodes("p");
|
||||
expectSelector(".text:contains(/\\bL\\w+\\b\\sipsum/)").toEqualNodes("p");
|
||||
expectSelector(".text:contains(item)").toEqualNodes("li");
|
||||
|
||||
// :contains (value)
|
||||
// :text
|
||||
expectSelector(".text:text(item)").toEqualNodes("");
|
||||
expectSelector(".text:text(first item)").toEqualNodes("li:first-of-type");
|
||||
|
||||
// :value
|
||||
expectSelector("input:value(john)").toEqualNodes("[name=name],[name=email]");
|
||||
expectSelector("input:value(john doe)").toEqualNodes("[name=name]");
|
||||
expectSelector("input:value('John Doe (JOD)')").toEqualNodes("[name=name]");
|
||||
|
|
@ -493,6 +500,17 @@ describe(parseUrl(import.meta.url), () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("query options", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect($$("input", { count: 2 })).toHaveLength(2);
|
||||
expect(() => $$("input", { count: 1 })).toThrow();
|
||||
|
||||
expect($$("option", { count: 6 })).toHaveLength(6);
|
||||
expect($$("option", { count: 3, root: "[name=title]" })).toHaveLength(3);
|
||||
expect(() => $$("option", { count: 6, root: "[name=title]" })).toThrow();
|
||||
});
|
||||
|
||||
test("advanced use cases", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
|
|
@ -596,9 +614,9 @@ describe(parseUrl(import.meta.url), () => {
|
|||
<div>PA4: PAV41</div>
|
||||
</span>
|
||||
`);
|
||||
expectSelector(
|
||||
`span:contains("Matrix (PAV11, PAV22, PAV31)\nPA4: PAV41")`
|
||||
).toEqualNodes("span");
|
||||
expectSelector(`span:contains("Matrix (PAV11, PAV22, PAV31) PA4: PAV41")`).toEqualNodes(
|
||||
"span"
|
||||
);
|
||||
});
|
||||
|
||||
test(":has(...):first", async () => {
|
||||
|
|
@ -730,7 +748,7 @@ describe(parseUrl(import.meta.url), () => {
|
|||
`);
|
||||
|
||||
expectSelector(
|
||||
`.o_content:has(.o_field_widget[name=messages]):has(td:contains(/^bbb$/)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin$/))`
|
||||
`.o_content:has(.o_field_widget[name=messages]):has(td:text(bbb)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin/))`
|
||||
).toEqualNodes(".o_content");
|
||||
});
|
||||
|
||||
|
|
@ -861,7 +879,7 @@ describe(parseUrl(import.meta.url), () => {
|
|||
expect($1(".title:first")).toBe(getFixture().querySelector("header .title"));
|
||||
|
||||
expect(() => $1(".title")).toThrow();
|
||||
expect(() => $1(".title", { exact: 2 })).toThrow();
|
||||
expect(() => $1(".title", { count: 2 })).toThrow();
|
||||
});
|
||||
|
||||
test("queryRect", async () => {
|
||||
|
|
@ -899,10 +917,10 @@ describe(parseUrl(import.meta.url), () => {
|
|||
|
||||
// queryOne error messages
|
||||
expect(() => $1()).toThrow(`found 0 elements instead of 1`);
|
||||
expect(() => $$([], { exact: 18 })).toThrow(`found 0 elements instead of 18`);
|
||||
expect(() => $$([], { count: 18 })).toThrow(`found 0 elements instead of 18`);
|
||||
expect(() => $1("")).toThrow(`found 0 elements instead of 1: 0 matching ""`);
|
||||
expect(() => $$(".tralalero", { exact: -20 })).toThrow(
|
||||
`found 1 element instead of -20: 1 matching ".tralalero"`
|
||||
expect(() => $$(".tralalero", { count: -20 })).toThrow(
|
||||
`invalid 'count' option: should be a positive integer`
|
||||
);
|
||||
expect(() => $1`.tralalero:contains(Tralala):visible:scrollable:first`).toThrow(
|
||||
`found 0 elements instead of 1: 0 matching ".tralalero:contains(Tralala):visible:scrollable:first" (1 element with text "Tralala" > 1 visible element > 0 scrollable elements)`
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { after, describe, expect, getFixture, test } from "@odoo/hoot";
|
||||
import {
|
||||
advanceTime,
|
||||
after,
|
||||
animationFrame,
|
||||
clear,
|
||||
click,
|
||||
dblclick,
|
||||
describe,
|
||||
drag,
|
||||
edit,
|
||||
expect,
|
||||
fill,
|
||||
getFixture,
|
||||
hover,
|
||||
keyDown,
|
||||
keyUp,
|
||||
leave,
|
||||
middleClick,
|
||||
mockFetch,
|
||||
mockTouch,
|
||||
mockUserAgent,
|
||||
on,
|
||||
pointerDown,
|
||||
pointerUp,
|
||||
|
|
@ -26,9 +32,9 @@ import {
|
|||
select,
|
||||
setInputFiles,
|
||||
setInputRange,
|
||||
test,
|
||||
uncheck,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { mockFetch, mockTouch, mockUserAgent } from "@odoo/hoot-mock";
|
||||
} from "@odoo/hoot";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { EventList } from "@web/../lib/hoot-dom/helpers/events";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
|
@ -1230,8 +1236,8 @@ describe(parseUrl(import.meta.url), () => {
|
|||
"pointerleave:0@input",
|
||||
"mouseleave:0@input",
|
||||
// Change
|
||||
"blur@input",
|
||||
"change@input",
|
||||
"blur@input",
|
||||
"focusout@input",
|
||||
]);
|
||||
});
|
||||
|
|
@ -1428,6 +1434,24 @@ describe(parseUrl(import.meta.url), () => {
|
|||
expect("input").toHaveValue(/file\.txt/);
|
||||
});
|
||||
|
||||
test("setInputFiles: shadow root", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="container" />
|
||||
`);
|
||||
|
||||
const shadow = queryOne(".container").attachShadow({
|
||||
mode: "open",
|
||||
});
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
shadow.appendChild(input);
|
||||
|
||||
await click(".container:shadow input");
|
||||
await setInputFiles(new File([""], "file.txt"));
|
||||
|
||||
expect(".container:shadow input").toHaveValue(/file\.txt/);
|
||||
});
|
||||
|
||||
test("setInputRange: basic case and events", async () => {
|
||||
await mountForTest(/* xml */ `<input type="range" min="10" max="40" />`);
|
||||
|
||||
|
|
@ -1735,6 +1759,8 @@ describe(parseUrl(import.meta.url), () => {
|
|||
"focus@input",
|
||||
"focusin@input",
|
||||
"focusin@form",
|
||||
"keyup:Tab@input",
|
||||
"keyup:Tab@form",
|
||||
// Enter
|
||||
"keydown:Enter@input",
|
||||
"keydown:Enter@form",
|
||||
|
|
@ -1766,6 +1792,8 @@ describe(parseUrl(import.meta.url), () => {
|
|||
"focus@button",
|
||||
"focusin@button",
|
||||
"focusin@form",
|
||||
"keyup:Tab@button",
|
||||
"keyup:Tab@form",
|
||||
// Enter
|
||||
"keydown:Enter@button",
|
||||
"keydown:Enter@form",
|
||||
|
|
@ -1798,6 +1826,8 @@ describe(parseUrl(import.meta.url), () => {
|
|||
"focus@button",
|
||||
"focusin@button",
|
||||
"focusin@form",
|
||||
"keyup:Tab@button",
|
||||
"keyup:Tab@form",
|
||||
// Enter
|
||||
"keydown:Enter@button",
|
||||
"keydown:Enter@form",
|
||||
|
|
@ -1817,10 +1847,11 @@ describe(parseUrl(import.meta.url), () => {
|
|||
</form>
|
||||
`);
|
||||
|
||||
mockFetch((url, { body, method }) => {
|
||||
mockFetch((url, { body, headers, method }) => {
|
||||
expect.step(new URL(url).pathname);
|
||||
|
||||
expect(method).toBe("post");
|
||||
expect(method).toBe("POST");
|
||||
expect(headers).toEqual(new Headers([["Content-Type", "multipart/form-data"]]));
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect(body.get("csrf_token")).toBe("CSRF_TOKEN_VALUE");
|
||||
expect(body.get("name")).toBe("Pierre");
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
Deferred,
|
||||
advanceTime,
|
||||
animationFrame,
|
||||
describe,
|
||||
expect,
|
||||
microTick,
|
||||
runAllTimers,
|
||||
test,
|
||||
tick,
|
||||
waitUntil,
|
||||
} from "@odoo/hoot-dom";
|
||||
} from "@odoo/hoot";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
// timeout of 1 second to ensure all timeouts are actually mocked
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { describe, expect, test } from "@odoo/hoot";
|
|||
import { queryOne } from "@odoo/hoot-dom";
|
||||
import { isInstanceOf, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import {
|
||||
deepCopy,
|
||||
deepEqual,
|
||||
formatHumanReadable,
|
||||
formatTechnical,
|
||||
|
|
@ -12,16 +13,30 @@ import {
|
|||
lookup,
|
||||
match,
|
||||
parseQuery,
|
||||
S_CIRCULAR,
|
||||
title,
|
||||
toExplicitString,
|
||||
} from "../hoot_utils";
|
||||
import { mountForTest, parseUrl } from "./local_helpers";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("deepEqual", () => {
|
||||
const recursive = {};
|
||||
recursive.self = recursive;
|
||||
const recursive = {};
|
||||
recursive.self = recursive;
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("deepCopy", () => {
|
||||
expect(deepCopy(true)).toEqual(true);
|
||||
expect(deepCopy(false)).toEqual(false);
|
||||
expect(deepCopy(null)).toEqual(null);
|
||||
expect(deepCopy(recursive)).toEqual({ self: S_CIRCULAR });
|
||||
expect(deepCopy(new Date(0))).toEqual(new Date(0));
|
||||
expect(deepCopy({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
|
||||
expect(deepCopy({ o: { a: [{ b: 1 }] } })).toEqual({ o: { a: [{ b: 1 }] } });
|
||||
expect(deepCopy(Symbol.for("a"))).toEqual(Symbol.for("a"));
|
||||
expect(deepCopy(document.createElement("div"))).toEqual(document.createElement("div"));
|
||||
expect(deepCopy([1, 2, 3])).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("deepEqual", () => {
|
||||
const TRUTHY_CASES = [
|
||||
[true, true],
|
||||
[false, false],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
{
|
||||
"imports": {
|
||||
"@odoo/hoot-dom": "/web/static/lib/hoot-dom/hoot-dom.js",
|
||||
"@odoo/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
|
||||
"@odoo/hoot": "/web/static/lib/hoot/hoot.js",
|
||||
"@odoo/owl": "/web/static/lib/hoot/tests/hoot-owl-module.js",
|
||||
"@web/../lib/hoot-dom/helpers/dom": "/web/static/lib/hoot-dom/helpers/dom.js",
|
||||
|
|
@ -34,7 +33,6 @@
|
|||
"/web/static/lib/hoot/core/test": "/web/static/lib/hoot/core/test.js",
|
||||
"/web/static/lib/hoot/core/url": "/web/static/lib/hoot/core/url.js",
|
||||
"/web/static/lib/hoot/hoot_utils": "/web/static/lib/hoot/hoot_utils.js",
|
||||
"/web/static/lib/hoot/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
|
||||
"/web/static/lib/hoot/hoot": "/web/static/lib/hoot/hoot.js",
|
||||
"/web/static/lib/hoot/lib/diff_match_patch": "/web/static/lib/hoot/lib/diff_match_patch.js",
|
||||
"/web/static/lib/hoot/main_runner": "/web/static/lib/hoot/main_runner.js",
|
||||
|
|
@ -47,7 +45,6 @@
|
|||
"/web/static/lib/hoot/mock/network": "/web/static/lib/hoot/mock/network.js",
|
||||
"/web/static/lib/hoot/mock/notification": "/web/static/lib/hoot/mock/notification.js",
|
||||
"/web/static/lib/hoot/mock/storage": "/web/static/lib/hoot/mock/storage.js",
|
||||
"/web/static/lib/hoot/mock/sync_values": "/web/static/lib/hoot/mock/sync_values.js",
|
||||
"/web/static/lib/hoot/mock/window": "/web/static/lib/hoot/mock/window.js",
|
||||
"/web/static/lib/hoot/tests/local_helpers": "/web/static/lib/hoot/tests/local_helpers.js",
|
||||
"/web/static/lib/hoot/ui/hoot_buttons": "/web/static/lib/hoot/ui/hoot_buttons.js",
|
||||
|
|
@ -85,7 +82,6 @@
|
|||
|
||||
<!-- Test assets -->
|
||||
<script src="/web/static/lib/owl/owl.js"></script>
|
||||
<script src="../hoot.js" type="module" defer></script>
|
||||
<link rel="stylesheet" href="/web/static/lib/hoot/ui/hoot_style.css" />
|
||||
<link rel="stylesheet" href="/web/static/src/libs/fontawesome/css/font-awesome.css" />
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { mockSendBeacon, mockTouch, mockVibrate } from "@odoo/hoot-mock";
|
||||
import { describe, expect, mockSendBeacon, mockTouch, mockVibrate, test } from "@odoo/hoot";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { mockFetch } from "@odoo/hoot-mock";
|
||||
import { after, describe, expect, mockFetch, test } from "@odoo/hoot";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
/**
|
||||
* @param {Blob | MediaSource} obj
|
||||
*/
|
||||
function createObjectURL(obj) {
|
||||
const url = URL.createObjectURL(obj);
|
||||
after(() => URL.revokeObjectURL(url));
|
||||
return url;
|
||||
}
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("setup network values", async () => {
|
||||
expect(document.cookie).toBe("");
|
||||
|
|
@ -20,13 +28,170 @@ describe(parseUrl(import.meta.url), () => {
|
|||
expect(document.title).toBe("");
|
||||
});
|
||||
|
||||
test("fetch should not mock internal URLs", async () => {
|
||||
test("fetch with internal URLs works without mocking fetch", async () => {
|
||||
const blob = new Blob([JSON.stringify({ name: "coucou" })], {
|
||||
type: "application/json",
|
||||
});
|
||||
const blobUrl = createObjectURL(blob);
|
||||
const blobResponse = await fetch(blobUrl).then((res) => res.json());
|
||||
const dataResponse = await fetch("data:text/html,<body></body>").then((res) => res.text());
|
||||
|
||||
expect(blobResponse).toEqual({ name: "coucou" });
|
||||
expect(dataResponse).toBe("<body></body>");
|
||||
|
||||
await expect(fetch("http://some.url")).rejects.toThrow(/fetch is not mocked/);
|
||||
});
|
||||
|
||||
test("fetch with internal URLs should return default value", async () => {
|
||||
mockFetch(expect.step);
|
||||
|
||||
await fetch("http://some.url");
|
||||
await fetch("/odoo");
|
||||
await fetch(URL.createObjectURL(new Blob([""])));
|
||||
const external = await fetch("http://some.url").then((res) => res.text());
|
||||
const internal = await fetch("/odoo").then((res) => res.text());
|
||||
const data = await fetch("data:text/html,<body></body>").then((res) => res.text());
|
||||
|
||||
expect.verifySteps(["http://some.url", "/odoo"]);
|
||||
expect(external).toBe("null");
|
||||
expect(internal).toBe("null");
|
||||
expect(data).toBe("<body></body>");
|
||||
|
||||
expect.verifySteps(["http://some.url", "/odoo", "data:text/html,<body></body>"]);
|
||||
});
|
||||
|
||||
test("fetch JSON with blob URLs", async () => {
|
||||
mockFetch(expect.step);
|
||||
|
||||
const blob = new Blob([JSON.stringify({ name: "coucou" })], {
|
||||
type: "application/json",
|
||||
});
|
||||
const blobUrl = createObjectURL(blob);
|
||||
const response = await fetch(blobUrl);
|
||||
const json = await response.json();
|
||||
|
||||
expect(json).toEqual({ name: "coucou" });
|
||||
|
||||
expect.verifySteps([blobUrl]);
|
||||
});
|
||||
|
||||
test("fetch with mocked blob URLs", async () => {
|
||||
mockFetch((input) => {
|
||||
expect.step(input);
|
||||
return "Some other content";
|
||||
});
|
||||
|
||||
const blob = new Blob([JSON.stringify({ name: "coucou" })], {
|
||||
type: "application/json",
|
||||
});
|
||||
const blobUrl = createObjectURL(blob);
|
||||
const response = await fetch(blobUrl);
|
||||
|
||||
expect(response.headers).toEqual(new Headers([["Content-Type", "text/plain"]]));
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
expect(text).toBe("Some other content");
|
||||
|
||||
expect.verifySteps([blobUrl]);
|
||||
});
|
||||
|
||||
test("mock response with nested blobs", async () => {
|
||||
mockFetch(
|
||||
() =>
|
||||
new Blob(["some blob", new Blob([" with nested content"], { type: "text/plain" })])
|
||||
);
|
||||
|
||||
const response = await fetch("/nestedBlob");
|
||||
const blob = await response.blob();
|
||||
const result = await blob.text();
|
||||
|
||||
expect(result).toBe("some blob with nested content");
|
||||
});
|
||||
|
||||
test("mock responses: array buffer", async () => {
|
||||
mockFetch(() => "some text");
|
||||
|
||||
const response = await fetch("/arrayBuffer");
|
||||
const result = await response.arrayBuffer();
|
||||
|
||||
expect(result).toBeInstanceOf(ArrayBuffer);
|
||||
expect(new TextDecoder("utf-8").decode(result)).toBe("some text");
|
||||
});
|
||||
|
||||
test("mock responses: blob", async () => {
|
||||
mockFetch(() => "blob content");
|
||||
|
||||
const response = await fetch("/blob");
|
||||
const result = await response.blob();
|
||||
|
||||
expect(result).toBeInstanceOf(Blob);
|
||||
expect(result.size).toBe(12);
|
||||
|
||||
const buffer = await result.arrayBuffer();
|
||||
expect(new TextDecoder("utf-8").decode(buffer)).toBe("blob content");
|
||||
});
|
||||
|
||||
test("mock responses: bytes", async () => {
|
||||
mockFetch(() => "some text");
|
||||
|
||||
const response = await fetch("/bytes");
|
||||
const result = await response.bytes();
|
||||
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(new TextDecoder("utf-8").decode(result)).toBe("some text");
|
||||
});
|
||||
|
||||
test("mock responses: formData", async () => {
|
||||
mockFetch(() => {
|
||||
const data = new FormData();
|
||||
data.append("name", "Frodo");
|
||||
return data;
|
||||
});
|
||||
|
||||
const response = await fetch("/formData");
|
||||
const result = await response.formData();
|
||||
|
||||
expect(result).toBeInstanceOf(FormData);
|
||||
expect(result.get("name")).toBe("Frodo");
|
||||
});
|
||||
|
||||
test("mock responses: json", async () => {
|
||||
mockFetch(() => ({ json: "content" }));
|
||||
|
||||
const response = await fetch("/json");
|
||||
const result = await response.json();
|
||||
|
||||
expect(result).toEqual({ json: "content" });
|
||||
});
|
||||
|
||||
test("mock responses: text", async () => {
|
||||
mockFetch(() => "some text");
|
||||
|
||||
const response = await fetch("/text");
|
||||
const result = await response.text();
|
||||
|
||||
expect(result).toBe("some text");
|
||||
});
|
||||
|
||||
test("mock responses: error handling after reading body", async () => {
|
||||
mockFetch(() => "some text");
|
||||
|
||||
const response = await fetch("/text");
|
||||
const responseClone = response.clone();
|
||||
const result = await response.text(); // read once
|
||||
|
||||
expect(result).toBe("some text");
|
||||
|
||||
// Rejects for every reader after body is used
|
||||
await expect(response.arrayBuffer()).rejects.toThrow(TypeError);
|
||||
await expect(response.blob()).rejects.toThrow(TypeError);
|
||||
await expect(response.bytes()).rejects.toThrow(TypeError);
|
||||
await expect(response.formData()).rejects.toThrow(TypeError);
|
||||
await expect(response.json()).rejects.toThrow(TypeError);
|
||||
await expect(response.text()).rejects.toThrow(TypeError);
|
||||
|
||||
const cloneResult = await responseClone.text(); // read clone
|
||||
|
||||
expect(cloneResult).toBe(result);
|
||||
|
||||
// Clone rejects reader as well
|
||||
await expect(responseClone.text()).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { after, describe, expect, test, watchListeners } from "@odoo/hoot";
|
||||
import { queryOne } from "@odoo/hoot-dom";
|
||||
import { EventBus } from "@odoo/owl";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
import { watchListeners } from "@odoo/hoot-mock";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
class TestBus extends EventBus {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ export class HootButtons extends Component {
|
|||
<t t-set="isRunning" t-value="runnerState.status === 'running'" />
|
||||
<t t-set="showAll" t-value="env.runner.hasRemovableFilter" />
|
||||
<t t-set="showFailed" t-value="runnerState.failedIds.size" />
|
||||
<t t-set="failedSuites" t-value="getFailedSuiteIds()" />
|
||||
<div
|
||||
class="${HootButtons.name} relative"
|
||||
t-on-pointerenter="onPointerEnter"
|
||||
|
|
@ -69,29 +68,35 @@ export class HootButtons extends Component {
|
|||
</div>
|
||||
<t t-if="state.open">
|
||||
<div
|
||||
class="animate-slide-down w-fit absolute flex flex-col end-0 shadow rounded overflow-hidden shadow z-2"
|
||||
class="
|
||||
w-fit absolute animate-slide-down
|
||||
flex flex-col end-0
|
||||
bg-base text-base shadow rounded z-2"
|
||||
>
|
||||
<t t-if="showAll">
|
||||
<HootLink class="'bg-btn p-2 whitespace-nowrap transition-colors'">
|
||||
Run <strong>all</strong> tests
|
||||
<HootLink
|
||||
class="'p-3 whitespace-nowrap transition-colors hover:bg-gray-300 dark:hover:bg-gray-700'"
|
||||
title="'Run all tests'"
|
||||
>
|
||||
Run <strong class="text-primary">all</strong> tests
|
||||
</HootLink>
|
||||
</t>
|
||||
<t t-if="showFailed">
|
||||
<HootLink
|
||||
ids="{ id: runnerState.failedIds }"
|
||||
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
|
||||
class="'p-3 whitespace-nowrap transition-colors hover:bg-gray-300 dark:hover:bg-gray-700'"
|
||||
title="'Run failed tests'"
|
||||
ids="{ id: runnerState.failedIds }"
|
||||
onClick="onRunFailedClick"
|
||||
>
|
||||
Run failed <strong>tests</strong>
|
||||
Run <strong class="text-rose">failed</strong> tests
|
||||
</HootLink>
|
||||
<HootLink
|
||||
ids="{ id: failedSuites }"
|
||||
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
|
||||
class="'p-3 whitespace-nowrap transition-colors hover:bg-gray-300 dark:hover:bg-gray-700'"
|
||||
title="'Run failed suites'"
|
||||
ids="{ id: getFailedSuiteIds() }"
|
||||
onClick="onRunFailedClick"
|
||||
>
|
||||
Run failed <strong>suites</strong>
|
||||
Run <strong class="text-rose">failed</strong> suites
|
||||
</HootLink>
|
||||
</t>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export class HootConfigMenu extends Component {
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
class="appearance-none border border-primary rounded-xs w-4 h-4"
|
||||
t-model="config.manual"
|
||||
/>
|
||||
<span>Run tests manually</span>
|
||||
|
|
@ -129,7 +129,7 @@ export class HootConfigMenu extends Component {
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
class="appearance-none border border-primary rounded-xs w-4 h-4"
|
||||
t-att-checked="config.bail"
|
||||
t-on-change="onBailChange"
|
||||
/>
|
||||
|
|
@ -152,7 +152,7 @@ export class HootConfigMenu extends Component {
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
class="appearance-none border border-primary rounded-xs w-4 h-4"
|
||||
t-att-checked="config.loglevel"
|
||||
t-on-change="onLogLevelChange"
|
||||
/>
|
||||
|
|
@ -181,7 +181,7 @@ export class HootConfigMenu extends Component {
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
class="appearance-none border border-primary rounded-xs w-4 h-4"
|
||||
t-model="config.notrycatch"
|
||||
/>
|
||||
<span>No try/catch</span>
|
||||
|
|
@ -232,7 +232,7 @@ export class HootConfigMenu extends Component {
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
class="appearance-none border border-primary rounded-xs w-4 h-4"
|
||||
t-model="config.headless"
|
||||
/>
|
||||
<span>Headless</span>
|
||||
|
|
@ -243,7 +243,7 @@ export class HootConfigMenu extends Component {
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="appearance-none border border-primary rounded-sm w-4 h-4"
|
||||
class="appearance-none border border-primary rounded-xs w-4 h-4"
|
||||
t-model="config.fun"
|
||||
/>
|
||||
<span>Enable incentives</span>
|
||||
|
|
|
|||
|
|
@ -154,6 +154,34 @@ export class HootReporting extends Component {
|
|||
</t>.
|
||||
</em>
|
||||
</t>
|
||||
<t t-elif="!runnerReporting.tests">
|
||||
<div class="flex flex-col gap-3 p-5 rounded bg-gray-200 dark:bg-gray-800">
|
||||
<h3 class="border-b border-gray pb-1">
|
||||
Test runner is ready
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<t t-if="config.manual">
|
||||
<button
|
||||
class="bg-btn px-2 py-1 transition-colors rounded"
|
||||
t-on-click="onRunClick"
|
||||
>
|
||||
<strong>Start</strong>
|
||||
</button>
|
||||
or press
|
||||
<kbd class="px-2 py-1 rounded text-primary bg-gray-300 dark:bg-gray-700">
|
||||
Enter
|
||||
</kbd>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Waiting for assets
|
||||
<div
|
||||
class="animate-spin shrink-0 grow-0 w-4 h-4 border-2 border-primary border-t-transparent rounded-full"
|
||||
role="status"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="flex flex-col gap-3 p-5 rounded bg-gray-200 dark:bg-gray-800">
|
||||
<h3 class="border-b border-gray pb-1">
|
||||
|
|
@ -350,6 +378,10 @@ export class HootReporting extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
onRunClick() {
|
||||
this.env.runner.manualStart();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
* @param {string} id
|
||||
|
|
|
|||
|
|
@ -339,7 +339,7 @@ export class HootSearch extends Component {
|
|||
<input
|
||||
type="search"
|
||||
class="w-full rounded p-1 outline-none"
|
||||
autofocus="autofocus"
|
||||
t-att-autofocus="!config.manual"
|
||||
placeholder="Filter suites, tests or tags"
|
||||
t-ref="search-input"
|
||||
t-att-class="{ 'text-gray': !config.filter }"
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ export class HootStatusPanel extends Component {
|
|||
static props = {};
|
||||
|
||||
static template = xml`
|
||||
<div class="${HootStatusPanel.name} flex items-center justify-between gap-3 px-3 py-1 bg-gray-300 dark:bg-gray-700" t-att-class="state.className">
|
||||
<div
|
||||
class="${HootStatusPanel.name} flex items-center justify-between gap-3 px-3 py-1 min-h-10 bg-gray-300 dark:bg-gray-700"
|
||||
t-att-class="state.className"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<t t-if="runnerState.status === 'ready'">
|
||||
Ready
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
|
||||
--hoot-spacing: 0.25rem;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
|
|
@ -116,7 +118,7 @@ ul {
|
|||
.hoot-controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
|
||||
.hoot-dropdown {
|
||||
|
|
@ -775,14 +777,14 @@ input[type="checkbox"]:checked {
|
|||
inset-inline-end: 0;
|
||||
}
|
||||
.end-2 {
|
||||
inset-inline-end: 0.5rem;
|
||||
inset-inline-end: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
|
||||
.top-0 {
|
||||
top: 0;
|
||||
}
|
||||
.top-2 {
|
||||
top: 0.5rem;
|
||||
top: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
|
||||
.bottom-0 {
|
||||
|
|
@ -826,19 +828,22 @@ input[type="checkbox"]:checked {
|
|||
width: 0;
|
||||
}
|
||||
.w-1 {
|
||||
width: 0.25rem;
|
||||
width: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.w-2 {
|
||||
width: 0.5rem;
|
||||
width: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
width: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
width: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.w-5 {
|
||||
width: 1.25rem;
|
||||
width: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
.w-64 {
|
||||
width: calc(var(--hoot-spacing) * 64);
|
||||
}
|
||||
.w-fit {
|
||||
width: fit-content;
|
||||
|
|
@ -847,27 +852,23 @@ input[type="checkbox"]:checked {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.w-64 {
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
.min-w-1 {
|
||||
min-width: 0.25rem;
|
||||
min-width: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.min-w-2 {
|
||||
min-width: 0.5rem;
|
||||
min-width: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.min-w-3 {
|
||||
min-width: 0.75rem;
|
||||
min-width: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.min-w-4 {
|
||||
min-width: 1rem;
|
||||
min-width: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.min-w-5 {
|
||||
min-width: 1.25rem;
|
||||
min-width: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
.min-w-fit {
|
||||
min-width: fit-content;
|
||||
|
|
@ -880,19 +881,19 @@ input[type="checkbox"]:checked {
|
|||
max-width: 0;
|
||||
}
|
||||
.max-w-1 {
|
||||
max-width: 0.25rem;
|
||||
max-width: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.max-w-2 {
|
||||
max-width: 0.5rem;
|
||||
max-width: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.max-w-3 {
|
||||
max-width: 0.75rem;
|
||||
max-width: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.max-w-4 {
|
||||
max-width: 1rem;
|
||||
max-width: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.max-w-5 {
|
||||
max-width: 1.25rem;
|
||||
max-width: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
.max-w-full {
|
||||
max-width: 100%;
|
||||
|
|
@ -902,19 +903,22 @@ input[type="checkbox"]:checked {
|
|||
height: 0;
|
||||
}
|
||||
.h-1 {
|
||||
height: 0.25rem;
|
||||
height: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.h-2 {
|
||||
height: 0.5rem;
|
||||
height: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.h-3 {
|
||||
height: 0.75rem;
|
||||
height: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
height: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.h-5 {
|
||||
height: 1.25rem;
|
||||
height: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
.h-7 {
|
||||
height: calc(var(--hoot-spacing) * 7);
|
||||
}
|
||||
.h-fit {
|
||||
height: fit-content;
|
||||
|
|
@ -923,27 +927,26 @@ input[type="checkbox"]:checked {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.h-7 {
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.min-h-0 {
|
||||
min-height: 0;
|
||||
}
|
||||
.min-h-1 {
|
||||
min-height: 0.25rem;
|
||||
min-height: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.min-h-2 {
|
||||
min-height: 0.5rem;
|
||||
min-height: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.min-h-3 {
|
||||
min-height: 0.75rem;
|
||||
min-height: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.min-h-4 {
|
||||
min-height: 1rem;
|
||||
min-height: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.min-h-5 {
|
||||
min-height: 1.25rem;
|
||||
min-height: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
.min-h-10 {
|
||||
min-height: calc(var(--hoot-spacing) * 10);
|
||||
}
|
||||
.min-h-full {
|
||||
min-height: 100%;
|
||||
|
|
@ -953,22 +956,22 @@ input[type="checkbox"]:checked {
|
|||
max-height: 0;
|
||||
}
|
||||
.max-h-1 {
|
||||
max-height: 0.25rem;
|
||||
max-height: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.max-h-2 {
|
||||
max-height: 0.5rem;
|
||||
max-height: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.max-h-3 {
|
||||
max-height: 0.75rem;
|
||||
max-height: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.max-h-4 {
|
||||
max-height: 1rem;
|
||||
max-height: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.max-h-5 {
|
||||
max-height: 1.25rem;
|
||||
max-height: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
.max-h-48 {
|
||||
max-height: 12rem;
|
||||
max-height: calc(var(--hoot-spacing) * 48);
|
||||
}
|
||||
.max-h-full {
|
||||
max-height: 100%;
|
||||
|
|
@ -983,48 +986,48 @@ input[type="checkbox"]:checked {
|
|||
gap: 1px;
|
||||
}
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
gap: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
gap: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.gap-3 {
|
||||
gap: 0.75rem;
|
||||
gap: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
gap: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.gap-x-0 {
|
||||
column-gap: 0;
|
||||
}
|
||||
.gap-x-1 {
|
||||
column-gap: 0.25rem;
|
||||
column-gap: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.gap-x-2 {
|
||||
column-gap: 0.5rem;
|
||||
column-gap: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.gap-x-3 {
|
||||
column-gap: 0.75rem;
|
||||
column-gap: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.gap-x-4 {
|
||||
column-gap: 1rem;
|
||||
column-gap: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.gap-y-0 {
|
||||
row-gap: 0;
|
||||
}
|
||||
.gap-y-1 {
|
||||
row-gap: 0.25rem;
|
||||
row-gap: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.gap-y-2 {
|
||||
row-gap: 0.5rem;
|
||||
row-gap: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.gap-y-3 {
|
||||
row-gap: 0.75rem;
|
||||
row-gap: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.gap-y-4 {
|
||||
row-gap: 1rem;
|
||||
row-gap: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
/* Spacing: margin */
|
||||
|
|
@ -1033,16 +1036,16 @@ input[type="checkbox"]:checked {
|
|||
margin: 0;
|
||||
}
|
||||
.m-1 {
|
||||
margin: 0.25rem;
|
||||
margin: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.m-2 {
|
||||
margin: 0.5rem;
|
||||
margin: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.m-3 {
|
||||
margin: 0.75rem;
|
||||
margin: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.m-4 {
|
||||
margin: 1rem;
|
||||
margin: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.mx-0 {
|
||||
|
|
@ -1050,20 +1053,20 @@ input[type="checkbox"]:checked {
|
|||
margin-right: 0;
|
||||
}
|
||||
.mx-1 {
|
||||
margin-left: 0.25rem;
|
||||
margin-right: 0.25rem;
|
||||
margin-left: calc(var(--hoot-spacing) * 1);
|
||||
margin-right: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.mx-2 {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-left: calc(var(--hoot-spacing) * 2);
|
||||
margin-right: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.mx-3 {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
margin-left: calc(var(--hoot-spacing) * 3);
|
||||
margin-right: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.mx-4 {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
margin-left: calc(var(--hoot-spacing) * 4);
|
||||
margin-right: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.my-0 {
|
||||
|
|
@ -1071,36 +1074,36 @@ input[type="checkbox"]:checked {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
.my-1 {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 1);
|
||||
margin-bottom: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.my-2 {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 2);
|
||||
margin-bottom: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.my-3 {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 3);
|
||||
margin-bottom: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.my-3 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 4);
|
||||
margin-bottom: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.ms-0 {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.ms-1 {
|
||||
margin-inline-start: 0.25rem;
|
||||
margin-inline-start: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.ms-2 {
|
||||
margin-inline-start: 0.5rem;
|
||||
margin-inline-start: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.ms-3 {
|
||||
margin-inline-start: 0.75rem;
|
||||
margin-inline-start: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.ms-4 {
|
||||
margin-inline-start: 1rem;
|
||||
margin-inline-start: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.ms-auto {
|
||||
margin-inline-start: auto;
|
||||
|
|
@ -1110,16 +1113,16 @@ input[type="checkbox"]:checked {
|
|||
margin-inline-end: 0;
|
||||
}
|
||||
.me-1 {
|
||||
margin-inline-end: 0.25rem;
|
||||
margin-inline-end: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.me-2 {
|
||||
margin-inline-end: 0.5rem;
|
||||
margin-inline-end: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.me-3 {
|
||||
margin-inline-end: 0.75rem;
|
||||
margin-inline-end: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.me-4 {
|
||||
margin-inline-end: 1rem;
|
||||
margin-inline-end: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.me-auto {
|
||||
margin-inline-end: auto;
|
||||
|
|
@ -1129,32 +1132,32 @@ input[type="checkbox"]:checked {
|
|||
margin-top: 0;
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.mt-3 {
|
||||
margin-top: 0.75rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
margin-top: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
margin-bottom: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.mb-3 {
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
/* Spacing: padding */
|
||||
|
|
@ -1166,19 +1169,19 @@ input[type="checkbox"]:checked {
|
|||
padding: 1px;
|
||||
}
|
||||
.p-1 {
|
||||
padding: 0.25rem;
|
||||
padding: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
padding: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
padding: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
padding: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.p-5 {
|
||||
padding: 1.25rem;
|
||||
padding: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
|
||||
.px-0 {
|
||||
|
|
@ -1186,20 +1189,20 @@ input[type="checkbox"]:checked {
|
|||
padding-right: 0;
|
||||
}
|
||||
.px-1 {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
padding-left: calc(var(--hoot-spacing) * 1);
|
||||
padding-right: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: calc(var(--hoot-spacing) * 2);
|
||||
padding-right: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.px-3 {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
padding-left: calc(var(--hoot-spacing) * 3);
|
||||
padding-right: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-left: calc(var(--hoot-spacing) * 4);
|
||||
padding-right: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.py-0 {
|
||||
|
|
@ -1207,84 +1210,84 @@ input[type="checkbox"]:checked {
|
|||
padding-bottom: 0;
|
||||
}
|
||||
.py-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 1);
|
||||
padding-bottom: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 2);
|
||||
padding-bottom: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.py-3 {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 3);
|
||||
padding-bottom: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 4);
|
||||
padding-bottom: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.ps-0 {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
.ps-1 {
|
||||
padding-inline-start: 0.25rem;
|
||||
padding-inline-start: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.ps-2 {
|
||||
padding-inline-start: 0.5rem;
|
||||
padding-inline-start: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.ps-3 {
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-start: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.ps-4 {
|
||||
padding-inline-start: 1rem;
|
||||
padding-inline-start: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.pe-0 {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
.pe-1 {
|
||||
padding-inline-end: 0.25rem;
|
||||
padding-inline-end: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.pe-2 {
|
||||
padding-inline-end: 0.5rem;
|
||||
padding-inline-end: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.pe-3 {
|
||||
padding-inline-end: 0.75rem;
|
||||
padding-inline-end: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.pe-4 {
|
||||
padding-inline-end: 1rem;
|
||||
padding-inline-end: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.pt-0 {
|
||||
padding-top: 0;
|
||||
}
|
||||
.pt-1 {
|
||||
padding-top: 0.25rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.pt-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.pt-3 {
|
||||
padding-top: 0.75rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
padding-top: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
.pb-0 {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.pb-1 {
|
||||
padding-bottom: 0.25rem;
|
||||
padding-bottom: calc(var(--hoot-spacing) * 1);
|
||||
}
|
||||
.pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
padding-bottom: calc(var(--hoot-spacing) * 2);
|
||||
}
|
||||
.pb-3 {
|
||||
padding-bottom: 0.75rem;
|
||||
padding-bottom: calc(var(--hoot-spacing) * 3);
|
||||
}
|
||||
.pb-4 {
|
||||
padding-bottom: 1rem;
|
||||
padding-bottom: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
|
||||
/* Text: alignment */
|
||||
|
|
@ -1472,15 +1475,15 @@ input[type="checkbox"]:checked {
|
|||
|
||||
.text-xs {
|
||||
font-size: 0.625rem;
|
||||
line-height: 1rem;
|
||||
line-height: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: calc(var(--hoot-spacing) * 5);
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
line-height: calc(var(--hoot-spacing) * 7);
|
||||
}
|
||||
|
||||
/* Transform: rotate */
|
||||
|
|
@ -1566,8 +1569,8 @@ input[type="checkbox"]:checked {
|
|||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.sm\:px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-left: calc(var(--hoot-spacing) * 4);
|
||||
padding-right: calc(var(--hoot-spacing) * 4);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1596,7 +1599,7 @@ input[type="checkbox"]:checked {
|
|||
@media (max-width: 640px) {
|
||||
.hoot-controls {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
gap: var(--hoot-spacing);
|
||||
grid-template: 1fr auto auto / 1fr auto auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import {
|
|||
useState,
|
||||
} from "@odoo/owl";
|
||||
import { isNode, toSelector } from "@web/../lib/hoot-dom/helpers/dom";
|
||||
import { isInstanceOf, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import { isInstanceOf, isIterable, isPromise } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import { logger } from "../core/logger";
|
||||
import {
|
||||
getTypeOf,
|
||||
isSafe,
|
||||
Markup,
|
||||
S_ANY,
|
||||
S_CIRCULAR,
|
||||
S_NONE,
|
||||
stringify,
|
||||
toExplicitString,
|
||||
|
|
@ -96,7 +97,7 @@ export class HootTechnicalValue extends Component {
|
|||
<t>/></t>
|
||||
</button>
|
||||
</t>
|
||||
<t t-elif="value === S_ANY or value === S_NONE">
|
||||
<t t-elif="SPECIAL_SYMBOLS.includes(value)">
|
||||
<span class="italic">
|
||||
<<t t-esc="symbolValue(value)" />>
|
||||
</span>
|
||||
|
|
@ -175,8 +176,7 @@ export class HootTechnicalValue extends Component {
|
|||
stringify = stringify;
|
||||
toSelector = toSelector;
|
||||
|
||||
S_ANY = S_ANY;
|
||||
S_NONE = S_NONE;
|
||||
SPECIAL_SYMBOLS = [S_ANY, S_CIRCULAR, S_NONE];
|
||||
|
||||
get explicitValue() {
|
||||
return toExplicitString(this.value);
|
||||
|
|
@ -249,7 +249,7 @@ export class HootTechnicalValue extends Component {
|
|||
}
|
||||
|
||||
wrapPromiseValue(promise) {
|
||||
if (!isInstanceOf(promise, Promise)) {
|
||||
if (!isPromise(promise)) {
|
||||
return;
|
||||
}
|
||||
this.state.promiseState = ["pending", null];
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ function stackTemplate(label, owner) {
|
|||
`;
|
||||
}
|
||||
|
||||
const DOC_URL = `https://www.odoo.com/documentation/18.0/developer/reference/frontend/unit_testing/hoot.html#`;
|
||||
|
||||
const ERROR_TEMPLATE = /* xml */ `
|
||||
<div class="text-rose flex items-center gap-1 px-2 truncate">
|
||||
<i class="fa fa-exclamation" />
|
||||
|
|
@ -114,8 +116,12 @@ const EVENT_TEMPLATE = /* xml */ `
|
|||
<t t-else="">
|
||||
<i class="fa" t-att-class="eventIcon" />
|
||||
</t>
|
||||
<!-- TODO: add documentation links once they exist -->
|
||||
<a href="#" class="hover:text-primary flex gap-1 items-center" t-att-class="{ 'text-cyan': sType === 'assertion' }">
|
||||
<a
|
||||
class="hover:text-primary flex gap-1 items-center"
|
||||
t-att-class="{ 'text-cyan': sType === 'assertion' }"
|
||||
t-att-href="DOC_URL + (event.docLabel or event.label)"
|
||||
target="_blank"
|
||||
>
|
||||
<t t-if="event.flags">
|
||||
<i t-if="event.hasFlag('rejects')" class="fa fa-times" />
|
||||
<i t-elif="event.hasFlag('resolves')" class="fa fa-arrow-right" />
|
||||
|
|
@ -306,6 +312,7 @@ export class HootTestResult extends Component {
|
|||
`;
|
||||
|
||||
CASE_EVENT_TYPES = CASE_EVENT_TYPES;
|
||||
DOC_URL = DOC_URL;
|
||||
|
||||
Tag = Tag;
|
||||
formatHumanReadable = formatHumanReadable;
|
||||
|
|
|
|||
|
|
@ -684,7 +684,7 @@
|
|||
const characterDataSetData = getDescriptor$1(characterDataProto, "data").set;
|
||||
const nodeGetFirstChild = getDescriptor$1(nodeProto$2, "firstChild").get;
|
||||
const nodeGetNextSibling = getDescriptor$1(nodeProto$2, "nextSibling").get;
|
||||
const NO_OP = () => { };
|
||||
const NO_OP$1 = () => { };
|
||||
function makePropSetter(name) {
|
||||
return function setProp(value) {
|
||||
// support 0, fallback to empty string for other falsy values
|
||||
|
|
@ -1014,7 +1014,7 @@
|
|||
idx: info.idx,
|
||||
refIdx: info.refIdx,
|
||||
setData: makeRefSetter(index, ctx.refList),
|
||||
updateData: NO_OP,
|
||||
updateData: NO_OP$1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1640,6 +1640,7 @@
|
|||
let current = fiber;
|
||||
do {
|
||||
current.node.fiber = current;
|
||||
fibersInError.set(current, error);
|
||||
current = current.parent;
|
||||
} while (current);
|
||||
fibersInError.set(fiber.root, error);
|
||||
|
|
@ -2386,13 +2387,19 @@
|
|||
const node = getCurrent();
|
||||
let render = batchedRenderFunctions.get(node);
|
||||
if (!render) {
|
||||
render = batched(node.render.bind(node, false));
|
||||
const wrapper = { fn: batched(node.render.bind(node, false)) };
|
||||
render = (...args) => wrapper.fn(...args);
|
||||
batchedRenderFunctions.set(node, render);
|
||||
// manual implementation of onWillDestroy to break cyclic dependency
|
||||
node.willDestroy.push(clearReactivesForCallback.bind(null, render));
|
||||
node.willDestroy.push(cleanupRenderAndReactives.bind(null, wrapper, render));
|
||||
}
|
||||
return reactive(state, render);
|
||||
}
|
||||
const NO_OP = () => { };
|
||||
function cleanupRenderAndReactives(wrapper, render) {
|
||||
wrapper.fn = NO_OP;
|
||||
clearReactivesForCallback(render);
|
||||
}
|
||||
class ComponentNode {
|
||||
constructor(C, props, app, parent, parentKey) {
|
||||
this.fiber = null;
|
||||
|
|
@ -5816,7 +5823,7 @@
|
|||
}
|
||||
|
||||
// do not modify manually. This file is generated by the release script.
|
||||
const version = "2.8.1";
|
||||
const version = "2.8.2";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
|
|
@ -6325,8 +6332,8 @@
|
|||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
|
||||
__info__.date = '2025-09-23T07:17:45.055Z';
|
||||
__info__.hash = '5211116';
|
||||
__info__.date = '2026-01-30T07:49:47.618Z';
|
||||
__info__.hash = '52abf8d';
|
||||
__info__.url = 'https://github.com/odoo/owl';
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4.5H6.5V7H4V4.5Z" fill="black"/>
|
||||
<path d="M6.5 10.5H4V13H6.5V10.5Z" fill="black"/>
|
||||
<path d="M13.25 10.5H10.75V13H13.25V10.5Z" fill="black"/>
|
||||
<path d="M17.5 10.5H20V13H17.5V10.5Z" fill="black"/>
|
||||
<path d="M6.5 16.5H4V19H6.5V16.5Z" fill="black"/>
|
||||
<path d="M10.75 16.5H13.25V19H10.75V16.5Z" fill="black"/>
|
||||
<path d="M20 16.5H17.5V19H20V16.5Z" fill="black"/>
|
||||
<path d="M13.25 4.5H10.75V7H13.25V4.5Z" fill="black"/>
|
||||
<path d="M17.5 4.5H20V7H17.5V4.5Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 573 B |
|
|
@ -287,6 +287,8 @@ pdfjs-editor-ink-opacity-input = Boullder
|
|||
pdfjs-editor-stamp-add-image-button =
|
||||
.title = Ouzhpennañ ur skeudenn
|
||||
pdfjs-editor-stamp-add-image-button-label = Ouzhpennañ ur skeudenn
|
||||
# This refers to the thickness of the line used for free highlighting (not bound to text)
|
||||
pdfjs-editor-free-highlight-thickness-input = Tevded
|
||||
pdfjs-free-text =
|
||||
.aria-label = Aozer testennoù
|
||||
pdfjs-ink =
|
||||
|
|
@ -306,7 +308,33 @@ pdfjs-editor-alt-text-save-button = Enrollañ
|
|||
|
||||
## Color picker
|
||||
|
||||
pdfjs-editor-colorpicker-button =
|
||||
.title = Cheñch liv
|
||||
pdfjs-editor-colorpicker-yellow =
|
||||
.title = Melen
|
||||
pdfjs-editor-colorpicker-blue =
|
||||
.title = Glas
|
||||
pdfjs-editor-colorpicker-pink =
|
||||
.title = Roz
|
||||
pdfjs-editor-colorpicker-red =
|
||||
.title = Ruz
|
||||
|
||||
## Show all highlights
|
||||
## This is a toggle button to show/hide all the highlights.
|
||||
|
||||
pdfjs-editor-highlight-show-all-button-label = Diskouez pep tra
|
||||
pdfjs-editor-highlight-show-all-button =
|
||||
.title = Diskouez pep tra
|
||||
|
||||
## New alt-text dialog
|
||||
## Group note for entire feature: Alternative text (alt text) helps when people can't see the image. This feature includes a tool to create alt text automatically using an AI model that works locally on the user's device to preserve privacy.
|
||||
|
||||
pdfjs-editor-new-alt-text-disclaimer-learn-more-url = Gouzout hiroc’h
|
||||
pdfjs-editor-new-alt-text-error-close-button = Serriñ
|
||||
|
||||
## Image alt-text settings
|
||||
|
||||
pdfjs-editor-alt-text-settings-delete-model-button = Dilemel
|
||||
pdfjs-editor-alt-text-settings-download-model-button = Pellgargañ
|
||||
pdfjs-editor-alt-text-settings-downloading-model-button = O pellgargañ…
|
||||
pdfjs-editor-alt-text-settings-close-button = Serriñ
|
||||
|
|
|
|||
|
|
@ -348,9 +348,10 @@ pdfjs-editor-free-highlight-thickness-input = Thickness
|
|||
pdfjs-editor-free-highlight-thickness-title =
|
||||
.title = Change thickness when highlighting items other than text
|
||||
|
||||
pdfjs-free-text =
|
||||
# .default-content is used as a placeholder in an empty text editor.
|
||||
pdfjs-free-text2 =
|
||||
.aria-label = Text Editor
|
||||
pdfjs-free-text-default-content = Start typing…
|
||||
.default-content = Start typing…
|
||||
pdfjs-ink =
|
||||
.aria-label = Draw Editor
|
||||
pdfjs-ink-canvas =
|
||||
|
|
@ -359,9 +360,12 @@ pdfjs-ink-canvas =
|
|||
## Alt-text dialog
|
||||
|
||||
# Alternative text (alt text) helps when people can't see the image.
|
||||
pdfjs-editor-alt-text-button =
|
||||
.aria-label = Alt text
|
||||
pdfjs-editor-alt-text-button-label = Alt text
|
||||
|
||||
pdfjs-editor-alt-text-edit-button-label = Edit alt text
|
||||
pdfjs-editor-alt-text-edit-button =
|
||||
.aria-label = Edit alt text
|
||||
pdfjs-editor-alt-text-dialog-label = Choose an option
|
||||
pdfjs-editor-alt-text-dialog-description = Alt text (alternative text) helps when people can’t see the image or when it doesn’t load.
|
||||
pdfjs-editor-alt-text-add-description-label = Add a description
|
||||
|
|
@ -456,12 +460,18 @@ pdfjs-editor-new-alt-text-ai-model-downloading-progress = Downloading alt text A
|
|||
.aria-valuetext = Downloading alt text AI model ({ $downloadedSize } of { $totalSize } MB)
|
||||
|
||||
# This is a button that users can click to edit the alt text they have already added.
|
||||
pdfjs-editor-new-alt-text-added-button =
|
||||
.aria-label = Alt text added
|
||||
pdfjs-editor-new-alt-text-added-button-label = Alt text added
|
||||
|
||||
# This is a button that users can click to open the alt text editor and add alt text when it is not present.
|
||||
pdfjs-editor-new-alt-text-missing-button =
|
||||
.aria-label = Missing alt text
|
||||
pdfjs-editor-new-alt-text-missing-button-label = Missing alt text
|
||||
|
||||
# This is a button that opens up the alt text modal where users should review the alt text that was automatically generated.
|
||||
pdfjs-editor-new-alt-text-to-review-button =
|
||||
.aria-label = Review alt text
|
||||
pdfjs-editor-new-alt-text-to-review-button-label = Review alt text
|
||||
|
||||
# "Created automatically" is a prefix that will be added to the beginning of any alt text that has been automatically generated. After the colon, the user will see/hear the actual alt text description. If the alt text has been edited by a human, this prefix will not appear.
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ pdfjs-document-properties-button-label = Propiedades del documento…
|
|||
pdfjs-document-properties-file-name = Nombre de archivo:
|
||||
pdfjs-document-properties-file-size = Tamaño de archivo:
|
||||
# Variables:
|
||||
# $kb (Number) - the PDF file size in kilobytes
|
||||
# $b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-size-kb = { NUMBER($kb, maximumSignificantDigits: 3) } KB ({ $b } bytes)
|
||||
# Variables:
|
||||
# $mb (Number) - the PDF file size in megabytes
|
||||
# $b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-size-mb = { NUMBER($mb, maximumSignificantDigits: 3) } MB ({ $b } bytes)
|
||||
# Variables:
|
||||
# $size_kb (Number) - the PDF file size in kilobytes
|
||||
# $size_b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-kb = { $size_kb } KB ({ $size_b } bytes)
|
||||
|
|
@ -119,6 +127,9 @@ pdfjs-document-properties-keywords = Palabras clave:
|
|||
pdfjs-document-properties-creation-date = Fecha de creación:
|
||||
pdfjs-document-properties-modification-date = Fecha de modificación:
|
||||
# Variables:
|
||||
# $dateObj (Date) - the creation/modification date and time of the PDF file
|
||||
pdfjs-document-properties-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||
# Variables:
|
||||
# $date (Date) - the creation/modification date of the PDF file
|
||||
# $time (Time) - the creation/modification time of the PDF file
|
||||
pdfjs-document-properties-date-string = { $date }, { $time }
|
||||
|
|
@ -275,6 +286,9 @@ pdfjs-annotation-date-string = { $date }, { $time }
|
|||
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
|
||||
pdfjs-text-annotation-type =
|
||||
.alt = [Anotación { $type }]
|
||||
# Variables:
|
||||
# $dateObj (Date) - the modification date and time of the annotation
|
||||
pdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||
|
||||
## Password
|
||||
|
||||
|
|
@ -412,9 +426,44 @@ pdfjs-editor-highlight-show-all-button =
|
|||
## New alt-text dialog
|
||||
## Group note for entire feature: Alternative text (alt text) helps when people can't see the image. This feature includes a tool to create alt text automatically using an AI model that works locally on the user's device to preserve privacy.
|
||||
|
||||
# Modal header positioned above a text box where users can edit the alt text.
|
||||
pdfjs-editor-new-alt-text-dialog-edit-label = Editar texto alternativo (descripción de la imagen)
|
||||
# Modal header positioned above a text box where users can add the alt text.
|
||||
pdfjs-editor-new-alt-text-dialog-add-label = Añadir texto alternativo (descripción de la imagen)
|
||||
pdfjs-editor-new-alt-text-textarea =
|
||||
.placeholder = Escribir la descripción aquí…
|
||||
# This text refers to the alt text box above this description. It offers a definition of alt text.
|
||||
pdfjs-editor-new-alt-text-description = Breve descripción para las personas que no pueden ver la imagen o cuando la imagen no se carga.
|
||||
# This is a required legal disclaimer that refers to the automatically created text inside the alt text box above this text. It disappears if the text is edited by a human.
|
||||
pdfjs-editor-new-alt-text-disclaimer1 = Este texto alternativo fue creado automáticamente y puede ser inexacto.
|
||||
pdfjs-editor-new-alt-text-disclaimer-learn-more-url = Saber más
|
||||
pdfjs-editor-new-alt-text-create-automatically-button-label = Crear texto alternativo automáticamente
|
||||
pdfjs-editor-new-alt-text-not-now-button = Ahora no
|
||||
pdfjs-editor-new-alt-text-error-title = No se ha podido crear el texto alternativo automáticamente
|
||||
pdfjs-editor-new-alt-text-error-description = Escriba su propio texto alternativo o inténtelo de nuevo más tarde.
|
||||
pdfjs-editor-new-alt-text-error-close-button = Cerrar
|
||||
# Variables:
|
||||
# $totalSize (Number) - the total size (in MB) of the AI model.
|
||||
# $downloadedSize (Number) - the downloaded size (in MB) of the AI model.
|
||||
# $percent (Number) - the percentage of the downloaded size.
|
||||
pdfjs-editor-new-alt-text-ai-model-downloading-progress = Descargando el modelo de IA de texto alternativo ({ $downloadedSize } de { $totalSize } MB)
|
||||
.aria-valuetext = Descargando el modelo de IA de texto alternativo ({ $downloadedSize } de { $totalSize } MB)
|
||||
# This is a button that users can click to edit the alt text they have already added.
|
||||
pdfjs-editor-new-alt-text-added-button-label = Se añadió el texto alternativo
|
||||
# This is a button that users can click to open the alt text editor and add alt text when it is not present.
|
||||
pdfjs-editor-new-alt-text-missing-button-label = Falta el texto alternativo
|
||||
# This is a button that opens up the alt text modal where users should review the alt text that was automatically generated.
|
||||
pdfjs-editor-new-alt-text-to-review-button-label = Revisar el texto alternativo
|
||||
# "Created automatically" is a prefix that will be added to the beginning of any alt text that has been automatically generated. After the colon, the user will see/hear the actual alt text description. If the alt text has been edited by a human, this prefix will not appear.
|
||||
# Variables:
|
||||
# $generatedAltText (String) - the generated alt-text.
|
||||
pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer = Creado automáticamente: { $generatedAltText }
|
||||
|
||||
## Image alt-text settings
|
||||
|
||||
pdfjs-image-alt-text-settings-button =
|
||||
.title = Ajustes del texto alternativo de la imagen
|
||||
pdfjs-image-alt-text-settings-button-label = Ajustes del texto alternativo de la imagen
|
||||
pdfjs-editor-alt-text-settings-dialog-label = Ajustes del texto alternativo de la imagen
|
||||
pdfjs-editor-alt-text-settings-automatic-title = Texto alternativo automático
|
||||
pdfjs-editor-alt-text-settings-create-model-button-label = Crear texto alternativo automáticamente
|
||||
|
|
|
|||
|
|
@ -220,6 +220,21 @@ pdfjs-find-match-diacritics-checkbox-label = Coincidir diacríticos
|
|||
pdfjs-find-entire-word-checkbox-label = Palabras completas
|
||||
pdfjs-find-reached-top = Se alcanzó el inicio del documento, se buscará al final
|
||||
pdfjs-find-reached-bottom = Se alcanzó el final del documento, se buscará al inicio
|
||||
# Variables:
|
||||
# $current (Number) - the index of the currently active find result
|
||||
# $total (Number) - the total number of matches in the document
|
||||
pdfjs-find-match-count =
|
||||
{ $total ->
|
||||
[one] { $current } de { $total } coincidencia
|
||||
*[other] { $current } de { $total } coincidencias
|
||||
}
|
||||
# Variables:
|
||||
# $limit (Number) - the maximum number of matches
|
||||
pdfjs-find-match-count-limit =
|
||||
{ $limit ->
|
||||
[one] Más de { $limit } coincidencia
|
||||
*[other] Más de { $limit } coincidencias
|
||||
}
|
||||
pdfjs-find-not-found = No se encontró la frase
|
||||
|
||||
## Predefined zoom values
|
||||
|
|
@ -277,9 +292,27 @@ pdfjs-editor-free-text-button-label = Texto
|
|||
pdfjs-editor-ink-button =
|
||||
.title = Dibujar
|
||||
pdfjs-editor-ink-button-label = Dibujar
|
||||
pdfjs-editor-stamp-button =
|
||||
.title = Agregar o editar imágenes
|
||||
pdfjs-editor-stamp-button-label = Agregar o editar imágenes
|
||||
pdfjs-editor-highlight-button =
|
||||
.title = Destacar
|
||||
pdfjs-editor-highlight-button-label = Destacar
|
||||
pdfjs-highlight-floating-button1 =
|
||||
.title = Destacados
|
||||
.aria-label = Destacados
|
||||
pdfjs-highlight-floating-button-label = Destacados
|
||||
|
||||
## Remove button for the various kind of editor.
|
||||
|
||||
pdfjs-editor-remove-ink-button =
|
||||
.title = Eliminar dibujo
|
||||
pdfjs-editor-remove-freetext-button =
|
||||
.title = Eliminar texto
|
||||
pdfjs-editor-remove-stamp-button =
|
||||
.title = Eliminar imagen
|
||||
pdfjs-editor-remove-highlight-button =
|
||||
.title = Eliminar destacado
|
||||
|
||||
##
|
||||
|
||||
|
|
@ -289,6 +322,13 @@ pdfjs-editor-free-text-size-input = Tamaño
|
|||
pdfjs-editor-ink-color-input = Color
|
||||
pdfjs-editor-ink-thickness-input = Grossor
|
||||
pdfjs-editor-ink-opacity-input = Opacidad
|
||||
pdfjs-editor-stamp-add-image-button =
|
||||
.title = Agregar imagen
|
||||
pdfjs-editor-stamp-add-image-button-label = Agregar imagen
|
||||
# This refers to the thickness of the line used for free highlighting (not bound to text)
|
||||
pdfjs-editor-free-highlight-thickness-input = Espesor
|
||||
pdfjs-editor-free-highlight-thickness-title =
|
||||
.title = Cambiar el grosor al resaltar elementos que no sean texto
|
||||
pdfjs-free-text =
|
||||
.aria-label = Editor de texto
|
||||
pdfjs-free-text-default-content = Empieza a escribir…
|
||||
|
|
@ -299,15 +339,42 @@ pdfjs-ink-canvas =
|
|||
|
||||
## Alt-text dialog
|
||||
|
||||
# Alternative text (alt text) helps when people can't see the image.
|
||||
pdfjs-editor-alt-text-button-label = Texto alternativo
|
||||
pdfjs-editor-alt-text-edit-button-label = Editar texto alternativo
|
||||
pdfjs-editor-alt-text-dialog-label = Elige una opción
|
||||
pdfjs-editor-alt-text-dialog-description = El texto alternativo (texto alternativo) ayuda cuando las personas no pueden ver la imagen o cuando no se carga.
|
||||
pdfjs-editor-alt-text-add-description-label = Añadir una descripción
|
||||
pdfjs-editor-alt-text-add-description-description = Intente escribir 1 o 2 oraciones que describan el tema, el entorno o las acciones.
|
||||
pdfjs-editor-alt-text-mark-decorative-label = Marcar como decorativo
|
||||
pdfjs-editor-alt-text-mark-decorative-description = Se utiliza para imágenes ornamentales, como bordes o marcas de agua.
|
||||
pdfjs-editor-alt-text-cancel-button = Cancelar
|
||||
pdfjs-editor-alt-text-save-button = Guardar
|
||||
pdfjs-editor-alt-text-decorative-tooltip = Marcado como decorativo
|
||||
# .placeholder: This is a placeholder for the alt text input area
|
||||
pdfjs-editor-alt-text-textarea =
|
||||
.placeholder = Por ejemplo: “Un joven se sienta a la mesa a comer”
|
||||
|
||||
## Editor resizers
|
||||
## This is used in an aria label to help to understand the role of the resizer.
|
||||
|
||||
pdfjs-editor-resizer-label-top-left = Esquina superior izquierda: cambiar el tamaño
|
||||
pdfjs-editor-resizer-label-top-middle = Arriba en el medio: cambiar el tamaño
|
||||
pdfjs-editor-resizer-label-top-right = Esquina superior derecha: cambiar el tamaño
|
||||
pdfjs-editor-resizer-label-middle-right = Centro derecha: cambiar el tamaño
|
||||
pdfjs-editor-resizer-label-bottom-right = Esquina inferior derecha: cambiar el tamaño
|
||||
pdfjs-editor-resizer-label-bottom-middle = Abajo en el medio: cambiar el tamaño
|
||||
pdfjs-editor-resizer-label-bottom-left = Esquina inferior izquierda: cambiar el tamaño
|
||||
pdfjs-editor-resizer-label-middle-left = Centro izquierda: cambiar el tamaño
|
||||
|
||||
## Color picker
|
||||
|
||||
# This means "Color used to highlight text"
|
||||
pdfjs-editor-highlight-colorpicker-label = Color de resaltado
|
||||
pdfjs-editor-colorpicker-button =
|
||||
.title = Cambiar color
|
||||
pdfjs-editor-colorpicker-dropdown =
|
||||
.aria-label = Opciones de color
|
||||
pdfjs-editor-colorpicker-yellow =
|
||||
.title = Amarillo
|
||||
pdfjs-editor-colorpicker-green =
|
||||
|
|
@ -325,3 +392,10 @@ pdfjs-editor-colorpicker-red =
|
|||
pdfjs-editor-highlight-show-all-button-label = Mostrar todo
|
||||
pdfjs-editor-highlight-show-all-button =
|
||||
.title = Mostrar todo
|
||||
|
||||
## New alt-text dialog
|
||||
## Group note for entire feature: Alternative text (alt text) helps when people can't see the image. This feature includes a tool to create alt text automatically using an AI model that works locally on the user's device to preserve privacy.
|
||||
|
||||
|
||||
## Image alt-text settings
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ pdfjs-document-properties-button-label = Dokumentuaren propietateak…
|
|||
pdfjs-document-properties-file-name = Fitxategi-izena:
|
||||
pdfjs-document-properties-file-size = Fitxategiaren tamaina:
|
||||
# Variables:
|
||||
# $kb (Number) - the PDF file size in kilobytes
|
||||
# $b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-size-kb = { NUMBER($kb, maximumSignificantDigits: 3) } KB ({ $b } byte)
|
||||
# Variables:
|
||||
# $mb (Number) - the PDF file size in megabytes
|
||||
# $b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-size-mb = { NUMBER($mb, maximumSignificantDigits: 3) } MB ({ $b } byte)
|
||||
# Variables:
|
||||
# $size_kb (Number) - the PDF file size in kilobytes
|
||||
# $size_b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-kb = { $size_kb } KB ({ $size_b } byte)
|
||||
|
|
@ -119,6 +127,9 @@ pdfjs-document-properties-keywords = Gako-hitzak:
|
|||
pdfjs-document-properties-creation-date = Sortze-data:
|
||||
pdfjs-document-properties-modification-date = Aldatze-data:
|
||||
# Variables:
|
||||
# $dateObj (Date) - the creation/modification date and time of the PDF file
|
||||
pdfjs-document-properties-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||
# Variables:
|
||||
# $date (Date) - the creation/modification date of the PDF file
|
||||
# $time (Time) - the creation/modification time of the PDF file
|
||||
pdfjs-document-properties-date-string = { $date }, { $time }
|
||||
|
|
@ -275,6 +286,9 @@ pdfjs-annotation-date-string = { $date }, { $time }
|
|||
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
|
||||
pdfjs-text-annotation-type =
|
||||
.alt = [{ $type } ohartarazpena]
|
||||
# Variables:
|
||||
# $dateObj (Date) - the modification date and time of the annotation
|
||||
pdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||
|
||||
## Password
|
||||
|
||||
|
|
@ -412,6 +426,56 @@ pdfjs-editor-highlight-show-all-button =
|
|||
## New alt-text dialog
|
||||
## Group note for entire feature: Alternative text (alt text) helps when people can't see the image. This feature includes a tool to create alt text automatically using an AI model that works locally on the user's device to preserve privacy.
|
||||
|
||||
# Modal header positioned above a text box where users can edit the alt text.
|
||||
pdfjs-editor-new-alt-text-dialog-edit-label = Editatu testu alternatiboa (irudiaren azalpena)
|
||||
# Modal header positioned above a text box where users can add the alt text.
|
||||
pdfjs-editor-new-alt-text-dialog-add-label = Gehitu testu alternatiboa (irudiaren azalpena)
|
||||
pdfjs-editor-new-alt-text-textarea =
|
||||
.placeholder = Idatzi zure azalpena hemen…
|
||||
# This text refers to the alt text box above this description. It offers a definition of alt text.
|
||||
pdfjs-editor-new-alt-text-description = Azalpen laburra irudia ikusi ezin duen jendearentzat edo irudia kargatu ezin denerako.
|
||||
# This is a required legal disclaimer that refers to the automatically created text inside the alt text box above this text. It disappears if the text is edited by a human.
|
||||
pdfjs-editor-new-alt-text-disclaimer1 = Testu alternatibo hau automatikoki sortu da eta okerra izan liteke.
|
||||
pdfjs-editor-new-alt-text-disclaimer-learn-more-url = Argibide gehiago
|
||||
pdfjs-editor-new-alt-text-create-automatically-button-label = Sortu testu alternatiboa automatikoki
|
||||
pdfjs-editor-new-alt-text-not-now-button = Une honetan ez
|
||||
pdfjs-editor-new-alt-text-error-title = Ezin da testu alternatiboa automatikoki sortu
|
||||
pdfjs-editor-new-alt-text-error-description = Idatzi zure testu alternatibo propioa edo saiatu berriro geroago.
|
||||
pdfjs-editor-new-alt-text-error-close-button = Itxi
|
||||
# Variables:
|
||||
# $totalSize (Number) - the total size (in MB) of the AI model.
|
||||
# $downloadedSize (Number) - the downloaded size (in MB) of the AI model.
|
||||
# $percent (Number) - the percentage of the downloaded size.
|
||||
pdfjs-editor-new-alt-text-ai-model-downloading-progress = Testu alternatiboaren AA modeloa deskargatzen ({ $totalSize }/{ $downloadedSize } MB)
|
||||
.aria-valuetext = Testu alternatiboaren AA modeloa deskargatzen ({ $totalSize }/{ $downloadedSize } MB)
|
||||
# This is a button that users can click to edit the alt text they have already added.
|
||||
pdfjs-editor-new-alt-text-added-button-label = Testu alternatiboa gehituta
|
||||
# This is a button that users can click to open the alt text editor and add alt text when it is not present.
|
||||
pdfjs-editor-new-alt-text-missing-button-label = Testu alternatiboa falta da
|
||||
# This is a button that opens up the alt text modal where users should review the alt text that was automatically generated.
|
||||
pdfjs-editor-new-alt-text-to-review-button-label = Berrikusi testu alternatiboa
|
||||
# "Created automatically" is a prefix that will be added to the beginning of any alt text that has been automatically generated. After the colon, the user will see/hear the actual alt text description. If the alt text has been edited by a human, this prefix will not appear.
|
||||
# Variables:
|
||||
# $generatedAltText (String) - the generated alt-text.
|
||||
pdfjs-editor-new-alt-text-generated-alt-text-with-disclaimer = Automatikoki sortua: { $generatedAltText }
|
||||
|
||||
## Image alt-text settings
|
||||
|
||||
pdfjs-image-alt-text-settings-button =
|
||||
.title = Irudiaren testu alternatiboaren ezarpenak
|
||||
pdfjs-image-alt-text-settings-button-label = Irudiaren testu alternatiboaren ezarpenak
|
||||
pdfjs-editor-alt-text-settings-dialog-label = Irudiaren testu alternatiboaren ezarpenak
|
||||
pdfjs-editor-alt-text-settings-automatic-title = Testu alternatibo automatikoa
|
||||
pdfjs-editor-alt-text-settings-create-model-button-label = Sortu testu alternatiboa automatikoki
|
||||
pdfjs-editor-alt-text-settings-create-model-description = Azalpenak iradokitzen ditu irudia ikusi ezin duen jendearentzat edo irudia kargatu ezin denerako.
|
||||
# Variables:
|
||||
# $totalSize (Number) - the total size (in MB) of the AI model.
|
||||
pdfjs-editor-alt-text-settings-download-model-label = Testu alternatiboaren AA modeloa ({ $totalSize } MB)
|
||||
pdfjs-editor-alt-text-settings-ai-model-description = Zure gailuan modu lokalean exekutatzen da eta zure datuak pribatu mantentzen dira. Testu alternatibo automatikorako beharrezkoa.
|
||||
pdfjs-editor-alt-text-settings-delete-model-button = Ezabatu
|
||||
pdfjs-editor-alt-text-settings-download-model-button = Deskargatu
|
||||
pdfjs-editor-alt-text-settings-downloading-model-button = Deskargatzen…
|
||||
pdfjs-editor-alt-text-settings-editor-title = Testu alternatiboaren editorea
|
||||
pdfjs-editor-alt-text-settings-show-dialog-button-label = Erakutsi testu alternatiboa irudi bat gehitzean berehala
|
||||
pdfjs-editor-alt-text-settings-show-dialog-description = Zure irudiek testu alternatiboa duela ziurtatzen laguntzen dizu.
|
||||
pdfjs-editor-alt-text-settings-close-button = Itxi
|
||||
|
|
|
|||
|
|
@ -433,7 +433,10 @@ pdfjs-editor-new-alt-text-dialog-add-label = Илова кардани матн
|
|||
pdfjs-editor-new-alt-text-textarea =
|
||||
.placeholder = Тафсири худро дар ин ҷо нависед…
|
||||
pdfjs-editor-new-alt-text-disclaimer-learn-more-url = Маълумоти бештар
|
||||
pdfjs-editor-new-alt-text-create-automatically-button-label = Ба таври худкор эҷод кардани матни иловагӣ
|
||||
pdfjs-editor-new-alt-text-not-now-button = Ҳоло не
|
||||
pdfjs-editor-new-alt-text-error-title = Матни иловагӣ ба таври худкор эҷод карда нашуд
|
||||
pdfjs-editor-new-alt-text-error-description = Лутфан, матни иловагии худро ворид кунед ё баъдтар аз нав кӯшиш кунед.
|
||||
pdfjs-editor-new-alt-text-error-close-button = Пӯшидан
|
||||
# This is a button that users can click to edit the alt text they have already added.
|
||||
pdfjs-editor-new-alt-text-added-button-label = Матни иловагӣ илова карда шуд
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ pdfjs-document-properties-button-label = คุณสมบัติเอกส
|
|||
pdfjs-document-properties-file-name = ชื่อไฟล์:
|
||||
pdfjs-document-properties-file-size = ขนาดไฟล์:
|
||||
# Variables:
|
||||
# $kb (Number) - the PDF file size in kilobytes
|
||||
# $b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-size-kb = { NUMBER($kb, maximumSignificantDigits: 3) } KB ({ $b } ไบต์)
|
||||
# Variables:
|
||||
# $mb (Number) - the PDF file size in megabytes
|
||||
# $b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-size-mb = { NUMBER($mb, maximumSignificantDigits: 3) } MB ({ $b } ไบต์)
|
||||
# Variables:
|
||||
# $size_kb (Number) - the PDF file size in kilobytes
|
||||
# $size_b (Number) - the PDF file size in bytes
|
||||
pdfjs-document-properties-kb = { $size_kb } KB ({ $size_b } ไบต์)
|
||||
|
|
@ -119,6 +127,9 @@ pdfjs-document-properties-keywords = คำสำคัญ:
|
|||
pdfjs-document-properties-creation-date = วันที่สร้าง:
|
||||
pdfjs-document-properties-modification-date = วันที่แก้ไข:
|
||||
# Variables:
|
||||
# $dateObj (Date) - the creation/modification date and time of the PDF file
|
||||
pdfjs-document-properties-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||
# Variables:
|
||||
# $date (Date) - the creation/modification date of the PDF file
|
||||
# $time (Time) - the creation/modification time of the PDF file
|
||||
pdfjs-document-properties-date-string = { $date }, { $time }
|
||||
|
|
@ -267,6 +278,9 @@ pdfjs-annotation-date-string = { $date }, { $time }
|
|||
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
|
||||
pdfjs-text-annotation-type =
|
||||
.alt = [คำอธิบายประกอบ { $type }]
|
||||
# Variables:
|
||||
# $dateObj (Date) - the modification date and time of the annotation
|
||||
pdfjs-annotation-date-time-string = { DATETIME($dateObj, dateStyle: "short", timeStyle: "medium") }
|
||||
|
||||
## Password
|
||||
|
||||
|
|
|
|||
|
|
@ -2027,6 +2027,7 @@
|
|||
|
||||
:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip{
|
||||
display:none;
|
||||
word-wrap:anywhere;
|
||||
}
|
||||
|
||||
.show:is(:is(:is(:is(:is(.annotationEditorLayer :is(.freeTextEditor,.inkEditor,.stampEditor,.highlightEditor),.textLayer) .editToolbar) .buttons) .altText) .tooltip){
|
||||
|
|
@ -3795,7 +3796,7 @@ body.wait::before{
|
|||
mask-image:var(--toolbarButton-editorStamp-icon);
|
||||
}
|
||||
|
||||
:is(#printButton, #secondaryPrint)::before{
|
||||
#printButton::before{
|
||||
-webkit-mask-image:var(--toolbarButton-print-icon);
|
||||
mask-image:var(--toolbarButton-print-icon);
|
||||
}
|
||||
|
|
@ -3805,7 +3806,7 @@ body.wait::before{
|
|||
mask-image:var(--toolbarButton-openFile-icon);
|
||||
}
|
||||
|
||||
:is(#downloadButton, #secondaryDownload)::before{
|
||||
#downloadButton::before{
|
||||
-webkit-mask-image:var(--toolbarButton-download-icon);
|
||||
mask-image:var(--toolbarButton-download-icon);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,7 +5,3 @@ declare module "@odoo/hoot" {
|
|||
declare module "@odoo/hoot-dom" {
|
||||
export * from "@web/../lib/hoot-dom/hoot-dom";
|
||||
}
|
||||
|
||||
declare module "@odoo/hoot-mock" {
|
||||
export * from "@web/../lib/hoot/hoot-mock";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ declare module "registries" {
|
|||
import { Component } from "@odoo/owl";
|
||||
import { OdooEnv } from "@web/env";
|
||||
import { NotificationOptions } from "@web/core/notifications/notification_service";
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { Compiler } from "@web/views/view_compiler";
|
||||
import { ActionDescription } from "@web/webclient/actions/action_service";
|
||||
|
||||
|
|
@ -79,6 +80,8 @@ declare module "registries" {
|
|||
|
||||
export type IrActionsReportHandlers = (action: ActionRequest, options: ActionOptions, env: OdooEnv) => (void | boolean | Promise<void | boolean>);
|
||||
|
||||
export type InteractionRegistryItemShape = typeof Interaction;
|
||||
|
||||
interface GlobalRegistryCategories {
|
||||
action_handlers: ActionHandlersRegistryItemShape;
|
||||
actions: ActionsRegistryItemShape;
|
||||
|
|
@ -96,6 +99,7 @@ declare module "registries" {
|
|||
main_components: MainComponentsRegistryItemShape;
|
||||
parsers: ParsersRegistryItemShape;
|
||||
public_components: PublicComponentsRegistryItemShape;
|
||||
"public.interactions": InteractionRegistryItemShape;
|
||||
sample_server: SampleServerRegistryItemShape;
|
||||
systray: SystrayRegistryItemShape;
|
||||
"ir.actions.report handlers": IrActionsReportHandlers;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export class AutoComplete extends Component {
|
|||
},
|
||||
},
|
||||
placeholder: { type: String, optional: true },
|
||||
title: { type: String, optional: true },
|
||||
autocomplete: { type: String, optional: true },
|
||||
autoSelect: { type: Boolean, optional: true },
|
||||
resetOnSelect: { type: Boolean, optional: true },
|
||||
|
|
@ -42,11 +43,11 @@ export class AutoComplete extends Component {
|
|||
menuPositionOptions: { type: Object, optional: true },
|
||||
menuCssClass: { type: [String, Array, Object], optional: true },
|
||||
selectOnBlur: { type: Boolean, optional: true },
|
||||
selectOnTab: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
value: "",
|
||||
placeholder: "",
|
||||
title: "",
|
||||
autocomplete: "new-password",
|
||||
autoSelect: false,
|
||||
dropdown: true,
|
||||
|
|
@ -71,6 +72,7 @@ export class AutoComplete extends Component {
|
|||
this.sources = [];
|
||||
this.inEdition = false;
|
||||
this.mouseSelectionActive = false;
|
||||
this.isOptionSelected = false;
|
||||
|
||||
this.state = useState({
|
||||
navigationRev: 0,
|
||||
|
|
@ -107,7 +109,7 @@ export class AutoComplete extends Component {
|
|||
|
||||
useExternalListener(window, "scroll", this.externalClose, true);
|
||||
useExternalListener(window, "pointerdown", this.externalClose, true);
|
||||
useExternalListener(window, "mousemove", () => this.mouseSelectionActive = true, true);
|
||||
useExternalListener(window, "mousemove", () => (this.mouseSelectionActive = true), true);
|
||||
|
||||
this.hotkey = useService("hotkey");
|
||||
this.hotkeysToRemove = [];
|
||||
|
|
@ -270,7 +272,7 @@ export class AutoComplete extends Component {
|
|||
if (this.props.resetOnSelect) {
|
||||
this.inputRef.el.value = "";
|
||||
}
|
||||
|
||||
this.isOptionSelected = true;
|
||||
this.forceValFromProp = true;
|
||||
option.onSelect();
|
||||
this.close();
|
||||
|
|
@ -332,18 +334,18 @@ export class AutoComplete extends Component {
|
|||
}
|
||||
// If selectOnBlur is true, we select the first element
|
||||
// of the autocomplete suggestions list, if this element exists
|
||||
if (this.props.selectOnBlur && this.sources[0]) {
|
||||
if (this.props.selectOnBlur && !this.isOptionSelected && this.sources[0]) {
|
||||
const firstOption = this.sources[0].options[0];
|
||||
if (firstOption) {
|
||||
this.state.activeSourceOption = firstOption.unselectable ? null : [0, 0];
|
||||
this.selectOption(this.activeOption);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.props.onBlur({
|
||||
inputValue: this.inputRef.el.value,
|
||||
});
|
||||
this.inEdition = false;
|
||||
this.isOptionSelected = false;
|
||||
}
|
||||
onInputClick() {
|
||||
if (!this.isOpened && this.props.searchOnInputClick) {
|
||||
|
|
@ -358,6 +360,7 @@ export class AutoComplete extends Component {
|
|||
}
|
||||
this.props.onChange({
|
||||
inputValue: this.inputRef.el.value,
|
||||
isOptionSelected: this.ignoreBlur,
|
||||
});
|
||||
}
|
||||
async onInput() {
|
||||
|
|
@ -409,10 +412,6 @@ export class AutoComplete extends Component {
|
|||
return;
|
||||
}
|
||||
this.selectOption(this.activeOption);
|
||||
if (this.props.selectOnBlur) {
|
||||
this.ignoreBlur = true;
|
||||
this.inputRef.el.blur();
|
||||
}
|
||||
break;
|
||||
case "escape":
|
||||
if (!this.isOpened) {
|
||||
|
|
@ -421,15 +420,6 @@ export class AutoComplete extends Component {
|
|||
this.cancel();
|
||||
break;
|
||||
case "tab":
|
||||
if (this.props.selectOnTab) {
|
||||
if (!this.isOpened || !this.state.activeSourceOption) {
|
||||
return;
|
||||
}
|
||||
this.selectOption(this.activeOption);
|
||||
this.ignoreBlur = true;
|
||||
this.inputRef.el.blur();
|
||||
break;
|
||||
}
|
||||
case "shift+tab":
|
||||
if (!this.isOpened) {
|
||||
return;
|
||||
|
|
@ -502,7 +492,10 @@ export class AutoComplete extends Component {
|
|||
return;
|
||||
}
|
||||
if (isScrollableY(this.listRef.el)) {
|
||||
scrollTo(this.listRef.el.querySelector(`#${this.activeSourceOptionId}`));
|
||||
const element = this.listRef.el.querySelector(`#${this.activeSourceOptionId}`);
|
||||
if (element) {
|
||||
scrollTo(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
class="o-autocomplete--input o_input pe-3 text-truncate"
|
||||
t-att-autocomplete="props.autocomplete"
|
||||
t-att-placeholder="props.placeholder"
|
||||
t-att-title="props.title"
|
||||
role="combobox"
|
||||
t-att-aria-activedescendant="activeSourceOptionId"
|
||||
t-att-aria-expanded="displayOptions ? 'true' : 'false'"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { delay } from "@web/core/utils/concurrency";
|
|||
import { loadJS } from "@web/core/assets";
|
||||
import { isVideoElementReady, buildZXingBarcodeDetector } from "./ZXingBarcodeDetector";
|
||||
import { CropOverlay } from "./crop_overlay";
|
||||
import { Component, onMounted, onWillStart, onWillUnmount, useRef, useState } from "@odoo/owl";
|
||||
import { Component, onMounted, onWillStart, onWillUnmount, status, useRef, useState } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
|
|
@ -86,7 +86,10 @@ export class BarcodeVideoScanner extends Component {
|
|||
return;
|
||||
}
|
||||
this.videoPreviewRef.el.srcObject = this.stream;
|
||||
await this.isVideoReady();
|
||||
const ready = await this.isVideoReady();
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
const { height, width } = getComputedStyle(this.videoPreviewRef.el);
|
||||
const divWidth = width.slice(0, -2);
|
||||
const divHeight = height.slice(0, -2);
|
||||
|
|
@ -95,6 +98,7 @@ export class BarcodeVideoScanner extends Component {
|
|||
const [track] = tracks;
|
||||
const settings = track.getSettings();
|
||||
this.zoomRatio = Math.min(divWidth / settings.width, divHeight / settings.height);
|
||||
this.addZoomSlider(track, settings);
|
||||
}
|
||||
this.detectorTimeout = setTimeout(this.detectCode.bind(this), 100);
|
||||
});
|
||||
|
|
@ -124,11 +128,15 @@ export class BarcodeVideoScanner extends Component {
|
|||
// FIXME: even if it shouldn't happened, a timeout could be useful here.
|
||||
while (!isVideoElementReady(this.videoPreviewRef.el)) {
|
||||
await delay(10);
|
||||
if (status(this) === "destroyed"){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.state.isReady = true;
|
||||
if (this.props.onReady) {
|
||||
this.props.onReady();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onResize(overlayInfo) {
|
||||
|
|
@ -198,6 +206,23 @@ export class BarcodeVideoScanner extends Component {
|
|||
}
|
||||
return newObject;
|
||||
}
|
||||
|
||||
addZoomSlider(track, settings) {
|
||||
const zoom = track.getCapabilities().zoom;
|
||||
if (zoom?.min !== undefined && zoom?.max !== undefined) {
|
||||
const inputElement = document.createElement("input");
|
||||
inputElement.type = "range";
|
||||
inputElement.min = zoom.min;
|
||||
inputElement.max = zoom.max;
|
||||
inputElement.step = zoom.step || 1;
|
||||
inputElement.value = settings.zoom;
|
||||
inputElement.classList.add("align-self-end", "m-5", "z-1");
|
||||
inputElement.addEventListener("input", async (event) => {
|
||||
await track?.applyConstraints({ advanced: [{ zoom: inputElement.value }] });
|
||||
});
|
||||
this.videoPreviewRef.el.parentElement.appendChild(inputElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Component, useRef, onPatched } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { isIOS } from "@web/core/browser/feature_detection";
|
||||
import { clamp } from "@web/core/utils/numbers";
|
||||
|
||||
export class CropOverlay extends Component {
|
||||
|
|
@ -27,6 +28,7 @@ export class CropOverlay extends Component {
|
|||
onPatched(() => {
|
||||
this.setupCropRect();
|
||||
});
|
||||
this.isIOS = isIOS();
|
||||
}
|
||||
|
||||
setupCropRect() {
|
||||
|
|
@ -114,6 +116,9 @@ export class CropOverlay extends Component {
|
|||
}
|
||||
|
||||
pointerDown(event) {
|
||||
if (event.target.matches("input")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.target.matches(".o_crop_icon")) {
|
||||
this.computeOverlayPosition();
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@
|
|||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.o_crop_overlay {
|
||||
.o_crop_overlay::after {
|
||||
content: '';
|
||||
display: block;
|
||||
}
|
||||
|
||||
.o_crop_overlay:not(.o_crop_overlay_ios) {
|
||||
background-color: RGB(0 0 0 / 0.75);
|
||||
mix-blend-mode: darken;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
clip-path: inset(var(--o-crop-y, 0px) var(--o-crop-x, 0px));
|
||||
|
|
@ -20,6 +23,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.o_crop_overlay.o_crop_overlay_ios {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
inset: var(--o-crop-y, 0px) var(--o-crop-x, 0px);
|
||||
border: 1px solid black;
|
||||
}
|
||||
}
|
||||
|
||||
.o_crop_icon {
|
||||
--o-crop-icon-width: 20px;
|
||||
--o-crop-icon-height: 20px;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
>
|
||||
<t t-slot="default"/>
|
||||
<t t-if="props.isReady">
|
||||
<div class="o_crop_overlay"/>
|
||||
<div class="o_crop_overlay" t-att-class="{'o_crop_overlay_ios': isIOS}"/>
|
||||
<img class="o_crop_icon" src="/web/static/img/transform.svg" draggable="false"/>
|
||||
</t>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -277,6 +277,10 @@
|
|||
text-align: start !important;
|
||||
}
|
||||
|
||||
.dropdown-item .o_stat_value {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.o_bottom_sheet_body:not(.o_custom_bottom_sheet) {
|
||||
// Dropdown
|
||||
.dropdown-item {
|
||||
|
|
|
|||
|
|
@ -36,9 +36,13 @@ export function isAndroid() {
|
|||
}
|
||||
|
||||
export function isIOS() {
|
||||
let isIOSPlatform = false;
|
||||
if ("platform" in browser.navigator) {
|
||||
isIOSPlatform = browser.navigator.platform === "MacIntel";
|
||||
}
|
||||
return (
|
||||
/(iPad|iPhone|iPod)/i.test(browser.navigator.userAgent) ||
|
||||
(browser.navigator.platform === "MacIntel" && maxTouchPoints() > 1)
|
||||
(isIOSPlatform && maxTouchPoints() > 1)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ function urlToState(urlObj) {
|
|||
|
||||
const [prefix, ...splitPath] = urlObj.pathname.split("/").filter(Boolean);
|
||||
|
||||
if (prefix === "odoo" || isScopedApp()) {
|
||||
if (["odoo", "scoped_app"].includes(prefix)) {
|
||||
const actionParts = [...splitPath.entries()].filter(
|
||||
([_, part]) => !isNumeric(part) && part !== "new"
|
||||
);
|
||||
|
|
@ -230,6 +230,11 @@ function urlToState(urlObj) {
|
|||
Object.assign(state, activeAction);
|
||||
state.actionStack = actions;
|
||||
}
|
||||
if (prefix === "scoped_app" && !isDisplayStandalone()) {
|
||||
// make sure /scoped_app are redirected to /odoo when using the browser instead of the PWA
|
||||
const url = browser.location.origin + router.stateToUrl(state);
|
||||
urlObj.href = url;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Component, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { isCSSColor, isColorGradient } from "@web/core/utils/colors";
|
||||
import { isCSSColor, isColorGradient, normalizeCSSColor } from "@web/core/utils/colors";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { POSITION_BUS } from "../position/position_hook";
|
||||
import { registry } from "../registry";
|
||||
|
|
@ -18,10 +18,12 @@ export const DEFAULT_COLORS = [
|
|||
["#630000", "#7B3900", "#846300", "#295218", "#083139", "#003163", "#21104A", "#4A1031"],
|
||||
];
|
||||
|
||||
const DEFAULT_GRAYSCALES = {
|
||||
export const DEFAULT_GRAYSCALES = {
|
||||
solid: ["black", "900", "800", "600", "400", "200", "100", "white"],
|
||||
};
|
||||
|
||||
// These CSS variables are defined in html_editor.
|
||||
// Using ColorPicker without html_editor installed is extremely unlikely.
|
||||
export const DEFAULT_THEME_COLOR_VARS = [
|
||||
"o-color-1",
|
||||
"o-color-2",
|
||||
|
|
@ -41,6 +43,8 @@ export class ColorPicker extends Component {
|
|||
selectedColorCombination: { type: String, optional: true },
|
||||
getTargetedElements: { type: Function, optional: true },
|
||||
defaultTab: String,
|
||||
selectedTab: { type: String, optional: true },
|
||||
// todo: remove the `mode` prop in master
|
||||
mode: { type: String, optional: true },
|
||||
},
|
||||
},
|
||||
|
|
@ -86,7 +90,7 @@ export class ColorPicker extends Component {
|
|||
this.getPreviewColor = () => {};
|
||||
|
||||
this.state = useState({
|
||||
activeTab: this.getDefaultTab(),
|
||||
activeTab: this.props.state.selectedTab || this.getDefaultTab(),
|
||||
currentCustomColor: this.props.state.selectedColor,
|
||||
currentColorPreview: undefined,
|
||||
showGradientPicker: false,
|
||||
|
|
@ -236,10 +240,9 @@ export class ColorPicker extends Component {
|
|||
}
|
||||
|
||||
getDefaultColorSet() {
|
||||
if (!this.props.state.getTargetedElements || !this.props.state.mode) {
|
||||
if (!this.props.state.selectedColor) {
|
||||
return;
|
||||
}
|
||||
const targetedEls = this.props.state.getTargetedElements();
|
||||
let defaultColors = this.props.enabledTabs.includes("solid")
|
||||
? this.DEFAULT_THEME_COLOR_VARS
|
||||
: [];
|
||||
|
|
@ -247,42 +250,20 @@ export class ColorPicker extends Component {
|
|||
defaultColors = defaultColors.concat(grayscale);
|
||||
}
|
||||
|
||||
const extractColorFromClasses = (targetedEls, prefix, defaultColors) => {
|
||||
for (const el of targetedEls) {
|
||||
for (const className of el.classList) {
|
||||
const match = className.match(new RegExp(`^${prefix}-(.+)$`));
|
||||
if (match && defaultColors.includes(match[1])) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
switch (this.props.state.mode) {
|
||||
case "color":
|
||||
return extractColorFromClasses(targetedEls, "text", defaultColors);
|
||||
case "background-color":
|
||||
case "backgroundColor":
|
||||
return extractColorFromClasses(targetedEls, "bg", defaultColors);
|
||||
case "selectFilterColor": {
|
||||
const filterEls = targetedEls
|
||||
.map((el) => el.querySelector(".o_we_bg_filter"))
|
||||
.filter((el) => el !== null);
|
||||
return extractColorFromClasses(filterEls, "bg", defaultColors);
|
||||
}
|
||||
default: {
|
||||
for (const el of targetedEls) {
|
||||
const color = el.dataset[this.props.state.mode];
|
||||
if (
|
||||
defaultColors.includes(color) ||
|
||||
defaultColors.includes(this.props.state.mode)
|
||||
) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
const targetedElement =
|
||||
this.props.state.getTargetedElements?.()[0] || document.documentElement;
|
||||
const selectedColor = this.props.state.selectedColor.toUpperCase();
|
||||
const htmlStyle =
|
||||
targetedElement.ownerDocument.defaultView.getComputedStyle(targetedElement);
|
||||
|
||||
for (const color of defaultColors) {
|
||||
const cssVar = normalizeCSSColor(htmlStyle.getPropertyValue(`--${color}`));
|
||||
if (cssVar?.toUpperCase() === selectedColor) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
colorPickerNavigation(ev) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
$o-we-toolbar-bg: #FFF !default;
|
||||
$o-we-toolbar-color-text: #2b2b33 !default; // Same as $o-we-bg-light
|
||||
$o-we-item-spacing: 8px !default;
|
||||
$o-we-color-success: #00ff9e !default;
|
||||
|
||||
.o_font_color_selector {
|
||||
@include o-input-number-no-arrows();
|
||||
--bg: #{$o-we-toolbar-bg};
|
||||
|
|
@ -87,11 +92,3 @@
|
|||
color: $o-we-color-success;
|
||||
}
|
||||
}
|
||||
|
||||
// Extend bootstrap to create background and text utilities for some colors
|
||||
@for $index from 1 through 5 {
|
||||
$-color-name: 'o-color-#{$index}';
|
||||
$-color: map-get($colors, $-color-name);
|
||||
@include bg-variant(".bg-#{$-color-name}", $-color);
|
||||
@include text-emphasis-variant(".text-#{$-color-name}", $-color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
convertHslToRgb,
|
||||
convertRgbaToCSSColor,
|
||||
convertRgbToHsl,
|
||||
normalizeCSSColor,
|
||||
} from "@web/core/utils/colors";
|
||||
import { uniqueId } from "@web/core/utils/functions";
|
||||
import { clamp } from "@web/core/utils/numbers";
|
||||
|
|
@ -139,7 +140,9 @@ export class CustomColorPicker extends Component {
|
|||
const newSelectedColor = newProps.selectedColor
|
||||
? newProps.selectedColor
|
||||
: newProps.defaultColor;
|
||||
this.setSelectedColor(newSelectedColor);
|
||||
if (normalizeCSSColor(newSelectedColor) !== this.colorComponents.cssColor) {
|
||||
this.setSelectedColor(newSelectedColor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { registry } from "@web/core/registry";
|
|||
import { isColorGradient } from "@web/core/utils/colors";
|
||||
import { CustomColorPicker } from "../custom_color_picker/custom_color_picker";
|
||||
|
||||
class ColorPickerCustomTab extends Component {
|
||||
export class ColorPickerCustomTab extends Component {
|
||||
static template = "web.ColorPickerCustomTab";
|
||||
static components = { CustomColorPicker };
|
||||
static props = {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Component } from "@odoo/owl";
|
|||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
class ColorPickerSolidTab extends Component {
|
||||
export class ColorPickerSolidTab extends Component {
|
||||
static template = "web.ColorPickerSolidTab";
|
||||
static props = {
|
||||
colorPickerNavigation: Function,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
<t t-name="web.CopyButton">
|
||||
<button
|
||||
type="button"
|
||||
class="text-nowrap"
|
||||
t-ref="button"
|
||||
t-att-disabled="props.disabled"
|
||||
|
|
|
|||
|
|
@ -68,11 +68,14 @@ export async function getCurrencyRates() {
|
|||
* @param {[number, number]} [options.digits] the number of digits that should
|
||||
* be used, instead of the default digits precision in the field. The first
|
||||
* number is always ignored (legacy constraint)
|
||||
* @param {number} [options.minDigits] the minimum number of decimal digits to display.
|
||||
* Displays maximum 6 decimal places if no precision is provided.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCurrency(amount, currencyId, options = {}) {
|
||||
const currency = getCurrency(currencyId);
|
||||
const digits = options.digits || (currency && currency.digits);
|
||||
|
||||
const digits = (options.digits !== undefined)? options.digits : (currency && currency.digits)
|
||||
|
||||
let formattedAmount;
|
||||
if (options.humanReadable) {
|
||||
|
|
@ -81,7 +84,7 @@ export function formatCurrency(amount, currencyId, options = {}) {
|
|||
minDigits: options.minDigits,
|
||||
});
|
||||
} else {
|
||||
formattedAmount = formatFloat(amount, { digits, trailingZeros: options.trailingZeros });
|
||||
formattedAmount = formatFloat(amount, { digits, minDigits: options.minDigits, trailingZeros: options.trailingZeros });
|
||||
}
|
||||
|
||||
if (!currency || options.noSymbol) {
|
||||
|
|
|
|||
|
|
@ -56,13 +56,9 @@
|
|||
// Utilities
|
||||
|
||||
.o_date_item_picker .o_datetime_button {
|
||||
&.o_selected,
|
||||
&:hover,
|
||||
&.o_today:not(.o_selected):hover {
|
||||
&:not(.o_select_start):not(.o_select_end) {
|
||||
background: $o-component-active-bg;
|
||||
color: $o-component-active-color;
|
||||
}
|
||||
&.o_selected:not(.o_select_start, .o_select_end) {
|
||||
background: $o-component-active-bg;
|
||||
color: $o-component-active-color;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,5 +69,24 @@
|
|||
|
||||
.o_date_item_cell {
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
|
||||
&:hover, &:focus {
|
||||
--DateTimePicker__date-cell-border-color-hover: #{$o-component-active-border};
|
||||
}
|
||||
|
||||
&:not([disabled])::before {
|
||||
@include o-position-absolute(0,0,0,0);
|
||||
|
||||
content: '';
|
||||
aspect-ratio: 1;
|
||||
border: $border-width solid var(--DateTimePicker__date-cell-border-color-hover);
|
||||
border-radius: $border-radius-pill;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_week_number_cell {
|
||||
font-variant: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div class="d-flex gap-3">
|
||||
<t t-foreach="items" t-as="month" t-key="month.id">
|
||||
<div
|
||||
class="o_date_picker d-grid flex-grow-1 bg-view rounded overflow-auto"
|
||||
class="o_date_picker d-grid flex-grow-1 rounded overflow-auto"
|
||||
t-on-pointerleave="() => (state.hoveredDate = null)"
|
||||
>
|
||||
<t t-foreach="month.daysOfWeek" t-as="dayOfWeek" t-key="dayOfWeek[0]">
|
||||
|
|
@ -18,14 +18,14 @@
|
|||
<t t-foreach="month.weeks" t-as="week" t-key="week.number">
|
||||
<t t-if="props.showWeekNumbers">
|
||||
<div
|
||||
class="o_week_number_cell d-flex align-items-center ps-2 fw-bolder"
|
||||
class="o_week_number_cell d-flex justify-content-end align-items-center pe-3 fw-bolder"
|
||||
t-esc="week.number"
|
||||
/>
|
||||
</t>
|
||||
<t t-foreach="week.days" t-as="itemInfo" t-key="itemInfo.id">
|
||||
<t t-set="arInfo" t-value="getActiveRangeInfo(itemInfo)" />
|
||||
<div
|
||||
class="o_date_item_cell o_datetime_button o_center cursor-pointer"
|
||||
class="o_date_item_cell o_datetime_button o_center"
|
||||
t-att-class="{
|
||||
'o_out_of_range text-muted': itemInfo.isOutOfRange,
|
||||
o_selected: arInfo.isSelected,
|
||||
|
|
@ -34,6 +34,8 @@
|
|||
o_highlighted: arInfo.isHighlighted,
|
||||
'o_today fw-bolder': itemInfo.includesToday,
|
||||
[itemInfo.extraClass]: true,
|
||||
'opacity-50': !itemInfo.isValid,
|
||||
'cursor-pointer': itemInfo.isValid,
|
||||
}"
|
||||
t-att-disabled="!itemInfo.isValid"
|
||||
t-on-pointerenter="() => (state.hoveredDate = itemInfo.range[0])"
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
onChange="(newTime) => this.onTimeChange(0, newTime)"
|
||||
minutesRounding="props.rounding"
|
||||
showSeconds="props.rounding === 0"
|
||||
inputCssClass="'o_input'"
|
||||
/>
|
||||
<i t-if="state.timeValues[0] and state.timeValues[1]" class="fa fa-long-arrow-right"/>
|
||||
<TimePicker
|
||||
|
|
@ -64,6 +67,7 @@
|
|||
onChange="(newTime) => this.onTimeChange(1, newTime)"
|
||||
minutesRounding="props.rounding"
|
||||
showSeconds="props.rounding === 0"
|
||||
inputCssClass="'o_input'"
|
||||
/>
|
||||
</div>
|
||||
<t t-slot="buttons" />
|
||||
|
|
@ -82,6 +86,7 @@
|
|||
o_select_end: arInfo.isSelectEnd,
|
||||
o_highlighted: arInfo.isHighlighted,
|
||||
o_today: itemInfo.includesToday,
|
||||
'opacity-50': !itemInfo.isValid,
|
||||
}"
|
||||
t-att-disabled="!itemInfo.isValid"
|
||||
t-on-click="() => this.zoomOrSelect(itemInfo)"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useRef } from "@odoo/owl";
|
||||
import { onWillDestroy, useRef } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
|
|
@ -28,5 +28,9 @@ export function useDateTimePicker(params) {
|
|||
useOwlHooks: true,
|
||||
});
|
||||
|
||||
return useService("datetime_picker").create(serviceParams);
|
||||
const picker = useService("datetime_picker").create(serviceParams);
|
||||
onWillDestroy(() => {
|
||||
picker.disable();
|
||||
});
|
||||
return picker;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ const parsers = {
|
|||
export const datetimePickerService = {
|
||||
dependencies: ["popover"],
|
||||
start(env, { popover: popoverService }) {
|
||||
const dateTimePickerList = new Set();
|
||||
return {
|
||||
/**
|
||||
* @param {DateTimePickerServiceParams} [params]
|
||||
|
|
@ -265,6 +266,9 @@ export const datetimePickerService = {
|
|||
popoverTarget.style.marginBottom = marginBottom;
|
||||
};
|
||||
}
|
||||
for (const picker of dateTimePickerList) {
|
||||
picker.close();
|
||||
}
|
||||
popover.open(popoverTarget, { pickerProps });
|
||||
}
|
||||
|
||||
|
|
@ -396,7 +400,7 @@ export const datetimePickerService = {
|
|||
getInputs(),
|
||||
ensureArray(pickerProps.value),
|
||||
(el, currentValue) => {
|
||||
if (!el) {
|
||||
if (!el || el.tagName?.toLowerCase() !== "input") {
|
||||
return currentValue;
|
||||
}
|
||||
const [parsedValue, error] = safeConvert("parse", el.value);
|
||||
|
|
@ -529,8 +533,16 @@ export const datetimePickerService = {
|
|||
`datetime picker service error: cannot use target as ref name when not using Owl hooks`
|
||||
);
|
||||
}
|
||||
|
||||
return { enable, isOpen, open, state: pickerProps };
|
||||
const picker = {
|
||||
enable,
|
||||
disable: () => dateTimePickerList.delete(picker),
|
||||
isOpen,
|
||||
open,
|
||||
close: () => popover.close(),
|
||||
state: pickerProps,
|
||||
};
|
||||
dateTimePickerList.add(picker);
|
||||
return picker;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,12 +21,8 @@
|
|||
<main class="modal-body" t-attf-class="{{ props.bodyClass }} {{ !props.withBodyPadding ? 'p-0': '' }}">
|
||||
<t t-slot="default" close="() => this.data.close()" />
|
||||
</main>
|
||||
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-md-start flex-wrap gap-1 w-100">
|
||||
<t t-slot="footer" close="() => this.data.close()">
|
||||
<button class="btn btn-primary o-default-button" t-on-click="() => this.data.close()">
|
||||
<t>Ok</t>
|
||||
</button>
|
||||
</t>
|
||||
<footer t-if="props.footer" class="modal-footer d-empty-none justify-content-around justify-content-md-start flex-wrap gap-1 w-100">
|
||||
<t t-slot="footer" close="() => this.data.close()"/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,7 +32,7 @@
|
|||
|
||||
<t t-name="web.Dialog.header">
|
||||
<t t-if="fullscreen">
|
||||
<button class="btn oi oi-arrow-left" aria-label="Close" t-on-click="dismiss" />
|
||||
<button class="btn oi oi-arrow-left" aria-label="Close" tabindex="-1" t-on-click="dismiss" />
|
||||
</t>
|
||||
<h4 class="modal-title text-break flex-grow-1" t-att-class="{ 'me-auto': fullscreen }">
|
||||
<t t-esc="props.title"/>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { effect } from "@web/core/utils/reactive";
|
|||
import { utils } from "@web/core/ui/ui_service";
|
||||
import { hasTouch } from "@web/core/browser/feature_detection";
|
||||
|
||||
function getFirstElementOfNode(node) {
|
||||
export function getFirstElementOfNode(node) {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1060,7 +1060,6 @@ const _getEmojisData1 = () => `{
|
|||
"category": "Smileys & Emotion",
|
||||
"codepoints": "😎",
|
||||
"emoticons": [
|
||||
"B)",
|
||||
"8)",
|
||||
"B-)",
|
||||
"8-)"
|
||||
|
|
@ -2266,6 +2265,7 @@ const _getEmojisData1 = () => `{
|
|||
],
|
||||
"name": "` + _t("hundred points") + `",
|
||||
"shortcodes": [
|
||||
":100:",
|
||||
":hundred_points:"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from "@odoo/owl";
|
||||
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { _t, appTranslateFn } from "@web/core/l10n/translation";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { fuzzyLookup } from "@web/core/utils/search";
|
||||
import { useAutofocus, useService } from "@web/core/utils/hooks";
|
||||
|
|
@ -571,6 +571,8 @@ export function usePicker(PickerComponent, ref, props, options = {}) {
|
|||
env: component.env,
|
||||
props: pickerMobileProps,
|
||||
getTemplate,
|
||||
translatableAttributes: ["data-tooltip"],
|
||||
translateFn: appTranslateFn,
|
||||
});
|
||||
app.mount(ref.el);
|
||||
remove = () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<div class="o-EmojiPicker-content overflow-auto d-flex flex-grow-1 w-100 flex-wrap align-items-center user-select-none mt-1" t-att-class="emojisFromSearch.length === 0 ? 'flex-column justify-content-center' : 'align-content-start'" t-ref="emoji-grid" t-on-scroll="highlightActiveCategory">
|
||||
<t t-if="searchTerm and emojisFromSearch.length === 0" class="d-flex flex-column">
|
||||
<span class="o-EmojiPicker-empty">😢</span>
|
||||
<span class="fs-5 text-muted">No emoji matches your search</span>
|
||||
<span class="fs-5 text-muted">No emojis match your search</span>
|
||||
</t>
|
||||
<t t-if="recentEmojis.length > 0">
|
||||
<t t-if="!searchTerm" t-call="web.EmojiPicker.section">
|
||||
|
|
|
|||
|
|
@ -85,7 +85,17 @@ export class FileInput extends Component {
|
|||
*/
|
||||
async onFileInputChange() {
|
||||
this.state.isDisable = true;
|
||||
const parsedFileData = await this.uploadFiles(this.props.route, this.httpParams);
|
||||
const httpParams = this.httpParams;
|
||||
if (this.props.onWillUploadFiles) {
|
||||
try {
|
||||
const files = await this.props.onWillUploadFiles(httpParams.ufile);
|
||||
httpParams.ufile = files;
|
||||
} catch (e) {
|
||||
this.state.isDisable = false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const parsedFileData = await this.uploadFiles(this.props.route, httpParams);
|
||||
if (parsedFileData) {
|
||||
// When calling onUpload, also pass the files to allow to get data like their names
|
||||
this.props.onUpload(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const fileUploadService = {
|
|||
return new window.XMLHttpRequest();
|
||||
},
|
||||
|
||||
start(env, { notificationService }) {
|
||||
start(env, { notification: notificationService }) {
|
||||
const uploads = reactive({});
|
||||
let nextId = 1;
|
||||
const bus = new EventBus();
|
||||
|
|
@ -67,26 +67,76 @@ export const fileUploadService = {
|
|||
});
|
||||
// Load listener
|
||||
xhr.addEventListener("load", () => {
|
||||
try {
|
||||
handleResponse();
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
return;
|
||||
}
|
||||
delete uploads[upload.id];
|
||||
upload.state = "loaded";
|
||||
bus.trigger("FILE_UPLOAD_LOADED", { upload });
|
||||
});
|
||||
// Error listener
|
||||
xhr.addEventListener("error", async () => {
|
||||
|
||||
function handleResponse() {
|
||||
const resp = xhr.responseText ?? xhr.response;
|
||||
let error;
|
||||
let errorMessage = "";
|
||||
if (!(xhr.status >= 200 && xhr.status < 300)) {
|
||||
error = true;
|
||||
}
|
||||
if (resp) {
|
||||
let content = resp;
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
content = JSON.parse(content);
|
||||
} catch {
|
||||
try {
|
||||
content = new DOMParser().parseFromString(content, "text/html");
|
||||
} catch {
|
||||
/** pass */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not sure what to do if the content is neither JSON nor HTML
|
||||
// Let's the call be successful then....
|
||||
if (error && content instanceof Document) {
|
||||
errorMessage = content.body.textContent;
|
||||
} else if (content instanceof Object) {
|
||||
if (content.error) {
|
||||
// https://www.jsonrpc.org/specification#error_object
|
||||
error = true;
|
||||
if (content.error.data) {
|
||||
// JsonRPCDispatcher.handle_error and http.serialize_exception
|
||||
errorMessage = `${content.error.data.name}: ${content.error.data.message}`;
|
||||
} else {
|
||||
errorMessage = content.error.message || errorMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function onError(error) {
|
||||
const defaultErrorMessage = _t("An error occured while uploading.");
|
||||
delete uploads[upload.id];
|
||||
upload.state = "error";
|
||||
const displayError = params.displayErrorNotification ?? true;
|
||||
// Disable this option if you need more explicit error handling.
|
||||
if (
|
||||
params.displayErrorNotification !== undefined &&
|
||||
params.displayErrorNotification
|
||||
) {
|
||||
notificationService.add(_t("An error occured while uploading."), {
|
||||
if (displayError) {
|
||||
notificationService.add(error?.message || defaultErrorMessage, {
|
||||
type: "danger",
|
||||
sticky: true,
|
||||
});
|
||||
}
|
||||
bus.trigger("FILE_UPLOAD_ERROR", { upload });
|
||||
});
|
||||
}
|
||||
// Error listener
|
||||
xhr.addEventListener("error", (ev) => onError(ev.error));
|
||||
// Abort listener, considered as error
|
||||
xhr.addEventListener("abort", async () => {
|
||||
delete uploads[upload.id];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export function createFileViewer() {
|
|||
* @param {import("@web/core/file_viewer/file_viewer").FileViewer.props.files} files
|
||||
*/
|
||||
function open(file, files = [file]) {
|
||||
close();
|
||||
if (!file.isViewable) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { browser } from "../browser/browser";
|
|||
import { registry } from "../registry";
|
||||
import { strftimeToLuxonFormat } from "./dates";
|
||||
import { localization } from "./localization";
|
||||
import { rpcBus } from "../network/rpc";
|
||||
import {
|
||||
translatedTerms,
|
||||
translatedTermsGlobal,
|
||||
|
|
@ -35,6 +36,13 @@ export const localizationService = {
|
|||
const translationURL = session.translationURL || "/web/webclient/translations";
|
||||
const lang = jsToPyLocale(user.lang || document.documentElement.getAttribute("lang"));
|
||||
|
||||
rpcBus.addEventListener("RPC:RESPONSE", (ev) => {
|
||||
const { method, model } = ev.detail.data.params || {};
|
||||
if (method === "lang_install" && model === "base.language.install") {
|
||||
rpcBus.trigger("CLEAR-CACHES");
|
||||
}
|
||||
});
|
||||
|
||||
const fetchTranslations = async (hash) => {
|
||||
let queryString = objectToUrlEncodedString({ hash, lang });
|
||||
queryString = queryString.length > 0 ? `?${queryString}` : queryString;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,68 @@
|
|||
import { markup } from "@odoo/owl";
|
||||
|
||||
import { formatList } from "@web/core/l10n/utils";
|
||||
import { isIterable } from "@web/core/utils/arrays";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { htmlSprintf } from "@web/core/utils/html";
|
||||
import { isObject } from "@web/core/utils/objects";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { htmlSprintf, isMarkup } from "@web/core/utils/html";
|
||||
import { mapSubstitutions, sprintf } from "@web/core/utils/strings";
|
||||
|
||||
export const translationLoaded = Symbol("translationLoaded");
|
||||
export const translatedTerms = {
|
||||
[translationLoaded]: false,
|
||||
};
|
||||
/**
|
||||
* Contains all the translated terms. Unlike "translatedTerms", there is no
|
||||
* "namespacing" by module. It is used as a fallback when no translation is
|
||||
* found within the module's context, or when the context is not known.
|
||||
* @typedef {ReturnType<markup>} Markup
|
||||
*/
|
||||
export const translatedTermsGlobal = {};
|
||||
export const translationIsReady = new Deferred();
|
||||
|
||||
const Markup = markup().constructor;
|
||||
/**
|
||||
* Returns true if the given value is a non-empty string, i.e. it contains other
|
||||
* characters than white spaces and zero-width spaces.
|
||||
*
|
||||
* @param {unknown} value
|
||||
*/
|
||||
function isNotBlank(value) {
|
||||
return typeof value === "string" && !R_BLANK.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same behavior as sprintf, but doing two additional things:
|
||||
* - If any of the provided values is an iterable, it will format its items
|
||||
* as a language-specific formatted string representing the elements of the
|
||||
* list.
|
||||
* - If any of the provided values is a markup, it will escape all non-markup
|
||||
* content before performing the interpolation, then wraps the result in a
|
||||
* markup.
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {Substitutions} substitutions
|
||||
* @returns {string | Markup | TranslatedString}
|
||||
*/
|
||||
function translationSprintf(str, substitutions) {
|
||||
let hasMarkup = false;
|
||||
|
||||
/**
|
||||
* @param {string | Markup} value
|
||||
* @returns {string | Markup}
|
||||
*/
|
||||
function formatSubstitution(value) {
|
||||
hasMarkup ||= isMarkup(value);
|
||||
// The `!(value instanceof String)` check is to prevent interpreting `Markup` and `TranslatedString`
|
||||
// objects as iterables, since they are both subclasses of `String`.
|
||||
if (isIterable(value) && !(value instanceof String)) {
|
||||
return formatList(value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
const formattedSubstitutions = mapSubstitutions(substitutions, formatSubstitution);
|
||||
if (hasMarkup) {
|
||||
return htmlSprintf(str, ...formattedSubstitutions);
|
||||
} else {
|
||||
return sprintf(str, ...formattedSubstitutions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template [T=unknown]
|
||||
* @typedef {import("@web/core/utils/strings").Substitutions<T>} Substitutions
|
||||
*/
|
||||
|
||||
const DEFAULT_MODULE = "base";
|
||||
const R_BLANK = /^[\s\u200B]*$/;
|
||||
|
||||
/**
|
||||
* Translates a term, or returns the term as it is if no translation can be
|
||||
|
|
@ -45,93 +88,12 @@ const Markup = markup().constructor;
|
|||
* _t("I love %s", markup`<blink>Minecraft</blink>`); // Markup {"J'adore <blink>Minecraft</blink>"}
|
||||
* _t("Good morning %s!", ["Mitchell", "Marc", "Louis"]); // Bonjour Mitchell, Marc et Louis !
|
||||
*
|
||||
* @param {string} term
|
||||
* @returns {string|Markup|LazyTranslatedString}
|
||||
* @param {string} source
|
||||
* @param {Substitutions} substitutions
|
||||
* @returns {string | Markup | TranslatedString}
|
||||
*/
|
||||
export function _t(term, ...values) {
|
||||
if (translatedTerms[translationLoaded]) {
|
||||
const translation = _getTranslation(term, odoo.translationContext);
|
||||
if (values.length === 0) {
|
||||
return translation;
|
||||
}
|
||||
return _safeFormatAndSprintf(translation, ...values);
|
||||
} else {
|
||||
return new LazyTranslatedString(term, values);
|
||||
}
|
||||
}
|
||||
|
||||
class LazyTranslatedString extends String {
|
||||
constructor(term, values) {
|
||||
super(term);
|
||||
this.translationContext = odoo.translationContext;
|
||||
this.values = values;
|
||||
}
|
||||
valueOf() {
|
||||
const term = super.valueOf();
|
||||
if (translatedTerms[translationLoaded]) {
|
||||
const translation = _getTranslation(term, this.translationContext);
|
||||
if (this.values.length === 0) {
|
||||
return translation;
|
||||
}
|
||||
return _safeFormatAndSprintf(translation, ...this.values);
|
||||
} else {
|
||||
throw new Error(`translation error`);
|
||||
}
|
||||
}
|
||||
toString() {
|
||||
return this.valueOf();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the installed languages long names and code
|
||||
*
|
||||
* The result of the call is put in cache.
|
||||
* If any new language is installed, a full page refresh will happen,
|
||||
* so there is no need invalidate it.
|
||||
*/
|
||||
export async function loadLanguages(orm) {
|
||||
if (!loadLanguages.installedLanguages) {
|
||||
loadLanguages.installedLanguages = await orm.call("res.lang", "get_installed");
|
||||
}
|
||||
return loadLanguages.installedLanguages;
|
||||
}
|
||||
|
||||
function _getTranslation(sourceTerm, ctx) {
|
||||
return translatedTerms[ctx]?.[sourceTerm] ?? translatedTermsGlobal[sourceTerm] ?? sourceTerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same behavior as sprintf, but doing two additional things:
|
||||
* - If any of the provided values is an iterable, it will format its items
|
||||
* as a language-specific formatted string representing the elements of the
|
||||
* list.
|
||||
* - If any of the provided values is a markup, it will escape all non-markup
|
||||
* content before performing the interpolation, then wraps the result in a
|
||||
* markup.
|
||||
*
|
||||
* @param {string} str The string with placeholders (%s) to insert values into.
|
||||
* @param {...any} values Primitive values to insert in place of placeholders.
|
||||
* @returns {string|Markup}
|
||||
*/
|
||||
function _safeFormatAndSprintf(str, ...values) {
|
||||
let hasMarkup = false;
|
||||
let valuesObject = values;
|
||||
if (values.length === 1 && isObject(values[0])) {
|
||||
valuesObject = values[0];
|
||||
}
|
||||
for (const [key, value] of Object.entries(valuesObject)) {
|
||||
// The `!(value instanceof String)` check is to prevent interpreting `Markup` and `LazyTranslatedString`
|
||||
// objects as iterables, since they are both subclasses of `String`.
|
||||
if (isIterable(value) && !(value instanceof String)) {
|
||||
valuesObject[key] = formatList(value);
|
||||
}
|
||||
hasMarkup ||= value instanceof Markup;
|
||||
}
|
||||
if (hasMarkup) {
|
||||
return htmlSprintf(str, ...values);
|
||||
}
|
||||
return sprintf(str, ...values);
|
||||
export function _t(source, ...substitutions) {
|
||||
return appTranslateFn(source, odoo.translationContext, ...substitutions);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -142,14 +104,88 @@ function _safeFormatAndSprintf(str, ...values) {
|
|||
* translations, e.g. "table" has a different meaning depending on the module:
|
||||
* the table of a restaurant (POS module) vs. a spreadsheet table.
|
||||
*
|
||||
* @param {string} str The term to translate
|
||||
* @param {string} source The term to translate
|
||||
* @param {string} moduleName The name of the module, used as a context key to
|
||||
* retrieve the translation.
|
||||
* @param {...any} args The other arguments passed to _t.
|
||||
* @param {Substitutions} substitutions The other arguments passed to _t.
|
||||
* @returns {string | Markup | TranslatedString}
|
||||
*/
|
||||
export function appTranslateFn(str, moduleName, ...args) {
|
||||
odoo.translationContext = moduleName;
|
||||
const translatedTerm = _t(str, ...args);
|
||||
odoo.translationContext = null;
|
||||
return translatedTerm;
|
||||
export function appTranslateFn(source, moduleName, ...substitutions) {
|
||||
const string = new TranslatedString(source, substitutions, moduleName);
|
||||
return string.lazy ? string : string.valueOf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the installed languages long names and code
|
||||
*
|
||||
* The result of the call is put in cache.
|
||||
* If any new language is installed, a full page refresh will happen,
|
||||
* so there is no need invalidate it.
|
||||
*
|
||||
* @param {import("services").ServiceFactories["orm"]} orm
|
||||
*/
|
||||
export async function loadLanguages(orm) {
|
||||
if (!loadLanguages.installedLanguages) {
|
||||
loadLanguages.installedLanguages = await orm.call("res.lang", "get_installed");
|
||||
}
|
||||
return loadLanguages.installedLanguages;
|
||||
}
|
||||
|
||||
export class TranslatedString extends String {
|
||||
/** @type {string} */
|
||||
context;
|
||||
lazy = false;
|
||||
/** @type {Substitutions} */
|
||||
substitutions;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} value
|
||||
* @param {Substitutions} substitutions
|
||||
* @param {string | null} [context]
|
||||
*/
|
||||
constructor(value, substitutions, context) {
|
||||
super(value);
|
||||
|
||||
if (!isNotBlank(value)) {
|
||||
return new String(value);
|
||||
}
|
||||
|
||||
this.lazy = !translatedTerms[translationLoaded];
|
||||
this.substitutions = substitutions;
|
||||
this.context = context || DEFAULT_MODULE;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.valueOf();
|
||||
}
|
||||
|
||||
valueOf() {
|
||||
const source = super.valueOf();
|
||||
if (this.lazy && !translatedTerms[translationLoaded]) {
|
||||
// Evaluate lazy translated string while translations are not loaded
|
||||
// -> error
|
||||
throw new Error(`Cannot translate string: translations have not been loaded`);
|
||||
}
|
||||
const translation =
|
||||
translatedTerms[this.context]?.[source] ?? translatedTermsGlobal[source] ?? source;
|
||||
if (this.substitutions.length) {
|
||||
return translationSprintf(translation, this.substitutions);
|
||||
} else {
|
||||
return translation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const translationLoaded = Symbol("translationLoaded");
|
||||
/** @type {Record<string, string>} */
|
||||
export const translatedTerms = {
|
||||
[translationLoaded]: false,
|
||||
};
|
||||
/**
|
||||
* Contains all the translated terms. Unlike "translatedTerms", there is no
|
||||
* "namespacing" by module. It is used as a fallback when no translation is
|
||||
* found within the module's context, or when the context is not known.
|
||||
*/
|
||||
export const translatedTermsGlobal = Object.create(null);
|
||||
export const translationIsReady = new Deferred();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { user } from "@web/core/user";
|
||||
|
||||
/**
|
||||
* @typedef {keyof typeof LIST_STYLES} FormatListStyle
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert Unicode TR35-49 list pattern types to ES Intl.ListFormat options
|
||||
*/
|
||||
|
|
@ -35,7 +39,8 @@ const LIST_STYLES = {
|
|||
};
|
||||
|
||||
/**
|
||||
* Format the items in `list` as a list in a locale-dependent manner with the chosen style.
|
||||
* Format the items in `values` as a list in a locale-dependent manner with the
|
||||
* chosen style.
|
||||
*
|
||||
* The available styles are defined in the Unicode TR35-49 spec:
|
||||
* * standard:
|
||||
|
|
@ -60,16 +65,17 @@ const LIST_STYLES = {
|
|||
* A list suitable for narrow units, where space on the screen is very limited.
|
||||
* e.g. "3′ 7″"
|
||||
*
|
||||
* See https://www.unicode.org/reports/tr35/tr35-49/tr35-general.html#ListPatterns for more details.
|
||||
* @see https://www.unicode.org/reports/tr35/tr35-general.html#ListPatterns for more details.
|
||||
*
|
||||
* @param {string[]} list The array of values to format into a list.
|
||||
* @param {Object} [param0]
|
||||
* @param {string} [param0.localeCode] The locale to use (e.g. en-US).
|
||||
* @param {"standard"|"standard-short"|"or"|"or-short"|"unit"|"unit-short"|"unit-narrow"} [param0.style="standard"] The style to format the list with.
|
||||
* @returns {string} The formatted list.
|
||||
* @param {Iterable<string>} values values to format into a list.
|
||||
* @param {{
|
||||
* localeCode?: string;
|
||||
* style?: FormatListStyle;
|
||||
* }} [options]
|
||||
* @returns {string} formatted list.
|
||||
*/
|
||||
export function formatList(list, { localeCode = "", style = "standard" } = {}) {
|
||||
export function formatList(values, { localeCode, style } = {}) {
|
||||
const locale = localeCode || user.lang || "en-US";
|
||||
const formatter = new Intl.ListFormat(locale, LIST_STYLES[style]);
|
||||
return formatter.format(Array.from(list, String));
|
||||
const formatter = new Intl.ListFormat(locale, LIST_STYLES[style || "standard"]);
|
||||
return formatter.format(Array.from(values, String));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
/**
|
||||
* @typedef {{
|
||||
* match: string;
|
||||
* start: number;
|
||||
* end: number;
|
||||
* }} NormalizedMatchResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes a string for use in comparison.
|
||||
*
|
||||
|
|
@ -20,7 +28,7 @@ export function normalize(str) {
|
|||
*
|
||||
* @param {string} src
|
||||
* @param {string} substr
|
||||
* @returns {{match: string, start: number, end: number}}
|
||||
* @returns {NormalizedMatchResult}
|
||||
*/
|
||||
export function normalizedMatch(src, substr) {
|
||||
if (!substr) {
|
||||
|
|
@ -84,7 +92,7 @@ export function normalizedMatch(src, substr) {
|
|||
*
|
||||
* @param {string} src
|
||||
* @param {string} substr
|
||||
* @returns {Array<{match: string, start: number, end: number}>}
|
||||
* @returns {NormalizedMatchResult[]}
|
||||
*/
|
||||
export function normalizedMatches(src, substr) {
|
||||
const matches = [];
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Component, xml } from "@odoo/owl";
|
|||
import { registry } from "@web/core/registry";
|
||||
import { useRegistry } from "@web/core/registry_hook";
|
||||
import { ErrorHandler } from "@web/core/utils/components";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
|
||||
const mainComponents = registry.category("main_components");
|
||||
|
||||
|
|
@ -14,7 +15,7 @@ export class MainComponentsContainer extends Component {
|
|||
static components = { ErrorHandler };
|
||||
static props = {};
|
||||
static template = xml`
|
||||
<div class="o-main-components-container">
|
||||
<div class="o-main-components-container" t-att-class="{'o_rtl': this.isRTL}">
|
||||
<t t-foreach="Components.entries" t-as="C" t-key="C[0]">
|
||||
<ErrorHandler onError="error => this.handleComponentError(error, C)">
|
||||
<t t-component="C[1].Component" t-props="C[1].props"/>
|
||||
|
|
@ -25,6 +26,7 @@ export class MainComponentsContainer extends Component {
|
|||
|
||||
setup() {
|
||||
this.Components = useRegistry(mainComponents);
|
||||
this.isRTL = localization.direction === "rtl";
|
||||
}
|
||||
|
||||
handleComponentError(error, C) {
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ export class ModelFieldSelectorPopover extends Component {
|
|||
async followRelation(fieldDef) {
|
||||
const { modelsInfo } = await this.keepLast.add(
|
||||
this.fieldService.loadPath(
|
||||
fieldDef.relation || this.state.page.resModel,
|
||||
fieldDef.is_property ? fieldDef.relation : this.state.page.resModel,
|
||||
`${fieldDef.name}.*`
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export class ModelSelector extends Component {
|
|||
// we will fetch all models we have access to
|
||||
models: { type: Array, optional: true },
|
||||
nbVisibleModels: { type: Number, optional: true },
|
||||
autofocus: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
sources="sources"
|
||||
placeholder="this.placeholder"
|
||||
autoSelect="props.autoSelect"
|
||||
autofocus="props.autofocus"
|
||||
/>
|
||||
<span class="o_dropdown_button" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { onWillUnmount, useEffect, useRef } from "@odoo/owl";
|
||||
import { onWillUnmount, useEffect, useExternalListener, useRef } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { deepMerge } from "@web/core/utils/objects";
|
||||
import { scrollTo } from "@web/core/utils/scrolling";
|
||||
import { throttleForAnimation } from "@web/core/utils/timing";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
export const ACTIVE_ELEMENT_CLASS = "focus";
|
||||
const throttledFocus = throttleForAnimation((el) => el?.focus());
|
||||
|
|
@ -91,7 +92,10 @@ class NavigationItem {
|
|||
* @private
|
||||
*/
|
||||
_onMouseMove() {
|
||||
if (this._navigator.activeItem !== this) {
|
||||
if (
|
||||
this._navigator.activeItem !== this &&
|
||||
this._navigator._isNavigationAvailable(this.target)
|
||||
) {
|
||||
this.setActive(false);
|
||||
this._options.onMouseEnter?.(this);
|
||||
}
|
||||
|
|
@ -121,7 +125,8 @@ export class Navigator {
|
|||
/**@private*/
|
||||
this._options = deepMerge(
|
||||
{
|
||||
isNavigationAvailable: ({ target }) => this.contains(target),
|
||||
isNavigationAvailable: ({ target }) =>
|
||||
this.contains(target) && (this.isFocused || this._options.virtualFocus),
|
||||
shouldFocusChildInput: true,
|
||||
shouldFocusFirstItem: false,
|
||||
shouldRegisterHotkeys: true,
|
||||
|
|
@ -163,16 +168,24 @@ export class Navigator {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current active item is not null and still inside the DOM
|
||||
* @type {boolean}
|
||||
*/
|
||||
get hasActiveItem() {
|
||||
return Boolean(this.activeItem?.el.isConnected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the focus is on any of the navigable items
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isFocused() {
|
||||
return Boolean(this.activeItem?.el.isConnected);
|
||||
return this.items.some((item) => item.target.contains(document.activeElement));
|
||||
}
|
||||
|
||||
next() {
|
||||
if (!this.isFocused) {
|
||||
if (!this.hasActiveItem) {
|
||||
this.items[0]?.setActive();
|
||||
} else {
|
||||
this.items[(this.activeItemIndex + 1) % this.items.length]?.setActive();
|
||||
|
|
@ -181,7 +194,7 @@ export class Navigator {
|
|||
|
||||
previous() {
|
||||
const index = this.activeItemIndex - 1;
|
||||
if (!this.isFocused || index < 0) {
|
||||
if (!this.hasActiveItem || index < 0) {
|
||||
this.items.at(-1)?.setActive();
|
||||
} else {
|
||||
this.items[index % this.items.length]?.setActive();
|
||||
|
|
@ -226,11 +239,14 @@ export class Navigator {
|
|||
oldActiveItem && oldActiveItem.el.isConnected
|
||||
? this.items.findIndex((item) => item.el === oldActiveItem.el)
|
||||
: -1;
|
||||
const focusedElementIndex = this.items.findIndex((item) => item.el === document.activeElement);
|
||||
if (activeItemIndex > -1) {
|
||||
this._updateActiveItemIndex(activeItemIndex);
|
||||
} else if (this.activeItemIndex >= 0) {
|
||||
const closest = Math.min(this.activeItemIndex, elements.length - 1);
|
||||
this._updateActiveItemIndex(closest);
|
||||
} else if (focusedElementIndex >= 0) {
|
||||
this._updateActiveItemIndex(focusedElementIndex);
|
||||
} else {
|
||||
this._updateActiveItemIndex(-1);
|
||||
}
|
||||
|
|
@ -248,7 +264,7 @@ export class Navigator {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
contains(target) {
|
||||
return this.items.some((item) => item.target === target);
|
||||
return this.items.some((item) => item.target.contains(target));
|
||||
}
|
||||
|
||||
registerHotkeys() {
|
||||
|
|
@ -275,7 +291,7 @@ export class Navigator {
|
|||
global: true,
|
||||
allowRepeat,
|
||||
isAvailable: (target) =>
|
||||
this._options.isNavigationAvailable({ navigator: this, target }) &&
|
||||
this._isNavigationAvailable(target) &&
|
||||
isAvailable({ navigator: this, target }),
|
||||
bypassEditableProtection,
|
||||
})
|
||||
|
|
@ -306,9 +322,13 @@ export class Navigator {
|
|||
*/
|
||||
_setActiveItem(index) {
|
||||
this.activeItem?.setInactive(false);
|
||||
this.activeItem = this.items[index];
|
||||
this.activeItemIndex = index;
|
||||
this._options.onItemActivated?.(this.activeItem.el);
|
||||
if (index >= 0) {
|
||||
this.activeItem = this.items[index];
|
||||
this._options.onItemActivated?.(this.activeItem.el);
|
||||
} else {
|
||||
this.activeItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -322,6 +342,22 @@ export class Navigator {
|
|||
this.activeItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_isNavigationAvailable(target) {
|
||||
return this._options.isNavigationAvailable({ navigator: this, target });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_checkFocus(target) {
|
||||
if (!(target instanceof HTMLElement) || !this._isNavigationAvailable(target)) {
|
||||
this._setActiveItem(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -412,6 +448,7 @@ export function useNavigation(containerRef, options = {}) {
|
|||
() => [containerRef.el]
|
||||
);
|
||||
|
||||
useExternalListener(browser, "focus", ({ target }) => navigator._checkFocus(target), true);
|
||||
onWillUnmount(() => navigator._destroy());
|
||||
|
||||
return navigator;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@ import { EventBus } from "@odoo/owl";
|
|||
import { browser } from "../browser/browser";
|
||||
import { omit } from "../utils/objects";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* code: number;
|
||||
* message: string;
|
||||
* data?: unknown;
|
||||
* type?: string;
|
||||
* }} JsonRpcError
|
||||
*/
|
||||
|
||||
export const rpcBus = new EventBus();
|
||||
|
||||
const RPC_SETTINGS = new Set(["cache", "silent", "xhr", "headers"]);
|
||||
|
|
@ -41,12 +50,15 @@ export class ConnectionLostError extends Error {
|
|||
|
||||
export class ConnectionAbortedError extends Error {}
|
||||
|
||||
export function makeErrorFromResponse(reponse) {
|
||||
/**
|
||||
* @param {JsonRpcError} response
|
||||
*/
|
||||
export function makeErrorFromResponse(response) {
|
||||
// Odoo returns error like this, in a error field instead of properly
|
||||
// using http error codes...
|
||||
const { code, data: errorData, message, type: subType } = reponse;
|
||||
const { code, data: errorData, message, type: subType } = response;
|
||||
const error = new RPCError();
|
||||
error.exceptionName = errorData.name;
|
||||
error.exceptionName = errorData?.name;
|
||||
error.subType = subType;
|
||||
error.data = errorData;
|
||||
error.message = message;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,30 @@
|
|||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { IndexedDB } from "@web/core/utils/indexed_db";
|
||||
import { IDBQuotaExceededError, IndexedDB } from "@web/core/utils/indexed_db";
|
||||
import { deepCopy } from "../utils/objects";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* callback?: function;
|
||||
* type?: "ram" | "disk";
|
||||
* update?: "once" | "always";
|
||||
* }} RPCCacheSettings
|
||||
*/
|
||||
|
||||
function jsonEqual(v1, v2) {
|
||||
return JSON.stringify(v1) === JSON.stringify(v2);
|
||||
}
|
||||
|
||||
function validateSettings({ type, update }) {
|
||||
if (!["ram", "disk"].includes(type)) {
|
||||
throw new Error(`Invalid "type" settings provided to RPCCache: ${type}`);
|
||||
}
|
||||
if (!["always", "once"].includes(update)) {
|
||||
throw new Error(`Invalid "update" settings provided to RPCCache: ${update}`);
|
||||
}
|
||||
}
|
||||
|
||||
const CRYPTO_ALGO = "AES-GCM";
|
||||
const MAX_STORAGE_SIZE = 2 * 1024 * 1024 * 1024; // 2Gb
|
||||
|
||||
class Crypto {
|
||||
constructor(secret) {
|
||||
|
|
@ -91,91 +113,132 @@ export class RPCCache {
|
|||
this.indexedDB = new IndexedDB(name, version + CRYPTO_ALGO);
|
||||
this.ramCache = new RamCache();
|
||||
this.pendingRequests = {};
|
||||
this.checkSize(); // we want to control the disk space used by Odoo
|
||||
}
|
||||
|
||||
async checkSize() {
|
||||
const { usage } = await navigator.storage.estimate();
|
||||
if (usage > MAX_STORAGE_SIZE) {
|
||||
console.log(`Deleting indexedDB database as maximum storage size is reached`);
|
||||
return this.indexedDB.deleteDatabase();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* callback?: function;
|
||||
* type?: "ram" | "disk";
|
||||
* update?: "once" | "always";
|
||||
* }} RPCCacheSettings
|
||||
* @param {string} table
|
||||
* @param {string} key
|
||||
* @param {function} fallback
|
||||
* @param {RPCCacheSettings} settings
|
||||
*/
|
||||
read(table, key, fallback, { callback = () => {}, type = "ram", update = "once" } = {}) {
|
||||
const ramValue = this.ramCache.read(table, key);
|
||||
const requestKey = `${table}/${key}`;
|
||||
const hadPendingRequest = requestKey in this.pendingRequests;
|
||||
this.pendingRequests[requestKey] = this.pendingRequests[requestKey] || [];
|
||||
this.pendingRequests[requestKey].push(callback);
|
||||
validateSettings({ type, update });
|
||||
|
||||
if (ramValue && (update !== "always" || hadPendingRequest)) {
|
||||
let ramValue = this.ramCache.read(table, key);
|
||||
|
||||
const requestKey = `${table}/${key}`;
|
||||
const hasPendingRequest = requestKey in this.pendingRequests;
|
||||
if (hasPendingRequest) {
|
||||
// never do the same call multiple times in parallel => return the same value for all
|
||||
// those calls, but store their callback to call them when/if the real value is obtained
|
||||
this.pendingRequests[requestKey].callbacks.push(callback);
|
||||
return ramValue.then((result) => deepCopy(result));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const fromCache = new Deferred();
|
||||
let fromCacheValue;
|
||||
const onFullfilled = (result) => {
|
||||
resolve(deepCopy(result));
|
||||
this.ramCache.write(table, key, Promise.resolve(result));
|
||||
const hasChanged =
|
||||
(fromCacheValue && fromCacheValue !== JSON.stringify(result)) || false;
|
||||
this.pendingRequests[requestKey]?.forEach((cb) => cb(deepCopy(result), hasChanged));
|
||||
delete this.pendingRequests[requestKey];
|
||||
if (type === "disk") {
|
||||
this.crypto.encrypt(result).then((encryptedResult) => {
|
||||
this.indexedDB.write(table, key, encryptedResult);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const onRejected = async (error) => {
|
||||
delete this.pendingRequests[requestKey];
|
||||
await fromCache;
|
||||
if (fromCacheValue) {
|
||||
// promise has already been fullfilled with the cached value
|
||||
throw error;
|
||||
}
|
||||
this.ramCache.delete(table, key); // remove rejected prom from ram cache
|
||||
reject(error);
|
||||
};
|
||||
const prom = fallback().then(onFullfilled, onRejected);
|
||||
if (ramValue) {
|
||||
ramValue.then((value) => {
|
||||
resolve(deepCopy(value));
|
||||
fromCacheValue = JSON.stringify(value);
|
||||
fromCache.resolve();
|
||||
});
|
||||
} else {
|
||||
this.ramCache.write(table, key, prom);
|
||||
if (type === "disk") {
|
||||
this.indexedDB.read(table, key).then(async (result) => {
|
||||
if (result) {
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await this.crypto.decrypt(result);
|
||||
} catch {
|
||||
fromCache.resolve();
|
||||
// Do nothing ! The cryptoKey is probably different.
|
||||
// The data will be updated with the new cryptoKey.
|
||||
return;
|
||||
}
|
||||
resolve(deepCopy(decrypted));
|
||||
this.ramCache.write(table, key, Promise.resolve(decrypted));
|
||||
fromCacheValue = JSON.stringify(decrypted);
|
||||
if (!ramValue || update === "always") {
|
||||
const request = { callbacks: [callback], invalidated: false };
|
||||
this.pendingRequests[requestKey] = request;
|
||||
|
||||
// execute the fallback and write the result in the caches
|
||||
const prom = new Promise((resolve, reject) => {
|
||||
const fromCache = new Deferred();
|
||||
let fromCacheValue;
|
||||
const onFullfilled = (result) => {
|
||||
resolve(result);
|
||||
// call the pending request callbacks with the result
|
||||
const hasChanged = !!fromCacheValue && !jsonEqual(fromCacheValue, result);
|
||||
request.callbacks.forEach((cb) => cb(deepCopy(result), hasChanged));
|
||||
if (request.invalidated) {
|
||||
return result;
|
||||
}
|
||||
delete this.pendingRequests[requestKey];
|
||||
// update the ram and optionally the disk caches with the latest data
|
||||
this.ramCache.write(table, key, Promise.resolve(result));
|
||||
if (type === "disk") {
|
||||
this.crypto.encrypt(result).then((encryptedResult) => {
|
||||
this.indexedDB.write(table, key, encryptedResult).catch((e) => {
|
||||
if (e instanceof IDBQuotaExceededError) {
|
||||
this.indexedDB.deleteDatabase();
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const onRejected = async (error) => {
|
||||
await fromCache;
|
||||
if (!request.invalidated) {
|
||||
delete this.pendingRequests[requestKey];
|
||||
if (!fromCacheValue) {
|
||||
this.ramCache.delete(table, key); // remove rejected prom from ram cache
|
||||
}
|
||||
}
|
||||
if (fromCacheValue) {
|
||||
// promise has already been fullfilled with the cached value
|
||||
throw error;
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
fallback().then(onFullfilled, onRejected);
|
||||
|
||||
// speed up the request by using the caches
|
||||
if (ramValue) {
|
||||
// ramValue is always already resolved here, as it can't be pending (otherwise
|
||||
// we would have early returned because of `pendingRequests`) and it would have
|
||||
// been removed from the ram cache if it had been rejected
|
||||
// => no need to define a `catch` callback.
|
||||
ramValue.then((value) => {
|
||||
resolve(value);
|
||||
fromCacheValue = value;
|
||||
fromCache.resolve();
|
||||
});
|
||||
} else if (type === "disk") {
|
||||
this.indexedDB
|
||||
.read(table, key)
|
||||
.then(async (result) => {
|
||||
if (result) {
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await this.crypto.decrypt(result);
|
||||
} catch {
|
||||
// Do nothing ! The cryptoKey is probably different.
|
||||
// The data will be updated with the new cryptoKey.
|
||||
return;
|
||||
}
|
||||
resolve(decrypted);
|
||||
fromCacheValue = decrypted;
|
||||
}
|
||||
})
|
||||
.finally(() => fromCache.resolve());
|
||||
} else {
|
||||
fromCache.resolve(); // fromCacheValue will remain undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
this.ramCache.write(table, key, prom);
|
||||
ramValue = prom;
|
||||
}
|
||||
|
||||
return ramValue.then((result) => deepCopy(result));
|
||||
}
|
||||
|
||||
invalidate(tables) {
|
||||
this.indexedDB.invalidate(tables);
|
||||
this.ramCache.invalidate(tables);
|
||||
// flag the pending requests as invalidated s.t. we don't write their results in caches
|
||||
for (const key in this.pendingRequests) {
|
||||
this.pendingRequests[key].invalidated = true;
|
||||
}
|
||||
this.pendingRequests = {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Component, onWillRender, onWillUpdateProps, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { KeepLast } from "@web/core/utils/concurrency";
|
||||
|
||||
/**
|
||||
* A notebook component that will render only the current page and allow
|
||||
|
|
@ -55,6 +56,7 @@ export class Notebook extends Component {
|
|||
className: "",
|
||||
orientation: "horizontal",
|
||||
onPageUpdate: () => {},
|
||||
onWillActivatePage: () => {},
|
||||
};
|
||||
static props = {
|
||||
slots: { type: Object, optional: true },
|
||||
|
|
@ -65,6 +67,7 @@ export class Notebook extends Component {
|
|||
orientation: { type: String, optional: true },
|
||||
icons: { type: Object, optional: true },
|
||||
onPageUpdate: { type: Function, optional: true },
|
||||
onWillActivatePage: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
|
|
@ -73,6 +76,7 @@ export class Notebook extends Component {
|
|||
this.invalidPages = new Set();
|
||||
this.state = useState({ currentPage: null });
|
||||
this.state.currentPage = this.computeActivePage(this.props.defaultPage, true);
|
||||
this.keepLastPageTransition = new KeepLast();
|
||||
useEffect(
|
||||
() => {
|
||||
this.props.onPageUpdate(this.state.currentPage);
|
||||
|
|
@ -100,10 +104,14 @@ export class Notebook extends Component {
|
|||
return page.Component && page;
|
||||
}
|
||||
|
||||
activatePage(pageIndex) {
|
||||
async activatePage(pageIndex) {
|
||||
if (!this.disabledPages.includes(pageIndex) && this.state.currentPage !== pageIndex) {
|
||||
this.activePane.el?.classList.remove("show");
|
||||
this.state.currentPage = pageIndex;
|
||||
const prom = (async () => this.props.onWillActivatePage(pageIndex))();
|
||||
const canProceed = await this.keepLastPageTransition.add(prom);
|
||||
if (canProceed !== false) {
|
||||
this.activePane.el?.classList.remove("show");
|
||||
this.state.currentPage = pageIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
.o_notebook_headers {
|
||||
margin: 0 var(--Notebook-margin-x, 0);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
&::-webkit-scrollbar {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export class Popover extends Component {
|
|||
|
||||
// Positioning props
|
||||
fixedPosition: { optional: true, type: Boolean },
|
||||
extendedFlipping: { optional: true, type: Boolean },
|
||||
holdOnHover: { optional: true, type: Boolean },
|
||||
onPositioned: { optional: true, type: Function },
|
||||
position: {
|
||||
|
|
@ -130,7 +131,20 @@ export class Popover extends Component {
|
|||
this.popoverRef = useRef("ref");
|
||||
this.position = usePosition("ref", () => this.props.target, this.positioningOptions);
|
||||
|
||||
onMounted(() => POPOVERS.set(this.props.target, this.popoverRef.el));
|
||||
if (!this.props.animation) {
|
||||
this.animationDone = true;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!this.props.fixedPosition && this.animationDone) {
|
||||
this.position.unlock();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
POPOVERS.set(this.props.target, this.popoverRef.el);
|
||||
resizeObserver.observe(this.popoverRef.el);
|
||||
});
|
||||
onWillDestroy(() => POPOVERS.delete(this.props.target));
|
||||
|
||||
if (this.props.target.isConnected) {
|
||||
|
|
@ -153,6 +167,7 @@ export class Popover extends Component {
|
|||
|
||||
get positioningOptions() {
|
||||
return {
|
||||
extendedFlipping: this.props.extendedFlipping,
|
||||
margin: this.props.arrow ? 8 : 0,
|
||||
onPositioned: (el, solution) => {
|
||||
this.onPositioned(solution);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const popoverService = {
|
|||
closeOnEscape: options.closeOnEscape,
|
||||
component,
|
||||
componentProps: markRaw(props),
|
||||
extendedFlipping: options.extendedFlipping,
|
||||
ref: options.ref,
|
||||
class: options.popoverClass,
|
||||
animation: options.animation,
|
||||
|
|
@ -62,6 +63,7 @@ export const popoverService = {
|
|||
env: options.env,
|
||||
onRemove: options.onClose,
|
||||
rootId: target.getRootNode()?.host?.id,
|
||||
sequence: options.sequence,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ import { localization } from "@web/core/l10n/localization";
|
|||
* position of the popper relative to the target
|
||||
* @property {boolean} [flip=true]
|
||||
* allow the popper to try a flipped direction when it overflows the container
|
||||
* @property {boolean} [extendedFlipping=false]
|
||||
* allow the popper to try for all possible flipping directions (including center)
|
||||
* when it overflows the container
|
||||
* @property {boolean} [shrink=false]
|
||||
* reduce the popper's height when it overflows the container
|
||||
*/
|
||||
|
|
@ -43,7 +46,15 @@ const DIRECTIONS = { t: "top", r: "right", b: "bottom", l: "left", c: "center" }
|
|||
/** @type {{[v: string]: Variant}} */
|
||||
const VARIANTS = { s: "start", m: "middle", e: "end", f: "fit" };
|
||||
/** @type DirectionFlipOrder */
|
||||
const DIRECTION_FLIP_ORDER = { top: "tb", right: "rl", bottom: "bt", left: "lr" };
|
||||
const DIRECTION_FLIP_ORDER = { top: "tb", right: "rl", bottom: "bt", left: "lr", center: "c" };
|
||||
/** @type DirectionFlipOrder */
|
||||
const EXTENDED_DIRECTION_FLIP_ORDER = {
|
||||
top: "tbrlc",
|
||||
right: "rlbtc",
|
||||
bottom: "btrlc",
|
||||
left: "lrbtc",
|
||||
center: "c",
|
||||
};
|
||||
/** @type VariantFlipOrder */
|
||||
const VARIANT_FLIP_ORDER = { start: "se", middle: "m", end: "es", fit: "f" };
|
||||
|
||||
|
|
@ -99,10 +110,19 @@ export function reverseForRTL(direction, variant = "middle") {
|
|||
* the containing block of the popper.
|
||||
* => can be applied to popper.style.(top|left)
|
||||
*/
|
||||
function computePosition(popper, target, { container, flip, margin, position, shrink }) {
|
||||
function computePosition(
|
||||
popper,
|
||||
target,
|
||||
{ container, extendedFlipping, flip, margin, position, shrink }
|
||||
) {
|
||||
// Retrieve directions and variants
|
||||
const [direction, variant = "middle"] = reverseForRTL(...position.split("-"));
|
||||
const directions = flip ? DIRECTION_FLIP_ORDER[direction] : [direction.at(0)];
|
||||
let directions = [direction.at(0)];
|
||||
if (flip) {
|
||||
directions = extendedFlipping
|
||||
? EXTENDED_DIRECTION_FLIP_ORDER[direction]
|
||||
: DIRECTION_FLIP_ORDER[direction];
|
||||
}
|
||||
const variants = VARIANT_FLIP_ORDER[variant];
|
||||
|
||||
// Retrieve container
|
||||
|
|
@ -164,7 +184,7 @@ function computePosition(popper, target, { container, flip, margin, position, sh
|
|||
function getPositioningData(d, v) {
|
||||
const [direction, variant] = reverseForRTL(DIRECTIONS[d], VARIANTS[v]);
|
||||
const result = { direction, variant };
|
||||
const vertical = ["t", "b"].includes(d);
|
||||
const vertical = ["t", "b", "c"].includes(d);
|
||||
const variantPrefix = vertical ? "v" : "h";
|
||||
const directionValue = directionsData[d];
|
||||
let variantValue = variantsData[variantPrefix + v];
|
||||
|
|
@ -209,7 +229,7 @@ function computePosition(popper, target, { container, flip, margin, position, sh
|
|||
|
||||
// All non zero values of variantOverflow lead to the
|
||||
// same malus value since it can be corrected by shifting
|
||||
const malus = Math.abs(directionOverflow) + (variantOverflow && 1);
|
||||
let malus = Math.abs(directionOverflow) + (variantOverflow && 1);
|
||||
|
||||
// Apply variant offset
|
||||
variantValue -= variantOverflow;
|
||||
|
|
@ -224,7 +244,13 @@ function computePosition(popper, target, { container, flip, margin, position, sh
|
|||
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
|
||||
result.top = positioning.top - popBox.top;
|
||||
result.left = positioning.left - popBox.left;
|
||||
if (shrink && malus) {
|
||||
if (d === "c") {
|
||||
// Artificial way to say the center direction is a fallback to every other
|
||||
// once there is a direction overflow since we can always shift the position
|
||||
// in any direction in that case
|
||||
malus = 1.001;
|
||||
result.top -= directionOverflow;
|
||||
} else if (shrink && malus) {
|
||||
const minTop = Math.floor(!vertical && v === "s" ? targetBox.top : contBox.top);
|
||||
result.top = Math.max(minTop, result.top);
|
||||
|
||||
|
|
@ -243,15 +269,6 @@ function computePosition(popper, target, { container, flip, margin, position, sh
|
|||
return { result, malus };
|
||||
}
|
||||
|
||||
if (direction === "center") {
|
||||
return {
|
||||
top: directionsData[direction[0]] - popBox.top,
|
||||
left: variantsData.vm - popBox.left,
|
||||
direction: DIRECTIONS[direction[0]],
|
||||
variant: "middle",
|
||||
};
|
||||
}
|
||||
|
||||
// Find best solution
|
||||
const matches = [];
|
||||
for (const d of directions) {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@
|
|||
.o_record_autocomplete_with_caret {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
position: relative;
|
||||
|
||||
&:hover, &:focus-within {
|
||||
&::after {
|
||||
@include o-caret-down;
|
||||
align-self: center;
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ export function useTagNavigation(refName, options = {}) {
|
|||
getItems: () =>
|
||||
tagsContainerRef.el?.querySelectorAll(":scope .o_tag, :scope .o-autocomplete--input") ??
|
||||
[],
|
||||
isNavigationAvailable: ({ navigator, target }) => isEnabled() && navigator.contains(target),
|
||||
isNavigationAvailable: ({ navigator, target }) =>
|
||||
isEnabled() && navigator.isFocused && navigator.contains(target),
|
||||
hotkeys: {
|
||||
tab: null,
|
||||
"shift+tab": null,
|
||||
|
|
|
|||
|
|
@ -140,10 +140,11 @@ export class SelectMenu extends Component {
|
|||
|
||||
this.selectedChoice = this.getSelectedChoice(this.props);
|
||||
onWillUpdateProps((nextProps) => {
|
||||
if (this.state.choices !== nextProps.choices) {
|
||||
const choicesChanged = this.state.choices !== nextProps.choices;
|
||||
if (choicesChanged) {
|
||||
this.state.choices = nextProps.choices;
|
||||
}
|
||||
if (this.props.value !== nextProps.value) {
|
||||
if (choicesChanged || this.props.value !== nextProps.value) {
|
||||
this.selectedChoice = this.getSelectedChoice(nextProps);
|
||||
}
|
||||
});
|
||||
|
|
@ -278,7 +279,7 @@ export class SelectMenu extends Component {
|
|||
this.inputRef.el.focus();
|
||||
}
|
||||
this.menuRef.el?.addEventListener("scroll", (ev) => this.onScroll(ev));
|
||||
const selectedElement = this.menuRef.el?.querySelectorAll(".active")[0];
|
||||
const selectedElement = this.menuRef.el?.querySelectorAll(".selected")[0];
|
||||
if (selectedElement) {
|
||||
scrollTo(selectedElement);
|
||||
}
|
||||
|
|
@ -298,7 +299,7 @@ export class SelectMenu extends Component {
|
|||
|
||||
getItemClass(choice) {
|
||||
if (this.isOptionSelected(choice)) {
|
||||
return "o_select_menu_item fw-bolder active";
|
||||
return "o_select_menu_item fw-bolder selected";
|
||||
} else {
|
||||
return "o_select_menu_item";
|
||||
}
|
||||
|
|
@ -340,6 +341,9 @@ export class SelectMenu extends Component {
|
|||
}
|
||||
} else if (!this.selectedChoice || this.selectedChoice.value !== value) {
|
||||
this.props.onSelect(value);
|
||||
if (this.inputRef.el) {
|
||||
this.inputRef.el.value = this.state.choices.find((c) => c.value === value).label;
|
||||
}
|
||||
}
|
||||
this.state.searchValue = null;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue