mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 08:32:03 +02:00
vanilla 19.0
This commit is contained in:
parent
991d2234ca
commit
d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions
|
|
@ -0,0 +1,12 @@
|
|||
import { expect } from "@odoo/hoot";
|
||||
|
||||
/**
|
||||
* Use `expect.step` instead
|
||||
* @deprecated
|
||||
*/
|
||||
export const asyncStep = expect.step;
|
||||
/**
|
||||
* Use `expect.waitForSteps` instead
|
||||
* @deprecated
|
||||
*/
|
||||
export const waitForSteps = expect.waitForSteps;
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import { after, destroy, getFixture } from "@odoo/hoot";
|
||||
import { queryFirst, queryOne } from "@odoo/hoot-dom";
|
||||
import { App, Component, xml } from "@odoo/owl";
|
||||
import { appTranslateFn } from "@web/core/l10n/translation";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { getPopoverForTarget } from "@web/core/popover/popover";
|
||||
import { getTemplate as defaultGetTemplate } from "@web/core/templates";
|
||||
import { isIterable } from "@web/core/utils/arrays";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import {
|
||||
customDirectives as defaultCustomDirectives,
|
||||
globalValues as defaultGlobalValues,
|
||||
} from "@web/env";
|
||||
import { getMockEnv, makeMockEnv } from "./env_test_helpers";
|
||||
|
||||
/**
|
||||
* @typedef {import("@odoo/hoot-dom").Target} Target
|
||||
* @typedef {import("@odoo/owl").Component} Component
|
||||
* @typedef {import("@web/env").OdooEnv} OdooEnv
|
||||
*
|
||||
* @typedef {ConstructorParameters<typeof App>[1]} AppConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template [P=any]
|
||||
* @template [E=any]
|
||||
* @typedef {import("@odoo/owl").ComponentConstructor<P, E>} ComponentConstructor
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ComponentConstructor} ComponentClass
|
||||
* @param {HTMLElement | ShadowRoot} targetEl
|
||||
* @param {AppConfig} config
|
||||
*/
|
||||
const mountComponentWithCleanup = (ComponentClass, targetEl, config) => {
|
||||
const app = new App(ComponentClass, config);
|
||||
after(() => destroy(app));
|
||||
return app.mount(targetEl);
|
||||
};
|
||||
|
||||
patch(MainComponentsContainer.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
hasMainComponent = true;
|
||||
after(() => (hasMainComponent = false));
|
||||
},
|
||||
});
|
||||
|
||||
let hasMainComponent = false;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {App | Component} parent
|
||||
* @param {(component: Component) => boolean} predicate
|
||||
* @returns {Component | null}
|
||||
*/
|
||||
export function findComponent(parent, predicate) {
|
||||
const rootNode = parent instanceof App ? parent.root : parent.__owl__;
|
||||
const queue = [rootNode, ...Object.values(rootNode.children)];
|
||||
while (queue.length) {
|
||||
const { children, component } = queue.pop();
|
||||
if (predicate(component)) {
|
||||
return component;
|
||||
}
|
||||
queue.unshift(...Object.values(children));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dropdown menu for a specific toggler.
|
||||
*
|
||||
* @param {Target} togglerSelector
|
||||
* @returns {HTMLElement | undefined}
|
||||
*/
|
||||
export function getDropdownMenu(togglerSelector) {
|
||||
if (getMockEnv().isSmall) {
|
||||
return queryFirst(".o-dropdown--menu", { eq: -1 });
|
||||
}
|
||||
let el = queryFirst(togglerSelector);
|
||||
if (el && !el.classList.contains("o-dropdown")) {
|
||||
el = el.querySelector(".o-dropdown");
|
||||
}
|
||||
if (!el) {
|
||||
throw new Error(`getDropdownMenu: Could not find element "${togglerSelector}".`);
|
||||
}
|
||||
return getPopoverForTarget(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a given component to the test fixture.
|
||||
*
|
||||
* By default, a `MainComponentsContainer` component is also mounted to the
|
||||
* fixture if none is found in the component tree (this can be overridden by the
|
||||
* `noMainContainer` option).
|
||||
*
|
||||
* @template {ComponentConstructor<P, E>} C
|
||||
* @template [P={}]
|
||||
* @template [E=OdooEnv]
|
||||
* @param {C | string} ComponentClass
|
||||
* @param {AppConfig & {
|
||||
* componentEnv?: Partial<OdooEnv>;
|
||||
* containerEnv?: Partial<OdooEnv>;
|
||||
* fixtureClassName?: string | string[] | null;
|
||||
* env?: E;
|
||||
* noMainContainer?: boolean;
|
||||
* props?: P;
|
||||
* target?: Target;
|
||||
* }} [options]
|
||||
*/
|
||||
export async function mountWithCleanup(ComponentClass, options) {
|
||||
const {
|
||||
componentEnv,
|
||||
containerEnv,
|
||||
customDirectives = defaultCustomDirectives,
|
||||
env,
|
||||
fixtureClassName = "o_web_client",
|
||||
getTemplate = defaultGetTemplate,
|
||||
globalValues = defaultGlobalValues,
|
||||
noMainContainer,
|
||||
props,
|
||||
target,
|
||||
templates,
|
||||
translatableAttributes,
|
||||
translateFn = appTranslateFn,
|
||||
} = options || {};
|
||||
|
||||
// Common component configuration
|
||||
const commonConfig = {
|
||||
customDirectives,
|
||||
getTemplate,
|
||||
globalValues,
|
||||
templates,
|
||||
translatableAttributes,
|
||||
translateFn,
|
||||
// The following keys are forced to ensure validation of all tested components
|
||||
dev: false,
|
||||
test: true,
|
||||
warnIfNoStaticProps: true,
|
||||
};
|
||||
|
||||
// Fixture
|
||||
const fixture = getFixture();
|
||||
const targetEl = target ? queryOne(target) : fixture;
|
||||
if (fixtureClassName) {
|
||||
const list = isIterable(fixtureClassName) ? fixtureClassName : [fixtureClassName];
|
||||
fixture.classList.add(...list);
|
||||
}
|
||||
|
||||
if (typeof ComponentClass === "string") {
|
||||
// Convert templates to components (if needed)
|
||||
ComponentClass = class extends Component {
|
||||
static name = "anonymous component";
|
||||
static props = {};
|
||||
static template = xml`${ComponentClass}`;
|
||||
};
|
||||
}
|
||||
|
||||
const commonEnv = env || getMockEnv() || (await makeMockEnv());
|
||||
const componentConfig = {
|
||||
...commonConfig,
|
||||
env: Object.assign(Object.create(commonEnv), componentEnv),
|
||||
name: `TEST: ${ComponentClass.name}`,
|
||||
props,
|
||||
};
|
||||
|
||||
/** @type {InstanceType<C>} */
|
||||
const component = await mountComponentWithCleanup(ComponentClass, targetEl, componentConfig);
|
||||
|
||||
if (!noMainContainer && !hasMainComponent) {
|
||||
const containerConfig = {
|
||||
...commonConfig,
|
||||
env: Object.assign(Object.create(commonEnv), containerEnv),
|
||||
name: `TEST: ${ComponentClass.name} (main container)`,
|
||||
props: {},
|
||||
};
|
||||
await mountComponentWithCleanup(MainComponentsContainer, targetEl, containerConfig);
|
||||
}
|
||||
|
||||
return component;
|
||||
}
|
||||
|
|
@ -0,0 +1,439 @@
|
|||
import { after, afterEach } from "@odoo/hoot";
|
||||
import {
|
||||
check,
|
||||
clear,
|
||||
click,
|
||||
dblclick,
|
||||
drag,
|
||||
edit,
|
||||
fill,
|
||||
getActiveElement,
|
||||
hover,
|
||||
keyDown,
|
||||
keyUp,
|
||||
manuallyDispatchProgrammaticEvent,
|
||||
pointerDown,
|
||||
press,
|
||||
queryOne,
|
||||
queryRect,
|
||||
scroll,
|
||||
select,
|
||||
uncheck,
|
||||
waitFor,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { advanceFrame, advanceTime, animationFrame } from "@odoo/hoot-mock";
|
||||
import { hasTouch } from "@web/core/browser/feature_detection";
|
||||
|
||||
/**
|
||||
* @typedef {import("@odoo/hoot-dom").DragHelpers} DragHelpers
|
||||
* @typedef {import("@odoo/hoot-dom").DragOptions} DragOptions
|
||||
* @typedef {import("@odoo/hoot-dom").FillOptions} FillOptions
|
||||
* @typedef {import("@odoo/hoot-dom").InputValue} InputValue
|
||||
* @typedef {import("@odoo/hoot-dom").KeyStrokes} KeyStrokes
|
||||
* @typedef {import("@odoo/hoot-dom").PointerOptions} PointerOptions
|
||||
* @typedef {import("@odoo/hoot-dom").Position} Position
|
||||
* @typedef {import("@odoo/hoot-dom").QueryOptions} QueryOptions
|
||||
* @typedef {import("@odoo/hoot-dom").Target} Target
|
||||
*
|
||||
* @typedef {DragOptions & {
|
||||
* initialPointerMoveDistance?: number;
|
||||
* pointerDownDuration: number;
|
||||
* }} DragAndDropOptions
|
||||
*
|
||||
* @typedef {{
|
||||
* altKey?: boolean;
|
||||
* ctrlKey?: boolean;
|
||||
* metaKey?: boolean;
|
||||
* shiftKey?: boolean;
|
||||
* }} KeyModifierOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("@odoo/hoot-dom").MaybePromise<T>} MaybePromise
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {(...args: Parameters<T>) => MaybePromise<ReturnType<T>>} Promisify
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {typeof click} clickFn
|
||||
* @param {Promise<Element>} nodePromise
|
||||
* @param {PointerOptions & KeyModifierOptions} [options]
|
||||
*/
|
||||
const callClick = async (clickFn, nodePromise, options) => {
|
||||
const actions = [() => clickFn(nodePromise, options)];
|
||||
if (options?.altKey) {
|
||||
actions.unshift(() => keyDown("Alt"));
|
||||
actions.push(() => keyUp("Alt"));
|
||||
}
|
||||
if (options?.ctrlKey) {
|
||||
actions.unshift(() => keyDown("Control"));
|
||||
actions.push(() => keyUp("Control"));
|
||||
}
|
||||
if (options?.metaKey) {
|
||||
actions.unshift(() => keyDown("Meta"));
|
||||
actions.push(() => keyUp("Meta"));
|
||||
}
|
||||
if (options?.shiftKey) {
|
||||
actions.unshift(() => keyDown("Shift"));
|
||||
actions.push(() => keyUp("Shift"));
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
await action();
|
||||
}
|
||||
await animationFrame();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Node} node
|
||||
* @param {number} [distance]
|
||||
*/
|
||||
const dragForTolerance = async (node, distance) => {
|
||||
if (distance === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = {
|
||||
x: distance || 100,
|
||||
y: distance || 100,
|
||||
};
|
||||
await hover(node, { position, relative: true });
|
||||
await advanceFrame();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} [delay]
|
||||
* These params are used to move the pointer from an arbitrary distance in the
|
||||
* element to trigger a drag sequence (the distance required to trigger a drag
|
||||
* is defined by the `tolerance` option in the draggable hook builder).
|
||||
* @see {draggable_hook_builder.js}
|
||||
*/
|
||||
const waitForTouchDelay = async (delay) => {
|
||||
if (hasTouch()) {
|
||||
await advanceTime(delay || 500);
|
||||
}
|
||||
};
|
||||
|
||||
let unconsumedContains = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (unconsumedContains.length) {
|
||||
const targets = unconsumedContains.map(String).join(", ");
|
||||
unconsumedContains = [];
|
||||
throw new Error(
|
||||
`called 'contains' on "${targets}" without any action: use 'waitFor' if no interaction is intended`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Target} target
|
||||
* @param {QueryOptions} [options]
|
||||
*/
|
||||
export function contains(target, options) {
|
||||
const consumeContains = () => {
|
||||
if (!consumed) {
|
||||
consumed = true;
|
||||
unconsumedContains.pop();
|
||||
}
|
||||
};
|
||||
|
||||
const focusCurrent = async () => {
|
||||
const node = await nodePromise;
|
||||
if (node !== getActiveElement(node)) {
|
||||
await pointerDown(node);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
let consumed = false;
|
||||
unconsumedContains.push(target);
|
||||
|
||||
/** @type {Promise<Element>} */
|
||||
const nodePromise = waitFor.as("contains")(target, { visible: true, ...options });
|
||||
return {
|
||||
/**
|
||||
* @param {PointerOptions} [options]
|
||||
*/
|
||||
check: async (options) => {
|
||||
consumeContains();
|
||||
await check(nodePromise, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
clear: async (options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await clear({ confirm: "auto", ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {PointerOptions & KeyModifierOptions} [options]
|
||||
*/
|
||||
click: async (options) => {
|
||||
consumeContains();
|
||||
await callClick(click, nodePromise, options);
|
||||
},
|
||||
/**
|
||||
* @param {PointerOptions & KeyModifierOptions} [options]
|
||||
*/
|
||||
dblclick: async (options) => {
|
||||
consumeContains();
|
||||
await callClick(dblclick, nodePromise, options);
|
||||
},
|
||||
/**
|
||||
* @param {DragAndDropOptions} [options]
|
||||
* @returns {Promise<DragHelpers>}
|
||||
*/
|
||||
drag: async (options) => {
|
||||
consumeContains();
|
||||
/** @type {typeof cancel} */
|
||||
const cancelWithDelay = async (options) => {
|
||||
await cancel(options);
|
||||
await advanceFrame();
|
||||
};
|
||||
|
||||
/** @type {typeof drop} */
|
||||
const dropWithDelay = async (to, options) => {
|
||||
if (to) {
|
||||
await moveToWithDelay(to, options);
|
||||
}
|
||||
await drop();
|
||||
await advanceFrame();
|
||||
};
|
||||
|
||||
/** @type {typeof moveTo} */
|
||||
const moveToWithDelay = async (to, options) => {
|
||||
await moveTo(to, options);
|
||||
await advanceFrame();
|
||||
|
||||
return helpersWithDelay;
|
||||
};
|
||||
|
||||
const { cancel, drop, moveTo } = await drag(nodePromise, options);
|
||||
const helpersWithDelay = {
|
||||
cancel: cancelWithDelay,
|
||||
drop: dropWithDelay,
|
||||
moveTo: moveToWithDelay,
|
||||
};
|
||||
|
||||
await waitForTouchDelay(options?.pointerDownDuration);
|
||||
|
||||
await dragForTolerance(nodePromise, options?.initialPointerMoveDistance);
|
||||
|
||||
return helpersWithDelay;
|
||||
},
|
||||
/**
|
||||
* @param {Target} target
|
||||
* @param {DragAndDropOptions} [dropOptions]
|
||||
* @param {DragOptions} [dragOptions]
|
||||
*/
|
||||
dragAndDrop: async (target, dropOptions, dragOptions) => {
|
||||
consumeContains();
|
||||
const [from, to] = await Promise.all([nodePromise, waitFor(target)]);
|
||||
const { drop, moveTo } = await drag(from, dragOptions);
|
||||
|
||||
await waitForTouchDelay(dropOptions?.pointerDownDuration);
|
||||
|
||||
await dragForTolerance(from, dropOptions?.initialPointerMoveDistance);
|
||||
|
||||
await moveTo(to, dropOptions);
|
||||
await advanceFrame();
|
||||
|
||||
await drop();
|
||||
await advanceFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
edit: async (value, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await edit(value, { confirm: "auto", ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
* @param {FillOptions} [options]
|
||||
*/
|
||||
fill: async (value, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await fill(value, { confirm: "auto", ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
focus: async () => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await animationFrame();
|
||||
},
|
||||
hover: async () => {
|
||||
consumeContains();
|
||||
await hover(nodePromise);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {KeyStrokes} keyStrokes
|
||||
* @param {KeyboardEventInit} [options]
|
||||
*/
|
||||
keyDown: async (keyStrokes, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await keyDown(keyStrokes, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {KeyStrokes} keyStrokes
|
||||
* @param {KeyboardEventInit} [options]
|
||||
*/
|
||||
keyUp: async (keyStrokes, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await keyUp(keyStrokes, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {KeyStrokes} keyStrokes
|
||||
* @param {KeyboardEventInit} [options]
|
||||
*/
|
||||
press: async (keyStrokes, options) => {
|
||||
consumeContains();
|
||||
await focusCurrent();
|
||||
await press(keyStrokes, options);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {Position} position
|
||||
*/
|
||||
scroll: async (position) => {
|
||||
consumeContains();
|
||||
// disable "scrollable" check
|
||||
await scroll(nodePromise, position, { scrollable: false, ...options });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
*/
|
||||
select: async (value) => {
|
||||
consumeContains();
|
||||
await select(value, { target: nodePromise });
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {InputValue} value
|
||||
*/
|
||||
selectDropdownItem: async (value) => {
|
||||
consumeContains();
|
||||
await callClick(click, queryOne(".dropdown-toggle", { root: await nodePromise }));
|
||||
const item = await waitFor(`.dropdown-item:contains(${value})`);
|
||||
await callClick(click, item);
|
||||
await animationFrame();
|
||||
},
|
||||
/**
|
||||
* @param {PointerOptions} [options]
|
||||
*/
|
||||
uncheck: async (options) => {
|
||||
consumeContains();
|
||||
await uncheck(nodePromise, options);
|
||||
await animationFrame();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} style
|
||||
*/
|
||||
export function defineStyle(style) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = style;
|
||||
|
||||
document.head.appendChild(styleEl);
|
||||
after(() => styleEl.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export async function editAce(value) {
|
||||
// Ace editor traps focus on "mousedown" events, which are not triggered in
|
||||
// mobile. To support both environments, a single "mouedown" event is triggered
|
||||
// in this specific case. This should not be reproduced and is only accepted
|
||||
// because the tested behaviour comes from a lib on which we have no control.
|
||||
await manuallyDispatchProgrammaticEvent(queryOne(".ace_editor .ace_content"), "mousedown");
|
||||
|
||||
await contains(".ace_editor textarea", { displayed: true, visible: false }).edit(value, {
|
||||
instantly: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dragging methods taking into account the fact that it's the top of the
|
||||
* dragged element that triggers the moves (not the position of the cursor),
|
||||
* and the fact that during the first move, the dragged element is replaced by
|
||||
* a placeholder that does not have the same height. The moves are done with
|
||||
* the same x position to prevent triggering horizontal moves.
|
||||
*
|
||||
* @param {Target} from
|
||||
* @param {DragAndDropOptions} [options]
|
||||
*/
|
||||
export async function sortableDrag(from, options) {
|
||||
const fromRect = queryRect(from);
|
||||
const { cancel, drop, moveTo } = await contains(from).drag({
|
||||
initialPointerMoveDistance: 0,
|
||||
...options,
|
||||
});
|
||||
|
||||
let isFirstMove = true;
|
||||
|
||||
/**
|
||||
* @param {string} [targetSelector]
|
||||
*/
|
||||
const moveAbove = async (targetSelector) => {
|
||||
await moveTo(targetSelector, {
|
||||
position: {
|
||||
x: fromRect.x - queryRect(targetSelector).x + fromRect.width / 2,
|
||||
y: fromRect.height / 2 + 5,
|
||||
},
|
||||
relative: true,
|
||||
});
|
||||
isFirstMove = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} [targetSelector]
|
||||
*/
|
||||
const moveUnder = async (targetSelector) => {
|
||||
const elRect = queryRect(targetSelector);
|
||||
// Need to consider that the moved element will be replaced by a
|
||||
// placeholder with a height of 5px
|
||||
const firstMoveBelow = isFirstMove && elRect.y > fromRect.y;
|
||||
await moveTo(targetSelector, {
|
||||
position: {
|
||||
x: fromRect.x - elRect.x + fromRect.width / 2,
|
||||
y:
|
||||
((firstMoveBelow ? -1 : 1) * fromRect.height) / 2 +
|
||||
elRect.height +
|
||||
(firstMoveBelow ? 4 : -1),
|
||||
},
|
||||
relative: true,
|
||||
});
|
||||
isFirstMove = false;
|
||||
};
|
||||
|
||||
return { cancel, moveAbove, moveTo, moveUnder, drop };
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import { after, afterEach, beforeEach, registerDebugInfo } from "@odoo/hoot";
|
||||
import { startRouter } from "@web/core/browser/router";
|
||||
import { createDebugContext } from "@web/core/debug/debug_context";
|
||||
import {
|
||||
translatedTerms,
|
||||
translatedTermsGlobal,
|
||||
translationLoaded,
|
||||
} from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { makeEnv, startServices } from "@web/env";
|
||||
import { MockServer, makeMockServer } from "./mock_server/mock_server";
|
||||
|
||||
/**
|
||||
* @typedef {Record<keyof Services, any>} Dependencies
|
||||
*
|
||||
* @typedef {import("@web/env").OdooEnv} OdooEnv
|
||||
*
|
||||
* @typedef {import("@web/core/registry").Registry} Registry
|
||||
*
|
||||
* @typedef {import("services").ServiceFactories} Services
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internals
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* TODO: remove when services do not have side effects anymore
|
||||
* This forsaken block of code ensures that all are properly cleaned up after each
|
||||
* test because they were populated during the starting process of some services.
|
||||
*
|
||||
* @param {Registry} registry
|
||||
*/
|
||||
const registerRegistryForCleanup = (registry) => {
|
||||
const content = Object.entries(registry.content).map(([key, value]) => [key, value.slice()]);
|
||||
registriesContent.set(registry, content);
|
||||
|
||||
for (const subRegistry of Object.values(registry.subRegistries)) {
|
||||
registerRegistryForCleanup(subRegistry);
|
||||
}
|
||||
};
|
||||
|
||||
const registriesContent = new WeakMap();
|
||||
/** @type {OdooEnv | null} */
|
||||
let currentEnv = null;
|
||||
|
||||
// Registers all registries for cleanup in all tests
|
||||
beforeEach(() => registerRegistryForCleanup(registry));
|
||||
afterEach(() => restoreRegistry(registry));
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Empties the given registry.
|
||||
*
|
||||
* @param {Registry} registry
|
||||
*/
|
||||
export function clearRegistry(registry) {
|
||||
registry.content = {};
|
||||
registry.elements = null;
|
||||
registry.entries = null;
|
||||
}
|
||||
|
||||
export function getMockEnv() {
|
||||
return currentEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof Services} T
|
||||
* @param {T} name
|
||||
* @returns {Services[T]}
|
||||
*/
|
||||
export function getService(name) {
|
||||
return currentEnv.services[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a mock environment along with a mock server
|
||||
*
|
||||
* @param {Partial<OdooEnv>} [partialEnv]
|
||||
* @param {{
|
||||
* makeNew?: boolean;
|
||||
* }} [options]
|
||||
*/
|
||||
export async function makeMockEnv(partialEnv, options) {
|
||||
if (currentEnv && !options?.makeNew) {
|
||||
throw new Error(
|
||||
`cannot create mock environment: a mock environment has already been declared`
|
||||
);
|
||||
}
|
||||
|
||||
if (!MockServer.current) {
|
||||
await makeMockServer();
|
||||
}
|
||||
|
||||
const env = makeEnv();
|
||||
Object.assign(env, partialEnv, createDebugContext(env)); // This is needed if the views are in debug mode
|
||||
|
||||
registerDebugInfo("env", env);
|
||||
|
||||
if (!currentEnv) {
|
||||
currentEnv = env;
|
||||
startRouter();
|
||||
after(() => {
|
||||
currentEnv = null;
|
||||
|
||||
// Ideally: should be done in a patch of the localization service, but this
|
||||
// is less intrusive for now.
|
||||
if (translatedTerms[translationLoaded]) {
|
||||
for (const key in translatedTerms) {
|
||||
delete translatedTerms[key];
|
||||
}
|
||||
for (const key in translatedTermsGlobal) {
|
||||
delete translatedTermsGlobal[key];
|
||||
}
|
||||
translatedTerms[translationLoaded] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await startServices(env);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a mock environment for dialog tests
|
||||
*
|
||||
* @param {Partial<OdooEnv>} [partialEnv]
|
||||
* @returns {Promise<OdooEnv>}
|
||||
*/
|
||||
export async function makeDialogMockEnv(partialEnv) {
|
||||
return makeMockEnv({
|
||||
...partialEnv,
|
||||
dialogData: {
|
||||
close: () => {},
|
||||
isActive: true,
|
||||
scrollToOrigin: () => {},
|
||||
...partialEnv?.dialogData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {keyof Services} T
|
||||
* @param {T} name
|
||||
* @param {Partial<Services[T]> |
|
||||
* (env: OdooEnv, dependencies: Dependencies) => Services[T]
|
||||
* } serviceFactory
|
||||
*/
|
||||
export function mockService(name, serviceFactory) {
|
||||
const serviceRegistry = registry.category("services");
|
||||
const originalService = serviceRegistry.get(name, null);
|
||||
serviceRegistry.add(
|
||||
name,
|
||||
{
|
||||
...originalService,
|
||||
start(env, dependencies) {
|
||||
if (typeof serviceFactory === "function") {
|
||||
return serviceFactory(env, dependencies);
|
||||
} else {
|
||||
const service = originalService.start(env, dependencies);
|
||||
if (service instanceof Promise) {
|
||||
service.then((value) => patch(value, serviceFactory));
|
||||
} else {
|
||||
patch(service, serviceFactory);
|
||||
}
|
||||
return service;
|
||||
}
|
||||
},
|
||||
},
|
||||
{ force: true }
|
||||
);
|
||||
|
||||
// Patch already initialized service
|
||||
if (currentEnv?.services?.[name]) {
|
||||
if (typeof serviceFactory === "function") {
|
||||
const dependencies = pick(currentEnv.services, ...(originalService.dependencies || []));
|
||||
currentEnv.services[name] = serviceFactory(currentEnv, dependencies);
|
||||
} else {
|
||||
patch(currentEnv.services[name], serviceFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Registry} registry
|
||||
*/
|
||||
export function restoreRegistry(registry) {
|
||||
if (registriesContent.has(registry)) {
|
||||
clearRegistry(registry);
|
||||
|
||||
registry.content = Object.fromEntries(registriesContent.get(registry));
|
||||
}
|
||||
|
||||
for (const subRegistry of Object.values(registry.subRegistries)) {
|
||||
restoreRegistry(subRegistry);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// @odoo-module ignore
|
||||
// ! WARNING: this module must be loaded after `module_loader` but cannot have dependencies !
|
||||
|
||||
(function (odoo) {
|
||||
"use strict";
|
||||
|
||||
if (odoo.define.name.endsWith("(hoot)")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = `${odoo.define.name} (hoot)`;
|
||||
odoo.define = {
|
||||
[name](name, dependencies, factory) {
|
||||
return odoo.loader.define(name, dependencies, factory, !name.endsWith(".hoot"));
|
||||
},
|
||||
}[name];
|
||||
})(globalThis.odoo);
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import {
|
||||
animationFrame,
|
||||
queryAll,
|
||||
queryAllAttributes,
|
||||
queryAllTexts,
|
||||
queryOne,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { contains } from "./dom_test_helpers";
|
||||
import { buildSelector } from "./view_test_helpers";
|
||||
import { getDropdownMenu } from "./component_test_helpers";
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export function clickKanbanLoadMore(columnIndex = 0) {
|
||||
return contains(".o_kanban_load_more button", { root: getKanbanColumn(columnIndex) }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickKanbanRecord(options) {
|
||||
await contains(buildSelector(`.o_kanban_record`, options)).click();
|
||||
}
|
||||
|
||||
export async function createKanbanRecord() {
|
||||
await contains(".o_control_panel_main_buttons button.o-kanban-button-new").click();
|
||||
return animationFrame(); // the kanban quick create is rendered in a second animation frame
|
||||
}
|
||||
|
||||
export function discardKanbanRecord() {
|
||||
return contains(".o_kanban_quick_create .o_kanban_cancel").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export function editKanbanColumnName(value) {
|
||||
return contains(".o_column_quick_create input").edit(value);
|
||||
}
|
||||
|
||||
export function editKanbanRecord() {
|
||||
return contains(".o_kanban_quick_create .o_kanban_edit").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
* @param {string} value
|
||||
*/
|
||||
export function editKanbanRecordQuickCreateInput(fieldName, value) {
|
||||
return contains(`.o_kanban_quick_create .o_field_widget[name=${fieldName}] input`).edit(value, {
|
||||
confirm: "tab",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
* @param {boolean} [ignoreFolded=false]
|
||||
*/
|
||||
export function getKanbanColumn(columnIndex = 0, ignoreFolded = false) {
|
||||
let selector = ".o_kanban_group";
|
||||
if (ignoreFolded) {
|
||||
selector += ":not(.o_column_folded)";
|
||||
}
|
||||
return queryAll(selector).at(columnIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
* @param {boolean} [ignoreFolded=false]
|
||||
*/
|
||||
export function getKanbanColumnDropdownMenu(columnIndex = 0, ignoreFolded = false) {
|
||||
const column = getKanbanColumn(columnIndex, ignoreFolded);
|
||||
return getDropdownMenu(column);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex]
|
||||
*/
|
||||
export function getKanbanColumnTooltips(columnIndex) {
|
||||
queryAllAttributes;
|
||||
const root = columnIndex >= 0 && getKanbanColumn(columnIndex);
|
||||
return queryAllAttributes(".o_column_progress .progress-bar", "data-tooltip", { root });
|
||||
}
|
||||
|
||||
export function getKanbanCounters() {
|
||||
return queryAllTexts(".o_animated_number");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export function getKanbanProgressBars(columnIndex = 0) {
|
||||
const column = getKanbanColumn(columnIndex);
|
||||
return queryAll(".o_column_progress .progress-bar", { root: column });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} options
|
||||
*/
|
||||
export function getKanbanRecord(options) {
|
||||
return queryOne(buildSelector(`.o_kanban_record`, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex]
|
||||
*/
|
||||
export function getKanbanRecordTexts(columnIndex) {
|
||||
const root = columnIndex >= 0 && getKanbanColumn(columnIndex);
|
||||
return queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)", { root });
|
||||
}
|
||||
|
||||
export function quickCreateKanbanColumn() {
|
||||
return contains(".o_column_quick_create.o_quick_create_folded div").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export async function quickCreateKanbanRecord(columnIndex = 0) {
|
||||
await contains(".o_kanban_quick_add", { root: getKanbanColumn(columnIndex) }).click();
|
||||
return animationFrame(); // the kanban quick create is rendered in a second animation frame
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [columnIndex=0]
|
||||
*/
|
||||
export async function toggleKanbanColumnActions(columnIndex = 0) {
|
||||
const column = getKanbanColumn(columnIndex);
|
||||
await contains(".o_group_config .dropdown-toggle", { root: column, visible: false }).click();
|
||||
return (buttonText) => {
|
||||
const menu = getDropdownMenu(column);
|
||||
return contains(`.dropdown-item:contains(/\\b${buttonText}\\b/i)`, { root: menu }).click();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [recordIndex=0]
|
||||
*/
|
||||
export function toggleKanbanRecordDropdown(recordIndex = 0) {
|
||||
return contains(`.o_kanban_record:eq(${recordIndex}) .o_dropdown_kanban .dropdown-toggle`, {
|
||||
visible: false,
|
||||
}).click();
|
||||
}
|
||||
|
||||
export function validateKanbanColumn() {
|
||||
return contains(".o_column_quick_create .o_kanban_add").click();
|
||||
}
|
||||
|
||||
export function validateKanbanRecord() {
|
||||
return contains(".o_kanban_quick_create .o_kanban_add").click();
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { mockLocation } from "@odoo/hoot-mock";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List of properties that should not be mocked on the browser object.
|
||||
*
|
||||
* This is because they are already handled by HOOT and tampering with them could
|
||||
* lead to unexpected behavior.
|
||||
*/
|
||||
const READONLY_PROPERTIES = [
|
||||
"cancelAnimationFrame",
|
||||
"clearInterval",
|
||||
"clearTimeout",
|
||||
"requestAnimationFrame",
|
||||
"setInterval",
|
||||
"setTimeout",
|
||||
];
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Browser module needs to be mocked to patch the `location` global object since
|
||||
* it can't be directly mocked on the window object.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {OdooModuleFactory} factory
|
||||
*/
|
||||
export function mockBrowserFactory(name, { fn }) {
|
||||
return (...args) => {
|
||||
const browserModule = fn(...args);
|
||||
const properties = {
|
||||
location: {
|
||||
get: () => mockLocation,
|
||||
set: (value) => (mockLocation.href = value),
|
||||
},
|
||||
};
|
||||
for (const property of READONLY_PROPERTIES) {
|
||||
const originalValue = browserModule.browser[property];
|
||||
properties[property] = {
|
||||
configurable: false,
|
||||
get: () => originalValue,
|
||||
};
|
||||
}
|
||||
|
||||
Object.defineProperties(browserModule.browser, properties);
|
||||
|
||||
return browserModule;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { onServerStateChange } from "./mock_server_state.hoot";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {import("./mock_server_state.hoot").ServerState} serverState
|
||||
*/
|
||||
const makeCurrencies = ({ currencies }) =>
|
||||
Object.fromEntries(
|
||||
currencies.map((currency) => [currency.id, { digits: [69, 2], ...currency }])
|
||||
);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {OdooModuleFactory} factory
|
||||
*/
|
||||
export function mockCurrencyFactory(name, { fn }) {
|
||||
return (requireModule, ...args) => {
|
||||
const currencyModule = fn(requireModule, ...args);
|
||||
|
||||
onServerStateChange(currencyModule.currencies, makeCurrencies);
|
||||
|
||||
return currencyModule;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
export function mockIndexedDB(_name, { fn }) {
|
||||
return (requireModule, ...args) => {
|
||||
const indexedDBModule = fn(requireModule, ...args);
|
||||
|
||||
const { IndexedDB } = indexedDBModule;
|
||||
class MockedIndexedDB {
|
||||
constructor() {
|
||||
this.mockIndexedDB = {};
|
||||
}
|
||||
|
||||
write(table, key, value) {
|
||||
if (!(table in this.mockIndexedDB)) {
|
||||
this.mockIndexedDB[table] = {};
|
||||
}
|
||||
this.mockIndexedDB[table][key] = value;
|
||||
}
|
||||
|
||||
read(table, key) {
|
||||
return Promise.resolve(this.mockIndexedDB[table]?.[key]);
|
||||
}
|
||||
|
||||
invalidate(tables = null) {
|
||||
if (tables) {
|
||||
tables = typeof tables === "string" ? [tables] : tables;
|
||||
for (const table of tables) {
|
||||
if (table in this.mockIndexedDB) {
|
||||
this.mockIndexedDB[table] = {};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.mockIndexedDB = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.assign(indexedDBModule, {
|
||||
IndexedDB: MockedIndexedDB,
|
||||
_OriginalIndexedDB: IndexedDB,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import { MockServerError } from "./mock_server_utils";
|
||||
|
||||
/**
|
||||
* @typedef {import("fields").INumerical["aggregator"]} Aggregator
|
||||
* @typedef {import("fields").FieldDefinition} FieldDefinition
|
||||
* @typedef {import("fields").FieldDefinitionsByType} FieldDefinitionsByType
|
||||
* @typedef {import("fields").FieldType} FieldType
|
||||
* @typedef {import("./mock_model").ModelRecord} ModelRecord
|
||||
*
|
||||
* @typedef {{
|
||||
* compute?: (() => void) | string;
|
||||
* default?: RecordFieldValue | ((record: ModelRecord) => RecordFieldValue);
|
||||
* onChange?: (record: ModelRecord) => void;
|
||||
* }} MockFieldProperties
|
||||
*
|
||||
* @typedef {number | string | boolean | number[]} RecordFieldValue
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function camelToPascal(name) {
|
||||
return (
|
||||
name[0].toUpperCase() + name.slice(1).replace(R_CAMEL_CASE, (_, char) => char.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function spawns a 2-level process to create field definitions: it's a function
|
||||
* returning a function returning a field descriptor.
|
||||
*
|
||||
* - this function ("generator") is called at the end of this file with pre-defined
|
||||
* parameters to configure the "constructor" functions, i.e. the ones that will
|
||||
* be called in the tests model definitions;
|
||||
*
|
||||
* - those "constructor" functions will then be called in model definitions and will
|
||||
* return the actual field descriptors.
|
||||
*
|
||||
* @template {FieldType} T
|
||||
* @template [R=never]
|
||||
* @param {T} type
|
||||
* @param {{
|
||||
* aggregator?: Aggregator;
|
||||
* requiredKeys?: R[];
|
||||
* }} params
|
||||
*/
|
||||
function makeFieldGenerator(type, { aggregator, requiredKeys = [] } = {}) {
|
||||
const constructorFnName = camelToPascal(type);
|
||||
const defaultDef = { ...DEFAULT_FIELD_PROPERTIES };
|
||||
if (aggregator) {
|
||||
defaultDef.aggregator = aggregator;
|
||||
}
|
||||
if (type !== "generic") {
|
||||
defaultDef.type = type;
|
||||
}
|
||||
|
||||
// 2nd level: returns the "constructor" function
|
||||
return {
|
||||
/**
|
||||
* @param {Partial<FieldDefinitionsByType[T] & MockFieldProperties>} [properties]
|
||||
*/
|
||||
[constructorFnName](properties) {
|
||||
// Creates a pre-version of the field definition
|
||||
const field = {
|
||||
...defaultDef,
|
||||
...properties,
|
||||
[S_FIELD]: true,
|
||||
};
|
||||
|
||||
for (const key of requiredKeys) {
|
||||
if (!(key in field)) {
|
||||
throw new MockServerError(
|
||||
`missing key "${key}" in ${type || "generic"} field definition`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fill default values in definition based on given properties
|
||||
if (isComputed(field)) {
|
||||
// By default: computed fields are readonly and not stored
|
||||
field.readonly = properties.readonly ?? true;
|
||||
field.store = properties.store ?? false;
|
||||
}
|
||||
|
||||
return field;
|
||||
},
|
||||
}[constructorFnName];
|
||||
}
|
||||
|
||||
const R_CAMEL_CASE = /_([a-z])/g;
|
||||
const R_ENDS_WITH_ID = /_id(s)?$/i;
|
||||
const R_LOWER_FOLLOWED_BY_UPPER = /([a-z])([A-Z])/g;
|
||||
const R_SPACE_OR_UNDERSCORE = /[\s_]+/g;
|
||||
|
||||
/**
|
||||
* @param {FieldDefinition & MockFieldProperties} field
|
||||
*/
|
||||
export function isComputed(field) {
|
||||
return globalThis.Boolean(field.compute || field.related);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function getFieldDisplayName(value) {
|
||||
const str = String(value)
|
||||
.replace(R_ENDS_WITH_ID, "$1")
|
||||
.replace(R_LOWER_FOLLOWED_BY_UPPER, (_, a, b) => `${a} ${b.toLowerCase()}`)
|
||||
.replace(R_SPACE_OR_UNDERSCORE, " ")
|
||||
.trim();
|
||||
return str[0].toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
// Default field values
|
||||
export const DEFAULT_MONEY_FIELD_VALUES = {
|
||||
monetary: () => 0,
|
||||
};
|
||||
export const DEFAULT_RELATIONAL_FIELD_VALUES = {
|
||||
many2many: () => [],
|
||||
many2one: () => false,
|
||||
many2one_reference: () => false,
|
||||
one2many: () => [],
|
||||
};
|
||||
export const DEFAULT_SELECTION_FIELD_VALUES = {
|
||||
reference: () => false,
|
||||
selection: () => false,
|
||||
};
|
||||
export const DEFAULT_STANDARD_FIELD_VALUES = {
|
||||
binary: () => false,
|
||||
boolean: () => false,
|
||||
char: () => false,
|
||||
date: () => false,
|
||||
datetime: () => false,
|
||||
float: () => 0,
|
||||
html: () => false,
|
||||
number: () => 0,
|
||||
image: () => false,
|
||||
integer: () => 0,
|
||||
json: () => false,
|
||||
properties: () => false,
|
||||
properties_definition: () => false,
|
||||
text: () => false,
|
||||
};
|
||||
export const DEFAULT_FIELD_VALUES = {
|
||||
...DEFAULT_MONEY_FIELD_VALUES,
|
||||
...DEFAULT_RELATIONAL_FIELD_VALUES,
|
||||
...DEFAULT_SELECTION_FIELD_VALUES,
|
||||
...DEFAULT_STANDARD_FIELD_VALUES,
|
||||
};
|
||||
|
||||
export const DEFAULT_FIELD_PROPERTIES = {
|
||||
readonly: false,
|
||||
required: false,
|
||||
searchable: true,
|
||||
sortable: true,
|
||||
store: true,
|
||||
groupable: true,
|
||||
};
|
||||
|
||||
export const S_FIELD = Symbol("field");
|
||||
export const S_SERVER_FIELD = Symbol("field");
|
||||
|
||||
export const Binary = makeFieldGenerator("binary");
|
||||
|
||||
export const Boolean = makeFieldGenerator("boolean");
|
||||
|
||||
export const Char = makeFieldGenerator("char");
|
||||
|
||||
export const Date = makeFieldGenerator("date");
|
||||
|
||||
export const Datetime = makeFieldGenerator("datetime");
|
||||
|
||||
export const Float = makeFieldGenerator("float", {
|
||||
aggregator: "sum",
|
||||
});
|
||||
|
||||
export const Generic = makeFieldGenerator("generic");
|
||||
|
||||
export const Html = makeFieldGenerator("html");
|
||||
|
||||
export const Image = makeFieldGenerator("image");
|
||||
|
||||
export const Integer = makeFieldGenerator("integer", {
|
||||
aggregator: "sum",
|
||||
});
|
||||
|
||||
export const Json = makeFieldGenerator("json");
|
||||
|
||||
export const Many2many = makeFieldGenerator("many2many", {
|
||||
requiredKeys: ["relation"],
|
||||
});
|
||||
|
||||
export const Many2one = makeFieldGenerator("many2one", {
|
||||
requiredKeys: ["relation"],
|
||||
});
|
||||
|
||||
export const Many2oneReference = makeFieldGenerator("many2one_reference", {
|
||||
requiredKeys: ["model_field", "relation"],
|
||||
});
|
||||
|
||||
export const Monetary = makeFieldGenerator("monetary", {
|
||||
aggregator: "sum",
|
||||
requiredKeys: ["currency_field"],
|
||||
});
|
||||
|
||||
export const One2many = makeFieldGenerator("one2many", {
|
||||
requiredKeys: ["relation"],
|
||||
});
|
||||
|
||||
export const Properties = makeFieldGenerator("properties", {
|
||||
requiredKeys: ["definition_record", "definition_record_field"],
|
||||
});
|
||||
|
||||
export const PropertiesDefinition = makeFieldGenerator("properties_definition");
|
||||
|
||||
export const Reference = makeFieldGenerator("reference", {
|
||||
requiredKeys: ["selection"],
|
||||
});
|
||||
|
||||
export const Selection = makeFieldGenerator("selection", {
|
||||
requiredKeys: ["selection"],
|
||||
});
|
||||
|
||||
export const Text = makeFieldGenerator("text");
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,15 @@
|
|||
declare module "mock_models" {
|
||||
import { webModels } from "@web/../tests/web_test_helpers";
|
||||
|
||||
export interface IrModelFields extends webModels.IrModelFields {}
|
||||
export interface IrModuleCategory extends webModels.IrModuleCategory {}
|
||||
export interface ResGroups extends webModels.ResGroups {}
|
||||
export interface ResGroupsPrivilege extends webModels.ResGroupsPrivilege {}
|
||||
|
||||
export interface Models {
|
||||
"ir.model.fields": IrModelFields;
|
||||
"ir.module.category": IrModuleCategory;
|
||||
"res.groups": ResGroups;
|
||||
"res.groups.privilege": ResGroupsPrivilege;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrAttachment extends ServerModel {
|
||||
_name = "ir.attachment";
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrHttp extends ServerModel {
|
||||
_name = "ir.http";
|
||||
|
||||
lazy_session_info() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrModel extends ServerModel {
|
||||
_name = "ir.model";
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
model: "res.partner",
|
||||
name: "Partner",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Model } from "../mock_model";
|
||||
|
||||
export class IrModelAccess extends Model {
|
||||
_name = "ir.model.access";
|
||||
|
||||
has_access() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrModelFields extends ServerModel {
|
||||
_name = "ir.model.fields";
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrModuleCategory extends ServerModel {
|
||||
_name = "ir.module.category";
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Model } from "../mock_model";
|
||||
|
||||
export class IrRule extends Model {
|
||||
_name = "ir.rule";
|
||||
|
||||
has_access() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class IrUiView extends ServerModel {
|
||||
_name = "ir.ui.view";
|
||||
|
||||
has_access() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import * as fields from "../mock_fields";
|
||||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResCompany extends ServerModel {
|
||||
_name = "res.company";
|
||||
|
||||
description = fields.Text();
|
||||
is_company = fields.Boolean({ default: false });
|
||||
|
||||
_records = serverState.companies.map((company) => ({
|
||||
id: company.id,
|
||||
active: true,
|
||||
name: company.name,
|
||||
partner_id: company.id,
|
||||
}));
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResCountry extends ServerModel {
|
||||
_name = "res.country";
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResCurrency extends ServerModel {
|
||||
_name = "res.currency";
|
||||
|
||||
_records = Object.entries(serverState.currencies).map(
|
||||
([id, { digits, name, position, symbol }]) => ({
|
||||
id: Number(id) + 1,
|
||||
decimal_places: digits?.at(-1) ?? 2,
|
||||
name,
|
||||
position,
|
||||
symbol,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import { ServerModel } from "../mock_model";
|
||||
import * as fields from "../mock_fields";
|
||||
|
||||
export class ResGroups extends ServerModel {
|
||||
_name = "res.groups";
|
||||
|
||||
full_name = fields.Char({ compute: "_compute_full_name" });
|
||||
|
||||
_compute_full_name() {
|
||||
for (const group of this) {
|
||||
const privilegeName = group.privilege_id?.name;
|
||||
const groupName = group.name;
|
||||
const shortDisplayName = this.env.context?.short_display_name;
|
||||
if (privilegeName && !shortDisplayName) {
|
||||
group.full_name = `${privilegeName} / ${groupName}`;
|
||||
} else {
|
||||
group.full_name = groupName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: serverState.groupId,
|
||||
name: "Internal User",
|
||||
privilege_id: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResGroupsPrivilege extends ServerModel {
|
||||
_name = "res.groups.privilege";
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import { ServerModel } from "../mock_model";
|
||||
import * as fields from "../mock_fields";
|
||||
|
||||
export class ResPartner extends ServerModel {
|
||||
_name = "res.partner";
|
||||
|
||||
main_user_id = fields.Many2one({ compute: "_compute_main_user_id", relation: "res.users" });
|
||||
|
||||
_compute_main_user_id() {
|
||||
/** @type {import("mock_models").ResUsers} */
|
||||
const ResUsers = this.env["res.users"];
|
||||
|
||||
for (const partner of this) {
|
||||
const users = ResUsers.browse(partner.user_ids);
|
||||
const internalUsers = users.filter((user) => !user.share);
|
||||
if (internalUsers.length > 0) {
|
||||
partner.main_user_id = internalUsers[0].id;
|
||||
} else if (users.length > 0) {
|
||||
partner.main_user_id = users[0].id;
|
||||
} else {
|
||||
partner.main_user_id = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_records = [
|
||||
...serverState.companies.map((company) => ({
|
||||
id: company.id,
|
||||
active: true,
|
||||
name: company.name,
|
||||
})),
|
||||
{
|
||||
id: serverState.partnerId,
|
||||
active: true,
|
||||
name: serverState.partnerName,
|
||||
},
|
||||
{
|
||||
id: serverState.publicPartnerId,
|
||||
active: true,
|
||||
is_public: true,
|
||||
name: serverState.publicPartnerName,
|
||||
},
|
||||
{
|
||||
id: serverState.odoobotId,
|
||||
active: false,
|
||||
name: "OdooBot",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { serverState } from "../../mock_server_state.hoot";
|
||||
import { ServerModel } from "../mock_model";
|
||||
|
||||
export class ResUsers extends ServerModel {
|
||||
_name = "res.users";
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: serverState.userId,
|
||||
active: true,
|
||||
company_id: serverState.companies[0]?.id,
|
||||
company_ids: serverState.companies.map((company) => company.id),
|
||||
login: "admin",
|
||||
partner_id: serverState.partnerId,
|
||||
password: "admin",
|
||||
},
|
||||
{
|
||||
id: serverState.publicUserId,
|
||||
active: false,
|
||||
login: "public",
|
||||
partner_id: serverState.publicPartnerId,
|
||||
password: "public",
|
||||
},
|
||||
{
|
||||
id: serverState.odoobotUid,
|
||||
active: false,
|
||||
login: "odoobot",
|
||||
partner_id: serverState.odoobotId,
|
||||
password: "odoobot",
|
||||
},
|
||||
];
|
||||
|
||||
has_group() {
|
||||
return true;
|
||||
}
|
||||
|
||||
_is_public(id) {
|
||||
return id === serverState.publicUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {ServerModel["create"]}
|
||||
*/
|
||||
create() {
|
||||
const userId = super.create(...arguments);
|
||||
const [user] = this.env["res.users"].browse(userId);
|
||||
if (user && !user.partner_id) {
|
||||
this.env["res.users"].write(userId, { partner_id: this.env["res.partner"].create({}) });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { ServerModel } from "../mock_model";
|
||||
import { getKwArgs } from "../mock_server_utils";
|
||||
import { ensureArray } from "@web/core/utils/arrays";
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("@web/../tests/web_test_helpers").KwArgs<T>} KwArgs
|
||||
*/
|
||||
|
||||
const ORM_AUTOMATIC_FIELDS = new Set([
|
||||
"create_date",
|
||||
"create_uid",
|
||||
"display_name",
|
||||
"name",
|
||||
"write_date",
|
||||
"write_uid",
|
||||
]);
|
||||
|
||||
export class ResUsersSettings extends ServerModel {
|
||||
// TODO: merge this class with mail models
|
||||
_name = "res.users.settings";
|
||||
|
||||
/** @param {number|number[]} userIdOrIds */
|
||||
_find_or_create_for_user(userIdOrIds) {
|
||||
const [userId] = ensureArray(userIdOrIds);
|
||||
const settings = this._filter([["user_id", "=", userId]])[0];
|
||||
if (settings) {
|
||||
return settings;
|
||||
}
|
||||
const settingsId = this.create({ user_id: userId });
|
||||
return this.browse(settingsId)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string[]} [fields_to_format]
|
||||
*/
|
||||
res_users_settings_format(id, fields_to_format) {
|
||||
const kwargs = getKwArgs(arguments, "id", "fields_to_format");
|
||||
id = kwargs.id;
|
||||
delete kwargs.id;
|
||||
fields_to_format = kwargs.fields_to_format;
|
||||
|
||||
const [settings] = this.browse(id);
|
||||
const filterPredicate = fields_to_format
|
||||
? ([fieldName]) => fields_to_format.includes(fieldName)
|
||||
: ([fieldName]) => !ORM_AUTOMATIC_FIELDS.has(fieldName);
|
||||
const res = Object.fromEntries(Object.entries(settings).filter(filterPredicate));
|
||||
if (Reflect.ownKeys(res).includes("user_id")) {
|
||||
res.user_id = { id: settings.user_id };
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number | Iterable<number>} idOrIds
|
||||
* @param {Object} newSettings
|
||||
* @param {KwArgs<{ new_settings }>} [kwargs]
|
||||
*/
|
||||
set_res_users_settings(idOrIds, new_settings) {
|
||||
const kwargs = getKwArgs(arguments, "idOrIds", "new_settings");
|
||||
idOrIds = kwargs.idOrIds;
|
||||
delete kwargs.idOrIds;
|
||||
new_settings = kwargs.new_settings || {};
|
||||
|
||||
const [id] = ensureArray(idOrIds);
|
||||
const [oldSettings] = this.browse(id);
|
||||
const changedSettings = {};
|
||||
for (const setting in new_settings) {
|
||||
if (setting in oldSettings && new_settings[setting] !== oldSettings[setting]) {
|
||||
changedSettings[setting] = new_settings[setting];
|
||||
}
|
||||
}
|
||||
this.write(id, changedSettings);
|
||||
return changedSettings;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,126 @@
|
|||
import { makeErrorFromResponse } from "@web/core/network/rpc";
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {import("./mock_server").KwArgs<T>} KwArgs
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This is a flag on keyword arguments, so that they can easily be distinguished
|
||||
* from args in ORM methods. They can then be easily retrieved with {@link getKwArgs}.
|
||||
*/
|
||||
const KWARGS_SYMBOL = Symbol("is_kwargs");
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Flags keyword arguments, so that they can easily be distinguished from regular
|
||||
* arguments in ORM methods.
|
||||
*
|
||||
* They can then be easily retrieved with {@link getKwArgs}.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} kwargs
|
||||
* @returns {T}
|
||||
*/
|
||||
export function makeKwArgs(kwargs) {
|
||||
kwargs[KWARGS_SYMBOL] = true;
|
||||
return kwargs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves keyword arguments flagged by {@link makeKwArgs} from an arguments list.
|
||||
*
|
||||
* @template {string} T
|
||||
* @param {Iterable<any>} allArgs arguments of method
|
||||
* @param {...T} argNames ordered names of positional arguments
|
||||
* @returns {KwArgs<Record<T, any>>} kwargs normalized params
|
||||
*/
|
||||
export function getKwArgs(allArgs, ...argNames) {
|
||||
const args = [...allArgs];
|
||||
const kwargs = args.at(-1)?.[KWARGS_SYMBOL] ? args.pop() : makeKwArgs({});
|
||||
if (args.length > argNames.length) {
|
||||
throw new MockServerError("more positional arguments than there are given argument names");
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== null && args[i] !== undefined) {
|
||||
kwargs[argNames[i]] = args[i];
|
||||
}
|
||||
}
|
||||
return kwargs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./mock_model").ModelRecord} record
|
||||
*/
|
||||
export function getRecordQualifier(record) {
|
||||
if (record.id) {
|
||||
return `record #${record.id}`;
|
||||
}
|
||||
const name = record.display_name || record.name;
|
||||
if (name) {
|
||||
return `record named "${name}"`;
|
||||
}
|
||||
return "anonymous record";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | any>} params
|
||||
*/
|
||||
export function makeServerError({
|
||||
code,
|
||||
context,
|
||||
description,
|
||||
message,
|
||||
subType,
|
||||
errorName,
|
||||
type,
|
||||
args,
|
||||
} = {}) {
|
||||
return makeErrorFromResponse({
|
||||
code: code || 0,
|
||||
message: message || "Odoo Server Error",
|
||||
data: {
|
||||
name: errorName || `odoo.exceptions.${type || "UserError"}`,
|
||||
debug: "traceback",
|
||||
arguments: args || [],
|
||||
context: context || {},
|
||||
subType,
|
||||
message: description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @param {string} [separator=","]
|
||||
*/
|
||||
export function safeSplit(value, separator) {
|
||||
return value
|
||||
? String(value)
|
||||
.trim()
|
||||
.split(separator || ",")
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the flag for keyword arguments.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} kwargs
|
||||
* @returns {T}
|
||||
*/
|
||||
export function unmakeKwArgs(kwargs) {
|
||||
delete kwargs[KWARGS_SYMBOL];
|
||||
return kwargs;
|
||||
}
|
||||
|
||||
export class MockServerError extends Error {
|
||||
name = "MockServerError";
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { after, before, beforeEach, createJobScopedGetter } from "@odoo/hoot";
|
||||
import { validateType } from "@odoo/owl";
|
||||
|
||||
const { view_info } = odoo.__session_info__ || {};
|
||||
delete odoo.__session_info__;
|
||||
|
||||
const { Settings } = luxon;
|
||||
|
||||
/**
|
||||
* @typedef {typeof SERVER_STATE_VALUES} ServerState
|
||||
*/
|
||||
|
||||
const applyDefaults = () => {
|
||||
Object.assign(Settings, DEFAULT_LUXON_SETTINGS);
|
||||
|
||||
notifySubscribers();
|
||||
};
|
||||
|
||||
const notifySubscribers = () => {
|
||||
// Apply new state to all subscribers
|
||||
for (const [target, callback] of subscriptions) {
|
||||
const descriptors = Object.getOwnPropertyDescriptors(callback(serverState));
|
||||
Object.defineProperties(target, descriptors);
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_LUXON_SETTINGS = {
|
||||
defaultLocale: Settings.defaultLocale,
|
||||
defaultNumberingSystem: Settings.defaultNumberingSystem,
|
||||
defaultOutputCalendar: Settings.defaultOutputCalendar,
|
||||
defaultZone: Settings.defaultZone,
|
||||
defaultWeekSettings: Settings.defaultWeekSettings,
|
||||
};
|
||||
const SERVER_STATE_VALUES = {
|
||||
companies: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Hermit",
|
||||
currency_id: 1,
|
||||
},
|
||||
],
|
||||
currencies: [
|
||||
{
|
||||
id: 1,
|
||||
name: "USD",
|
||||
position: "before",
|
||||
symbol: "$",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "EUR",
|
||||
position: "after",
|
||||
symbol: "€",
|
||||
},
|
||||
],
|
||||
db: "test",
|
||||
debug: "",
|
||||
groupId: 11,
|
||||
lang: "en",
|
||||
multiLang: false,
|
||||
odoobotId: 418,
|
||||
odoobotUid: 518,
|
||||
partnerId: 17,
|
||||
partnerName: "Mitchell Admin",
|
||||
publicPartnerId: 18,
|
||||
publicPartnerName: "Public user",
|
||||
publicUserId: 8,
|
||||
serverVersion: [1, 0, 0, "final", 0, ""],
|
||||
timezone: "taht",
|
||||
userContext: {},
|
||||
userId: 7,
|
||||
view_info,
|
||||
};
|
||||
|
||||
const SERVER_STATE_VALUES_SCHEMA = {
|
||||
companies: { type: Array, element: Object },
|
||||
currencies: { type: Array, element: Object },
|
||||
db: String,
|
||||
debug: String,
|
||||
groupId: [Number, { value: false }],
|
||||
lang: String,
|
||||
multiLang: Boolean,
|
||||
odoobotId: [Number, { value: false }],
|
||||
partnerId: [Number, { value: false }],
|
||||
partnerName: String,
|
||||
publicPartnerId: [Number, { value: false }],
|
||||
publicPartnerName: String,
|
||||
publicUserId: Number,
|
||||
serverVersion: { type: Array, element: [String, Number] },
|
||||
timezone: String,
|
||||
userContext: Object,
|
||||
userId: [Number, { value: false }],
|
||||
view_info: Object,
|
||||
};
|
||||
|
||||
const getServerStateValues = createJobScopedGetter(
|
||||
(previousValues) => ({
|
||||
...JSON.parse(JSON.stringify(SERVER_STATE_VALUES)),
|
||||
...previousValues,
|
||||
}),
|
||||
applyDefaults
|
||||
);
|
||||
|
||||
/** @type {Map<any, (state: ServerState) => any>} */
|
||||
const subscriptions = new Map([
|
||||
[
|
||||
odoo,
|
||||
({ db, debug, serverVersion }) => ({
|
||||
...odoo,
|
||||
debug,
|
||||
info: {
|
||||
db,
|
||||
server_version: serverVersion.slice(0, 2).join("."),
|
||||
server_version_info: serverVersion,
|
||||
isEnterprise: serverVersion.slice(-1)[0] === "e",
|
||||
},
|
||||
isReady: true,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} target
|
||||
* @param {(state: ServerState) => T} callback
|
||||
*/
|
||||
export function onServerStateChange(target, callback) {
|
||||
before(() => {
|
||||
subscriptions.set(target, callback);
|
||||
});
|
||||
after(() => {
|
||||
subscriptions.delete(target);
|
||||
});
|
||||
}
|
||||
|
||||
export const serverState = new Proxy(SERVER_STATE_VALUES, {
|
||||
deleteProperty(_target, p) {
|
||||
return Reflect.deleteProperty(getServerStateValues(), p);
|
||||
},
|
||||
get(_target, p) {
|
||||
return Reflect.get(getServerStateValues(), p);
|
||||
},
|
||||
has(_target, p) {
|
||||
return Reflect.has(getServerStateValues(), p);
|
||||
},
|
||||
set(_target, p, newValue) {
|
||||
if (p in SERVER_STATE_VALUES_SCHEMA && newValue !== null && newValue !== undefined) {
|
||||
const errorMessage = validateType(p, newValue, SERVER_STATE_VALUES_SCHEMA[p]);
|
||||
if (errorMessage) {
|
||||
throw new TypeError(errorMessage);
|
||||
}
|
||||
}
|
||||
const result = Reflect.set(getServerStateValues(), p, newValue);
|
||||
if (result) {
|
||||
notifySubscribers();
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(applyDefaults, { global: true });
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { onServerStateChange, serverState } from "./mock_server_state.hoot";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {typeof serverState} serverState
|
||||
*/
|
||||
const makeSession = ({
|
||||
companies,
|
||||
db,
|
||||
lang,
|
||||
partnerId,
|
||||
partnerName,
|
||||
serverVersion,
|
||||
timezone,
|
||||
userContext,
|
||||
userId,
|
||||
view_info,
|
||||
}) => ({
|
||||
active_ids_limit: 20000,
|
||||
bundle_params: {
|
||||
debug: new URLSearchParams(location.search).get("debug"),
|
||||
lang,
|
||||
},
|
||||
can_insert_in_spreadsheet: true,
|
||||
db,
|
||||
registry_hash: "05500d71e084497829aa807e3caa2e7e9782ff702c15b2f57f87f2d64d049bd0",
|
||||
home_action_id: false,
|
||||
is_admin: true,
|
||||
is_internal_user: true,
|
||||
is_system: true,
|
||||
max_file_upload_size: 134217728,
|
||||
name: partnerName,
|
||||
partner_display_name: partnerName,
|
||||
partner_id: partnerId,
|
||||
profile_collectors: null,
|
||||
profile_params: null,
|
||||
profile_session: null,
|
||||
server_version: serverVersion.slice(0, 2).join("."),
|
||||
server_version_info: serverVersion,
|
||||
show_effect: true,
|
||||
uid: userId,
|
||||
// Commit: 3e847fc8f499c96b8f2d072ab19f35e105fd7749
|
||||
// to see what user_companies is
|
||||
user_companies: {
|
||||
allowed_companies: Object.fromEntries(companies.map((company) => [company.id, company])),
|
||||
current_company: companies[0]?.id,
|
||||
},
|
||||
user_context: {
|
||||
...userContext,
|
||||
lang,
|
||||
tz: timezone,
|
||||
uid: userId,
|
||||
},
|
||||
user_id: [userId],
|
||||
username: "admin",
|
||||
["web.base.url"]: "http://localhost:8069",
|
||||
view_info,
|
||||
});
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function mockSessionFactory() {
|
||||
return () => {
|
||||
const session = makeSession(serverState);
|
||||
|
||||
onServerStateChange(session, makeSession);
|
||||
|
||||
return { session };
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* We remove all the `src` attributes (and derived forms) from the template and
|
||||
* replace them by data attributes (e.g. `src` to `data-src`, `t-att-src` to
|
||||
* `t-att-data-src`). This is done to ensure images will not trigger an actual request
|
||||
* on the server.
|
||||
*
|
||||
* @param {Element} template
|
||||
*/
|
||||
const replaceAttributes = (template) => {
|
||||
for (const [tagName, value] of SRC_REPLACERS) {
|
||||
for (const prefix of ATTRIBUTE_PREFIXES) {
|
||||
const targetAttribute = `${prefix}src`;
|
||||
const dataAttribute = `${prefix}data-src`;
|
||||
for (const element of template.querySelectorAll(`${tagName}[${targetAttribute}]`)) {
|
||||
element.setAttribute(dataAttribute, element.getAttribute(targetAttribute));
|
||||
if (prefix) {
|
||||
element.removeAttribute(targetAttribute);
|
||||
}
|
||||
element.setAttribute("src", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ONE_FUSCHIA_PIXEL_IMG =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z9DwHwAGBQKA3H7sNwAAAABJRU5ErkJggg==";
|
||||
|
||||
const SRC_REPLACERS = [
|
||||
["iframe", ""],
|
||||
["img", ONE_FUSCHIA_PIXEL_IMG],
|
||||
];
|
||||
const ATTRIBUTE_PREFIXES = ["", "t-att-", "t-attf-"];
|
||||
|
||||
const { loader } = odoo;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {OdooModuleFactory} factory
|
||||
*/
|
||||
export function makeTemplateFactory(name, factory) {
|
||||
return () => {
|
||||
if (loader.modules.has(name)) {
|
||||
return loader.modules.get(name);
|
||||
}
|
||||
|
||||
/** @type {Map<string, function>} */
|
||||
const compiledTemplates = new Map();
|
||||
|
||||
const factoryFn = factory.fn;
|
||||
factory.fn = (...args) => {
|
||||
const exports = factoryFn(...args);
|
||||
const { clearProcessedTemplates, getTemplate } = exports;
|
||||
|
||||
// Patch "getTemplates" to access local cache
|
||||
exports.getTemplate = function mockedGetTemplate(name) {
|
||||
if (!this) {
|
||||
// Used outside of Owl.
|
||||
return getTemplate(name);
|
||||
}
|
||||
const rawTemplate = getTemplate(name) || this.rawTemplates[name];
|
||||
if (typeof rawTemplate === "function" && !(rawTemplate instanceof Element)) {
|
||||
return rawTemplate;
|
||||
}
|
||||
if (!compiledTemplates.has(rawTemplate)) {
|
||||
compiledTemplates.set(rawTemplate, this._compileTemplate(name, rawTemplate));
|
||||
}
|
||||
return compiledTemplates.get(rawTemplate);
|
||||
};
|
||||
|
||||
// Patch "clearProcessedTemplates" to clear local template cache
|
||||
exports.clearProcessedTemplates = function mockedClearProcessedTemplates() {
|
||||
compiledTemplates.clear();
|
||||
return clearProcessedTemplates(...arguments);
|
||||
};
|
||||
|
||||
// Replace alt & src attributes by default on all templates
|
||||
exports.registerTemplateProcessor(replaceAttributes);
|
||||
|
||||
return exports;
|
||||
};
|
||||
|
||||
return loader.startModule(name);
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { onServerStateChange } from "./mock_server_state.hoot";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {OdooModuleFactory} factory
|
||||
*/
|
||||
export function mockUserFactory(name, { fn }) {
|
||||
return (requireModule, ...args) => {
|
||||
const { session } = requireModule("@web/session");
|
||||
const userModule = fn(requireModule, ...args);
|
||||
|
||||
onServerStateChange(userModule.user, () => userModule._makeUser(session));
|
||||
|
||||
return userModule;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,748 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { describe, dryRun, globals, start, stop } from "@odoo/hoot";
|
||||
import { Deferred, delay } from "@odoo/hoot-dom";
|
||||
import { watchAddedNodes, watchKeys, watchListeners } from "@odoo/hoot-mock";
|
||||
|
||||
import { mockBrowserFactory } from "./mock_browser.hoot";
|
||||
import { mockCurrencyFactory } from "./mock_currency.hoot";
|
||||
import { mockIndexedDB } from "./mock_indexed_db.hoot";
|
||||
import { mockSessionFactory } from "./mock_session.hoot";
|
||||
import { makeTemplateFactory } from "./mock_templates.hoot";
|
||||
import { mockUserFactory } from "./mock_user.hoot";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* addonsKey: string;
|
||||
* filter?: (path: string) => boolean;
|
||||
* moduleNames: string[];
|
||||
* }} ModuleSet
|
||||
*
|
||||
* @typedef {{
|
||||
* addons?: Iterable<string>;
|
||||
* }} ModuleSetParams
|
||||
*/
|
||||
|
||||
const { fetch: realFetch } = globals;
|
||||
const { define, loader } = odoo;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Record<any, any>} object
|
||||
*/
|
||||
function clearObject(object) {
|
||||
for (const key in object) {
|
||||
delete object[key];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileSuffix
|
||||
* @param {string[]} entryPoints
|
||||
* @param {Set<string>} additionalAddons
|
||||
*/
|
||||
async function defineModuleSet(fileSuffix, entryPoints, additionalAddons) {
|
||||
/** @type {ModuleSet} */
|
||||
const moduleSet = {};
|
||||
if (additionalAddons.has("*")) {
|
||||
// Use all addons
|
||||
moduleSet.addonsKey = "*";
|
||||
moduleSet.moduleNames = sortedModuleNames.filter((name) => !name.endsWith(fileSuffix));
|
||||
} else {
|
||||
// Use subset of addons
|
||||
for (const entryPoint of entryPoints) {
|
||||
additionalAddons.add(getAddonName(entryPoint));
|
||||
}
|
||||
const addons = await fetchDependencies(additionalAddons);
|
||||
for (const addon in AUTO_INCLUDED_ADDONS) {
|
||||
if (addons.has(addon)) {
|
||||
for (const toInclude of AUTO_INCLUDED_ADDONS[addon]) {
|
||||
addons.add(toInclude);
|
||||
}
|
||||
}
|
||||
}
|
||||
const filter = (path) => addons.has(getAddonName(path));
|
||||
|
||||
// Module names are cached for each configuration of addons
|
||||
const joinedAddons = [...addons].sort().join(",");
|
||||
if (!moduleNamesCache.has(joinedAddons)) {
|
||||
moduleNamesCache.set(
|
||||
joinedAddons,
|
||||
sortedModuleNames.filter((name) => !name.endsWith(fileSuffix) && filter(name))
|
||||
);
|
||||
}
|
||||
|
||||
moduleSet.addonsKey = joinedAddons;
|
||||
moduleSet.filter = filter;
|
||||
moduleSet.moduleNames = moduleNamesCache.get(joinedAddons);
|
||||
}
|
||||
|
||||
return moduleSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fileSuffix
|
||||
* @param {string[]} entryPoints
|
||||
*/
|
||||
async function describeDrySuite(fileSuffix, entryPoints) {
|
||||
const moduleSet = await defineModuleSet(fileSuffix, entryPoints, new Set(["*"]));
|
||||
const moduleSetLoader = new ModuleSetLoader(moduleSet);
|
||||
|
||||
moduleSetLoader.setup();
|
||||
|
||||
for (const entryPoint of entryPoints) {
|
||||
// Run test factory
|
||||
describe(getSuitePath(entryPoint), () => {
|
||||
// Load entry point module
|
||||
const fullModuleName = entryPoint + fileSuffix;
|
||||
const module = moduleSetLoader.startModule(fullModuleName);
|
||||
|
||||
// Check exports (shouldn't have any)
|
||||
const exports = Object.keys(module || {});
|
||||
if (exports.length) {
|
||||
throw new Error(
|
||||
`Test files cannot have exports, found the following exported member(s) in module ${fullModuleName}:${exports
|
||||
.map((name) => `\n - ${name}`)
|
||||
.join("")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await moduleSetLoader.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set<string>} addons
|
||||
*/
|
||||
async function fetchDependencies(addons) {
|
||||
// Fetch missing dependencies
|
||||
const addonsToFetch = [];
|
||||
for (const addon of addons) {
|
||||
if (!dependencyCache[addon] && !DEFAULT_ADDONS.includes(addon)) {
|
||||
addonsToFetch.push(addon);
|
||||
dependencyCache[addon] = new Deferred();
|
||||
}
|
||||
}
|
||||
if (addonsToFetch.length) {
|
||||
if (!dependencyBatch.length) {
|
||||
dependencyBatchPromise = Deferred.resolve().then(() => {
|
||||
const module_names = [...new Set(dependencyBatch)];
|
||||
dependencyBatch = [];
|
||||
return unmockedOrm("ir.module.module.dependency", "all_dependencies", [], {
|
||||
module_names,
|
||||
});
|
||||
});
|
||||
}
|
||||
dependencyBatch.push(...addonsToFetch);
|
||||
dependencyBatchPromise.then((allDependencies) => {
|
||||
for (const [moduleName, dependencyNames] of Object.entries(allDependencies)) {
|
||||
dependencyCache[moduleName] ||= new Deferred();
|
||||
dependencyCache[moduleName].resolve();
|
||||
|
||||
dependencies[moduleName] = dependencyNames.filter(
|
||||
(dep) => !DEFAULT_ADDONS.includes(dep)
|
||||
);
|
||||
}
|
||||
|
||||
resolveAddonDependencies(dependencies);
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([...addons].map((addon) => dependencyCache[addon]));
|
||||
|
||||
return getDependencies(addons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function findMockFactory(name) {
|
||||
if (MODULE_MOCKS_BY_NAME.has(name)) {
|
||||
return MODULE_MOCKS_BY_NAME.get(name);
|
||||
}
|
||||
for (const [key, factory] of MODULE_MOCKS_BY_REGEX) {
|
||||
if (key instanceof RegExp && key.test(name)) {
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the size of the given field and freeze it.
|
||||
*
|
||||
* @param {Record<string, unknown>>} field
|
||||
*/
|
||||
function freezeField(field) {
|
||||
delete field.name;
|
||||
if (field.groupable) {
|
||||
delete field.groupable;
|
||||
}
|
||||
if (!field.readonly && !field.related) {
|
||||
delete field.readonly;
|
||||
}
|
||||
if (!field.required) {
|
||||
delete field.required;
|
||||
}
|
||||
if (field.searchable) {
|
||||
delete field.searchable;
|
||||
}
|
||||
if (field.sortable) {
|
||||
delete field.sortable;
|
||||
}
|
||||
if (field.store && !field.related) {
|
||||
delete field.store;
|
||||
}
|
||||
return Object.freeze(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the size of the given model and freeze it.
|
||||
*
|
||||
* @param {Record<string, unknown>>} model
|
||||
*/
|
||||
function freezeModel(model) {
|
||||
if (model.fields) {
|
||||
for (const [fieldName, field] of Object.entries(model.fields)) {
|
||||
model.fields[fieldName] = freezeField(field);
|
||||
}
|
||||
Object.freeze(model.fields);
|
||||
}
|
||||
if (model.inherit) {
|
||||
if (model.inherit.length) {
|
||||
model.inherit = model.inherit.filter((m) => m !== "base");
|
||||
}
|
||||
if (!model.inherit.length) {
|
||||
delete model.inherit;
|
||||
}
|
||||
}
|
||||
if (model.order === "id") {
|
||||
delete model.order;
|
||||
}
|
||||
if (model.parent_name === "parent_id") {
|
||||
delete model.parent_name;
|
||||
}
|
||||
if (model.rec_name === "name") {
|
||||
delete model.rec_name;
|
||||
}
|
||||
return Object.freeze(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function getAddonName(name) {
|
||||
return name.match(R_PATH_ADDON)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Iterable<string>} addons
|
||||
*/
|
||||
function getDependencies(addons) {
|
||||
const result = new Set(DEFAULT_ADDONS);
|
||||
for (const addon of addons) {
|
||||
if (DEFAULT_ADDONS.includes(addon)) {
|
||||
continue;
|
||||
}
|
||||
result.add(addon);
|
||||
for (const dep of dependencies[addon]) {
|
||||
result.add(dep);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
function getSuitePath(name) {
|
||||
return name.replace("../tests/", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the original definition of a factory.
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
function makeFixedFactory(name) {
|
||||
return () => {
|
||||
if (!loader.modules.has(name)) {
|
||||
loader.startModule(name);
|
||||
}
|
||||
return loader.modules.get(name);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {Record<string, string[]>} T
|
||||
* @param {T} dependencies
|
||||
*/
|
||||
function resolveAddonDependencies(dependencies) {
|
||||
const findJob = () =>
|
||||
Object.entries(remaining).find(([, deps]) => deps.every((dep) => dep in solved));
|
||||
|
||||
const remaining = { ...dependencies };
|
||||
/** @type {T} */
|
||||
const solved = {};
|
||||
|
||||
let entry;
|
||||
while ((entry = findJob())) {
|
||||
const [name, deps] = entry;
|
||||
solved[name] = [...new Set(deps.flatMap((dep) => [dep, ...solved[dep]]))];
|
||||
delete remaining[name];
|
||||
}
|
||||
|
||||
const remainingKeys = Object.keys(remaining);
|
||||
if (remainingKeys.length) {
|
||||
throw new Error(`Unresolved dependencies: ${remainingKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
Object.assign(dependencies, solved);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>>} model
|
||||
*/
|
||||
function unfreezeModel(model) {
|
||||
const fields = Object.create(null);
|
||||
if (model.fields) {
|
||||
for (const [fieldName, field] of Object.entries(model.fields)) {
|
||||
fields[fieldName] = { ...field };
|
||||
}
|
||||
}
|
||||
return { ...model, fields };
|
||||
}
|
||||
|
||||
/**
|
||||
* This method tries to manually run the garbage collector (if exposed) and logs
|
||||
* the current heap size (if available). It is meant to be called right after a
|
||||
* suite's module set has been fully executed.
|
||||
*
|
||||
* This is used for debugging memory leaks, or if the containing process running
|
||||
* unit tests doesn't know how much available memory it actually has.
|
||||
*
|
||||
* To enable this feature, the containing process must simply use the `--expose-gc`
|
||||
* flag.
|
||||
*
|
||||
* @param {string} label
|
||||
* @param {number} [testCount]
|
||||
*/
|
||||
async function __gcAndLogMemory(label, testCount) {
|
||||
if (typeof window.gc !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup last retained textarea
|
||||
const textarea = document.createElement("textarea");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.value = "aaa";
|
||||
textarea.focus();
|
||||
textarea.remove();
|
||||
|
||||
// Run garbage collection
|
||||
await window.gc({ type: "major", execution: "async" });
|
||||
|
||||
// Log memory usage
|
||||
const logs = [
|
||||
`[MEMINFO] ${label} (after GC)`,
|
||||
"- used:",
|
||||
window.performance.memory.usedJSHeapSize,
|
||||
"- total:",
|
||||
window.performance.memory.totalJSHeapSize,
|
||||
"- limit:",
|
||||
window.performance.memory.jsHeapSizeLimit,
|
||||
];
|
||||
if (Number.isInteger(testCount)) {
|
||||
logs.push("- tests:", testCount);
|
||||
}
|
||||
console.log(...logs);
|
||||
}
|
||||
|
||||
/** @extends {OdooModuleLoader} */
|
||||
class ModuleSetLoader extends loader.constructor {
|
||||
cleanups = [];
|
||||
preventGlobalDefine = false;
|
||||
|
||||
/**
|
||||
* @param {ModuleSet} moduleSet
|
||||
*/
|
||||
constructor(moduleSet) {
|
||||
super();
|
||||
|
||||
this.factories = new Map(loader.factories);
|
||||
this.modules = new Map(loader.modules);
|
||||
this.moduleSet = moduleSet;
|
||||
|
||||
odoo.define = this.define.bind(this);
|
||||
odoo.loader = this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {typeof loader["addJob"]}
|
||||
*/
|
||||
addJob(name) {
|
||||
if (this.canAddModule(name)) {
|
||||
super.addJob(...arguments);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
canAddModule(name) {
|
||||
const { filter } = this.moduleSet;
|
||||
return !filter || filter(name) || R_DEFAULT_MODULE.test(name);
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
// Wait for side-effects to be properly caught up by the different "watchers"
|
||||
// (like mutation records).
|
||||
await delay();
|
||||
|
||||
odoo.define = define;
|
||||
odoo.loader = loader;
|
||||
|
||||
while (this.cleanups.length) {
|
||||
this.cleanups.pop()();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {typeof loader["define"]}
|
||||
*/
|
||||
define(name, deps, factory) {
|
||||
if (!this.preventGlobalDefine && !loader.factories.has(name)) {
|
||||
// Lazy-loaded modules are added to the main loader for next ModuleSetLoader
|
||||
// instances.
|
||||
loader.define(name, deps, factory, true);
|
||||
// We assume that lazy-loaded modules are not required by any other
|
||||
// module.
|
||||
sortedModuleNames.push(name);
|
||||
moduleNamesCache.clear();
|
||||
}
|
||||
return super.define(...arguments);
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.cleanups.push(
|
||||
watchKeys(window.odoo),
|
||||
watchKeys(window, ALLOWED_GLOBAL_KEYS),
|
||||
watchListeners(window),
|
||||
watchAddedNodes(window)
|
||||
);
|
||||
|
||||
// Load module set modules (without entry point)
|
||||
for (const name of this.moduleSet.moduleNames) {
|
||||
const mockFactory = findMockFactory(name);
|
||||
if (mockFactory) {
|
||||
// Use mock
|
||||
this.factories.set(name, {
|
||||
deps: [],
|
||||
fn: mockFactory(name, this.factories.get(name)),
|
||||
});
|
||||
}
|
||||
if (!this.modules.has(name)) {
|
||||
// Run (or re-run) module factory
|
||||
this.startModule(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {typeof loader["startModule"]}
|
||||
*/
|
||||
startModule(name) {
|
||||
if (this.canAddModule(name)) {
|
||||
return super.startModule(...arguments);
|
||||
}
|
||||
this.jobs.delete(name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_GLOBAL_KEYS = [
|
||||
"ace", // Ace editor
|
||||
// Bootstrap.js is voluntarily ignored as it is deprecated
|
||||
"Chart", // Chart.js
|
||||
"Cropper", // Cropper.js
|
||||
"DiffMatchPatch", // Diff Match Patch
|
||||
"DOMPurify", // DOMPurify
|
||||
"Diff2Html",
|
||||
"FullCalendar", // Full Calendar
|
||||
"L", // Leaflet
|
||||
"lamejs", // LameJS
|
||||
"luxon", // Luxon
|
||||
"odoo", // Odoo global object
|
||||
"owl", // Owl
|
||||
"pdfjsLib", // PDF JS
|
||||
"Popper", // Popper
|
||||
"Prism", // PrismJS
|
||||
"SignaturePad", // Signature Pad
|
||||
"StackTrace", // StackTrace
|
||||
"ZXing", // ZXing
|
||||
];
|
||||
const AUTO_INCLUDED_ADDONS = {
|
||||
/**
|
||||
* spreadsheet addons defines a module that does not starts with `@spreadsheet` but `@odoo` (`@odoo/o-spreadsheet)
|
||||
* To ensure that this module is loaded, we have to include `odoo` in the dependencies
|
||||
*/
|
||||
spreadsheet: ["odoo"],
|
||||
/**
|
||||
* Add all view types by default
|
||||
*/
|
||||
web_enterprise: ["web_gantt", "web_grid", "web_map"],
|
||||
};
|
||||
const CSRF_TOKEN = odoo.csrf_token;
|
||||
const DEFAULT_ADDONS = ["base", "web"];
|
||||
const MODULE_MOCKS_BY_NAME = new Map([
|
||||
// Fixed modules
|
||||
["@web/core/template_inheritance", makeFixedFactory],
|
||||
// Other mocks
|
||||
["@web/core/browser/browser", mockBrowserFactory],
|
||||
["@web/core/utils/indexed_db", mockIndexedDB],
|
||||
["@web/core/currency", mockCurrencyFactory],
|
||||
["@web/core/templates", makeTemplateFactory],
|
||||
["@web/core/user", mockUserFactory],
|
||||
["@web/session", mockSessionFactory],
|
||||
]);
|
||||
const MODULE_MOCKS_BY_REGEX = new Map([
|
||||
// Fixed modules
|
||||
[/\.bundle\.xml$/, makeFixedFactory],
|
||||
]);
|
||||
const R_DEFAULT_MODULE = /^@odoo\/(owl|hoot)/;
|
||||
const R_PATH_ADDON = /^[@/]?(\w+)/;
|
||||
const TEMPLATE_MODULE_NAME = "@web/core/templates";
|
||||
|
||||
/** @type {Record<string, string[]} */
|
||||
const dependencies = {};
|
||||
/** @type {Record<string, Deferred} */
|
||||
const dependencyCache = {};
|
||||
/** @type {Record<string, Promise<Response>} */
|
||||
const globalFetchCache = Object.create(null);
|
||||
/** @type {Set<string>} */
|
||||
const modelsToFetch = new Set();
|
||||
/** @type {Map<string, string[]>} */
|
||||
const moduleNamesCache = new Map();
|
||||
/** @type {Map<string, Record<string, unknown>>} */
|
||||
const serverModelCache = new Map();
|
||||
/** @type {string[]} */
|
||||
const sortedModuleNames = [];
|
||||
|
||||
/** @type {string[]} */
|
||||
let dependencyBatch = [];
|
||||
/** @type {Promise<Record<string, string[]>> | null} */
|
||||
let dependencyBatchPromise = null;
|
||||
let nextRpcId = 1e9;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function clearServerModelCache() {
|
||||
serverModelCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Iterable<string>} modelNames
|
||||
*/
|
||||
export async function fetchModelDefinitions(modelNames) {
|
||||
// Fetch missing definitions
|
||||
const namesList = [...modelsToFetch];
|
||||
if (namesList.length) {
|
||||
const formData = new FormData();
|
||||
formData.set("csrf_token", CSRF_TOKEN);
|
||||
formData.set("model_names", JSON.stringify(namesList));
|
||||
|
||||
const response = await realFetch("/web/model/get_definitions", {
|
||||
body: formData,
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const [s, some, does] =
|
||||
namesList.length === 1 ? ["", "this", "does"] : ["s", "some or all of these", "do"];
|
||||
const message = `Could not fetch definition${s} for server model${s} "${namesList.join(
|
||||
`", "`
|
||||
)}": ${some} model${s} ${does} not exist`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const modelDefs = await response.json();
|
||||
|
||||
for (const [modelName, modelDef] of Object.entries(modelDefs)) {
|
||||
serverModelCache.set(modelName, freezeModel(modelDef));
|
||||
modelsToFetch.delete(modelName);
|
||||
}
|
||||
}
|
||||
|
||||
const result = Object.create(null);
|
||||
for (const modelName of modelNames) {
|
||||
result[modelName] = unfreezeModel(serverModelCache.get(modelName));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | URL} input
|
||||
* @param {RequestInit} [init]
|
||||
*/
|
||||
export function globalCachedFetch(input, init) {
|
||||
if (init?.method && init.method.toLowerCase() !== "get") {
|
||||
throw new Error(`cannot use a global cached fetch with HTTP method "${init.method}"`);
|
||||
}
|
||||
const key = String(input);
|
||||
if (!(key in globalFetchCache)) {
|
||||
globalFetchCache[key] = realFetch(input, init).catch((reason) => {
|
||||
delete globalFetchCache[key];
|
||||
throw reason;
|
||||
});
|
||||
}
|
||||
return globalFetchCache[key].then((response) => response.clone());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
*/
|
||||
export function registerModelToFetch(modelName) {
|
||||
if (!serverModelCache.has(modelName)) {
|
||||
modelsToFetch.add(modelName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ fileSuffix?: string }} [options]
|
||||
*/
|
||||
export async function runTests(options) {
|
||||
const { fileSuffix = "" } = options || {};
|
||||
// Find dependency issues
|
||||
const errors = loader.findErrors(loader.factories.keys());
|
||||
delete errors.unloaded; // Only a few modules have been loaded yet => irrelevant
|
||||
if (Object.keys(errors).length) {
|
||||
return loader.reportErrors(errors);
|
||||
}
|
||||
|
||||
// Sort modules to accelerate loading time
|
||||
/** @type {Record<string, Deferred>} */
|
||||
const defs = {};
|
||||
/** @type {string[]} */
|
||||
const testModuleNames = [];
|
||||
for (const [name, { deps }] of loader.factories) {
|
||||
// Register test module
|
||||
if (name.endsWith(fileSuffix)) {
|
||||
const baseName = name.slice(0, -fileSuffix.length);
|
||||
testModuleNames.push(baseName);
|
||||
}
|
||||
|
||||
// Register module dependencies
|
||||
const [modDef, ...depDefs] = [name, ...deps].map((dep) => (defs[dep] ||= new Deferred()));
|
||||
Promise.all(depDefs).then(() => {
|
||||
sortedModuleNames.push(name);
|
||||
modDef.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(Object.values(defs));
|
||||
|
||||
// Dry run
|
||||
const { suites } = await dryRun(() => describeDrySuite(fileSuffix, testModuleNames));
|
||||
|
||||
// Run all test files
|
||||
const filteredSuitePaths = new Set(suites.map((s) => s.fullName));
|
||||
let currentAddonsKey = "";
|
||||
let lastSuiteName = null;
|
||||
let lastNumberTests = 0;
|
||||
for (const moduleName of testModuleNames) {
|
||||
if (lastSuiteName) {
|
||||
await __gcAndLogMemory(lastSuiteName, lastNumberTests);
|
||||
lastSuiteName = null;
|
||||
}
|
||||
const suitePath = getSuitePath(moduleName);
|
||||
if (!filteredSuitePaths.has(suitePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const moduleSet = await defineModuleSet(fileSuffix, [moduleName], new Set());
|
||||
const moduleSetLoader = new ModuleSetLoader(moduleSet);
|
||||
|
||||
if (currentAddonsKey !== moduleSet.addonsKey) {
|
||||
if (moduleSetLoader.modules.has(TEMPLATE_MODULE_NAME)) {
|
||||
// If templates module is available: set URL filter to filter out
|
||||
// static templates and cleanup current processed templates.
|
||||
const templateModule = moduleSetLoader.modules.get(TEMPLATE_MODULE_NAME);
|
||||
templateModule.setUrlFilters(moduleSet.filter ? [moduleSet.filter] : []);
|
||||
templateModule.clearProcessedTemplates();
|
||||
}
|
||||
currentAddonsKey = moduleSet.addonsKey;
|
||||
}
|
||||
|
||||
const suite = describe(suitePath, () => {
|
||||
moduleSetLoader.setup();
|
||||
moduleSetLoader.startModule(moduleName + fileSuffix);
|
||||
});
|
||||
|
||||
// Run recently added tests
|
||||
const running = await start(suite);
|
||||
|
||||
await moduleSetLoader.cleanup();
|
||||
|
||||
lastSuiteName = suite.fullName;
|
||||
lastNumberTests = suite.reporting.tests;
|
||||
|
||||
if (!running) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastSuiteName) {
|
||||
await __gcAndLogMemory(lastSuiteName, lastNumberTests);
|
||||
}
|
||||
|
||||
await stop();
|
||||
|
||||
// Perform final cleanups
|
||||
moduleNamesCache.clear();
|
||||
serverModelCache.clear();
|
||||
clearObject(dependencies);
|
||||
clearObject(dependencyCache);
|
||||
clearObject(globalFetchCache);
|
||||
const templateModule = loader.modules.get(TEMPLATE_MODULE_NAME);
|
||||
if (templateModule) {
|
||||
templateModule.setUrlFilters([]);
|
||||
templateModule.clearProcessedTemplates();
|
||||
}
|
||||
|
||||
await __gcAndLogMemory("tests done");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toned-down version of the RPC + ORM features since this file cannot depend on
|
||||
* them.
|
||||
*
|
||||
* @param {string} model
|
||||
* @param {string} method
|
||||
* @param {any[]} args
|
||||
* @param {Record<string, any>} kwargs
|
||||
*/
|
||||
export async function unmockedOrm(model, method, args, kwargs) {
|
||||
const response = await realFetch(`/web/dataset/call_kw/${model}/${method}`, {
|
||||
body: JSON.stringify({
|
||||
id: nextRpcId++,
|
||||
jsonrpc: "2.0",
|
||||
method: "call",
|
||||
params: { args, kwargs, method, model },
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
const { error, result } = await response.json();
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { after } from "@odoo/hoot";
|
||||
import { onTimeZoneChange } from "@odoo/hoot-mock";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
const { FixedOffsetZone, IANAZone, Settings } = luxon;
|
||||
|
||||
onTimeZoneChange((tz) => {
|
||||
let defaultZone;
|
||||
if (typeof tz === "string") {
|
||||
defaultZone = IANAZone.create(tz);
|
||||
} else {
|
||||
const offset = new Date().getTimezoneOffset();
|
||||
defaultZone = FixedOffsetZone.instance(-offset);
|
||||
}
|
||||
patchWithCleanup(Settings, { defaultZone });
|
||||
});
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @type {typeof patch} */
|
||||
export function patchWithCleanup(obj, patchValue) {
|
||||
const unpatch = patch(obj, patchValue);
|
||||
after(unpatch);
|
||||
return unpatch;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { afterEach, onError } from "@odoo/hoot";
|
||||
|
||||
export function preventResizeObserverError() {
|
||||
let resizeObserverErrorCount = 0;
|
||||
onError((ev) => {
|
||||
// commits cb1fcb598f404bd4b0be3a541297cbdc556b29be and f478310d170028b99eb009560382e53330159200
|
||||
// This error is sometimes thrown but is essentially harmless as long as it is not thrown
|
||||
// indefinitely. cf https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
|
||||
if (ev.message === "ResizeObserver loop completed with undelivered notifications.") {
|
||||
if (resizeObserverErrorCount < 1) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
resizeObserverErrorCount++;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resizeObserverErrorCount = 0;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
import { queryAll, queryAllTexts, queryOne, queryText } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { findComponent, mountWithCleanup } from "./component_test_helpers";
|
||||
import { contains } from "./dom_test_helpers";
|
||||
import { getMockEnv, makeMockEnv } from "./env_test_helpers";
|
||||
|
||||
import { WithSearch } from "@web/search/with_search/with_search";
|
||||
import { getDefaultConfig } from "@web/views/view";
|
||||
|
||||
const ensureSearchView = async () => {
|
||||
if (
|
||||
getMockEnv().isSmall &&
|
||||
queryAll`.o_control_panel_navigation`.length &&
|
||||
!queryAll`.o_searchview`.length
|
||||
) {
|
||||
await contains(`.o_control_panel_navigation .fa-search`).click();
|
||||
}
|
||||
};
|
||||
|
||||
const ensureSearchBarMenu = async () => {
|
||||
if (!queryAll`.o_search_bar_menu`.length) {
|
||||
await toggleSearchBarMenu();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This function is aim to be used only in the tests.
|
||||
* It will filter the props that are needed by the Component.
|
||||
* This is to avoid errors of props validation. This occurs for example, on ControlPanel tests.
|
||||
* In production, View use WithSearch for the Controllers, and the Layout send only the props that
|
||||
* need to the ControlPanel.
|
||||
*
|
||||
* @param {Component} Component
|
||||
* @param {Object} props
|
||||
* @returns {Object} filtered props
|
||||
*/
|
||||
function filterPropsForComponent(Component, props) {
|
||||
// This if, can be removed once all the Components have the props defined
|
||||
if (Component.props) {
|
||||
let componentKeys = null;
|
||||
if (Component.props instanceof Array) {
|
||||
componentKeys = Component.props.map((x) => x.replace("?", ""));
|
||||
} else {
|
||||
componentKeys = Object.keys(Component.props);
|
||||
}
|
||||
if (componentKeys.includes("*")) {
|
||||
return props;
|
||||
} else {
|
||||
return Object.keys(props)
|
||||
.filter((k) => componentKeys.includes(k))
|
||||
.reduce((o, k) => {
|
||||
o[k] = props[k];
|
||||
return o;
|
||||
}, {});
|
||||
}
|
||||
} else {
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Search view
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mounts a component wrapped within a WithSearch.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} componentConstructor
|
||||
* @param {Record<string, any>} [options]
|
||||
* @param {Record<string, any>} [config]
|
||||
* @returns {Promise<InstanceType<T>>}
|
||||
*/
|
||||
export async function mountWithSearch(componentConstructor, searchProps = {}, config = {}) {
|
||||
class ComponentWithSearch extends Component {
|
||||
static template = xml`
|
||||
<WithSearch t-props="withSearchProps" t-slot-scope="search">
|
||||
<t t-component="component" t-props="getProps(search)"/>
|
||||
</WithSearch>
|
||||
`;
|
||||
static components = { WithSearch };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.withSearchProps = searchProps;
|
||||
this.component = componentConstructor;
|
||||
}
|
||||
|
||||
getProps(search) {
|
||||
const props = {
|
||||
context: search.context,
|
||||
domain: search.domain,
|
||||
groupBy: search.groupBy,
|
||||
orderBy: search.orderBy,
|
||||
comparison: search.comparison,
|
||||
display: search.display,
|
||||
};
|
||||
return filterPropsForComponent(componentConstructor, props);
|
||||
}
|
||||
}
|
||||
|
||||
const fullConfig = { ...getDefaultConfig(), ...config };
|
||||
const env = await makeMockEnv({ config: fullConfig });
|
||||
const root = await mountWithCleanup(ComponentWithSearch, { env });
|
||||
return findComponent(root, (component) => component instanceof componentConstructor);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Menu (generic)
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function toggleMenu(label) {
|
||||
await contains(`button.o-dropdown:contains(/^${label}$/i)`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function toggleMenuItem(label) {
|
||||
const target = queryOne`.o_menu_item:contains(/^${label}$/i)`;
|
||||
if (target.classList.contains("dropdown-toggle")) {
|
||||
await contains(target).hover();
|
||||
} else {
|
||||
await contains(target).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} itemLabel
|
||||
* @param {string} optionLabel
|
||||
*/
|
||||
export async function toggleMenuItemOption(itemLabel, optionLabel) {
|
||||
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
|
||||
const target = queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root });
|
||||
if (target.classList.contains("dropdown-toggle")) {
|
||||
await contains(target).hover();
|
||||
} else {
|
||||
await contains(target).click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export function isItemSelected(label) {
|
||||
return queryOne`.o_menu_item:contains(/^${label}$/i)`.classList.contains("selected");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} itemLabel
|
||||
* @param {string} optionLabel
|
||||
*/
|
||||
export function isOptionSelected(itemLabel, optionLabel) {
|
||||
const { parentElement: root } = queryOne`.o_menu_item:contains(/^${itemLabel}$/i)`;
|
||||
return queryOne(`.o_item_option:contains(/^${optionLabel}$/i)`, { root }).classList.contains(
|
||||
"selected"
|
||||
);
|
||||
}
|
||||
|
||||
export function getMenuItemTexts() {
|
||||
return queryAllTexts`.dropdown-menu .o_menu_item`;
|
||||
}
|
||||
|
||||
export function getButtons() {
|
||||
return queryAll`.o_control_panel_breadcrumbs button`;
|
||||
}
|
||||
|
||||
export function getVisibleButtons() {
|
||||
return queryAll`.o_control_panel_breadcrumbs button:visible, .o_control_panel_actions button:visible`;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Filter menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleFilterMenu() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_filter_menu button.dropdown-toggle`).click();
|
||||
}
|
||||
|
||||
export async function openAddCustomFilterDialog() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_filter_menu .o_menu_item.o_add_custom_filter`).click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Group by menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleGroupByMenu() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_group_by_menu .dropdown-toggle`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
*/
|
||||
export async function selectGroup(fieldName) {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_add_custom_group_menu`).select(fieldName);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Favorite menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleFavoriteMenu() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .dropdown-toggle`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
*/
|
||||
export async function editFavorite(text) {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .o_menu_item:contains(/^${text}$/i) i.fa-pencil`, {
|
||||
visible: false,
|
||||
}).click();
|
||||
}
|
||||
|
||||
export async function toggleSaveFavorite() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .o_add_favorite`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function editFavoriteName(name) {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(
|
||||
`.o_favorite_menu .o_add_favorite + .o_accordion_values input[type="text"]`
|
||||
).edit(name, { confirm: false });
|
||||
}
|
||||
|
||||
export async function saveFavorite() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .o_save_favorite`).click();
|
||||
}
|
||||
|
||||
export async function saveAndEditFavorite() {
|
||||
await ensureSearchBarMenu();
|
||||
await contains(`.o_favorite_menu .o_edit_favorite`).click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Search bar
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function getFacetTexts() {
|
||||
return queryAllTexts(`.o_searchview_facet`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
*/
|
||||
export async function removeFacet(label) {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview_facet:contains(/^${label}$/i) .o_facet_remove`).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export async function editSearch(value) {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview input`).edit(value, { confirm: false });
|
||||
}
|
||||
|
||||
export async function validateSearch() {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview input`).press("Enter");
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Switch view
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {import("./mock_server/mock_server").ViewType} viewType
|
||||
*/
|
||||
export async function switchView(viewType) {
|
||||
if (getMockEnv().isSmall) {
|
||||
await contains(".o_cp_switch_buttons .dropdown-toggle").click();
|
||||
await contains(`.dropdown-item:contains(${viewType.toUpperCase()})`).click();
|
||||
} else {
|
||||
await contains(`button.o_switch_view.o_${viewType}`).click();
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Pager
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export function getPagerValue(root) {
|
||||
return queryText(".o_pager .o_pager_value", { root })
|
||||
.split(/\s*-\s*/)
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export function getPagerLimit(root) {
|
||||
return parseInt(queryText(".o_pager .o_pager_limit", { root }), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export async function pagerNext(root) {
|
||||
await contains(".o_pager button.o_pager_next", { root }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export async function pagerPrevious(root) {
|
||||
await contains(".o_pager button.o_pager_previous", { root }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export async function editPager(value) {
|
||||
await contains(`.o_pager .o_pager_limit`).edit(value);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Action Menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {EventTarget} el
|
||||
* @param {string} [menuFinder="Action"]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function toggleActionMenu() {
|
||||
await contains(".o_cp_action_menus .dropdown-toggle").click();
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Search bar menu
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export async function toggleSearchBarMenu() {
|
||||
await ensureSearchView();
|
||||
await contains(`.o_searchview_dropdown_toggler`).click();
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
|
||||
|
||||
import { definePreset, defineTags, isHootReady } from "@odoo/hoot";
|
||||
import { runTests } from "./module_set.hoot";
|
||||
|
||||
function beforeFocusRequired(test) {
|
||||
if (!document.hasFocus()) {
|
||||
console.warn(
|
||||
"[FOCUS REQUIRED]",
|
||||
`test "${test.name}" requires focus inside of the browser window and will probably fail without it`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
definePreset("desktop", {
|
||||
icon: "fa-desktop",
|
||||
label: "Desktop",
|
||||
platform: "linux",
|
||||
size: [1366, 768],
|
||||
tags: ["-mobile"],
|
||||
touch: false,
|
||||
});
|
||||
definePreset("mobile", {
|
||||
icon: "fa-mobile font-bold",
|
||||
label: "Mobile",
|
||||
platform: "android",
|
||||
size: [375, 667],
|
||||
tags: ["-desktop"],
|
||||
touch: true,
|
||||
});
|
||||
defineTags(
|
||||
{
|
||||
name: "desktop",
|
||||
exclude: ["headless", "mobile"],
|
||||
},
|
||||
{
|
||||
name: "mobile",
|
||||
exclude: ["desktop", "headless"],
|
||||
},
|
||||
{
|
||||
name: "headless",
|
||||
exclude: ["desktop", "mobile"],
|
||||
},
|
||||
{
|
||||
name: "focus required",
|
||||
before: beforeFocusRequired,
|
||||
}
|
||||
);
|
||||
|
||||
// Invoke tests after the interface has finished loading.
|
||||
isHootReady.then(() => runTests({ fileSuffix: ".test" }));
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { queryFirst } from "@odoo/hoot-dom";
|
||||
import { advanceTime } from "@odoo/hoot-mock";
|
||||
import { contains } from "./dom_test_helpers";
|
||||
|
||||
/**
|
||||
* @typedef {import("@odoo/hoot-dom").PointerOptions} PointerOptions
|
||||
* @typedef {import("@odoo/hoot-dom").Target} Target
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Target} target
|
||||
* @param {number} direction
|
||||
* @param {PointerOptions} [dragOptions]
|
||||
* @param {PointerOptions} [moveToOptions]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function swipe(target, direction, dragOptions, moveToOptions) {
|
||||
const el = queryFirst(target);
|
||||
if (direction < 0) {
|
||||
// The scrollable element is set at its right limit
|
||||
el.scrollLeft = el.scrollWidth - el.offsetWidth;
|
||||
} else {
|
||||
// The scrollable element is set at its left limit
|
||||
el.scrollLeft = 0;
|
||||
}
|
||||
|
||||
const { moveTo, drop } = await contains(el).drag({
|
||||
position: { x: 0, y: 0 },
|
||||
...dragOptions,
|
||||
});
|
||||
|
||||
await moveTo(el, {
|
||||
position: { x: direction * el.clientWidth },
|
||||
...moveToOptions,
|
||||
});
|
||||
|
||||
await drop();
|
||||
await advanceTime(1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Will simulate a swipe left on the target element.
|
||||
*
|
||||
* @param {Target} target
|
||||
* @param {PointerOptions} [dragOptions]
|
||||
* @param {PointerOptions} [moveToOptions]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function swipeLeft(target, dragOptions, moveToOptions) {
|
||||
await swipe(target, -1, dragOptions, moveToOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Will simulate a swipe right on the target element.
|
||||
*
|
||||
* @param {Target} target
|
||||
* @param {PointerOptions} [dragOptions]
|
||||
* @param {PointerOptions} [moveToOptions]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function swipeRight(target, dragOptions, moveToOptions) {
|
||||
await swipe(target, +1, dragOptions, moveToOptions);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { after } from "@odoo/hoot";
|
||||
import { serverState } from "./mock_server_state.hoot";
|
||||
import { patchWithCleanup } from "./patch_test_helpers";
|
||||
|
||||
import {
|
||||
loadLanguages,
|
||||
translatedTerms,
|
||||
translatedTermsGlobal,
|
||||
translationLoaded,
|
||||
} from "@web/core/l10n/translation";
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} languages
|
||||
*/
|
||||
export function installLanguages(languages) {
|
||||
serverState.multiLang = true;
|
||||
patchWithCleanup(loadLanguages, {
|
||||
installedLanguages: Object.entries(languages),
|
||||
});
|
||||
}
|
||||
|
||||
export function allowTranslations() {
|
||||
translatedTerms[translationLoaded] = true;
|
||||
after(() => {
|
||||
translatedTerms[translationLoaded] = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, Record<string, string>>} [terms]
|
||||
*/
|
||||
export function patchTranslations(terms = {}) {
|
||||
allowTranslations();
|
||||
for (const addonName in terms) {
|
||||
if (!(addonName in translatedTerms)) {
|
||||
patchWithCleanup(translatedTerms, { [addonName]: {} });
|
||||
}
|
||||
patchWithCleanup(translatedTerms[addonName], terms[addonName]);
|
||||
patchWithCleanup(translatedTermsGlobal, terms[addonName]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
import { after, expect, getFixture } from "@odoo/hoot";
|
||||
import {
|
||||
click,
|
||||
formatXml,
|
||||
queryAll,
|
||||
queryAllTexts,
|
||||
queryFirst,
|
||||
runAllTimers,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { animationFrame, Deferred, tick } from "@odoo/hoot-mock";
|
||||
import { Component, onMounted, useSubEnv, xml } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { View } from "@web/views/view";
|
||||
import { mountWithCleanup } from "./component_test_helpers";
|
||||
import { contains } from "./dom_test_helpers";
|
||||
import { getMockEnv, getService } from "./env_test_helpers";
|
||||
import { registerInlineViewArchs } from "./mock_server/mock_model";
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/views/view").Config} Config
|
||||
*
|
||||
* @typedef {ViewProps & {
|
||||
* archs?: Record<string, string>
|
||||
* config?: Config;
|
||||
* env?: import("@web/env").OdooEnv;
|
||||
* resId?: number;
|
||||
* [key: string]: any;
|
||||
* }} MountViewParams
|
||||
*
|
||||
* @typedef {{
|
||||
* class?: string;
|
||||
* id?: string;
|
||||
* index?: number;
|
||||
* modifier?: string;
|
||||
* target?: string;
|
||||
* text?: string;
|
||||
* }} SelectorOptions
|
||||
*
|
||||
* * @typedef {{
|
||||
* value?: string;
|
||||
* index?: number;
|
||||
* }} EditSelectMenuParams
|
||||
*
|
||||
* @typedef {import("@odoo/hoot-dom").FormatXmlOptions} FormatXmlOptions
|
||||
* @typedef {import("@web/views/view").ViewProps} ViewProps
|
||||
* @typedef {import("./mock_server/mock_model").ViewType} ViewType
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internals
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* FIXME: isolate to external helper in @web?
|
||||
*
|
||||
* @param {unknown} value
|
||||
*/
|
||||
const isNil = (value) => value === null || value === undefined;
|
||||
|
||||
class ViewDialog extends Component {
|
||||
static components = { Dialog, View };
|
||||
|
||||
static props = {
|
||||
onMounted: Function,
|
||||
viewEnv: Object,
|
||||
viewProps: Object,
|
||||
close: Function,
|
||||
};
|
||||
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
<View t-props="props.viewProps" />
|
||||
</Dialog>
|
||||
`;
|
||||
|
||||
setup() {
|
||||
useSubEnv(this.props.viewEnv);
|
||||
onMounted(() => this.props.onMounted());
|
||||
}
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} base
|
||||
* @param {SelectorOptions} [params]
|
||||
*/
|
||||
export function buildSelector(base, params) {
|
||||
let selector = base;
|
||||
params ||= {};
|
||||
if (params.id) {
|
||||
selector += `#${params.id}`;
|
||||
}
|
||||
if (params.class) {
|
||||
selector += `.${params.class}`;
|
||||
}
|
||||
if (params.modifier) {
|
||||
selector += `:${params.modifier}`;
|
||||
}
|
||||
if (params.text) {
|
||||
selector += `:contains(${params.text})`;
|
||||
}
|
||||
if (!isNil(params.index)) {
|
||||
selector += `:eq(${params.index})`;
|
||||
}
|
||||
if (params.target) {
|
||||
selector += ` ${params.target}`;
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickButton(options) {
|
||||
await contains(buildSelector(`.btn:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickCancel(options) {
|
||||
await contains(buildSelector(`.o_form_button_cancel:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickFieldDropdown(fieldName, options) {
|
||||
const selector = getMockEnv().isSmall
|
||||
? `[name='${fieldName}'] input`
|
||||
: `[name='${fieldName}'] .dropdown input`;
|
||||
await contains(buildSelector(selector, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fieldName
|
||||
* @param {string} itemContent
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickFieldDropdownItem(fieldName, itemContent, options) {
|
||||
if (getMockEnv().isSmall) {
|
||||
await contains(`.o_kanban_record:contains('${itemContent}')`).click();
|
||||
return;
|
||||
}
|
||||
const dropdowns = queryAll(
|
||||
buildSelector(`[name='${fieldName}'] .dropdown .dropdown-menu`, options)
|
||||
);
|
||||
if (dropdowns.length === 0) {
|
||||
throw new Error(`No dropdown found for field ${fieldName}`);
|
||||
} else if (dropdowns.length > 1) {
|
||||
throw new Error(`Found ${dropdowns.length} dropdowns for field ${fieldName}`);
|
||||
}
|
||||
const dropdownItems = queryAll(buildSelector("li", options), { root: dropdowns[0] });
|
||||
const indexToClick = queryAllTexts(dropdownItems).indexOf(itemContent);
|
||||
if (indexToClick === -1) {
|
||||
throw new Error(`The element '${itemContent}' does not exist in the dropdown`);
|
||||
}
|
||||
await click(dropdownItems[indexToClick]);
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickModalButton(options) {
|
||||
await contains(buildSelector(`.modal .btn:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickSave(options) {
|
||||
await contains(buildSelector(`.o_form_button_save:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function clickViewButton(options) {
|
||||
await contains(buildSelector(`.o_view_controller .btn:enabled`, options)).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export function expectMarkup(value) {
|
||||
return {
|
||||
/**
|
||||
* @param {string} expected
|
||||
* @param {FormatXmlOptions} [options]
|
||||
*/
|
||||
toBe(expected, options) {
|
||||
expect(formatXml(value, options)).toBe(formatXml(expected, options));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {SelectorOptions} options
|
||||
*/
|
||||
export function fieldInput(name, options) {
|
||||
return contains(buildSelector(`.o_field_widget[name='${name}'] input`, options));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MountViewParams} params
|
||||
*/
|
||||
export async function mountViewInDialog(params) {
|
||||
const container = await mountWithCleanup(MainComponentsContainer, {
|
||||
env: params.env,
|
||||
});
|
||||
const deferred = new Deferred();
|
||||
getService("dialog").add(ViewDialog, {
|
||||
viewEnv: { config: params.config },
|
||||
viewProps: parseViewProps(params),
|
||||
onMounted() {
|
||||
deferred.resolve();
|
||||
},
|
||||
});
|
||||
await deferred;
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MountViewParams} params
|
||||
* @param {HTMLElement} [target]
|
||||
*/
|
||||
export async function mountView(params, target = null) {
|
||||
const actionManagerEl = document.createElement("div");
|
||||
actionManagerEl.classList.add("o_action_manager");
|
||||
(target ?? getFixture()).append(actionManagerEl);
|
||||
after(() => actionManagerEl.remove());
|
||||
return mountWithCleanup(View, {
|
||||
env: params.env,
|
||||
componentEnv: { config: params.config },
|
||||
props: parseViewProps(params),
|
||||
target: actionManagerEl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ViewProps & { archs?: Record<string, string> }} props
|
||||
* @returns {ViewProps}
|
||||
*/
|
||||
export function parseViewProps(props) {
|
||||
let className = "o_action";
|
||||
if (props.className) {
|
||||
className += " " + props.className;
|
||||
}
|
||||
|
||||
const viewProps = { ...props, className };
|
||||
|
||||
if (
|
||||
props.archs ||
|
||||
!isNil(props.arch) ||
|
||||
!isNil(props.searchViewArch) ||
|
||||
!isNil(props.searchViewId) ||
|
||||
!isNil(props.viewId)
|
||||
) {
|
||||
viewProps.viewId ??= -1;
|
||||
viewProps.searchViewId ??= -1;
|
||||
registerInlineViewArchs(viewProps.resModel, {
|
||||
...props.archs,
|
||||
[[viewProps.type, viewProps.viewId]]: viewProps.arch,
|
||||
[["search", viewProps.searchViewId]]: viewProps.searchViewArch,
|
||||
});
|
||||
} else {
|
||||
// Force `get_views` call
|
||||
viewProps.viewId = false;
|
||||
viewProps.searchViewId = false;
|
||||
}
|
||||
|
||||
delete viewProps.arch;
|
||||
delete viewProps.archs;
|
||||
delete viewProps.config;
|
||||
delete viewProps.searchViewArch;
|
||||
|
||||
return viewProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a field dropdown and click on the item which matches the
|
||||
* given content
|
||||
* @param {string} fieldName
|
||||
* @param {string} itemContent
|
||||
* @param {SelectorOptions} [options]
|
||||
*/
|
||||
export async function selectFieldDropdownItem(fieldName, itemContent, options) {
|
||||
await clickFieldDropdown(fieldName, options);
|
||||
await clickFieldDropdownItem(fieldName, itemContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emulates the behaviour when we hide the tab in the browser.
|
||||
*/
|
||||
export async function hideTab() {
|
||||
const prop = Object.getOwnPropertyDescriptor(Document.prototype, "visibilityState");
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await tick();
|
||||
Object.defineProperty(document, "visibilityState", prop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes or clears the value in a SelectMenu component, supporting when
|
||||
* the input is displayed in the toggler, in a Dropdown menu or in a
|
||||
* BottomSheet as well. The helper can directly select a value if it's
|
||||
* displayed or perform a search in the SelectMenu input if present.
|
||||
* @param {string} selector
|
||||
* @param {EditSelectMenuParams} [params]
|
||||
*/
|
||||
export async function editSelectMenu(selector, { value, index }) {
|
||||
async function selectItem(value) {
|
||||
const elementToSelect = queryFirst(`.o_select_menu_item:contains(${value})`);
|
||||
if (elementToSelect) {
|
||||
await click(elementToSelect);
|
||||
return;
|
||||
} else {
|
||||
await contains(inputSelector).edit(value, { confirm: false });
|
||||
await runAllTimers();
|
||||
return selectItem(value);
|
||||
}
|
||||
}
|
||||
let inputSelector = buildSelector(selector);
|
||||
const selectMenuId = queryFirst(inputSelector).closest(".o_select_menu").dataset.id;
|
||||
if (!queryFirst(`.o_select_menu_menu [data-id='${selectMenuId}']`)) {
|
||||
await contains(inputSelector).click();
|
||||
}
|
||||
if (queryFirst(".o_select_menu_menu input")) {
|
||||
inputSelector = ".o_select_menu_menu input";
|
||||
await contains(inputSelector).click();
|
||||
}
|
||||
if (index !== undefined) {
|
||||
return await contains(`.o_select_menu_item:nth-of-type(${index + 1})`).click();
|
||||
}
|
||||
if (value === "") {
|
||||
// Because this helper must work even when no input is editable (searchable=false),
|
||||
// we unselect the currently selected value with the 'X' button
|
||||
const clearButton = queryFirst(
|
||||
`.o_select_menu[data-id='${selectMenuId}'] .o_select_menu_toggler_clear, .o_select_menu_menu .o_clear_button`
|
||||
);
|
||||
if (clearButton) {
|
||||
await click(clearButton);
|
||||
} else {
|
||||
await contains(inputSelector).edit("", { confirm: false });
|
||||
queryFirst(inputSelector).dispatchEvent(new Event("blur"));
|
||||
}
|
||||
} else {
|
||||
await selectItem(value);
|
||||
}
|
||||
await animationFrame();
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
import { mountWithCleanup } from "./component_test_helpers";
|
||||
|
||||
class TestClientAction extends Component {
|
||||
static template = xml`
|
||||
<div class="test_client_action">
|
||||
ClientAction_<t t-esc="props.action.params?.description"/>
|
||||
</div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
export function useTestClientAction() {
|
||||
const tag = "__test__client__action__";
|
||||
registry.category("actions").add(tag, TestClientAction);
|
||||
return {
|
||||
tag,
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Id 1" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Parameters<typeof mountWithCleanup>[1]} [options]
|
||||
*/
|
||||
export async function mountWebClient(options = {}) {
|
||||
const WebClientComponent = options.WebClient || WebClient;
|
||||
delete options.WebClient;
|
||||
const webClient = await mountWithCleanup(WebClientComponent, options);
|
||||
// Wait for visual changes caused by a potential loadState
|
||||
await animationFrame();
|
||||
// wait for BlankComponent
|
||||
await animationFrame();
|
||||
// wait for the regular rendering
|
||||
await animationFrame();
|
||||
|
||||
return webClient;
|
||||
}
|
||||
|
|
@ -0,0 +1,678 @@
|
|||
/** @odoo-module alias=@web/../tests/mobile/core/action_swiper_tests default=false */
|
||||
|
||||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { hover, queryFirst } from "@odoo/hoot-dom";
|
||||
import { advanceTime, animationFrame, mockTouch } from "@odoo/hoot-mock";
|
||||
import { Component, onPatched, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
defineParams,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
swipeLeft,
|
||||
swipeRight,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { ActionSwiper } from "@web/core/action_swiper/action_swiper";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
|
||||
beforeEach(() => mockTouch(true));
|
||||
|
||||
// Tests marked as will fail on browsers that don't support
|
||||
// TouchEvent by default. It might be an option to activate on some browser.
|
||||
|
||||
test("render only its target if no props is given", async () => {
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper>
|
||||
<div class="target-component"/>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect("div.o_actionswiper").toHaveCount(0);
|
||||
expect("div.target-component").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("only render the necessary divs", async () => {
|
||||
await mountWithCleanup(ActionSwiper, {
|
||||
props: {
|
||||
onRightSwipe: {
|
||||
action: () => {},
|
||||
icon: "fa-circle",
|
||||
bgColor: "bg-warning",
|
||||
},
|
||||
slots: {},
|
||||
},
|
||||
});
|
||||
expect("div.o_actionswiper_right_swipe_area").toHaveCount(1);
|
||||
expect("div.o_actionswiper_left_swipe_area").toHaveCount(0);
|
||||
await mountWithCleanup(ActionSwiper, {
|
||||
props: {
|
||||
onLeftSwipe: {
|
||||
action: () => {},
|
||||
icon: "fa-circle",
|
||||
bgColor: "bg-warning",
|
||||
},
|
||||
slots: {},
|
||||
},
|
||||
});
|
||||
expect("div.o_actionswiper_right_swipe_area").toHaveCount(1);
|
||||
expect("div.o_actionswiper_left_swipe_area").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("render with the height of its content", async () => {
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="o-container d-flex" style="width: 200px; height: 200px; overflow: auto">
|
||||
<ActionSwiper onRightSwipe = "{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning'
|
||||
}">
|
||||
<div class="target-component" style="height: 800px">This element is very high and
|
||||
the o-container element must have a scrollbar</div>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
onRightSwipe() {
|
||||
expect.step("onRightSwipe");
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(queryFirst(".o_actionswiper").scrollHeight).toBe(
|
||||
queryFirst(".target-component").scrollHeight,
|
||||
{ message: "the swiper has the height of its content" }
|
||||
);
|
||||
expect(queryFirst(".o_actionswiper").scrollHeight).toBeGreaterThan(
|
||||
queryFirst(".o_actionswiper").clientHeight,
|
||||
{ message: "the height of the swiper must make the parent div scrollable" }
|
||||
);
|
||||
});
|
||||
|
||||
test("can perform actions by swiping to the right", async () => {
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper onRightSwipe = "{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning'
|
||||
}">
|
||||
<div class="target-component" style="width: 200px; height: 80px">Test</div>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
onRightSwipe() {
|
||||
expect.step("onRightSwipe");
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
const swiper = queryFirst(".o_actionswiper");
|
||||
const targetContainer = queryFirst(".o_actionswiper_target_container");
|
||||
const dragHelper = await contains(swiper).drag({
|
||||
position: {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(swiper, {
|
||||
position: {
|
||||
clientX: (3 * swiper.clientWidth) / 4,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).toInclude("translateX", {
|
||||
message: "target has translateX",
|
||||
});
|
||||
|
||||
// Touch ends before the half of the distance has been reached
|
||||
await dragHelper.moveTo(swiper, {
|
||||
position: {
|
||||
clientX: swiper.clientWidth / 2 - 1,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
await dragHelper.drop();
|
||||
await animationFrame();
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message: "target does not have a translate value",
|
||||
});
|
||||
|
||||
// Touch ends once the half of the distance has been crossed
|
||||
await swipeRight(".o_actionswiper");
|
||||
// The action is performed AND the component is reset
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message: "target does not have a translate value",
|
||||
});
|
||||
|
||||
expect.verifySteps(["onRightSwipe"]);
|
||||
});
|
||||
|
||||
test("can perform actions by swiping in both directions", async () => {
|
||||
expect.assertions(5);
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper
|
||||
onRightSwipe = "{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning'
|
||||
}"
|
||||
onLeftSwipe = "{
|
||||
action: () => this.onLeftSwipe(),
|
||||
icon: 'fa-check',
|
||||
bgColor: 'bg-success'
|
||||
}">
|
||||
<div class="target-component" style="width: 250px; height: 80px">Swipe in both directions</div>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
onRightSwipe() {
|
||||
expect.step("onRightSwipe");
|
||||
}
|
||||
onLeftSwipe() {
|
||||
expect.step("onLeftSwipe");
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
const swiper = queryFirst(".o_actionswiper");
|
||||
const targetContainer = queryFirst(".o_actionswiper_target_container");
|
||||
const dragHelper = await contains(swiper).drag({
|
||||
position: {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(swiper, {
|
||||
position: {
|
||||
clientX: (3 * swiper.clientWidth) / 4,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).toInclude("translateX", {
|
||||
message: "target has translateX",
|
||||
});
|
||||
// Touch ends before the half of the distance has been reached to the left
|
||||
await dragHelper.moveTo(swiper, {
|
||||
position: {
|
||||
clientX: -swiper.clientWidth / 2 + 1,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await dragHelper.drop();
|
||||
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message: "target does not have a translate value",
|
||||
});
|
||||
|
||||
// Touch ends once the half of the distance has been crossed to the left
|
||||
await swipeLeft(".o_actionswiper");
|
||||
expect.verifySteps(["onLeftSwipe"]);
|
||||
// Touch ends once the half of the distance has been crossed to the right
|
||||
await swipeRight(".o_actionswiper");
|
||||
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message: "target doesn't have translateX after all actions are performed",
|
||||
});
|
||||
|
||||
expect.verifySteps(["onRightSwipe"]);
|
||||
});
|
||||
|
||||
test("invert the direction of swipes when language is rtl", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
direction: "rtl",
|
||||
},
|
||||
});
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper
|
||||
onRightSwipe = "{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning'
|
||||
}"
|
||||
onLeftSwipe = "{
|
||||
action: () => this.onLeftSwipe(),
|
||||
icon: 'fa-check',
|
||||
bgColor: 'bg-success'
|
||||
}">
|
||||
<div class="target-component" style="width: 250px; height: 80px">Swipe in both directions</div>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
onRightSwipe() {
|
||||
expect.step("onRightSwipe");
|
||||
}
|
||||
onLeftSwipe() {
|
||||
expect.step("onLeftSwipe");
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
// Touch ends once the half of the distance has been crossed to the left
|
||||
await swipeLeft(".o_actionswiper");
|
||||
await advanceTime(500);
|
||||
// In rtl languages, actions are permuted
|
||||
expect.verifySteps(["onRightSwipe"]);
|
||||
await swipeRight(".o_actionswiper");
|
||||
await advanceTime(500);
|
||||
// In rtl languages, actions are permuted
|
||||
expect.verifySteps(["onLeftSwipe"]);
|
||||
});
|
||||
|
||||
test("swiping when the swiper contains scrollable areas", async () => {
|
||||
expect.assertions(7);
|
||||
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper
|
||||
onRightSwipe = "{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning'
|
||||
}"
|
||||
onLeftSwipe = "{
|
||||
action: () => this.onLeftSwipe(),
|
||||
icon: 'fa-check',
|
||||
bgColor: 'bg-success'
|
||||
}">
|
||||
<div class="target-component" style="width: 200px; height: 300px">
|
||||
<h1>Test about swiping and scrolling</h1>
|
||||
<div class="large-content overflow-auto">
|
||||
<h2>This div contains a larger element that will make it scrollable</h2>
|
||||
<p class="large-text" style="width: 400px">This element is so large it needs to be scrollable</p>
|
||||
</div>
|
||||
</div>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
|
||||
onRightSwipe() {
|
||||
expect.step("onRightSwipe");
|
||||
}
|
||||
|
||||
onLeftSwipe() {
|
||||
expect.step("onLeftSwipe");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
const swiper = queryFirst(".o_actionswiper");
|
||||
const targetContainer = queryFirst(".o_actionswiper_target_container");
|
||||
const scrollable = queryFirst(".large-content");
|
||||
const largeText = queryFirst(".large-text", { root: scrollable });
|
||||
const clientYMiddleScrollBar = Math.floor(
|
||||
scrollable.getBoundingClientRect().top + scrollable.getBoundingClientRect().height / 2
|
||||
);
|
||||
|
||||
// The scrollable element is set as scrollable
|
||||
scrollable.scrollLeft = 100;
|
||||
let dragHelper = await contains(swiper).drag({
|
||||
position: {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(swiper, {
|
||||
position: {
|
||||
clientX: (3 * swiper.clientWidth) / 4,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).toInclude("translateX", {
|
||||
message: "the swiper can swipe if the scrollable area is not under touch pressure",
|
||||
});
|
||||
await dragHelper.moveTo(swiper, {
|
||||
position: {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
},
|
||||
});
|
||||
await dragHelper.drop();
|
||||
|
||||
dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has not swiped to the right because the scrollable element was scrollable to the left",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
// The scrollable element is set at its left limit
|
||||
scrollable.scrollLeft = 0;
|
||||
await hover(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has swiped to the right because the scrollable element couldn't scroll anymore to the left",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
await advanceTime(500);
|
||||
expect.verifySteps(["onRightSwipe"]);
|
||||
|
||||
dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has not swiped to the left because the scrollable element was scrollable to the right",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
|
||||
// The scrollable element is set at its right limit
|
||||
scrollable.scrollLeft = scrollable.scrollWidth - scrollable.getBoundingClientRect().right;
|
||||
await hover(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: clientYMiddleScrollBar,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has swiped to the left because the scrollable element couldn't scroll anymore to the right",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
await advanceTime(500);
|
||||
expect.verifySteps(["onLeftSwipe"]);
|
||||
});
|
||||
|
||||
test("preventing swipe on scrollable areas when language is rtl", async () => {
|
||||
expect.assertions(6);
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
direction: "rtl",
|
||||
},
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper
|
||||
onRightSwipe="{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning'
|
||||
}"
|
||||
onLeftSwipe="{
|
||||
action: () => this.onLeftSwipe(),
|
||||
icon: 'fa-check',
|
||||
bgColor: 'bg-success'
|
||||
}">
|
||||
<div class="target-component" style="width: 200px; height: 300px">
|
||||
<h1>Test about swiping and scrolling for rtl</h1>
|
||||
<div class="large-content overflow-auto">
|
||||
<h2>elballorcs ti ekam lliw taht tnemele regral a sniatnoc vid sihT</h2>
|
||||
<p class="large-text" style="width: 400px">elballorcs eb ot sdeen ti egral os si tnemele sihT</p>
|
||||
</div>
|
||||
</div>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
|
||||
onRightSwipe() {
|
||||
expect.step("onRightSwipe");
|
||||
}
|
||||
|
||||
onLeftSwipe() {
|
||||
expect.step("onLeftSwipe");
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
const targetContainer = queryFirst(".o_actionswiper_target_container");
|
||||
const scrollable = queryFirst(".large-content");
|
||||
const largeText = queryFirst(".large-text", { root: scrollable });
|
||||
const scrollableMiddleClientY = Math.floor(
|
||||
scrollable.getBoundingClientRect().top + scrollable.getBoundingClientRect().height / 2
|
||||
);
|
||||
// RIGHT => Left trigger
|
||||
// The scrollable element is set as scrollable
|
||||
scrollable.scrollLeft = 100;
|
||||
let dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has not swiped to the right because the scrollable element was scrollable to the left",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
|
||||
// The scrollable element is set at its left limit
|
||||
scrollable.scrollLeft = 0;
|
||||
await hover(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has swiped to the right because the scrollable element couldn't scroll anymore to the left",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
await advanceTime(500);
|
||||
// In rtl languages, actions are permuted
|
||||
expect.verifySteps(["onLeftSwipe"]);
|
||||
// LEFT => RIGHT trigger
|
||||
await hover(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has not swiped to the left because the scrollable element was scrollable to the right",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
// The scrollable element is set at its right limit
|
||||
scrollable.scrollLeft = scrollable.scrollWidth - scrollable.getBoundingClientRect().right;
|
||||
await hover(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
dragHelper = await contains(largeText).drag({
|
||||
position: {
|
||||
clientX: scrollable.clientWidth,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
await dragHelper.moveTo(largeText, {
|
||||
position: {
|
||||
clientX: scrollable.clientLeft,
|
||||
clientY: scrollableMiddleClientY,
|
||||
},
|
||||
});
|
||||
expect(targetContainer.style.transform).toInclude("translateX", {
|
||||
message:
|
||||
"the swiper has swiped to the left because the scrollable element couldn't scroll anymore to the right",
|
||||
});
|
||||
await dragHelper.drop();
|
||||
await advanceTime(500);
|
||||
|
||||
// In rtl languages, actions are permuted
|
||||
expect.verifySteps(["onRightSwipe"]);
|
||||
});
|
||||
|
||||
test("swipeInvalid prop prevents swiping", async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
class Parent extends Component {
|
||||
static props = ["*"];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper onRightSwipe = "{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning',
|
||||
}" swipeInvalid = "swipeInvalid">
|
||||
<div class="target-component" style="width: 200px; height: 80px">Test</div>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
onRightSwipe() {
|
||||
expect.step("onRightSwipe");
|
||||
}
|
||||
swipeInvalid() {
|
||||
expect.step("swipeInvalid");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
const targetContainer = queryFirst(".o_actionswiper_target_container");
|
||||
// Touch ends once the half of the distance has been crossed
|
||||
await swipeRight(".o_actionswiper");
|
||||
|
||||
expect(targetContainer.style.transform).not.toInclude("translateX", {
|
||||
message: "target doesn't have translateX after action is performed",
|
||||
});
|
||||
expect.verifySteps(["swipeInvalid"]);
|
||||
});
|
||||
|
||||
test("action should be done before a new render", async () => {
|
||||
let executingAction = false;
|
||||
const prom = new Deferred();
|
||||
|
||||
patchWithCleanup(ActionSwiper.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
onPatched(() => {
|
||||
if (executingAction) {
|
||||
expect.step("ActionSwiper patched");
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static props = [];
|
||||
static components = { ActionSwiper };
|
||||
static template = xml`
|
||||
<div class="d-flex">
|
||||
<ActionSwiper animationType="'forwards'" onRightSwipe = "{
|
||||
action: () => this.onRightSwipe(),
|
||||
icon: 'fa-circle',
|
||||
bgColor: 'bg-warning',
|
||||
}">
|
||||
<span>test</span>
|
||||
</ActionSwiper>
|
||||
</div>
|
||||
`;
|
||||
|
||||
async onRightSwipe() {
|
||||
await animationFrame();
|
||||
expect.step("action done");
|
||||
prom.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await swipeRight(".o_actionswiper");
|
||||
executingAction = true;
|
||||
await prom;
|
||||
await animationFrame();
|
||||
expect.verifySteps(["action done", "ActionSwiper patched"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,838 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
Deferred,
|
||||
animationFrame,
|
||||
hover,
|
||||
isInViewPort,
|
||||
isScrollable,
|
||||
pointerDown,
|
||||
pointerUp,
|
||||
press,
|
||||
queryAllAttributes,
|
||||
queryAllTexts,
|
||||
queryFirst,
|
||||
queryOne,
|
||||
queryRect,
|
||||
runAllTimers,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
|
||||
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
|
||||
/**
|
||||
* Helper needed until `isInViewPort` also checks intermediate parent elements.
|
||||
* This is to make sure an element is actually visible, not just "within
|
||||
* viewport boundaries" but below or above a parent's scroll point.
|
||||
*
|
||||
* @param {import("@odoo/hoot-dom").Target} target
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isInViewWithinScrollableY(target) {
|
||||
const element = queryFirst(target);
|
||||
let container = element.parentElement;
|
||||
while (
|
||||
container &&
|
||||
(container.scrollHeight <= container.clientHeight ||
|
||||
!["auto", "scroll"].includes(getComputedStyle(container).overflowY))
|
||||
) {
|
||||
container = container.parentElement;
|
||||
}
|
||||
if (!container) {
|
||||
return isInViewPort(element);
|
||||
}
|
||||
const { x, y } = queryRect(element);
|
||||
const { height: containerHeight, width: containerWidth } = queryRect(container);
|
||||
return y > 0 && y < containerHeight && x > 0 && x < containerWidth;
|
||||
}
|
||||
|
||||
function buildSources(generate, options = {}) {
|
||||
return [
|
||||
{
|
||||
options: generate,
|
||||
optionSlot: options.optionSlot,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function item(label, onSelect, data = {}) {
|
||||
return {
|
||||
data,
|
||||
label,
|
||||
onSelect() {
|
||||
return onSelect?.(this);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("can be rendered", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="'Hello'" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete").toHaveCount(1);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(queryAllTexts(".o-autocomplete--dropdown-item")).toEqual(["World", "Hello"]);
|
||||
|
||||
const dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
|
||||
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["true", "false"]);
|
||||
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
|
||||
});
|
||||
|
||||
test("select option", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="state.value" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "Hello" });
|
||||
sources = buildSources(() => [
|
||||
item("World", this.onSelect.bind(this)),
|
||||
item("Hello", this.onSelect.bind(this)),
|
||||
]);
|
||||
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(option.label);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
|
||||
expect(".o-autocomplete input").toHaveValue("World");
|
||||
expect.verifySteps(["World"]);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete--dropdown-item:last").click();
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
expect.verifySteps(["Hello"]);
|
||||
});
|
||||
|
||||
test("autocomplete with resetOnSelect='true'", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<div>
|
||||
<div class= "test_value" t-esc="state.value"/>
|
||||
<AutoComplete value="''" sources="sources" resetOnSelect="true"/>
|
||||
</div>
|
||||
`;
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "Hello" });
|
||||
sources = buildSources(() => [
|
||||
item("World", this.onSelect.bind(this)),
|
||||
item("Hello", this.onSelect.bind(this)),
|
||||
]);
|
||||
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(option.label);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".test_value").toHaveText("Hello");
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
await contains(".o-autocomplete input").edit("Blip", { confirm: false });
|
||||
await runAllTimers();
|
||||
await contains(".o-autocomplete--dropdown-item:last").click();
|
||||
expect(".test_value").toHaveText("Hello");
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
expect.verifySteps(["Hello"]);
|
||||
});
|
||||
|
||||
test("open dropdown on input", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="'Hello'" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
await contains(".o-autocomplete input").fill("a", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("cancel result on escape keydown", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="'Hello'" sources="sources" autoSelect="true"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").edit("H", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(".o-autocomplete input").press("Escape");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
});
|
||||
|
||||
test("select input text on first focus", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="'Bar'" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("Bar")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(getSelection().toString()).toBe("Bar");
|
||||
});
|
||||
|
||||
test("scroll outside should cancel result", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<div class="autocomplete_container overflow-auto" style="max-height: 100px;">
|
||||
<div style="height: 1000px;">
|
||||
<AutoComplete value="'Hello'" sources="sources" autoSelect="true"/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").edit("H", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(".autocomplete_container").scroll({ top: 10 });
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
});
|
||||
|
||||
test("scroll inside should keep dropdown open", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<div class="autocomplete_container overflow-auto" style="max-height: 100px;">
|
||||
<div style="height: 1000px;">
|
||||
<AutoComplete value="'Hello'" sources="sources"/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(".o-autocomplete .dropdown-menu").scroll({ top: 10 });
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("losing focus should cancel result", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="'Hello'" sources="sources" autoSelect="true"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").edit("H", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
});
|
||||
|
||||
test("click out after clearing input", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="'Hello'" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("Hello");
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await contains(".o-autocomplete input").clear({ confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
});
|
||||
|
||||
test("open twice should not display previous results", async () => {
|
||||
const ITEMS = [item("AB"), item("AC"), item("BC")];
|
||||
|
||||
let def = new Deferred();
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="''" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(async (request) => {
|
||||
await def;
|
||||
return ITEMS.filter((option) => option.label.includes(request));
|
||||
});
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
|
||||
|
||||
def.resolve();
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(3);
|
||||
expect(".fa-spin").toHaveCount(0);
|
||||
|
||||
def = new Deferred();
|
||||
await contains(".o-autocomplete input").fill("A", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
|
||||
def.resolve();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
|
||||
expect(".fa-spin").toHaveCount(0);
|
||||
|
||||
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
// re-open the dropdown -> should not display the previous results
|
||||
def = new Deferred();
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(1);
|
||||
expect(".o-autocomplete--dropdown-item .fa-spin").toHaveCount(1); // loading
|
||||
});
|
||||
|
||||
test("press enter on autocomplete with empty source", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="''" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => []);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
// click inside the input and press "enter", because why not
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
await contains(".o-autocomplete input").press("Enter");
|
||||
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("press enter on autocomplete with empty source (2)", async () => {
|
||||
// in this test, the source isn't empty at some point, but becomes empty as the user
|
||||
// updates the input's value.
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="''" sources="sources"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources((request) =>
|
||||
request.length > 2 ? [item("test A"), item("test B"), item("test C")] : []
|
||||
);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
await contains(".o-autocomplete input").edit("test", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
expect(".o-autocomplete .dropdown-menu .o-autocomplete--dropdown-item").toHaveCount(3);
|
||||
|
||||
await contains(".o-autocomplete input").edit("t", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
|
||||
await contains(".o-autocomplete input").press("Enter");
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
expect(".o-autocomplete input").toHaveValue("t");
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("autofocus=true option work as expected", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`<AutoComplete value="'Hello'" sources="sources" autofocus="true"/>`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toBeFocused();
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("autocomplete in edition keep edited value before select option", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<button class="myButton" t-on-mouseover="onHover">My button</button>
|
||||
<AutoComplete value="this.state.value" sources="sources"/>
|
||||
`;
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("My Selection", this.onSelect.bind(this))]);
|
||||
state = useState({ value: "Hello" });
|
||||
|
||||
onHover() {
|
||||
this.state.value = "My Click";
|
||||
}
|
||||
onSelect() {
|
||||
this.state.value = "My Selection";
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(".o-autocomplete input").edit("Yolo", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete input").toHaveValue("Yolo");
|
||||
|
||||
// We want to simulate an external value edition (like a delayed onChange)
|
||||
await contains(".myButton").hover();
|
||||
expect(".o-autocomplete input").toHaveValue("Yolo");
|
||||
|
||||
// Leave inEdition mode when selecting an option
|
||||
await contains(".o-autocomplete input").click();
|
||||
await runAllTimers();
|
||||
await contains(queryFirst(".o-autocomplete--dropdown-item")).click();
|
||||
expect(".o-autocomplete input").toHaveValue("My Selection");
|
||||
|
||||
// Will also trigger the hover event
|
||||
await contains(".myButton").click();
|
||||
expect(".o-autocomplete input").toHaveValue("My Click");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("autocomplete in edition keep edited value before blur", async () => {
|
||||
let count = 0;
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<button class="myButton" t-on-mouseover="onHover">My button</button>
|
||||
<AutoComplete value="this.state.value" sources="[]"/>
|
||||
`;
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "Hello" });
|
||||
|
||||
onHover() {
|
||||
this.state.value = `My Click ${count++}`;
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(".o-autocomplete input").edit("", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
// We want to simulate an external value edition (like a delayed onChange)
|
||||
await contains(".myButton").hover();
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
// Leave inEdition mode when blur the input
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete input").toHaveValue("");
|
||||
|
||||
// Will also trigger the hover event
|
||||
await contains(".myButton").click();
|
||||
expect(".o-autocomplete input").toHaveValue("My Click 1");
|
||||
});
|
||||
|
||||
test("correct sequence of blur, focus and select", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { AutoComplete };
|
||||
static template = xml`
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onBlur.bind="onBlur"
|
||||
onChange.bind="onChange"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "" });
|
||||
sources = buildSources(() => [
|
||||
item("World", this.onSelect.bind(this)),
|
||||
item("Hello", this.onSelect.bind(this)),
|
||||
]);
|
||||
|
||||
onBlur() {
|
||||
expect.step("blur");
|
||||
}
|
||||
onChange() {
|
||||
expect.step("change");
|
||||
}
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
expect.step(`select ${option.label}`);
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
await contains(".o-autocomplete input").click();
|
||||
|
||||
// Navigate suggestions using arrow keys
|
||||
let dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
|
||||
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["true", "false"]);
|
||||
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[0]);
|
||||
|
||||
await contains(".o-autocomplete--input").press("ArrowDown");
|
||||
|
||||
dropdownItemIds = queryAllAttributes(".dropdown-item", "id");
|
||||
expect(dropdownItemIds).toEqual(["autocomplete_0_0", "autocomplete_0_1"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "role")).toEqual(["option", "option"]);
|
||||
expect(queryAllAttributes(".dropdown-item", "aria-selected")).toEqual(["false", "true"]);
|
||||
expect(".o-autocomplete--input").toHaveAttribute("aria-activedescendant", dropdownItemIds[1]);
|
||||
|
||||
// Start typing hello and click on the result
|
||||
await contains(".o-autocomplete input").edit("h", { confirm: false });
|
||||
await runAllTimers();
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(1);
|
||||
await contains(".o-autocomplete--dropdown-item:last").click();
|
||||
expect.verifySteps(["change", "select Hello"]);
|
||||
expect(".o-autocomplete input").toBeFocused();
|
||||
|
||||
// Clear input and focus out
|
||||
await contains(".o-autocomplete input").edit("", { confirm: false });
|
||||
await runAllTimers();
|
||||
await contains(document.body).click();
|
||||
expect.verifySteps(["blur", "change"]);
|
||||
expect(".o-autocomplete .dropdown-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autocomplete always closes on click away", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<AutoComplete value="state.value" sources="sources" autoSelect="true"/>`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "" });
|
||||
sources = buildSources(() => [
|
||||
item("World", this.onSelect.bind(this)),
|
||||
item("Hello", this.onSelect.bind(this)),
|
||||
]);
|
||||
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
await contains(".o-autocomplete input").click();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
|
||||
await pointerDown(".o-autocomplete--dropdown-item:last");
|
||||
await pointerUp(document.body);
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(2);
|
||||
await contains(document.body).click();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autocomplete trim spaces for search", async () => {
|
||||
const ITEMS = [item("World"), item("Hello")];
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<AutoComplete value="state.value" sources="sources"/>`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: " World" });
|
||||
sources = buildSources((request) => ITEMS.filter(({ label }) => label.startsWith(request)));
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(`.o-autocomplete input`).click();
|
||||
expect(queryAllTexts(`.o-autocomplete--dropdown-item`)).toEqual(["World", "Hello"]);
|
||||
});
|
||||
|
||||
test("tab and shift+tab close the dropdown", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<AutoComplete value="state.value" sources="sources"/>`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "" });
|
||||
sources = buildSources(() => [item("World"), item("Hello")]);
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
const input = ".o-autocomplete input";
|
||||
const dropdown = ".o-autocomplete--dropdown-menu";
|
||||
expect(input).toHaveCount(1);
|
||||
// Tab
|
||||
await contains(input).click();
|
||||
expect(dropdown).toBeVisible();
|
||||
await press("Tab");
|
||||
await animationFrame();
|
||||
expect(dropdown).not.toHaveCount();
|
||||
// Shift + Tab
|
||||
await contains(input).click();
|
||||
expect(dropdown).toBeVisible();
|
||||
await press("Tab", { shiftKey: true });
|
||||
await animationFrame();
|
||||
expect(dropdown).not.toHaveCount();
|
||||
});
|
||||
|
||||
test("autocomplete scrolls when moving with arrows", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<style>
|
||||
.o-autocomplete--dropdown-menu {
|
||||
max-height: 100px;
|
||||
}
|
||||
</style>
|
||||
<AutoComplete value="state.value" sources="sources" autoSelect="true"/>
|
||||
`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
state = useState({ value: "" });
|
||||
sources = buildSources(() => [
|
||||
item("Never"),
|
||||
item("Gonna"),
|
||||
item("Give"),
|
||||
item("You"),
|
||||
item("Up"),
|
||||
]);
|
||||
}
|
||||
const dropdownSelector = ".o-autocomplete--dropdown-menu";
|
||||
const activeItemSelector = ".o-autocomplete--dropdown-item .ui-state-active";
|
||||
const msgInView = "active item should be in view within dropdown";
|
||||
const msgNotInView = "item should not be in view within dropdown";
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o-autocomplete input").toHaveCount(1);
|
||||
// Open with arrow key.
|
||||
await contains(".o-autocomplete input").focus();
|
||||
await press("ArrowDown");
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete--dropdown-item").toHaveCount(5);
|
||||
expect(isScrollable(dropdownSelector)).toBe(true, { message: "dropdown should be scrollable" });
|
||||
// First element focused and visible (dropdown is not scrolled yet).
|
||||
expect(".o-autocomplete--dropdown-item:first-child a").toHaveClass("ui-state-active");
|
||||
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
|
||||
// Navigate with the arrow keys. Go to the last item.
|
||||
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:contains('Up')")).toBe(false, {
|
||||
message: "'Up' " + msgNotInView,
|
||||
});
|
||||
await press("ArrowUp");
|
||||
await press("ArrowUp");
|
||||
await animationFrame();
|
||||
expect(activeItemSelector).toHaveText("Up");
|
||||
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
|
||||
// Navigate to an item that is not currently visible.
|
||||
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:contains('Never')")).toBe(
|
||||
false,
|
||||
{ message: "'Never' " + msgNotInView }
|
||||
);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await press("ArrowUp");
|
||||
}
|
||||
await animationFrame();
|
||||
expect(activeItemSelector).toHaveText("Never");
|
||||
expect(isInViewWithinScrollableY(activeItemSelector)).toBe(true, { message: msgInView });
|
||||
expect(isInViewWithinScrollableY(".o-autocomplete--dropdown-item:last")).toBe(false, {
|
||||
message: "last " + msgNotInView,
|
||||
});
|
||||
});
|
||||
|
||||
test("source with option slot", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<AutoComplete value="''" sources="sources">
|
||||
<t t-set-slot="use_this_slot" t-slot-scope="scope">
|
||||
<div class="slot_item">
|
||||
<t t-esc="scope.data.id"/>: <t t-esc="scope.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</AutoComplete>
|
||||
`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(
|
||||
() => [item("Hello", () => {}, { id: 1 }), item("World", () => {}, { id: 2 })],
|
||||
{ optionSlot: "use_this_slot" }
|
||||
);
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(`.o-autocomplete input`).click();
|
||||
expect(queryAllTexts(`.o-autocomplete--dropdown-item .slot_item`)).toEqual([
|
||||
"1: Hello",
|
||||
"2: World",
|
||||
]);
|
||||
});
|
||||
|
||||
test("unselectable options are... not selectable", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<AutoComplete value="''" sources="sources"/>
|
||||
`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [
|
||||
{ label: "unselectable" },
|
||||
item("selectable", this.onSelect.bind(this)),
|
||||
{ label: "selectable" },
|
||||
{ label: "unselectable" },
|
||||
]);
|
||||
|
||||
onSelect(option) {
|
||||
expect.step(`selected: ${option.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await contains(`.o-autocomplete input`).click();
|
||||
expect(`.o-autocomplete--input`).toHaveAttribute("aria-activedescendant", "autocomplete_0_1");
|
||||
expect(`.dropdown-item#autocomplete_0_1`).toHaveText("selectable");
|
||||
expect(`.dropdown-item#autocomplete_0_1`).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await press("arrowup");
|
||||
await animationFrame();
|
||||
expect(`.o-autocomplete--input`).not.toHaveAttribute("aria-activedescendant");
|
||||
|
||||
await press("arrowdown");
|
||||
await animationFrame();
|
||||
expect(`.o-autocomplete--input`).toHaveAttribute("aria-activedescendant", "autocomplete_0_1");
|
||||
|
||||
await press("arrowdown");
|
||||
await animationFrame();
|
||||
expect(`.o-autocomplete--input`).not.toHaveAttribute("aria-activedescendant");
|
||||
|
||||
await press("arrowup");
|
||||
await animationFrame();
|
||||
expect(`.o-autocomplete--input`).toHaveAttribute("aria-activedescendant", "autocomplete_0_1");
|
||||
|
||||
expect(`.o-autocomplete--input`).toBeFocused();
|
||||
await contains(`.dropdown-item:eq(0)`).click();
|
||||
expect(`.o-autocomplete--input`).toBeFocused();
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(`.dropdown-item:eq(2)`).click();
|
||||
expect(`.o-autocomplete--input`).toBeFocused();
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(`.dropdown-item:eq(1)`).click();
|
||||
expect(`.o-autocomplete--input`).toBeFocused();
|
||||
expect.verifySteps(["selected: selectable"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("items are selected only when the mouse moves, not just on enter", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<AutoComplete value="''" sources="sources"/>`;
|
||||
static components = { AutoComplete };
|
||||
static props = [];
|
||||
|
||||
sources = buildSources(() => [item("one"), item("two"), item("three")]);
|
||||
}
|
||||
|
||||
// In this test we use custom events to prevent unwanted mouseenter/mousemove events
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
queryOne(`.o-autocomplete input`).focus();
|
||||
queryOne(`.o-autocomplete input`).click();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o-autocomplete--dropdown-item:nth-child(1) .dropdown-item").toHaveClass(
|
||||
"ui-state-active"
|
||||
);
|
||||
|
||||
await hover(".o-autocomplete--dropdown-item:nth-child(2)");
|
||||
await animationFrame();
|
||||
// mouseenter should be ignored
|
||||
expect(".o-autocomplete--dropdown-item:nth-child(2) .dropdown-item").not.toHaveClass(
|
||||
"ui-state-active"
|
||||
);
|
||||
|
||||
await press("arrowdown");
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete--dropdown-item:nth-child(2) .dropdown-item").toHaveClass(
|
||||
"ui-state-active"
|
||||
);
|
||||
|
||||
await hover(".o-autocomplete--dropdown-item:nth-child(3)");
|
||||
await animationFrame();
|
||||
expect(".o-autocomplete--dropdown-item:nth-child(2) .dropdown-item").not.toHaveClass(
|
||||
"ui-state-active"
|
||||
);
|
||||
expect(".o-autocomplete--dropdown-item:nth-child(3) .dropdown-item").toHaveClass(
|
||||
"ui-state-active"
|
||||
);
|
||||
});
|
||||
|
|
@ -1,572 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import {
|
||||
click,
|
||||
editInput,
|
||||
getFixture,
|
||||
makeDeferred,
|
||||
mount,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
triggerEvent,
|
||||
triggerEvents,
|
||||
} from "../helpers/utils";
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let env;
|
||||
let target;
|
||||
|
||||
QUnit.module("Components", (hooks) => {
|
||||
hooks.beforeEach(async () => {
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
env = await makeTestEnv();
|
||||
target = getFixture();
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.module("AutoComplete");
|
||||
|
||||
QUnit.test("can be rendered", async (assert) => {
|
||||
class Parent extends Component {}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-autocomplete");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
await click(target, ".o-autocomplete--input");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
const options = [...target.querySelectorAll(".o-autocomplete--dropdown-item")];
|
||||
assert.deepEqual(
|
||||
options.map((el) => el.textContent),
|
||||
["World", "Hello"]
|
||||
);
|
||||
|
||||
const optionItems = [...target.querySelectorAll(".dropdown-item")];
|
||||
assert.deepEqual(
|
||||
optionItems.map((el) => ({
|
||||
id: el.id,
|
||||
role: el.getAttribute("role"),
|
||||
"aria-selected": el.getAttribute("aria-selected"),
|
||||
})),
|
||||
[
|
||||
{ id: "autocomplete_0_0", role: "option", "aria-selected": "true" },
|
||||
{ id: "autocomplete_0_1", role: "option", "aria-selected": "false" },
|
||||
]
|
||||
);
|
||||
|
||||
const input = target.querySelector(".o-autocomplete--input");
|
||||
assert.strictEqual(input.getAttribute("aria-activedescendant"), optionItems[0].id);
|
||||
});
|
||||
|
||||
QUnit.test("select option", async (assert) => {
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "Hello",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
onSelect(option) {
|
||||
this.state.value = option.label;
|
||||
assert.step(option.label);
|
||||
}
|
||||
}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onSelect="(option) => this.onSelect(option)"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
|
||||
await click(target, ".o-autocomplete--input");
|
||||
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[0]);
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "World");
|
||||
assert.verifySteps(["World"]);
|
||||
|
||||
await click(target, ".o-autocomplete--input");
|
||||
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1]);
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
assert.verifySteps(["Hello"]);
|
||||
});
|
||||
|
||||
QUnit.test("open dropdown on input", async (assert) => {
|
||||
class Parent extends Component {}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
await triggerEvent(target, ".o-autocomplete--input", "input");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
});
|
||||
|
||||
QUnit.test("cancel result on escape keydown", async (assert) => {
|
||||
class Parent extends Component {}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
|
||||
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
await editInput(target, ".o-autocomplete--input", "H");
|
||||
|
||||
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Escape" });
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
});
|
||||
|
||||
QUnit.test("scroll outside should cancel result", async (assert) => {
|
||||
class Parent extends Component {}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
|
||||
await click(target, ".o-autocomplete--input");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
await editInput(target, ".o-autocomplete--input", "H");
|
||||
|
||||
await triggerEvent(target, null, "scroll");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
});
|
||||
|
||||
QUnit.test("scroll inside should keep dropdown open", async (assert) => {
|
||||
class Parent extends Component {}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
await click(target, ".o-autocomplete--input");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
await triggerEvent(target, ".o-autocomplete--dropdown-menu", "scroll");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
});
|
||||
|
||||
QUnit.test("losing focus should cancel result", async (assert) => {
|
||||
class Parent extends Component {}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
|
||||
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
await editInput(target, ".o-autocomplete--input", "H");
|
||||
|
||||
await triggerEvent(target, "", "pointerdown");
|
||||
await triggerEvent(target, ".o-autocomplete--input", "blur");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
});
|
||||
|
||||
QUnit.test("click out after clearing input", async (assert) => {
|
||||
class Parent extends Component {}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="'Hello'"
|
||||
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
|
||||
onSelect="() => {}"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
|
||||
|
||||
await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
await editInput(target, ".o-autocomplete--input", "");
|
||||
|
||||
await triggerEvent(target, "", "pointerdown");
|
||||
await triggerEvent(target, ".o-autocomplete--input", "blur");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
|
||||
});
|
||||
|
||||
QUnit.test("open twice should not display previous results", async (assert) => {
|
||||
let def = makeDeferred();
|
||||
class Parent extends Component {
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
async options(search) {
|
||||
await def;
|
||||
if (search === "A") {
|
||||
return [{ label: "AB" }, { label: "AC" }];
|
||||
}
|
||||
return [{ label: "AB" }, { label: "AC" }, { label: "BC" }];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete value="''" sources="sources" onSelect="() => {}"/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
await triggerEvent(target, ".o-autocomplete--input", "click");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-item");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-item .fa-spin"); // loading
|
||||
|
||||
def.resolve();
|
||||
await nextTick();
|
||||
assert.containsN(target, ".o-autocomplete--dropdown-item", 3);
|
||||
assert.containsNone(target, ".fa-spin");
|
||||
|
||||
def = makeDeferred();
|
||||
target.querySelector(".o-autocomplete--input").value = "A";
|
||||
await triggerEvent(target, ".o-autocomplete--input", "input");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-item");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-item .fa-spin"); // loading
|
||||
|
||||
def.resolve();
|
||||
await nextTick();
|
||||
assert.containsN(target, ".o-autocomplete--dropdown-item", 2);
|
||||
assert.containsNone(target, ".fa-spin");
|
||||
|
||||
await click(target.querySelector(".o-autocomplete--dropdown-item"));
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
// re-open the dropdown -> should not display the previous results
|
||||
def = makeDeferred();
|
||||
await triggerEvent(target, ".o-autocomplete--input", "click");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-item");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-item .fa-spin"); // loading
|
||||
});
|
||||
|
||||
QUnit.test("press enter on autocomplete with empty source", async (assert) => {
|
||||
class Parent extends Component {
|
||||
get sources() {
|
||||
return [{ options: [] }];
|
||||
}
|
||||
onSelect() {}
|
||||
}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-autocomplete--input");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
// click inside the input and press "enter", because why not
|
||||
await click(target, ".o-autocomplete--input");
|
||||
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Enter" });
|
||||
|
||||
assert.containsOnce(target, ".o-autocomplete--input");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
});
|
||||
|
||||
QUnit.test("press enter on autocomplete with empty source (2)", async (assert) => {
|
||||
// in this test, the source isn't empty at some point, but becomes empty as the user
|
||||
// updates the input's value.
|
||||
class Parent extends Component {
|
||||
get sources() {
|
||||
const options = (val) => {
|
||||
if (val.length > 2) {
|
||||
return [{ label: "test A" }, { label: "test B" }, { label: "test C" }];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
return [{ options }];
|
||||
}
|
||||
onSelect() {}
|
||||
}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`<AutoComplete value="''" sources="sources" onSelect="onSelect"/>`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-autocomplete--input");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "");
|
||||
|
||||
// click inside the input and press "enter", because why not
|
||||
await editInput(target, ".o-autocomplete--input", "test");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
assert.containsN(
|
||||
target,
|
||||
".o-autocomplete--dropdown-menu .o-autocomplete--dropdown-item",
|
||||
3
|
||||
);
|
||||
|
||||
await editInput(target, ".o-autocomplete--input", "t");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
|
||||
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Enter" });
|
||||
assert.containsOnce(target, ".o-autocomplete--input");
|
||||
assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "t");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
});
|
||||
|
||||
QUnit.test("correct sequence of blur, focus and select [REQUIRE FOCUS]", async (assert) => {
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
onChange() {
|
||||
assert.step("change");
|
||||
}
|
||||
onSelect(option) {
|
||||
target.querySelector(".o-autocomplete--input").value = option.label;
|
||||
assert.step("select " + option.label);
|
||||
}
|
||||
onBlur() {
|
||||
assert.step("blur");
|
||||
}
|
||||
}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onSelect.bind="onSelect"
|
||||
onBlur.bind="onBlur"
|
||||
onChange.bind="onChange"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-autocomplete--input");
|
||||
const input = target.querySelector(".o-autocomplete--input");
|
||||
await click(input);
|
||||
input.focus();
|
||||
|
||||
// Navigate suggestions using arrow keys
|
||||
const optionItems = [...target.querySelectorAll(".dropdown-item")];
|
||||
assert.deepEqual(
|
||||
optionItems.map((el) => ({
|
||||
id: el.id,
|
||||
role: el.getAttribute("role"),
|
||||
"aria-selected": el.getAttribute("aria-selected"),
|
||||
})),
|
||||
[
|
||||
{ id: "autocomplete_0_0", role: "option", "aria-selected": "true" },
|
||||
{ id: "autocomplete_0_1", role: "option", "aria-selected": "false" },
|
||||
]
|
||||
);
|
||||
assert.strictEqual(input.getAttribute("aria-activedescendant"), optionItems[0].id);
|
||||
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "arrowdown" });
|
||||
assert.deepEqual(
|
||||
optionItems.map((el) => ({
|
||||
id: el.id,
|
||||
role: el.getAttribute("role"),
|
||||
"aria-selected": el.getAttribute("aria-selected"),
|
||||
})),
|
||||
[
|
||||
{ id: "autocomplete_0_0", role: "option", "aria-selected": "false" },
|
||||
{ id: "autocomplete_0_1", role: "option", "aria-selected": "true" },
|
||||
]
|
||||
);
|
||||
assert.strictEqual(input.getAttribute("aria-activedescendant"), optionItems[1].id);
|
||||
|
||||
// Start typing hello and click on the result
|
||||
await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "h" });
|
||||
input.value = "h";
|
||||
await triggerEvent(input, "", "input");
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
const pointerdownEvent = await triggerEvent(
|
||||
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
|
||||
"",
|
||||
"pointerdown"
|
||||
);
|
||||
assert.strictEqual(pointerdownEvent.defaultPrevented, false);
|
||||
const mousedownEvent = await triggerEvent(
|
||||
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
|
||||
"",
|
||||
"mousedown"
|
||||
);
|
||||
assert.strictEqual(mousedownEvent.defaultPrevented, false);
|
||||
await triggerEvent(input, "", "change");
|
||||
await triggerEvent(input, "", "blur");
|
||||
await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1], "");
|
||||
assert.verifySteps(["change", "select Hello"]);
|
||||
assert.strictEqual(input, document.activeElement);
|
||||
|
||||
// Clear input and focus out
|
||||
await triggerEvent(input, "", "keydown", { key: "Backspace" });
|
||||
input.value = "";
|
||||
await triggerEvent(input, "", "input");
|
||||
await triggerEvent(target, "", "pointerdown");
|
||||
await triggerEvent(input, "", "change");
|
||||
input.blur();
|
||||
await click(target, "");
|
||||
assert.verifySteps(["change", "blur"]);
|
||||
});
|
||||
|
||||
QUnit.test("autocomplete always closes on click away [REQUIRE FOCUS]", async (assert) => {
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options: [{ label: "World" }, { label: "Hello" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
onSelect(option) {
|
||||
target.querySelector(".o-autocomplete--input").value = option.label;
|
||||
}
|
||||
}
|
||||
Parent.components = { AutoComplete };
|
||||
Parent.template = xml`
|
||||
<AutoComplete
|
||||
value="state.value"
|
||||
sources="sources"
|
||||
onSelect.bind="onSelect"
|
||||
autoSelect="true"
|
||||
/>
|
||||
`;
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-autocomplete--input");
|
||||
const input = target.querySelector(".o-autocomplete--input");
|
||||
await click(input);
|
||||
assert.containsN(target, ".o-autocomplete--dropdown-item", 2);
|
||||
const pointerdownEvent = await triggerEvent(
|
||||
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
|
||||
"",
|
||||
"pointerdown"
|
||||
);
|
||||
assert.strictEqual(pointerdownEvent.defaultPrevented, false);
|
||||
const mousedownEvent = await triggerEvent(
|
||||
target.querySelectorAll(".o-autocomplete--dropdown-item")[1],
|
||||
"",
|
||||
"mousedown"
|
||||
);
|
||||
assert.strictEqual(mousedownEvent.defaultPrevented, false);
|
||||
await triggerEvent(input, "", "blur");
|
||||
await triggerEvent(target, "", "pointerup");
|
||||
await triggerEvent(target, "", "mouseup");
|
||||
assert.containsN(target, ".o-autocomplete--dropdown-item", 2);
|
||||
await triggerEvent(target, "", "pointerdown");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-item");
|
||||
});
|
||||
|
||||
QUnit.test("autocomplete trim spaces for search", async (assert) => {
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: " World",
|
||||
});
|
||||
}
|
||||
get sources() {
|
||||
return [
|
||||
{
|
||||
options(search) {
|
||||
return [{ label: "World" }, { label: "Hello" }].filter(({ label }) =>
|
||||
label.startsWith(search)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
Parent.template = xml`
|
||||
<AutoComplete value="state.value" sources="sources" onSelect="() => {}"/>
|
||||
`;
|
||||
Parent.props = ["*"];
|
||||
Parent.components = { AutoComplete };
|
||||
await mount(Parent, target, { env });
|
||||
await click(target, `.o-autocomplete input`);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll(`.o-autocomplete--dropdown-item`)].map(
|
||||
(el) => el.textContent
|
||||
),
|
||||
["World", "Hello"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { getService, makeMockEnv } from "@web/../tests/web_test_helpers";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
let titleService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await makeMockEnv();
|
||||
titleService = getService("title");
|
||||
});
|
||||
|
||||
test("simple title", () => {
|
||||
titleService.setParts({ one: "MyOdoo" });
|
||||
expect(titleService.current).toBe("MyOdoo");
|
||||
});
|
||||
|
||||
test("add title part", () => {
|
||||
titleService.setParts({ one: "MyOdoo", two: null });
|
||||
expect(titleService.current).toBe("MyOdoo");
|
||||
titleService.setParts({ three: "Import" });
|
||||
expect(titleService.current).toBe("MyOdoo - Import");
|
||||
});
|
||||
|
||||
test("modify title part", () => {
|
||||
titleService.setParts({ one: "MyOdoo" });
|
||||
expect(titleService.current).toBe("MyOdoo");
|
||||
titleService.setParts({ one: "Zopenerp" });
|
||||
expect(titleService.current).toBe("Zopenerp");
|
||||
});
|
||||
|
||||
test("delete title part", () => {
|
||||
titleService.setParts({ one: "MyOdoo" });
|
||||
expect(titleService.current).toBe("MyOdoo");
|
||||
titleService.setParts({ one: null });
|
||||
expect(titleService.current).toBe("Odoo");
|
||||
});
|
||||
|
||||
test("all at once", () => {
|
||||
titleService.setParts({ one: "MyOdoo", two: "Import" });
|
||||
expect(titleService.current).toBe("MyOdoo - Import");
|
||||
titleService.setParts({ one: "Zopenerp", two: null, three: "Sauron" });
|
||||
expect(titleService.current).toBe("Zopenerp - Sauron");
|
||||
});
|
||||
|
||||
test("get title parts", () => {
|
||||
expect(titleService.current).toBe("");
|
||||
titleService.setParts({ one: "MyOdoo", two: "Import" });
|
||||
expect(titleService.current).toBe("MyOdoo - Import");
|
||||
const parts = titleService.getParts();
|
||||
expect(parts).toEqual({ one: "MyOdoo", two: "Import" });
|
||||
parts.action = "Export";
|
||||
expect(titleService.current).toBe("MyOdoo - Import"); // parts is a copy!
|
||||
});
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { titleService } from "@web/core/browser/title_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { makeTestEnv } from "../../helpers/mock_env";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
let env;
|
||||
let title;
|
||||
|
||||
QUnit.module("Title", {
|
||||
async beforeEach() {
|
||||
title = document.title;
|
||||
registry.category("services").add("title", titleService);
|
||||
env = await makeTestEnv();
|
||||
},
|
||||
afterEach() {
|
||||
document.title = title;
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test("simple title", async (assert) => {
|
||||
assert.expect(1);
|
||||
env.services.title.setParts({ zopenerp: "Odoo" });
|
||||
assert.strictEqual(env.services.title.current, "Odoo");
|
||||
});
|
||||
|
||||
QUnit.test("add title part", async (assert) => {
|
||||
assert.expect(2);
|
||||
env.services.title.setParts({ zopenerp: "Odoo", chat: null });
|
||||
assert.strictEqual(env.services.title.current, "Odoo");
|
||||
env.services.title.setParts({ action: "Import" });
|
||||
assert.strictEqual(env.services.title.current, "Odoo - Import");
|
||||
});
|
||||
|
||||
QUnit.test("modify title part", async (assert) => {
|
||||
assert.expect(2);
|
||||
env.services.title.setParts({ zopenerp: "Odoo" });
|
||||
assert.strictEqual(env.services.title.current, "Odoo");
|
||||
env.services.title.setParts({ zopenerp: "Zopenerp" });
|
||||
assert.strictEqual(env.services.title.current, "Zopenerp");
|
||||
});
|
||||
|
||||
QUnit.test("delete title part", async (assert) => {
|
||||
assert.expect(2);
|
||||
env.services.title.setParts({ zopenerp: "Odoo" });
|
||||
assert.strictEqual(env.services.title.current, "Odoo");
|
||||
env.services.title.setParts({ zopenerp: null });
|
||||
assert.strictEqual(env.services.title.current, "");
|
||||
});
|
||||
|
||||
QUnit.test("all at once", async (assert) => {
|
||||
assert.expect(2);
|
||||
env.services.title.setParts({ zopenerp: "Odoo", action: "Import" });
|
||||
assert.strictEqual(env.services.title.current, "Odoo - Import");
|
||||
env.services.title.setParts({ action: null, zopenerp: "Zopenerp", chat: "Sauron" });
|
||||
assert.strictEqual(env.services.title.current, "Zopenerp - Sauron");
|
||||
});
|
||||
|
||||
QUnit.test("get title parts", async (assert) => {
|
||||
assert.expect(3);
|
||||
env.services.title.setParts({ zopenerp: "Odoo", action: "Import" });
|
||||
assert.strictEqual(env.services.title.current, "Odoo - Import");
|
||||
const parts = env.services.title.getParts();
|
||||
assert.deepEqual(parts, { zopenerp: "Odoo", action: "Import" });
|
||||
parts.action = "Export";
|
||||
assert.strictEqual(env.services.title.current, "Odoo - Import"); // parts is a copy!
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { Deferred } from "@odoo/hoot-mock";
|
||||
|
||||
import { Cache } from "@web/core/utils/cache";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
test("do not call getValue if already cached", () => {
|
||||
const cache = new Cache((key) => {
|
||||
expect.step(key);
|
||||
return key.toUpperCase();
|
||||
});
|
||||
|
||||
expect(cache.read("a")).toBe("A");
|
||||
expect(cache.read("b")).toBe("B");
|
||||
expect(cache.read("a")).toBe("A");
|
||||
|
||||
expect.verifySteps(["a", "b"]);
|
||||
});
|
||||
|
||||
test("multiple cache key", async () => {
|
||||
const cache = new Cache((...keys) => expect.step(keys.join("-")));
|
||||
|
||||
cache.read("a", 1);
|
||||
cache.read("a", 2);
|
||||
cache.read("a", 1);
|
||||
|
||||
expect.verifySteps(["a-1", "a-2"]);
|
||||
});
|
||||
|
||||
test("compute key", async () => {
|
||||
const cache = new Cache(
|
||||
(key) => expect.step(key),
|
||||
(key) => key.toLowerCase()
|
||||
);
|
||||
|
||||
cache.read("a");
|
||||
cache.read("A");
|
||||
|
||||
expect.verifySteps(["a"]);
|
||||
});
|
||||
|
||||
test("cache promise", async () => {
|
||||
const cache = new Cache((key) => {
|
||||
expect.step(`read ${key}`);
|
||||
return new Deferred();
|
||||
});
|
||||
|
||||
cache.read("a").then((k) => expect.step(`then ${k}`));
|
||||
cache.read("b").then((k) => expect.step(`then ${k}`));
|
||||
cache.read("a").then((k) => expect.step(`then ${k}`));
|
||||
cache.read("a").resolve("a");
|
||||
cache.read("b").resolve("b");
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect.verifySteps(["read a", "read b", "then a", "then a", "then b"]);
|
||||
});
|
||||
|
||||
test("clear cache", async () => {
|
||||
const cache = new Cache((key) => expect.step(key));
|
||||
|
||||
cache.read("a");
|
||||
cache.read("b");
|
||||
expect.verifySteps(["a", "b"]);
|
||||
|
||||
cache.read("a");
|
||||
cache.read("b");
|
||||
expect.verifySteps([]);
|
||||
|
||||
cache.clear("a");
|
||||
cache.read("a");
|
||||
cache.read("b");
|
||||
expect.verifySteps(["a"]);
|
||||
|
||||
cache.clear();
|
||||
cache.read("a");
|
||||
cache.read("b");
|
||||
expect.verifySteps([]);
|
||||
|
||||
cache.invalidate();
|
||||
cache.read("a");
|
||||
cache.read("b");
|
||||
expect.verifySteps(["a", "b"]);
|
||||
});
|
||||
148
odoo-bringout-oca-ocb-web/web/static/tests/core/checkbox.test.js
Normal file
148
odoo-bringout-oca-ocb-web/web/static/tests/core/checkbox.test.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { check, uncheck } from "@odoo/hoot-dom";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { contains, defineParams, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { CheckBox } from "@web/core/checkbox/checkbox";
|
||||
|
||||
test("can be rendered", async () => {
|
||||
await mountWithCleanup(CheckBox);
|
||||
|
||||
expect(`.o-checkbox input[type=checkbox]`).toHaveCount(1);
|
||||
expect(`.o-checkbox input[type=checkbox]`).toBeEnabled();
|
||||
});
|
||||
|
||||
test("has a slot for translatable text", async () => {
|
||||
defineParams({ translations: { ragabadabadaba: "rugubudubudubu" } });
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<div t-translation-context="web"><CheckBox>ragabadabadaba</CheckBox></div>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.form-check`).toHaveCount(1);
|
||||
expect(`.form-check`).toHaveText("rugubudubudubu", { exact: true });
|
||||
});
|
||||
|
||||
test("call onChange prop when some change occurs", async () => {
|
||||
let value = false;
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox onChange="onChange" />`;
|
||||
onChange(checked) {
|
||||
value = checked;
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
|
||||
await check("input");
|
||||
|
||||
expect(value).toBe(true);
|
||||
|
||||
await uncheck("input");
|
||||
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
|
||||
test("checkbox with props disabled", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox disabled="true" />`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
expect(`.o-checkbox input`).not.toBeEnabled();
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("can toggle value by pressing ENTER", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox onChange.bind="onChange" value="state.value" />`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({ value: false });
|
||||
}
|
||||
|
||||
onChange(checked) {
|
||||
this.state.value = checked;
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press("Enter");
|
||||
|
||||
expect(`.o-checkbox input`).toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press("Enter");
|
||||
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("toggling through multiple ways", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox onChange.bind="onChange" value="state.value" />`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({ value: false });
|
||||
}
|
||||
|
||||
onChange(checked) {
|
||||
this.state.value = checked;
|
||||
expect.step(String(checked));
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
|
||||
await contains(".o-checkbox").click();
|
||||
|
||||
expect(`.o-checkbox input`).toBeChecked();
|
||||
|
||||
await contains(".o-checkbox > .form-check-label", { visible: false }).uncheck();
|
||||
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press("Enter");
|
||||
|
||||
expect(`.o-checkbox input`).toBeChecked();
|
||||
|
||||
await contains(".o-checkbox input").press(" ");
|
||||
|
||||
expect(`.o-checkbox input`).not.toBeChecked();
|
||||
expect.verifySteps(["true", "false", "true", "false"]);
|
||||
});
|
||||
|
||||
test("checkbox with props indeterminate", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CheckBox };
|
||||
static props = {};
|
||||
static template = xml`<CheckBox indeterminate="true" />`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(`.o-checkbox input`).toHaveCount(1);
|
||||
expect(`.o-checkbox input`).toBeChecked({ indeterminate: true });
|
||||
});
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { CheckBox } from "@web/core/checkbox/checkbox";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { translatedTerms } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
|
||||
import {
|
||||
click,
|
||||
getFixture,
|
||||
patchWithCleanup,
|
||||
mount,
|
||||
triggerEvent,
|
||||
triggerHotkey,
|
||||
nextTick,
|
||||
} from "@web/../tests/helpers/utils";
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let target;
|
||||
|
||||
QUnit.module("Components", (hooks) => {
|
||||
hooks.beforeEach(async () => {
|
||||
target = getFixture();
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
});
|
||||
|
||||
QUnit.module("CheckBox");
|
||||
|
||||
QUnit.test("can be rendered", async (assert) => {
|
||||
const env = await makeTestEnv();
|
||||
await mount(CheckBox, target, { env, props: {} });
|
||||
assert.containsOnce(target, '.o-checkbox input[type="checkbox"]');
|
||||
});
|
||||
|
||||
QUnit.test("has a slot for translatable text", async (assert) => {
|
||||
patchWithCleanup(translatedTerms, { ragabadabadaba: "rugubudubudubu" });
|
||||
serviceRegistry.add("localization", makeFakeLocalizationService());
|
||||
const env = await makeTestEnv();
|
||||
|
||||
class Parent extends Component {}
|
||||
Parent.template = xml`<CheckBox>ragabadabadaba</CheckBox>`;
|
||||
Parent.components = { CheckBox };
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, "div.form-check");
|
||||
assert.strictEqual(target.querySelector("div.form-check").textContent, "rugubudubudubu");
|
||||
});
|
||||
|
||||
QUnit.test("call onChange prop when some change occurs", async (assert) => {
|
||||
const env = await makeTestEnv();
|
||||
|
||||
let value = false;
|
||||
class Parent extends Component {
|
||||
onChange(checked) {
|
||||
value = checked;
|
||||
}
|
||||
}
|
||||
Parent.template = xml`<CheckBox onChange="onChange"/>`;
|
||||
Parent.components = { CheckBox };
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-checkbox input");
|
||||
await click(target.querySelector("input"));
|
||||
assert.strictEqual(value, true);
|
||||
await click(target.querySelector("input"));
|
||||
assert.strictEqual(value, false);
|
||||
});
|
||||
|
||||
QUnit.test("does not call onChange prop when disabled", async (assert) => {
|
||||
const env = await makeTestEnv();
|
||||
|
||||
let onChangeCalled = false;
|
||||
class Parent extends Component {
|
||||
onChange(checked) {
|
||||
onChangeCalled = true;
|
||||
}
|
||||
}
|
||||
Parent.template = xml`<CheckBox onChange="onChange" disabled="true"/>`;
|
||||
Parent.components = { CheckBox };
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-checkbox input");
|
||||
await click(target.querySelector("input"));
|
||||
assert.strictEqual(onChangeCalled, false);
|
||||
});
|
||||
|
||||
QUnit.test("can toggle value by pressing ENTER", async (assert) => {
|
||||
const env = await makeTestEnv();
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({ value: false });
|
||||
}
|
||||
onChange(checked) {
|
||||
this.state.value = checked;
|
||||
}
|
||||
}
|
||||
Parent.template = xml`<CheckBox onChange.bind="onChange" value="state.value"/>`;
|
||||
Parent.components = { CheckBox };
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-checkbox input");
|
||||
assert.notOk(target.querySelector(".o-checkbox input").checked);
|
||||
await triggerEvent(target, ".o-checkbox input", "keydown", { key: "Enter" });
|
||||
assert.ok(target.querySelector(".o-checkbox input").checked);
|
||||
await triggerEvent(target, ".o-checkbox input", "keydown", { key: "Enter" });
|
||||
assert.notOk(target.querySelector(".o-checkbox input").checked);
|
||||
});
|
||||
|
||||
QUnit.test("toggling through multiple ways", async (assert) => {
|
||||
const env = await makeTestEnv();
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({ value: false });
|
||||
}
|
||||
onChange(checked) {
|
||||
this.state.value = checked;
|
||||
assert.step(`${checked}`);
|
||||
}
|
||||
}
|
||||
Parent.template = xml`<CheckBox onChange.bind="onChange" value="state.value"/>`;
|
||||
Parent.components = { CheckBox };
|
||||
await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o-checkbox input");
|
||||
assert.notOk(target.querySelector(".o-checkbox input").checked);
|
||||
|
||||
// Click on div
|
||||
assert.verifySteps([]);
|
||||
await click(target, ".o-checkbox");
|
||||
assert.ok(target.querySelector(".o-checkbox input").checked);
|
||||
assert.verifySteps(["true"]);
|
||||
|
||||
// Click on label
|
||||
assert.verifySteps([]);
|
||||
await click(target, ".o-checkbox > .form-check-label", true);
|
||||
assert.notOk(target.querySelector(".o-checkbox input").checked);
|
||||
assert.verifySteps(["false"]);
|
||||
|
||||
// Click on input (only possible programmatically)
|
||||
assert.verifySteps([]);
|
||||
await click(target, ".o-checkbox input");
|
||||
assert.ok(target.querySelector(".o-checkbox input").checked);
|
||||
assert.verifySteps(["true"]);
|
||||
|
||||
// When somehow applying focus on label, the focus receives it
|
||||
// (this is the default behavior from the label)
|
||||
target.querySelector(".o-checkbox > .form-check-label").focus();
|
||||
await nextTick();
|
||||
assert.strictEqual(document.activeElement, target.querySelector(".o-checkbox input"));
|
||||
|
||||
// Press Enter when focus is on input
|
||||
assert.verifySteps([]);
|
||||
triggerHotkey("Enter");
|
||||
await nextTick();
|
||||
assert.notOk(target.querySelector(".o-checkbox input").checked);
|
||||
assert.verifySteps(["false"]);
|
||||
|
||||
// Pressing Space when focus is on the input is a standard behavior
|
||||
// So we simulate it and verify that it will have its standard behavior.
|
||||
assert.strictEqual(document.activeElement, target.querySelector(".o-checkbox input"));
|
||||
const event = triggerEvent(
|
||||
document.activeElement,
|
||||
null,
|
||||
"keydown",
|
||||
{ key: "Space" },
|
||||
{ fast: true }
|
||||
);
|
||||
assert.ok(!event.defaultPrevented);
|
||||
target.querySelector(".o-checkbox input").checked = true;
|
||||
assert.verifySteps([]);
|
||||
triggerEvent(target, ".o-checkbox input", "change", {}, { fast: true });
|
||||
await nextTick();
|
||||
assert.ok(target.querySelector(".o-checkbox input").checked);
|
||||
assert.verifySteps(["true"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, markup, useState, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
editAce,
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
preloadBundle,
|
||||
preventResizeObserverError,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { CodeEditor } from "@web/core/code_editor/code_editor";
|
||||
import { debounce } from "@web/core/utils/timing";
|
||||
|
||||
preloadBundle("web.ace_lib");
|
||||
preventResizeObserverError();
|
||||
|
||||
function getDomValue() {
|
||||
return queryAll(".ace_line")
|
||||
.map((root) => queryAllTexts(`:scope > span`, { root }).join(""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function getFakeAceEditor() {
|
||||
return {
|
||||
session: {
|
||||
on: () => {},
|
||||
setMode: () => {},
|
||||
setUseWorker: () => {},
|
||||
setOptions: () => {},
|
||||
getValue: () => {},
|
||||
setValue: () => {},
|
||||
},
|
||||
renderer: {
|
||||
setOptions: () => {},
|
||||
$cursorLayer: { element: { style: {} } },
|
||||
},
|
||||
setOptions: () => {},
|
||||
setValue: () => {},
|
||||
getValue: () => "",
|
||||
setTheme: () => {},
|
||||
resize: () => {},
|
||||
destroy: () => {},
|
||||
setSession: () => {},
|
||||
getSession() {
|
||||
return this.session;
|
||||
},
|
||||
on: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
A custom implementation to dispatch keyboard events for ace specifically
|
||||
It is very naive and simple, and could extended
|
||||
|
||||
FIXME: Specificities of Ace 1.32.3
|
||||
-- Ace heavily relies on KeyboardEvent.keyCode, so hoot's helpers
|
||||
cannot be used for this simple test.
|
||||
-- Ace still relies on the keypress event
|
||||
-- The textarea has no size in ace, it is a "hidden" input and a part of Ace's internals
|
||||
hoot's helpers won't focus it naturally
|
||||
-- The same Ace considers that if "Win" is not part of the useragent's string, we are in a MAC environment
|
||||
So, instead of patching the useragent, we send to ace what it wants. (ie: Command + metaKey: true)
|
||||
*/
|
||||
function dispatchKeyboardEvents(el, tupleArray) {
|
||||
for (const [evType, eventInit] of tupleArray) {
|
||||
el.dispatchEvent(new KeyboardEvent(evType, { ...eventInit, bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
test("Can be rendered", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`<CodeEditor maxLines="10" mode="'xml'" />`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".ace_editor").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("CodeEditor shouldn't accepts markup values", async () => {
|
||||
expect.errors(1);
|
||||
|
||||
patchWithCleanup(console, {
|
||||
warn: (msg) => expect.step(msg),
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`<CodeEditor value="props.value" />`;
|
||||
static props = ["*"];
|
||||
}
|
||||
class GrandParent extends Component {
|
||||
static components = { Parent };
|
||||
static template = xml`<Parent value="state.value"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ value: `<div>Some Text</div>` });
|
||||
}
|
||||
}
|
||||
|
||||
const codeEditor = await mountWithCleanup(GrandParent);
|
||||
const textMarkup = markup`<div>Some Text</div>`;
|
||||
|
||||
codeEditor.state.value = textMarkup;
|
||||
await animationFrame();
|
||||
|
||||
expect.verifyErrors(["Invalid props for component 'CodeEditor': 'value' is not valid"]);
|
||||
expect.verifySteps(["[Owl] Unhandled error. Destroying the root component"]);
|
||||
});
|
||||
|
||||
test("onChange props called when code is edited", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`<CodeEditor maxLines="10" onChange.bind="onChange" />`;
|
||||
static props = ["*"];
|
||||
onChange(value) {
|
||||
expect.step(value);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await editAce("Some Text");
|
||||
expect.verifySteps(["Some Text"]);
|
||||
});
|
||||
|
||||
test("onChange props not called when value props is updated", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`
|
||||
<CodeEditor
|
||||
value="state.value"
|
||||
maxLines="10"
|
||||
onChange.bind="onChange"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
state = useState({ value: "initial value" });
|
||||
onChange(value) {
|
||||
expect.step(value || "__emptystring__");
|
||||
}
|
||||
}
|
||||
|
||||
const parent = await mountWithCleanup(Parent);
|
||||
expect(".ace_line").toHaveText("initial value");
|
||||
|
||||
parent.state.value = "new value";
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect(".ace_line").toHaveText("new value");
|
||||
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
|
||||
test("Default value correctly set and updates", async () => {
|
||||
const textA = "<div>\n<p>A Paragraph</p>\n</div>";
|
||||
const textB = "<div>\n<p>An Other Paragraph</p>\n</div>";
|
||||
const textC = "<div>\n<p>A Paragraph</p>\n</div>\n<p>And More</p>";
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`
|
||||
<CodeEditor
|
||||
mode="'xml'"
|
||||
value="state.value"
|
||||
onChange.bind="onChange"
|
||||
maxLines="200"
|
||||
/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ value: textA });
|
||||
this.onChange = debounce(this.onChange.bind(this));
|
||||
}
|
||||
onChange(value) {
|
||||
// Changing the value of the textarea manualy triggers an Ace "remove" event
|
||||
// of the whole text (the value is thus empty), then an "add" event with the
|
||||
// actual value, this isn't ideal but we ignore the remove.
|
||||
if (value.length <= 0) {
|
||||
return;
|
||||
}
|
||||
expect.step(value);
|
||||
}
|
||||
changeValue(newValue) {
|
||||
this.state.value = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
const codeEditor = await mountWithCleanup(Parent);
|
||||
expect(getDomValue()).toBe(textA);
|
||||
|
||||
// Disable XML autocompletion for xml end tag.
|
||||
// Necessary because the contains().edit() helpers triggers as if it was
|
||||
// a real user interaction.
|
||||
const ace_editor = window.ace.edit(queryOne(".ace_editor"));
|
||||
ace_editor.setBehavioursEnabled(false);
|
||||
|
||||
const aceEditor = window.ace.edit(queryOne(".ace_editor"));
|
||||
aceEditor.selectAll();
|
||||
await editAce(textB);
|
||||
expect(getDomValue()).toBe(textB);
|
||||
|
||||
codeEditor.changeValue(textC);
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect(getDomValue()).toBe(textC);
|
||||
expect.verifySteps([textB]);
|
||||
});
|
||||
|
||||
test("Mode props update imports the mode", async () => {
|
||||
const fakeAceEditor = getFakeAceEditor();
|
||||
fakeAceEditor.session.setMode = (mode) => {
|
||||
expect.step(mode);
|
||||
};
|
||||
|
||||
patchWithCleanup(window.ace, {
|
||||
edit: () => fakeAceEditor,
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`<CodeEditor maxLines="10" mode="state.mode" />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ mode: "xml" });
|
||||
}
|
||||
setMode(newMode) {
|
||||
this.state.mode = newMode;
|
||||
}
|
||||
}
|
||||
|
||||
const codeEditor = await mountWithCleanup(Parent);
|
||||
expect.verifySteps(["ace/mode/xml"]);
|
||||
|
||||
await codeEditor.setMode("javascript");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["ace/mode/javascript"]);
|
||||
});
|
||||
|
||||
test("Theme props updates imports the theme", async () => {
|
||||
const fakeAceEditor = getFakeAceEditor();
|
||||
fakeAceEditor.setTheme = (theme) => {
|
||||
expect.step(theme ? theme : "default");
|
||||
};
|
||||
|
||||
patchWithCleanup(window.ace, {
|
||||
edit: () => fakeAceEditor,
|
||||
});
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`<CodeEditor maxLines="10" theme="state.theme" />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ theme: "" });
|
||||
}
|
||||
setTheme(newTheme) {
|
||||
this.state.theme = newTheme;
|
||||
}
|
||||
}
|
||||
|
||||
const codeEditor = await mountWithCleanup(Parent);
|
||||
expect.verifySteps(["default"]);
|
||||
|
||||
await codeEditor.setTheme("monokai");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["ace/theme/monokai"]);
|
||||
});
|
||||
|
||||
test("initial value cannot be undone", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`<CodeEditor mode="'xml'" value="'some value'" class="'h-100'" />`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
await animationFrame();
|
||||
expect(".ace_editor").toHaveCount(1);
|
||||
expect(".ace_editor .ace_content").toHaveText("some value");
|
||||
|
||||
const editor = window.ace.edit(queryOne(".ace_editor"));
|
||||
const undo = editor.session.$undoManager.undo.bind(editor.session.$undoManager);
|
||||
editor.session.$undoManager.undo = (...args) => {
|
||||
expect.step("ace undo");
|
||||
return undo(...args);
|
||||
};
|
||||
|
||||
const aceContent = queryOne(".ace_editor textarea");
|
||||
dispatchKeyboardEvents(aceContent, [
|
||||
["keydown", { key: "Control", keyCode: 17 }],
|
||||
["keypress", { key: "Control", keyCode: 17 }],
|
||||
["keydown", { key: "z", keyCode: 90, ctrlKey: true }],
|
||||
["keypress", { key: "z", keyCode: 90, ctrlKey: true }],
|
||||
["keyup", { key: "z", keyCode: 90, ctrlKey: true }],
|
||||
["keyup", { key: "Control", keyCode: 17 }],
|
||||
]);
|
||||
await animationFrame();
|
||||
expect(".ace_editor .ace_content").toHaveText("some value");
|
||||
expect.verifySteps(["ace undo"]);
|
||||
});
|
||||
|
||||
test("code editor can take an initial cursor position", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { CodeEditor };
|
||||
static template = xml`<CodeEditor maxLines="2" value="value" initialCursorPosition="initialPosition" onChange="onChange"/>`;
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.value = `
|
||||
1
|
||||
2
|
||||
3
|
||||
4aa
|
||||
5
|
||||
`.replace(/^\s*/gm, ""); // simple dedent
|
||||
|
||||
this.initialPosition = { row: 3, column: 2 };
|
||||
}
|
||||
|
||||
onChange(value, startPosition) {
|
||||
expect.step({ value, startPosition });
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
await animationFrame();
|
||||
|
||||
const editor = window.ace.edit(queryOne(".ace_editor"));
|
||||
expect(document.activeElement).toBe(editor.textInput.getElement());
|
||||
expect(editor.getCursorPosition()).toEqual({ row: 3, column: 2 });
|
||||
|
||||
expect(queryAllTexts(".ace_gutter-cell")).toEqual(["3", "4", "5"]);
|
||||
expect.verifySteps([]);
|
||||
await contains(".ace_editor textarea", { displayed: true, visible: false }).edit("new\nvalue", {
|
||||
instantly: true,
|
||||
});
|
||||
expect.verifySteps([
|
||||
{
|
||||
startPosition: {
|
||||
column: 0,
|
||||
row: 0,
|
||||
},
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
startPosition: {
|
||||
column: 0,
|
||||
row: 0,
|
||||
},
|
||||
value: "new\nvalue",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import { test, expect } from "@odoo/hoot";
|
||||
import { press, click, animationFrame, queryOne } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { defineStyle, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { ColorPicker, DEFAULT_COLORS } from "@web/core/color_picker/color_picker";
|
||||
import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
test("basic rendering", async () => {
|
||||
await mountWithCleanup(ColorPicker, {
|
||||
props: {
|
||||
state: {
|
||||
selectedColor: "",
|
||||
defaultTab: "",
|
||||
},
|
||||
getUsedCustomColors: () => [],
|
||||
applyColor() {},
|
||||
applyColorPreview() {},
|
||||
applyColorResetPreview() {},
|
||||
colorPrefix: "",
|
||||
},
|
||||
});
|
||||
expect(".o_font_color_selector").toHaveCount(1);
|
||||
expect(".o_font_color_selector .btn-tab").toHaveCount(2);
|
||||
expect(".o_font_color_selector .btn.fa-trash").toHaveCount(1);
|
||||
expect(".o_font_color_selector .o_colorpicker_section").toHaveCount(1);
|
||||
expect(".o_font_color_selector .o_colorpicker_section .o_color_button").toHaveCount(5);
|
||||
expect(".o_font_color_selector .o_color_section .o_color_button[data-color]").toHaveCount(
|
||||
DEFAULT_COLORS.flat().length
|
||||
);
|
||||
});
|
||||
|
||||
test("basic rendering with selected color", async () => {
|
||||
await mountWithCleanup(ColorPicker, {
|
||||
props: {
|
||||
state: {
|
||||
selectedColor: "#B5D6A5",
|
||||
defaultTab: "",
|
||||
},
|
||||
getUsedCustomColors: () => [],
|
||||
applyColor() {},
|
||||
applyColorPreview() {},
|
||||
applyColorResetPreview() {},
|
||||
colorPrefix: "",
|
||||
},
|
||||
});
|
||||
expect(".o_font_color_selector").toHaveCount(1);
|
||||
expect(".o_font_color_selector .o_color_section .o_color_button[data-color]").toHaveCount(
|
||||
DEFAULT_COLORS.flat().length
|
||||
);
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color='#B5D6A5'].selected"
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("keyboard navigation", async () => {
|
||||
await mountWithCleanup(ColorPicker, {
|
||||
props: {
|
||||
state: {
|
||||
selectedColor: "",
|
||||
defaultTab: "",
|
||||
},
|
||||
getUsedCustomColors: () => [],
|
||||
applyColor() {},
|
||||
applyColorPreview() {},
|
||||
applyColorResetPreview() {},
|
||||
colorPrefix: "",
|
||||
},
|
||||
});
|
||||
// select the first color
|
||||
await click(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
|
||||
);
|
||||
await animationFrame();
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
|
||||
).toBeFocused();
|
||||
|
||||
// move to the second color
|
||||
await press("arrowright");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(2)"
|
||||
).toBeFocused();
|
||||
|
||||
// select the second color using Enter key
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(2)"
|
||||
).toHaveClass("selected");
|
||||
|
||||
// move back to the first color
|
||||
await press("arrowleft");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
|
||||
).toBeFocused();
|
||||
|
||||
// cannot move if no previous color
|
||||
await press("arrowleft");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
|
||||
).toBeFocused();
|
||||
|
||||
// move the color below
|
||||
await press("arrowdown");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(9)"
|
||||
).toBeFocused();
|
||||
|
||||
// move back to the first color
|
||||
await press("arrowup");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:first-of-type"
|
||||
).toBeFocused();
|
||||
|
||||
// select the last color of the first row
|
||||
await click(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(8)"
|
||||
);
|
||||
await animationFrame();
|
||||
|
||||
// move to the first color of the second row
|
||||
await press("arrowright");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(9)"
|
||||
).toBeFocused();
|
||||
|
||||
// move back to the last color of the first row
|
||||
await press("arrowleft");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:nth-of-type(8)"
|
||||
).toBeFocused();
|
||||
|
||||
// select the last color
|
||||
await click(".o_font_color_selector .o_color_section .o_color_button[data-color]:last-of-type");
|
||||
await animationFrame();
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:last-of-type"
|
||||
).toBeFocused();
|
||||
|
||||
// cannot move if no next color
|
||||
await press("arrowright");
|
||||
expect(
|
||||
".o_font_color_selector .o_color_section .o_color_button[data-color]:last-of-type"
|
||||
).toBeFocused();
|
||||
});
|
||||
|
||||
test("colorpicker inside the builder are linked to the builder theme colors", async () => {
|
||||
await mountWithCleanup(ColorPicker, {
|
||||
props: {
|
||||
state: {
|
||||
selectedColor: "",
|
||||
defaultTab: "",
|
||||
},
|
||||
getUsedCustomColors: () => [],
|
||||
applyColor() {},
|
||||
applyColorPreview() {},
|
||||
applyColorResetPreview() {},
|
||||
colorPrefix: "",
|
||||
cssVarColorPrefix: "xyz-",
|
||||
},
|
||||
});
|
||||
const getButtonColor = (sel) => getComputedStyle(queryOne(sel)).backgroundColor;
|
||||
|
||||
defineStyle(`
|
||||
:root {
|
||||
--o-color-1: rgb(113, 75, 103);
|
||||
--o-color-2: rgb(45, 49, 66);
|
||||
--xyz-o-color-1: rgb(113, 75, 103);
|
||||
--xyz-o-color-2: rgb(45, 49, 66);
|
||||
}
|
||||
`);
|
||||
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(113, 75, 103)");
|
||||
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(45, 49, 66)");
|
||||
|
||||
defineStyle(`
|
||||
:root {
|
||||
--xyz-o-color-1: rgb(0, 0, 255);
|
||||
--xyz-o-color-2: rgb(0, 255, 0);
|
||||
}
|
||||
`);
|
||||
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(0, 0, 255)");
|
||||
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(0, 255, 0)");
|
||||
});
|
||||
|
||||
test("colorpicker outside the builder are not linked to the builder theme colors", async () => {
|
||||
await mountWithCleanup(ColorPicker, {
|
||||
props: {
|
||||
state: {
|
||||
selectedColor: "",
|
||||
defaultTab: "",
|
||||
},
|
||||
getUsedCustomColors: () => [],
|
||||
applyColor() {},
|
||||
applyColorPreview() {},
|
||||
applyColorResetPreview() {},
|
||||
colorPrefix: "",
|
||||
cssVarColorPrefix: "",
|
||||
},
|
||||
});
|
||||
const getButtonColor = (sel) => getComputedStyle(queryOne(sel)).backgroundColor;
|
||||
|
||||
defineStyle(`
|
||||
:root {
|
||||
--o-color-1: rgb(113, 75, 103);
|
||||
--o-color-2: rgb(45, 49, 66);
|
||||
--xyz-o-color-1: rgb(113, 75, 103);
|
||||
--xyz-o-color-2: rgb(45, 49, 66);
|
||||
}
|
||||
`);
|
||||
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(113, 75, 103)");
|
||||
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(45, 49, 66)");
|
||||
|
||||
defineStyle(`
|
||||
:root {
|
||||
--xyz-o-color-1: rgb(0, 0, 255);
|
||||
--xyz-o-color-2: rgb(0, 255, 0);
|
||||
}
|
||||
`);
|
||||
expect(getButtonColor("button[data-color='o-color-1']")).toBe("rgb(113, 75, 103)");
|
||||
expect(getButtonColor("button[data-color='o-color-2']")).toBe("rgb(45, 49, 66)");
|
||||
});
|
||||
|
||||
test("custom color picker sets default color as selected", async () => {
|
||||
await mountWithCleanup(CustomColorPicker, {
|
||||
props: {
|
||||
defaultColor: "#FF0000",
|
||||
},
|
||||
});
|
||||
expect("input.o_hex_input").toHaveValue("#FF0000");
|
||||
});
|
||||
|
||||
test("custom color picker change color on click in hue slider", async () => {
|
||||
await mountWithCleanup(CustomColorPicker, { props: { selectedColor: "#FF0000" } });
|
||||
expect("input.o_hex_input").toHaveValue("#FF0000");
|
||||
await click(".o_color_slider");
|
||||
expect("input.o_hex_input").not.toHaveValue("#FF0000");
|
||||
});
|
||||
|
||||
class ExtraTab extends Component {
|
||||
static template = xml`<p>Color picker extra tab</p>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
test("can register an extra tab", async () => {
|
||||
registry.category("color_picker_tabs").add("web.extra", {
|
||||
id: "extra",
|
||||
name: "Extra",
|
||||
component: ExtraTab,
|
||||
});
|
||||
await mountWithCleanup(ColorPicker, {
|
||||
props: {
|
||||
state: {
|
||||
selectedColor: "#FF0000",
|
||||
defaultTab: "",
|
||||
},
|
||||
getUsedCustomColors: () => [],
|
||||
applyColor() {},
|
||||
applyColorPreview() {},
|
||||
applyColorResetPreview() {},
|
||||
colorPrefix: "",
|
||||
enabledTabs: ["solid", "custom", "extra"],
|
||||
},
|
||||
});
|
||||
expect(".o_font_color_selector .btn-tab").toHaveCount(3);
|
||||
await click("button.extra-tab");
|
||||
await animationFrame();
|
||||
expect("button.extra-tab").toHaveClass("active");
|
||||
expect(".o_font_color_selector>p:last-child").toHaveText("Color picker extra tab");
|
||||
registry.category("color_picker_tabs").remove("web.extra");
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { contains, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { ColorList } from "@web/core/colorlist/colorlist";
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<t t-component="Component" t-props="componentProps"/>
|
||||
<div class="outsideDiv">Outside div</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
|
||||
get Component() {
|
||||
return this.props.Component || ColorList;
|
||||
}
|
||||
|
||||
get componentProps() {
|
||||
const props = { ...this.props };
|
||||
delete props.Component;
|
||||
if (!props.onColorSelected) {
|
||||
props.onColorSelected = () => {};
|
||||
}
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
test("basic rendering with forceExpanded props", async () => {
|
||||
await mountWithCleanup(Parent, {
|
||||
props: {
|
||||
colors: [0, 9],
|
||||
forceExpanded: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_colorlist").toHaveCount(1);
|
||||
expect(".o_colorlist button").toHaveCount(2);
|
||||
expect(".o_colorlist button:eq(1)").toHaveAttribute("title", "Raspberry");
|
||||
expect(".o_colorlist button:eq(1)").toHaveClass("o_colorlist_item_color_9");
|
||||
});
|
||||
|
||||
test("color click does not open the list if canToggle props is not given", async () => {
|
||||
const selectedColorId = 0;
|
||||
await mountWithCleanup(Parent, {
|
||||
props: {
|
||||
colors: [4, 5, 6],
|
||||
selectedColor: selectedColorId,
|
||||
onColorSelected: (colorId) => expect.step("color #" + colorId + " is selected"),
|
||||
},
|
||||
});
|
||||
expect(".o_colorlist").toHaveCount(1);
|
||||
expect("button.o_colorlist_toggler").toHaveCount(1);
|
||||
|
||||
await contains(".o_colorlist").click();
|
||||
expect("button.o_colorlist_toggler").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("open the list of colors if canToggle props is given", async function () {
|
||||
const selectedColorId = 0;
|
||||
await mountWithCleanup(Parent, {
|
||||
props: {
|
||||
canToggle: true,
|
||||
colors: [4, 5, 6],
|
||||
selectedColor: selectedColorId,
|
||||
onColorSelected: (colorId) => expect.step("color #" + colorId + " is selected"),
|
||||
},
|
||||
});
|
||||
expect(".o_colorlist").toHaveCount(1);
|
||||
expect(".o_colorlist button").toHaveClass("o_colorlist_item_color_" + selectedColorId);
|
||||
|
||||
await contains(".o_colorlist button").click();
|
||||
expect("button.o_colorlist_toggler").toHaveCount(0);
|
||||
expect(".o_colorlist button").toHaveCount(3);
|
||||
|
||||
await contains(".outsideDiv").click();
|
||||
expect(".o_colorlist button").toHaveCount(1);
|
||||
expect("button.o_colorlist_toggler").toHaveCount(1);
|
||||
|
||||
await contains(".o_colorlist_toggler").click();
|
||||
await contains(".o_colorlist button:eq(2)").click();
|
||||
expect.verifySteps(["color #6 is selected"]);
|
||||
});
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { ColorList } from "@web/core/colorlist/colorlist";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import { click, getFixture, mount } from "../helpers/utils";
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let target;
|
||||
|
||||
/**
|
||||
* @param {typeof ColorList} Picker
|
||||
* @param {Object} props
|
||||
* @returns {Promise<ColorList>}
|
||||
*/
|
||||
async function mountComponent(Picker, props) {
|
||||
serviceRegistry.add("ui", uiService);
|
||||
target = getFixture();
|
||||
|
||||
class Parent extends Component {}
|
||||
Parent.template = xml/* xml */ `
|
||||
<t t-component="props.Picker" t-props="props.props"/>
|
||||
<div class="outsideDiv">Outside div</div>
|
||||
`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
if (!props.onColorSelected) {
|
||||
props.onColorSelected = () => {};
|
||||
}
|
||||
const parent = await mount(Parent, target, { env, props: { Picker, props } });
|
||||
return parent;
|
||||
}
|
||||
|
||||
QUnit.module("Components", () => {
|
||||
QUnit.module("ColorList");
|
||||
|
||||
QUnit.test("basic rendering with forceExpanded props", async function (assert) {
|
||||
await mountComponent(ColorList, {
|
||||
colors: [0, 9],
|
||||
forceExpanded: true,
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_colorlist");
|
||||
assert.containsN(target, ".o_colorlist button", 2, "two buttons are available");
|
||||
const secondBtn = target.querySelectorAll(".o_colorlist button")[1];
|
||||
assert.strictEqual(
|
||||
secondBtn.attributes.title.value,
|
||||
"Fuchsia",
|
||||
"second button color is Fuchsia"
|
||||
);
|
||||
assert.hasClass(
|
||||
secondBtn,
|
||||
"o_colorlist_item_color_9",
|
||||
"second button has the corresponding class"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"color click does not open the list if canToggle props is not given",
|
||||
async function (assert) {
|
||||
const selectedColorId = 0;
|
||||
await mountComponent(ColorList, {
|
||||
colors: [4, 5, 6],
|
||||
selectedColor: selectedColorId,
|
||||
onColorSelected: (colorId) => assert.step("color #" + colorId + " is selected"),
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_colorlist");
|
||||
assert.containsOnce(
|
||||
target,
|
||||
"button.o_colorlist_toggler",
|
||||
"only the toggler button is available"
|
||||
);
|
||||
|
||||
await click(target.querySelector(".o_colorlist button"));
|
||||
|
||||
assert.containsOnce(target, "button.o_colorlist_toggler", "button is still visible");
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("open the list of colors if canToggle props is given", async function (assert) {
|
||||
const selectedColorId = 0;
|
||||
await mountComponent(ColorList, {
|
||||
canToggle: true,
|
||||
colors: [4, 5, 6],
|
||||
selectedColor: selectedColorId,
|
||||
onColorSelected: (colorId) => assert.step("color #" + colorId + " is selected"),
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_colorlist");
|
||||
assert.hasClass(
|
||||
target.querySelector(".o_colorlist button"),
|
||||
"o_colorlist_item_color_" + selectedColorId,
|
||||
"toggler has the right class"
|
||||
);
|
||||
|
||||
await click(target.querySelector(".o_colorlist button"));
|
||||
|
||||
assert.containsNone(
|
||||
target,
|
||||
"button.o_colorlist_toggler",
|
||||
"toggler button is no longer visible"
|
||||
);
|
||||
assert.containsN(target, ".o_colorlist button", 3, "three buttons are available");
|
||||
|
||||
await click(target.querySelector(".outsideDiv"));
|
||||
assert.containsOnce(target, ".o_colorlist button", "only one button is available");
|
||||
assert.containsOnce(
|
||||
target,
|
||||
"button.o_colorlist_toggler",
|
||||
"colorlist has been closed and toggler is visible"
|
||||
);
|
||||
|
||||
// reopen the colorlist and select a color
|
||||
await click(target.querySelector(".o_colorlist_toggler"));
|
||||
await click(target.querySelectorAll(".o_colorlist button")[2]);
|
||||
|
||||
assert.verifySteps(["color #6 is selected"]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,141 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { press, queryAllTexts } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
defineActions,
|
||||
defineMenus,
|
||||
getService,
|
||||
mountWithCleanup,
|
||||
useTestClientAction,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
|
||||
defineMenus([
|
||||
{ id: 0 }, // prevents auto-loading the first action
|
||||
{ id: 1, name: "Contact", actionID: 1001 },
|
||||
{
|
||||
id: 2,
|
||||
name: "Sales",
|
||||
actionID: 1002,
|
||||
appID: 2,
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
name: "Info",
|
||||
appID: 2,
|
||||
actionID: 1003,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Report",
|
||||
appID: 2,
|
||||
actionID: 1004,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const testAction = useTestClientAction();
|
||||
defineActions([
|
||||
{ ...testAction, id: 1001, params: { description: "Id 1" } },
|
||||
{ ...testAction, id: 1003, params: { description: "Info" } },
|
||||
{ ...testAction, id: 1004, params: { description: "Report" } },
|
||||
]);
|
||||
|
||||
test.tags("desktop");
|
||||
test("displays only apps if the search value is '/'", async () => {
|
||||
await mountWithCleanup(WebClient);
|
||||
expect(".o_menu_brand").toHaveCount(0);
|
||||
|
||||
await press(["control", "k"]);
|
||||
await animationFrame();
|
||||
await contains(".o_command_palette_search input").edit("/", { confirm: false });
|
||||
await animationFrame();
|
||||
expect(".o_command_palette").toHaveCount(1);
|
||||
expect(".o_command_category").toHaveCount(1);
|
||||
expect(".o_command").toHaveCount(2);
|
||||
expect(queryAllTexts(".o_command_name")).toEqual(["Contact", "Sales"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("displays apps and menu items if the search value is not only '/'", async () => {
|
||||
await mountWithCleanup(WebClient);
|
||||
|
||||
await press(["control", "k"]);
|
||||
await animationFrame();
|
||||
await contains(".o_command_palette_search input").edit("/sal", { confirm: false });
|
||||
await animationFrame();
|
||||
expect(".o_command_palette").toHaveCount(1);
|
||||
expect(".o_command").toHaveCount(3);
|
||||
expect(queryAllTexts(".o_command_name")).toEqual(["Sales", "Sales / Info", "Sales / Report"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("opens an app", async () => {
|
||||
await mountWithCleanup(WebClient);
|
||||
expect(".o_menu_brand").toHaveCount(0);
|
||||
|
||||
await press(["control", "k"]);
|
||||
await animationFrame();
|
||||
await contains(".o_command_palette_search input").edit("/", { confirm: false });
|
||||
await animationFrame();
|
||||
expect(".o_command_palette").toHaveCount(1);
|
||||
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
// empty screen for now, wait for actual action to show up
|
||||
await animationFrame();
|
||||
expect(".o_menu_brand").toHaveText("Contact");
|
||||
expect(".test_client_action").toHaveText("ClientAction_Id 1");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("opens a menu items", async () => {
|
||||
await mountWithCleanup(WebClient);
|
||||
expect(".o_menu_brand").toHaveCount(0);
|
||||
|
||||
await press(["control", "k"]);
|
||||
await animationFrame();
|
||||
await contains(".o_command_palette_search input").edit("/sal", { confirm: false });
|
||||
await animationFrame();
|
||||
expect(".o_command_palette").toHaveCount(1);
|
||||
expect(".o_command_category").toHaveCount(2);
|
||||
|
||||
await contains("#o_command_2").click();
|
||||
await animationFrame();
|
||||
// empty screen for now, wait for actual action to show up
|
||||
await animationFrame();
|
||||
expect(".o_menu_brand").toHaveText("Sales");
|
||||
expect(".test_client_action").toHaveText("ClientAction_Report");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("open a menu item when a dialog is displayed", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static template = xml`<Dialog contentClass="'test'">content</Dialog>`;
|
||||
static components = { Dialog };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(WebClient);
|
||||
expect(".o_menu_brand").toHaveCount(0);
|
||||
expect(".modal .test").toHaveCount(0);
|
||||
|
||||
getService("dialog").add(CustomDialog);
|
||||
await animationFrame();
|
||||
expect(".modal .test").toHaveCount(1);
|
||||
|
||||
await press(["control", "k"]);
|
||||
await animationFrame();
|
||||
await contains(".o_command_palette_search input").edit("/sal", { confirm: false });
|
||||
await animationFrame();
|
||||
expect(".o_command_palette").toHaveCount(1);
|
||||
expect(".modal .test").toHaveCount(1);
|
||||
|
||||
await contains("#o_command_2").click();
|
||||
await animationFrame();
|
||||
expect(".o_menu_brand").toHaveText("Sales");
|
||||
});
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { createWebClient, getActionManagerServerData } from "@web/../tests/webclient/helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { click, getFixture, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils";
|
||||
import { editSearchBar } from "./command_service_tests";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
QUnit.module("Menu Command Provider", {
|
||||
async beforeEach() {
|
||||
patchWithCleanup(browser, {
|
||||
clearTimeout: () => {},
|
||||
setTimeout: (later) => {
|
||||
later();
|
||||
},
|
||||
});
|
||||
const commandCategoryRegistry = registry.category("command_categories");
|
||||
commandCategoryRegistry.add("apps", { namespace: "/" }, { sequence: 10 });
|
||||
commandCategoryRegistry.add("menu_items", { namespace: "/" }, { sequence: 20 });
|
||||
serverData = getActionManagerServerData();
|
||||
serverData.menus = {
|
||||
root: { id: "root", children: [0, 1, 2], name: "root", appID: "root" },
|
||||
0: { id: 0, children: [], name: "UglyHack", appID: 0, xmlid: "menu_0" },
|
||||
1: { id: 1, children: [], name: "Contact", appID: 1, actionID: 1001, xmlid: "menu_1" },
|
||||
2: {
|
||||
id: 2,
|
||||
children: [3, 4],
|
||||
name: "Sales",
|
||||
appID: 2,
|
||||
actionID: 1002,
|
||||
xmlid: "menu_2",
|
||||
},
|
||||
3: {
|
||||
id: 3,
|
||||
children: [],
|
||||
name: "Info",
|
||||
appID: 2,
|
||||
actionID: 1003,
|
||||
xmlid: "menu_3",
|
||||
},
|
||||
4: {
|
||||
id: 4,
|
||||
children: [],
|
||||
name: "Report",
|
||||
appID: 2,
|
||||
actionID: 1004,
|
||||
xmlid: "menu_4",
|
||||
},
|
||||
};
|
||||
serverData.actions[1003] = {
|
||||
id: 1003,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Info" },
|
||||
};
|
||||
serverData.actions[1004] = {
|
||||
id: 1004,
|
||||
tag: "__test__client__action__",
|
||||
target: "main",
|
||||
type: "ir.actions.client",
|
||||
params: { description: "Report" },
|
||||
};
|
||||
|
||||
target = getFixture();
|
||||
},
|
||||
afterEach() {},
|
||||
});
|
||||
|
||||
QUnit.test("displays only apps if the search value is '/'", async (assert) => {
|
||||
await createWebClient({ serverData });
|
||||
assert.containsNone(target, ".o_menu_brand");
|
||||
|
||||
triggerHotkey("control+k");
|
||||
await nextTick();
|
||||
await editSearchBar("/");
|
||||
assert.containsOnce(target, ".o_command_palette");
|
||||
assert.containsOnce(target, ".o_command_category");
|
||||
assert.containsN(target, ".o_command", 2);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll(".o_command_name")].map((el) => el.textContent),
|
||||
["Contact", "Sales"]
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("displays apps and menu items if the search value is not only '/'", async (assert) => {
|
||||
await createWebClient({ serverData });
|
||||
|
||||
triggerHotkey("control+k");
|
||||
await nextTick();
|
||||
await editSearchBar("/sal");
|
||||
assert.containsOnce(target, ".o_command_palette");
|
||||
assert.containsN(target, ".o_command", 3);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll(".o_command_name")].map((el) => el.textContent),
|
||||
["Sales", "Sales / Info", "Sales / Report"]
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("opens an app", async (assert) => {
|
||||
await createWebClient({ serverData });
|
||||
assert.containsNone(target, ".o_menu_brand");
|
||||
|
||||
triggerHotkey("control+k");
|
||||
await nextTick();
|
||||
await editSearchBar("/");
|
||||
assert.containsOnce(target, ".o_command_palette");
|
||||
|
||||
triggerHotkey("enter");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Contact");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".test_client_action").textContent,
|
||||
" ClientAction_Id 1"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("opens a menu items", async (assert) => {
|
||||
await createWebClient({ serverData });
|
||||
assert.containsNone(target, ".o_menu_brand");
|
||||
|
||||
triggerHotkey("control+k");
|
||||
await nextTick();
|
||||
await editSearchBar("/sal");
|
||||
assert.containsOnce(target, ".o_command_palette");
|
||||
assert.containsN(target, ".o_command_category", 2);
|
||||
|
||||
click(target, "#o_command_2");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Sales");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".test_client_action").textContent,
|
||||
" ClientAction_Report"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("open a menu item when a dialog is displayed", async (assert) => {
|
||||
class CustomDialog extends Component {}
|
||||
CustomDialog.components = { Dialog };
|
||||
CustomDialog.template = xml`<Dialog contentClass="'test'">content</Dialog>`;
|
||||
|
||||
const webclient = await createWebClient({ serverData });
|
||||
assert.containsNone(target, ".o_menu_brand");
|
||||
assert.containsNone(target, ".modal .test");
|
||||
|
||||
webclient.env.services.dialog.add(CustomDialog);
|
||||
await nextTick();
|
||||
assert.containsOnce(target, ".modal .test");
|
||||
|
||||
triggerHotkey("control+k");
|
||||
await nextTick();
|
||||
await editSearchBar("/sal");
|
||||
assert.containsOnce(target, ".o_command_palette");
|
||||
assert.containsOnce(target, ".modal .test");
|
||||
|
||||
await click(target, "#o_command_2");
|
||||
await nextTick();
|
||||
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Sales");
|
||||
assert.containsNone(target, ".modal .test");
|
||||
});
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { CopyButton } from "@web/core/copy_button/copy_button";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { click } from "@odoo/hoot-dom";
|
||||
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(browser.navigator.clipboard, {
|
||||
async writeText(text) {
|
||||
expect.step(`writeText: ${text}`);
|
||||
},
|
||||
async write(object) {
|
||||
expect.step(
|
||||
`write: {${Object.entries(object)
|
||||
.map(([k, v]) => k + ": " + v)
|
||||
.join(", ")}}`
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("copies a string to the clipboard", async () => {
|
||||
await mountWithCleanup(CopyButton, { props: { content: "content to copy" } });
|
||||
await click(".o_clipboard_button");
|
||||
expect.verifySteps(["writeText: content to copy"]);
|
||||
});
|
||||
|
||||
test("copies an object to the clipboard", async () => {
|
||||
await mountWithCleanup(CopyButton, { props: { content: { oneKey: "oneValue" } } });
|
||||
await click(".o_clipboard_button");
|
||||
expect.verifySteps(["write: {oneKey: oneValue}"]);
|
||||
});
|
||||
|
||||
test("copies a string via a function to the clipboard", async () => {
|
||||
let contentToCopy = "content to copy 1";
|
||||
const content = () => contentToCopy;
|
||||
await mountWithCleanup(CopyButton, { props: { content } });
|
||||
await click(".o_clipboard_button");
|
||||
contentToCopy = "content to copy 2";
|
||||
await click(".o_clipboard_button");
|
||||
expect.verifySteps(["writeText: content to copy 1", "writeText: content to copy 2"]);
|
||||
});
|
||||
|
||||
test("copies an object via a function to the clipboard", async () => {
|
||||
let contentToCopy = { oneKey: "oneValue" };
|
||||
const content = () => contentToCopy;
|
||||
await mountWithCleanup(CopyButton, { props: { content } });
|
||||
await click(".o_clipboard_button");
|
||||
contentToCopy = { anotherKey: "anotherValue" };
|
||||
await click(".o_clipboard_button");
|
||||
expect.verifySteps(["write: {oneKey: oneValue}", "write: {anotherKey: anotherValue}"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { click, edit } from "@odoo/hoot-dom";
|
||||
import { animationFrame, tick } from "@odoo/hoot-mock";
|
||||
import { Component, reactive, useState, xml } from "@odoo/owl";
|
||||
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||
import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
/**
|
||||
* @param {() => any} setup
|
||||
*/
|
||||
const mountInput = async (setup) => {
|
||||
await mountWithCleanup(Root, { props: { setup } });
|
||||
};
|
||||
|
||||
class Root extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static template = xml`<input type="text" class="datetime_hook_input" t-ref="start-date"/>`;
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.props.setup();
|
||||
}
|
||||
}
|
||||
|
||||
test("reactivity: update inert object", async () => {
|
||||
const pickerProps = {
|
||||
value: false,
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
useDateTimePicker({ pickerProps });
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
|
||||
pickerProps.value = DateTime.fromSQL("2023-06-06");
|
||||
await tick();
|
||||
|
||||
expect(".datetime_hook_input").toHaveText("");
|
||||
});
|
||||
|
||||
test("reactivity: useState & update getter object", async () => {
|
||||
const pickerProps = reactive({
|
||||
value: false,
|
||||
type: "date",
|
||||
});
|
||||
|
||||
await mountInput(() => {
|
||||
const state = useState(pickerProps);
|
||||
state.value; // artificially subscribe to value
|
||||
|
||||
useDateTimePicker({
|
||||
get pickerProps() {
|
||||
return pickerProps;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
|
||||
pickerProps.value = DateTime.fromSQL("2023-06-06");
|
||||
await animationFrame();
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("06/06/2023");
|
||||
});
|
||||
|
||||
test("reactivity: update reactive object returned by the hook", async () => {
|
||||
let pickerProps;
|
||||
const defaultPickerProps = {
|
||||
value: false,
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
expect(pickerProps.value).toBe(false);
|
||||
|
||||
pickerProps.value = DateTime.fromSQL("2023-06-06");
|
||||
await tick();
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("06/06/2023");
|
||||
});
|
||||
|
||||
test("returned value is updated when input has changed", async () => {
|
||||
let pickerProps;
|
||||
const defaultPickerProps = {
|
||||
value: false,
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
pickerProps = useDateTimePicker({ pickerProps: defaultPickerProps }).state;
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("");
|
||||
expect(pickerProps.value).toBe(false);
|
||||
await click(".datetime_hook_input");
|
||||
await edit("06/06/2023");
|
||||
await click(document.body);
|
||||
|
||||
expect(pickerProps.value.toSQL().split(" ")[0]).toBe("2023-06-06");
|
||||
});
|
||||
|
||||
test("value is not updated if it did not change", async () => {
|
||||
const getShortDate = (date) => date.toSQL().split(" ")[0];
|
||||
|
||||
let pickerProps;
|
||||
const defaultPickerProps = {
|
||||
value: DateTime.fromSQL("2023-06-06"),
|
||||
type: "date",
|
||||
};
|
||||
|
||||
await mountInput(() => {
|
||||
pickerProps = useDateTimePicker({
|
||||
pickerProps: defaultPickerProps,
|
||||
onApply: (value) => {
|
||||
expect.step(getShortDate(value));
|
||||
},
|
||||
}).state;
|
||||
});
|
||||
|
||||
expect(".datetime_hook_input").toHaveValue("06/06/2023");
|
||||
expect(getShortDate(pickerProps.value)).toBe("2023-06-06");
|
||||
|
||||
await click(".datetime_hook_input");
|
||||
await edit("06/06/2023");
|
||||
await click(document.body);
|
||||
|
||||
expect(getShortDate(pickerProps.value)).toBe("2023-06-06");
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".datetime_hook_input");
|
||||
await edit("07/07/2023");
|
||||
await click(document.body);
|
||||
|
||||
expect(getShortDate(pickerProps.value)).toBe("2023-07-07");
|
||||
expect.verifySteps(["2023-07-07"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,587 @@
|
|||
import { test, expect, describe } from "@odoo/hoot";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
assertDateTimePicker,
|
||||
editTime,
|
||||
getPickerCell,
|
||||
} from "../../datetime/datetime_test_helpers";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { DateTimeInput } from "@web/core/datetime/datetime_input";
|
||||
import {
|
||||
contains,
|
||||
defineParams,
|
||||
makeMockEnv,
|
||||
mountWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { click, edit, queryFirst } from "@odoo/hoot-dom";
|
||||
|
||||
const { DateTime } = luxon;
|
||||
|
||||
class DateTimeInputComp extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static template = xml`<DateTimeInput t-props="props" />`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
async function changeLang(lang) {
|
||||
serverState.lang = lang;
|
||||
await makeMockEnv();
|
||||
}
|
||||
|
||||
describe("DateTimeInput (date)", () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d/%m/%Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
test("basic rendering", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
class: "custom_class",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveCount(1);
|
||||
assertDateTimePicker(false);
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997");
|
||||
expect(".o_datetime_input").toHaveClass("custom_class");
|
||||
|
||||
await click(".o_datetime_input");
|
||||
await animationFrame();
|
||||
|
||||
assertDateTimePicker({
|
||||
title: "January 1997",
|
||||
date: [
|
||||
{
|
||||
cells: [
|
||||
[29, 30, 31, 1, 2, 3, 4],
|
||||
[5, 6, 7, 8, [9], 10, 11],
|
||||
[12, 13, 14, 15, 16, 17, 18],
|
||||
[19, 20, 21, 22, 23, 24, 25],
|
||||
[26, 27, 28, 29, 30, 31, 1],
|
||||
[2, 3, 4, 5, 6, 7, 8],
|
||||
],
|
||||
daysOfWeek: ["", "S", "M", "T", "W", "T", "F", "S"],
|
||||
weekNumbers: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("pick a date", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
await makeMockEnv();
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("08/02/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(".o_datetime_picker .o_next").click();
|
||||
|
||||
expect.verifySteps([]);
|
||||
await contains(getPickerCell("8")).click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("pick a date with FR locale", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
await changeLang("fr-FR");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
format: "dd MMM, yyyy",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("19/09/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 janv., 1997");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(".o_zoom_out").click();
|
||||
await contains(getPickerCell("sept.")).click();
|
||||
await contains(getPickerCell("19")).click();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("19 sept., 1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("pick a date with locale (locale with different symbols)", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await changeLang("gu");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
format: "dd MMM, yyyy",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("19/09/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 જાન્યુ, 1997");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 જાન્યુ, 1997");
|
||||
|
||||
await contains(".o_zoom_out").click();
|
||||
await contains(getPickerCell("સપ્ટે")).click();
|
||||
await contains(getPickerCell("19")).click();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("19 સપ્ટે, 1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("enter a date value", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("08/02/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("08/02/1997");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
|
||||
await click(".o_datetime_input");
|
||||
await animationFrame();
|
||||
|
||||
expect(getPickerCell("8", true)).toHaveClass("o_selected");
|
||||
});
|
||||
|
||||
test("Date format is correctly set", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
format: "yyyy/MM/dd",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("1997/01/09");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("1997/01/09");
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("popover should have enough space to be displayed", async () => {
|
||||
class Root extends Component {
|
||||
static components = { DateTimeInput };
|
||||
static template = xml`<div class="d-flex"><DateTimeInput t-props="props" /></div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await mountWithCleanup(Root, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
},
|
||||
});
|
||||
const parent = queryFirst(".o_datetime_input").parentElement;
|
||||
const initialParentHeight = parent.clientHeight;
|
||||
|
||||
await contains(".o_datetime_input", { root: parent }).click();
|
||||
|
||||
const pickerRectHeight = queryFirst(".o_datetime_picker").clientHeight;
|
||||
|
||||
expect(initialParentHeight).toBeLessThan(pickerRectHeight, {
|
||||
message: "initial height shouldn't be big enough to display the picker",
|
||||
});
|
||||
expect(parent.clientHeight).toBeGreaterThan(pickerRectHeight, {
|
||||
message: "initial height should be big enough to display the picker",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DateTimeInput (datetime)", () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d/%m/%Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
test("basic rendering", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveCount(1);
|
||||
assertDateTimePicker(false);
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997 12:30:01");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
assertDateTimePicker({
|
||||
title: "January 1997",
|
||||
date: [
|
||||
{
|
||||
cells: [
|
||||
[29, 30, 31, 1, 2, 3, 4],
|
||||
[5, 6, 7, 8, [9], 10, 11],
|
||||
[12, 13, 14, 15, 16, 17, 18],
|
||||
[19, 20, 21, 22, 23, 24, 25],
|
||||
[26, 27, 28, 29, 30, 31, 1],
|
||||
[2, 3, 4, 5, 6, 7, 8],
|
||||
],
|
||||
daysOfWeek: ["", "S", "M", "T", "W", "T", "F", "S"],
|
||||
weekNumbers: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
],
|
||||
time: ["12:30"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pick a date and time", async () => {
|
||||
await makeMockEnv();
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997 12:30:01");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
// Select February 8th
|
||||
await contains(".o_datetime_picker .o_next").click();
|
||||
await contains(getPickerCell("8")).click();
|
||||
|
||||
// Select 15:45
|
||||
await editTime("15:45");
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:01");
|
||||
expect.verifySteps(["1997-02-08 12:30:01", "1997-02-08 15:45:01"]);
|
||||
});
|
||||
|
||||
test("pick a date and time with locale", async () => {
|
||||
await changeLang("fr_FR");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd MMM, yyyy HH:mm",
|
||||
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 janv., 1997 12:30");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
await contains(".o_zoom_out").click();
|
||||
await contains(getPickerCell("sept.")).click();
|
||||
await contains(getPickerCell("1")).click();
|
||||
|
||||
// Select 15:45
|
||||
await editTime("15:45");
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("01 sept., 1997 15:45");
|
||||
expect.verifySteps(["1997-09-01 12:30:01", "1997-09-01 15:45:01"]);
|
||||
});
|
||||
|
||||
test("pick a time with 12 hour format without meridiem", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d/%m/%Y",
|
||||
time_format: "%I:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 08:30", "dd/MM/yyyy HH:mm"),
|
||||
type: "datetime",
|
||||
onChange: (date) => expect.step(date.toSQL().split(".")[0]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09/01/1997 08:30:00");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
await editTime("8:15");
|
||||
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["1997-01-09 08:15:00"]);
|
||||
});
|
||||
|
||||
test("enter a datetime value", async () => {
|
||||
expect.assertions(6);
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
format: "dd/MM/yyyy HH:mm:ss",
|
||||
type: "datetime",
|
||||
onChange: (date) => {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy HH:mm:ss")).toBe("08/02/1997 15:45:05", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("08/02/1997 15:45:05");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:05");
|
||||
expect(getPickerCell("8", true)).toHaveClass("o_selected");
|
||||
|
||||
expect(".o_time_picker_input").toHaveValue("15:45");
|
||||
});
|
||||
|
||||
test("Date time format is correctly set", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "HH:mm:ss yyyy/MM/dd",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("12:30:01 1997/01/09");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("12:30:01 1997/01/09");
|
||||
});
|
||||
|
||||
test("Datepicker works with norwegian locale", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await changeLang("nb");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd MMM, yyyy",
|
||||
onChange(date) {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy")).toBe("01/04/1997", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 apr., 1997");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("09 apr., 1997");
|
||||
|
||||
await contains(getPickerCell("1")).click();
|
||||
expect(".o_datetime_input").toHaveValue("01 apr., 1997");
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
test("Datepicker works with dots and commas in format", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd.MM,yyyy",
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("10.03,2023");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await contains(".o_datetime_input").click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("10.03,2023");
|
||||
});
|
||||
|
||||
test("start with no value", async () => {
|
||||
expect.assertions(5);
|
||||
|
||||
await makeMockEnv();
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
type: "datetime",
|
||||
onChange(date) {
|
||||
expect.step("datetime-changed");
|
||||
expect(date.toFormat("dd/MM/yyyy HH:mm")).toBe("08/02/1997 15:45", {
|
||||
message: "Event should transmit the correct date",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("");
|
||||
expect.verifySteps([]);
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("08/02/1997 15:45");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["datetime-changed"]);
|
||||
expect(".o_datetime_input").toHaveValue("08/02/1997 15:45:00");
|
||||
});
|
||||
|
||||
test("Clicking clear button closes datetime picker", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd MMM, yyyy HH:mm:ss",
|
||||
},
|
||||
});
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(".o_datetime_picker .o_datetime_buttons .btn-secondary").click();
|
||||
|
||||
expect(".o_datetime_picker").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Clicking apply button closes datetime picker", async () => {
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
type: "datetime",
|
||||
format: "dd MMM, yyyy HH:mm:ss",
|
||||
},
|
||||
});
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(".o_datetime_picker .o_datetime_buttons .btn-primary").click();
|
||||
|
||||
expect(".o_datetime_picker").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("check datepicker in localization with textual month format", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%b/%d/%Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
let onChangeDate;
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: {
|
||||
value: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
type: "date",
|
||||
onChange: (date) => (onChangeDate = date),
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("Jan/09/1997");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await contains(getPickerCell("5")).click();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("Jan/05/1997");
|
||||
expect(onChangeDate.toFormat("dd/MM/yyyy")).toBe("05/01/1997");
|
||||
});
|
||||
|
||||
test("arab locale, latin numbering system as input", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
date_format: "%d %b, %Y",
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
await changeLang("ar-001");
|
||||
|
||||
await mountWithCleanup(DateTimeInputComp, {
|
||||
props: { rounding: 0 },
|
||||
});
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
|
||||
|
||||
await contains(".o_datetime_input").click();
|
||||
await edit("15 07, 2020 12:30:43");
|
||||
await animationFrame();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_datetime_input").toHaveValue("١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,460 @@
|
|||
import { test, expect } from "@odoo/hoot";
|
||||
import { Deferred, animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { click, press } from "@odoo/hoot-dom";
|
||||
import { Pager } from "@web/core/pager/pager";
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { contains, mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { config as transitionConfig } from "@web/core/transition";
|
||||
|
||||
class PagerController extends Component {
|
||||
static template = xml`<Pager t-props="state" />`;
|
||||
static components = { Pager };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.state = useState({ ...this.props });
|
||||
}
|
||||
async updateProps(nextProps) {
|
||||
Object.assign(this.state, nextProps);
|
||||
await animationFrame();
|
||||
}
|
||||
}
|
||||
|
||||
test("basic interactions", async () => {
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
async onUpdate(data) {
|
||||
expect.step(`offset: ${data.offset}, limit: ${data.limit}`);
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await contains(".o_pager button.o_pager_next:enabled").click();
|
||||
await contains(".o_pager button.o_pager_previous:enabled").click();
|
||||
|
||||
expect.verifySteps(["offset: 4, limit: 4", "offset: 0, limit: 4"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("basic interactions on desktop", async () => {
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
async onUpdate(data) {
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_counter .o_pager_value").toHaveText("1-4");
|
||||
|
||||
await click(".o_pager button.o_pager_next");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_pager_counter .o_pager_value").toHaveText("5-8");
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("basic interactions on mobile", async () => {
|
||||
patchWithCleanup(transitionConfig, { disabled: true });
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
async onUpdate(data) {
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_indicator").toHaveCount(0);
|
||||
|
||||
await click(".o_pager button.o_pager_next");
|
||||
await animationFrame();
|
||||
await animationFrame(); // transition
|
||||
|
||||
expect(".o_pager_indicator").toHaveCount(1);
|
||||
expect(".o_pager_indicator .o_pager_value").toHaveText("5-8");
|
||||
await runAllTimers();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_pager_indicator").toHaveCount(0);
|
||||
|
||||
await click(".o_pager button.o_pager_previous");
|
||||
await animationFrame();
|
||||
await animationFrame(); // transition
|
||||
|
||||
expect(".o_pager_indicator").toHaveCount(1);
|
||||
expect(".o_pager_indicator .o_pager_value").toHaveText("1-4");
|
||||
await runAllTimers();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_pager_indicator").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("edit the pager", async () => {
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
async onUpdate(data) {
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await click(".o_pager_value");
|
||||
await animationFrame();
|
||||
|
||||
expect("input").toHaveCount(1);
|
||||
expect(".o_pager_counter .o_pager_value").toHaveValue("1-4");
|
||||
|
||||
await contains("input.o_pager_value").edit("1-6");
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
|
||||
expect("input").toHaveCount(0);
|
||||
expect(".o_pager_counter .o_pager_value").toHaveText("1-6");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("keydown on pager with same value", async () => {
|
||||
await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
onUpdate(data) {
|
||||
expect.step("pager-changed");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await click(".o_pager_value");
|
||||
await animationFrame();
|
||||
|
||||
expect("input").toHaveCount(1);
|
||||
expect(".o_pager_counter .o_pager_value").toHaveValue("1-4");
|
||||
expect.verifySteps([]);
|
||||
|
||||
await press("Enter");
|
||||
await animationFrame();
|
||||
expect("input").toHaveCount(0);
|
||||
expect(".o_pager_counter .o_pager_value").toHaveText("1-4");
|
||||
expect.verifySteps(["pager-changed"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("pager value formatting", async () => {
|
||||
expect.assertions(8);
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
async onUpdate(data) {
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_counter .o_pager_value").toHaveText("1-4");
|
||||
|
||||
async function inputAndAssert(inputValue, expected) {
|
||||
await click(".o_pager_counter .o_pager_value");
|
||||
await animationFrame();
|
||||
await contains("input.o_pager_value").edit(inputValue);
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect(".o_pager_counter .o_pager_value").toHaveText(expected);
|
||||
}
|
||||
|
||||
await inputAndAssert("4-4", "4");
|
||||
await inputAndAssert("1-11", "1-10");
|
||||
await inputAndAssert("20-15", "10");
|
||||
await inputAndAssert("6-5", "10");
|
||||
await inputAndAssert("definitelyValidNumber", "10");
|
||||
await inputAndAssert(" 1 , 2 ", "1-2");
|
||||
await inputAndAssert("3 8", "3-8");
|
||||
});
|
||||
|
||||
test("pager disabling", async () => {
|
||||
const reloadPromise = new Deferred();
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
// The goal here is to test the reactivity of the pager; in a
|
||||
// typical views, we disable the pager after switching page
|
||||
// to avoid switching twice with the same action (double click).
|
||||
async onUpdate(data) {
|
||||
// 1. Simulate a (long) server action
|
||||
await reloadPromise;
|
||||
// 2. Update the view with loaded data
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Click and check button is disabled
|
||||
await click(".o_pager button.o_pager_next");
|
||||
await animationFrame();
|
||||
expect(".o_pager button.o_pager_next").toHaveAttribute("disabled");
|
||||
|
||||
await click(".o_pager button.o_pager_previous");
|
||||
await animationFrame();
|
||||
expect(".o_pager button.o_pager_previous").toHaveAttribute("disabled");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("pager disabling on desktop", async () => {
|
||||
const reloadPromise = new Deferred();
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
// The goal here is to test the reactivity of the pager; in a
|
||||
// typical views, we disable the pager after switching page
|
||||
// to avoid switching twice with the same action (double click).
|
||||
async onUpdate(data) {
|
||||
// 1. Simulate a (long) server action
|
||||
await reloadPromise;
|
||||
// 2. Update the view with loaded data
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await click(".o_pager button.o_pager_next");
|
||||
await animationFrame();
|
||||
// Try to edit the pager value
|
||||
await click(".o_pager_value");
|
||||
await animationFrame();
|
||||
|
||||
expect("button").toHaveCount(2);
|
||||
expect("button:nth-child(1)").toHaveAttribute("disabled");
|
||||
expect("button:nth-child(2)").toHaveAttribute("disabled");
|
||||
expect("span.o_pager_value").toHaveCount(1);
|
||||
|
||||
reloadPromise.resolve();
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
|
||||
expect("button").toHaveCount(2);
|
||||
expect("button:nth-child(1)").not.toHaveAttribute("disabled");
|
||||
expect("button:nth-child(2)").not.toHaveAttribute("disabled");
|
||||
expect(".o_pager_counter .o_pager_value").toHaveText("5-8");
|
||||
|
||||
await click(".o_pager_value");
|
||||
await animationFrame();
|
||||
|
||||
expect("input.o_pager_value").toHaveCount(1);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("desktop input interaction", async () => {
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 4,
|
||||
total: 10,
|
||||
async onUpdate(data) {
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
await click(".o_pager_value");
|
||||
await animationFrame();
|
||||
|
||||
expect("input").toHaveCount(1);
|
||||
expect("input").toBeFocused();
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect("input").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("updateTotal props: click on total", async () => {
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
total: 10,
|
||||
onUpdate() {},
|
||||
async updateTotal() {
|
||||
await pager.updateProps({ total: 25, updateTotal: undefined });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_value").toHaveText("1-5");
|
||||
expect(".o_pager_limit").toHaveText("10+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await click(".o_pager_limit_fetch");
|
||||
await animationFrame();
|
||||
expect(".o_pager_value").toHaveText("1-5");
|
||||
expect(".o_pager_limit").toHaveText("25");
|
||||
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("updateTotal props: click next", async () => {
|
||||
let tempTotal = 10;
|
||||
const realTotal = 18;
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
total: tempTotal,
|
||||
async onUpdate(data) {
|
||||
tempTotal = Math.min(realTotal, Math.max(tempTotal, data.offset + data.limit));
|
||||
const nextProps = { ...data, total: tempTotal };
|
||||
if (tempTotal === realTotal) {
|
||||
nextProps.updateTotal = undefined;
|
||||
}
|
||||
await pager.updateProps(nextProps);
|
||||
},
|
||||
updateTotal() {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_value").toHaveText("1-5");
|
||||
expect(".o_pager_limit").toHaveText("10+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await contains(".o_pager_next:enabled").click();
|
||||
|
||||
expect(".o_pager_value").toHaveText("6-10");
|
||||
expect(".o_pager_limit").toHaveText("10+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await contains(".o_pager_next:enabled").click();
|
||||
|
||||
expect(".o_pager_value").toHaveText("11-15");
|
||||
expect(".o_pager_limit").toHaveText("15+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await contains(".o_pager_next:enabled").click();
|
||||
|
||||
expect(".o_pager_value").toHaveText("16-18");
|
||||
expect(".o_pager_limit").toHaveText("18");
|
||||
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("updateTotal props: edit input", async () => {
|
||||
let tempTotal = 10;
|
||||
const realTotal = 18;
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
total: tempTotal,
|
||||
async onUpdate(data) {
|
||||
tempTotal = Math.min(realTotal, Math.max(tempTotal, data.offset + data.limit));
|
||||
const nextProps = { ...data, total: tempTotal };
|
||||
if (tempTotal === realTotal) {
|
||||
nextProps.updateTotal = undefined;
|
||||
}
|
||||
await pager.updateProps(nextProps);
|
||||
},
|
||||
updateTotal() {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_value").toHaveText("1-5");
|
||||
expect(".o_pager_limit").toHaveText("10+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await click(".o_pager_value");
|
||||
await animationFrame();
|
||||
await contains("input.o_pager_value").edit("3-8");
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_pager_value").toHaveText("3-8");
|
||||
expect(".o_pager_limit").toHaveText("10+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await click(".o_pager_value");
|
||||
await animationFrame();
|
||||
await contains("input.o_pager_value").edit("3-20");
|
||||
await click(document.body);
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
expect(".o_pager_value").toHaveText("3-18");
|
||||
expect(".o_pager_limit").toHaveText("18");
|
||||
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("updateTotal props: can use next even if single page", async () => {
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
total: 5,
|
||||
async onUpdate(data) {
|
||||
await pager.updateProps({ ...data, total: 10 });
|
||||
},
|
||||
updateTotal() {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_value").toHaveText("1-5");
|
||||
expect(".o_pager_limit").toHaveText("5+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await click(".o_pager_next");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_pager_value").toHaveText("6-10");
|
||||
expect(".o_pager_limit").toHaveText("10+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("updateTotal props: click previous", async () => {
|
||||
const pager = await mountWithCleanup(PagerController, {
|
||||
props: {
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
total: 10,
|
||||
async onUpdate(data) {
|
||||
await pager.updateProps(data);
|
||||
},
|
||||
async updateTotal() {
|
||||
const total = 23;
|
||||
await pager.updateProps({ total, updateTotal: undefined });
|
||||
return total;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(".o_pager_value").toHaveText("1-5");
|
||||
expect(".o_pager_limit").toHaveText("10+");
|
||||
expect(".o_pager_limit").toHaveClass("o_pager_limit_fetch");
|
||||
|
||||
await click(".o_pager_previous");
|
||||
await animationFrame();
|
||||
await animationFrame(); // double call to updateProps
|
||||
|
||||
expect(".o_pager_value").toHaveText("21-23");
|
||||
expect(".o_pager_limit").toHaveText("23");
|
||||
expect(".o_pager_limit").not.toHaveClass("o_pager_limit_fetch");
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { PagerIndicator } from "@web/core/pager/pager_indicator";
|
||||
import { mountWithCleanup, patchWithCleanup } from "../../web_test_helpers";
|
||||
import { config as transitionConfig } from "@web/core/transition";
|
||||
import { expect, test } from "@odoo/hoot";
|
||||
import { PAGER_UPDATED_EVENT, pagerBus } from "@web/core/pager/pager";
|
||||
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
|
||||
test("displays the pager indicator", async () => {
|
||||
patchWithCleanup(transitionConfig, { disabled: true });
|
||||
await mountWithCleanup(PagerIndicator, { noMainContainer: true });
|
||||
expect(".o_pager_indicator").toHaveCount(0, {
|
||||
message: "the pager indicator should not be displayed",
|
||||
});
|
||||
pagerBus.trigger(PAGER_UPDATED_EVENT, { value: "1-4", total: 10 });
|
||||
await animationFrame();
|
||||
expect(".o_pager_indicator").toHaveCount(1, {
|
||||
message: "the pager indicator should be displayed",
|
||||
});
|
||||
expect(".o_pager_indicator").toHaveText("1-4 / 10");
|
||||
await runAllTimers();
|
||||
await animationFrame();
|
||||
expect(".o_pager_indicator").toHaveCount(0, {
|
||||
message: "the pager indicator should not be displayed",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { click, queryAllTexts, edit, press, animationFrame, runAllTimers } from "@odoo/hoot-dom";
|
||||
import { mockDate } from "@odoo/hoot-mock";
|
||||
import { mountWithCleanup, defineParams } from "@web/../tests/web_test_helpers";
|
||||
import { TimePicker } from "@web/core/time_picker/time_picker";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
|
||||
/**
|
||||
* @param {any} value
|
||||
*/
|
||||
const pad2 = (value) => String(value).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* @template {any} [T=number]
|
||||
* @param {number} length
|
||||
* @param {(index: number) => T} mapping
|
||||
*/
|
||||
const range = (length, mapping = (n) => n) => [...Array(length)].map((_, i) => mapping(i));
|
||||
|
||||
const getTimeOptions = (rounding = 15) => {
|
||||
const _hours = range(24, String);
|
||||
const _minutes = range(60, (i) => i)
|
||||
.filter((i) => i % rounding === 0)
|
||||
.map((i) => pad2(i));
|
||||
return _hours.flatMap((h) => _minutes.map((m) => `${h}:${m}`));
|
||||
};
|
||||
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
time_format: "%H:%M:%S",
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockDate("2023-04-25T12:45:01");
|
||||
});
|
||||
|
||||
test("default params, click on suggestion to select time", async () => {
|
||||
await mountWithCleanup(TimePicker);
|
||||
|
||||
expect(".o_time_picker").toHaveCount(1);
|
||||
expect("input.o_time_picker_input").toHaveValue("0:00");
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(1);
|
||||
expect(queryAllTexts(".o_time_picker_option")).toEqual(getTimeOptions());
|
||||
|
||||
await click(".o_time_picker_option:contains(12:15)");
|
||||
await animationFrame();
|
||||
|
||||
expect("input.o_time_picker_input").toHaveValue("12:15");
|
||||
});
|
||||
|
||||
test("when opening, select the suggestion equals to the props value", async () => {
|
||||
await mountWithCleanup(TimePicker, {
|
||||
props: {
|
||||
value: "12:30",
|
||||
},
|
||||
});
|
||||
|
||||
expect("input.o_time_picker_input").toHaveValue("12:30");
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(1);
|
||||
expect(queryAllTexts(".o_time_picker_option")).toEqual(getTimeOptions());
|
||||
expect(".o_time_picker_option:contains(12:30)").toHaveClass("focus");
|
||||
});
|
||||
|
||||
test("onChange only triggers if the value has changed", async () => {
|
||||
await mountWithCleanup(TimePicker, {
|
||||
props: {
|
||||
value: "12:15",
|
||||
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
|
||||
},
|
||||
});
|
||||
|
||||
expect("input.o_time_picker_input").toHaveValue("12:15");
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
await click(".o_time_picker_option:contains(12:15)");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(0);
|
||||
expect("input.o_time_picker_input").toHaveValue("12:15");
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
await click(".o_time_picker_option:contains(12:30)");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(0);
|
||||
expect("input.o_time_picker_input").toHaveValue("12:30");
|
||||
expect.verifySteps(["12:30"]);
|
||||
});
|
||||
|
||||
test("seconds only shown and usable when 'showSeconds' is true", async () => {
|
||||
await mountWithCleanup(TimePicker, {
|
||||
props: {
|
||||
showSeconds: true,
|
||||
onChange: (value) => expect.step(`${value.hour}:${value.minute}:${value.second}`),
|
||||
},
|
||||
});
|
||||
|
||||
expect("input.o_time_picker_input").toHaveValue("0:00:00");
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
|
||||
await click(".o_time_picker_option:contains(12:15)");
|
||||
await animationFrame();
|
||||
|
||||
expect("input.o_time_picker_input").toHaveValue("12:15:00");
|
||||
expect.verifySteps(["12:15:0"]);
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await edit("15:25:33", { confirm: "enter" });
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveValue("15:25:33");
|
||||
expect.verifySteps(["15:25:33"]);
|
||||
});
|
||||
|
||||
test("handle 12h (am/pm) time format", async () => {
|
||||
defineParams({
|
||||
lang_parameters: {
|
||||
time_format: "hh:mm:ss a",
|
||||
},
|
||||
});
|
||||
|
||||
await mountWithCleanup(TimePicker, {
|
||||
props: {
|
||||
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
|
||||
},
|
||||
});
|
||||
|
||||
expect("input.o_time_picker_input").toHaveValue("12:00am");
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
|
||||
const M = range(60, (i) => i)
|
||||
.filter((i) => i % 15 === 0)
|
||||
.map((i) => pad2(i));
|
||||
const H = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const options = [];
|
||||
["am", "pm"].forEach((a) => H.forEach((h) => M.forEach((m) => options.push(`${h}:${m}${a}`))));
|
||||
expect(queryAllTexts(".o_time_picker_dropdown .o_time_picker_option")).toEqual(options);
|
||||
|
||||
await edit("4:15pm", { confirm: "enter" });
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveValue("4:15pm");
|
||||
// actual data is always in 24h format
|
||||
expect.verifySteps(["16:15"]);
|
||||
|
||||
await edit("8:30", { confirm: "enter" });
|
||||
await animationFrame();
|
||||
// default to am when no meridiem is provided
|
||||
expect("input.o_time_picker_input").toHaveValue("8:30am");
|
||||
expect.verifySteps(["8:30"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("validity updated on input and cannot apply non-valid time strings", async () => {
|
||||
await mountWithCleanup(TimePicker, {
|
||||
props: {
|
||||
onChange: () => expect.step("change"),
|
||||
},
|
||||
});
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
|
||||
await edit("gg ez", { confirm: false });
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveClass("o_invalid");
|
||||
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect.verifySteps([]);
|
||||
|
||||
await edit("12:30", { confirm: false });
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").not.toHaveClass("o_invalid");
|
||||
expect.verifySteps([]);
|
||||
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["change"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("arrow keys navigation, enter selects items, up/down arrow updates the input value", async () => {
|
||||
await mountWithCleanup(TimePicker, {
|
||||
props: {
|
||||
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
|
||||
},
|
||||
});
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveValue("0:00");
|
||||
|
||||
await press("arrowdown");
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveValue("0:15");
|
||||
|
||||
await press("arrowup");
|
||||
await press("arrowup");
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveValue("23:45");
|
||||
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["23:45"]);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("if typing after navigating, enter validates input value", async () => {
|
||||
await mountWithCleanup(TimePicker, {
|
||||
props: {
|
||||
onChange: (value) => expect.step(`${value.hour}:${value.minute}`),
|
||||
},
|
||||
});
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
|
||||
await press("arrowdown");
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveValue("0:15");
|
||||
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
// Enter selects the navigated item
|
||||
expect.verifySteps(["0:15"]);
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
|
||||
await press("arrowdown");
|
||||
await press("arrowdown");
|
||||
await animationFrame();
|
||||
expect("input.o_time_picker_input").toHaveValue("0:45");
|
||||
|
||||
await edit("12:5", { confirm: false });
|
||||
await press("enter");
|
||||
await animationFrame();
|
||||
// Enter validates the edited input
|
||||
expect.verifySteps(["12:50"]);
|
||||
});
|
||||
|
||||
test("typing a value that is in the suggestions will focus it in the dropdown", async () => {
|
||||
await mountWithCleanup(TimePicker);
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
await runAllTimers();
|
||||
expect(".o_time_picker_option.focus").toHaveText("0:00");
|
||||
|
||||
await edit("12:3", { confirm: false });
|
||||
await animationFrame();
|
||||
expect(".o_time_picker_option.focus").toHaveText("12:30");
|
||||
expect(".o_time_picker_option.focus").toBeVisible();
|
||||
});
|
||||
|
||||
test("false, null and undefined are accepted values", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { TimePicker };
|
||||
static props = {};
|
||||
static template = xml`<TimePicker value="state.value"/>`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const comp = await mountWithCleanup(Parent);
|
||||
expect(".o_time_picker_input").toHaveValue("");
|
||||
|
||||
comp.state.value = false;
|
||||
await runAllTimers();
|
||||
expect(".o_time_picker_input").toHaveValue("");
|
||||
|
||||
comp.state.value = undefined;
|
||||
await runAllTimers();
|
||||
expect(".o_time_picker_input").toHaveValue("0:00");
|
||||
});
|
||||
|
||||
test("click-out triggers onChange", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { TimePicker, Dropdown };
|
||||
static props = {};
|
||||
static template = xml`
|
||||
<div>
|
||||
<Dropdown>
|
||||
<button class="open">Open</button>
|
||||
<t t-set-slot="content">
|
||||
<TimePicker onChange.bind="onChange"/>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<button class="outside">Outside</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
onChange(value) {
|
||||
expect.step(`${value.hour}:${value.minute}`);
|
||||
}
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
await click(".open");
|
||||
await animationFrame();
|
||||
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
expect(".o_time_picker_option.focus").toHaveText("0:00");
|
||||
|
||||
await edit("12:3", { confirm: false });
|
||||
await animationFrame();
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".outside");
|
||||
await animationFrame();
|
||||
expect(".o-dropdown--menu.o_time_picker_dropdown").toHaveCount(0);
|
||||
expect.verifySteps(["12:30"]);
|
||||
});
|
||||
|
||||
test("changing the props value updates the input", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { TimePicker };
|
||||
static props = {};
|
||||
static template = xml`<TimePicker value="state.value" onChange.bind="onChange"/>`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: null,
|
||||
});
|
||||
}
|
||||
|
||||
onChange(value) {
|
||||
expect.step(`${value.hour}:${value.minute}`);
|
||||
}
|
||||
}
|
||||
|
||||
const comp = await mountWithCleanup(Parent);
|
||||
expect(".o_time_picker_input").toHaveValue("");
|
||||
|
||||
// Set value from props
|
||||
comp.state.value = "12:00";
|
||||
await runAllTimers();
|
||||
expect(".o_time_picker_input").toHaveValue("12:00");
|
||||
expect.verifySteps([]);
|
||||
|
||||
// Set value by clicking
|
||||
await click(".o_time_picker_input");
|
||||
await animationFrame();
|
||||
await click(`.o_time_picker_option:contains("11:30")`);
|
||||
await animationFrame();
|
||||
await runAllTimers();
|
||||
expect.verifySteps(["11:30"]);
|
||||
|
||||
// Set falsy value from props
|
||||
comp.state.value = false;
|
||||
await runAllTimers();
|
||||
expect(".o_time_picker_input").toHaveValue("");
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
|
||||
test("ensure placeholder is customizable", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { TimePicker };
|
||||
static props = {};
|
||||
static template = xml`<TimePicker placeholder="state.placeholder"/>`;
|
||||
|
||||
setup() {
|
||||
this.state = useState({ placeholder: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
const comp = await mountWithCleanup(Parent);
|
||||
await animationFrame();
|
||||
expect(".o_time_picker_input").toHaveAttribute("placeholder", "hh:mm");
|
||||
|
||||
comp.state.placeholder = "your time";
|
||||
await animationFrame();
|
||||
expect(".o_time_picker_input").toHaveAttribute("placeholder", "your time");
|
||||
});
|
||||
|
||||
test("add a custom class", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { TimePicker };
|
||||
static props = {};
|
||||
static template = xml`<TimePicker cssClass="'o_custom_class'"/>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_time_picker").toHaveClass("o_custom_class");
|
||||
});
|
||||
|
||||
test("add a custom input class", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { TimePicker };
|
||||
static props = {};
|
||||
static template = xml`<TimePicker inputCssClass="'o_custom_class'"/>`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_time_picker_input").toHaveClass("o_custom_class");
|
||||
});
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
import { expect, test, describe, destroy } from "@odoo/hoot";
|
||||
import { tick, Deferred } from "@odoo/hoot-mock";
|
||||
import { press } from "@odoo/hoot-dom";
|
||||
import { mountWithCleanup, contains, makeDialogMockEnv } from "@web/../tests/web_test_helpers";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("check content confirmation dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {},
|
||||
cancel: () => {},
|
||||
},
|
||||
});
|
||||
expect(".modal-header").toHaveText("Confirmation");
|
||||
expect(".modal-body").toHaveText("Some content");
|
||||
});
|
||||
|
||||
test("Without dismiss callback: pressing escape to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await press("escape");
|
||||
await tick();
|
||||
expect.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
test("With dismiss callback: pressing escape to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
dismiss: () => {
|
||||
expect.step("Dismiss action");
|
||||
},
|
||||
},
|
||||
});
|
||||
await press("escape");
|
||||
await tick();
|
||||
expect.verifySteps(["Dismiss action", "Close action"]);
|
||||
});
|
||||
|
||||
test("Without dismiss callback: clicking on 'X' to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-header .btn-close").click();
|
||||
expect.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
test("With dismiss callback: clicking on 'X' to close the dialog", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
dismiss: () => {
|
||||
expect.step("Dismiss action");
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-header .btn-close").click();
|
||||
expect.verifySteps(["Dismiss action", "Close action"]);
|
||||
});
|
||||
|
||||
test("clicking on 'Ok'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect.verifySteps(["Confirm action", "Close action"]);
|
||||
});
|
||||
|
||||
test("clicking on 'Cancel'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await contains(".modal-footer .btn-secondary").click();
|
||||
expect.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
test("hotkey on 'Ok'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await press("alt+q");
|
||||
await tick();
|
||||
expect.verifySteps(["Confirm action", "Close action"]);
|
||||
});
|
||||
|
||||
test("hotkey on 'Cancel'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
await press("alt+x");
|
||||
await tick();
|
||||
expect.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
test("can't click twice on 'Ok'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
},
|
||||
cancel: () => {},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
expect(".modal-footer .btn-primary").not.toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").not.toHaveAttribute("disabled");
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect(".modal-footer .btn-primary").toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").toHaveAttribute("disabled");
|
||||
expect.verifySteps(["Confirm action"]);
|
||||
});
|
||||
|
||||
test("can't click twice on 'Cancel'", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {},
|
||||
cancel: () => {
|
||||
expect.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect.verifySteps([]);
|
||||
expect(".modal-footer .btn-primary").not.toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").not.toHaveAttribute("disabled");
|
||||
await contains(".modal-footer .btn-secondary").click();
|
||||
expect(".modal-footer .btn-primary").toHaveAttribute("disabled");
|
||||
expect(".modal-footer .btn-secondary").toHaveAttribute("disabled");
|
||||
expect.verifySteps(["Cancel action"]);
|
||||
});
|
||||
|
||||
test("can't cancel (with escape) after confirm", async () => {
|
||||
const def = new Deferred();
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
return def;
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not cancel");
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect.verifySteps(["Confirm action"]);
|
||||
await press("escape");
|
||||
await tick();
|
||||
expect.verifySteps([]);
|
||||
def.resolve();
|
||||
await tick();
|
||||
expect.verifySteps(["Close action"]);
|
||||
});
|
||||
|
||||
test("wait for confirm callback before closing", async () => {
|
||||
const def = new Deferred();
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
expect.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
expect.step("Confirm action");
|
||||
return def;
|
||||
},
|
||||
},
|
||||
});
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect.verifySteps(["Confirm action"]);
|
||||
def.resolve();
|
||||
await tick();
|
||||
expect.verifySteps(["Close action"]);
|
||||
});
|
||||
|
||||
test("Focus is correctly restored after confirmation", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
|
||||
class Parent extends Component {
|
||||
static template = xml`<div class="my-comp"><input type="text" class="my-input"/></div>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent, { env });
|
||||
await contains(".my-input").focus();
|
||||
expect(".my-input").toBeFocused();
|
||||
|
||||
const dialog = await mountWithCleanup(ConfirmationDialog, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
confirm: () => {},
|
||||
close: () => {},
|
||||
},
|
||||
});
|
||||
expect(".modal-footer .btn-primary").toBeFocused();
|
||||
await contains(".modal-footer .btn-primary").click();
|
||||
expect(document.body).toBeFocused();
|
||||
destroy(dialog);
|
||||
await Promise.resolve();
|
||||
expect(".my-input").toBeFocused();
|
||||
});
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import { click, getFixture, makeDeferred, mount, nextTick, triggerHotkey } from "../helpers/utils";
|
||||
import { makeFakeDialogService } from "../helpers/mock_services";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
let target;
|
||||
|
||||
async function makeDialogTestEnv() {
|
||||
const env = await makeTestEnv();
|
||||
env.dialogData = {
|
||||
isActive: true,
|
||||
close: () => {},
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
QUnit.module("Components", (hooks) => {
|
||||
hooks.beforeEach(async (assert) => {
|
||||
target = getFixture();
|
||||
async function addDialog(dialogClass, props) {
|
||||
assert.strictEqual(props.body, "Some content");
|
||||
assert.strictEqual(props.title, "Confirmation");
|
||||
}
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
serviceRegistry.add("ui", uiService);
|
||||
serviceRegistry.add("dialog", makeFakeDialogService(addDialog), { force: true });
|
||||
});
|
||||
|
||||
QUnit.module("ConfirmationDialog");
|
||||
|
||||
QUnit.test("pressing escape to close the dialog", async function (assert) {
|
||||
const env = await makeDialogTestEnv();
|
||||
await mount(ConfirmationDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
assert.step("Close action");
|
||||
},
|
||||
confirm: () => {},
|
||||
cancel: () => {
|
||||
assert.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.verifySteps([]);
|
||||
triggerHotkey("escape");
|
||||
await nextTick();
|
||||
assert.verifySteps(
|
||||
["Cancel action", "Close action"],
|
||||
"dialog has called its cancel method before its closure"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("clicking on 'Ok'", async function (assert) {
|
||||
const env = await makeDialogTestEnv();
|
||||
await mount(ConfirmationDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
assert.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
assert.step("Confirm action");
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.verifySteps([]);
|
||||
await click(target, ".modal-footer .btn-primary");
|
||||
assert.verifySteps(["Confirm action", "Close action"]);
|
||||
});
|
||||
|
||||
QUnit.test("clicking on 'Cancel'", async function (assert) {
|
||||
const env = await makeDialogTestEnv();
|
||||
await mount(ConfirmationDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
assert.step("Close action");
|
||||
},
|
||||
confirm: () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
cancel: () => {
|
||||
assert.step("Cancel action");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.verifySteps([]);
|
||||
await click(target, ".modal-footer .btn-secondary");
|
||||
assert.verifySteps(["Cancel action", "Close action"]);
|
||||
});
|
||||
|
||||
QUnit.test("can't click twice on 'Ok'", async function (assert) {
|
||||
const env = await makeDialogTestEnv();
|
||||
await mount(ConfirmationDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {
|
||||
assert.step("Confirm");
|
||||
},
|
||||
cancel: () => {},
|
||||
},
|
||||
});
|
||||
assert.notOk(target.querySelector(".modal-footer .btn-primary").disabled);
|
||||
assert.notOk(target.querySelector(".modal-footer .btn-secondary").disabled);
|
||||
click(target, ".modal-footer .btn-primary");
|
||||
assert.ok(target.querySelector(".modal-footer .btn-primary").disabled);
|
||||
assert.ok(target.querySelector(".modal-footer .btn-secondary").disabled);
|
||||
assert.verifySteps(["Confirm"]);
|
||||
});
|
||||
|
||||
QUnit.test("can't click twice on 'Cancel'", async function (assert) {
|
||||
const env = await makeDialogTestEnv();
|
||||
await mount(ConfirmationDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {},
|
||||
confirm: () => {},
|
||||
cancel: () => {
|
||||
assert.step("Cancel");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.notOk(target.querySelector(".modal-footer .btn-primary").disabled);
|
||||
assert.notOk(target.querySelector(".modal-footer .btn-secondary").disabled);
|
||||
click(target, ".modal-footer .btn-secondary");
|
||||
assert.ok(target.querySelector(".modal-footer .btn-primary").disabled);
|
||||
assert.ok(target.querySelector(".modal-footer .btn-secondary").disabled);
|
||||
assert.verifySteps(["Cancel"]);
|
||||
});
|
||||
|
||||
QUnit.test("can't cancel (with escape) after confirm", async function (assert) {
|
||||
const def = makeDeferred();
|
||||
const env = await makeDialogTestEnv();
|
||||
await mount(ConfirmationDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
assert.step("close");
|
||||
},
|
||||
confirm: () => {
|
||||
assert.step("confirm");
|
||||
return def;
|
||||
},
|
||||
cancel: () => {
|
||||
throw new Error("should not cancel");
|
||||
},
|
||||
},
|
||||
});
|
||||
await click(target, ".modal-footer .btn-primary");
|
||||
assert.verifySteps(["confirm"]);
|
||||
triggerHotkey("escape");
|
||||
await nextTick();
|
||||
assert.verifySteps([]);
|
||||
def.resolve();
|
||||
await nextTick();
|
||||
assert.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
QUnit.test("wait for confirm callback before closing", async function (assert) {
|
||||
const env = await makeDialogTestEnv();
|
||||
const def = makeDeferred();
|
||||
await mount(ConfirmationDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
body: "Some content",
|
||||
title: "Confirmation",
|
||||
close: () => {
|
||||
assert.step("close");
|
||||
},
|
||||
confirm: () => {
|
||||
assert.step("confirm");
|
||||
return def;
|
||||
},
|
||||
},
|
||||
});
|
||||
await click(target, ".modal-footer .btn-primary");
|
||||
assert.verifySteps(["confirm"]);
|
||||
def.resolve();
|
||||
await nextTick();
|
||||
assert.verifySteps(["close"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { evalPartialContext, makeContext } from "@web/core/context";
|
||||
import { expect, test, describe } from "@odoo/hoot";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
describe("makeContext", () => {
|
||||
test("return empty context", () => {
|
||||
expect(makeContext([])).toEqual({});
|
||||
});
|
||||
|
||||
test("duplicate a context", () => {
|
||||
const ctx1 = { a: 1 };
|
||||
const ctx2 = makeContext([ctx1]);
|
||||
expect(ctx1).not.toBe(ctx2);
|
||||
expect(ctx1).toEqual(ctx2);
|
||||
});
|
||||
|
||||
test("can accept undefined or empty string", () => {
|
||||
expect(makeContext([undefined])).toEqual({});
|
||||
expect(makeContext([{ a: 1 }, undefined, { b: 2 }])).toEqual({ a: 1, b: 2 });
|
||||
expect(makeContext([""])).toEqual({});
|
||||
expect(makeContext([{ a: 1 }, "", { b: 2 }])).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
test("evaluate strings", () => {
|
||||
expect(makeContext(["{'a': 33}"])).toEqual({ a: 33 });
|
||||
});
|
||||
|
||||
test("evaluated context is used as evaluation context along the way", () => {
|
||||
expect(makeContext([{ a: 1 }, "{'a': a + 1}"])).toEqual({ a: 2 });
|
||||
expect(makeContext([{ a: 1 }, "{'b': a + 1}"])).toEqual({ a: 1, b: 2 });
|
||||
expect(makeContext([{ a: 1 }, "{'b': a + 1}", "{'c': b + 1}"])).toEqual({
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
});
|
||||
expect(makeContext([{ a: 1 }, "{'b': a + 1}", "{'a': b + 1}"])).toEqual({ a: 3, b: 2 });
|
||||
});
|
||||
|
||||
test("initial evaluation context", () => {
|
||||
expect(makeContext(["{'a': a + 1}"], { a: 1 })).toEqual({ a: 2 });
|
||||
expect(makeContext(["{'b': a + 1}"], { a: 1 })).toEqual({ b: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("evalPartialContext", () => {
|
||||
test("static contexts", () => {
|
||||
expect(evalPartialContext("{}", {})).toEqual({});
|
||||
expect(evalPartialContext("{'a': 1}", {})).toEqual({ a: 1 });
|
||||
expect(evalPartialContext("{'a': 'b'}", {})).toEqual({ a: "b" });
|
||||
expect(evalPartialContext("{'a': true}", {})).toEqual({ a: true });
|
||||
expect(evalPartialContext("{'a': None}", {})).toEqual({ a: null });
|
||||
});
|
||||
|
||||
test("complete dynamic contexts", () => {
|
||||
expect(evalPartialContext("{'a': a, 'b': 1}", { a: 2 })).toEqual({ a: 2, b: 1 });
|
||||
});
|
||||
|
||||
test("partial dynamic contexts", () => {
|
||||
expect(evalPartialContext("{'a': a}", {})).toEqual({});
|
||||
expect(evalPartialContext("{'a': a, 'b': 1}", {})).toEqual({ b: 1 });
|
||||
expect(evalPartialContext("{'a': a, 'b': b}", { a: 2 })).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
test("value of type obj (15)", () => {
|
||||
expect(evalPartialContext("{'a': a.b.c}", {})).toEqual({});
|
||||
expect(evalPartialContext("{'a': a.b.c}", { a: {} })).toEqual({});
|
||||
expect(evalPartialContext("{'a': a.b.c}", { a: { b: { c: 2 } } })).toEqual({ a: 2 });
|
||||
});
|
||||
|
||||
test("value of type op (14)", () => {
|
||||
expect(evalPartialContext("{'a': a + 1}", {})).toEqual({});
|
||||
expect(evalPartialContext("{'a': a + b}", {})).toEqual({});
|
||||
expect(evalPartialContext("{'a': a + b}", { a: 2 })).toEqual({});
|
||||
expect(evalPartialContext("{'a': a + 1}", { a: 2 })).toEqual({ a: 3 });
|
||||
expect(evalPartialContext("{'a': a + b}", { a: 2, b: 3 })).toEqual({ a: 5 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { makeContext } from "@web/core/context";
|
||||
|
||||
QUnit.module("utils", {}, () => {
|
||||
QUnit.module("makeContext");
|
||||
|
||||
QUnit.test("return empty context", (assert) => {
|
||||
assert.deepEqual(makeContext([]), {});
|
||||
});
|
||||
|
||||
QUnit.test("duplicate a context", (assert) => {
|
||||
const ctx1 = { a: 1 };
|
||||
const ctx2 = makeContext([ctx1]);
|
||||
assert.notStrictEqual(ctx1, ctx2);
|
||||
assert.deepEqual(ctx1, ctx2);
|
||||
});
|
||||
|
||||
QUnit.test("can accept undefined or empty string", (assert) => {
|
||||
assert.deepEqual(makeContext([undefined]), {});
|
||||
assert.deepEqual(makeContext([{ a: 1 }, undefined, { b: 2 }]), { a: 1, b: 2 });
|
||||
assert.deepEqual(makeContext([""]), {});
|
||||
assert.deepEqual(makeContext([{ a: 1 }, "", { b: 2 }]), { a: 1, b: 2 });
|
||||
});
|
||||
|
||||
QUnit.test("evaluate strings", (assert) => {
|
||||
assert.deepEqual(makeContext(["{'a': 33}"]), { a: 33 });
|
||||
});
|
||||
|
||||
QUnit.test("evaluated context is used as evaluation context along the way", (assert) => {
|
||||
assert.deepEqual(makeContext([{ a: 1 }, "{'a': a + 1}"]), { a: 2 });
|
||||
assert.deepEqual(makeContext([{ a: 1 }, "{'b': a + 1}"]), { a: 1, b: 2 });
|
||||
assert.deepEqual(makeContext([{ a: 1 }, "{'b': a + 1}", "{'c': b + 1}"]), {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: 3,
|
||||
});
|
||||
assert.deepEqual(makeContext([{ a: 1 }, "{'b': a + 1}", "{'a': b + 1}"]), { a: 3, b: 2 });
|
||||
});
|
||||
|
||||
QUnit.test("initial evaluation context", (assert) => {
|
||||
assert.deepEqual(makeContext(["{'a': a + 1}"], { a: 1 }), { a: 2 });
|
||||
assert.deepEqual(makeContext(["{'b': a + 1}"], { a: 1 }), { b: 2 });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { makeMockEnv, serverState } from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { formatCurrency } from "@web/core/currency";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
beforeEach(async () => {
|
||||
await makeMockEnv(); // To start the localization service
|
||||
});
|
||||
|
||||
test("formatCurrency", async () => {
|
||||
serverState.currencies = [
|
||||
{ id: 1, position: "after", symbol: "€" },
|
||||
{ id: 2, position: "before", symbol: "$" },
|
||||
];
|
||||
|
||||
expect(formatCurrency(200)).toBe("200.00");
|
||||
expect(formatCurrency(1234567.654, 1)).toBe("1,234,567.65\u00a0€");
|
||||
expect(formatCurrency(1234567.654, 2)).toBe("$\u00a01,234,567.65");
|
||||
expect(formatCurrency(1234567.654, 44)).toBe("1,234,567.65");
|
||||
expect(formatCurrency(1234567.654, 1, { noSymbol: true })).toBe("1,234,567.65");
|
||||
expect(formatCurrency(8.0, 1, { humanReadable: true })).toBe("8.00\u00a0€");
|
||||
expect(formatCurrency(1234567.654, 1, { humanReadable: true })).toBe("1.23M\u00a0€");
|
||||
expect(formatCurrency(1990000.001, 1, { humanReadable: true })).toBe("1.99M\u00a0€");
|
||||
expect(formatCurrency(1234567.654, 44, { digits: [69, 1] })).toBe("1,234,567.7");
|
||||
expect(formatCurrency(1234567.654, 2, { digits: [69, 1] })).toBe("$\u00a01,234,567.7", {
|
||||
message: "options digits should take over currency digits when both are defined",
|
||||
});
|
||||
});
|
||||
|
||||
test("formatCurrency without currency", async () => {
|
||||
serverState.currencies = [];
|
||||
|
||||
expect(formatCurrency(1234567.654, 10, { humanReadable: true })).toBe("1.23M");
|
||||
expect(formatCurrency(1234567.654, 10)).toBe("1,234,567.65");
|
||||
});
|
||||
|
|
@ -1,806 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { applyFilter, toggleMenu } from "@web/../tests/search/helpers";
|
||||
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import ActionModel from "web.ActionModel";
|
||||
import CustomFilterItem from "web.CustomFilterItem";
|
||||
import { createComponent } from "web.test_utils";
|
||||
import { editSelect } from "web.test_utils_fields";
|
||||
import { registerCleanup } from "../helpers/cleanup";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import { makeFakeLocalizationService } from "../helpers/mock_services";
|
||||
import {
|
||||
click,
|
||||
editInput,
|
||||
getFixture,
|
||||
mount,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
triggerEvent,
|
||||
} from "../helpers/utils";
|
||||
|
||||
const { DateTime, Settings } = luxon;
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let target;
|
||||
|
||||
/**
|
||||
* @param {typeof DatePicker} Picker
|
||||
* @param {Object} props
|
||||
* @returns {Promise<DatePicker>}
|
||||
*/
|
||||
async function mountPicker(Picker, props) {
|
||||
serviceRegistry
|
||||
.add(
|
||||
"localization",
|
||||
makeFakeLocalizationService({
|
||||
dateFormat: "dd/MM/yyyy",
|
||||
dateTimeFormat: "dd/MM/yyyy HH:mm:ss",
|
||||
})
|
||||
)
|
||||
.add("ui", uiService)
|
||||
.add("hotkey", hotkeyService);
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState(props);
|
||||
}
|
||||
|
||||
onDateChange(date) {
|
||||
if (props.onDateTimeChanged) {
|
||||
props.onDateTimeChanged(date);
|
||||
}
|
||||
this.state.date = date;
|
||||
}
|
||||
}
|
||||
Parent.template = xml/* xml */ `
|
||||
<t t-component="props.Picker" t-props="state" onDateTimeChanged.bind="onDateChange" />
|
||||
`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
return await mount(Parent, target, { env, props: { Picker } });
|
||||
}
|
||||
|
||||
function useFRLocale() {
|
||||
if (!window.moment.locales().includes("fr")) {
|
||||
// Mocks the FR locale if not loaded
|
||||
const originalLocale = window.moment.locale();
|
||||
window.moment.defineLocale("fr", {
|
||||
months: "janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre".split(
|
||||
"_"
|
||||
),
|
||||
monthsShort: "janv._févr._mars_avr._mai_juin_juil._août_sept._oct._nov._déc.".split(
|
||||
"_"
|
||||
),
|
||||
code: "fr",
|
||||
monthsParseExact: true,
|
||||
week: { dow: 1, doy: 4 },
|
||||
});
|
||||
// Moment automatically assigns newly defined locales.
|
||||
window.moment.locale(originalLocale);
|
||||
registerCleanup(() => window.moment.updateLocale("fr", null));
|
||||
}
|
||||
return "fr";
|
||||
}
|
||||
|
||||
var symbolMap = {
|
||||
1: "૧",
|
||||
2: "૨",
|
||||
3: "૩",
|
||||
4: "૪",
|
||||
5: "૫",
|
||||
6: "૬",
|
||||
7: "૭",
|
||||
8: "૮",
|
||||
9: "૯",
|
||||
0: "૦",
|
||||
};
|
||||
var numberMap = {
|
||||
"૧": "1",
|
||||
"૨": "2",
|
||||
"૩": "3",
|
||||
"૪": "4",
|
||||
"૫": "5",
|
||||
"૬": "6",
|
||||
"૭": "7",
|
||||
"૮": "8",
|
||||
"૯": "9",
|
||||
"૦": "0",
|
||||
};
|
||||
|
||||
function useGULocale() {
|
||||
if (!window.moment.locales().includes("gu")) {
|
||||
const originalLocale = window.moment.locale();
|
||||
window.moment.defineLocale("gu", {
|
||||
months: "જાન્યુઆરી_ફેબ્રુઆરી_માર્ચ_એપ્રિલ_મે_જૂન_જુલાઈ_ઑગસ્ટ_સપ્ટેમ્બર_ઑક્ટ્બર_નવેમ્બર_ડિસેમ્બર".split(
|
||||
"_"
|
||||
),
|
||||
monthsShort: "જાન્યુ._ફેબ્રુ._માર્ચ_એપ્રિ._મે_જૂન_જુલા._ઑગ._સપ્ટે._ઑક્ટ્._નવે._ડિસે.".split(
|
||||
"_"
|
||||
),
|
||||
monthsParseExact: true,
|
||||
week: {
|
||||
dow: 0, // Sunday is the first day of the week.
|
||||
doy: 6, // The week that contains Jan 1st is the first week of the year.
|
||||
},
|
||||
preparse: function (string) {
|
||||
return string.replace(/[૧૨૩૪૫૬૭૮૯૦]/g, function (match) {
|
||||
return numberMap[match];
|
||||
});
|
||||
},
|
||||
postformat: function (string) {
|
||||
return string.replace(/\d/g, function (match) {
|
||||
return symbolMap[match];
|
||||
});
|
||||
},
|
||||
});
|
||||
// Moment automatically assigns newly defined locales.
|
||||
window.moment.locale(originalLocale);
|
||||
registerCleanup(() => window.moment.updateLocale("gu", null));
|
||||
}
|
||||
return "gu";
|
||||
}
|
||||
|
||||
function useNOLocale() {
|
||||
if (!window.moment.locales().includes("nb")) {
|
||||
const originalLocale = window.moment.locale();
|
||||
window.moment.defineLocale("nb", {
|
||||
months: "januar_februar_mars_april_mai_juni_juli_august_september_oktober_november_desember".split(
|
||||
"_"
|
||||
),
|
||||
monthsShort: "jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.".split("_"),
|
||||
monthsParseExact: true,
|
||||
week: {
|
||||
dow: 1, // Monday is the first day of the week.
|
||||
doy: 4, // The week that contains Jan 4th is the first week of the year.
|
||||
},
|
||||
});
|
||||
// Moment automatically assigns newly defined locales.
|
||||
window.moment.locale(originalLocale);
|
||||
registerCleanup(() => window.moment.updateLocale("nb", null));
|
||||
}
|
||||
return "nb";
|
||||
}
|
||||
|
||||
QUnit.module("Components", ({ beforeEach }) => {
|
||||
beforeEach(() => {
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.module("DatePicker");
|
||||
|
||||
QUnit.test("basic rendering", async function (assert) {
|
||||
assert.expect(8);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
|
||||
});
|
||||
|
||||
assert.containsOnce(target, "input.o_input.o_datepicker_input");
|
||||
assert.containsOnce(target, "span.o_datepicker_button");
|
||||
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
|
||||
|
||||
const datePicker = target.querySelector(".o_datepicker");
|
||||
const input = datePicker.querySelector("input.o_input.o_datepicker_input");
|
||||
assert.strictEqual(input.value, "09/01/1997", "Value should be the one given");
|
||||
assert.strictEqual(
|
||||
datePicker.dataset.targetInput,
|
||||
`#${datePicker.querySelector("input[type=hidden]").id}`,
|
||||
"DatePicker id should match its input target"
|
||||
);
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
|
||||
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
|
||||
assert.strictEqual(
|
||||
document.querySelector(".datepicker .day.active").dataset.day,
|
||||
"01/09/1997",
|
||||
"Datepicker should have set the correct day"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("pick a date", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy"),
|
||||
"08/02/1997",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
await click(input);
|
||||
await click(document.querySelector(".datepicker th.next")); // next month
|
||||
|
||||
assert.verifySteps([]);
|
||||
|
||||
await click(document.querySelectorAll(".datepicker table td")[15]); // previous day
|
||||
|
||||
assert.strictEqual(input.value, "08/02/1997");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("pick a date with locale (locale given in props)", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
|
||||
format: "dd MMM, yyyy",
|
||||
locale: useFRLocale(),
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy"),
|
||||
"01/09/1997",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "09 janv., 1997");
|
||||
|
||||
await click(input);
|
||||
await click(document.querySelector(".datepicker .picker-switch")); // month picker
|
||||
await click(document.querySelectorAll(".datepicker .month")[8]); // september
|
||||
await click(document.querySelector(".datepicker .day")); // first day
|
||||
|
||||
assert.strictEqual(input.value, "01 sept., 1997");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("pick a date with locale (locale from date props)", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", {
|
||||
zone: "utc",
|
||||
locale: useFRLocale(),
|
||||
}),
|
||||
format: "dd MMM, yyyy",
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy"),
|
||||
"01/09/1997",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "09 janv., 1997");
|
||||
|
||||
await click(input);
|
||||
await click(document.querySelector(".datepicker .picker-switch")); // month picker
|
||||
await click(document.querySelectorAll(".datepicker .month")[8]); // september
|
||||
await click(document.querySelector(".datepicker .day")); // first day
|
||||
|
||||
assert.strictEqual(input.value, "01 sept., 1997");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("pick a date with locale (locale with different symbols)", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", {
|
||||
zone: "utc",
|
||||
locale: useGULocale(),
|
||||
}),
|
||||
format: "dd MMM, yyyy",
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy"),
|
||||
"01/09/1997",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "09 જાન્યુ, 1997");
|
||||
|
||||
await click(document.querySelector(".datepicker .picker-switch")); // month picker
|
||||
await click(document.querySelectorAll(".datepicker .month")[8]); // september
|
||||
await click(document.querySelectorAll(".datepicker .day")[1]); // first day of september
|
||||
|
||||
assert.strictEqual(input.value, "01 સપ્ટે, 1997");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("enter a date value", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy"),
|
||||
"08/02/1997",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.verifySteps([]);
|
||||
|
||||
input.value = "08/02/1997";
|
||||
await triggerEvent(target, ".o_datepicker_input", "change");
|
||||
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector(".datepicker .day.active").dataset.day,
|
||||
"02/08/1997",
|
||||
"Datepicker should have set the correct day"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Date format is correctly set", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
|
||||
format: "yyyy/MM/dd",
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "1997/01/09");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "1997/01/09");
|
||||
});
|
||||
|
||||
QUnit.test("Validate input date with 'Enter'", async (assert) => {
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
|
||||
format: "dd/MM/yyyy",
|
||||
});
|
||||
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "09/01/1997");
|
||||
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
|
||||
|
||||
input.value = "23/03/2022";
|
||||
await triggerEvent(input, null, "keydown", { key: "Enter" });
|
||||
|
||||
assert.strictEqual(input.value, "23/03/2022");
|
||||
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
|
||||
});
|
||||
|
||||
QUnit.test("Validate input date with 'Escape'", async (assert) => {
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy", { zone: "utc" }),
|
||||
format: "dd/MM/yyyy",
|
||||
});
|
||||
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "09/01/1997");
|
||||
assert.containsOnce(document.body, ".bootstrap-datetimepicker-widget");
|
||||
|
||||
input.value = "23/03/2022";
|
||||
await triggerEvent(input, null, "keydown", { key: "Escape" });
|
||||
|
||||
assert.strictEqual(input.value, "23/03/2022");
|
||||
assert.containsNone(document.body, ".bootstrap-datetimepicker-widget");
|
||||
});
|
||||
|
||||
QUnit.module("DateTimePicker");
|
||||
|
||||
QUnit.test("basic rendering", async function (assert) {
|
||||
assert.expect(11);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
});
|
||||
|
||||
assert.containsOnce(target, "input.o_input.o_datepicker_input");
|
||||
assert.containsOnce(target, "span.o_datepicker_button");
|
||||
assert.containsNone(document.body, "div.bootstrap-datetimepicker-widget");
|
||||
|
||||
const datePicker = target.querySelector(".o_datepicker");
|
||||
const input = datePicker.querySelector("input.o_input.o_datepicker_input");
|
||||
assert.strictEqual(input.value, "09/01/1997 12:30:01", "Value should be the one given");
|
||||
assert.strictEqual(
|
||||
datePicker.dataset.targetInput,
|
||||
`#${datePicker.querySelector("input[type=hidden]").id}`,
|
||||
"DateTimePicker id should match its input target"
|
||||
);
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .datepicker");
|
||||
assert.containsOnce(document.body, "div.bootstrap-datetimepicker-widget .timepicker");
|
||||
assert.strictEqual(
|
||||
document.querySelector(".datepicker .day.active").dataset.day,
|
||||
"01/09/1997",
|
||||
"Datepicker should have set the correct day"
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
|
||||
"12",
|
||||
"Datepicker should have set the correct hour"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
|
||||
"30",
|
||||
"Datepicker should have set the correct minute"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
|
||||
"01",
|
||||
"Datepicker should have set the correct second"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("pick a date and time", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy HH:mm:ss"),
|
||||
"08/02/1997 15:45:05",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
const input = target.querySelector("input.o_input.o_datepicker_input");
|
||||
|
||||
await click(input);
|
||||
await click(document.querySelector(".datepicker th.next")); // February
|
||||
await click(document.querySelectorAll(".datepicker table td")[15]); // 08
|
||||
await click(document.querySelector('a[title="Select Time"]'));
|
||||
await click(document.querySelector(".timepicker .timepicker-hour"));
|
||||
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
|
||||
await click(document.querySelector(".timepicker .timepicker-minute"));
|
||||
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
|
||||
await click(document.querySelector(".timepicker .timepicker-second"));
|
||||
|
||||
assert.verifySteps([]);
|
||||
|
||||
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
|
||||
|
||||
assert.strictEqual(input.value, "08/02/1997 15:45:05");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("pick a date and time with locale", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
format: "dd MMM, yyyy HH:mm:ss",
|
||||
locale: useFRLocale(),
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy HH:mm:ss"),
|
||||
"01/09/1997 15:45:05",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const input = target.querySelector("input.o_input.o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "09 janv., 1997 12:30:01");
|
||||
|
||||
await click(input);
|
||||
|
||||
await click(document.querySelector(".datepicker .picker-switch")); // month picker
|
||||
await click(document.querySelectorAll(".datepicker .month")[8]); // september
|
||||
await click(document.querySelector(".datepicker .day")); // first day
|
||||
|
||||
await click(document.querySelector('a[title="Select Time"]'));
|
||||
await click(document.querySelector(".timepicker .timepicker-hour"));
|
||||
await click(document.querySelectorAll(".timepicker .hour")[15]); // 15h
|
||||
await click(document.querySelector(".timepicker .timepicker-minute"));
|
||||
await click(document.querySelectorAll(".timepicker .minute")[9]); // 45m
|
||||
await click(document.querySelector(".timepicker .timepicker-second"));
|
||||
|
||||
assert.verifySteps([]);
|
||||
|
||||
await click(document.querySelectorAll(".timepicker .second")[1]); // 05s
|
||||
|
||||
assert.strictEqual(input.value, "01 sept., 1997 15:45:05");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("pick a time with 12 hour format locale", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997 08:30:01", "dd/MM/yyyy hh:mm:ss"),
|
||||
format: "dd/MM/yyyy hh:mm:ss",
|
||||
locale: useFRLocale(),
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy HH:mm:ss"),
|
||||
"09/01/1997 20:30:02",
|
||||
"The new time should be in the afternoon"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const input = target.querySelector("input.o_input.o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "09/01/1997 08:30:01");
|
||||
|
||||
await click(input);
|
||||
|
||||
await click(document.querySelector('a[title="Select Time"]'));
|
||||
await click(document.querySelector('a[title="Increment Second"]'));
|
||||
await click(document.querySelector('button[title="Toggle Period"]'));
|
||||
|
||||
assert.verifySteps([]);
|
||||
|
||||
await click(document.querySelector('a[title="Close the picker"]'));
|
||||
|
||||
assert.strictEqual(input.value, "09/01/1997 08:30:02");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("enter a datetime value", async function (assert) {
|
||||
assert.expect(9);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
onDateTimeChanged: (date) => {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy HH:mm:ss"),
|
||||
"08/02/1997 15:45:05",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.verifySteps([]);
|
||||
|
||||
input.value = "08/02/1997 15:45:05";
|
||||
await triggerEvent(target, ".o_datepicker_input", "change");
|
||||
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "08/02/1997 15:45:05");
|
||||
assert.strictEqual(
|
||||
document.querySelector(".datepicker .day.active").dataset.day,
|
||||
"02/08/1997",
|
||||
"Datepicker should have set the correct day"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector(".timepicker .timepicker-hour").innerText.trim(),
|
||||
"15",
|
||||
"Datepicker should have set the correct hour"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector(".timepicker .timepicker-minute").innerText.trim(),
|
||||
"45",
|
||||
"Datepicker should have set the correct minute"
|
||||
);
|
||||
assert.strictEqual(
|
||||
document.querySelector(".timepicker .timepicker-second").innerText.trim(),
|
||||
"05",
|
||||
"Datepicker should have set the correct second"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Date time format is correctly set", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
format: "HH:mm:ss yyyy/MM/dd",
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "12:30:01 1997/01/09");
|
||||
|
||||
// Forces an update to assert that the registered format is the correct one
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "12:30:01 1997/01/09");
|
||||
});
|
||||
|
||||
QUnit.test("Datepicker works with norwegian locale", async (assert) => {
|
||||
assert.expect(6);
|
||||
|
||||
await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/04/1997 12:30:01", "dd/MM/yyyy HH:mm:ss"),
|
||||
format: "dd MMM, yyyy",
|
||||
locale: useNOLocale(),
|
||||
onDateTimeChanged(date) {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy"),
|
||||
"01/04/1997",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "09 apr., 1997");
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "09 apr., 1997");
|
||||
|
||||
const days = [...document.querySelectorAll(".datepicker .day")];
|
||||
await click(days.find((d) => d.innerText.trim() === "1")); // first day of april
|
||||
|
||||
assert.strictEqual(input.value, "01 apr., 1997");
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
});
|
||||
|
||||
QUnit.test("Datepicker works with dots and commas in format", async (assert) => {
|
||||
assert.expect(2);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
date: DateTime.fromFormat("10/03/2023 13:14:27", "dd/MM/yyyy HH:mm:ss"),
|
||||
format: "dd.MM,yyyy",
|
||||
});
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
assert.strictEqual(input.value, "10.03,2023");
|
||||
|
||||
await click(input);
|
||||
|
||||
assert.strictEqual(input.value, "10.03,2023");
|
||||
});
|
||||
|
||||
QUnit.test("custom filter date", async function (assert) {
|
||||
assert.expect(3);
|
||||
class MockedSearchModel extends ActionModel {
|
||||
dispatch(method, ...args) {
|
||||
assert.strictEqual(method, "createNewFilters");
|
||||
const preFilters = args[0];
|
||||
const preFilter = preFilters[0];
|
||||
assert.strictEqual(
|
||||
preFilter.description,
|
||||
'A date is equal to "05/05/2005"',
|
||||
"description should be in localized format"
|
||||
);
|
||||
assert.deepEqual(
|
||||
preFilter.domain,
|
||||
'[["date_field","=","2005-05-05"]]',
|
||||
"domain should be in UTC format"
|
||||
);
|
||||
}
|
||||
}
|
||||
const searchModel = new MockedSearchModel();
|
||||
const date_field = { name: "date_field", string: "A date", type: "date", searchable: true };
|
||||
await createComponent(CustomFilterItem, {
|
||||
props: {
|
||||
fields: { date_field },
|
||||
},
|
||||
env: { searchModel },
|
||||
});
|
||||
await toggleMenu(target, "Add Custom Filter");
|
||||
await editSelect(target.querySelector(".o_generator_menu_field"), "date_field");
|
||||
const valueInput = target.querySelector(".o_generator_menu_value .o_input");
|
||||
await click(valueInput);
|
||||
await editSelect(valueInput, "05/05/2005");
|
||||
await applyFilter(target);
|
||||
});
|
||||
|
||||
QUnit.test("start with no value", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
onDateTimeChanged(date) {
|
||||
assert.step("datetime-changed");
|
||||
assert.strictEqual(
|
||||
date.toFormat("dd/MM/yyyy HH:mm:ss"),
|
||||
"08/02/1997 15:45:05",
|
||||
"Event should transmit the correct date"
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
assert.strictEqual(input.value, "");
|
||||
|
||||
assert.verifySteps([]);
|
||||
input.value = "08/02/1997 15:45:05";
|
||||
await triggerEvent(target, ".o_datepicker_input", "change");
|
||||
|
||||
assert.verifySteps(["datetime-changed"]);
|
||||
assert.strictEqual(input.value, "08/02/1997 15:45:05");
|
||||
});
|
||||
|
||||
QUnit.test("arab locale, latin numbering system as input", async (assert) => {
|
||||
const dateFormat = "dd MMM, yyyy";
|
||||
const timeFormat = "hh:mm:ss";
|
||||
const dateTimeFormat = `${dateFormat} ${timeFormat}`;
|
||||
|
||||
patchWithCleanup(localization, { dateFormat, timeFormat, dateTimeFormat });
|
||||
patchWithCleanup(Settings, {
|
||||
defaultLocale: "ar-001",
|
||||
defaultNumberingSystem: "arab",
|
||||
});
|
||||
|
||||
await mountPicker(DateTimePicker, {
|
||||
format: dateTimeFormat,
|
||||
});
|
||||
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
|
||||
await editInput(input, null, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
|
||||
|
||||
assert.strictEqual(input.value, "٠٤ يونيو, ٢٠٢٣ ١١:٣٣:٠٠");
|
||||
|
||||
await editInput(input, null, "15 07, 2020 12:30:43");
|
||||
|
||||
assert.strictEqual(input.value, "١٥ يوليو, ٢٠٢٠ ١٢:٣٠:٤٣");
|
||||
});
|
||||
|
||||
QUnit.test("keep date between component and datepicker in sync", async (assert) => {
|
||||
const parent = await mountPicker(DatePicker, {
|
||||
date: DateTime.fromFormat("09/01/1997", "dd/MM/yyyy"),
|
||||
format: "dd/MM/yyyy",
|
||||
});
|
||||
|
||||
const input = target.querySelector(".o_datepicker_input");
|
||||
assert.strictEqual(input.value, "09/01/1997");
|
||||
await nextTick();
|
||||
|
||||
await click(input);
|
||||
assert.hasClass(document.querySelector("td.day[data-day='01/09/1997']"), "active");
|
||||
|
||||
// Change the date of the component externally (not through the
|
||||
// datepicker interface)
|
||||
parent.state.date = parent.state.date.plus({ days: 1 });
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(input.value, "10/01/1997");
|
||||
assert.hasClass(document.querySelector("td.day[data-day='01/10/1997']"), "active");
|
||||
|
||||
parent.state.date = false;
|
||||
await nextTick();
|
||||
|
||||
assert.strictEqual(input.value, "");
|
||||
assert.containsN(document.body, "td.day", 42);
|
||||
assert.containsNone(document.body, "td.day.active");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import { expect } from "@odoo/hoot";
|
||||
import { click, edit, queryAll, queryAllTexts, queryText } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
|
||||
const PICKER_COLS = 7;
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/core/datetime/datetime_picker").DateTimePickerProps} DateTimePickerProps
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {false | {
|
||||
* title?: string | string[],
|
||||
* date?: {
|
||||
* cells: (number | string | [number] | [string])[][],
|
||||
* daysOfWeek?: string[],
|
||||
* weekNumbers?: number[],
|
||||
* }[],
|
||||
* time?: ([number, number] | [number, number, "AM" | "PM"])[],
|
||||
* }} expectedParams
|
||||
*/
|
||||
export function assertDateTimePicker(expectedParams) {
|
||||
// Check for picker in DOM
|
||||
if (expectedParams) {
|
||||
expect(".o_datetime_picker").toHaveCount(1);
|
||||
} else {
|
||||
expect(".o_datetime_picker").toHaveCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, date, time } = expectedParams;
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
expect(".o_datetime_picker_header").toHaveCount(1);
|
||||
expect(".o_datetime_picker_header").toHaveText(title);
|
||||
} else {
|
||||
expect(".o_datetime_picker_header").toHaveCount(0);
|
||||
}
|
||||
|
||||
// Time picker
|
||||
if (time) {
|
||||
expect(".o_time_picker").toHaveCount(time.length);
|
||||
for (let i = 0; i < time.length; i++) {
|
||||
const expectedTime = time[i];
|
||||
expect(
|
||||
`.o_time_picker:nth-child(${i + 1} of .o_time_picker) .o_time_picker_input`
|
||||
).toHaveValue(expectedTime, {
|
||||
message: `time values should be [${expectedTime}]`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
expect(".o_time_picker").toHaveCount(0);
|
||||
}
|
||||
|
||||
// Date picker
|
||||
expect(".o_date_picker").toHaveCount(date.length);
|
||||
|
||||
let selectedCells = 0;
|
||||
let invalidCells = 0;
|
||||
let todayCells = 0;
|
||||
for (let i = 0; i < date.length; i++) {
|
||||
const { cells, daysOfWeek, weekNumbers } = date[i];
|
||||
const cellEls = queryAll(`.o_date_picker:nth-child(${i + 1}) .o_date_item_cell`);
|
||||
const pickerRows = cells.length;
|
||||
expect(cellEls.length).toBe(pickerRows * PICKER_COLS, {
|
||||
message: `picker should have ${
|
||||
pickerRows * PICKER_COLS
|
||||
} cells (${pickerRows} rows and ${PICKER_COLS} columns)`,
|
||||
});
|
||||
|
||||
if (daysOfWeek) {
|
||||
const actualDow = queryAllTexts(
|
||||
`.o_date_picker:nth-child(${i + 1}) .o_day_of_week_cell`
|
||||
);
|
||||
expect(actualDow).toEqual(daysOfWeek, {
|
||||
message: `picker should display the days of week: ${daysOfWeek
|
||||
.map((dow) => `"${dow}"`)
|
||||
.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (weekNumbers) {
|
||||
expect(
|
||||
queryAllTexts(`.o_date_picker:nth-child(${i + 1}) .o_week_number_cell`).map(Number)
|
||||
).toEqual(weekNumbers, {
|
||||
message: `picker should display the week numbers (${weekNumbers.join(", ")})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Date cells
|
||||
const expectedCells = cells.flatMap((row, rowIndex) =>
|
||||
row.map((cell, colIndex) => {
|
||||
const cellEl = cellEls[rowIndex * PICKER_COLS + colIndex];
|
||||
let value = cell;
|
||||
if (Array.isArray(cell)) {
|
||||
// Selected
|
||||
value = value[0];
|
||||
selectedCells++;
|
||||
expect(cellEl).toHaveClass("o_selected");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
// Today
|
||||
value = Number(value);
|
||||
todayCells++;
|
||||
expect(cellEl).toHaveClass("o_today");
|
||||
}
|
||||
if (value < 0) {
|
||||
// Invalid
|
||||
value = Math.abs(value);
|
||||
invalidCells++;
|
||||
expect(cellEl).toHaveAttribute("disabled");
|
||||
}
|
||||
return String(value);
|
||||
})
|
||||
);
|
||||
|
||||
expect(cellEls.map((cell) => queryText(cell))).toEqual(expectedCells, {
|
||||
message: `cell content should match the expected values: [${expectedCells.join(", ")}]`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(".o_selected").toHaveCount(selectedCells);
|
||||
expect(".o_datetime_button[disabled]").toHaveCount(invalidCells);
|
||||
expect(".o_today").toHaveCount(todayCells);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RegExp | string} expr
|
||||
* @param {boolean} [inBounds=false]
|
||||
*/
|
||||
export function getPickerCell(expr, inBounds = false) {
|
||||
const cells = queryAll(
|
||||
`.o_datetime_picker .o_date_item_cell${
|
||||
inBounds ? ":not(.o_out_of_range)" : ""
|
||||
}:contains("/^${expr}$/")`
|
||||
);
|
||||
return cells.length === 1 ? cells[0] : cells;
|
||||
}
|
||||
|
||||
export async function zoomOut() {
|
||||
click(".o_zoom_out");
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
export async function editTime(time, timepickerIndex = 0) {
|
||||
await click(`.o_time_picker_input:eq(${timepickerIndex})`);
|
||||
await animationFrame();
|
||||
await edit(time, { confirm: "enter" });
|
||||
await animationFrame();
|
||||
}
|
||||
|
|
@ -0,0 +1,768 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
click,
|
||||
queryAll,
|
||||
queryAllProperties,
|
||||
queryAllTexts,
|
||||
queryOne,
|
||||
queryText,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
clearRegistry,
|
||||
contains,
|
||||
defineModels,
|
||||
defineWebModels,
|
||||
fields,
|
||||
getService,
|
||||
makeDialogMockEnv,
|
||||
models,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
webModels,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useDebugCategory, useOwnDebugContext } from "@web/core/debug/debug_context";
|
||||
import { DebugMenu } from "@web/core/debug/debug_menu";
|
||||
import { becomeSuperuser, regenerateAssets } from "@web/core/debug/debug_menu_items";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
import { ActionDialog } from "@web/webclient/actions/action_dialog";
|
||||
import { openViewItem } from "@web/webclient/debug/debug_items";
|
||||
import { WebClient } from "@web/webclient/webclient";
|
||||
|
||||
class DebugMenuParent extends Component {
|
||||
static template = xml`<DebugMenu/>`;
|
||||
static components = { DebugMenu };
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
useOwnDebugContext({ categories: ["default", "custom"] });
|
||||
}
|
||||
}
|
||||
|
||||
const debugRegistry = registry.category("debug");
|
||||
|
||||
onRpc("has_access", () => true);
|
||||
onRpc("ir.attachment", "regenerate_assets_bundles", () => {
|
||||
expect.step("ir.attachment/regenerate_assets_bundles");
|
||||
return true;
|
||||
});
|
||||
beforeEach(() => {
|
||||
// Remove this service to clear the debug menu from anything else than what the test insert into
|
||||
registry.category("services").remove("profiling");
|
||||
clearRegistry(debugRegistry.category("default"));
|
||||
clearRegistry(debugRegistry.category("custom"));
|
||||
});
|
||||
|
||||
describe.tags("desktop");
|
||||
describe("DebugMenu", () => {
|
||||
test("can be rendered", async () => {
|
||||
debugRegistry
|
||||
.category("default")
|
||||
.add("item_1", () => ({
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
callback: () => {
|
||||
expect.step("callback item_1");
|
||||
},
|
||||
sequence: 10,
|
||||
section: "a",
|
||||
}))
|
||||
.add("item_2", () => ({
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
callback: () => {
|
||||
expect.step("callback item_2");
|
||||
},
|
||||
sequence: 5,
|
||||
section: "a",
|
||||
}))
|
||||
.add("item_3", () => ({
|
||||
type: "item",
|
||||
description: "Item 3",
|
||||
callback: () => {
|
||||
expect.step("callback item_3");
|
||||
},
|
||||
section: "b",
|
||||
}))
|
||||
.add("item_4", () => null);
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(3);
|
||||
expect(".dropdown-menu .dropdown-menu_group").toHaveCount(2);
|
||||
const children = [...queryOne(".dropdown-menu").children] || [];
|
||||
expect(children.map((el) => el.tagName)).toEqual(["DIV", "SPAN", "SPAN", "DIV", "SPAN"]);
|
||||
expect(queryAllTexts(children)).toEqual(["a", "Item 2", "Item 1", "b", "Item 3"]);
|
||||
|
||||
for (const item of queryAll(".dropdown-menu .dropdown-item")) {
|
||||
await click(item);
|
||||
}
|
||||
|
||||
expect.verifySteps(["callback item_2", "callback item_1", "callback item_3"]);
|
||||
});
|
||||
|
||||
test("items are sorted by sequence regardless of category", async () => {
|
||||
debugRegistry
|
||||
.category("default")
|
||||
.add("item_1", () => ({
|
||||
type: "item",
|
||||
description: "Item 4",
|
||||
sequence: 4,
|
||||
}))
|
||||
.add("item_2", () => ({
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
sequence: 1,
|
||||
}));
|
||||
debugRegistry
|
||||
.category("custom")
|
||||
.add("item_1", () => ({
|
||||
type: "item",
|
||||
description: "Item 3",
|
||||
sequence: 3,
|
||||
}))
|
||||
.add("item_2", () => ({
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
sequence: 2,
|
||||
}));
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(queryAllTexts(".dropdown-menu .dropdown-item")).toEqual([
|
||||
"Item 1",
|
||||
"Item 2",
|
||||
"Item 3",
|
||||
"Item 4",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Don't display the DebugMenu if debug mode is disabled", async () => {
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ActionDialog, {
|
||||
env,
|
||||
props: { close: () => {} },
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog .o_debug_manager .fa-bug").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Display the DebugMenu correctly in a ActionDialog if debug mode is enabled", async () => {
|
||||
debugRegistry.category("default").add("global", () => ({
|
||||
type: "item",
|
||||
description: "Global 1",
|
||||
callback: () => {
|
||||
expect.step("callback global_1");
|
||||
},
|
||||
sequence: 0,
|
||||
}));
|
||||
debugRegistry
|
||||
.category("custom")
|
||||
.add("item1", () => ({
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
callback: () => {
|
||||
expect.step("callback item_1");
|
||||
},
|
||||
sequence: 10,
|
||||
}))
|
||||
.add("item2", ({ customKey }) => ({
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
callback: () => {
|
||||
expect.step("callback item_2");
|
||||
expect(customKey).toBe("abc");
|
||||
},
|
||||
sequence: 20,
|
||||
}));
|
||||
class WithCustom extends ActionDialog {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
useDebugCategory("custom", { customKey: "abc" });
|
||||
}
|
||||
}
|
||||
serverState.debug = "1";
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(WithCustom, {
|
||||
env,
|
||||
props: { close: () => {} },
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog .o_debug_manager .fa-bug").toHaveCount(1);
|
||||
await contains(".o_dialog .o_debug_manager button").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(2);
|
||||
// Check that global debugManager elements are not displayed (global_1)
|
||||
const items = queryAll(".dropdown-menu .dropdown-item");
|
||||
expect(queryAllTexts(items)).toEqual(["Item 1", "Item 2"]);
|
||||
for (const item of items) {
|
||||
await click(item);
|
||||
}
|
||||
expect.verifySteps(["callback item_1", "callback item_2"]);
|
||||
});
|
||||
|
||||
test("can regenerate assets bundles", async () => {
|
||||
patchWithCleanup(browser.location, {
|
||||
reload: () => expect.step("reloadPage"),
|
||||
});
|
||||
debugRegistry.category("default").add("regenerateAssets", regenerateAssets);
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(1);
|
||||
const item = queryOne(".dropdown-menu .dropdown-item");
|
||||
expect(item).toHaveText("Regenerate Assets");
|
||||
await click(item);
|
||||
await animationFrame();
|
||||
expect.verifySteps(["ir.attachment/regenerate_assets_bundles", "reloadPage"]);
|
||||
});
|
||||
|
||||
test("cannot acess the Become superuser menu if not admin", async () => {
|
||||
debugRegistry.category("default").add("becomeSuperuser", becomeSuperuser);
|
||||
user.isAdmin = false;
|
||||
await mountWithCleanup(DebugMenuParent);
|
||||
await contains("button.dropdown-toggle").click();
|
||||
expect(".dropdown-menu .dropdown-item").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("can open a view", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.IrUiView._views.list = `<list><field name="name"/><field name="type"/></list>`;
|
||||
webModels.ResPartner._views["form,1"] = `<form><div class="some_view"/></form>`;
|
||||
|
||||
webModels.IrUiView._records.push({
|
||||
id: 1,
|
||||
name: "formView",
|
||||
model: "res.partner",
|
||||
type: "form",
|
||||
active: true,
|
||||
});
|
||||
|
||||
defineWebModels();
|
||||
registry.category("debug").category("default").add("openViewItem", openViewItem);
|
||||
|
||||
await mountWithCleanup(WebClient);
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item").click();
|
||||
expect(".modal .o_list_view").toHaveCount(1);
|
||||
await contains(".modal .o_list_view .o_data_row td").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
expect(".some_view").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("get view: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.list = `<list><field name="name"/></list>`;
|
||||
|
||||
defineWebModels();
|
||||
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "list"]],
|
||||
});
|
||||
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Computed Arch')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
expect(".modal-body").toHaveText(`<list><field name="name"/></list>`);
|
||||
});
|
||||
|
||||
test("can edit a pivot view", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views["pivot,18"] = "<pivot></pivot>";
|
||||
webModels.IrUiView._records.push({ id: 18, name: "Edit View" });
|
||||
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "pivot"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('View: Pivot')").click();
|
||||
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Edit View");
|
||||
expect(".o_field_widget[name=id]").toHaveText("18");
|
||||
await click(".breadcrumb .o_back_button");
|
||||
await animationFrame();
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Partners");
|
||||
});
|
||||
|
||||
test("can edit a search view", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.list = `<list><field name="id"/></list>`;
|
||||
webModels.ResPartner._views["search,293"] = "<search></search>";
|
||||
webModels.IrUiView._records.push({ id: 293, name: "Edit View" });
|
||||
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
search_view_id: [293, "some_search_view"],
|
||||
views: [[false, "list"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('SearchView')").click();
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Edit View");
|
||||
expect(".o_field_widget[name=id]").toHaveText("293");
|
||||
});
|
||||
|
||||
test("edit search view on action without search_view_id", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.list = `<list><field name="id"/></list>`;
|
||||
webModels.ResPartner._views["search,293"] = "<search></search>";
|
||||
webModels.IrUiView._records.push({ id: 293, name: "Edit View" });
|
||||
webModels.IrUiView._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
search_view_id: false,
|
||||
views: [[false, "list"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('SearchView')").click();
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Edit View");
|
||||
expect(".o_field_widget[name=id]").toHaveText("293");
|
||||
});
|
||||
|
||||
test("cannot edit the control panel of a form view contained in a dialog without control panel.", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views.form = `<form><field name="id"/></form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Create a Partner",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
target: "new",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
|
||||
await contains(".o_dialog .o_debug_manager button").click();
|
||||
expect(".dropdown-menu .dropdown-item:contains('SearchView')").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("set defaults: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
webModels.ResPartner._views["form,24"] = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
</form>`;
|
||||
webModels.ResPartner._records.push({ id: 1000, name: "p1" });
|
||||
webModels.IrUiView._records.push({ id: 24 });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
res_id: 1000,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[24, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
expect(".modal select#formview_default_fields").toHaveCount(1);
|
||||
expect(".modal #formview_default_fields option").toHaveCount(2);
|
||||
expect(".modal #formview_default_fields option").toHaveCount(2);
|
||||
expect(".modal #formview_default_fields option:nth-child(1)").toHaveText("");
|
||||
expect(".modal #formview_default_fields option:nth-child(2)").toHaveText("Name = p1");
|
||||
});
|
||||
|
||||
test("set defaults: click close", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
onRpc("ir.default", "set", () => {
|
||||
throw new Error("should not create a default");
|
||||
});
|
||||
|
||||
webModels.ResPartner._views["form,25"] = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
</form>`;
|
||||
webModels.ResPartner._records.push({ id: 1001, name: "p1" });
|
||||
webModels.IrUiView._records.push({ id: 25 });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
res_id: 1001,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[25, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
await contains(".modal .modal-footer button").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("set defaults: select and save", async () => {
|
||||
expect.assertions(3);
|
||||
serverState.debug = "1";
|
||||
|
||||
onRpc("ir.default", "set", ({ args }) => {
|
||||
expect(args).toEqual(["res.partner", "name", "p1", true, true, false]);
|
||||
return true;
|
||||
});
|
||||
|
||||
webModels.ResPartner._views["form,26"] = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
</form>`;
|
||||
webModels.ResPartner._records.push({ id: 1002, name: "p1" });
|
||||
webModels.IrUiView._records.push({ id: 26 });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
res_id: 1002,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[26, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
|
||||
await contains(".modal #formview_default_fields").select("name");
|
||||
await contains(".modal .modal-footer button:nth-child(2)").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("fetch raw data: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
class Custom extends models.Model {
|
||||
_name = "custom";
|
||||
|
||||
name = fields.Char();
|
||||
raw = fields.Binary();
|
||||
parent_id = fields.Many2one({ relation: "custom" });
|
||||
properties = fields.Properties({
|
||||
string: "Properties",
|
||||
definition_record: "parent_id",
|
||||
definition_record_field: "definitions",
|
||||
});
|
||||
definitions = fields.PropertiesDefinition({
|
||||
string: "Definitions",
|
||||
});
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "custom1",
|
||||
raw: "<raw>",
|
||||
definitions: [{ name: "xphone_prop_1", string: "P1", type: "boolean" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
const fnames = Object.keys(Custom._fields).map((fname) => `<field name="${fname}"/>`);
|
||||
Custom._views.form = `<form>\n${fnames.join("\n")}\n</form>`;
|
||||
|
||||
defineWebModels();
|
||||
defineModels([Custom]);
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Custom",
|
||||
res_model: "custom",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains(/^Data/)").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
const data = queryText(".modal-body pre");
|
||||
const modalObj = JSON.parse(data);
|
||||
expect(modalObj).toInclude("create_date");
|
||||
expect(modalObj).toInclude("write_date");
|
||||
expect(modalObj).not.toInclude("raw");
|
||||
const expectedObj = {
|
||||
display_name: "custom1",
|
||||
id: 1,
|
||||
name: "custom1",
|
||||
properties: false,
|
||||
definitions: [
|
||||
{
|
||||
name: "xphone_prop_1",
|
||||
string: "P1",
|
||||
type: "boolean",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(modalObj).toMatchObject(expectedObj);
|
||||
});
|
||||
|
||||
test("view metadata: basic rendering", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
onRpc("get_metadata", () => [
|
||||
{
|
||||
create_date: "2023-01-26 14:12:10",
|
||||
create_uid: [4, "Some user"],
|
||||
id: 1003,
|
||||
noupdate: false,
|
||||
write_date: "2023-01-26 14:13:31",
|
||||
write_uid: [6, "Another User"],
|
||||
xmlid: "abc.partner_16",
|
||||
xmlids: [{ xmlid: "abc.partner_16", noupdate: false }],
|
||||
},
|
||||
]);
|
||||
|
||||
webModels.ResPartner._records.push({ id: 1003, name: "p1" });
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partner",
|
||||
res_model: "res.partner",
|
||||
res_id: 1003,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Metadata')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
const contentModal = queryAll(".modal-body table tr th, .modal-body table tr td");
|
||||
expect(queryAllTexts(contentModal)).toEqual([
|
||||
"ID:",
|
||||
"1003",
|
||||
"XML ID:",
|
||||
"abc.partner_16",
|
||||
"No Update:",
|
||||
"false (change)",
|
||||
"Creation User:",
|
||||
"Some user",
|
||||
"Creation Date:",
|
||||
"01/26/2023 15:12:10",
|
||||
"Latest Modification by:",
|
||||
"Another User",
|
||||
"Latest Modification Date:",
|
||||
"01/26/2023 15:13:31",
|
||||
]);
|
||||
});
|
||||
|
||||
test("set defaults: setting default value for datetime field", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
onRpc("ir.default", "set", ({ args }) => {
|
||||
expect.step(args);
|
||||
return true;
|
||||
});
|
||||
|
||||
class Partner extends models.Model {
|
||||
_name = "partner";
|
||||
|
||||
datetime = fields.Datetime();
|
||||
reference = fields.Reference({ selection: [["pony", "Pony"]] });
|
||||
m2o = fields.Many2one({ relation: "pony" });
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "p1",
|
||||
datetime: "2024-01-24 16:46:16",
|
||||
reference: "pony,1",
|
||||
m2o: 1,
|
||||
},
|
||||
];
|
||||
|
||||
_views = {
|
||||
"form,18": /* xml */ `
|
||||
<form>
|
||||
<field name="datetime"/>
|
||||
<field name="reference"/>
|
||||
<field name="m2o"/>
|
||||
</form>
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
class Pony extends models.Model {
|
||||
_name = "pony";
|
||||
|
||||
_records = [{ id: 1 }];
|
||||
}
|
||||
|
||||
class IrUiView extends models.Model {
|
||||
_name = "ir.ui.view";
|
||||
|
||||
name = fields.Char();
|
||||
model = fields.Char();
|
||||
|
||||
_records = [{ id: 18 }];
|
||||
}
|
||||
|
||||
defineModels([Partner, Pony, IrUiView]);
|
||||
await mountWithCleanup(WebClient);
|
||||
|
||||
for (const field_name of ["datetime", "reference", "m2o"]) {
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
|
||||
await contains(".modal #formview_default_fields").select(field_name);
|
||||
await contains(".modal .modal-footer button:nth-child(2)").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
}
|
||||
|
||||
expect.verifySteps([
|
||||
["partner", "datetime", "2024-01-24 16:46:16", true, true, false],
|
||||
[
|
||||
"partner",
|
||||
"reference",
|
||||
{ resId: 1, resModel: "pony", displayName: "pony,1" },
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
],
|
||||
["partner", "m2o", 1, true, true, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test("display model view in developer tools", async () => {
|
||||
serverState.debug = "1";
|
||||
webModels.ResPartner._views.form = `<form><field name="name"/></form>`;
|
||||
webModels.ResPartner._records.push({ id: 88, name: "p1" });
|
||||
webModels.IrModel._views.form = `
|
||||
<form>
|
||||
<field name="name"/>
|
||||
<field name="model"/>
|
||||
</form>`;
|
||||
|
||||
defineWebModels();
|
||||
await mountWithCleanup(WebClient);
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "res.partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Model:')").click();
|
||||
|
||||
expect(".breadcrumb-item").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveCount(1);
|
||||
expect(".o_breadcrumb .active").toHaveText("Partner");
|
||||
});
|
||||
|
||||
test("set defaults: settings default value with a very long value", async () => {
|
||||
serverState.debug = "1";
|
||||
|
||||
const fooValue = "12".repeat(250);
|
||||
|
||||
onRpc("ir.default", "set", ({ args }) => {
|
||||
expect.step(args);
|
||||
return true;
|
||||
});
|
||||
|
||||
class Partner extends models.Model {
|
||||
_name = "partner";
|
||||
|
||||
foo = fields.Char();
|
||||
description = fields.Html();
|
||||
bar = fields.Many2one({ relation: "ir.ui.view" });
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "p1",
|
||||
foo: fooValue,
|
||||
description: fooValue,
|
||||
bar: 18,
|
||||
},
|
||||
];
|
||||
|
||||
_views = {
|
||||
form: `
|
||||
<form>
|
||||
<field name="foo"/>
|
||||
<field name="description"/>
|
||||
<field name="bar" invisible="1"/>
|
||||
</form>`,
|
||||
};
|
||||
}
|
||||
|
||||
class IrUiView extends models.Model {
|
||||
_name = "ir.ui.view";
|
||||
|
||||
name = fields.Char();
|
||||
model = fields.Char();
|
||||
|
||||
_records = [{ id: 18 }];
|
||||
}
|
||||
|
||||
defineModels([Partner, IrUiView]);
|
||||
|
||||
await mountWithCleanup(WebClient);
|
||||
|
||||
await getService("action").doAction({
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
});
|
||||
await contains(".o_debug_manager button").click();
|
||||
await contains(".dropdown-menu .dropdown-item:contains('Set Default Values')").click();
|
||||
expect(".modal").toHaveCount(1);
|
||||
|
||||
expect(queryAllTexts`.modal #formview_default_fields option`).toEqual([
|
||||
"",
|
||||
"Foo = 121212121212121212121212121212121212121212121212121212121...",
|
||||
"Description = 121212121212121212121212121212121212121212121212121212121...",
|
||||
]);
|
||||
|
||||
expect(queryAllProperties(".modal #formview_default_fields option", "value")).toEqual([
|
||||
"",
|
||||
"foo",
|
||||
"description",
|
||||
]);
|
||||
|
||||
await contains(".modal #formview_default_fields").select("foo");
|
||||
await contains(".modal .modal-footer button:nth-child(2)").click();
|
||||
expect(".modal").toHaveCount(0);
|
||||
expect.verifySteps([["partner", "foo", fooValue, true, true, false]]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,884 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { DebugMenu } from "@web/core/debug/debug_menu";
|
||||
import { regenerateAssets, becomeSuperuser } from "@web/core/debug/debug_menu_items";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useDebugCategory, useOwnDebugContext } from "@web/core/debug/debug_context";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { useSetupView } from "@web/views/view_hook";
|
||||
import { ActionDialog } from "@web/webclient/actions/action_dialog";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { makeTestEnv, utils } from "../../helpers/mock_env";
|
||||
import {
|
||||
fakeCompanyService,
|
||||
fakeCommandService,
|
||||
makeFakeDialogService,
|
||||
makeFakeLocalizationService,
|
||||
makeFakeUserService,
|
||||
} from "../../helpers/mock_services";
|
||||
import {
|
||||
click,
|
||||
getFixture,
|
||||
getNodesTextContent,
|
||||
legacyExtraNextTick,
|
||||
mount,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
} from "../../helpers/utils";
|
||||
import { createWebClient, doAction, getActionManagerServerData } from "../../webclient/helpers";
|
||||
import { openViewItem } from "@web/webclient/debug_items";
|
||||
import { editSearchView, editView, setDefaults, viewMetadata } from "@web/views/debug_items";
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
const { prepareRegistriesWithCleanup } = utils;
|
||||
|
||||
export class DebugMenuParent extends Component {
|
||||
setup() {
|
||||
useOwnDebugContext({ categories: ["default", "custom"] });
|
||||
}
|
||||
}
|
||||
DebugMenuParent.template = xml`<DebugMenu/>`;
|
||||
DebugMenuParent.components = { DebugMenu };
|
||||
|
||||
const debugRegistry = registry.category("debug");
|
||||
let target;
|
||||
let testConfig;
|
||||
|
||||
QUnit.module("DebugMenu", (hooks) => {
|
||||
hooks.beforeEach(async () => {
|
||||
target = getFixture();
|
||||
registry
|
||||
.category("services")
|
||||
.add("hotkey", hotkeyService)
|
||||
.add("ui", uiService)
|
||||
.add("orm", ormService)
|
||||
.add("dialog", makeFakeDialogService())
|
||||
.add("localization", makeFakeLocalizationService())
|
||||
.add("command", fakeCommandService);
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
testConfig = { mockRPC };
|
||||
});
|
||||
QUnit.test("can be rendered", async (assert) => {
|
||||
debugRegistry
|
||||
.category("default")
|
||||
.add("item_1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
callback: () => {
|
||||
assert.step("callback item_1");
|
||||
},
|
||||
sequence: 10,
|
||||
};
|
||||
})
|
||||
.add("item_2", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
callback: () => {
|
||||
assert.step("callback item_2");
|
||||
},
|
||||
sequence: 5,
|
||||
};
|
||||
})
|
||||
.add("item_3", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 3",
|
||||
callback: () => {
|
||||
assert.step("callback item_3");
|
||||
},
|
||||
};
|
||||
})
|
||||
.add("separator", () => {
|
||||
return {
|
||||
type: "separator",
|
||||
sequence: 20,
|
||||
};
|
||||
})
|
||||
.add("separator_2", () => {
|
||||
return null;
|
||||
})
|
||||
.add("item_4", () => {
|
||||
return null;
|
||||
});
|
||||
const env = await makeTestEnv(testConfig);
|
||||
await mount(DebugMenuParent, target, { env });
|
||||
await click(target.querySelector("button.dropdown-toggle"));
|
||||
assert.containsN(target, ".dropdown-menu .dropdown-item", 3);
|
||||
assert.containsOnce(target, ".dropdown-divider");
|
||||
const children = [...(target.querySelector(".dropdown-menu").children || [])];
|
||||
assert.deepEqual(
|
||||
children.map((el) => el.tagName),
|
||||
["SPAN", "SPAN", "DIV", "SPAN"]
|
||||
);
|
||||
const items = [...target.querySelectorAll(".dropdown-menu .dropdown-item")] || [];
|
||||
assert.deepEqual(
|
||||
items.map((el) => el.textContent),
|
||||
["Item 2", "Item 1", "Item 3"]
|
||||
);
|
||||
for (const item of items) {
|
||||
click(item);
|
||||
}
|
||||
assert.verifySteps(["callback item_2", "callback item_1", "callback item_3"]);
|
||||
});
|
||||
|
||||
QUnit.test("items are sorted by sequence regardless of category", async (assert) => {
|
||||
debugRegistry
|
||||
.category("default")
|
||||
.add("item_1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 4",
|
||||
sequence: 4,
|
||||
};
|
||||
})
|
||||
.add("item_2", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
sequence: 1,
|
||||
};
|
||||
});
|
||||
debugRegistry
|
||||
.category("custom")
|
||||
.add("item_1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 3",
|
||||
sequence: 3,
|
||||
};
|
||||
})
|
||||
.add("item_2", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
sequence: 2,
|
||||
};
|
||||
});
|
||||
const env = await makeTestEnv(testConfig);
|
||||
await mount(DebugMenuParent, target, { env });
|
||||
await click(target.querySelector("button.dropdown-toggle"));
|
||||
const items = [...target.querySelectorAll(".dropdown-menu .dropdown-item")];
|
||||
assert.deepEqual(
|
||||
items.map((el) => el.textContent),
|
||||
["Item 1", "Item 2", "Item 3", "Item 4"]
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Don't display the DebugMenu if debug mode is disabled", async (assert) => {
|
||||
const env = await makeTestEnv(testConfig);
|
||||
env.dialogData = {
|
||||
isActive: true,
|
||||
close() {},
|
||||
};
|
||||
await mount(ActionDialog, target, {
|
||||
env,
|
||||
props: { close: () => {} },
|
||||
});
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.containsNone(target, ".o_dialog .o_debug_manager .fa-bug");
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"Display the DebugMenu correctly in a ActionDialog if debug mode is enabled",
|
||||
async (assert) => {
|
||||
assert.expect(8);
|
||||
debugRegistry.category("default").add("global", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Global 1",
|
||||
callback: () => {
|
||||
assert.step("callback global_1");
|
||||
},
|
||||
sequence: 0,
|
||||
};
|
||||
});
|
||||
debugRegistry
|
||||
.category("custom")
|
||||
.add("item1", () => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 1",
|
||||
callback: () => {
|
||||
assert.step("callback item_1");
|
||||
},
|
||||
sequence: 10,
|
||||
};
|
||||
})
|
||||
.add("item2", ({ customKey }) => {
|
||||
return {
|
||||
type: "item",
|
||||
description: "Item 2",
|
||||
callback: () => {
|
||||
assert.step("callback item_2");
|
||||
assert.strictEqual(customKey, "abc");
|
||||
},
|
||||
sequence: 20,
|
||||
};
|
||||
});
|
||||
class WithCustom extends ActionDialog {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
useDebugCategory("custom", { customKey: "abc" });
|
||||
}
|
||||
}
|
||||
patchWithCleanup(odoo, { debug: "1" });
|
||||
const env = await makeTestEnv(testConfig);
|
||||
env.dialogData = {
|
||||
isActive: true,
|
||||
close() {},
|
||||
};
|
||||
await mount(WithCustom, target, {
|
||||
env,
|
||||
props: { close: () => {} },
|
||||
});
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.containsOnce(target, ".o_dialog .o_debug_manager .fa-bug");
|
||||
await click(target, ".o_dialog .o_debug_manager button");
|
||||
const debugManagerEl = target.querySelector(".o_debug_manager");
|
||||
assert.containsN(debugManagerEl, ".dropdown-menu .dropdown-item", 2);
|
||||
// Check that global debugManager elements are not displayed (global_1)
|
||||
const items =
|
||||
[...debugManagerEl.querySelectorAll(".dropdown-menu .dropdown-item")] || [];
|
||||
assert.deepEqual(
|
||||
items.map((el) => el.textContent),
|
||||
["Item 1", "Item 2"]
|
||||
);
|
||||
for (const item of items) {
|
||||
click(item);
|
||||
}
|
||||
assert.verifySteps(["callback item_1", "callback item_2"]);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("can regenerate assets bundles", async (assert) => {
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (route === "/web/dataset/call_kw/ir.attachment/regenerate_assets_bundles") {
|
||||
assert.step("ir.attachment/regenerate_assets_bundles");
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
testConfig = { mockRPC };
|
||||
patchWithCleanup(browser, {
|
||||
location: {
|
||||
reload: () => assert.step("reloadPage"),
|
||||
},
|
||||
});
|
||||
debugRegistry.category("default").add("regenerateAssets", regenerateAssets);
|
||||
const env = await makeTestEnv(testConfig);
|
||||
await mount(DebugMenuParent, target, { env });
|
||||
await click(target.querySelector("button.dropdown-toggle"));
|
||||
assert.containsOnce(target, ".dropdown-menu .dropdown-item");
|
||||
const item = target.querySelector(".dropdown-menu .dropdown-item");
|
||||
assert.strictEqual(item.textContent, "Regenerate Assets Bundles");
|
||||
await click(item);
|
||||
assert.verifySteps(["ir.attachment/regenerate_assets_bundles", "reloadPage"]);
|
||||
});
|
||||
|
||||
QUnit.test("cannot acess the Become superuser menu if not admin", async (assert) => {
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
debugRegistry.category("default").add("becomeSuperuser", becomeSuperuser);
|
||||
|
||||
testConfig = { mockRPC };
|
||||
const env = await makeTestEnv(testConfig);
|
||||
env.services.user.isAdmin = false;
|
||||
await mount(DebugMenuParent, target, { env });
|
||||
|
||||
await click(target.querySelector("button.dropdown-toggle"));
|
||||
assert.containsNone(target, ".dropdown-menu .dropdown-item");
|
||||
});
|
||||
|
||||
QUnit.test("can open a view", async (assert) => {
|
||||
assert.expect(3);
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
prepareRegistriesWithCleanup();
|
||||
registry.category("services").add("company", fakeCompanyService);
|
||||
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("debug").category("default").add("openViewItem", openViewItem);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
Object.assign(serverData.models, {
|
||||
"ir.ui.view": {
|
||||
fields: {
|
||||
model: { type: "char" },
|
||||
name: { type: "char" },
|
||||
type: { type: "char" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: "formView",
|
||||
model: "partner",
|
||||
type: "form",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(serverData.views, {
|
||||
"ir.ui.view,false,list": `<list><field name="name"/><field name="type"/></list>`,
|
||||
"ir.ui.view,false,search": `<search/>`,
|
||||
"partner,1,form": `<form><div class="some_view"/></form>`,
|
||||
});
|
||||
|
||||
await createWebClient({ serverData, mockRPC });
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
assert.containsOnce(target, ".modal .o_list_view");
|
||||
|
||||
await click(target.querySelector(".modal .o_list_view .o_data_row td"));
|
||||
assert.containsNone(target, ".modal");
|
||||
assert.containsOnce(target, ".some_view");
|
||||
});
|
||||
|
||||
QUnit.test("can edit a pivot view", async (assert) => {
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
prepareRegistriesWithCleanup();
|
||||
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("services").add("user", makeFakeUserService());
|
||||
registry.category("debug").category("view").add("editViewItem", editView);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.actions[1234] = {
|
||||
id: 1234,
|
||||
xml_id: "action_1234",
|
||||
name: "Reporting Ponies",
|
||||
res_model: "pony",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "pivot"]],
|
||||
};
|
||||
serverData.views["pony,18,pivot"] = "<pivot></pivot>";
|
||||
serverData.models["ir.ui.view"] = {
|
||||
fields: {},
|
||||
records: [{ id: 18 }],
|
||||
};
|
||||
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
|
||||
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1234);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
assert.containsOnce(target, ".modal .o_form_view");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
|
||||
"18"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("can edit a search view", async (assert) => {
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
prepareRegistriesWithCleanup();
|
||||
registry.category("services").add("company", fakeCompanyService);
|
||||
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("debug").category("view").add("editSearchViewItem", editSearchView);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
|
||||
serverData.views["partner,293,search"] = "<search></search>";
|
||||
serverData.actions[1].search_view_id = [293, "some_search_view"];
|
||||
serverData.models["ir.ui.view"] = {
|
||||
fields: {},
|
||||
records: [{ id: 293 }],
|
||||
};
|
||||
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
|
||||
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
await legacyExtraNextTick();
|
||||
assert.containsOnce(target, ".modal .o_form_view");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
|
||||
"293"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("edit search view on action without search_view_id", async (assert) => {
|
||||
// When the kanban view will be converted to Owl, this test could be simplified by
|
||||
// removing the toy view and using the kanban view directly
|
||||
prepareRegistriesWithCleanup();
|
||||
|
||||
class ToyController extends Component {
|
||||
setup() {
|
||||
useSetupView();
|
||||
}
|
||||
}
|
||||
ToyController.template = xml`<div class="o-toy-view"/>`;
|
||||
|
||||
registry.category("views").add("toy", {
|
||||
type: "toy",
|
||||
display_name: "toy view",
|
||||
Controller: ToyController,
|
||||
});
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("debug").category("view").add("editSearchViewItem", editSearchView);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.actions[1] = {
|
||||
id: 1,
|
||||
xml_id: "action_1",
|
||||
name: "Partners Action 1",
|
||||
res_model: "partner",
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "toy"]],
|
||||
search_view_id: false,
|
||||
};
|
||||
serverData.models["ir.ui.view"] = {
|
||||
fields: {},
|
||||
records: [{ id: 293 }],
|
||||
};
|
||||
serverData.views = {};
|
||||
serverData.views["ir.ui.view,false,form"] = `<form><field name="id"/></form>`;
|
||||
serverData.views["partner,false,toy"] = `<toy></toy>`;
|
||||
serverData.views["partner,293,search"] = `<search></search>`;
|
||||
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1);
|
||||
assert.containsOnce(target, ".o-toy-view");
|
||||
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
await legacyExtraNextTick();
|
||||
assert.containsOnce(target, ".modal .o_form_view");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".modal .o_form_view .o_field_widget[name=id] input").value,
|
||||
"293"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"cannot edit the control panel of a form view contained in a dialog without control panel.",
|
||||
async (assert) => {
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
prepareRegistriesWithCleanup();
|
||||
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
registry.category("debug").category("view").add("editSearchViewItem", editSearchView);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
// opens a form view in a dialog without a control panel.
|
||||
await doAction(webClient, 5);
|
||||
await click(target.querySelector(".o_dialog .o_debug_manager button"));
|
||||
assert.containsNone(target, ".o_debug_manager .dropdown-item");
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("set defaults: basic rendering", async (assert) => {
|
||||
prepareRegistriesWithCleanup();
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("services").add("user", makeFakeUserService());
|
||||
registry.category("debug").category("form").add("setDefaults", setDefaults);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.actions[1234] = {
|
||||
id: 1234,
|
||||
xml_id: "action_1234",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "form"]],
|
||||
};
|
||||
serverData.views["partner,18,form"] = `
|
||||
<form>
|
||||
<field name="m2o"/>
|
||||
<field name="foo"/>
|
||||
<field name="o2m"/>
|
||||
</form>`;
|
||||
serverData.models["ir.ui.view"] = {
|
||||
fields: {},
|
||||
records: [{ id: 18 }],
|
||||
};
|
||||
serverData.models.partner.records = [{ id: 1, display_name: "p1", foo: "hello" }];
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1234);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
assert.containsOnce(target, ".modal");
|
||||
assert.containsOnce(target, ".modal select#formview_default_fields");
|
||||
assert.containsN(target.querySelector(".modal #formview_default_fields"), "option", 2);
|
||||
const options = target.querySelectorAll(".modal #formview_default_fields option");
|
||||
assert.strictEqual(options[0].value, "");
|
||||
assert.strictEqual(options[1].value, "foo");
|
||||
});
|
||||
|
||||
QUnit.test("set defaults: click close", async (assert) => {
|
||||
prepareRegistriesWithCleanup();
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("services").add("user", makeFakeUserService());
|
||||
registry.category("debug").category("form").add("setDefaults", setDefaults);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.actions[1234] = {
|
||||
id: 1234,
|
||||
xml_id: "action_1234",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "form"]],
|
||||
};
|
||||
serverData.views["partner,18,form"] = `
|
||||
<form>
|
||||
<field name="foo"/>
|
||||
</form>`;
|
||||
serverData.models["ir.ui.view"] = {
|
||||
fields: {},
|
||||
records: [{ id: 18 }],
|
||||
};
|
||||
serverData.models.partner.records = [{ id: 1, display_name: "p1", foo: "hello" }];
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "set" && args.model === "ir.default") {
|
||||
throw new Error("should not create a default");
|
||||
}
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1234);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
assert.containsOnce(target, ".modal");
|
||||
|
||||
await click(target.querySelector(".modal .modal-footer button"));
|
||||
assert.containsNone(target, ".modal");
|
||||
});
|
||||
|
||||
QUnit.test("set defaults: select and save", async (assert) => {
|
||||
assert.expect(3);
|
||||
|
||||
prepareRegistriesWithCleanup();
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("services").add("user", makeFakeUserService());
|
||||
registry.category("debug").category("form").add("setDefaults", setDefaults);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.actions[1234] = {
|
||||
id: 1234,
|
||||
xml_id: "action_1234",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "form"]],
|
||||
};
|
||||
serverData.views["partner,18,form"] = `
|
||||
<form>
|
||||
<field name="foo"/>
|
||||
</form>`;
|
||||
serverData.models["ir.ui.view"] = {
|
||||
fields: {},
|
||||
records: [{ id: 18 }],
|
||||
};
|
||||
serverData.models.partner.records = [{ id: 1, display_name: "p1", foo: "hello" }];
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (args.method === "set" && args.model === "ir.default") {
|
||||
assert.deepEqual(args.args, ["partner", "foo", "hello", true, true, false]);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1234);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
assert.containsOnce(target, ".modal");
|
||||
|
||||
const select = target.querySelector(".modal #formview_default_fields");
|
||||
select.value = "foo";
|
||||
select.dispatchEvent(new Event("change"));
|
||||
await nextTick();
|
||||
await click(target.querySelectorAll(".modal .modal-footer button")[1]);
|
||||
assert.containsNone(target, ".modal");
|
||||
});
|
||||
|
||||
QUnit.test("view metadata: basic rendering", async (assert) => {
|
||||
prepareRegistriesWithCleanup();
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("services").add("user", makeFakeUserService());
|
||||
registry.category("debug").category("form").add("viewMetadata", viewMetadata);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.actions[1234] = {
|
||||
id: 1234,
|
||||
xml_id: "action_1234",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 27,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[false, "form"]],
|
||||
};
|
||||
serverData.models.partner.records = [{ id: 27, display_name: "p1" }];
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (args.method === "get_metadata") {
|
||||
return [
|
||||
{
|
||||
create_date: "2023-01-26 14:12:10",
|
||||
create_uid: [4, "Some user"],
|
||||
id: 27,
|
||||
noupdate: false,
|
||||
write_date: "2023-01-26 14:13:31",
|
||||
write_uid: [6, "Another User"],
|
||||
xmlid: "abc.partner_16",
|
||||
xmlids: [{ xmlid: "abc.partner_16", noupdate: false }],
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1234);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
assert.containsOnce(target, ".modal");
|
||||
assert.deepEqual(
|
||||
getNodesTextContent(
|
||||
target.querySelectorAll(".modal-body table tr th, .modal-body table tr td")
|
||||
),
|
||||
[
|
||||
"ID:",
|
||||
"27",
|
||||
"XML ID:",
|
||||
"abc.partner_16",
|
||||
"No Update:",
|
||||
"false (change)",
|
||||
"Creation User:",
|
||||
"Some user",
|
||||
"Creation Date:",
|
||||
"01/26/2023 15:12:10",
|
||||
"Latest Modification by:",
|
||||
"Another User",
|
||||
"Latest Modification Date:",
|
||||
"01/26/2023 15:13:31",
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("set defaults: setting default value for datetime field", async (assert) => {
|
||||
assert.expect(7);
|
||||
|
||||
prepareRegistriesWithCleanup();
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
|
||||
registry.category("services").add("user", makeFakeUserService());
|
||||
registry.category("debug").category("form").add("setDefaults", setDefaults);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.actions[1234] = {
|
||||
id: 1234,
|
||||
xml_id: "action_1234",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "form"]],
|
||||
};
|
||||
serverData.models.partner.fields.datetime = {string: 'Datetime', type: 'datetime'}
|
||||
serverData.models.partner.fields.reference = {string: 'Reference', type: 'reference', selection: [["pony", "Pony"]]}
|
||||
serverData.views["partner,18,form"] = `
|
||||
<form>
|
||||
<field name="datetime"/>
|
||||
<field name="reference"/>
|
||||
<field name="m2o"/>
|
||||
</form>`;
|
||||
serverData.models["ir.ui.view"] = {
|
||||
fields: {},
|
||||
records: [{ id: 18 }],
|
||||
};
|
||||
serverData.models.pony.records = [{
|
||||
id: 1,
|
||||
name: "Test"
|
||||
}];
|
||||
serverData.models.partner.records = [{
|
||||
id: 1,
|
||||
display_name: "p1",
|
||||
datetime: "2024-01-24 16:46:16",
|
||||
reference: 'pony,1',
|
||||
m2o: 1
|
||||
}];
|
||||
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (args.method === "set" && args.model === "ir.default") {
|
||||
arg_steps.push(args.args)
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({serverData, mockRPC});
|
||||
let arg_steps = [];
|
||||
for (const field_name of ['datetime', 'reference', 'm2o']) {
|
||||
await doAction(webClient, 1234);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
assert.containsOnce(target, ".modal");
|
||||
|
||||
const select = target.querySelector(".modal #formview_default_fields");
|
||||
select.value = field_name;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
await nextTick();
|
||||
await click(target.querySelectorAll(".modal .modal-footer button")[1]);
|
||||
assert.containsNone(target, ".modal");
|
||||
}
|
||||
assert.deepEqual(arg_steps, [
|
||||
["partner", "datetime", "2024-01-24 16:46:16", true, true, false],
|
||||
["partner", "reference", {"displayName": "Test", "resId": 1, "resModel": "pony"}, true, true, false],
|
||||
["partner", "m2o", 1, true, true, false],
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test("set defaults: settings default value for a very long value", async (assert) => {
|
||||
prepareRegistriesWithCleanup();
|
||||
patchWithCleanup(odoo, {
|
||||
debug: true,
|
||||
});
|
||||
registry.category("services").add("user", makeFakeUserService());
|
||||
registry.category("debug").category("form").add("setDefaults", setDefaults);
|
||||
|
||||
const serverData = getActionManagerServerData();
|
||||
serverData.models.partner.fields.description = { string: "Description", type: "html" };
|
||||
serverData.actions[1234] = {
|
||||
id: 1234,
|
||||
xml_id: "action_1234",
|
||||
name: "Partners",
|
||||
res_model: "partner",
|
||||
res_id: 1,
|
||||
type: "ir.actions.act_window",
|
||||
views: [[18, "form"]],
|
||||
};
|
||||
const fooValue = "12".repeat(250);
|
||||
serverData.views["partner,18,form"] = `
|
||||
<form>
|
||||
<group>
|
||||
<field name="display_name"/>
|
||||
<field name="description"/>
|
||||
<field name="foo"/>
|
||||
</group>
|
||||
</form>
|
||||
`;
|
||||
serverData.models.partner.records[0].foo = fooValue;
|
||||
serverData.models.partner.records[0].description = fooValue;
|
||||
const mockRPC = async (route, args) => {
|
||||
if (args.method === "check_access_rights") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (args.method === "set" && args.model === "ir.default") {
|
||||
assert.step("setting default");
|
||||
assert.deepEqual(args.args, ["partner", "foo", fooValue, true, true, false]);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const webClient = await createWebClient({ serverData, mockRPC });
|
||||
await doAction(webClient, 1234);
|
||||
await click(target.querySelector(".o_debug_manager button"));
|
||||
await click(target.querySelector(".o_debug_manager .dropdown-item"));
|
||||
const select = target.querySelector(".modal #formview_default_fields");
|
||||
|
||||
const options = Object.fromEntries(
|
||||
Array.from(select.querySelectorAll("option")).map((option) => [
|
||||
option.value,
|
||||
option.textContent,
|
||||
])
|
||||
);
|
||||
assert.deepEqual(options, {
|
||||
"": "",
|
||||
display_name: "Display Name = First record",
|
||||
foo: "Foo = 121212121212121212121212121212121212121212121212121212121...",
|
||||
description:
|
||||
"Description = 121212121212121212121212121212121212121212121212121212121...",
|
||||
});
|
||||
|
||||
select.value = "foo";
|
||||
select.dispatchEvent(new Event("change"));
|
||||
await nextTick();
|
||||
await click(target.querySelectorAll(".modal .modal-footer button")[1]);
|
||||
assert.verifySteps(["setting default"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { test, expect, beforeEach } from "@odoo/hoot";
|
||||
import { runAllTimers } from "@odoo/hoot-mock";
|
||||
import { fields, models, defineModels, mountView } from "@web/../tests/web_test_helpers";
|
||||
|
||||
beforeEach(() => {
|
||||
const qweb = JSON.stringify([
|
||||
{
|
||||
exec_context: [],
|
||||
results: {
|
||||
archs: {
|
||||
1: `<t name="Test1" t-name="test">
|
||||
<t t-call-assets="web.assets_tests"/>
|
||||
</t>`,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
delay: 0.1,
|
||||
directive: 't-call-assets="web.assets_tests"',
|
||||
query: 9,
|
||||
view_id: 1,
|
||||
xpath: "/t/t",
|
||||
},
|
||||
],
|
||||
},
|
||||
stack: [],
|
||||
start: 42,
|
||||
},
|
||||
]);
|
||||
|
||||
class Custom extends models.Model {
|
||||
_name = "custom";
|
||||
|
||||
qweb = fields.Text();
|
||||
|
||||
_records = [{ qweb }];
|
||||
}
|
||||
|
||||
class View extends models.Model {
|
||||
_name = "ir.ui.view";
|
||||
|
||||
name = fields.Char();
|
||||
model = fields.Char();
|
||||
type = fields.Char();
|
||||
|
||||
_records = [
|
||||
{
|
||||
id: 1,
|
||||
name: "formView",
|
||||
model: "custom",
|
||||
type: "form",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
defineModels([Custom, View]);
|
||||
});
|
||||
|
||||
test("profiling qweb view field renders delay and query", async function (assert) {
|
||||
await mountView({
|
||||
resModel: "custom",
|
||||
type: "form",
|
||||
resId: 1,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="qweb" widget="profiling_qweb_view"/>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
await runAllTimers();
|
||||
|
||||
expect("[name='qweb'] .ace_gutter .ace_gutter-cell").toHaveCount(3);
|
||||
expect("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info").toHaveCount(1);
|
||||
expect("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info .o_delay").toHaveText("0.1");
|
||||
expect("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info .o_query").toHaveText("9");
|
||||
expect("[name='qweb'] .o_select_view_profiling .o_delay").toHaveText("0.1 ms");
|
||||
expect("[name='qweb'] .o_select_view_profiling .o_query").toHaveText("9 query");
|
||||
});
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { getFixture, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Debug > Profiling QWeb", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
const qweb = JSON.stringify([
|
||||
{
|
||||
exec_context: [],
|
||||
results: {
|
||||
archs: {
|
||||
1: `<t name="Test1" t-name="test">
|
||||
<t t-call-assets="web.assets_tests"/>
|
||||
</t>`,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
delay: 0.1,
|
||||
directive: 't-call-assets="web.assets_tests"',
|
||||
query: 9,
|
||||
view_id: 1,
|
||||
xpath: "/t/t",
|
||||
},
|
||||
],
|
||||
},
|
||||
stack: [],
|
||||
start: 42,
|
||||
},
|
||||
]);
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
qweb: {
|
||||
string: "QWeb",
|
||||
type: "text",
|
||||
},
|
||||
},
|
||||
records: [{ qweb }],
|
||||
},
|
||||
"ir.ui.view": {
|
||||
fields: {
|
||||
model: { type: "char" },
|
||||
name: { type: "char" },
|
||||
type: { type: "char" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: "formView",
|
||||
model: "partner",
|
||||
type: "form",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
|
||||
});
|
||||
|
||||
QUnit.test("profiling qweb view field renders delay and query", async function (assert) {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="qweb" widget="profiling_qweb_view" />
|
||||
</form>`,
|
||||
});
|
||||
|
||||
assert.containsN(target, "[name='qweb'] .ace_gutter .ace_gutter-cell", 3);
|
||||
assert.containsN(target, "[name='qweb'] .ace_gutter .ace_gutter-cell .o_info", 1);
|
||||
const infoEl = target.querySelector("[name='qweb'] .ace_gutter .ace_gutter-cell .o_info");
|
||||
assert.strictEqual(infoEl.querySelector(".o_delay").textContent, "0.1");
|
||||
assert.strictEqual(infoEl.querySelector(".o_query").textContent, "9");
|
||||
|
||||
const header = target.querySelector("[name='qweb'] .o_select_view_profiling");
|
||||
assert.strictEqual(header.querySelector(".o_delay").textContent, "0.1 ms");
|
||||
assert.strictEqual(header.querySelector(".o_query").textContent, "9 query");
|
||||
});
|
||||
});
|
||||
427
odoo-bringout-oca-ocb-web/web/static/tests/core/dialog.test.js
Normal file
427
odoo-bringout-oca-ocb-web/web/static/tests/core/dialog.test.js
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
import { destroy, expect, test } from "@odoo/hoot";
|
||||
import { keyDown, keyUp, press, queryAllTexts, queryOne, resize } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, onMounted, useState, xml } from "@odoo/owl";
|
||||
import {
|
||||
contains,
|
||||
getService,
|
||||
makeDialogMockEnv,
|
||||
mountWithCleanup,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
test("simple rendering", async () => {
|
||||
expect.assertions(8);
|
||||
class Parent extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog title="'Wow(l) Effect'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog header .modal-title").toHaveCount(1, {
|
||||
message: "the header is rendered by default",
|
||||
});
|
||||
expect("header .modal-title").toHaveText("Wow(l) Effect");
|
||||
expect(".o_dialog main").toHaveCount(1, { message: "a dialog has always a main node" });
|
||||
expect("main").toHaveText("Hello!");
|
||||
expect(".o_dialog footer").toHaveCount(1, { message: "the footer is rendered by default" });
|
||||
expect(".o_dialog footer button").toHaveCount(1, {
|
||||
message: "the footer is rendered with a single button 'Ok' by default",
|
||||
});
|
||||
expect("footer button").toHaveText("Ok");
|
||||
});
|
||||
|
||||
test("hotkeys work on dialogs", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog title="'Wow(l) Effect'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
close: () => expect.step("close"),
|
||||
dismiss: () => expect.step("dismiss"),
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
expect("header .modal-title").toHaveText("Wow(l) Effect");
|
||||
expect("footer button").toHaveText("Ok");
|
||||
// Same effect as clicking on the x button
|
||||
await press("escape");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["dismiss", "close"]);
|
||||
// Same effect as clicking on the Ok button
|
||||
await keyDown("control+enter");
|
||||
await keyUp("ctrl+enter");
|
||||
expect.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
test("simple rendering with two dialogs", async () => {
|
||||
expect.assertions(3);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<Dialog title="'First Title'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
<Dialog title="'Second Title'">
|
||||
Hello again!
|
||||
</Dialog>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
expect(queryAllTexts("header .modal-title")).toEqual(["First Title", "Second Title"]);
|
||||
expect(queryAllTexts(".o_dialog .modal-body")).toEqual(["Hello!", "Hello again!"]);
|
||||
});
|
||||
|
||||
test("click on the button x triggers the service close", async () => {
|
||||
expect.assertions(2);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
close: (params) => expect.step(`close ${JSON.stringify(params)}`),
|
||||
dismiss: () => expect.step("dismiss"),
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
await contains(".o_dialog header button[aria-label='Close']").click();
|
||||
expect.verifySteps(["dismiss", 'close {"dismiss":true}']);
|
||||
});
|
||||
|
||||
test("click on the button x triggers the close and dismiss defined by a Child component", async () => {
|
||||
expect.assertions(2);
|
||||
class Child extends Component {
|
||||
static template = xml`<div>Hello</div>`;
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.env.dialogData.close = () => expect.step("close");
|
||||
this.env.dialogData.dismiss = () => expect.step("dismiss");
|
||||
this.env.dialogData.scrollToOrigin = () => {};
|
||||
}
|
||||
}
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
<Child/>
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Child, Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
|
||||
await contains(".o_dialog header button[aria-label='Close']").click();
|
||||
expect.verifySteps(["dismiss", "close"]);
|
||||
});
|
||||
|
||||
test("click on the default footer button triggers the service close", async () => {
|
||||
expect.assertions(2);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv({
|
||||
dialogData: {
|
||||
close: () => expect.step("close"),
|
||||
dismiss: () => expect.step("dismiss"),
|
||||
},
|
||||
});
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
|
||||
await contains(".o_dialog footer button").click();
|
||||
expect.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
test("render custom footer buttons is possible", async () => {
|
||||
expect.assertions(2);
|
||||
class SimpleButtonsDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
content
|
||||
<t t-set-slot="footer">
|
||||
<div>
|
||||
<button class="btn btn-primary">The First Button</button>
|
||||
<button class="btn btn-primary">The Second Button</button>
|
||||
</div>
|
||||
</t>
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<SimpleButtonsDialog/>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { SimpleButtonsDialog };
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({
|
||||
displayDialog: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog footer button").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("embed an arbitrary component in a dialog is possible", async () => {
|
||||
expect.assertions(4);
|
||||
class SubComponent extends Component {
|
||||
static template = xml`
|
||||
<div class="o_subcomponent" t-esc="props.text" t-on-click="_onClick"/>
|
||||
`;
|
||||
static props = ["*"];
|
||||
_onClick() {
|
||||
expect.step("subcomponent-clicked");
|
||||
this.props.onClicked();
|
||||
}
|
||||
}
|
||||
class Parent extends Component {
|
||||
static components = { Dialog, SubComponent };
|
||||
static template = xml`
|
||||
<Dialog>
|
||||
<SubComponent text="'Wow(l) Effect'" onClicked="_onSubcomponentClicked"/>
|
||||
</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
_onSubcomponentClicked() {
|
||||
expect.step("message received by parent");
|
||||
}
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog main .o_subcomponent").toHaveCount(1);
|
||||
expect(".o_subcomponent").toHaveText("Wow(l) Effect");
|
||||
await contains(".o_subcomponent").click();
|
||||
expect.verifySteps(["subcomponent-clicked", "message received by parent"]);
|
||||
});
|
||||
|
||||
test("dialog without header/footer", async () => {
|
||||
expect.assertions(4);
|
||||
class Parent extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`
|
||||
<Dialog header="false" footer="false">content</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog header").toHaveCount(0);
|
||||
expect("main").toHaveCount(1, { message: "a dialog has always a main node" });
|
||||
expect(".o_dialog footer").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dialog size can be chosen", async () => {
|
||||
expect.assertions(5);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<Dialog contentClass="'xl'" size="'xl'">content</Dialog>
|
||||
<Dialog contentClass="'lg'">content</Dialog>
|
||||
<Dialog contentClass="'md'" size="'md'">content</Dialog>
|
||||
<Dialog contentClass="'sm'" size="'sm'">content</Dialog>
|
||||
</div>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(4);
|
||||
expect(".o_dialog .modal-dialog.modal-xl .xl").toHaveCount(1);
|
||||
expect(".o_dialog .modal-dialog.modal-lg .lg").toHaveCount(1);
|
||||
expect(".o_dialog .modal-dialog.modal-md .md").toHaveCount(1);
|
||||
expect(".o_dialog .modal-dialog.modal-sm .sm").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("dialog can be rendered on fullscreen", async () => {
|
||||
expect.assertions(2);
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dialog fullscreen="true">content</Dialog>
|
||||
`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog .modal").toHaveClass("o_modal_full");
|
||||
});
|
||||
|
||||
test("can be the UI active element", async () => {
|
||||
expect.assertions(4);
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
setup() {
|
||||
this.ui = useService("ui");
|
||||
expect(this.ui.activeElement).toBe(document, {
|
||||
message:
|
||||
"UI active element should be the default (document) as Parent is not mounted yet",
|
||||
});
|
||||
onMounted(() => {
|
||||
expect(".modal").toHaveCount(1);
|
||||
expect(this.ui.activeElement).toBe(
|
||||
queryOne(".modal", { message: "UI active element should be the dialog modal" })
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
const parent = await mountWithCleanup(Parent);
|
||||
destroy(parent);
|
||||
await Promise.resolve();
|
||||
expect(getService("ui").activeElement).toBe(document, {
|
||||
message: "UI owner should be reset to the default (document)",
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("dialog can't be moved on small screen", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static components = { Dialog };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".modal-content").toHaveStyle({
|
||||
top: "0px",
|
||||
left: "0px",
|
||||
});
|
||||
|
||||
const header = queryOne(".modal-header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
|
||||
// Even if the `dragAndDrop` is called, confirms that there are no effects
|
||||
await contains(header).dragAndDrop(".modal-content", {
|
||||
position: {
|
||||
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
|
||||
// so we need to move the dialog based on these coordinates.
|
||||
x: headerRect.x + headerRect.width / 2 + 20,
|
||||
y: headerRect.y + headerRect.height / 2 + 50,
|
||||
},
|
||||
});
|
||||
|
||||
expect(".modal-content").toHaveStyle({
|
||||
top: "0px",
|
||||
left: "0px",
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("dialog can be moved", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
|
||||
const modalRect = queryOne(".modal").getBoundingClientRect();
|
||||
const header = queryOne(".modal-header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
await contains(header).dragAndDrop(".modal-content", {
|
||||
position: {
|
||||
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
|
||||
// so we need to move the dialog based on these coordinates.
|
||||
x: headerRect.x + headerRect.width / 2 + 20,
|
||||
y: headerRect.y + headerRect.height / 2 + 50,
|
||||
},
|
||||
});
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: `${modalRect.y + 20}px`,
|
||||
top: `${modalRect.x + 50}px`,
|
||||
});
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("dialog's position is reset on resize", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<Dialog>content</Dialog>`;
|
||||
static props = ["*"];
|
||||
static components = { Dialog };
|
||||
}
|
||||
await makeDialogMockEnv();
|
||||
await mountWithCleanup(Parent);
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
|
||||
const modalRect = queryOne(".modal").getBoundingClientRect();
|
||||
const header = queryOne(".modal-header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
await contains(header).dragAndDrop(".modal-content", {
|
||||
position: {
|
||||
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
|
||||
// so we need to move the dialog based on these coordinates.
|
||||
x: headerRect.x + headerRect.width / 2 + 20,
|
||||
y: headerRect.y + headerRect.height / 2 + 50,
|
||||
},
|
||||
});
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: `${modalRect.y + 20}px`,
|
||||
top: `${modalRect.x + 50}px`,
|
||||
});
|
||||
|
||||
await resize();
|
||||
await animationFrame();
|
||||
expect(".modal-content").toHaveStyle({
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import { test, expect, beforeEach, describe } from "@odoo/hoot";
|
||||
import { click, press, queryAll, queryAllTexts, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { getService, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { useAutofocus } from "@web/core/utils/hooks";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
beforeEach(async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
});
|
||||
|
||||
test("Simple rendering with a single dialog", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="'Welcome'">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
getService("dialog").add(CustomDialog);
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Welcome");
|
||||
await click(".o_dialog footer button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Simple rendering and close a single dialog", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="'Welcome'">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const removeDialog = getService("dialog").add(CustomDialog);
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Welcome");
|
||||
|
||||
removeDialog();
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
|
||||
// Call a second time, the close on the dialog.
|
||||
// As the dialog is already close, this call is just ignored. No error should be raised.
|
||||
removeDialog();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("rendering with two dialogs", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Hello");
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Sauron" });
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
|
||||
await click(".o_dialog footer button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Sauron");
|
||||
});
|
||||
|
||||
test("multiple dialogs can become the UI active element", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Sauron" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Rafiki" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
});
|
||||
|
||||
test("a popover with an autofocus child can become the UI active element", async () => {
|
||||
class TestPopover extends Component {
|
||||
static template = xml`<input type="text" t-ref="autofocus" />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
useAutofocus();
|
||||
}
|
||||
}
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">
|
||||
<button class="btn test" t-on-click="showPopover">show</button>
|
||||
</Dialog>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.popover = usePopover(TestPopover);
|
||||
}
|
||||
showPopover(event) {
|
||||
this.popover.open(event.target, {});
|
||||
}
|
||||
}
|
||||
|
||||
expect(document).toBe(getService("ui").activeElement);
|
||||
expect(document.body).toBeFocused();
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_dialog:not(.o_inactive_modal) .modal")).toBe(
|
||||
getService("ui").activeElement
|
||||
);
|
||||
expect(".btn.test").toBeFocused();
|
||||
|
||||
await click(".btn.test");
|
||||
await animationFrame();
|
||||
expect(queryOne(".o_popover")).toBe(getService("ui").activeElement);
|
||||
expect(".o_popover input").toBeFocused();
|
||||
});
|
||||
|
||||
test("Interactions between multiple dialogs", async () => {
|
||||
function activity(modals) {
|
||||
const active = [];
|
||||
const names = [];
|
||||
for (let i = 0; i < modals.length; i++) {
|
||||
active[i] = !modals[i].classList.contains("o_inactive_modal");
|
||||
names[i] = modals[i].querySelector(".modal-title").textContent;
|
||||
}
|
||||
return { active, names };
|
||||
}
|
||||
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
getService("dialog").add(CustomDialog, { title: "Sauron" });
|
||||
await animationFrame();
|
||||
getService("dialog").add(CustomDialog, { title: "Rafiki" });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_dialog").toHaveCount(3);
|
||||
let res = activity(queryAll(".o_dialog"));
|
||||
expect(res.active).toEqual([false, false, true]);
|
||||
expect(res.names).toEqual(["Hello", "Sauron", "Rafiki"]);
|
||||
|
||||
await press("Escape", { bubbles: true });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
res = activity(queryAll(".o_dialog"));
|
||||
expect(res.active).toEqual([false, true]);
|
||||
expect(res.names).toEqual(["Hello", "Sauron"]);
|
||||
|
||||
await click(".o_dialog:not(.o_inactive_modal) footer button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
res = activity(queryAll(".o_dialog"));
|
||||
expect(res.active).toEqual([true]);
|
||||
expect(res.names).toEqual(["Hello"]);
|
||||
|
||||
await click("footer button");
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dialog component crashes", async () => {
|
||||
expect.errors(1);
|
||||
|
||||
class FailingDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="'Error'">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
throw new Error("Some Error");
|
||||
}
|
||||
}
|
||||
|
||||
getService("dialog").add(FailingDialog);
|
||||
await animationFrame();
|
||||
|
||||
expect(".modal .o_error_dialog").toHaveCount(1);
|
||||
expect.verifyErrors(["Error: Some Error"]);
|
||||
});
|
||||
|
||||
test("two dialogs, close the first one, closeAll", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const close = getService("dialog").add(CustomDialog, { title: "Hello" });
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Hello");
|
||||
|
||||
getService("dialog").add(CustomDialog, { title: "Sauron" });
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
|
||||
|
||||
close();
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Sauron");
|
||||
|
||||
getService("dialog").closeAll();
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("two dialogs, close the first one twice, then closeAll", async () => {
|
||||
class CustomDialog extends Component {
|
||||
static components = { Dialog };
|
||||
static template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
static props = ["*"];
|
||||
}
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
getService("dialog").add(
|
||||
CustomDialog,
|
||||
{ title: "Hello" },
|
||||
{
|
||||
onClose: () => expect.step("close dialog 1"),
|
||||
}
|
||||
);
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Hello");
|
||||
expect(document.body).toHaveClass("modal-open");
|
||||
|
||||
const close = getService("dialog").add(
|
||||
CustomDialog,
|
||||
{ title: "Sauron" },
|
||||
{
|
||||
onClose: () => expect.step("close dialog 2"),
|
||||
}
|
||||
);
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(2);
|
||||
expect(queryAllTexts("header .modal-title")).toEqual(["Hello", "Sauron"]);
|
||||
|
||||
close();
|
||||
close();
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Hello");
|
||||
expect(document.body).toHaveClass("modal-open");
|
||||
expect.verifySteps(["close dialog 2"]);
|
||||
|
||||
getService("dialog").closeAll();
|
||||
await animationFrame();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
expect.verifySteps(["close dialog 1"]);
|
||||
});
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { ErrorDialog } from "@web/core/errors/error_dialogs";
|
||||
import { errorService } from "@web/core/errors/error_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { registerCleanup } from "../helpers/cleanup";
|
||||
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
|
||||
import { makeFakeLocalizationService, makeFakeRPCService } from "../helpers/mock_services";
|
||||
import {
|
||||
click,
|
||||
getFixture,
|
||||
makeDeferred,
|
||||
mount,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
} from "../helpers/utils";
|
||||
import { Dialog } from "../../src/core/dialog/dialog";
|
||||
|
||||
import { Component, onMounted, xml } from "@odoo/owl";
|
||||
|
||||
let env;
|
||||
let target;
|
||||
const serviceRegistry = registry.category("services");
|
||||
const mainComponentRegistry = registry.category("main_components");
|
||||
|
||||
class PseudoWebClient extends Component {
|
||||
setup() {
|
||||
this.Components = mainComponentRegistry.getEntries();
|
||||
}
|
||||
}
|
||||
PseudoWebClient.template = xml`
|
||||
<div>
|
||||
<div>
|
||||
<t t-foreach="Components" t-as="C" t-key="C[0]">
|
||||
<t t-component="C[1].Component" t-props="C[1].props"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
QUnit.module("DialogManager", {
|
||||
async beforeEach() {
|
||||
target = getFixture();
|
||||
clearRegistryWithCleanup(mainComponentRegistry);
|
||||
serviceRegistry.add("dialog", dialogService);
|
||||
serviceRegistry.add("ui", uiService);
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
serviceRegistry.add("l10n", makeFakeLocalizationService());
|
||||
|
||||
env = await makeTestEnv();
|
||||
},
|
||||
});
|
||||
QUnit.test("Simple rendering with a single dialog", async (assert) => {
|
||||
assert.expect(4);
|
||||
class CustomDialog extends Component {}
|
||||
CustomDialog.components = { Dialog };
|
||||
CustomDialog.template = xml`<Dialog title="'Welcome'">content</Dialog>`;
|
||||
await mount(PseudoWebClient, target, { env });
|
||||
assert.containsNone(target, ".o_dialog_container .o_dialog");
|
||||
env.services.dialog.add(CustomDialog);
|
||||
await nextTick();
|
||||
assert.containsOnce(target, ".o_dialog_container .o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Welcome");
|
||||
await click(target.querySelector(".o_dialog_container .o_dialog footer button"));
|
||||
assert.containsNone(target, ".o_dialog_container .o_dialog");
|
||||
});
|
||||
|
||||
QUnit.test("Simple rendering and close a single dialog", async (assert) => {
|
||||
assert.expect(4);
|
||||
|
||||
class CustomDialog extends Component {}
|
||||
CustomDialog.components = { Dialog };
|
||||
CustomDialog.template = xml`<Dialog title="'Welcome'">content</Dialog>`;
|
||||
|
||||
await mount(PseudoWebClient, target, { env });
|
||||
assert.containsNone(target, ".o_dialog_container .o_dialog");
|
||||
|
||||
const removeDialog = env.services.dialog.add(CustomDialog);
|
||||
await nextTick();
|
||||
assert.containsOnce(target, ".o_dialog_container .o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Welcome");
|
||||
|
||||
removeDialog();
|
||||
await nextTick();
|
||||
assert.containsNone(target, ".o_dialog_container .o_dialog");
|
||||
|
||||
// Call a second time, the close on the dialog.
|
||||
// As the dialog is already close, this call is just ignored. No error should be raised.
|
||||
removeDialog();
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
QUnit.test("rendering with two dialogs", async (assert) => {
|
||||
assert.expect(7);
|
||||
class CustomDialog extends Component {}
|
||||
CustomDialog.components = { Dialog };
|
||||
CustomDialog.template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
|
||||
await mount(PseudoWebClient, target, { env });
|
||||
assert.containsNone(target, ".o_dialog_container .o_dialog");
|
||||
env.services.dialog.add(CustomDialog, { title: "Hello" });
|
||||
await nextTick();
|
||||
assert.containsOnce(target, ".o_dialog_container .o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Hello");
|
||||
env.services.dialog.add(CustomDialog, { title: "Sauron" });
|
||||
await nextTick();
|
||||
assert.containsN(target, ".o_dialog_container .o_dialog", 2);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("header .modal-title")].map((el) => el.textContent),
|
||||
["Hello", "Sauron"]
|
||||
);
|
||||
await click(target.querySelector(".o_dialog_container .o_dialog footer button"));
|
||||
assert.containsOnce(target, ".o_dialog_container .o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Sauron");
|
||||
});
|
||||
|
||||
QUnit.test("multiple dialogs can become the UI active element", async (assert) => {
|
||||
assert.expect(3);
|
||||
class CustomDialog extends Component {}
|
||||
CustomDialog.components = { Dialog };
|
||||
CustomDialog.template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
await mount(PseudoWebClient, target, { env });
|
||||
|
||||
env.services.dialog.add(CustomDialog, { title: "Hello" });
|
||||
await nextTick();
|
||||
let dialogModal = target.querySelector(
|
||||
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
|
||||
);
|
||||
|
||||
assert.strictEqual(dialogModal, env.services.ui.activeElement);
|
||||
|
||||
env.services.dialog.add(CustomDialog, { title: "Sauron" });
|
||||
await nextTick();
|
||||
dialogModal = target.querySelector(
|
||||
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
|
||||
);
|
||||
|
||||
assert.strictEqual(dialogModal, env.services.ui.activeElement);
|
||||
|
||||
env.services.dialog.add(CustomDialog, { title: "Rafiki" });
|
||||
await nextTick();
|
||||
dialogModal = target.querySelector(
|
||||
".o_dialog_container .o_dialog .modal:not(.o_inactive_modal)"
|
||||
);
|
||||
|
||||
assert.strictEqual(dialogModal, env.services.ui.activeElement);
|
||||
});
|
||||
|
||||
QUnit.test("Interactions between multiple dialogs", async (assert) => {
|
||||
assert.expect(14);
|
||||
function activity(modals) {
|
||||
const active = [];
|
||||
const names = [];
|
||||
for (let i = 0; i < modals.length; i++) {
|
||||
active[i] = !modals[i].classList.contains("o_inactive_modal");
|
||||
names[i] = modals[i].querySelector(".modal-title").textContent;
|
||||
}
|
||||
return { active, names };
|
||||
}
|
||||
|
||||
class CustomDialog extends Component {}
|
||||
CustomDialog.components = { Dialog };
|
||||
CustomDialog.template = xml`<Dialog title="props.title">content</Dialog>`;
|
||||
await mount(PseudoWebClient, target, { env });
|
||||
|
||||
env.services.dialog.add(CustomDialog, { title: "Hello" });
|
||||
await nextTick();
|
||||
env.services.dialog.add(CustomDialog, { title: "Sauron" });
|
||||
await nextTick();
|
||||
env.services.dialog.add(CustomDialog, { title: "Rafiki" });
|
||||
await nextTick();
|
||||
|
||||
let modals = document.querySelectorAll(".modal");
|
||||
assert.containsN(target, ".o_dialog", 3);
|
||||
let res = activity(modals);
|
||||
assert.deepEqual(res.active, [false, false, true]);
|
||||
assert.deepEqual(res.names, ["Hello", "Sauron", "Rafiki"]);
|
||||
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
|
||||
|
||||
let lastDialog = modals[modals.length - 1];
|
||||
lastDialog.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
await nextTick();
|
||||
modals = document.querySelectorAll(".modal");
|
||||
assert.containsN(target, ".o_dialog", 2);
|
||||
res = activity(modals);
|
||||
assert.deepEqual(res.active, [false, true]);
|
||||
assert.deepEqual(res.names, ["Hello", "Sauron"]);
|
||||
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
|
||||
|
||||
lastDialog = modals[modals.length - 1];
|
||||
await click(lastDialog, "footer button");
|
||||
modals = document.querySelectorAll(".modal");
|
||||
assert.containsN(target, ".o_dialog", 1);
|
||||
res = activity(modals);
|
||||
assert.deepEqual(res.active, [true]);
|
||||
assert.deepEqual(res.names, ["Hello"]);
|
||||
assert.hasClass(target.querySelector(".o_dialog_container"), "modal-open");
|
||||
|
||||
lastDialog = modals[modals.length - 1];
|
||||
await click(lastDialog, "footer button");
|
||||
assert.containsNone(target, ".o_dialog_container .modal");
|
||||
assert.containsOnce(target, ".o_dialog_container");
|
||||
});
|
||||
|
||||
QUnit.test("dialog component crashes", async (assert) => {
|
||||
assert.expect(4);
|
||||
|
||||
class FailingDialog extends Component {
|
||||
setup() {
|
||||
throw new Error("Some Error");
|
||||
}
|
||||
}
|
||||
FailingDialog.components = { Dialog };
|
||||
FailingDialog.template = xml`<Dialog title="'Error'">content</Dialog>`;
|
||||
|
||||
const prom = makeDeferred();
|
||||
patchWithCleanup(ErrorDialog.prototype, {
|
||||
setup() {
|
||||
this._super();
|
||||
onMounted(() => {
|
||||
prom.resolve();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handler = (ev) => {
|
||||
assert.step("error");
|
||||
// need to preventDefault to remove error from console (so python test pass)
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener("unhandledrejection", handler);
|
||||
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
|
||||
patchWithCleanup(QUnit, {
|
||||
onUnhandledRejection: () => {},
|
||||
});
|
||||
|
||||
const rpc = makeFakeRPCService();
|
||||
serviceRegistry.add("rpc", rpc);
|
||||
serviceRegistry.add("notification", notificationService);
|
||||
serviceRegistry.add("error", errorService);
|
||||
|
||||
await mount(PseudoWebClient, target, { env });
|
||||
|
||||
env.services.dialog.add(FailingDialog);
|
||||
await prom;
|
||||
|
||||
assert.verifySteps(["error"]);
|
||||
assert.containsOnce(target, ".modal");
|
||||
assert.containsOnce(target, ".modal .o_dialog_error");
|
||||
});
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import { click, destroy, getFixture, mount } from "../helpers/utils";
|
||||
import { makeFakeDialogService } from "../helpers/mock_services";
|
||||
|
||||
import { Component, useState, onMounted, xml } from "@odoo/owl";
|
||||
const serviceRegistry = registry.category("services");
|
||||
let parent;
|
||||
let target;
|
||||
|
||||
async function makeDialogTestEnv() {
|
||||
const env = await makeTestEnv();
|
||||
env.dialogData = {
|
||||
isActive: true,
|
||||
close: () => {},
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
QUnit.module("Components", (hooks) => {
|
||||
hooks.beforeEach(async () => {
|
||||
target = getFixture();
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
serviceRegistry.add("ui", uiService);
|
||||
serviceRegistry.add("dialog", makeFakeDialogService());
|
||||
});
|
||||
hooks.afterEach(() => {
|
||||
if (parent) {
|
||||
parent = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.module("Dialog");
|
||||
|
||||
QUnit.test("simple rendering", async function (assert) {
|
||||
assert.expect(8);
|
||||
class Parent extends Component {}
|
||||
Parent.components = { Dialog };
|
||||
Parent.template = xml`
|
||||
<Dialog title="'Wow(l) Effect'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
|
||||
const env = await makeDialogTestEnv();
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_dialog header .modal-title",
|
||||
"the header is rendered by default"
|
||||
);
|
||||
assert.strictEqual(
|
||||
target.querySelector("header .modal-title").textContent,
|
||||
"Wow(l) Effect"
|
||||
);
|
||||
assert.containsOnce(target, ".o_dialog main", "a dialog has always a main node");
|
||||
assert.strictEqual(target.querySelector("main").textContent, " Hello! ");
|
||||
assert.containsOnce(target, ".o_dialog footer", "the footer is rendered by default");
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_dialog footer button",
|
||||
"the footer is rendered with a single button 'Ok' by default"
|
||||
);
|
||||
assert.strictEqual(target.querySelector("footer button").textContent, "Ok");
|
||||
});
|
||||
|
||||
QUnit.test("simple rendering with two dialogs", async function (assert) {
|
||||
assert.expect(3);
|
||||
class Parent extends Component {}
|
||||
Parent.template = xml`
|
||||
<div>
|
||||
<Dialog title="'First Title'">
|
||||
Hello!
|
||||
</Dialog>
|
||||
<Dialog title="'Second Title'">
|
||||
Hello again!
|
||||
</Dialog>
|
||||
</div>
|
||||
`;
|
||||
Parent.components = { Dialog };
|
||||
const env = await makeDialogTestEnv();
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsN(target, ".o_dialog", 2);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("header .modal-title")].map((el) => el.textContent),
|
||||
["First Title", "Second Title"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll(".o_dialog .modal-body")].map((el) => el.textContent),
|
||||
[" Hello! ", " Hello again! "]
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("click on the button x triggers the service close", async function (assert) {
|
||||
assert.expect(3);
|
||||
const env = await makeDialogTestEnv();
|
||||
env.dialogData.close = () => assert.step("close");
|
||||
|
||||
class Parent extends Component {}
|
||||
Parent.template = xml`
|
||||
<Dialog>
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
Parent.components = { Dialog };
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
await click(target, ".o_dialog header button.btn-close");
|
||||
assert.verifySteps(["close"]);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"click on the default footer button triggers the service close",
|
||||
async function (assert) {
|
||||
const env = await makeDialogTestEnv();
|
||||
env.dialogData.close = () => assert.step("close");
|
||||
assert.expect(3);
|
||||
class Parent extends Component {}
|
||||
|
||||
Parent.template = xml`
|
||||
<Dialog>
|
||||
Hello!
|
||||
</Dialog>
|
||||
`;
|
||||
Parent.components = { Dialog };
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
await click(target, ".o_dialog footer button");
|
||||
assert.verifySteps(["close"]);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("render custom footer buttons is possible", async function (assert) {
|
||||
assert.expect(2);
|
||||
class SimpleButtonsDialog extends Component {}
|
||||
SimpleButtonsDialog.components = { Dialog };
|
||||
SimpleButtonsDialog.template = xml`
|
||||
<Dialog>
|
||||
content
|
||||
<t t-set-slot="footer">
|
||||
<div>
|
||||
<button class="btn btn-primary">The First Button</button>
|
||||
<button class="btn btn-primary">The Second Button</button>
|
||||
</div>
|
||||
</t>
|
||||
</Dialog>
|
||||
`;
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.state = useState({
|
||||
displayDialog: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Parent.template = xml`
|
||||
<div>
|
||||
<SimpleButtonsDialog/>
|
||||
</div>
|
||||
`;
|
||||
Parent.components = { SimpleButtonsDialog };
|
||||
const env = await makeDialogTestEnv();
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.containsN(target, ".o_dialog footer button", 2);
|
||||
});
|
||||
|
||||
QUnit.test("embed an arbitrary component in a dialog is possible", async function (assert) {
|
||||
assert.expect(6);
|
||||
class SubComponent extends Component {
|
||||
_onClick() {
|
||||
assert.step("subcomponent-clicked");
|
||||
this.props.onClicked();
|
||||
}
|
||||
}
|
||||
SubComponent.template = xml`
|
||||
<div class="o_subcomponent" t-esc="props.text" t-on-click="_onClick"/>
|
||||
`;
|
||||
class Parent extends Component {
|
||||
_onSubcomponentClicked() {
|
||||
assert.step("message received by parent");
|
||||
}
|
||||
}
|
||||
Parent.components = { Dialog, SubComponent };
|
||||
Parent.template = xml`
|
||||
<Dialog>
|
||||
<SubComponent text="'Wow(l) Effect'" onClicked="_onSubcomponentClicked"/>
|
||||
</Dialog>
|
||||
`;
|
||||
const env = await makeDialogTestEnv();
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.containsOnce(target, ".o_dialog main .o_subcomponent");
|
||||
assert.strictEqual(target.querySelector(".o_subcomponent").textContent, "Wow(l) Effect");
|
||||
await click(target.querySelector(".o_subcomponent"));
|
||||
assert.verifySteps(["subcomponent-clicked", "message received by parent"]);
|
||||
});
|
||||
|
||||
QUnit.test("dialog without header/footer", async function (assert) {
|
||||
assert.expect(4);
|
||||
class Parent extends Component {}
|
||||
Parent.template = xml`
|
||||
<Dialog header="false" footer="false">content</Dialog>
|
||||
`;
|
||||
const env = await makeDialogTestEnv();
|
||||
Parent.components = { Dialog };
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.containsNone(target, ".o_dialog header");
|
||||
assert.containsOnce(target, "main", "a dialog has always a main node");
|
||||
assert.containsNone(target, ".o_dialog footer");
|
||||
});
|
||||
|
||||
QUnit.test("dialog size can be chosen", async function (assert) {
|
||||
assert.expect(5);
|
||||
class Parent extends Component {}
|
||||
Parent.template = xml`
|
||||
<div>
|
||||
<Dialog contentClass="'xl'" size="'xl'">content</Dialog>
|
||||
<Dialog contentClass="'lg'">content</Dialog>
|
||||
<Dialog contentClass="'md'" size="'md'">content</Dialog>
|
||||
<Dialog contentClass="'sm'" size="'sm'">content</Dialog>
|
||||
</div>`;
|
||||
Parent.components = { Dialog };
|
||||
const env = await makeDialogTestEnv();
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsN(target, ".o_dialog", 4);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
target.querySelectorAll(".o_dialog .modal-dialog.modal-xl .xl")
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
target.querySelectorAll(".o_dialog .modal-dialog.modal-lg .lg")
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
target.querySelectorAll(".o_dialog .modal-dialog.modal-md .md")
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
target.querySelectorAll(".o_dialog .modal-dialog.modal-sm .sm")
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("dialog can be rendered on fullscreen", async function (assert) {
|
||||
assert.expect(2);
|
||||
class Parent extends Component {}
|
||||
Parent.template = xml`
|
||||
<Dialog fullscreen="true">content</Dialog>
|
||||
`;
|
||||
Parent.components = { Dialog };
|
||||
const env = await makeDialogTestEnv();
|
||||
parent = await mount(Parent, target, { env });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.hasClass(target.querySelector(".o_dialog .modal"), "o_modal_full");
|
||||
});
|
||||
|
||||
QUnit.test("can be the UI active element", async function (assert) {
|
||||
assert.expect(4);
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.ui = useService("ui");
|
||||
assert.strictEqual(
|
||||
this.ui.activeElement,
|
||||
document,
|
||||
"UI active element should be the default (document) as Parent is not mounted yet"
|
||||
);
|
||||
onMounted(() => {
|
||||
assert.containsOnce(target, ".modal");
|
||||
assert.strictEqual(
|
||||
this.ui.activeElement,
|
||||
target.querySelector(".modal"),
|
||||
"UI active element should be the dialog modal"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
const env = await makeDialogTestEnv();
|
||||
Parent.template = xml`<Dialog>content</Dialog>`;
|
||||
Parent.components = { Dialog };
|
||||
|
||||
const parent = await mount(Parent, target, { env });
|
||||
destroy(parent);
|
||||
|
||||
assert.strictEqual(
|
||||
env.services.ui.activeElement,
|
||||
document,
|
||||
"UI owner should be reset to the default (document)"
|
||||
);
|
||||
});
|
||||
});
|
||||
597
odoo-bringout-oca-ocb-web/web/static/tests/core/domain.test.js
Normal file
597
odoo-bringout-oca-ocb-web/web/static/tests/core/domain.test.js
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
|
||||
import { patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { PyDate } from "@web/core/py_js/py_date";
|
||||
|
||||
describe.current.tags("headless");
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Basic properties
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
describe("Basic Properties", () => {
|
||||
test("empty", () => {
|
||||
expect(new Domain([]).contains({})).toBe(true);
|
||||
expect(new Domain([]).toString()).toBe("[]");
|
||||
expect(new Domain([]).toList()).toEqual([]);
|
||||
});
|
||||
|
||||
test("undefined domain", () => {
|
||||
expect(new Domain(undefined).contains({})).toBe(true);
|
||||
expect(new Domain(undefined).toString()).toBe("[]");
|
||||
expect(new Domain(undefined).toList()).toEqual([]);
|
||||
});
|
||||
|
||||
test("simple condition", () => {
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
|
||||
expect(new Domain([["a", "=", 3]]).toList()).toEqual([["a", "=", 3]]);
|
||||
});
|
||||
|
||||
test("can be created from domain", () => {
|
||||
const domain = new Domain([["a", "=", 3]]);
|
||||
expect(new Domain(domain).toString()).toBe(`[("a", "=", 3)]`);
|
||||
});
|
||||
|
||||
test("basic", () => {
|
||||
const record = {
|
||||
a: 3,
|
||||
group_method: "line",
|
||||
select1: "day",
|
||||
rrule_type: "monthly",
|
||||
};
|
||||
expect(new Domain([["a", "=", 3]]).contains(record)).toBe(true);
|
||||
expect(new Domain([["a", "=", 5]]).contains(record)).toBe(false);
|
||||
expect(new Domain([["group_method", "!=", "count"]]).contains(record)).toBe(true);
|
||||
expect(
|
||||
new Domain([
|
||||
["select1", "=", "day"],
|
||||
["rrule_type", "=", "monthly"],
|
||||
]).contains(record)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("support of '=?' operator", () => {
|
||||
const record = { a: 3 };
|
||||
expect(new Domain([["a", "=?", null]]).contains(record)).toBe(true);
|
||||
expect(new Domain([["a", "=?", false]]).contains(record)).toBe(true);
|
||||
expect(new Domain(["!", ["a", "=?", false]]).contains(record)).toBe(false);
|
||||
expect(new Domain([["a", "=?", 1]]).contains(record)).toBe(false);
|
||||
expect(new Domain([["a", "=?", 3]]).contains(record)).toBe(true);
|
||||
expect(new Domain(["!", ["a", "=?", 3]]).contains(record)).toBe(false);
|
||||
});
|
||||
|
||||
test("or", () => {
|
||||
const currentDomain = [
|
||||
"|",
|
||||
["section_id", "=", 42],
|
||||
"|",
|
||||
["user_id", "=", 3],
|
||||
["member_ids", "in", [3]],
|
||||
];
|
||||
const record = {
|
||||
section_id: null,
|
||||
user_id: null,
|
||||
member_ids: null,
|
||||
};
|
||||
expect(new Domain(currentDomain).contains({ ...record, section_id: 42 })).toBe(true);
|
||||
expect(new Domain(currentDomain).contains({ ...record, user_id: 3 })).toBe(true);
|
||||
expect(new Domain(currentDomain).contains({ ...record, member_ids: 3 })).toBe(true);
|
||||
});
|
||||
|
||||
test("and", () => {
|
||||
const domain = new Domain(["&", "&", ["a", "=", 1], ["b", "=", 2], ["c", "=", 3]]);
|
||||
|
||||
expect(domain.contains({ a: 1, b: 2, c: 3 })).toBe(true);
|
||||
expect(domain.contains({ a: -1, b: 2, c: 3 })).toBe(false);
|
||||
expect(domain.contains({ a: 1, b: -1, c: 3 })).toBe(false);
|
||||
expect(domain.contains({ a: 1, b: 2, c: -1 })).toBe(false);
|
||||
});
|
||||
|
||||
test("not", () => {
|
||||
const record = {
|
||||
a: 5,
|
||||
group_method: "line",
|
||||
};
|
||||
expect(new Domain(["!", ["a", "=", 3]]).contains(record)).toBe(true);
|
||||
expect(new Domain(["!", ["group_method", "=", "count"]]).contains(record)).toBe(true);
|
||||
});
|
||||
|
||||
test("like, =like, ilike, =ilike and not likes", () => {
|
||||
expect.assertions(34);
|
||||
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: "Some Value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "like", "value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: "Some Value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "=like", "%value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: "Some Value" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "value" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "some value" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: "Some Value" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "%value"]]).contains({ a: false })).toBe(false);
|
||||
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "some value" })).not.toBe(
|
||||
true
|
||||
);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "Some Value" })).toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "something" })).toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: "Something" })).toBe(true);
|
||||
expect(new Domain([["a", "not like", "value"]]).contains({ a: false })).toBe(true);
|
||||
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "value" })).not.toBe(true);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "some value" })).toBe(false);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "Some Value" })).toBe(false);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "something" })).toBe(true);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: "Something" })).toBe(true);
|
||||
expect(new Domain([["a", "not ilike", "value"]]).contains({ a: false })).toBe(true);
|
||||
|
||||
expect(new Domain([["a", "not =like", "%value"]]).contains({ a: "some value" })).toBe(false);
|
||||
expect(new Domain([["a", "not =like", "%value"]]).contains({ a: "Some Value" })).not.toBe(false);
|
||||
|
||||
expect(new Domain([["a", "not =ilike", "%value"]]).contains({ a: "value" })).toBe(false);
|
||||
expect(new Domain([["a", "not =ilike", "%value"]]).contains({ a: "some value" })).toBe(false);
|
||||
expect(new Domain([["a", "not =ilike", "%value"]]).contains({ a: "Some Value" })).toBe(false);
|
||||
expect(new Domain([["a", "not =ilike", "%value"]]).contains({ a: false })).toBe(true);
|
||||
});
|
||||
|
||||
test("complex domain", () => {
|
||||
const domain = new Domain(["&", "!", ["a", "=", 1], "|", ["a", "=", 2], ["a", "=", 3]]);
|
||||
|
||||
expect(domain.contains({ a: 1 })).toBe(false);
|
||||
expect(domain.contains({ a: 2 })).toBe(true);
|
||||
expect(domain.contains({ a: 3 })).toBe(true);
|
||||
expect(domain.contains({ a: 4 })).toBe(false);
|
||||
});
|
||||
|
||||
test("toList", () => {
|
||||
expect(new Domain([]).toList()).toEqual([]);
|
||||
expect(new Domain([["a", "=", 3]]).toList()).toEqual([["a", "=", 3]]);
|
||||
expect(
|
||||
new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "!=", "4"],
|
||||
]).toList()
|
||||
).toEqual(["&", ["a", "=", 3], ["b", "!=", "4"]]);
|
||||
expect(new Domain(["!", ["a", "=", 3]]).toList()).toEqual(["!", ["a", "=", 3]]);
|
||||
});
|
||||
|
||||
test("toString", () => {
|
||||
expect(new Domain([]).toString()).toBe(`[]`);
|
||||
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
|
||||
expect(
|
||||
new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "!=", "4"],
|
||||
]).toString()
|
||||
).toBe(`["&", ("a", "=", 3), ("b", "!=", "4")]`);
|
||||
expect(new Domain(["!", ["a", "=", 3]]).toString()).toBe(`["!", ("a", "=", 3)]`);
|
||||
expect(new Domain([["name", "=", null]]).toString()).toBe('[("name", "=", None)]');
|
||||
expect(new Domain([["name", "=", false]]).toString()).toBe('[("name", "=", False)]');
|
||||
expect(new Domain([["name", "=", true]]).toString()).toBe('[("name", "=", True)]');
|
||||
expect(new Domain([["name", "=", "null"]]).toString()).toBe('[("name", "=", "null")]');
|
||||
expect(new Domain([["name", "=", "false"]]).toString()).toBe('[("name", "=", "false")]');
|
||||
expect(new Domain([["name", "=", "true"]]).toString()).toBe('[("name", "=", "true")]');
|
||||
expect(new Domain().toString()).toBe("[]");
|
||||
expect(new Domain([["name", "in", [true, false]]]).toString()).toBe(
|
||||
'[("name", "in", [True, False])]'
|
||||
);
|
||||
expect(new Domain([["name", "in", [null]]]).toString()).toBe('[("name", "in", [None])]');
|
||||
expect(new Domain([["name", "in", ["foo", "bar"]]]).toString()).toBe(
|
||||
'[("name", "in", ["foo", "bar"])]'
|
||||
);
|
||||
expect(new Domain([["name", "in", [1, 2]]]).toString()).toBe('[("name", "in", [1, 2])]');
|
||||
expect(new Domain(["&", ["name", "=", "foo"], ["type", "=", "bar"]]).toString()).toBe(
|
||||
'["&", ("name", "=", "foo"), ("type", "=", "bar")]'
|
||||
);
|
||||
expect(new Domain(["|", ["name", "=", "foo"], ["type", "=", "bar"]]).toString()).toBe(
|
||||
'["|", ("name", "=", "foo"), ("type", "=", "bar")]'
|
||||
);
|
||||
expect(new Domain().toString()).toBe("[]");
|
||||
|
||||
// string domains are only reformatted
|
||||
expect(new Domain('[("name","ilike","foo")]').toString()).toBe(
|
||||
'[("name", "ilike", "foo")]'
|
||||
);
|
||||
});
|
||||
|
||||
test("toJson", () => {
|
||||
expect(new Domain([]).toJson()).toEqual([]);
|
||||
expect(new Domain("[]").toJson()).toEqual([]);
|
||||
expect(new Domain([["a", "=", 3]]).toJson()).toEqual([["a", "=", 3]]);
|
||||
expect(new Domain('[("a", "=", 3)]').toJson()).toEqual([["a", "=", 3]]);
|
||||
expect(new Domain('[("user_id", "=", uid)]').toJson()).toBe('[("user_id", "=", uid)]');
|
||||
expect(new Domain('[("date", "=", context_today())]').toJson()).toBe(
|
||||
'[("date", "=", context_today())]'
|
||||
);
|
||||
});
|
||||
|
||||
test("implicit &", () => {
|
||||
const domain = new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "=", 4],
|
||||
]);
|
||||
expect(domain.contains({})).toBe(false);
|
||||
expect(domain.contains({ a: 3, b: 4 })).toBe(true);
|
||||
expect(domain.contains({ a: 3, b: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
test("comparison operators", () => {
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "=", 3]]).contains({ a: 4 })).toBe(false);
|
||||
expect(new Domain([["a", "=", 3]]).toString()).toBe(`[("a", "=", 3)]`);
|
||||
expect(new Domain([["a", "==", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "==", 3]]).contains({ a: 4 })).toBe(false);
|
||||
expect(new Domain([["a", "==", 3]]).toString()).toBe(`[("a", "==", 3)]`);
|
||||
expect(new Domain([["a", "!=", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "!=", 3]]).contains({ a: 4 })).toBe(true);
|
||||
expect(new Domain([["a", "!=", 3]]).toString()).toBe(`[("a", "!=", 3)]`);
|
||||
expect(new Domain([["a", "<>", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "<>", 3]]).contains({ a: 4 })).toBe(true);
|
||||
expect(new Domain([["a", "<>", 3]]).toString()).toBe(`[("a", "<>", 3)]`);
|
||||
expect(new Domain([["a", "<", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "<", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "<", 3]]).contains({ a: 2 })).toBe(true);
|
||||
expect(new Domain([["a", "<", 3]]).toString()).toBe(`[("a", "<", 3)]`);
|
||||
expect(new Domain([["a", "<=", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "<=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "<=", 3]]).contains({ a: 2 })).toBe(true);
|
||||
expect(new Domain([["a", "<=", 3]]).toString()).toBe(`[("a", "<=", 3)]`);
|
||||
expect(new Domain([["a", ">", 3]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", ">", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", ">", 3]]).contains({ a: 2 })).toBe(false);
|
||||
expect(new Domain([["a", ">", 3]]).toString()).toBe(`[("a", ">", 3)]`);
|
||||
expect(new Domain([["a", ">=", 3]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", ">=", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", ">=", 3]]).contains({ a: 2 })).toBe(false);
|
||||
expect(new Domain([["a", ">=", 3]]).toString()).toBe(`[("a", ">=", 3)]`);
|
||||
});
|
||||
|
||||
test("other operators", () => {
|
||||
expect(new Domain([["a", "in", 3]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [3] })).toBe(true);
|
||||
expect(new Domain([["a", "in", 3]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 5 })).toBe(false);
|
||||
expect(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [5] })).toBe(false);
|
||||
expect(new Domain([["a", "not in", 3]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 3 })).toBe(false);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [3] })).toBe(false);
|
||||
expect(new Domain([["a", "not in", 3]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 5 })).toBe(true);
|
||||
expect(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [5] })).toBe(true);
|
||||
expect(new Domain([["a", "like", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "like", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
expect(new Domain([["a", "=like", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "=like", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
expect(new Domain([["a", "ilike", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "ilike", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
expect(new Domain([["a", "=ilike", "abc"]]).contains({ a: "abc" })).toBe(true);
|
||||
expect(new Domain([["a", "=ilike", "abc"]]).contains({ a: "def" })).toBe(false);
|
||||
});
|
||||
|
||||
test("creating a domain with a string expression", () => {
|
||||
expect(new Domain(`[('a', '>=', 3)]`).toString()).toBe(`[("a", ">=", 3)]`);
|
||||
expect(new Domain(`[('a', '>=', 3)]`).contains({ a: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
test("can evaluate a python expression", () => {
|
||||
expect(new Domain(`[('date', '!=', False)]`).toList()).toEqual([["date", "!=", false]]);
|
||||
expect(new Domain(`[('date', '!=', False)]`).toList()).toEqual([["date", "!=", false]]);
|
||||
expect(new Domain(`[('date', '!=', 1 + 2)]`).toString()).toBe(`[("date", "!=", 1 + 2)]`);
|
||||
expect(new Domain(`[('date', '!=', 1 + 2)]`).toList()).toEqual([["date", "!=", 3]]);
|
||||
expect(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 3 })).toBe(true);
|
||||
expect(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
test("some expression with date stuff", () => {
|
||||
patchWithCleanup(PyDate, {
|
||||
today() {
|
||||
return new PyDate(2013, 4, 24);
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
new Domain(
|
||||
"[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"
|
||||
).toList()
|
||||
).toEqual([["date", ">=", "2013-03-25"]]);
|
||||
|
||||
const domainList = new Domain(
|
||||
"[('date', '>=', context_today() - relativedelta(days=30))]"
|
||||
).toList(); // domain creation using `parseExpr` function since the parameter is a string.
|
||||
|
||||
expect(domainList[0][2]).toEqual(PyDate.create({ day: 25, month: 3, year: 2013 }), {
|
||||
message: "The right item in the rule in the domain should be a PyDate object",
|
||||
});
|
||||
expect(JSON.stringify(domainList)).toBe('[["date",">=","2013-03-25"]]');
|
||||
|
||||
const domainList2 = new Domain(domainList).toList(); // domain creation using `toAST` function since the parameter is a list.
|
||||
expect(domainList2[0][2]).toEqual(PyDate.create({ day: 25, month: 3, year: 2013 }), {
|
||||
message: "The right item in the rule in the domain should be a PyDate object",
|
||||
});
|
||||
expect(JSON.stringify(domainList2)).toBe('[["date",">=","2013-03-25"]]');
|
||||
});
|
||||
|
||||
test("Check that there is no dependency between two domains", () => {
|
||||
// The purpose of this test is to verify that a domain created on the basis
|
||||
// of another one does not share any dependency.
|
||||
const domain1 = new Domain(`[('date', '!=', False)]`);
|
||||
const domain2 = new Domain(domain1);
|
||||
expect(domain1.toString()).toBe(domain2.toString());
|
||||
|
||||
domain2.ast.value.unshift({ type: 1, value: "!" });
|
||||
expect(domain1.toString()).not.toBe(domain2.toString());
|
||||
});
|
||||
|
||||
test("TRUE and FALSE Domain", () => {
|
||||
expect(Domain.TRUE.contains({})).toBe(true);
|
||||
expect(Domain.FALSE.contains({})).toBe(false);
|
||||
|
||||
expect(Domain.and([Domain.TRUE, new Domain([["a", "=", 3]])]).contains({ a: 3 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(Domain.and([Domain.FALSE, new Domain([["a", "=", 3]])]).contains({ a: 3 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid domains should not succeed", () => {
|
||||
expect(() => new Domain(["|", ["hr_presence_state", "=", "absent"]])).toThrow(
|
||||
/invalid domain .* \(missing 1 segment/
|
||||
);
|
||||
expect(
|
||||
() =>
|
||||
new Domain([
|
||||
"|",
|
||||
"|",
|
||||
["hr_presence_state", "=", "absent"],
|
||||
["attendance_state", "=", "checked_in"],
|
||||
])
|
||||
).toThrow(/invalid domain .* \(missing 1 segment/);
|
||||
expect(() => new Domain(["|", "|", ["hr_presence_state", "=", "absent"]])).toThrow(
|
||||
/invalid domain .* \(missing 2 segment\(s\)/
|
||||
);
|
||||
expect(() => new Domain(["&", ["composition_mode", "!=", "mass_post"]])).toThrow(
|
||||
/invalid domain .* \(missing 1 segment/
|
||||
);
|
||||
expect(() => new Domain(["!"])).toThrow(/invalid domain .* \(missing 1 segment/);
|
||||
expect(() => new Domain(`[(1, 2)]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[(1, 2, 3, 4)]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`["a"]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[1]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[x]`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`[True]`)).toThrow(/Invalid domain AST/); // will possibly change with CHM work
|
||||
expect(() => new Domain(`[(x.=, "=", 1)]`)).toThrow(/Invalid domain representation/);
|
||||
expect(() => new Domain(`[(+, "=", 1)]`)).toThrow(/Invalid domain representation/);
|
||||
expect(() => new Domain([{}])).toThrow(/Invalid domain representation/);
|
||||
expect(() => new Domain([1])).toThrow(/Invalid domain representation/);
|
||||
});
|
||||
|
||||
test("follow relations", () => {
|
||||
expect(
|
||||
new Domain([["partner.city", "ilike", "Bru"]]).contains({
|
||||
name: "Lucas",
|
||||
partner: {
|
||||
city: "Bruxelles",
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
new Domain([["partner.city.name", "ilike", "Bru"]]).contains({
|
||||
name: "Lucas",
|
||||
partner: {
|
||||
city: {
|
||||
name: "Bruxelles",
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("Arrays comparison", () => {
|
||||
const domain = new Domain(["&", ["a", "==", []], ["b", "!=", []]]);
|
||||
|
||||
expect(domain.contains({ a: [] })).toBe(true);
|
||||
expect(domain.contains({ a: [], b: [4] })).toBe(true);
|
||||
expect(domain.contains({ a: [1] })).toBe(false);
|
||||
expect(domain.contains({ b: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Normalization", () => {
|
||||
test("return simple (normalized) domains", () => {
|
||||
const domains = ["[]", `[("a", "=", 1)]`, `["!", ("a", "=", 1)]`];
|
||||
for (const domain of domains) {
|
||||
expect(new Domain(domain).toString()).toBe(domain);
|
||||
}
|
||||
});
|
||||
|
||||
test("properly add the & in a non normalized domain", () => {
|
||||
expect(new Domain(`[("a", "=", 1), ("b", "=", 2)]`).toString()).toBe(
|
||||
`["&", ("a", "=", 1), ("b", "=", 2)]`
|
||||
);
|
||||
});
|
||||
|
||||
test("normalize domain with ! operator", () => {
|
||||
expect(new Domain(`["!", ("a", "=", 1), ("b", "=", 2)]`).toString()).toBe(
|
||||
`["&", "!", ("a", "=", 1), ("b", "=", 2)]`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combining domains
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Combining domains", () => {
|
||||
test("combining zero domain", () => {
|
||||
expect(Domain.combine([], "AND").toString()).toBe("[]");
|
||||
expect(Domain.combine([], "OR").toString()).toBe("[]");
|
||||
expect(Domain.combine([], "AND").contains({ a: 1, b: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
test("combining one domain", () => {
|
||||
expect(Domain.combine([`[("a", "=", 1)]`], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine([`[("user_id", "=", uid)]`], "AND").toString()).toBe(
|
||||
`[("user_id", "=", uid)]`
|
||||
);
|
||||
expect(Domain.combine([[["a", "=", 1]]], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine(["[('a', '=', '1'), ('b', '!=', 2)]"], "AND").toString()).toBe(
|
||||
`["&", ("a", "=", "1"), ("b", "!=", 2)]`
|
||||
);
|
||||
});
|
||||
|
||||
test("combining two domains", () => {
|
||||
expect(Domain.combine([`[("a", "=", 1)]`, "[]"], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine([`[("a", "=", 1)]`, []], "AND").toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "AND").toString()).toBe(
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
expect(Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "OR").toString()).toBe(
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
expect(Domain.combine([[["a", "=", 1]], "[('uid', '<=', uid)]"], "AND").toString()).toBe(
|
||||
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
|
||||
);
|
||||
expect(Domain.combine([[["a", "=", 1]], "[('b', '<=', 3)]"], "OR").toString()).toBe(
|
||||
`["|", ("a", "=", 1), ("b", "<=", 3)]`
|
||||
);
|
||||
expect(
|
||||
Domain.combine(
|
||||
["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"],
|
||||
"OR"
|
||||
).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
expect(
|
||||
Domain.combine(
|
||||
[new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"), "[('b', '<=', 3)]"],
|
||||
"OR"
|
||||
).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
});
|
||||
|
||||
test("combining three domains", () => {
|
||||
expect(
|
||||
Domain.combine(
|
||||
[
|
||||
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
|
||||
[["b", "<=", 3]],
|
||||
`['!', ('uid', '=', uid)]`,
|
||||
],
|
||||
"OR"
|
||||
).toString()
|
||||
).toBe(
|
||||
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), "|", ("b", "<=", 3), "!", ("uid", "=", uid)]`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPERATOR AND / OR / NOT
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Operator and - or - not", () => {
|
||||
test("combining two domains with and/or", () => {
|
||||
expect(Domain.and([`[("a", "=", 1)]`, "[]"]).toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.and([`[("a", "=", 1)]`, []]).toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.and([new Domain(`[("a", "=", 1)]`), "[]"]).toString()).toBe(
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
expect(Domain.or([new Domain(`[("a", "=", 1)]`), "[]"]).toString()).toBe(`[("a", "=", 1)]`);
|
||||
expect(Domain.and([[["a", "=", 1]], "[('uid', '<=', uid)]"]).toString()).toBe(
|
||||
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
|
||||
);
|
||||
expect(Domain.or([[["a", "=", 1]], "[('b', '<=', 3)]"]).toString()).toBe(
|
||||
`["|", ("a", "=", 1), ("b", "<=", 3)]`
|
||||
);
|
||||
expect(
|
||||
Domain.or(["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"]).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
expect(
|
||||
Domain.or([
|
||||
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
|
||||
"[('b', '<=', 3)]",
|
||||
]).toString()
|
||||
).toBe(`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`);
|
||||
});
|
||||
|
||||
test("apply `NOT` on a Domain", () => {
|
||||
expect(Domain.not("[('a', '=', 1)]").toString()).toBe(`["!", ("a", "=", 1)]`);
|
||||
expect(Domain.not('[("uid", "<=", uid)]').toString()).toBe(`["!", ("uid", "<=", uid)]`);
|
||||
expect(Domain.not(new Domain("[('a', '=', 1)]")).toString()).toBe(`["!", ("a", "=", 1)]`);
|
||||
expect(Domain.not(new Domain([["a", "=", 1]])).toString()).toBe(`["!", ("a", "=", 1)]`);
|
||||
});
|
||||
|
||||
test("tuple are supported", () => {
|
||||
expect(
|
||||
new Domain(`(("field", "like", "string"), ("field", "like", "strOng"))`).toList()
|
||||
).toEqual(["&", ["field", "like", "string"], ["field", "like", "strOng"]]);
|
||||
expect(new Domain(`("!",("field", "like", "string"))`).toList()).toEqual([
|
||||
"!",
|
||||
["field", "like", "string"],
|
||||
]);
|
||||
expect(() => new Domain(`(("field", "like", "string"))`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`("&", "&", "|")`)).toThrow(/Invalid domain AST/);
|
||||
expect(() => new Domain(`("&", "&", 3)`)).toThrow(/Invalid domain AST/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Remove domain leaf", () => {
|
||||
test("Remove leaf in domain.", () => {
|
||||
let domain = [
|
||||
["start_datetime", "!=", false],
|
||||
["end_datetime", "!=", false],
|
||||
["sale_line_id", "!=", false],
|
||||
];
|
||||
const keysToRemove = ["start_datetime", "end_datetime"];
|
||||
let newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
|
||||
let expectedDomain = new Domain([
|
||||
"&",
|
||||
...Domain.TRUE.toList({}),
|
||||
...Domain.TRUE.toList({}),
|
||||
["sale_line_id", "!=", false],
|
||||
]);
|
||||
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
|
||||
domain = [
|
||||
"|",
|
||||
["role_id", "=", false],
|
||||
"&",
|
||||
["resource_id", "!=", false],
|
||||
["start_datetime", "=", false],
|
||||
["sale_line_id", "!=", false],
|
||||
];
|
||||
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
|
||||
expectedDomain = new Domain([
|
||||
"|",
|
||||
["role_id", "=", false],
|
||||
"&",
|
||||
["resource_id", "!=", false],
|
||||
...Domain.TRUE.toList({}),
|
||||
["sale_line_id", "!=", false],
|
||||
]);
|
||||
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
|
||||
domain = [
|
||||
"|",
|
||||
["start_datetime", "=", false],
|
||||
["end_datetime", "=", false],
|
||||
["sale_line_id", "!=", false],
|
||||
];
|
||||
newDomain = Domain.removeDomainLeaves(domain, keysToRemove);
|
||||
expectedDomain = new Domain([...Domain.TRUE.toList({}), ["sale_line_id", "!=", false]]);
|
||||
expect(newDomain.toList({})).toEqual(expectedDomain.toList({}));
|
||||
});
|
||||
});
|
||||
1284
odoo-bringout-oca-ocb-web/web/static/tests/core/domain_field.test.js
Normal file
1284
odoo-bringout-oca-ocb-web/web/static/tests/core/domain_field.test.js
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import {
|
||||
Country,
|
||||
Partner,
|
||||
Player,
|
||||
Product,
|
||||
Stage,
|
||||
Team,
|
||||
} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
|
||||
import {
|
||||
contains,
|
||||
defineModels,
|
||||
makeDialogMockEnv,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
|
||||
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
async function makeDomainSelectorDialog(params = {}) {
|
||||
const props = { ...params };
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { DomainSelectorDialog };
|
||||
static template = xml`<DomainSelectorDialog t-props="domainSelectorProps"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
this.domainSelectorProps = {
|
||||
readonly: false,
|
||||
domain: "[]",
|
||||
close: () => {},
|
||||
onConfirm: () => {},
|
||||
...props,
|
||||
resModel: "partner",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const env = await makeDialogMockEnv();
|
||||
return mountWithCleanup(Parent, { env, props });
|
||||
}
|
||||
|
||||
defineModels([Partner, Product, Team, Player, Country, Stage]);
|
||||
|
||||
test("a domain with a user context dynamic part is valid", async () => {
|
||||
await makeDomainSelectorDialog({
|
||||
domain: "[('foo', '=', uid)]",
|
||||
onConfirm(domain) {
|
||||
expect(domain).toBe("[('foo', '=', uid)]");
|
||||
expect.step("confirmed");
|
||||
},
|
||||
});
|
||||
onRpc("/web/domain/validate", () => {
|
||||
expect.step("validation");
|
||||
return true;
|
||||
});
|
||||
await contains(".o_dialog footer button").click();
|
||||
expect.verifySteps(["validation", "confirmed"]);
|
||||
});
|
||||
|
||||
test("can extend eval context", async () => {
|
||||
await makeDomainSelectorDialog({
|
||||
domain: "['&', ('foo', '=', uid), ('bar', '=', var)]",
|
||||
context: { uid: 99, var: "true" },
|
||||
onConfirm(domain) {
|
||||
expect(domain).toBe("['&', ('foo', '=', uid), ('bar', '=', var)]");
|
||||
expect.step("confirmed");
|
||||
},
|
||||
});
|
||||
onRpc("/web/domain/validate", () => {
|
||||
expect.step("validation");
|
||||
return true;
|
||||
});
|
||||
await contains(".o_dialog footer button").click();
|
||||
expect.verifySteps(["validation", "confirmed"]);
|
||||
});
|
||||
|
||||
test("a domain with an unknown expression is not valid", async () => {
|
||||
await makeDomainSelectorDialog({
|
||||
domain: "[('foo', '=', unknown)]",
|
||||
onConfirm() {
|
||||
expect.step("confirmed");
|
||||
},
|
||||
});
|
||||
onRpc("/web/domain/validate", () => {
|
||||
expect.step("validation");
|
||||
return true;
|
||||
});
|
||||
await contains(".o_dialog footer button").click();
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
|
||||
test("model_field_selector should close on dialog drag", async () => {
|
||||
await makeDomainSelectorDialog({
|
||||
domain: "[('foo', '=', unknown)]",
|
||||
});
|
||||
|
||||
expect(".o_model_field_selector_popover").toHaveCount(0);
|
||||
await contains(".o_model_field_selector_value").click();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(1);
|
||||
|
||||
const header = queryOne(".modal-header");
|
||||
const headerRect = header.getBoundingClientRect();
|
||||
await contains(header).dragAndDrop(document.body, {
|
||||
position: {
|
||||
// the util function sets the source coordinates at (x; y) + (w/2; h/2)
|
||||
// so we need to move the dialog based on these coordinates.
|
||||
x: headerRect.x + headerRect.width / 2 + 20,
|
||||
y: headerRect.y + headerRect.height / 2 + 50,
|
||||
},
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_model_field_selector_popover").toHaveCount(0);
|
||||
});
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { SELECTORS as treeEditorSELECTORS } from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers";
|
||||
|
||||
export const SELECTORS = {
|
||||
...treeEditorSELECTORS,
|
||||
debugArea: ".o_domain_selector_debug_container textarea",
|
||||
resetButton: ".o_domain_selector_row > button",
|
||||
};
|
||||
|
|
@ -1,650 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { popoverService } from "@web/core/popover/popover_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import { click, editInput, editSelect, getFixture, mount, triggerEvent } from "../helpers/utils";
|
||||
import { makeFakeLocalizationService } from "../helpers/mock_services";
|
||||
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
async function mountComponent(Component, params = {}) {
|
||||
const env = await makeTestEnv({ serverData, mockRPC: params.mockRPC });
|
||||
await mount(MainComponentsContainer, target, { env });
|
||||
return mount(Component, target, { env, props: params.props || {} });
|
||||
}
|
||||
|
||||
QUnit.module("Components", (hooks) => {
|
||||
hooks.beforeEach(async () => {
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char", searchable: true },
|
||||
bar: { string: "Bar", type: "boolean", searchable: true },
|
||||
product_id: {
|
||||
string: "Product",
|
||||
type: "many2one",
|
||||
relation: "product",
|
||||
searchable: true,
|
||||
},
|
||||
datetime: { string: "Date Time", type: "datetime", searchable: true },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, foo: "yop", bar: true, product_id: 37 },
|
||||
{ id: 2, foo: "blip", bar: true, product_id: false },
|
||||
{ id: 4, foo: "abc", bar: false, product_id: 41 },
|
||||
],
|
||||
onchanges: {},
|
||||
},
|
||||
product: {
|
||||
fields: {
|
||||
name: { string: "Product Name", type: "char", searchable: true },
|
||||
},
|
||||
records: [
|
||||
{ id: 37, display_name: "xphone" },
|
||||
{ id: 41, display_name: "xpad" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("popover", popoverService);
|
||||
registry.category("services").add("orm", ormService);
|
||||
registry.category("services").add("ui", uiService);
|
||||
registry.category("services").add("hotkey", hotkeyService);
|
||||
registry.category("services").add("localization", makeFakeLocalizationService());
|
||||
registry.category("services").add("view", viewService);
|
||||
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.module("DomainSelector");
|
||||
|
||||
QUnit.test("creating a domain from scratch", async (assert) => {
|
||||
assert.expect(12);
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.value = "[]";
|
||||
}
|
||||
onUpdate(newValue) {
|
||||
this.value = newValue;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
Parent.components = { DomainSelector };
|
||||
Parent.template = xml`
|
||||
<DomainSelector
|
||||
resModel="'partner'"
|
||||
value="value"
|
||||
readonly="false"
|
||||
isDebugMode="true"
|
||||
update="(newValue) => this.onUpdate(newValue)"
|
||||
/>
|
||||
`;
|
||||
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(Parent);
|
||||
|
||||
// As we gave an empty domain, there should be a visible button to add
|
||||
// the first domain part
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_domain_add_first_node_button",
|
||||
"there should be a button to create first domain element"
|
||||
);
|
||||
|
||||
// Clicking on the button should add a visible field selector in the
|
||||
// widget so that the user can change the field chain
|
||||
await click(target, ".o_domain_add_first_node_button");
|
||||
assert.containsOnce(target, ".o_field_selector", "there should be a field selector");
|
||||
|
||||
// Focusing the field selector input should open a field selector popover
|
||||
await click(target, ".o_field_selector");
|
||||
assert.containsOnce(
|
||||
document.body,
|
||||
".o_field_selector_popover",
|
||||
"field selector popover should be visible"
|
||||
);
|
||||
|
||||
// The field selector popover should contain the list of "partner"
|
||||
// fields. "Bar" should be among them. "Bar" result li will display the
|
||||
// name of the field and some debug info.
|
||||
assert.strictEqual(
|
||||
document.body.querySelector(".o_field_selector_popover li").textContent,
|
||||
"Barbar (boolean)",
|
||||
"field selector popover should contain the 'Bar' field"
|
||||
);
|
||||
|
||||
// Clicking the "Bar" field should change the internal domain and this
|
||||
// should be displayed in the debug textarea
|
||||
await click(document.body.querySelector(".o_field_selector_popover li"));
|
||||
assert.containsOnce(target, "textarea.o_domain_debug_input");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_debug_input").value,
|
||||
`[("bar", "=", True)]`,
|
||||
"the domain input should contain a domain with 'bar'"
|
||||
);
|
||||
|
||||
// There should be a "+" button to add a domain part; clicking on it
|
||||
// should add the default "['id', '=', 1]" domain
|
||||
assert.containsOnce(target, ".fa-plus-circle", "there should be a '+' button");
|
||||
await click(target, ".fa-plus-circle");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_debug_input").value,
|
||||
`["&", ("bar", "=", True), ("id", "=", 1)]`,
|
||||
"the domain input should contain a domain with 'bar' and 'id'"
|
||||
);
|
||||
|
||||
// There should be two "..." buttons to add a domain group; clicking on
|
||||
// the first one, should add this group with defaults "['id', '=', 1]"
|
||||
// domains and the "|" operator
|
||||
assert.containsN(target, ".fa-ellipsis-h", 2, "there should be two '...' buttons");
|
||||
|
||||
await click(target.querySelector(".fa-ellipsis-h"));
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_debug_input").value,
|
||||
`["&", ("bar", "=", True), "&", "|", ("id", "=", 1), ("id", "=", 1), ("id", "=", 1)]`,
|
||||
"the domain input should contain a domain with 'bar', 'id' and a subgroup"
|
||||
);
|
||||
|
||||
// There should be five "-" buttons to remove domain part; clicking on
|
||||
// the two last ones, should leave a domain with only the "bar" and
|
||||
// "foo" fields, with the initial "&" operator
|
||||
assert.containsN(
|
||||
target,
|
||||
".o_domain_delete_node_button",
|
||||
5,
|
||||
"there should be five 'x' buttons"
|
||||
);
|
||||
let buttons = target.querySelectorAll(".o_domain_delete_node_button");
|
||||
await click(buttons[buttons.length - 1]);
|
||||
buttons = target.querySelectorAll(".o_domain_delete_node_button");
|
||||
await click(buttons[buttons.length - 1]);
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_debug_input").value,
|
||||
`["&", ("bar", "=", True), ("id", "=", 1)]`,
|
||||
"the domain input should contain a domain with 'bar' and 'id'"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("building a domain with a datetime", async (assert) => {
|
||||
assert.expect(4);
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.value = `[("datetime", "=", "2017-03-27 15:42:00")]`;
|
||||
}
|
||||
onUpdate(newValue) {
|
||||
assert.strictEqual(
|
||||
newValue,
|
||||
`[("datetime", "=", "2017-02-26 15:42:00")]`,
|
||||
"datepicker value should have changed"
|
||||
);
|
||||
this.value = newValue;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
Parent.components = { DomainSelector };
|
||||
Parent.template = xml`
|
||||
<DomainSelector
|
||||
resModel="'partner'"
|
||||
value="value"
|
||||
readonly="false"
|
||||
isDebugMode="true"
|
||||
update="(newValue) => this.onUpdate(newValue)"
|
||||
/>
|
||||
`;
|
||||
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(Parent);
|
||||
|
||||
// Check that there is a datepicker to choose the date
|
||||
assert.containsOnce(target, ".o_datepicker", "there should be a datepicker");
|
||||
// The input field should display the date and time in the user's timezone
|
||||
assert.equal(target.querySelector(".o_datepicker_input").value, "03/27/2017 16:42:00");
|
||||
|
||||
// Change the date in the datepicker
|
||||
await click(target, ".o_datepicker_input");
|
||||
await click(
|
||||
document.body.querySelector(
|
||||
`.bootstrap-datetimepicker-widget :not(.today)[data-action="selectDay"]`
|
||||
)
|
||||
); // => February 26th
|
||||
await click(
|
||||
document.body.querySelector(`.bootstrap-datetimepicker-widget a[data-action="close"]`)
|
||||
);
|
||||
|
||||
// The input field should display the date and time in the user's timezone
|
||||
assert.equal(target.querySelector(".o_datepicker_input").value, "02/26/2017 16:42:00");
|
||||
});
|
||||
|
||||
QUnit.test("building a domain with a datetime: context_today()", async (assert) => {
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
resModel: "partner",
|
||||
value: `[("datetime", "=", context_today())]`,
|
||||
readonly: false,
|
||||
update: () => {
|
||||
assert.step("SHOULD NEVER BE CALLED");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check that there is a datepicker to choose the date
|
||||
assert.containsOnce(target, ".o_datepicker", "there should be a datepicker");
|
||||
// The input field should display that the date is invalid
|
||||
assert.equal(target.querySelector(".o_datepicker_input").value, "Invalid DateTime");
|
||||
|
||||
// Open and close the datepicker
|
||||
await click(target, ".o_datepicker_input");
|
||||
await click(
|
||||
document.body.querySelector(`.bootstrap-datetimepicker-widget [data-action=close]`)
|
||||
);
|
||||
|
||||
// The input field should continue displaying 'Invalid DateTime'.
|
||||
// The value is still invalid.
|
||||
assert.equal(target.querySelector(".o_datepicker_input").value, "Invalid DateTime");
|
||||
assert.verifySteps([]);
|
||||
});
|
||||
|
||||
QUnit.test("building a domain with a m2o without following the relation", async (assert) => {
|
||||
assert.expect(1);
|
||||
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
resModel: "partner",
|
||||
value: `[("product_id", "ilike", 1)]`,
|
||||
readonly: false,
|
||||
isDebugMode: true,
|
||||
update: (newValue) => {
|
||||
assert.strictEqual(
|
||||
newValue,
|
||||
`[("product_id", "ilike", "pad")]`,
|
||||
"string should have been allowed as m2o value"
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = target.querySelector(".o_domain_leaf_value_input");
|
||||
input.value = "pad";
|
||||
await triggerEvent(input, null, "change");
|
||||
});
|
||||
|
||||
QUnit.test("editing a domain with `parent` key", async (assert) => {
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
resModel: "product",
|
||||
value: `[("name", "=", parent.foo)]`,
|
||||
readonly: false,
|
||||
isDebugMode: true,
|
||||
},
|
||||
});
|
||||
assert.strictEqual(
|
||||
target.lastElementChild.textContent,
|
||||
" This domain is not supported. Reset domain",
|
||||
"an error message should be displayed because of the `parent` key"
|
||||
);
|
||||
assert.containsOnce(target, "button:contains(Reset domain)");
|
||||
});
|
||||
|
||||
QUnit.test("creating a domain with a default option", async (assert) => {
|
||||
assert.expect(1);
|
||||
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
resModel: "partner",
|
||||
value: "[]",
|
||||
readonly: false,
|
||||
isDebugMode: true,
|
||||
defaultLeafValue: ["foo", "=", "kikou"],
|
||||
update: (newValue) => {
|
||||
assert.strictEqual(
|
||||
newValue,
|
||||
`[("foo", "=", "kikou")]`,
|
||||
"the domain input should contain the default domain"
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Clicking on the button should add a visible field selector in the
|
||||
// widget so that the user can change the field chain
|
||||
await click(target, ".o_domain_add_first_node_button");
|
||||
});
|
||||
|
||||
QUnit.test("edit a domain with the debug textarea", async (assert) => {
|
||||
assert.expect(5);
|
||||
|
||||
let newValue;
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.value = `[("product_id", "ilike", 1)]`;
|
||||
}
|
||||
onUpdate(value, fromDebug) {
|
||||
assert.strictEqual(value, newValue);
|
||||
assert.ok(fromDebug);
|
||||
}
|
||||
}
|
||||
Parent.components = { DomainSelector };
|
||||
Parent.template = xml`
|
||||
<DomainSelector
|
||||
value="value"
|
||||
resModel="'partner'"
|
||||
readonly="false"
|
||||
isDebugMode="true"
|
||||
update="(...args) => this.onUpdate(...args)"
|
||||
/>
|
||||
`;
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(Parent);
|
||||
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_domain_node.o_domain_leaf",
|
||||
"should have a single domain node"
|
||||
);
|
||||
newValue = `
|
||||
[
|
||||
['product_id', 'ilike', 1],
|
||||
['id', '=', 0]
|
||||
]`;
|
||||
const input = target.querySelector(".o_domain_debug_input");
|
||||
input.value = newValue;
|
||||
await triggerEvent(input, null, "change");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_debug_input").value,
|
||||
newValue,
|
||||
"the domain should not have been formatted"
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_domain_node.o_domain_leaf",
|
||||
"should still have a single domain node"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"set [(1, '=', 1)] or [(0, '=', 1)] as domain with the debug textarea",
|
||||
async (assert) => {
|
||||
assert.expect(15);
|
||||
|
||||
let newValue;
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.value = `[("product_id", "ilike", 1)]`;
|
||||
}
|
||||
onUpdate(value, fromDebug) {
|
||||
this.value = value;
|
||||
assert.strictEqual(value, newValue);
|
||||
assert.ok(fromDebug);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
Parent.components = { DomainSelector };
|
||||
Parent.template = xml`
|
||||
<DomainSelector
|
||||
value="value"
|
||||
resModel="'partner'"
|
||||
readonly="false"
|
||||
isDebugMode="true"
|
||||
update="(...args) => this.onUpdate(...args)"
|
||||
/>
|
||||
`;
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(Parent);
|
||||
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_domain_node.o_domain_leaf",
|
||||
"should have a single domain node"
|
||||
);
|
||||
newValue = `[(1, "=", 1)]`;
|
||||
let input = target.querySelector(".o_domain_debug_input");
|
||||
input.value = newValue;
|
||||
await triggerEvent(input, null, "change");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_debug_input").value,
|
||||
newValue,
|
||||
"the domain should not have been formatted"
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_domain_node.o_domain_leaf",
|
||||
"should still have a single domain node"
|
||||
);
|
||||
|
||||
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "1");
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "1");
|
||||
|
||||
newValue = `[(0, "=", 1)]`;
|
||||
input = target.querySelector(".o_domain_debug_input");
|
||||
input.value = newValue;
|
||||
await triggerEvent(input, null, "change");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_debug_input").value,
|
||||
newValue,
|
||||
"the domain should not have been formatted"
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_domain_node.o_domain_leaf",
|
||||
"should still have a single domain node"
|
||||
);
|
||||
|
||||
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "0");
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "1");
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("operator fallback", async (assert) => {
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
resModel: "partner",
|
||||
value: "[['foo', 'like', 'kikou']]",
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf").textContent, `Foo like "kikou"`);
|
||||
});
|
||||
|
||||
QUnit.test("operator fallback in edit mode", async (assert) => {
|
||||
registry.category("domain_selector/operator").add("test", {
|
||||
category: "test",
|
||||
label: "test",
|
||||
value: "test",
|
||||
onDidChange: () => null,
|
||||
matches: ({ operator }) => operator === "test",
|
||||
hideValue: true,
|
||||
});
|
||||
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
readonly: false,
|
||||
resModel: "partner",
|
||||
value: "[['foo', 'test', 'kikou']]",
|
||||
},
|
||||
});
|
||||
|
||||
// check that the DomainSelector does not crash
|
||||
assert.containsOnce(target, ".o_domain_selector");
|
||||
assert.containsN(target, ".o_domain_leaf_edition > div", 2, "value should be hidden");
|
||||
});
|
||||
|
||||
QUnit.test("cache fields_get", async (assert) => {
|
||||
await mountComponent(DomainSelector, {
|
||||
mockRPC(route, { method }) {
|
||||
if (method === "fields_get") {
|
||||
assert.step("fields_get");
|
||||
}
|
||||
},
|
||||
props: {
|
||||
readonly: false,
|
||||
resModel: "partner",
|
||||
value: "['&', ['foo', '=', 'kikou'], ['bar', '=', 'true']]",
|
||||
},
|
||||
});
|
||||
|
||||
assert.verifySteps(["fields_get"]);
|
||||
});
|
||||
|
||||
QUnit.test("selection field with operator change from 'is set' to '='", async (assert) => {
|
||||
serverData.models.partner.fields.state = {
|
||||
string: "State",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["abc", "ABC"],
|
||||
["def", "DEF"],
|
||||
["ghi", "GHI"],
|
||||
],
|
||||
};
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.value = `[['state', '!=', false]]`;
|
||||
}
|
||||
onUpdate(newValue) {
|
||||
this.value = newValue;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
Parent.components = { DomainSelector };
|
||||
Parent.template = xml`
|
||||
<DomainSelector
|
||||
resModel="'partner'"
|
||||
value="value"
|
||||
readonly="false"
|
||||
update="(newValue) => this.onUpdate(newValue)"
|
||||
/>
|
||||
`;
|
||||
|
||||
// Create the domain selector and its mock environment
|
||||
await mountComponent(Parent);
|
||||
|
||||
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "2"); // option "!="
|
||||
|
||||
await editSelect(target, ".o_domain_leaf_operator_select", 0);
|
||||
|
||||
assert.strictEqual(target.querySelector(".o_field_selector_chain_part").innerText, "State");
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf_operator_select").value, "0"); // option "="
|
||||
assert.strictEqual(target.querySelector(".o_domain_leaf_value_input").value, "abc");
|
||||
});
|
||||
|
||||
QUnit.test("show correct operator", async (assert) => {
|
||||
serverData.models.partner.fields.state = {
|
||||
string: "State",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["abc", "ABC"],
|
||||
["def", "DEF"],
|
||||
["ghi", "GHI"],
|
||||
],
|
||||
};
|
||||
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
resModel: "partner",
|
||||
value: `[['state', 'in', ['abc']]]`,
|
||||
readonly: false,
|
||||
},
|
||||
});
|
||||
|
||||
const select = target.querySelector(".o_domain_leaf_operator_select");
|
||||
assert.strictEqual(select.options[select.options.selectedIndex].text, "in");
|
||||
});
|
||||
|
||||
QUnit.test("multi selection", async (assert) => {
|
||||
serverData.models.partner.fields.state = {
|
||||
string: "State",
|
||||
type: "selection",
|
||||
selection: [
|
||||
["a", "A"],
|
||||
["b", "B"],
|
||||
["c", "C"],
|
||||
],
|
||||
};
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.value = `[("state", "in", ["a", "b", "c"])]`;
|
||||
}
|
||||
onUpdate(newValue) {
|
||||
this.value = newValue;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
Parent.components = { DomainSelector };
|
||||
Parent.template = xml`
|
||||
<DomainSelector
|
||||
resModel="'partner'"
|
||||
value="value"
|
||||
readonly="false"
|
||||
update="(newValue) => this.onUpdate(newValue)"
|
||||
/>
|
||||
`;
|
||||
|
||||
// Create the domain selector and its mock environment
|
||||
const comp = await mountComponent(Parent);
|
||||
|
||||
assert.containsOnce(target, ".o_domain_leaf_value_input");
|
||||
assert.strictEqual(comp.value, `[("state", "in", ["a", "b", "c"])]`);
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_domain_leaf_value_input").value,
|
||||
`["a", "b", "c"]`
|
||||
);
|
||||
|
||||
await editInput(target, ".o_domain_leaf_value_input", `[]`);
|
||||
assert.strictEqual(comp.value, `[("state", "in", [])]`);
|
||||
|
||||
await editInput(target, ".o_domain_leaf_value_input", `["b"]`);
|
||||
assert.strictEqual(comp.value, `[("state", "in", ["b"])]`);
|
||||
});
|
||||
|
||||
QUnit.test("updating path should also update operator if invalid", async (assert) => {
|
||||
await mountComponent(DomainSelector, {
|
||||
props: {
|
||||
resModel: "partner",
|
||||
value: `[("id", "<", 0)]`,
|
||||
readonly: false,
|
||||
update: (domain) => {
|
||||
assert.strictEqual(domain, `[("foo", "=", "")]`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await click(target, ".o_field_selector");
|
||||
await click(target, ".o_field_selector_popover .o_field_selector_item[data-name=foo]");
|
||||
});
|
||||
|
||||
QUnit.test("do not crash with connector '!'", async (assert) => {
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.domain = `["!", ("foo", "=", "abc")]`;
|
||||
}
|
||||
}
|
||||
Parent.components = { DomainSelector };
|
||||
Parent.template = xml`<DomainSelector resModel="'partner'" value="domain" readonly="false"/>`;
|
||||
await mountComponent(Parent);
|
||||
assert.containsOnce(target, ".o_domain_node.o_domain_leaf");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,562 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { PyDate } from "../../src/core/py_js/py_date";
|
||||
import { patchWithCleanup } from "../helpers/utils";
|
||||
|
||||
QUnit.module("domain", {}, () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Basic properties
|
||||
// ---------------------------------------------------------------------------
|
||||
QUnit.module("Basic Properties");
|
||||
|
||||
QUnit.test("empty", function (assert) {
|
||||
assert.ok(new Domain([]).contains({}));
|
||||
assert.strictEqual(new Domain([]).toString(), "[]");
|
||||
assert.deepEqual(new Domain([]).toList(), []);
|
||||
});
|
||||
|
||||
QUnit.test("undefined domain", function (assert) {
|
||||
assert.ok(new Domain(undefined).contains({}));
|
||||
assert.strictEqual(new Domain(undefined).toString(), "[]");
|
||||
assert.deepEqual(new Domain(undefined).toList(), []);
|
||||
});
|
||||
|
||||
QUnit.test("simple condition", function (assert) {
|
||||
assert.ok(new Domain([["a", "=", 3]]).contains({ a: 3 }));
|
||||
assert.notOk(new Domain([["a", "=", 3]]).contains({ a: 5 }));
|
||||
assert.strictEqual(new Domain([["a", "=", 3]]).toString(), `[("a", "=", 3)]`);
|
||||
assert.deepEqual(new Domain([["a", "=", 3]]).toList(), [["a", "=", 3]]);
|
||||
});
|
||||
|
||||
QUnit.test("can be created from domain", function (assert) {
|
||||
const domain = new Domain([["a", "=", 3]]);
|
||||
assert.strictEqual(new Domain(domain).toString(), `[("a", "=", 3)]`);
|
||||
});
|
||||
|
||||
QUnit.test("basic", function (assert) {
|
||||
const record = {
|
||||
a: 3,
|
||||
group_method: "line",
|
||||
select1: "day",
|
||||
rrule_type: "monthly",
|
||||
};
|
||||
assert.ok(new Domain([["a", "=", 3]]).contains(record));
|
||||
assert.notOk(new Domain([["a", "=", 5]]).contains(record));
|
||||
assert.ok(new Domain([["group_method", "!=", "count"]]).contains(record));
|
||||
assert.ok(
|
||||
new Domain([
|
||||
["select1", "=", "day"],
|
||||
["rrule_type", "=", "monthly"],
|
||||
]).contains(record)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("support of '=?' operator", function (assert) {
|
||||
const record = { a: 3 };
|
||||
assert.ok(new Domain([["a", "=?", null]]).contains(record));
|
||||
assert.ok(new Domain([["a", "=?", false]]).contains(record));
|
||||
assert.notOk(new Domain(["!", ["a", "=?", false]]).contains(record));
|
||||
assert.notOk(new Domain([["a", "=?", 1]]).contains(record));
|
||||
assert.ok(new Domain([["a", "=?", 3]]).contains(record));
|
||||
assert.notOk(new Domain(["!", ["a", "=?", 3]]).contains(record));
|
||||
});
|
||||
|
||||
QUnit.test("or", function (assert) {
|
||||
const currentDomain = [
|
||||
"|",
|
||||
["section_id", "=", 42],
|
||||
"|",
|
||||
["user_id", "=", 3],
|
||||
["member_ids", "in", [3]],
|
||||
];
|
||||
const record = {
|
||||
section_id: null,
|
||||
user_id: null,
|
||||
member_ids: null,
|
||||
};
|
||||
assert.ok(new Domain(currentDomain).contains({ ...record, section_id: 42 }));
|
||||
assert.ok(new Domain(currentDomain).contains({ ...record, user_id: 3 }));
|
||||
assert.ok(new Domain(currentDomain).contains({ ...record, member_ids: 3 }));
|
||||
});
|
||||
|
||||
QUnit.test("and", function (assert) {
|
||||
const domain = new Domain(["&", "&", ["a", "=", 1], ["b", "=", 2], ["c", "=", 3]]);
|
||||
|
||||
assert.ok(domain.contains({ a: 1, b: 2, c: 3 }));
|
||||
assert.notOk(domain.contains({ a: -1, b: 2, c: 3 }));
|
||||
assert.notOk(domain.contains({ a: 1, b: -1, c: 3 }));
|
||||
assert.notOk(domain.contains({ a: 1, b: 2, c: -1 }));
|
||||
});
|
||||
|
||||
QUnit.test("not", function (assert) {
|
||||
const record = {
|
||||
a: 5,
|
||||
group_method: "line",
|
||||
};
|
||||
assert.ok(new Domain(["!", ["a", "=", 3]]).contains(record));
|
||||
assert.ok(new Domain(["!", ["group_method", "=", "count"]]).contains(record));
|
||||
});
|
||||
|
||||
QUnit.test("like, =like, ilike and =ilike", function (assert) {
|
||||
assert.expect(16);
|
||||
|
||||
assert.ok(new Domain([["a", "like", "value"]]).contains({ a: "value" }));
|
||||
assert.ok(new Domain([["a", "like", "value"]]).contains({ a: "some value" }));
|
||||
assert.notOk(new Domain([["a", "like", "value"]]).contains({ a: "Some Value" }));
|
||||
assert.notOk(new Domain([["a", "like", "value"]]).contains({ a: false }));
|
||||
|
||||
assert.ok(new Domain([["a", "=like", "%value"]]).contains({ a: "value" }));
|
||||
assert.ok(new Domain([["a", "=like", "%value"]]).contains({ a: "some value" }));
|
||||
assert.notOk(new Domain([["a", "=like", "%value"]]).contains({ a: "Some Value" }));
|
||||
assert.notOk(new Domain([["a", "=like", "%value"]]).contains({ a: false }));
|
||||
|
||||
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "value" }));
|
||||
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "some value" }));
|
||||
assert.ok(new Domain([["a", "ilike", "value"]]).contains({ a: "Some Value" }));
|
||||
assert.notOk(new Domain([["a", "ilike", "value"]]).contains({ a: false }));
|
||||
|
||||
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "value" }));
|
||||
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "some value" }));
|
||||
assert.ok(new Domain([["a", "=ilike", "%value"]]).contains({ a: "Some Value" }));
|
||||
assert.notOk(new Domain([["a", "=ilike", "%value"]]).contains({ a: false }));
|
||||
});
|
||||
|
||||
QUnit.test("complex domain", function (assert) {
|
||||
const domain = new Domain(["&", "!", ["a", "=", 1], "|", ["a", "=", 2], ["a", "=", 3]]);
|
||||
|
||||
assert.notOk(domain.contains({ a: 1 }));
|
||||
assert.ok(domain.contains({ a: 2 }));
|
||||
assert.ok(domain.contains({ a: 3 }));
|
||||
assert.notOk(domain.contains({ a: 4 }));
|
||||
});
|
||||
|
||||
QUnit.test("toList", function (assert) {
|
||||
assert.deepEqual(new Domain([]).toList(), []);
|
||||
assert.deepEqual(new Domain([["a", "=", 3]]).toList(), [["a", "=", 3]]);
|
||||
assert.deepEqual(
|
||||
new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "!=", "4"],
|
||||
]).toList(),
|
||||
["&", ["a", "=", 3], ["b", "!=", "4"]]
|
||||
);
|
||||
assert.deepEqual(new Domain(["!", ["a", "=", 3]]).toList(), ["!", ["a", "=", 3]]);
|
||||
});
|
||||
|
||||
QUnit.test("toString", function (assert) {
|
||||
assert.strictEqual(new Domain([]).toString(), `[]`);
|
||||
assert.strictEqual(new Domain([["a", "=", 3]]).toString(), `[("a", "=", 3)]`);
|
||||
assert.strictEqual(
|
||||
new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "!=", "4"],
|
||||
]).toString(),
|
||||
`["&", ("a", "=", 3), ("b", "!=", "4")]`
|
||||
);
|
||||
assert.strictEqual(new Domain(["!", ["a", "=", 3]]).toString(), `["!", ("a", "=", 3)]`);
|
||||
assert.strictEqual(new Domain([["name", "=", null]]).toString(), '[("name", "=", None)]');
|
||||
assert.strictEqual(new Domain([["name", "=", false]]).toString(), '[("name", "=", False)]');
|
||||
assert.strictEqual(new Domain([["name", "=", true]]).toString(), '[("name", "=", True)]');
|
||||
assert.strictEqual(
|
||||
new Domain([["name", "=", "null"]]).toString(),
|
||||
'[("name", "=", "null")]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Domain([["name", "=", "false"]]).toString(),
|
||||
'[("name", "=", "false")]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Domain([["name", "=", "true"]]).toString(),
|
||||
'[("name", "=", "true")]'
|
||||
);
|
||||
assert.strictEqual(new Domain().toString(), "[]");
|
||||
assert.strictEqual(
|
||||
new Domain([["name", "in", [true, false]]]).toString(),
|
||||
'[("name", "in", [True, False])]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Domain([["name", "in", [null]]]).toString(),
|
||||
'[("name", "in", [None])]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Domain([["name", "in", ["foo", "bar"]]]).toString(),
|
||||
'[("name", "in", ["foo", "bar"])]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Domain([["name", "in", [1, 2]]]).toString(),
|
||||
'[("name", "in", [1, 2])]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Domain(["&", ["name", "=", "foo"], ["type", "=", "bar"]]).toString(),
|
||||
'["&", ("name", "=", "foo"), ("type", "=", "bar")]'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Domain(["|", ["name", "=", "foo"], ["type", "=", "bar"]]).toString(),
|
||||
'["|", ("name", "=", "foo"), ("type", "=", "bar")]'
|
||||
);
|
||||
assert.strictEqual(new Domain().toString(), "[]");
|
||||
|
||||
// string domains are only reformatted
|
||||
assert.strictEqual(
|
||||
new Domain('[("name","ilike","foo")]').toString(),
|
||||
'[("name", "ilike", "foo")]'
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("implicit &", function (assert) {
|
||||
const domain = new Domain([
|
||||
["a", "=", 3],
|
||||
["b", "=", 4],
|
||||
]);
|
||||
assert.notOk(domain.contains({}));
|
||||
assert.ok(domain.contains({ a: 3, b: 4 }));
|
||||
assert.notOk(domain.contains({ a: 3, b: 5 }));
|
||||
});
|
||||
|
||||
QUnit.test("comparison operators", function (assert) {
|
||||
assert.ok(new Domain([["a", "=", 3]]).contains({ a: 3 }));
|
||||
assert.notOk(new Domain([["a", "=", 3]]).contains({ a: 4 }));
|
||||
assert.strictEqual(new Domain([["a", "=", 3]]).toString(), `[("a", "=", 3)]`);
|
||||
assert.ok(new Domain([["a", "==", 3]]).contains({ a: 3 }));
|
||||
assert.notOk(new Domain([["a", "==", 3]]).contains({ a: 4 }));
|
||||
assert.strictEqual(new Domain([["a", "==", 3]]).toString(), `[("a", "==", 3)]`);
|
||||
assert.notOk(new Domain([["a", "!=", 3]]).contains({ a: 3 }));
|
||||
assert.ok(new Domain([["a", "!=", 3]]).contains({ a: 4 }));
|
||||
assert.strictEqual(new Domain([["a", "!=", 3]]).toString(), `[("a", "!=", 3)]`);
|
||||
assert.notOk(new Domain([["a", "<>", 3]]).contains({ a: 3 }));
|
||||
assert.ok(new Domain([["a", "<>", 3]]).contains({ a: 4 }));
|
||||
assert.strictEqual(new Domain([["a", "<>", 3]]).toString(), `[("a", "<>", 3)]`);
|
||||
assert.notOk(new Domain([["a", "<", 3]]).contains({ a: 5 }));
|
||||
assert.notOk(new Domain([["a", "<", 3]]).contains({ a: 3 }));
|
||||
assert.ok(new Domain([["a", "<", 3]]).contains({ a: 2 }));
|
||||
assert.strictEqual(new Domain([["a", "<", 3]]).toString(), `[("a", "<", 3)]`);
|
||||
assert.notOk(new Domain([["a", "<=", 3]]).contains({ a: 5 }));
|
||||
assert.ok(new Domain([["a", "<=", 3]]).contains({ a: 3 }));
|
||||
assert.ok(new Domain([["a", "<=", 3]]).contains({ a: 2 }));
|
||||
assert.strictEqual(new Domain([["a", "<=", 3]]).toString(), `[("a", "<=", 3)]`);
|
||||
assert.ok(new Domain([["a", ">", 3]]).contains({ a: 5 }));
|
||||
assert.notOk(new Domain([["a", ">", 3]]).contains({ a: 3 }));
|
||||
assert.notOk(new Domain([["a", ">", 3]]).contains({ a: 2 }));
|
||||
assert.strictEqual(new Domain([["a", ">", 3]]).toString(), `[("a", ">", 3)]`);
|
||||
assert.ok(new Domain([["a", ">=", 3]]).contains({ a: 5 }));
|
||||
assert.ok(new Domain([["a", ">=", 3]]).contains({ a: 3 }));
|
||||
assert.notOk(new Domain([["a", ">=", 3]]).contains({ a: 2 }));
|
||||
assert.strictEqual(new Domain([["a", ">=", 3]]).toString(), `[("a", ">=", 3)]`);
|
||||
});
|
||||
|
||||
QUnit.test("other operators", function (assert) {
|
||||
assert.ok(new Domain([["a", "in", 3]]).contains({ a: 3 }));
|
||||
assert.ok(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 3 }));
|
||||
assert.ok(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [3] }));
|
||||
assert.notOk(new Domain([["a", "in", 3]]).contains({ a: 5 }));
|
||||
assert.notOk(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: 5 }));
|
||||
assert.notOk(new Domain([["a", "in", [1, 2, 3]]]).contains({ a: [5] }));
|
||||
assert.notOk(new Domain([["a", "not in", 3]]).contains({ a: 3 }));
|
||||
assert.notOk(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 3 }));
|
||||
assert.notOk(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [3] }));
|
||||
assert.ok(new Domain([["a", "not in", 3]]).contains({ a: 5 }));
|
||||
assert.ok(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: 5 }));
|
||||
assert.ok(new Domain([["a", "not in", [1, 2, 3]]]).contains({ a: [5] }));
|
||||
assert.ok(new Domain([["a", "like", "abc"]]).contains({ a: "abc" }));
|
||||
assert.notOk(new Domain([["a", "like", "abc"]]).contains({ a: "def" }));
|
||||
assert.ok(new Domain([["a", "=like", "abc"]]).contains({ a: "abc" }));
|
||||
assert.notOk(new Domain([["a", "=like", "abc"]]).contains({ a: "def" }));
|
||||
assert.ok(new Domain([["a", "ilike", "abc"]]).contains({ a: "abc" }));
|
||||
assert.notOk(new Domain([["a", "ilike", "abc"]]).contains({ a: "def" }));
|
||||
assert.ok(new Domain([["a", "=ilike", "abc"]]).contains({ a: "abc" }));
|
||||
assert.notOk(new Domain([["a", "=ilike", "abc"]]).contains({ a: "def" }));
|
||||
});
|
||||
|
||||
QUnit.test("creating a domain with a string expression", function (assert) {
|
||||
assert.strictEqual(new Domain(`[('a', '>=', 3)]`).toString(), `[("a", ">=", 3)]`);
|
||||
assert.ok(new Domain(`[('a', '>=', 3)]`).contains({ a: 5 }));
|
||||
});
|
||||
|
||||
QUnit.test("can evaluate a python expression", function (assert) {
|
||||
assert.deepEqual(new Domain(`[('date', '!=', False)]`).toList(), [["date", "!=", false]]);
|
||||
assert.deepEqual(new Domain(`[('date', '!=', False)]`).toList(), [["date", "!=", false]]);
|
||||
assert.deepEqual(
|
||||
new Domain(`[('date', '!=', 1 + 2)]`).toString(),
|
||||
`[("date", "!=", 1 + 2)]`
|
||||
);
|
||||
assert.deepEqual(new Domain(`[('date', '!=', 1 + 2)]`).toList(), [["date", "!=", 3]]);
|
||||
assert.ok(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 3 }));
|
||||
assert.notOk(new Domain(`[('a', '==', 1 + 2)]`).contains({ a: 2 }));
|
||||
});
|
||||
|
||||
QUnit.test("some expression with date stuff", function (assert) {
|
||||
patchWithCleanup(PyDate, {
|
||||
today() {
|
||||
return new PyDate(2013, 4, 24);
|
||||
},
|
||||
});
|
||||
let domainStr =
|
||||
"[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]";
|
||||
assert.deepEqual(new Domain(domainStr).toList(), [["date", ">=", "2013-03-25"]]);
|
||||
domainStr = "[('date', '>=', context_today() - relativedelta(days=30))]";
|
||||
const domainList = new Domain(domainStr).toList(); // domain creation using `parseExpr` function since the parameter is a string.
|
||||
assert.deepEqual(
|
||||
domainList[0][2],
|
||||
PyDate.create({ day: 25, month: 3, year: 2013 }),
|
||||
"The right item in the rule in the domain should be a PyDate object"
|
||||
);
|
||||
assert.deepEqual(JSON.stringify(domainList), '[["date",">=","2013-03-25"]]');
|
||||
const domainList2 = new Domain(domainList).toList(); // domain creation using `toAST` function since the parameter is a list.
|
||||
assert.deepEqual(
|
||||
domainList2[0][2],
|
||||
PyDate.create({ day: 25, month: 3, year: 2013 }),
|
||||
"The right item in the rule in the domain should be a PyDate object"
|
||||
);
|
||||
assert.deepEqual(JSON.stringify(domainList2), '[["date",">=","2013-03-25"]]');
|
||||
});
|
||||
|
||||
QUnit.test("Check that there is no dependency between two domains", function (assert) {
|
||||
// The purpose of this test is to verify that a domain created on the basis
|
||||
// of another one does not share any dependency.
|
||||
const domain1 = new Domain(`[('date', '!=', False)]`);
|
||||
const domain2 = new Domain(domain1);
|
||||
assert.strictEqual(domain1.toString(), domain2.toString());
|
||||
|
||||
domain2.ast.value.unshift({ type: 1, value: "!" });
|
||||
assert.notEqual(domain1.toString(), domain2.toString());
|
||||
});
|
||||
|
||||
QUnit.test("TRUE and FALSE Domain", function (assert) {
|
||||
assert.ok(Domain.TRUE.contains({}));
|
||||
assert.notOk(Domain.FALSE.contains({}));
|
||||
|
||||
assert.ok(Domain.and([Domain.TRUE, new Domain([["a", "=", 3]])]).contains({ a: 3 }));
|
||||
assert.notOk(Domain.and([Domain.FALSE, new Domain([["a", "=", 3]])]).contains({ a: 3 }));
|
||||
});
|
||||
|
||||
QUnit.test("invalid domains should not succeed", function (assert) {
|
||||
assert.throws(
|
||||
() => new Domain(["|", ["hr_presence_state", "=", "absent"]]),
|
||||
/invalid domain .* \(missing 1 segment/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
new Domain([
|
||||
"|",
|
||||
"|",
|
||||
["hr_presence_state", "=", "absent"],
|
||||
["attendance_state", "=", "checked_in"],
|
||||
]),
|
||||
/invalid domain .* \(missing 1 segment/
|
||||
);
|
||||
assert.throws(
|
||||
() => new Domain(["|", "|", ["hr_presence_state", "=", "absent"]]),
|
||||
/invalid domain .* \(missing 2 segment\(s\)/
|
||||
);
|
||||
assert.throws(
|
||||
() => new Domain(["&", ["composition_mode", "!=", "mass_post"]]),
|
||||
/invalid domain .* \(missing 1 segment/
|
||||
);
|
||||
assert.throws(() => new Domain(["!"]), /invalid domain .* \(missing 1 segment/);
|
||||
});
|
||||
|
||||
QUnit.test("follow relations", function (assert) {
|
||||
assert.ok(
|
||||
new Domain([["partner.city", "ilike", "Bru"]]).contains({
|
||||
name: "Lucas",
|
||||
partner: {
|
||||
city: "Bruxelles",
|
||||
},
|
||||
})
|
||||
);
|
||||
assert.ok(
|
||||
new Domain([["partner.city.name", "ilike", "Bru"]]).contains({
|
||||
name: "Lucas",
|
||||
partner: {
|
||||
city: {
|
||||
name: "Bruxelles",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Arrays comparison", (assert) => {
|
||||
const domain = new Domain(["&", ["a", "==", []], ["b", "!=", []]]);
|
||||
|
||||
assert.ok(domain.contains({ a: [] }));
|
||||
assert.ok(domain.contains({ a: [], b: [4] }));
|
||||
assert.notOk(domain.contains({ a: [1] }));
|
||||
assert.notOk(domain.contains({ b: [] }));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
QUnit.module("Normalization");
|
||||
|
||||
QUnit.test("return simple (normalized) domains", function (assert) {
|
||||
const domains = ["[]", `[("a", "=", 1)]`, `["!", ("a", "=", 1)]`];
|
||||
for (const domain of domains) {
|
||||
assert.strictEqual(new Domain(domain).toString(), domain);
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test("properly add the & in a non normalized domain", function (assert) {
|
||||
assert.strictEqual(
|
||||
new Domain(`[("a", "=", 1), ("b", "=", 2)]`).toString(),
|
||||
`["&", ("a", "=", 1), ("b", "=", 2)]`
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("normalize domain with ! operator", function (assert) {
|
||||
assert.strictEqual(
|
||||
new Domain(`["!", ("a", "=", 1), ("b", "=", 2)]`).toString(),
|
||||
`["&", "!", ("a", "=", 1), ("b", "=", 2)]`
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combining domains
|
||||
// ---------------------------------------------------------------------------
|
||||
QUnit.module("Combining domains");
|
||||
|
||||
QUnit.test("combining zero domain", function (assert) {
|
||||
assert.strictEqual(Domain.combine([], "AND").toString(), "[]");
|
||||
assert.strictEqual(Domain.combine([], "OR").toString(), "[]");
|
||||
assert.ok(Domain.combine([], "AND").contains({ a: 1, b: 2 }));
|
||||
});
|
||||
|
||||
QUnit.test("combining one domain", function (assert) {
|
||||
assert.strictEqual(
|
||||
Domain.combine([`[("a", "=", 1)]`], "AND").toString(),
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine([`[("user_id", "=", uid)]`], "AND").toString(),
|
||||
`[("user_id", "=", uid)]`
|
||||
);
|
||||
assert.strictEqual(Domain.combine([[["a", "=", 1]]], "AND").toString(), `[("a", "=", 1)]`);
|
||||
assert.strictEqual(
|
||||
Domain.combine(["[('a', '=', '1'), ('b', '!=', 2)]"], "AND").toString(),
|
||||
`["&", ("a", "=", "1"), ("b", "!=", 2)]`
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("combining two domains", function (assert) {
|
||||
assert.strictEqual(
|
||||
Domain.combine([`[("a", "=", 1)]`, "[]"], "AND").toString(),
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine([`[("a", "=", 1)]`, []], "AND").toString(),
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "AND").toString(),
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine([new Domain(`[("a", "=", 1)]`), "[]"], "OR").toString(),
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine([[["a", "=", 1]], "[('uid', '<=', uid)]"], "AND").toString(),
|
||||
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine([[["a", "=", 1]], "[('b', '<=', 3)]"], "OR").toString(),
|
||||
`["|", ("a", "=", 1), ("b", "<=", 3)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine(
|
||||
["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"],
|
||||
"OR"
|
||||
).toString(),
|
||||
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.combine(
|
||||
[new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"), "[('b', '<=', 3)]"],
|
||||
"OR"
|
||||
).toString(),
|
||||
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("combining three domains", function (assert) {
|
||||
assert.strictEqual(
|
||||
Domain.combine(
|
||||
[
|
||||
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
|
||||
[["b", "<=", 3]],
|
||||
`['!', ('uid', '=', uid)]`,
|
||||
],
|
||||
"OR"
|
||||
).toString(),
|
||||
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), "|", ("b", "<=", 3), "!", ("uid", "=", uid)]`
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPERATOR AND / OR / NOT
|
||||
// ---------------------------------------------------------------------------
|
||||
QUnit.module("Operator and/or/not");
|
||||
QUnit.test("combining two domains with and/or", function (assert) {
|
||||
assert.strictEqual(Domain.and([`[("a", "=", 1)]`, "[]"]).toString(), `[("a", "=", 1)]`);
|
||||
assert.strictEqual(Domain.and([`[("a", "=", 1)]`, []]).toString(), `[("a", "=", 1)]`);
|
||||
assert.strictEqual(
|
||||
Domain.and([new Domain(`[("a", "=", 1)]`), "[]"]).toString(),
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.or([new Domain(`[("a", "=", 1)]`), "[]"]).toString(),
|
||||
`[("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.and([[["a", "=", 1]], "[('uid', '<=', uid)]"]).toString(),
|
||||
`["&", ("a", "=", 1), ("uid", "<=", uid)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.or([[["a", "=", 1]], "[('b', '<=', 3)]"]).toString(),
|
||||
`["|", ("a", "=", 1), ("b", "<=", 3)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.or(["[('a', '=', '1'), ('c', 'in', [4, 5])]", "[('b', '<=', 3)]"]).toString(),
|
||||
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.or([
|
||||
new Domain("[('a', '=', '1'), ('c', 'in', [4, 5])]"),
|
||||
"[('b', '<=', 3)]",
|
||||
]).toString(),
|
||||
`["|", "&", ("a", "=", "1"), ("c", "in", [4, 5]), ("b", "<=", 3)]`
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("apply `NOT` on a Domain", function (assert) {
|
||||
assert.strictEqual(Domain.not("[('a', '=', 1)]").toString(), `["!", ("a", "=", 1)]`);
|
||||
assert.strictEqual(
|
||||
Domain.not('[("uid", "<=", uid)]').toString(),
|
||||
`["!", ("uid", "<=", uid)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.not(new Domain("[('a', '=', 1)]")).toString(),
|
||||
`["!", ("a", "=", 1)]`
|
||||
);
|
||||
assert.strictEqual(
|
||||
Domain.not(new Domain([["a", "=", 1]])).toString(),
|
||||
`["!", ("a", "=", 1)]`
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("tuple are supported", (assert) => {
|
||||
assert.deepEqual(
|
||||
new Domain(`(("field", "like", "string"), ("field", "like", "strOng"))`).toList(),
|
||||
["&", ["field", "like", "string"], ["field", "like", "strOng"]]
|
||||
);
|
||||
assert.deepEqual(new Domain(`("!",("field", "like", "string"))`).toList(), [
|
||||
"!",
|
||||
["field", "like", "string"],
|
||||
]);
|
||||
assert.throws(() => new Domain(`(("field", "like", "string"))`), /Invalid domain AST/);
|
||||
assert.throws(() => new Domain(`("&", "&", "|")`), /Invalid domain AST/);
|
||||
assert.throws(() => new Domain(`("&", "&", 3)`), /Invalid domain AST/);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,84 @@
|
|||
import { test, expect } from "@odoo/hoot";
|
||||
import { click, press, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { AccordionItem } from "@web/core/dropdown/accordion_item";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
test("accordion can be rendered", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`<AccordionItem description="'Test'" class="'text-primary'" selected="false"><h5>In accordion</h5></AccordionItem>`;
|
||||
static components = { AccordionItem };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect("div.o_accordion").toHaveCount(1);
|
||||
expect(".o_accordion button.o_accordion_toggle").toHaveCount(1);
|
||||
expect(".o_accordion_values").toHaveCount(0);
|
||||
|
||||
await click("button.o_accordion_toggle");
|
||||
await animationFrame();
|
||||
expect(".o_accordion_values").toHaveCount(1);
|
||||
expect(queryOne(".o_accordion_values").innerHTML).toBe(`<h5>In accordion</h5>`);
|
||||
});
|
||||
|
||||
test("dropdown with accordion keyboard navigation", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<Dropdown>
|
||||
<button>dropdown</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem>item 1</DropdownItem>
|
||||
<AccordionItem description="'item 2'" selected="false">
|
||||
<DropdownItem>item 2-1</DropdownItem>
|
||||
<DropdownItem>item 2-2</DropdownItem>
|
||||
</AccordionItem>
|
||||
<DropdownItem>item 3</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
`;
|
||||
static components = { Dropdown, DropdownItem, AccordionItem };
|
||||
static props = ["*"];
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await click(".o-dropdown.dropdown-toggle");
|
||||
await animationFrame();
|
||||
|
||||
expect(".dropdown-menu > .focus").toHaveCount(0);
|
||||
|
||||
const scenarioSteps = [
|
||||
{ key: "arrowdown", expected: "item 1" },
|
||||
{ key: "arrowdown", expected: "item 2" },
|
||||
{ key: "arrowdown", expected: "item 3" },
|
||||
{ key: "arrowdown", expected: "item 1" },
|
||||
{ key: "tab", expected: "item 2" },
|
||||
{ key: "enter", expected: "item 2" },
|
||||
{ key: "tab", expected: "item 2-1" },
|
||||
{ key: "tab", expected: "item 2-2" },
|
||||
{ key: "tab", expected: "item 3" },
|
||||
{ key: "tab", expected: "item 1" },
|
||||
{ key: "arrowup", expected: "item 3" },
|
||||
{ key: "arrowup", expected: "item 2-2" },
|
||||
{ key: "arrowup", expected: "item 2-1" },
|
||||
{ key: "arrowup", expected: "item 2" },
|
||||
{ key: "enter", expected: "item 2" },
|
||||
{ key: "arrowup", expected: "item 1" },
|
||||
{ key: "shift+tab", expected: "item 3" },
|
||||
{ key: "shift+tab", expected: "item 2" },
|
||||
{ key: "shift+tab", expected: "item 1" },
|
||||
{ key: "end", expected: "item 3" },
|
||||
{ key: "home", expected: "item 1" },
|
||||
];
|
||||
|
||||
for (let i = 0; i < scenarioSteps.length; i++) {
|
||||
const step = scenarioSteps[i];
|
||||
await press(step.key);
|
||||
await animationFrame();
|
||||
await runAllTimers();
|
||||
expect(`.dropdown-menu .focus:contains(${step.expected})`).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { click, hover, queryOne } from "@odoo/hoot-dom";
|
||||
import { Deferred, animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
import { getDropdownMenu, mountWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownGroup } from "@web/core/dropdown/dropdown_group";
|
||||
|
||||
const DROPDOWN_MENU = ".o-dropdown--menu.dropdown-menu";
|
||||
|
||||
test.tags("desktop");
|
||||
test("DropdownGroup: when one Dropdown is open, others with same group name can be toggled on mouse-enter", async () => {
|
||||
expect.assertions(16);
|
||||
const beforeOpenProm = new Deferred();
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownGroup };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<div>
|
||||
<div class="outside">OUTSIDE</div>
|
||||
<DropdownGroup>
|
||||
<Dropdown menuClass="'menu-one'">
|
||||
<button class="one">One</button>
|
||||
<t t-set-slot="content">
|
||||
Content One
|
||||
</t>
|
||||
</Dropdown>
|
||||
<Dropdown beforeOpen="() => this.beforeOpen()" menuClass="'menu-two'">
|
||||
<button class="two">Two</button>
|
||||
<t t-set-slot="content">
|
||||
Content Two
|
||||
</t>
|
||||
</Dropdown>
|
||||
<Dropdown menuClass="'menu-three'">
|
||||
<button class="three">Three</button>
|
||||
<t t-set-slot="content">
|
||||
Content Three
|
||||
</t>
|
||||
</Dropdown>
|
||||
</DropdownGroup>
|
||||
<DropdownGroup>
|
||||
<Dropdown menuClass="'menu-four'">
|
||||
<button class="four">Four</button>
|
||||
<t t-set-slot="content">
|
||||
Content Four
|
||||
</t>
|
||||
</Dropdown>
|
||||
</DropdownGroup>
|
||||
</div>
|
||||
`;
|
||||
|
||||
beforeOpen() {
|
||||
expect.step("beforeOpen");
|
||||
return beforeOpenProm;
|
||||
}
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
// Click on ONE
|
||||
await click(queryOne(".one"));
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps([]);
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
expect(".one").toHaveClass("show");
|
||||
|
||||
// Hover on TWO
|
||||
await hover(".two");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["beforeOpen"]);
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
expect(".menu-two").toHaveCount(0);
|
||||
|
||||
beforeOpenProm.resolve();
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
expect(".menu-two").toHaveCount(1);
|
||||
|
||||
// Hover on THREE
|
||||
await hover(".three");
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
expect(".menu-three").toHaveCount(1);
|
||||
|
||||
// Hover on FOUR (Should not open)
|
||||
expect(".menu-four").toHaveCount(0);
|
||||
await hover(".four");
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
expect(".menu-three").toHaveCount(1);
|
||||
expect(".menu-four").toHaveCount(0);
|
||||
|
||||
// Click on OUTSIDE
|
||||
await click("div.outside");
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(0);
|
||||
|
||||
// Hover on ONE, TWO, THREE
|
||||
await hover(".one");
|
||||
await hover(".two");
|
||||
await hover(".three");
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("DropdownGroup: when non-sibling Dropdown is open, other must not be toggled on mouse-enter", async () => {
|
||||
class Parent extends Component {
|
||||
static template = xml`
|
||||
<div>
|
||||
<DropdownGroup>
|
||||
<Dropdown>
|
||||
<button class="one">One</button>
|
||||
<t t-set-slot="content">One Content</t>
|
||||
</Dropdown>
|
||||
</DropdownGroup>
|
||||
<DropdownGroup>
|
||||
<Dropdown>
|
||||
<button class="two">Two</button>
|
||||
<t t-set-slot="content">Two Content</t>
|
||||
</Dropdown>
|
||||
</DropdownGroup>
|
||||
</div>
|
||||
`;
|
||||
static components = { Dropdown, DropdownGroup };
|
||||
static props = [];
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
// Click on One
|
||||
await click(".one");
|
||||
await animationFrame();
|
||||
expect(getDropdownMenu(".one")).toHaveCount(1);
|
||||
|
||||
// Hover on Two
|
||||
await hover(".two");
|
||||
await animationFrame();
|
||||
expect(getDropdownMenu(".one")).toHaveCount(1);
|
||||
|
||||
expect(".one").toHaveClass("show");
|
||||
expect(".two").not.toHaveClass("show");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("DropdownGroup: when one is open, then non-sibling toggled, siblings must not be toggled on mouse-enter", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownGroup };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<div>
|
||||
<DropdownGroup>
|
||||
<Dropdown>
|
||||
<button class="one">One</button>
|
||||
<t t-set-slot="content">
|
||||
One Content
|
||||
</t>
|
||||
</Dropdown>
|
||||
</DropdownGroup>
|
||||
<DropdownGroup>
|
||||
<Dropdown>
|
||||
<button class="two">Two</button>
|
||||
<t t-set-slot="content">
|
||||
Two Content
|
||||
</t>
|
||||
</Dropdown>
|
||||
</DropdownGroup>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
// Click on BAR1
|
||||
await click(".two");
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
|
||||
// Click on FOO
|
||||
await click(".one");
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
|
||||
// Hover on BAR1
|
||||
await hover(".two");
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveCount(1);
|
||||
expect(".two-menu").toHaveCount(0);
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("DropdownGroup: toggler focused on mouseenter", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownGroup };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<DropdownGroup>
|
||||
<Dropdown>
|
||||
<button class="one">One</button>
|
||||
<t t-set-slot="content">
|
||||
One Content
|
||||
</t>
|
||||
</Dropdown>
|
||||
<Dropdown>
|
||||
<button class="two">Two</button>
|
||||
<t t-set-slot="content">
|
||||
Two Content
|
||||
</t>
|
||||
</Dropdown>
|
||||
</DropdownGroup>
|
||||
`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
// Click on one
|
||||
await click("button.one");
|
||||
await animationFrame();
|
||||
expect("button.one").toBeFocused();
|
||||
expect(DROPDOWN_MENU).toHaveText("One Content");
|
||||
|
||||
// Hover on two
|
||||
await hover("button.two");
|
||||
await animationFrame();
|
||||
expect("button.two").toBeFocused();
|
||||
expect(DROPDOWN_MENU).toHaveText("Two Content");
|
||||
});
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { expect, test } from "@odoo/hoot";
|
||||
import { click, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
|
||||
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
const DROPDOWN_TOGGLE = ".o-dropdown.dropdown-toggle";
|
||||
const DROPDOWN_MENU = ".o-dropdown--menu.dropdown-menu";
|
||||
const DROPDOWN_ITEM = ".o-dropdown-item.dropdown-item:not(.o-dropdown)";
|
||||
|
||||
function getHexcode(selector, pseudoSelector) {
|
||||
const content = getComputedStyle(queryOne(selector), pseudoSelector).content;
|
||||
if (content !== "none") {
|
||||
return "\\" + content.replace(/['"]/g, "").charCodeAt(0).toString(16);
|
||||
} else {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
test("can be rendered as <span/>", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`<DropdownItem>coucou</DropdownItem>`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect(".dropdown-item").toHaveClass(["o-dropdown-item", "o-navigable", "dropdown-item"]);
|
||||
expect(".dropdown-item").toHaveAttribute("role", "menuitem");
|
||||
});
|
||||
|
||||
test("can be rendered using the tag prop", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`<DropdownItem tag="'button'">coucou</DropdownItem>`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
|
||||
expect("button.dropdown-item").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("(with href prop) can be rendered as <a/>", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`<DropdownItem attrs="{ href: '#' }">coucou</DropdownItem>`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
expect(DROPDOWN_ITEM).toHaveAttribute("href", "#");
|
||||
});
|
||||
|
||||
test("prevents click default with href", async () => {
|
||||
expect.assertions(4);
|
||||
// A DropdownItem should preventDefault a click as it may take the shape
|
||||
// of an <a/> tag with an [href] attribute and e.g. could change the url when clicked.
|
||||
patchWithCleanup(DropdownItem.prototype, {
|
||||
onClick(ev) {
|
||||
expect(!ev.defaultPrevented).toBe(true);
|
||||
super.onClick(...arguments);
|
||||
const href = ev.target.getAttribute("href");
|
||||
// defaultPrevented only if props.href is defined
|
||||
expect(href !== null ? ev.defaultPrevented : !ev.defaultPrevented).toBe(true);
|
||||
},
|
||||
});
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<Dropdown>
|
||||
<button>Coucou</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem class="'link'" attrs="{href: '#'}"/>
|
||||
<DropdownItem class="'nolink'" />
|
||||
</t>
|
||||
</Dropdown>`;
|
||||
}
|
||||
await mountWithCleanup(Parent);
|
||||
// The item containing the link class contains an href prop,
|
||||
// which will turn it into <a href=> So it must be defaultPrevented
|
||||
// The other one not contain any href props, it must not be defaultPrevented,
|
||||
// so as not to prevent the background change flow for example
|
||||
await click(DROPDOWN_TOGGLE);
|
||||
await animationFrame();
|
||||
await click(".link");
|
||||
await click("button.dropdown-toggle");
|
||||
await click(".nolink");
|
||||
});
|
||||
|
||||
test("can be styled", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<Dropdown menuClass="'test-menu'">
|
||||
<button class="test-toggler">Coucou</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem class="'test-dropdown-item'">Item</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
expect(DROPDOWN_TOGGLE).toHaveClass("test-toggler");
|
||||
|
||||
await click(DROPDOWN_TOGGLE);
|
||||
await animationFrame();
|
||||
expect(DROPDOWN_MENU).toHaveClass("test-menu");
|
||||
expect(DROPDOWN_ITEM).toHaveClass("test-dropdown-item");
|
||||
});
|
||||
|
||||
test.tags("desktop");
|
||||
test("'active' and 'selected' classes shows a checked icon", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<Dropdown menuClass="'test-menu'">
|
||||
<button class="test-toggler">Coucou</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem class="'no-check'">Item</DropdownItem>
|
||||
<DropdownItem class="'selected'">Item</DropdownItem>
|
||||
<DropdownItem class="'active'">Item</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await click(DROPDOWN_TOGGLE);
|
||||
await animationFrame();
|
||||
|
||||
expect(getHexcode(".o-dropdown-item.no-check", ":before")).toEqual("none");
|
||||
expect(getHexcode(".o-dropdown-item.selected", ":before")).toEqual("\\f00c");
|
||||
expect(getHexcode(".o-dropdown-item.active", ":before")).toEqual("\\f00c");
|
||||
});
|
||||
|
||||
test.tags("mobile");
|
||||
test("'active' and 'selected' classes shows a checked icon (mobile)", async () => {
|
||||
class Parent extends Component {
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = [];
|
||||
static template = xml`
|
||||
<Dropdown menuClass="'test-menu'">
|
||||
<button class="test-toggler">Coucou</button>
|
||||
<t t-set-slot="content">
|
||||
<DropdownItem class="'no-check'">Item</DropdownItem>
|
||||
<DropdownItem class="'selected'">Item</DropdownItem>
|
||||
<DropdownItem class="'active'">Item</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
await mountWithCleanup(Parent);
|
||||
await click(DROPDOWN_TOGGLE);
|
||||
await animationFrame();
|
||||
|
||||
expect(getHexcode(".o-dropdown-item.no-check", "::after")).toEqual("none");
|
||||
expect(getHexcode(".o-dropdown-item.selected", "::after")).toEqual("\\f00c");
|
||||
expect(getHexcode(".o-dropdown-item.active", "::after")).toEqual("\\f00c");
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,88 @@
|
|||
import { beforeEach, expect, test } from "@odoo/hoot";
|
||||
import { click, manuallyDispatchProgrammaticEvent, queryOne } from "@odoo/hoot-dom";
|
||||
import { animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, markup, xml } from "@odoo/owl";
|
||||
import { getService, mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
|
||||
import { MainComponentsContainer } from "@web/core/main_components_container";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
let effectParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
await mountWithCleanup(MainComponentsContainer);
|
||||
effectParams = {
|
||||
message: markup`<div>Congrats!</div>`,
|
||||
};
|
||||
});
|
||||
|
||||
test("effect service displays a rainbowman by default", async () => {
|
||||
getService("effect").add();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_reward").toHaveCount(1);
|
||||
expect(".o_reward").toHaveText("Well Done!");
|
||||
});
|
||||
|
||||
test("rainbowman effect with show_effect: false", async () => {
|
||||
patchWithCleanup(user, { showEffect: false });
|
||||
|
||||
getService("effect").add();
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_reward").toHaveCount(0);
|
||||
expect(".o_notification").toHaveCount(1);
|
||||
});
|
||||
|
||||
test("rendering a rainbowman destroy after animation", async () => {
|
||||
getService("effect").add(effectParams);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_reward").toHaveCount(1);
|
||||
expect(".o_reward_rainbow").toHaveCount(1);
|
||||
expect(".o_reward_msg_content").toHaveInnerHTML("<div>Congrats!</div>");
|
||||
|
||||
await manuallyDispatchProgrammaticEvent(queryOne(".o_reward"), "animationend", {
|
||||
animationName: "reward-fading-reverse",
|
||||
});
|
||||
await animationFrame();
|
||||
expect(".o_reward").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("rendering a rainbowman destroy on click", async () => {
|
||||
getService("effect").add(effectParams);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_reward").toHaveCount(1);
|
||||
expect(".o_reward_rainbow").toHaveCount(1);
|
||||
|
||||
await click(".o_reward");
|
||||
await animationFrame();
|
||||
expect(".o_reward").toHaveCount(0);
|
||||
});
|
||||
|
||||
test("rendering a rainbowman with an escaped message", async () => {
|
||||
getService("effect").add(effectParams);
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_reward").toHaveCount(1);
|
||||
expect(".o_reward_rainbow").toHaveCount(1);
|
||||
expect(".o_reward_msg_content").toHaveText("Congrats!");
|
||||
});
|
||||
|
||||
test("rendering a rainbowman with a custom component", async () => {
|
||||
expect.assertions(2);
|
||||
const props = { foo: "bar" };
|
||||
|
||||
class Custom extends Component {
|
||||
static template = xml`<div class="custom">foo is <t t-esc="props.foo"/></div>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
expect(this.props).toEqual(props);
|
||||
}
|
||||
}
|
||||
|
||||
getService("effect").add({ Component: Custom, props });
|
||||
await animationFrame();
|
||||
|
||||
expect(".o_reward_msg_content").toHaveInnerHTML(`<div class="custom">foo is bar</div>`);
|
||||
});
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { effectService } from "@web/core/effects/effect_service";
|
||||
import { userService } from "@web/core/user_service";
|
||||
import { session } from "@web/session";
|
||||
import { makeTestEnv } from "../../helpers/mock_env";
|
||||
import { makeFakeLocalizationService } from "../../helpers/mock_services";
|
||||
import {
|
||||
click,
|
||||
getFixture,
|
||||
mockTimeout,
|
||||
mount,
|
||||
nextTick,
|
||||
patchWithCleanup,
|
||||
} from "../../helpers/utils";
|
||||
|
||||
import { Component, markup, xml } from "@odoo/owl";
|
||||
const serviceRegistry = registry.category("services");
|
||||
const mainComponentRegistry = registry.category("main_components");
|
||||
|
||||
let target;
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.EffectContainer = mainComponentRegistry.get("EffectContainer");
|
||||
this.NotificationContainer = mainComponentRegistry.get("NotificationContainer");
|
||||
}
|
||||
}
|
||||
Parent.template = xml`
|
||||
<div>
|
||||
<t t-component="EffectContainer.Component" t-props="EffectContainer.props" />
|
||||
<t t-component="NotificationContainer.Component" t-props="NotificationContainer.props" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
async function makeParent() {
|
||||
const env = await makeTestEnv({ serviceRegistry });
|
||||
const parent = await mount(Parent, target, { env });
|
||||
return parent;
|
||||
}
|
||||
|
||||
QUnit.module("Effect Service", (hooks) => {
|
||||
let effectParams;
|
||||
let execRegisteredTimeouts;
|
||||
hooks.beforeEach(() => {
|
||||
effectParams = {
|
||||
message: markup("<div>Congrats!</div>"),
|
||||
};
|
||||
|
||||
execRegisteredTimeouts = mockTimeout().execRegisteredTimeouts;
|
||||
patchWithCleanup(session, { show_effect: true }); // enable effects
|
||||
|
||||
serviceRegistry.add("user", userService);
|
||||
serviceRegistry.add("effect", effectService);
|
||||
serviceRegistry.add("notification", notificationService);
|
||||
serviceRegistry.add("localization", makeFakeLocalizationService());
|
||||
|
||||
target = getFixture();
|
||||
});
|
||||
|
||||
QUnit.test("effect service displays a rainbowman by default", async function (assert) {
|
||||
const parent = await makeParent();
|
||||
parent.env.services.effect.add();
|
||||
await nextTick();
|
||||
execRegisteredTimeouts();
|
||||
|
||||
assert.containsOnce(target, ".o_reward");
|
||||
assert.strictEqual(target.querySelector(".o_reward").innerText, "Well Done!");
|
||||
});
|
||||
|
||||
QUnit.test("rainbowman effect with show_effect: false", async function (assert) {
|
||||
patchWithCleanup(session, { show_effect: false });
|
||||
|
||||
const parent = await makeParent();
|
||||
parent.env.services.effect.add();
|
||||
await nextTick();
|
||||
execRegisteredTimeouts();
|
||||
|
||||
assert.containsNone(target, ".o_reward");
|
||||
assert.containsOnce(target, ".o_notification");
|
||||
});
|
||||
|
||||
QUnit.test("rendering a rainbowman destroy after animation", async function (assert) {
|
||||
const parent = await makeParent();
|
||||
parent.env.services.effect.add(effectParams);
|
||||
await nextTick();
|
||||
execRegisteredTimeouts();
|
||||
|
||||
assert.containsOnce(target, ".o_reward");
|
||||
assert.containsOnce(target, ".o_reward_rainbow");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_reward_msg_content").innerHTML,
|
||||
"<div>Congrats!</div>"
|
||||
);
|
||||
|
||||
const ev = new AnimationEvent("animationend", { animationName: "reward-fading-reverse" });
|
||||
target.querySelector(".o_reward").dispatchEvent(ev);
|
||||
await nextTick();
|
||||
assert.containsNone(target, ".o_reward");
|
||||
});
|
||||
|
||||
QUnit.test("rendering a rainbowman destroy on click", async function (assert) {
|
||||
const parent = await makeParent();
|
||||
|
||||
parent.env.services.effect.add(effectParams);
|
||||
await nextTick();
|
||||
execRegisteredTimeouts();
|
||||
|
||||
assert.containsOnce(target, ".o_reward");
|
||||
assert.containsOnce(target, ".o_reward_rainbow");
|
||||
|
||||
await click(target);
|
||||
assert.containsNone(target, ".o_reward");
|
||||
});
|
||||
|
||||
QUnit.test("rendering a rainbowman with an escaped message", async function (assert) {
|
||||
const parent = await makeParent();
|
||||
|
||||
parent.env.services.effect.add(effectParams);
|
||||
await nextTick();
|
||||
execRegisteredTimeouts();
|
||||
|
||||
assert.containsOnce(target, ".o_reward");
|
||||
assert.containsOnce(target, ".o_reward_rainbow");
|
||||
assert.strictEqual(target.querySelector(".o_reward_msg_content").textContent, "Congrats!");
|
||||
});
|
||||
|
||||
QUnit.test("rendering a rainbowman with a custom component", async function (assert) {
|
||||
assert.expect(2);
|
||||
const props = { foo: "bar" };
|
||||
class Custom extends Component {
|
||||
setup() {
|
||||
assert.deepEqual(this.props, props, "should have received these props");
|
||||
}
|
||||
}
|
||||
Custom.template = xml`<div class="custom">foo is <t t-esc="props.foo"/></div>`;
|
||||
|
||||
const parent = await makeParent();
|
||||
parent.env.services.effect.add({ Component: Custom, props });
|
||||
await nextTick();
|
||||
execRegisteredTimeouts();
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_reward_msg_content").innerHTML,
|
||||
`<div class="custom">foo is bar</div>`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
import { browser } from "@web/core/browser/browser";
|
||||
import { describe, test, expect } from "@odoo/hoot";
|
||||
import { animationFrame, tick } from "@odoo/hoot-mock";
|
||||
import {
|
||||
mountWithCleanup,
|
||||
patchWithCleanup,
|
||||
mockService,
|
||||
makeDialogMockEnv,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { click, freezeTime, queryAllTexts } from "@odoo/hoot-dom";
|
||||
import {
|
||||
ClientErrorDialog,
|
||||
Error504Dialog,
|
||||
ErrorDialog,
|
||||
RedirectWarningDialog,
|
||||
SessionExpiredDialog,
|
||||
WarningDialog,
|
||||
} from "@web/core/errors/error_dialogs";
|
||||
|
||||
describe.current.tags("desktop");
|
||||
|
||||
test("ErrorDialog with traceback", async () => {
|
||||
freezeTime();
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: "Something bad happened",
|
||||
data: { debug: "Some strange unreadable stack" },
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback string",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Oops!");
|
||||
expect("main button").toHaveText("See technical details");
|
||||
expect(queryAllTexts("footer button")).toEqual(["Close"]);
|
||||
expect("main p").toHaveText(
|
||||
"Something went wrong... If you really are stuck, share the report with your friendly support service"
|
||||
);
|
||||
expect("div.o_error_detail").toHaveCount(0);
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
expect(queryAllTexts("main .clearfix p")).toEqual([
|
||||
"Odoo Error",
|
||||
"Something bad happened",
|
||||
"Occured on 2019-03-11 09:30:00 GMT",
|
||||
]);
|
||||
expect("main .clearfix code").toHaveText("ERROR_NAME");
|
||||
expect("div.o_error_detail").toHaveCount(1);
|
||||
expect("div.o_error_detail pre").toHaveText("This is a traceback string");
|
||||
});
|
||||
|
||||
test("Client ErrorDialog with traceback", async () => {
|
||||
freezeTime();
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ClientErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: "Something bad happened",
|
||||
data: { debug: "Some strange unreadable stack" },
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback string",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Oops!");
|
||||
expect("main button").toHaveText("See technical details");
|
||||
expect(queryAllTexts("footer button")).toEqual(["Close"]);
|
||||
expect("main p").toHaveText(
|
||||
"Something went wrong... If you really are stuck, share the report with your friendly support service"
|
||||
);
|
||||
expect("div.o_error_detail").toHaveCount(0);
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
expect(queryAllTexts("main .clearfix p")).toEqual([
|
||||
"Odoo Client Error",
|
||||
"Something bad happened",
|
||||
"Occured on 2019-03-11 09:30:00 GMT",
|
||||
]);
|
||||
expect("main .clearfix code").toHaveText("ERROR_NAME");
|
||||
expect("div.o_error_detail").toHaveCount(1);
|
||||
expect("div.o_error_detail pre").toHaveText("This is a traceback string");
|
||||
});
|
||||
|
||||
test("button clipboard copy error traceback", async () => {
|
||||
freezeTime();
|
||||
expect.assertions(1);
|
||||
const error = new Error();
|
||||
error.name = "ERROR_NAME";
|
||||
error.message = "This is the message";
|
||||
error.traceback = "This is a traceback";
|
||||
patchWithCleanup(navigator.clipboard, {
|
||||
writeText(value) {
|
||||
expect(value).toBe(
|
||||
`${error.name}\n\n${error.message}\n\nOccured on 2019-03-11 09:30:00 GMT\n\n${error.traceback}`
|
||||
);
|
||||
},
|
||||
});
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
traceback: error.traceback,
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
await click(".fa-clipboard");
|
||||
await tick();
|
||||
});
|
||||
|
||||
test("Display a tooltip on clicking copy button", async () => {
|
||||
expect.assertions(1);
|
||||
mockService("popover", () => ({
|
||||
add(el, comp, params) {
|
||||
expect(params).toEqual({ tooltip: "Copied" });
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(ErrorDialog, {
|
||||
env,
|
||||
props: {
|
||||
message: "This is the message",
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
await click("main button");
|
||||
await animationFrame();
|
||||
await click(".fa-clipboard");
|
||||
});
|
||||
|
||||
test("WarningDialog", async () => {
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(WarningDialog, {
|
||||
env,
|
||||
props: {
|
||||
exceptionName: "odoo.exceptions.UserError",
|
||||
message: "...",
|
||||
data: { arguments: ["Some strange unreadable message"] },
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Invalid Operation");
|
||||
expect(".o_error_dialog").toHaveCount(1);
|
||||
expect("main").toHaveText("Some strange unreadable message");
|
||||
expect(".o_dialog footer button").toHaveText("Close");
|
||||
});
|
||||
|
||||
test("RedirectWarningDialog", async () => {
|
||||
mockService("action", {
|
||||
doAction(actionId) {
|
||||
expect.step(actionId);
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(RedirectWarningDialog, {
|
||||
env,
|
||||
props: {
|
||||
data: {
|
||||
arguments: [
|
||||
"Some strange unreadable message",
|
||||
"buy_action_id",
|
||||
"Buy book on cryptography",
|
||||
],
|
||||
},
|
||||
close() {
|
||||
expect.step("dialog-closed");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Odoo Warning");
|
||||
expect("main").toHaveText("Some strange unreadable message");
|
||||
expect(queryAllTexts("footer button")).toEqual(["Buy book on cryptography", "Close"]);
|
||||
|
||||
await click("footer button:nth-child(1)"); // click on "Buy book on cryptography"
|
||||
await animationFrame();
|
||||
expect.verifySteps(["buy_action_id", "dialog-closed"]);
|
||||
|
||||
await click("footer button:nth-child(2)"); // click on "Cancel"
|
||||
await animationFrame();
|
||||
expect.verifySteps(["dialog-closed"]);
|
||||
});
|
||||
|
||||
test("Error504Dialog", async () => {
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(Error504Dialog, { env, props: { close() {} } });
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Request timeout");
|
||||
expect("main p").toHaveText(
|
||||
"The operation was interrupted. This usually means that the current operation is taking too much time."
|
||||
);
|
||||
expect(".o_dialog footer button").toHaveText("Close");
|
||||
});
|
||||
|
||||
test("SessionExpiredDialog", async () => {
|
||||
patchWithCleanup(browser.location, {
|
||||
reload() {
|
||||
expect.step("location reload");
|
||||
},
|
||||
});
|
||||
expect(".o_dialog").toHaveCount(0);
|
||||
const env = await makeDialogMockEnv();
|
||||
await mountWithCleanup(SessionExpiredDialog, { env, props: { close() {} } });
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect(".o_dialog").toHaveCount(1);
|
||||
expect("header .modal-title").toHaveText("Odoo Session Expired");
|
||||
expect("main p").toHaveText(
|
||||
"Your Odoo session expired. The current page is about to be refreshed."
|
||||
);
|
||||
expect(".o_dialog footer button").toHaveText("Close");
|
||||
await click(".o_dialog footer button");
|
||||
await animationFrame();
|
||||
expect.verifySteps(["location reload"]);
|
||||
});
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import {
|
||||
ClientErrorDialog,
|
||||
Error504Dialog,
|
||||
ErrorDialog,
|
||||
RedirectWarningDialog,
|
||||
SessionExpiredDialog,
|
||||
WarningDialog,
|
||||
} from "@web/core/errors/error_dialogs";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { makeTestEnv } from "../../helpers/mock_env";
|
||||
import { makeFakeDialogService, makeFakeLocalizationService } from "../../helpers/mock_services";
|
||||
import { click, getFixture, mount, nextTick, patchWithCleanup } from "../../helpers/utils";
|
||||
|
||||
let target;
|
||||
let env;
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
async function makeDialogTestEnv() {
|
||||
const env = await makeTestEnv();
|
||||
env.dialogData = {
|
||||
isActive: true,
|
||||
close() {},
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
QUnit.module("Error dialogs", {
|
||||
async beforeEach() {
|
||||
target = getFixture();
|
||||
serviceRegistry.add("ui", uiService);
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
serviceRegistry.add("localization", makeFakeLocalizationService());
|
||||
serviceRegistry.add("dialog", makeFakeDialogService());
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test("ErrorDialog with traceback", async (assert) => {
|
||||
assert.expect(11);
|
||||
assert.containsNone(target, ".o_dialog");
|
||||
env = await makeDialogTestEnv();
|
||||
await mount(ErrorDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
message: "Something bad happened",
|
||||
data: { debug: "Some strange unreadable stack" },
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a tracback string",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Odoo Error");
|
||||
const mainButtons = target.querySelectorAll("main button");
|
||||
assert.deepEqual(
|
||||
[...mainButtons].map((el) => el.textContent),
|
||||
["Copy the full error to clipboard", "See details"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
|
||||
[
|
||||
"An error occurred",
|
||||
"Please use the copy button to report the error to your support service.",
|
||||
]
|
||||
);
|
||||
assert.containsNone(target, "div.o_error_detail");
|
||||
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
|
||||
click(mainButtons[1]);
|
||||
await nextTick();
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
|
||||
[
|
||||
"An error occurred",
|
||||
"Please use the copy button to report the error to your support service.",
|
||||
"Something bad happened",
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("main .clearfix code")].map((el) => el.textContent),
|
||||
["ERROR_NAME"]
|
||||
);
|
||||
assert.containsOnce(target, "div.o_error_detail");
|
||||
assert.strictEqual(
|
||||
target.querySelector("div.o_error_detail").textContent,
|
||||
"This is a tracback string"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Client ErrorDialog with traceback", async (assert) => {
|
||||
assert.expect(11);
|
||||
assert.containsNone(target, ".o_dialog");
|
||||
env = await makeDialogTestEnv();
|
||||
await mount(ClientErrorDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
message: "Something bad happened",
|
||||
data: { debug: "Some strange unreadable stack" },
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback string",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.strictEqual(
|
||||
target.querySelector("header .modal-title").textContent,
|
||||
"Odoo Client Error"
|
||||
);
|
||||
const mainButtons = target.querySelectorAll("main button");
|
||||
assert.deepEqual(
|
||||
[...mainButtons].map((el) => el.textContent),
|
||||
["Copy the full error to clipboard", "See details"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
|
||||
[
|
||||
"An error occurred",
|
||||
"Please use the copy button to report the error to your support service.",
|
||||
]
|
||||
);
|
||||
assert.containsNone(target, "div.o_error_detail");
|
||||
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
|
||||
click(mainButtons[1]);
|
||||
await nextTick();
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("main .clearfix p")].map((el) => el.textContent),
|
||||
[
|
||||
"An error occurred",
|
||||
"Please use the copy button to report the error to your support service.",
|
||||
"Something bad happened",
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll("main .clearfix code")].map((el) => el.textContent),
|
||||
["ERROR_NAME"]
|
||||
);
|
||||
assert.containsOnce(target, "div.o_error_detail");
|
||||
assert.strictEqual(
|
||||
target.querySelector("div.o_error_detail").textContent,
|
||||
"This is a traceback string"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("button clipboard copy error traceback", async (assert) => {
|
||||
assert.expect(1);
|
||||
const error = new Error();
|
||||
error.name = "ERROR_NAME";
|
||||
error.message = "This is the message";
|
||||
error.traceback = "This is a traceback";
|
||||
patchWithCleanup(browser, {
|
||||
navigator: {
|
||||
clipboard: {
|
||||
writeText: (value) => {
|
||||
assert.strictEqual(
|
||||
value,
|
||||
`${error.name}\n${error.message}\n${error.traceback}`
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
env = await makeDialogTestEnv();
|
||||
await mount(ErrorDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
message: error.message,
|
||||
name: "ERROR_NAME",
|
||||
traceback: "This is a traceback",
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
const clipboardButton = target.querySelector(".fa-clipboard");
|
||||
click(clipboardButton);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
QUnit.test("WarningDialog", async (assert) => {
|
||||
assert.expect(6);
|
||||
assert.containsNone(target, ".o_dialog");
|
||||
env = await makeDialogTestEnv();
|
||||
await mount(WarningDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
exceptionName: "odoo.exceptions.UserError",
|
||||
message: "...",
|
||||
data: { arguments: ["Some strange unreadable message"] },
|
||||
close() {},
|
||||
},
|
||||
});
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "User Error");
|
||||
assert.containsOnce(target, "main .o_dialog_warning");
|
||||
assert.strictEqual(target.querySelector("main").textContent, "Some strange unreadable message");
|
||||
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
|
||||
});
|
||||
|
||||
QUnit.test("RedirectWarningDialog", async (assert) => {
|
||||
assert.expect(10);
|
||||
const faceActionService = {
|
||||
name: "action",
|
||||
start() {
|
||||
return {
|
||||
doAction(actionId) {
|
||||
assert.step(actionId);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
serviceRegistry.add("action", faceActionService);
|
||||
env = await makeDialogTestEnv();
|
||||
assert.containsNone(target, ".o_dialog");
|
||||
await mount(RedirectWarningDialog, target, {
|
||||
env,
|
||||
props: {
|
||||
data: {
|
||||
arguments: [
|
||||
"Some strange unreadable message",
|
||||
"buy_action_id",
|
||||
"Buy book on cryptography",
|
||||
],
|
||||
},
|
||||
close() {
|
||||
assert.step("dialog-closed");
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Odoo Warning");
|
||||
assert.strictEqual(target.querySelector("main").textContent, "Some strange unreadable message");
|
||||
const footerButtons = target.querySelectorAll("footer button");
|
||||
assert.deepEqual(
|
||||
[...footerButtons].map((el) => el.textContent),
|
||||
["Buy book on cryptography", "Cancel"]
|
||||
);
|
||||
await click(footerButtons[0]); // click on "Buy book on cryptography"
|
||||
assert.verifySteps(["buy_action_id", "dialog-closed"]);
|
||||
|
||||
await click(footerButtons[1]); // click on "Cancel"
|
||||
assert.verifySteps(["dialog-closed"]);
|
||||
});
|
||||
|
||||
QUnit.test("Error504Dialog", async (assert) => {
|
||||
assert.expect(5);
|
||||
assert.containsNone(target, ".o_dialog");
|
||||
env = await makeDialogTestEnv();
|
||||
await mount(Error504Dialog, target, { env, props: { close() {} } });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.strictEqual(target.querySelector("header .modal-title").textContent, "Request timeout");
|
||||
assert.strictEqual(
|
||||
target.querySelector("main p").textContent,
|
||||
" The operation was interrupted. This usually means that the current operation is taking too much time. "
|
||||
);
|
||||
assert.strictEqual(target.querySelector(".o_dialog footer button").textContent, "Ok");
|
||||
});
|
||||
|
||||
QUnit.test("SessionExpiredDialog", async (assert) => {
|
||||
assert.expect(7);
|
||||
patchWithCleanup(browser, {
|
||||
location: {
|
||||
reload() {
|
||||
assert.step("location reload");
|
||||
},
|
||||
},
|
||||
});
|
||||
env = await makeDialogTestEnv();
|
||||
assert.containsNone(target, ".o_dialog");
|
||||
await mount(SessionExpiredDialog, target, { env, props: { close() {} } });
|
||||
assert.containsOnce(target, ".o_dialog");
|
||||
assert.strictEqual(
|
||||
target.querySelector("header .modal-title").textContent,
|
||||
"Odoo Session Expired"
|
||||
);
|
||||
assert.strictEqual(
|
||||
target.querySelector("main p").textContent,
|
||||
" Your Odoo session expired. The current page is about to be refreshed. "
|
||||
);
|
||||
const footerButton = target.querySelector(".o_dialog footer button");
|
||||
assert.strictEqual(footerButton.textContent, "Ok");
|
||||
click(footerButton);
|
||||
assert.verifySteps(["location reload"]);
|
||||
});
|
||||
|
|
@ -0,0 +1,523 @@
|
|||
import { beforeEach, describe, expect, test } from "@odoo/hoot";
|
||||
import { manuallyDispatchProgrammaticEvent } from "@odoo/hoot-dom";
|
||||
import { Deferred, advanceTime, animationFrame } from "@odoo/hoot-mock";
|
||||
import { Component, OwlError, onError, onWillStart, xml } from "@odoo/owl";
|
||||
import {
|
||||
makeMockEnv,
|
||||
mockService,
|
||||
mountWithCleanup,
|
||||
onRpc,
|
||||
patchWithCleanup,
|
||||
serverState,
|
||||
} from "@web/../tests/web_test_helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import {
|
||||
ClientErrorDialog,
|
||||
RPCErrorDialog,
|
||||
standardErrorDialogProps,
|
||||
} from "@web/core/errors/error_dialogs";
|
||||
import { UncaughtPromiseError } from "@web/core/errors/error_service";
|
||||
import { ConnectionLostError, RPCError } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { omit } from "@web/core/utils/objects";
|
||||
|
||||
const errorDialogRegistry = registry.category("error_dialogs");
|
||||
const errorHandlerRegistry = registry.category("error_handlers");
|
||||
|
||||
test("can handle rejected promise errors with a string as reason", async () => {
|
||||
expect.assertions(2);
|
||||
expect.errors(1);
|
||||
await makeMockEnv();
|
||||
errorHandlerRegistry.add(
|
||||
"__test_handler__",
|
||||
(env, err, originalError) => {
|
||||
expect(originalError).toBe("-- something went wrong --");
|
||||
},
|
||||
{ sequence: 0 }
|
||||
);
|
||||
Promise.reject("-- something went wrong --");
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["-- something went wrong --"]);
|
||||
});
|
||||
|
||||
test("handle RPC_ERROR of type='server' and no associated dialog class", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
const error = new RPCError();
|
||||
error.code = 701;
|
||||
error.message = "Some strange error occured";
|
||||
error.data = { debug: "somewhere" };
|
||||
error.subType = "strange_error";
|
||||
error.model = "some model";
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(RPCErrorDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "RPC_ERROR",
|
||||
type: "server",
|
||||
code: 701,
|
||||
data: {
|
||||
debug: "somewhere",
|
||||
},
|
||||
subType: "strange_error",
|
||||
message: "Some strange error occured",
|
||||
exceptionName: null,
|
||||
model: "some model",
|
||||
});
|
||||
expect(props.traceback).toMatch(/RPC_ERROR/);
|
||||
expect(props.traceback).toMatch(/Some strange error occured/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["RPC_ERROR: Some strange error occured"]);
|
||||
});
|
||||
|
||||
test("handle custom RPC_ERROR of type='server' and associated custom dialog class", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
class CustomDialog extends Component {
|
||||
static template = xml`<RPCErrorDialog title="'Strange Error'"/>`;
|
||||
static components = { RPCErrorDialog };
|
||||
static props = { ...standardErrorDialogProps };
|
||||
}
|
||||
const error = new RPCError();
|
||||
error.code = 701;
|
||||
error.message = "Some strange error occured";
|
||||
error.model = "some model";
|
||||
const errorData = {
|
||||
context: { exception_class: "strange_error" },
|
||||
name: "strange_error",
|
||||
};
|
||||
error.data = errorData;
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(CustomDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "RPC_ERROR",
|
||||
type: "server",
|
||||
code: 701,
|
||||
data: errorData,
|
||||
subType: null,
|
||||
message: "Some strange error occured",
|
||||
exceptionName: null,
|
||||
model: "some model",
|
||||
});
|
||||
expect(props.traceback).toMatch(/RPC_ERROR/);
|
||||
expect(props.traceback).toMatch(/Some strange error occured/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
errorDialogRegistry.add("strange_error", CustomDialog);
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["RPC_ERROR: Some strange error occured"]);
|
||||
});
|
||||
|
||||
test("handle normal RPC_ERROR of type='server' and associated custom dialog class", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
class CustomDialog extends Component {
|
||||
static template = xml`<RPCErrorDialog title="'Strange Error'"/>`;
|
||||
static components = { RPCErrorDialog };
|
||||
static props = ["*"];
|
||||
}
|
||||
class NormalDialog extends Component {
|
||||
static template = xml`<RPCErrorDialog title="'Normal Error'"/>`;
|
||||
static components = { RPCErrorDialog };
|
||||
static props = ["*"];
|
||||
}
|
||||
const error = new RPCError();
|
||||
error.code = 701;
|
||||
error.message = "A normal error occured";
|
||||
const errorData = {
|
||||
context: { exception_class: "strange_error" },
|
||||
};
|
||||
error.exceptionName = "normal_error";
|
||||
error.data = errorData;
|
||||
error.model = "some model";
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(NormalDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "RPC_ERROR",
|
||||
type: "server",
|
||||
code: 701,
|
||||
data: errorData,
|
||||
subType: null,
|
||||
message: "A normal error occured",
|
||||
exceptionName: "normal_error",
|
||||
model: "some model",
|
||||
});
|
||||
expect(props.traceback).toMatch(/RPC_ERROR/);
|
||||
expect(props.traceback).toMatch(/A normal error occured/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
errorDialogRegistry.add("strange_error", CustomDialog);
|
||||
errorDialogRegistry.add("normal_error", NormalDialog);
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["RPC_ERROR: A normal error occured"]);
|
||||
});
|
||||
|
||||
test("handle CONNECTION_LOST_ERROR", async () => {
|
||||
expect.errors(1);
|
||||
mockService("notification", {
|
||||
add(message) {
|
||||
expect.step(`create (${message})`);
|
||||
return () => {
|
||||
expect.step(`close`);
|
||||
};
|
||||
},
|
||||
});
|
||||
const values = [false, true]; // simulate the 'back online status' after 2 'version_info' calls
|
||||
onRpc("/web/webclient/version_info", async () => {
|
||||
expect.step("version_info");
|
||||
const online = values.shift();
|
||||
if (online) {
|
||||
return true;
|
||||
} else {
|
||||
return Promise.reject();
|
||||
}
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
const error = new ConnectionLostError("/fake_url");
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
patchWithCleanup(Math, {
|
||||
random: () => 0,
|
||||
});
|
||||
// wait for timeouts
|
||||
await advanceTime(2000);
|
||||
await advanceTime(3500);
|
||||
expect.verifySteps([
|
||||
"create (Connection lost. Trying to reconnect...)",
|
||||
"version_info",
|
||||
"version_info",
|
||||
"close",
|
||||
"create (Connection restored. You are back online.)",
|
||||
]);
|
||||
expect.verifyErrors([
|
||||
`Error: Connection to "/fake_url" couldn't be established or was interrupted`,
|
||||
]);
|
||||
});
|
||||
|
||||
test("will let handlers from the registry handle errors first", async () => {
|
||||
expect.assertions(4);
|
||||
expect.errors(1);
|
||||
const testEnv = await makeMockEnv();
|
||||
testEnv.someValue = 14;
|
||||
errorHandlerRegistry.add("__test_handler__", (env, err, originalError) => {
|
||||
expect(originalError).toBe(error);
|
||||
expect(env.someValue).toBe(14);
|
||||
expect.step("in handler");
|
||||
return true;
|
||||
});
|
||||
const error = new Error();
|
||||
error.name = "boom";
|
||||
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["boom"]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
});
|
||||
|
||||
test("originalError is the root cause of the error chain", async () => {
|
||||
expect.assertions(10);
|
||||
expect.errors(2);
|
||||
await makeMockEnv();
|
||||
const error = new Error();
|
||||
error.name = "boom";
|
||||
errorHandlerRegistry.add("__test_handler__", (env, err, originalError) => {
|
||||
expect(err).toBeInstanceOf(UncaughtPromiseError); // Wrapped by error service
|
||||
expect(err.cause).toBeInstanceOf(OwlError); // Wrapped by owl
|
||||
expect(err.cause.cause).toBe(originalError); // original error
|
||||
expect.step("in handler");
|
||||
return true;
|
||||
});
|
||||
|
||||
class ErrHandler extends Component {
|
||||
static template = xml`<t t-component="props.comp"/>`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onError(async (err) => {
|
||||
Promise.reject(err);
|
||||
await animationFrame();
|
||||
prom.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
class ThrowInSetup extends Component {
|
||||
static template = xml``;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let prom = new Deferred();
|
||||
mountWithCleanup(ErrHandler, { props: { comp: ThrowInSetup } });
|
||||
await prom;
|
||||
expect.verifyErrors([
|
||||
`Error: An error occured in the owl lifecycle (see this Error's "cause" property)`,
|
||||
]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
|
||||
class ThrowInWillStart extends Component {
|
||||
static template = xml``;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
onWillStart(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
prom = new Deferred();
|
||||
mountWithCleanup(ErrHandler, { props: { comp: ThrowInWillStart } });
|
||||
await prom;
|
||||
expect.verifyErrors([`Error: The following error occurred in onWillStart: ""`]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
});
|
||||
|
||||
test("handle uncaught promise errors", async () => {
|
||||
expect.assertions(5);
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.message = "This is an error test";
|
||||
error.name = "TestError";
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(ClientErrorDialog);
|
||||
expect(omit(props, "traceback", "serverHost")).toEqual({
|
||||
name: "UncaughtPromiseError > TestError",
|
||||
message: "Uncaught Promise > This is an error test",
|
||||
});
|
||||
expect(props.traceback).toMatch(/TestError/);
|
||||
expect(props.traceback).toMatch(/This is an error test/);
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
Promise.reject(error);
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["TestError: This is an error test"]);
|
||||
});
|
||||
|
||||
test("handle uncaught client errors", async () => {
|
||||
expect.assertions(4);
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.message = "This is an error test";
|
||||
error.name = "TestError";
|
||||
|
||||
mockService("dialog", {
|
||||
add(dialogClass, props) {
|
||||
expect(dialogClass).toBe(ClientErrorDialog);
|
||||
expect(props.name).toBe("UncaughtClientError > TestError");
|
||||
expect(props.message).toBe("Uncaught Javascript Error > This is an error test");
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
setTimeout(() => {
|
||||
throw error;
|
||||
});
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["TestError: This is an error test"]);
|
||||
});
|
||||
|
||||
test("don't show dialog for errors in third-party scripts", async () => {
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.name = "Script error.";
|
||||
|
||||
mockService("dialog", {
|
||||
add(_dialogClass, props) {
|
||||
throw new Error("should not pass here");
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
// Error events from errors in third-party scripts have no colno, no lineno and no filename
|
||||
// because of CORS.
|
||||
await manuallyDispatchProgrammaticEvent(window, "error", { error });
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["Script error."]);
|
||||
});
|
||||
|
||||
test("show dialog for errors in third-party scripts in debug mode", async () => {
|
||||
expect.errors(1);
|
||||
class TestError extends Error {}
|
||||
const error = new TestError();
|
||||
error.name = "Script error.";
|
||||
serverState.debug = "1";
|
||||
|
||||
mockService("dialog", {
|
||||
add(_dialogClass, props) {
|
||||
expect.step("Dialog: " + props.message);
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
await makeMockEnv();
|
||||
|
||||
// Error events from errors in third-party scripts have no colno, no lineno and no filename
|
||||
// because of CORS.
|
||||
await manuallyDispatchProgrammaticEvent(window, "error", { error });
|
||||
await animationFrame();
|
||||
expect.verifyErrors(["Script error."]);
|
||||
expect.verifySteps(["Dialog: Third-Party Script Error"]);
|
||||
});
|
||||
|
||||
test("lazy loaded handlers", async () => {
|
||||
expect.assertions(3);
|
||||
expect.errors(2);
|
||||
await makeMockEnv();
|
||||
|
||||
Promise.reject(new Error("error"));
|
||||
await animationFrame();
|
||||
|
||||
expect.verifyErrors(["Error: error"]);
|
||||
|
||||
errorHandlerRegistry.add("__test_handler__", () => {
|
||||
expect.step("in handler");
|
||||
return true;
|
||||
});
|
||||
|
||||
Promise.reject(new Error("error"));
|
||||
await animationFrame();
|
||||
|
||||
expect.verifyErrors(["Error: error"]);
|
||||
expect.verifySteps(["in handler"]);
|
||||
});
|
||||
|
||||
let unhandledRejectionCb;
|
||||
let errorCb;
|
||||
|
||||
describe("Error Service Logs", () => {
|
||||
beforeEach(() => {
|
||||
patchWithCleanup(browser, {
|
||||
addEventListener: (type, cb) => {
|
||||
if (type === "unhandledrejection") {
|
||||
unhandledRejectionCb = cb;
|
||||
} else if (type === "error") {
|
||||
errorCb = cb;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("logs the traceback of the full error chain for unhandledrejection", async () => {
|
||||
expect.assertions(2);
|
||||
const regexParts = [
|
||||
/^.*This is a wrapper error/,
|
||||
/Caused by:.*This is a second wrapper error/,
|
||||
/Caused by:.*This is the original error/,
|
||||
];
|
||||
const errorRegex = new RegExp(regexParts.map((re) => re.source).join(/[\s\S]*/.source));
|
||||
patchWithCleanup(console, {
|
||||
error(errorMessage) {
|
||||
expect(errorMessage).toMatch(errorRegex);
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error("This is a wrapper error");
|
||||
error.cause = new Error("This is a second wrapper error");
|
||||
error.cause.cause = new Error("This is the original error");
|
||||
|
||||
await makeMockEnv();
|
||||
const errorEvent = new PromiseRejectionEvent("unhandledrejection", {
|
||||
reason: error,
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
});
|
||||
await unhandledRejectionCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test("logs the traceback of the full error chain for uncaughterror", async () => {
|
||||
expect.assertions(2);
|
||||
const regexParts = [
|
||||
/^.*This is a wrapper error/,
|
||||
/Caused by:.*This is a second wrapper error/,
|
||||
/Caused by:.*This is the original error/,
|
||||
];
|
||||
const errorRegex = new RegExp(regexParts.map((re) => re.source).join(/[\s\S]*/.source));
|
||||
patchWithCleanup(console, {
|
||||
error(errorMessage) {
|
||||
expect(errorMessage).toMatch(errorRegex);
|
||||
},
|
||||
});
|
||||
|
||||
const error = new Error("This is a wrapper error");
|
||||
error.cause = new Error("This is a second wrapper error");
|
||||
error.cause.cause = new Error("This is the original error");
|
||||
|
||||
await makeMockEnv();
|
||||
const errorEvent = new Event("error", {
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
});
|
||||
errorEvent.error = error;
|
||||
errorEvent.filename = "dummy_file.js"; // needed to not be treated as a CORS error
|
||||
await errorCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test("error in handlers while handling an error", async () => {
|
||||
// Scenario: an error occurs at the early stage of the "boot" sequence, error handlers
|
||||
// that are supposed to spawn dialogs are not ready then and will crash.
|
||||
// We assert that *exactly one* error message is logged, that contains the original error's traceback
|
||||
// and an indication that a handler has crashed just for not loosing information.
|
||||
// The crash of the error handler should merely be seen as a consequence of the early stage at which the error occurs.
|
||||
errorHandlerRegistry.add(
|
||||
"__test_handler__",
|
||||
(env, err, originalError) => {
|
||||
throw new Error("Boom in handler");
|
||||
},
|
||||
{ sequence: 0 }
|
||||
);
|
||||
// We want to assert that the error_service code does the preventDefault.
|
||||
patchWithCleanup(console, {
|
||||
error(errorMessage) {
|
||||
expect(errorMessage).toMatch(
|
||||
new RegExp(
|
||||
`^@web/core/error_service: handler "__test_handler__" failed with "Error: Boom in handler" while trying to handle:\nError: Genuine Business Boom.*`
|
||||
)
|
||||
);
|
||||
expect.step("error logged");
|
||||
},
|
||||
});
|
||||
|
||||
await makeMockEnv();
|
||||
let errorEvent = new Event("error", {
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
errorEvent.error = new Error("Genuine Business Boom");
|
||||
errorEvent.error.annotatedTraceback = "annotated";
|
||||
errorEvent.filename = "dummy_file.js"; // needed to not be treated as a CORS error
|
||||
await errorCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
expect.verifySteps(["error logged"]);
|
||||
|
||||
errorEvent = new PromiseRejectionEvent("unhandledrejection", {
|
||||
promise: null,
|
||||
cancelable: true,
|
||||
reason: new Error("Genuine Business Boom"),
|
||||
});
|
||||
await unhandledRejectionCb(errorEvent);
|
||||
expect(errorEvent.defaultPrevented).toBe(true);
|
||||
expect.verifySteps(["error logged"]);
|
||||
});
|
||||
});
|
||||
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