Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,444 @@
/**
*------------------------------------------------------------------------------
* Odoo Web Boostrap Code
*------------------------------------------------------------------------------
*
* Each module can return a promise. In that case, the module is marked as loaded
* only when the promise is resolved, and its value is equal to the resolved value.
* The module can be rejected (unloaded). This will be logged in the console as info.
*
* logs:
* Missing dependencies:
* These modules do not appear in the page. It is possible that the
* JavaScript file is not in the page or that the module name is wrong
* Failed modules:
* A javascript error is detected
* Rejected modules:
* The module returns a rejected promise. It (and its dependent modules)
* is not loaded.
* Rejected linked modules:
* Modules who depend on a rejected module
* Non loaded modules:
* Modules who depend on a missing or a failed module
* Debug:
* Non loaded or failed module informations for debugging
*/
(function () {
"use strict";
var jobUID = Date.now();
var jobs = [];
var factories = Object.create(null);
var jobDeps = [];
var jobPromises = [];
var services = Object.create({});
var commentRegExp = /(\/\*([\s\S]*?)\*\/|([^:]|^)\/\/(.*)$)/gm;
var cjsRequireRegExp = /[^.]\s*require\s*\(\s*["']([^'"\s]+)["']\s*\)/g;
if (!globalThis.odoo) {
globalThis.odoo = {};
}
var odoo = globalThis.odoo;
var debug = odoo.debug;
var didLogInfoResolve;
var didLogInfoPromise = new Promise(function (resolve) {
didLogInfoResolve = resolve;
});
odoo.remainingJobs = jobs;
odoo.__DEBUG__ = {
didLogInfo: didLogInfoPromise,
getDependencies: function (name, transitive) {
var deps = name instanceof Array ? name : [name];
var changed;
do {
changed = false;
jobDeps.forEach(function (dep) {
if (deps.indexOf(dep.to) >= 0 && deps.indexOf(dep.from) < 0) {
deps.push(dep.from);
changed = true;
}
});
} while (changed && transitive);
return deps;
},
getDependents: function (name) {
return jobDeps
.filter(function (dep) {
return dep.from === name;
})
.map(function (dep) {
return dep.to;
});
},
getWaitedJobs: function () {
return jobs
.map(function (job) {
return job.name;
})
.filter(function (item, index, self) {
// uniq
return self.indexOf(item) === index;
});
},
getMissingJobs: function () {
var self = this;
var waited = this.getWaitedJobs();
var missing = [];
waited.forEach(function (job) {
self.getDependencies(job).forEach(function (job) {
if (!(job in self.services)) {
missing.push(job);
}
});
});
return missing
.filter(function (item, index, self) {
return self.indexOf(item) === index;
})
.filter(function (item) {
return waited.indexOf(item) < 0;
})
.filter(function (job) {
return !job.error;
});
},
getFailedJobs: function () {
return jobs.filter(function (job) {
return !!job.error;
});
},
processJobs: function () {
var job;
function processJob(job) {
var require = makeRequire(job);
var jobExec;
function onError(e) {
job.error = e;
console.error(`Error while loading ${job.name}: ${e.message}`, e);
Promise.reject(e);
}
var def = new Promise(function (resolve) {
try {
jobExec = job.factory.call(null, require);
jobs.splice(jobs.indexOf(job), 1);
} catch (e) {
onError(e);
}
if (!job.error) {
Promise.resolve(jobExec)
.then(function (data) {
services[job.name] = data;
resolve();
odoo.__DEBUG__.processJobs();
})
.guardedCatch(function (e) {
job.rejected = e || true;
jobs.push(job);
})
.catch(function (e) {
if (e instanceof Error) {
onError(e);
}
resolve();
});
} else {
resolve();
}
});
jobPromises.push(def);
def.then(job.resolve);
}
function isReady(job) {
return (
!job.error &&
!job.rejected &&
job.factory.deps.every(function (name) {
return name in services;
})
);
}
function makeRequire(job) {
var deps = {};
Object.keys(services)
.filter(function (item) {
return job.deps.indexOf(item) >= 0;
})
.forEach(function (key) {
deps[key] = services[key];
});
return function require(name) {
if (!(name in deps)) {
console.error("Undefined dependency: ", name);
}
return deps[name];
};
}
while (jobs.length) {
job = undefined;
for (var i = 0; i < jobs.length; i++) {
if (isReady(jobs[i])) {
job = jobs[i];
break;
}
}
if (!job) {
break;
}
processJob(job);
}
return services;
},
factories: factories,
services: services,
};
odoo.define = function () {
var args = Array.prototype.slice.call(arguments);
var name = typeof args[0] === "string" ? args.shift() : "__odoo_job" + jobUID++;
var factory = args[args.length - 1];
var deps;
if (args[0] instanceof Array) {
deps = args[0];
} else {
deps = [];
factory
.toString()
.replace(commentRegExp, "")
.replace(cjsRequireRegExp, function (match, dep) {
deps.push(dep);
});
}
if (!(deps instanceof Array)) {
throw new Error("Dependencies should be defined by an array", deps);
}
if (typeof factory !== "function") {
throw new Error("Factory should be defined by a function", factory);
}
if (typeof name !== "string") {
throw new Error("Invalid name definition (should be a string", name);
}
if (name in factories) {
throw new Error("Service " + name + " already defined");
}
factory.deps = deps;
factories[name] = factory;
let promiseResolve;
const promise = new Promise((resolve) => {
promiseResolve = resolve;
});
jobs.push({
name: name,
factory: factory,
deps: deps,
resolve: promiseResolve,
promise: promise,
});
deps.forEach(function (dep) {
jobDeps.push({ from: dep, to: name });
});
odoo.__DEBUG__.processJobs();
};
odoo.log = function () {
var missing = [];
var failed = [];
var cycle = null;
if (jobs.length) {
var debugJobs = {};
var rejected = [];
var rejectedLinked = [];
var job;
var jobdep;
for (var k = 0; k < jobs.length; k++) {
debugJobs[jobs[k].name] = job = {
dependencies: jobs[k].deps,
dependents: odoo.__DEBUG__.getDependents(jobs[k].name),
name: jobs[k].name,
};
if (jobs[k].error) {
job.error = jobs[k].error;
}
if (jobs[k].rejected) {
job.rejected = jobs[k].rejected;
rejected.push(job.name);
}
var deps = odoo.__DEBUG__.getDependencies(job.name);
for (var i = 0; i < deps.length; i++) {
if (job.name !== deps[i] && !(deps[i] in services)) {
jobdep = debugJobs[deps[i]];
if (!jobdep && deps[i] in factories) {
for (var j = 0; j < jobs.length; j++) {
if (jobs[j].name === deps[i]) {
jobdep = jobs[j];
break;
}
}
}
if (jobdep && jobdep.rejected) {
if (!job.rejected) {
job.rejected = [];
rejectedLinked.push(job.name);
}
job.rejected.push(deps[i]);
} else {
if (!job.missing) {
job.missing = [];
}
job.missing.push(deps[i]);
}
}
}
}
missing = odoo.__DEBUG__.getMissingJobs();
failed = odoo.__DEBUG__.getFailedJobs();
var unloaded = Object.keys(debugJobs) // Object.values is not supported
.map(function (key) {
return debugJobs[key];
})
.filter(function (job) {
return job.missing;
});
if (debug || failed.length || unloaded.length) {
var log = globalThis.console[
!failed.length || !unloaded.length ? "info" : "error"
].bind(globalThis.console);
log(
(failed.length ? "error" : unloaded.length ? "warning" : "info") +
": Some modules could not be started"
);
if (missing.length) {
log("Missing dependencies: ", missing);
}
if (failed.length) {
log(
"Failed modules: ",
failed.map(function (fail) {
return fail.name;
})
);
}
if (rejected.length) {
log("Rejected modules: ", rejected);
}
if (rejectedLinked.length) {
log("Rejected linked modules: ", rejectedLinked);
}
if (unloaded.length) {
cycle = findCycle(unloaded);
if (cycle) {
console.error("Cyclic dependencies: " + cycle);
}
log(
"Non loaded modules: ",
unloaded.map(function (unload) {
return unload.name;
})
);
}
if (debug && Object.keys(debugJobs).length) {
log("Debug: ", debugJobs);
}
}
}
odoo.__DEBUG__.jsModules = {
missing: missing,
failed: failed.map((mod) => mod.name),
unloaded: unloaded ? unloaded.map((mod) => mod.name) : [],
cycle,
};
didLogInfoResolve(true);
};
/**
* Returns a resolved promise when the targeted services are loaded.
* If no service is found the promise is used directly.
*
* @param {string|RegExp} serviceName name of the service to expect
* or regular expression matching the service.
* @returns {Promise<number>} resolved when the services ares
* loaded. The value is equal to the number of services found.
*/
odoo.ready = async function (serviceName) {
function match(name) {
return typeof serviceName === "string" ? name === serviceName : serviceName.test(name);
}
await Promise.all(jobs.filter((job) => match(job.name)).map((job) => job.promise));
return Object.keys(factories).filter(match).length;
};
odoo.runtimeImport = function (moduleName) {
if (!(moduleName in services)) {
throw new Error(`Service "${moduleName} is not defined or isn't finished loading."`);
}
return services[moduleName];
};
// Automatically log errors detected when loading modules
globalThis.addEventListener("load", function logWhenLoaded() {
const len = jobPromises.length;
Promise.all(jobPromises).then(function () {
if (len === jobPromises.length) {
odoo.log();
} else {
logWhenLoaded();
}
});
});
/**
* Visit the list of jobs, and return the first found cycle, if any
*
* @param {any[]} jobs
* @returns {null | string} either a string describing a cycle, or null
*/
function findCycle(jobs) {
// build dependency graph
const dependencyGraph = new Map();
for (const job of jobs) {
dependencyGraph.set(job.name, job.dependencies);
}
// helpers
function visitJobs(jobs, visited = new Set()) {
for (const job of jobs) {
const result = visitJob(job, visited);
if (result) {
return result;
}
}
return null;
}
function visitJob(job, visited) {
if (visited.has(job)) {
const jobs = Array.from(visited).concat([job]);
const index = jobs.indexOf(job);
return jobs
.slice(index)
.map((j) => `"${j}"`)
.join(" => ");
}
const deps = dependencyGraph.get(job);
return deps ? visitJobs(deps, new Set(visited).add(job)) : null;
}
// visit each root to find cycles
return visitJobs(jobs.map((j) => j.name));
}
})();

View file

@ -0,0 +1,224 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { localization } from "@web/core/l10n/localization";
import { clamp } from "@web/core/utils/numbers";
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
const isScrollSwipable = (scrollables) => {
return {
left: !scrollables.filter((e) => e.scrollLeft !== 0).length,
right: !scrollables.filter(
(e) => e.scrollLeft + Math.round(e.getBoundingClientRect().width) !== e.scrollWidth
).length,
};
};
/**
* Action Swiper
*
* This component is intended to perform action once a user has completed a touch swipe.
* You can choose the direction allowed for such behavior (left, right or both).
* The action to perform must be passed as a props. It is possible to define a condition
* to allow the swipe interaction conditionnally.
* @extends Component
*/
export class ActionSwiper extends Component {
setup() {
this.actionTimeoutId = null;
this.resetTimeoutId = null;
this.defaultState = {
containerStyle: "",
isSwiping: false,
width: undefined,
};
this.root = useRef("root");
this.targetContainer = useRef("targetContainer");
this.state = useState({ ...this.defaultState });
this.scrollables = undefined;
this.startX = undefined;
this.swipedDistance = 0;
this.isScrollValidated = false;
onMounted(() => {
if (this.targetContainer.el) {
this.state.width = this.targetContainer.el.getBoundingClientRect().width;
}
// Forward classes set on component to slot, as we only want to wrap an
// existing component without altering the DOM structure any more than
// strictly necessary
if (this.props.onLeftSwipe || this.props.onRightSwipe) {
const classes = new Set(this.root.el.classList);
classes.delete("o_actionswiper");
for (const className of classes) {
this.targetContainer.el.firstChild.classList.add(className);
this.root.el.classList.remove(className);
}
}
});
onWillUnmount(() => {
browser.clearTimeout(this.actionTimeoutId);
browser.clearTimeout(this.resetTimeoutId);
});
}
get localizedProps() {
return {
onLeftSwipe:
localization.direction === "rtl" ? this.props.onRightSwipe : this.props.onLeftSwipe,
onRightSwipe:
localization.direction === "rtl" ? this.props.onLeftSwipe : this.props.onRightSwipe,
};
}
/**
* @private
* @param {TouchEvent} ev
*/
_onTouchEndSwipe() {
if (this.state.isSwiping) {
this.state.isSwiping = false;
if (
this.localizedProps.onRightSwipe &&
this.swipedDistance > this.state.width / this.props.swipeDistanceRatio
) {
this.swipedDistance = this.state.width;
this.handleSwipe(this.localizedProps.onRightSwipe.action);
} else if (
this.localizedProps.onLeftSwipe &&
this.swipedDistance < -this.state.width / this.props.swipeDistanceRatio
) {
this.swipedDistance = -this.state.width;
this.handleSwipe(this.localizedProps.onLeftSwipe.action);
} else {
this.state.containerStyle = "";
}
}
}
/**
* @private
* @param {TouchEvent} ev
*/
_onTouchMoveSwipe(ev) {
if (this.state.isSwiping) {
if (this.props.swipeInvalid && this.props.swipeInvalid()) {
this.state.isSwiping = false;
return;
}
const { onLeftSwipe, onRightSwipe } = this.localizedProps;
this.swipedDistance = clamp(
ev.touches[0].clientX - this.startX,
onLeftSwipe ? -this.state.width : 0,
onRightSwipe ? this.state.width : 0
);
// Prevent the browser to navigate back/forward when using swipe
// gestures while still allowing to scroll vertically.
if (Math.abs(this.swipedDistance) > 40) {
ev.preventDefault();
}
// If there are scrollable elements under touch pressure,
// they must be at their limits to allow swiping.
if (
!this.isScrollValidated &&
this.scrollables &&
!isScrollSwipable(this.scrollables)[this.swipedDistance > 0 ? "left" : "right"]
) {
return this._reset();
}
this.isScrollValidated = true;
if (this.props.animationOnMove) {
this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`;
}
}
}
/**
* @private
* @param {TouchEvent} ev
*/
_onTouchStartSwipe(ev) {
this.scrollables = ev
.composedPath()
.filter(
(e) =>
e.nodeType === 1 &&
this.targetContainer.el.contains(e) &&
e.scrollWidth > e.getBoundingClientRect().width &&
["auto", "scroll"].includes(window.getComputedStyle(e)["overflow-x"])
);
if (!this.state.width) {
this.state.width =
this.targetContainer && this.targetContainer.el.getBoundingClientRect().width;
}
this.state.isSwiping = true;
this.isScrollValidated = false;
this.startX = ev.touches[0].clientX;
}
/**
* @private
*/
_reset() {
Object.assign(this.state, { ...this.defaultState });
this.scrollables = undefined;
this.startX = undefined;
this.swipedDistance = 0;
this.isScrollValidated = false;
}
handleSwipe(action) {
if (this.props.animationType === "bounce") {
this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`;
this.actionTimeoutId = browser.setTimeout(async () => {
await action();
this._reset();
}, 500);
} else if (this.props.animationType === "forwards") {
this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`;
this.actionTimeoutId = browser.setTimeout(async () => {
await action();
this.state.isSwiping = true;
this.state.containerStyle = `transform: translateX(${-this.swipedDistance}px)`;
this.resetTimeoutId = browser.setTimeout(() => {
this._reset();
}, 100);
}, 100);
} else {
return action();
}
}
}
ActionSwiper.props = {
onLeftSwipe: {
type: Object,
args: {
action: Function,
icon: String,
bgColor: String,
},
optional: true,
},
onRightSwipe: {
type: Object,
args: {
action: Function,
icon: String,
bgColor: String,
},
optional: true,
},
slots: Object,
animationOnMove: { type: Boolean, optional: true },
animationType: { type: String, optional: true },
swipeDistanceRatio: { type: Number, optional: true },
swipeInvalid: { type: Function, optional: true },
};
ActionSwiper.defaultProps = {
onLeftSwipe: undefined,
onRightSwipe: undefined,
animationOnMove: true,
animationType: "bounce",
swipeDistanceRatio: 2,
};
ActionSwiper.template = "web.ActionSwiper";

View file

@ -0,0 +1,20 @@
.o_actionswiper {
position: relative;
touch-action: pan-y;
}
.o_actionswiper_target_container {
transition: transform 0.4s;
}
.o_actionswiper_swiping {
transition: none;
}
.o_actionswiper_right_swipe_area {
/*rtl:ignore*/
transform: translateX(-100%);
inset: 0 auto auto 0;
}
.o_actionswiper_left_swipe_area {
/*rtl:ignore*/
transform: translateX(100%);
inset: 0 0 auto auto;
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ActionSwiper" owl="1">
<t t-if="props.onRightSwipe || props.onLeftSwipe">
<div class="o_actionswiper" t-on-touchend="_onTouchEndSwipe" t-on-touchmove="_onTouchMoveSwipe" t-on-touchstart="_onTouchStartSwipe" t-ref="root">
<div class="o_actionswiper_overflow_container position-relative overflow-hidden">
<div class="o_actionswiper_target_container" t-ref="targetContainer" t-att-style="state.containerStyle" t-att-class="{ o_actionswiper_swiping: state.isSwiping }">
<t t-slot="default"/>
<t t-if="localizedProps.onRightSwipe and (localizedProps.onRightSwipe.icon or localizedProps.onRightSwipe.bgColor)">
<div t-att-style="'max-width: ' + swipedDistance + 'px;'" class="o_actionswiper_right_swipe_area position-absolute overflow-hidden w-100 h-100 d-flex align-items-center justify-content-center rounded-end" t-att-class="localizedProps.onRightSwipe.bgColor">
<span><i class="fa fa-2x" t-att-class="localizedProps.onRightSwipe.icon"/></span>
</div>
</t>
<t t-if="localizedProps.onLeftSwipe and (localizedProps.onLeftSwipe.icon or localizedProps.onLeftSwipe.bgColor)">
<div t-att-style="'max-width: ' + -swipedDistance + 'px;'" class="o_actionswiper_left_swipe_area position-absolute overflow-hidden w-100 h-100 d-flex align-items-center justify-content-center rounded-start" t-att-class="localizedProps.onLeftSwipe.bgColor">
<span><i class="fa fa-2x" t-att-class="localizedProps.onLeftSwipe.icon"/></span>
</div>
</t>
</div>
</div>
</div>
</t>
<t t-else="">
<t t-slot="default"/>
</t>
</t>
</templates>

View file

@ -0,0 +1,284 @@
/** @odoo-module **/
import { memoize } from "./utils/functions";
import { browser } from "./browser/browser";
import { registry } from "./registry";
import { session } from "@web/session";
/**
* This export is done only in order to modify the behavior of the exported
* functions. This is done in order to be able to make a test environment.
* Modules should only use the methods exported below.
*/
export const assets = {
retries: {
count: 3,
delay: 5000,
extraDelay: 2500,
},
};
class AssetsLoadingError extends Error {}
/**
* Loads the given url inside a script tag.
*
* @param {string} url the url of the script
* @returns {Promise<true>} resolved when the script has been loaded
*/
export const _loadJS = (assets.loadJS = memoize(function loadJS(url) {
if (document.querySelector(`script[src="${url}"]`)) {
// Already in the DOM and wasn't loaded through this function
// Unfortunately there is no way to check whether a script has loaded
// or not (which may not be the case for async/defer scripts)
// so we assume it is.
return Promise.resolve();
}
const scriptEl = document.createElement("script");
scriptEl.type = "text/javascript";
scriptEl.src = url;
document.head.appendChild(scriptEl);
return new Promise((resolve, reject) => {
scriptEl.addEventListener("load", () => resolve(true));
scriptEl.addEventListener("error", () => {
reject(new AssetsLoadingError(`The loading of ${url} failed`));
});
});
}));
/**
* Loads the given url as a stylesheet.
*
* @param {string} url the url of the stylesheet
* @returns {Promise<true>} resolved when the stylesheet has been loaded
*/
export const _loadCSS = (assets.loadCSS = memoize(function loadCSS(url, retryCount = 0) {
if (document.querySelector(`link[href="${url}"]`)) {
// Already in the DOM and wasn't loaded through this function
// Unfortunately there is no way to check whether a link has loaded
// or not (which may not be the case for async/defer stylesheets)
// so we assume it is.
return Promise.resolve();
}
const linkEl = document.createElement("link");
linkEl.type = "text/css";
linkEl.rel = "stylesheet";
linkEl.href = url;
const promise = new Promise((resolve, reject) => {
linkEl.addEventListener("load", () => resolve(true));
linkEl.addEventListener("error", async () => {
if (retryCount < assets.retries.count) {
await new Promise(resolve => setTimeout(resolve, assets.retries.delay + assets.retries.extraDelay * retryCount));
linkEl.remove();
loadCSS(url, retryCount + 1).then(resolve).catch(reject);
} else {
reject(new AssetsLoadingError(`The loading of ${url} failed`));
}
});
});
document.head.appendChild(linkEl);
return promise;
}));
/**
* Container dom containing all the owl templates that have been loaded.
* This can be imported by the modules in order to use it when loading the
* application and the components.
*/
export const templates = new DOMParser().parseFromString("<odoo/>", "text/xml");
let defaultApp;
/**
* Loads the given xml template.
*
* @param {string} xml the string defining the templates
* @param {App} [app=defaultApp] optional owl App instance (default value
* can be changed with setLoadXmlDefaultApp method)
* @returns {Promise<true>} resolved when the template xml has been loaded
*/
export const _loadXML = (assets.loadXML = function loadXML(xml, app = defaultApp) {
const doc = new DOMParser().parseFromString(xml, "text/xml");
if (doc.querySelector("parsererror")) {
// The generated error XML is non-standard so we log the full content to
// ensure that the relevant info is actually logged.
throw new Error(doc.querySelector("parsererror").textContent.trim());
}
for (const element of doc.querySelectorAll("templates > [t-name][owl]")) {
element.removeAttribute("owl");
const name = element.getAttribute("t-name");
const previous = templates.querySelector(`[t-name="${name}"]`);
if (previous) {
console.debug("Override template: " + name);
previous.replaceWith(element);
} else {
templates.documentElement.appendChild(element);
}
}
if (app || defaultApp) {
console.debug("Add templates in Owl app.");
app.addTemplates(templates, app || defaultApp);
} else {
console.debug("Add templates on window Owl container.");
}
});
/**
* Update the default app to load templates.
*
* @param {App} app owl App instance
*/
export function setLoadXmlDefaultApp(app) {
defaultApp = app;
}
/**
* Get the files information as descriptor object from a public asset template.
*
* @param {string} bundleName Name of the bundle containing the list of files
* @returns {Promise<{cssLibs, cssContents, jsLibs, jsContents}>}
*/
export const _getBundle = (assets.getBundle = memoize(async function getBundle(bundleName) {
const url = new URL(`/web/bundle/${bundleName}`, location.origin);
for (const [key, value] of Object.entries(session.bundle_params || {})) {
url.searchParams.set(key, value);
}
const response = await browser.fetch(url.href);
const json = await response.json();
const assets = {
cssLibs: [],
cssContents: [],
jsLibs: [],
jsContents: [],
};
for (const key in json) {
const file = json[key];
if (file.type === "link") {
assets.cssLibs.push(file.src);
} else if (file.type === "style") {
assets.cssContents.push(file.content);
} else {
if (file.src) {
assets.jsLibs.push(file.src);
} else {
assets.jsContents.push(file.content);
}
}
}
return assets;
}));
/**
* Loads the given js/css libraries and asset bundles. Note that no library or
* asset will be loaded if it was already done before.
*
* @param {Object} desc
* @param {Array<string|string[]>} [desc.assetLibs=[]]
* The list of assets to load. Each list item may be a string (the xmlID
* of the asset to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the assets in
* inner lists are loaded in parallel (use this for efficiency but only
* if the order does not matter, should rarely be the case for assets).
* @param {string[]} [desc.cssLibs=[]]
* The list of CSS files to load. They will all be loaded in parallel but
* put in the DOM in the given order (only the order in the DOM is used
* to determine priority of CSS rules, not loaded time).
* @param {Array<string|string[]>} [desc.jsLibs=[]]
* The list of JS files to load. Each list item may be a string (the URL
* of the file to load) or a list of strings. The first level is loaded
* sequentially (so use this if the order matters) while the files in inner
* lists are loaded in parallel (use this for efficiency but only
* if the order does not matter).
* @param {string[]} [desc.cssContents=[]]
* List of inline styles to add after loading the CSS files.
* @param {string[]} [desc.jsContents=[]]
* List of inline scripts to add after loading the JS files.
*
* @returns {Promise}
*/
export const _loadBundle = (assets.loadBundle = async function loadBundle(desc) {
// Load css in parallel
const promiseCSS = Promise.all((desc.cssLibs || []).map(assets.loadCSS)).then(() => {
if (desc.cssContents && desc.cssContents.length) {
const style = document.createElement("style");
style.textContent = desc.cssContents.join("\n");
document.head.appendChild(style);
}
});
// Load JavaScript (don't wait for the css loading)
for (const urlData of desc.jsLibs || []) {
if (typeof urlData === "string") {
// serial loading
await assets.loadJS(urlData);
// Wait template if the JavaScript come from bundle.
const bundle = urlData.match(/\/web\/assets\/.*\/([^/]+?)(\.min)?\.js/);
if (bundle) {
await odoo.ready(bundle[1] + ".bundle.xml");
}
} else {
// parallel loading
await Promise.all(urlData.map(loadJS));
}
}
if (desc.jsContents && desc.jsContents.length) {
const script = document.createElement("script");
script.type = "text/javascript";
script.textContent = desc.jsContents.join("\n");
document.head.appendChild(script);
}
// Wait for the scc loading to be completed before loading the other bundle
await promiseCSS;
// Load other desc
for (const bundleName of desc.assetLibs || []) {
if (typeof bundleName === "string") {
// serial loading
const desc = await assets.getBundle(bundleName);
await assets.loadBundle(desc);
} else {
// parallel loading
await Promise.all(
bundleName.map(async (bundleName) => {
const desc = await assets.getBundle(bundleName);
return assets.loadBundle(desc);
})
);
}
}
});
export const loadJS = function (url) {
return assets.loadJS(url);
};
export const loadCSS = function (url) {
return assets.loadCSS(url);
};
export const loadXML = function (xml, app = defaultApp) {
return assets.loadXML(xml, app);
};
export const getBundle = function (bundleName) {
return assets.getBundle(bundleName);
};
export const loadBundle = function (desc) {
return assets.loadBundle(desc);
};
import { Component, xml, onWillStart } from "@odoo/owl";
/**
* Utility component that loads an asset bundle before instanciating a component
*/
export class LazyComponent extends Component {
setup() {
onWillStart(async () => {
const bundle = await getBundle(this.props.bundle);
await loadBundle(bundle);
this.Component = registry.category("lazy_components").get(this.props.Component);
});
}
}
LazyComponent.template = xml`<t t-component="Component" t-props="props.props"/>`;
LazyComponent.props = {
Component: String,
bundle: String,
props: { type: Object, optional: true },
};

View file

@ -0,0 +1,383 @@
/** @odoo-module **/
import { Deferred } from "@web/core/utils/concurrency";
import { useForwardRefToParent, useService } from "@web/core/utils/hooks";
import { useDebounced } from "@web/core/utils/timing";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { usePosition } from "@web/core/position_hook";
import { Component, useExternalListener, useRef, useState } from "@odoo/owl";
export class AutoComplete extends Component {
setup() {
this.nextSourceId = 0;
this.nextOptionId = 0;
this.sources = [];
this.state = useState({
navigationRev: 0,
optionsRev: 0,
open: false,
activeSourceOption: null,
value: this.props.value,
});
this.inputRef = useForwardRefToParent("input");
this.root = useRef("root");
this.debouncedProcessInput = useDebounced(async () => {
const currentPromise = this.pendingPromise;
this.pendingPromise = null;
this.props.onInput({
inputValue: this.inputRef.el.value,
});
try {
await this.open(true);
currentPromise.resolve();
} catch {
currentPromise.reject();
} finally {
if (currentPromise === this.loadingPromise) {
this.loadingPromise = null;
}
}
}, this.constructor.timeout);
useExternalListener(window, "scroll", this.externalClose, true);
useExternalListener(window, "pointerdown", this.externalClose, true);
this.hotkey = useService("hotkey");
this.hotkeysToRemove = [];
owl.onWillUpdateProps((nextProps) => {
if (this.props.value !== nextProps.value || this.forceValFromProp) {
this.forceValFromProp = false;
this.state.value = nextProps.value;
this.inputRef.el.value = nextProps.value;
this.close();
}
});
// position and size
usePosition(() => this.inputRef.el, {
popper: "sourcesList",
position: "bottom-start",
});
}
get activeSourceOptionId() {
if (!this.isOpened || !this.state.activeSourceOption) {
return undefined;
}
const [sourceIndex, optionIndex] = this.state.activeSourceOption;
const source = this.sources[sourceIndex];
return `${this.props.id || "autocomplete"}_${sourceIndex}_${
source.isLoading ? "loading" : optionIndex
}`;
}
get isOpened() {
return this.state.open;
}
get hasOptions() {
for (const source of this.sources) {
if (source.isLoading || source.options.length) {
return true;
}
}
return false;
}
get activeOption() {
const [sourceIndex, optionIndex] = this.state.activeSourceOption;
return this.sources[sourceIndex].options[optionIndex];
}
open(useInput = false) {
this.state.open = true;
return this.loadSources(useInput);
}
close() {
this.state.open = false;
this.state.activeSourceOption = null;
}
cancel() {
if (this.inputRef.el.value.length) {
if (this.props.autoSelect) {
this.inputRef.el.value = this.props.value;
this.props.onCancel();
}
}
this.close();
}
async loadSources(useInput) {
this.sources = [];
this.state.activeSourceOption = null;
const proms = [];
for (const pSource of this.props.sources) {
const source = this.makeSource(pSource);
this.sources.push(source);
const options = this.loadOptions(
pSource.options,
useInput ? this.inputRef.el.value.trim() : ""
);
if (options instanceof Promise) {
source.isLoading = true;
const prom = options.then((options) => {
source.options = options.map((option) => this.makeOption(option));
source.isLoading = false;
this.state.optionsRev++;
});
proms.push(prom);
} else {
source.options = options.map((option) => this.makeOption(option));
}
}
await Promise.all(proms);
this.navigate(0);
}
loadOptions(options, request) {
if (typeof options === "function") {
return options(request);
} else {
return options;
}
}
makeOption(option) {
return Object.assign(Object.create(option), {
id: ++this.nextOptionId,
});
}
makeSource(source) {
return {
id: ++this.nextSourceId,
options: [],
isLoading: false,
placeholder: source.placeholder,
optionTemplate: source.optionTemplate,
};
}
isActiveSourceOption([sourceIndex, optionIndex]) {
return (
this.state.activeSourceOption &&
this.state.activeSourceOption[0] === sourceIndex &&
this.state.activeSourceOption[1] === optionIndex
);
}
selectOption(option, params = {}) {
if (option.unselectable) {
this.inputRef.el.value = "";
this.close();
return;
}
if (this.props.resetOnSelect) {
this.inputRef.el.value = "";
}
this.forceValFromProp = true;
this.props.onSelect(option, {
...params,
input: this.inputRef.el,
});
const customEvent = new CustomEvent("AutoComplete:OPTION_SELECTED", { bubbles: true });
this.root.el.dispatchEvent(customEvent);
this.close();
}
navigate(direction) {
let step = Math.sign(direction);
if (!step) {
this.state.activeSourceOption = null;
step = 1;
} else {
this.state.navigationRev++;
}
if (this.state.activeSourceOption) {
let [sourceIndex, optionIndex] = this.state.activeSourceOption;
let source = this.sources[sourceIndex];
optionIndex += step;
if (0 > optionIndex || optionIndex >= source.options.length) {
sourceIndex += step;
source = this.sources[sourceIndex];
while (source && source.isLoading) {
sourceIndex += step;
source = this.sources[sourceIndex];
}
if (source) {
optionIndex = step < 0 ? source.options.length - 1 : 0;
}
}
this.state.activeSourceOption = source ? [sourceIndex, optionIndex] : null;
} else {
let sourceIndex = step < 0 ? this.sources.length - 1 : 0;
let source = this.sources[sourceIndex];
while (source && source.isLoading) {
sourceIndex += step;
source = this.sources[sourceIndex];
}
if (source) {
const optionIndex = step < 0 ? source.options.length - 1 : 0;
if (optionIndex < source.options.length) {
this.state.activeSourceOption = [sourceIndex, optionIndex];
}
}
}
}
onInputBlur() {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
this.props.onBlur({
inputValue: this.inputRef.el.value,
});
}
onInputClick() {
if (!this.isOpened) {
this.open(this.inputRef.el.value.trim() !== this.props.value.trim());
} else {
this.close();
}
}
onInputChange(ev) {
if (this.ignoreBlur) {
ev.stopImmediatePropagation();
}
this.props.onChange({
inputValue: this.inputRef.el.value,
});
}
async onInput() {
this.pendingPromise = this.pendingPromise || new Deferred();
this.loadingPromise = this.pendingPromise;
this.debouncedProcessInput();
}
async onInputKeydown(ev) {
const hotkey = getActiveHotkey(ev);
const isSelectKey = hotkey === "enter" || hotkey === "tab";
if (this.loadingPromise && isSelectKey) {
if (hotkey === "enter") {
ev.stopPropagation();
ev.preventDefault();
}
await this.loadingPromise;
}
switch (hotkey) {
case "enter":
if (!this.isOpened || !this.state.activeSourceOption) {
return;
}
this.selectOption(this.activeOption);
break;
case "escape":
if (!this.isOpened) {
return;
}
this.cancel();
break;
case "tab":
if (!this.isOpened) {
return;
}
if (
this.props.autoSelect &&
this.state.activeSourceOption &&
(this.state.navigationRev > 0 || this.inputRef.el.value.length > 0)
) {
this.selectOption(this.activeOption);
}
this.close();
return;
case "arrowup":
this.navigate(-1);
if (!this.isOpened) {
this.open(true);
}
break;
case "arrowdown":
this.navigate(+1);
if (!this.isOpened) {
this.open(true);
}
break;
default:
return;
}
ev.stopPropagation();
ev.preventDefault();
}
onOptionMouseEnter(indices) {
this.state.activeSourceOption = indices;
}
onOptionMouseLeave() {
this.state.activeSourceOption = null;
}
onOptionClick(option) {
this.selectOption(option);
this.inputRef.el.focus();
}
externalClose(ev) {
if (this.isOpened && !this.root.el.contains(ev.target)) {
this.cancel();
}
}
}
Object.assign(AutoComplete, {
template: "web.AutoComplete",
props: {
value: { type: String },
id: { type: String, optional: true },
onSelect: { type: Function },
sources: {
type: Array,
element: {
type: Object,
shape: {
placeholder: { type: String, optional: true },
optionTemplate: { type: String, optional: true },
options: [Array, Function],
},
},
},
placeholder: { type: String, optional: true },
autoSelect: { type: Boolean, optional: true },
resetOnSelect: { type: Boolean, optional: true },
onCancel: { type: Function, optional: true },
onInput: { type: Function, optional: true },
onChange: { type: Function, optional: true },
onBlur: { type: Function, optional: true },
input: { type: Function, optional: true },
},
defaultProps: {
placeholder: "",
autoSelect: false,
onCancel: () => {},
onInput: () => {},
onChange: () => {},
onBlur: () => {},
},
timeout: 250,
});

View file

@ -0,0 +1,6 @@
.o-autocomplete {
.o-autocomplete--input {
width: 100%;
}
}

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.AutoComplete" owl="1">
<div class="o-autocomplete dropdown" t-ref="root">
<input
type="text"
t-att-id="props.id"
class="o-autocomplete--input o_input"
autocomplete="off"
t-att-placeholder="props.placeholder"
role="combobox"
t-att-aria-activedescendant="activeSourceOptionId"
t-att-aria-expanded="(isOpened and hasOptions) ? 'true' : 'false'"
aria-autocomplete="list"
aria-haspopup="listbox"
t-model="state.value"
t-on-blur="onInputBlur"
t-on-click="onInputClick"
t-on-change="onInputChange"
t-on-input="onInput"
t-on-keydown="onInputKeydown"
t-ref="input"
/>
<t t-if="isOpened and hasOptions">
<ul role="listbox" class="o-autocomplete--dropdown-menu dropdown-menu ui-widget ui-autocomplete show" t-ref="sourcesList">
<t t-foreach="sources" t-as="source" t-key="source.id">
<t t-if="source.isLoading">
<li class="o-autocomplete--dropdown-item ui-menu-item">
<a
t-attf-id="{{props.id or 'autocomplete'}}_{{source_index}}_loading"
role="option"
href="#"
class="dropdown-item ui-menu-item-wrapper"
aria-selected="true"
>
<i class="fa fa-spin fa-circle-o-notch" /> <t t-esc="source.placeholder" />
</a>
</li>
</t>
<t t-else="">
<t t-foreach="source.options" t-as="option" t-key="option.id">
<li
class="o-autocomplete--dropdown-item ui-menu-item"
t-att-class="option.classList"
t-on-mouseenter="() => this.onOptionMouseEnter([source_index, option_index])"
t-on-mouseleave="() => this.onOptionMouseLeave([source_index, option_index])"
t-on-click="() => this.onOptionClick(option)"
t-on-pointerdown="() => this.ignoreBlur = true"
>
<a
t-attf-id="{{props.id or 'autocomplete'}}_{{source_index}}_{{option_index}}"
role="option"
href="#"
class="dropdown-item ui-menu-item-wrapper text-truncate"
t-att-class="{ 'ui-state-active': isActiveSourceOption([source_index, option_index]) }"
t-att-aria-selected="isActiveSourceOption([source_index, option_index]) ? 'true' : 'false'"
>
<t t-if="source.optionTemplate">
<t t-call="{{ source.optionTemplate }}" />
</t>
<t t-else="">
<t t-esc="option.label" />
</t>
</a>
</li>
</t>
</t>
</t>
</ul>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,102 @@
/** @odoo-module **/
/**
* Browser
*
* This file exports an object containing common browser API. It may not look
* incredibly useful, but it is very convenient when one needs to test code using
* these methods. With this indirection, it is possible to patch the browser
* object for a test.
*/
let sessionStorage;
let localStorage;
try {
sessionStorage = window.sessionStorage;
localStorage = window.localStorage;
// Safari crashes in Private Browsing
localStorage.setItem("__localStorage__", "true");
localStorage.removeItem("__localStorage__");
} catch (_e) {
localStorage = makeRAMLocalStorage();
sessionStorage = makeRAMLocalStorage();
}
export const browser = {
addEventListener: window.addEventListener.bind(window),
removeEventListener: window.removeEventListener.bind(window),
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
performance: window.performance,
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
console: window.console,
history: window.history,
navigator,
Notification: window.Notification,
open: window.open.bind(window),
SharedWorker: window.SharedWorker,
Worker: window.Worker,
XMLHttpRequest: window.XMLHttpRequest,
localStorage,
sessionStorage,
fetch: window.fetch.bind(window),
innerHeight: window.innerHeight,
innerWidth: window.innerWidth,
ontouchstart: window.ontouchstart,
};
Object.defineProperty(browser, "location", {
set(val) {
window.location = val;
},
get() {
return window.location;
},
configurable: true,
});
Object.defineProperty(browser, "innerHeight", {
get: () => window.innerHeight,
configurable: true,
});
Object.defineProperty(browser, "innerWidth", {
get: () => window.innerWidth,
configurable: true,
});
// -----------------------------------------------------------------------------
// memory localStorage
// -----------------------------------------------------------------------------
/**
* @returns {typeof window["localStorage"]}
*/
export function makeRAMLocalStorage() {
let store = {};
return {
setItem(key, value) {
const newValue = String(value);
store[key] = newValue;
window.dispatchEvent(new StorageEvent("storage", { key, newValue }));
},
getItem(key) {
return store[key];
},
clear() {
store = {};
},
removeItem(key) {
delete store[key];
window.dispatchEvent(new StorageEvent("storage", { key, newValue: null }));
},
get length() {
return Object.keys(store).length;
},
key() {
return "";
},
};
}

View file

@ -0,0 +1,61 @@
/** @odoo-module **/
import { registry } from "../registry";
/**
* Service to make use of document.cookie
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
* As recommended, storage should not be done by the cookie
* but with localStorage/sessionStorage
*/
const COOKIE_TTL = 24 * 60 * 60 * 365;
function parseCookieString(str) {
const cookie = {};
const parts = str.split("; ");
for (const part of parts) {
const [key, value] = part.split("=");
cookie[key] = value || "";
}
return cookie;
}
function cookieToString(key, value, ttl = COOKIE_TTL) {
let fullCookie = [];
if (value !== undefined) {
fullCookie.push(`${key}=${value}`);
}
fullCookie = fullCookie.concat(["path=/", `max-age=${ttl}`]);
return fullCookie.join(";");
}
function makeCookieService() {
function getCurrent() {
return parseCookieString(document.cookie);
}
let cookie = getCurrent();
function setCookie(key, value, ttl) {
// TODO When this will be used from website pages, recover the
// optional cookie mechanism.
document.cookie = cookieToString(key, value, ttl);
cookie = getCurrent();
}
return {
get current() {
return cookie;
},
setCookie,
deleteCookie(key) {
setCookie(key, "kill", 0);
},
};
}
export const cookieService = {
start() {
return makeCookieService();
},
};
registry.category("services").add("cookie", cookieService);

View file

@ -0,0 +1,60 @@
/** @odoo-module **/
import { browser } from "./browser";
// -----------------------------------------------------------------------------
// Feature detection
// -----------------------------------------------------------------------------
/**
* true if the browser is based on Chromium (Google Chrome, Opera, Edge)
*
* @returns {boolean}
*/
export function isBrowserChrome() {
return browser.navigator.userAgent.includes("Chrome");
}
/**
* true if the browser is Firefox
*
* @returns {boolean}
*/
export function isBrowserFirefox() {
return browser.navigator.userAgent.includes("Firefox");
}
export function isAndroid() {
return /Android/i.test(browser.navigator.userAgent);
}
export function isIOS() {
return (
/(iPad|iPhone|iPod)/i.test(browser.navigator.userAgent) ||
(browser.navigator.platform === "MacIntel" && maxTouchPoints() > 1)
);
}
export function isOtherMobileOS() {
return /(webOS|BlackBerry|Windows Phone)/i.test(browser.navigator.userAgent);
}
export function isMacOS() {
return Boolean(browser.navigator.userAgent.match(/Mac/i));
}
export function isMobileOS() {
return isAndroid() || isIOS() || isOtherMobileOS();
}
export function isIosApp() {
return /OdooMobile \(iOS\)/i.test(browser.navigator.userAgent);
}
export function hasTouch() {
return browser.ontouchstart !== undefined;
}
export function maxTouchPoints() {
return browser.navigator.maxTouchPoints || 1;
}

View file

@ -0,0 +1,219 @@
/** @odoo-module **/
import { registry } from "../registry";
import { shallowEqual } from "../utils/objects";
import { objectToUrlEncodedString } from "../utils/urls";
import { browser } from "./browser";
/**
* Casts the given string to a number if possible.
*
* @param {string} value
* @returns {string|number}
*/
function cast(value) {
return !value || isNaN(value) ? value : Number(value);
}
/**
* @typedef {{ [key: string]: string }} Query
* @typedef {{ [key: string]: any }} Route
*/
function parseString(str) {
const parts = str.split("&");
const result = {};
for (const part of parts) {
const [key, value] = part.split("=");
const decoded = decodeURIComponent(value || "");
result[key] = cast(decoded);
}
return result;
}
/**
* For each push request (replaceState or pushState), filterout keys that have been locked before
* overrides locked keys that are explicitly re-locked or unlocked
* registers keys in "hash" in "lockedKeys" according to the "lock" Boolean
*
* @param {Set<string>} lockedKeys A set containing all keys that were locked
* @param {Query} hash An Object representing the pushed url hash
* @param {Query} currentHash The current hash compare against
* @param {Object} [options={}] Whether to lock all hash keys in "hash" to prevent them from being changed afterwards
* @param {Boolean} [options.lock] Whether to lock all hash keys in "hash" to prevent them from being changed afterwards
* @return {Query} The resulting "hash" where previous locking has been applied
*/
function applyLocking(lockedKeys, hash, currentHash, options = {}) {
const newHash = {};
for (const key in hash) {
if ("lock" in options) {
options.lock ? lockedKeys.add(key) : lockedKeys.delete(key);
} else if (lockedKeys.has(key)) {
// forbid implicit override of key
continue;
}
newHash[key] = hash[key];
}
for (const key in currentHash) {
if (lockedKeys.has(key) && !(key in newHash)) {
newHash[key] = currentHash[key];
}
}
return newHash;
}
function computeNewRoute(hash, replace, currentRoute) {
if (!replace) {
hash = Object.assign({}, currentRoute.hash, hash);
}
hash = sanitizeHash(hash);
if (!shallowEqual(currentRoute.hash, hash)) {
return Object.assign({}, currentRoute, { hash });
}
return false;
}
function sanitizeHash(hash) {
return Object.fromEntries(
Object.entries(hash)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, cast(v)])
);
}
/**
* @param {string} hash
* @returns {any}
*/
export function parseHash(hash) {
return hash && hash !== "#" ? parseString(hash.slice(1)) : {};
}
/**
* @param {string} search
* @returns {any}
*/
export function parseSearchQuery(search) {
return search ? parseString(search.slice(1)) : {};
}
/**
* @param {{ [key: string]: any }} route
* @returns
*/
export function routeToUrl(route) {
const search = objectToUrlEncodedString(route.search);
const hash = objectToUrlEncodedString(route.hash);
return route.pathname + (search ? "?" + search : "") + (hash ? "#" + hash : "");
}
async function redirect(env, url, wait = false) {
if (wait) {
await new Promise((resolve) => {
const waitForServer = (delay) => {
browser.setTimeout(async () => {
env.services
.rpc("/web/webclient/version_info", {})
.then(resolve)
.catch(() => waitForServer(250));
}, delay);
};
waitForServer(1000);
});
}
browser.location.assign(url);
}
function getRoute(urlObj) {
const { pathname, search, hash } = urlObj;
const searchQuery = parseSearchQuery(search);
const hashQuery = parseHash(hash);
return { pathname, search: searchQuery, hash: hashQuery };
}
function makeRouter(env) {
const bus = env.bus;
const lockedKeys = new Set();
let current = getRoute(browser.location);
let pushTimeout;
browser.addEventListener("hashchange", (ev) => {
browser.clearTimeout(pushTimeout);
const loc = new URL(ev.newURL);
current = getRoute(loc);
bus.trigger("ROUTE_CHANGE");
});
browser.addEventListener("pageshow", (ev) => {
// To avoid rendering inconsistencies, we need to reload when loading from a `bfcache'.
if (ev.persisted) {
browser.clearTimeout(pushTimeout);
bus.trigger("ROUTE_CHANGE");
}
});
/**
* @param {string} mode
* @returns {(hash: string, options: any) => any}
*/
function makeDebouncedPush(mode) {
let allPushArgs = [];
function doPush() {
// Aggregates push/replace state arguments
const replace = allPushArgs.some(([, options]) => options && options.replace);
const newHash = allPushArgs.reduce((finalHash, [hash, options]) => {
hash = applyLocking(lockedKeys, hash, current.hash, options);
if (finalHash) {
hash = applyLocking(lockedKeys, hash, finalHash, options);
}
return Object.assign(finalHash || {}, hash);
}, null);
// Calculates new route based on aggregated hash and options
const newRoute = computeNewRoute(newHash, replace, current);
if (!newRoute) {
return;
}
// If the route changed: pushes or replaces browser state
const url = browser.location.origin + routeToUrl(newRoute);
if (mode === "push") {
browser.history.pushState({}, "", url);
} else {
browser.history.replaceState({}, "", url);
}
current = getRoute(browser.location);
}
return function pushOrReplaceState(hash, options) {
allPushArgs.push([hash, options]);
browser.clearTimeout(pushTimeout);
pushTimeout = browser.setTimeout(() => {
doPush();
pushTimeout = null;
allPushArgs = [];
});
};
}
return {
get current() {
return current;
},
pushState: makeDebouncedPush("push"),
replaceState: makeDebouncedPush("replace"),
redirect: (url, wait) => redirect(env, url, wait),
cancelPushes: () => browser.clearTimeout(pushTimeout),
};
}
export const routerService = {
start(env) {
return makeRouter(env);
},
};
export function objectToQuery(obj) {
const query = {};
Object.entries(obj).forEach(([k, v]) => {
query[k] = v ? String(v) : v;
});
return query;
}
registry.category("services").add("router", routerService);

View file

@ -0,0 +1,38 @@
/** @odoo-module **/
import { registry } from "../registry";
export const titleService = {
start() {
const titleParts = {};
function getParts() {
return Object.assign({}, titleParts);
}
function setParts(parts) {
for (const key in parts) {
const val = parts[key];
if (!val) {
delete titleParts[key];
} else {
titleParts[key] = val;
}
}
document.title = Object.values(titleParts).join(" - ");
}
return {
/**
* @returns {string}
*/
get current() {
return document.title;
},
getParts,
setParts,
};
},
};
registry.category("services").add("title", titleService);

View file

@ -0,0 +1,96 @@
/** @odoo-module **/
import { useHotkey } from "../hotkeys/hotkey_hook";
import { Component, useRef } from "@odoo/owl";
/**
* Custom checkbox
*
* <CheckBox
* value="boolean"
* disabled="boolean"
* onChange="_onValueChange"
* >
* Change the label text
* </CheckBox>
*
* @extends Component
*/
export class CheckBox extends Component {
setup() {
this.id = `checkbox-comp-${CheckBox.nextId++}`;
this.rootRef = useRef("root");
// Make it toggleable through the Enter hotkey
// when the focus is inside the root element
useHotkey(
"Enter",
({ area }) => {
const oldValue = area.querySelector("input").checked;
this.props.onChange(!oldValue);
},
{ area: () => this.rootRef.el, bypassEditableProtection: true }
);
}
onClick(ev) {
if (ev.composedPath().find((el) => ["INPUT", "LABEL"].includes(el.tagName))) {
// The onChange will handle these cases.
ev.stopPropagation();
return;
}
// Reproduce the click event behavior as if it comes from the input element.
const input = this.rootRef.el.querySelector("input");
input.focus();
if (!this.props.disabled) {
ev.stopPropagation();
input.checked = !input.checked;
this.props.onChange(input.checked);
}
}
onChange(ev) {
if (!this.props.disabled) {
this.props.onChange(ev.target.checked);
}
}
}
CheckBox.template = "web.CheckBox";
CheckBox.nextId = 1;
CheckBox.defaultProps = {
onChange: () => {},
};
CheckBox.props = {
id: {
type: true,
optional: true,
},
disabled: {
type: Boolean,
optional: true,
},
value: {
type: Boolean,
optional: true,
},
slots: {
type: Object,
optional: true,
},
onChange: {
type: Function,
optional: true,
},
className: {
type: String,
optional: true,
},
name: {
type: String,
optional: true,
},
};

View file

@ -0,0 +1,3 @@
.o-checkbox {
width: fit-content;
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.CheckBox" owl="1">
<div class="o-checkbox form-check" t-attf-class="{{ props.slots ? 'form-check' : '' }}" t-att-class="props.className" t-on-click="onClick" t-ref="root">
<input
t-att-id="props.id or id"
type="checkbox"
class="form-check-input"
t-att-disabled="props.disabled"
t-att-checked="props.value"
t-att-name="props.name"
t-on-change="onChange"
/>
<label t-att-for="props.id or id" class="form-check-label">
<t t-slot="default"/>
</label>
</div>
</t>
</templates>

View file

@ -0,0 +1,64 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { Component, useRef, useState, useExternalListener } from "@odoo/owl";
export class ColorList extends Component {
setup() {
this.colorlistRef = useRef("colorlist");
this.state = useState({ isExpanded: this.props.isExpanded });
useExternalListener(window, "click", this.onOutsideClick);
}
get colors() {
return this.constructor.COLORS;
}
onColorSelected(id) {
this.props.onColorSelected(id);
if (!this.props.forceExpanded) {
this.state.isExpanded = false;
}
}
onOutsideClick(ev) {
if (this.colorlistRef.el.contains(ev.target) || this.props.forceExpanded) {
return;
}
this.state.isExpanded = false;
}
onToggle(ev) {
if (this.props.canToggle) {
ev.preventDefault();
ev.stopPropagation();
this.state.isExpanded = !this.state.isExpanded;
this.colorlistRef.el.firstElementChild.focus();
}
}
}
ColorList.COLORS = [
_lt("No color"),
_lt("Red"),
_lt("Orange"),
_lt("Yellow"),
_lt("Light blue"),
_lt("Dark purple"),
_lt("Salmon pink"),
_lt("Medium blue"),
_lt("Dark blue"),
_lt("Fuchsia"),
_lt("Green"),
_lt("Purple"),
];
ColorList.template = "web.ColorList";
ColorList.defaultProps = {
forceExpanded: false,
isExpanded: false,
};
ColorList.props = {
canToggle: { type: Boolean, optional: true },
colors: Array,
forceExpanded: { type: Boolean, optional: true },
isExpanded: { type: Boolean, optional: true },
onColorSelected: Function,
selectedColor: { type: Number, optional: true },
};

View file

@ -0,0 +1,35 @@
.o_colorlist {
button {
border: 1px solid $white;
box-shadow: 0 0 0 1px $gray-500;
width: 22px;
height: 17px;
}
.o_colorlist_selected {
box-shadow: 0 0 0 2px $o-brand-odoo !important;
}
}
// Set all the colors but the "no-color" one
@for $size from 2 through length($o-colors) {
.o_colorlist_item_color_#{$size - 1} {
background-color: nth($o-colors, $size);
}
}
// Set the "no-color", a red bar on white background
.o_colorlist_item_color_0 {
position: relative;
&::before {
content: "";
@include o-position-absolute(-2px, $left: 10px);
display: block;
width: 1px;
height: 19px;
transform: rotate(45deg);
background-color: red;
}
&::after {
background-color: white;
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColorList" owl="1">
<div class="o_colorlist d-flex flex-wrap align-items-center mw-100 gap-2" aria-atomic="true" t-ref="colorlist">
<t t-if="!props.forceExpanded and !state.isExpanded">
<button t-on-click="onToggle" role="menuitem" t-att-title="colors[props.selectedColor]" t-att-data-color="props.selectedColor" t-att-aria-label="colors[props.selectedColor]" t-attf-class="btn p-0 o_colorlist_toggler o_colorlist_item_color_{{ props.selectedColor }}"/>
</t>
<t t-else="" t-foreach="props.colors" t-as="colorId" t-key="colorId">
<button t-on-click.prevent.stop="() => this.onColorSelected(colorId)" role="menuitem" t-att-title="colors[colorId]" t-att-data-color="colorId" t-att-aria-label="colors[colorId]" t-attf-class="btn p-0 o_colorlist_item_color_{{ colorId }} {{ colorId === props.selectedColor ? 'o_colorlist_selected' : '' }}"/>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,12 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
const commandCategoryRegistry = registry.category("command_categories");
commandCategoryRegistry
.add("app", {}, { sequence: 10 })
.add("smart_action", {}, { sequence: 15 })
.add("actions", {}, { sequence: 30 })
.add("navbar", {}, { sequence: 40 })
.add("default", {}, { sequence: 100 })
.add("debug", {}, { sequence: 110 });

View file

@ -0,0 +1,25 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { useEffect } from "@odoo/owl";
/**
* @typedef {import("./command_service").CommandOptions} CommandOptions
*/
/**
* This hook will subscribe/unsubscribe the given subscription
* when the caller component will mount/unmount.
*
* @param {string} name
* @param {()=>(void | CommandPaletteConfig)} action
* @param {CommandOptions} [options]
*/
export function useCommand(name, action, options = {}) {
const commandService = useService("command");
useEffect(
() => commandService.add(name, action, options),
() => []
);
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DefaultFooter" owl="1">
<span>
<span class="fw-bolder text-primary">TIP</span> — search for
<t t-foreach="elements" t-as="element" t-key="element.namespace">
<t t-if="!(element_first || element_last)">, </t>
<t t-if="element_last and !element_first"> and </t>
<span class="o_namespace btn-link text-primary cursor-pointer" t-on-click="() => this.onClick(element.namespace)">
<span t-out="element.namespace" class="fw-bolder text-primary"/><t t-out="element.name"/>
</span>
</t>
</span>
</t>
<t t-name="web.DefaultCommandItem" owl="1">
<div class="o_command_default d-flex align-items-center justify-content-between px-4 py-2 cursor-pointer">
<t t-slot="name"/>
<t t-slot="focusMessage"/>
</div>
</t>
<t t-name="web.HotkeyCommandItem" owl="1">
<div class="o_command_hotkey d-flex align-items-center justify-content-between px-4 py-2 cursor-pointer">
<t t-slot="name"/>
<span>
<t t-foreach="getKeysToPress(props)" t-as="key" t-key="key_index">
<kbd t-out="key" class="d-inline-block px-3 py-1" />
<span t-if="!key_last"> + </span>
</t>
</span>
</div>
</t>
</templates>

View file

@ -0,0 +1,372 @@
/** @odoo-module **/
import { Dialog } from "@web/core/dialog/dialog";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { _lt } from "@web/core/l10n/translation";
import { KeepLast, Race } from "@web/core/utils/concurrency";
import { useAutofocus, useService } from "@web/core/utils/hooks";
import { scrollTo } from "@web/core/utils/scrolling";
import { fuzzyLookup } from "@web/core/utils/search";
import { debounce } from "@web/core/utils/timing";
import { isMacOS, isMobileOS } from "@web/core/browser/feature_detection";
import { escapeRegExp } from "@web/core/utils/strings";
import {
Component,
onWillStart,
onWillDestroy,
useRef,
useState,
markRaw,
useExternalListener,
} from "@odoo/owl";
const DEFAULT_PLACEHOLDER = _lt("Search...");
const DEFAULT_EMPTY_MESSAGE = _lt("No result found");
const FUZZY_NAMESPACES = ["default"];
/**
* @typedef {import("./command_service").Command} Command
*/
/**
* @typedef {Command & {
* Component?: Component;
* props?: object;
* }} CommandItem
*/
/**
* @typedef {{
* namespace?: string;
* provide: ()=>CommandItem[];
* }} Provider
*/
/**
* @typedef {{
* categories: string[];
* debounceDelay: number;
* emptyMessage: string;
* placeholder: string;
* }} NamespaceConfig
*/
/**
* @typedef {{
* configByNamespace?: {[namespace: string]: NamespaceConfig};
* FooterComponent?: Component;
* providers: Provider[];
* searchValue?: string;
* }} CommandPaletteConfig
*/
/**
* Util used to filter commands that are within category.
* Note: for the default category, also get all commands having invalid category.
*
* @param {string} categoryName the category key
* @param {string[]} categories
* @returns an array filter predicate
*/
function commandsWithinCategory(categoryName, categories) {
return (cmd) => {
const inCurrentCategory = categoryName === cmd.category;
const fallbackCategory = categoryName === "default" && !categories.includes(cmd.category);
return inCurrentCategory || fallbackCategory;
};
}
export function splitCommandName(name, searchValue) {
if (name) {
const splitName = name.split(new RegExp(`(${escapeRegExp(searchValue)})`, "ig"));
return searchValue.length && splitName.length > 1 ? splitName : [name];
}
return [];
}
export class DefaultCommandItem extends Component {}
DefaultCommandItem.template = "web.DefaultCommandItem";
export class CommandPalette extends Component {
setup() {
if (this.props.bus) {
const setConfig = ({ detail }) => this.setCommandPaletteConfig(detail);
this.props.bus.addEventListener(`SET-CONFIG`, setConfig);
onWillDestroy(() => this.props.bus.removeEventListener(`SET-CONFIG`, setConfig));
}
this.keyId = 1;
this.race = new Race();
this.keepLast = new KeepLast();
this._sessionId = CommandPalette.lastSessionId++;
this.DefaultCommandItem = DefaultCommandItem;
this.activeElement = useService("ui").activeElement;
this.inputRef = useAutofocus();
useHotkey("Enter", () => this.executeSelectedCommand(), { bypassEditableProtection: true });
useHotkey("Control+Enter", () => this.executeSelectedCommand(true), {
bypassEditableProtection: true,
});
useHotkey("ArrowUp", () => this.selectCommandAndScrollTo("PREV"), {
bypassEditableProtection: true,
allowRepeat: true,
});
useHotkey("ArrowDown", () => this.selectCommandAndScrollTo("NEXT"), {
bypassEditableProtection: true,
allowRepeat: true,
});
useExternalListener(window, "mousedown", this.onWindowMouseDown);
/**
* @type {{ commands: CommandItem[],
* emptyMessage: string,
* FooterComponent: Component,
* namespace: string,
* placeholder: string,
* searchValue: string,
* selectedCommand: CommandItem }}
*/
this.state = useState({});
this.root = useRef("root");
this.listboxRef = useRef("listbox");
onWillStart(() => this.setCommandPaletteConfig(this.props.config));
}
get commandsByCategory() {
const categories = [];
for (const category of this.categoryKeys) {
const commands = this.state.commands.filter(
commandsWithinCategory(category, this.categoryKeys)
);
if (commands.length) {
categories.push({
commands,
keyId: category,
});
}
}
return categories;
}
/**
* Apply the new config to the command pallet
* @param {CommandPaletteConfig} config
*/
async setCommandPaletteConfig(config) {
this.configByNamespace = config.configByNamespace || {};
this.state.FooterComponent = config.FooterComponent;
this.providersByNamespace = { default: [] };
for (const provider of config.providers) {
const namespace = provider.namespace || "default";
if (namespace in this.providersByNamespace) {
this.providersByNamespace[namespace].push(provider);
} else {
this.providersByNamespace[namespace] = [provider];
}
}
const { namespace, searchValue } = this.processSearchValue(config.searchValue || "");
this.switchNamespace(namespace);
this.state.searchValue = searchValue;
await this.race.add(this.search(searchValue));
}
/**
* Modifies the commands to be displayed according to the namespace and the options.
* Selects the first command in the new list.
* @param {string} namespace
* @param {object} options
*/
async setCommands(namespace, options = {}) {
this.categoryKeys = ["default"];
const proms = this.providersByNamespace[namespace].map((provider) => {
const { provide } = provider;
const result = provide(this.env, options);
return result;
});
let commands = (await this.keepLast.add(Promise.all(proms))).flat();
const namespaceConfig = this.configByNamespace[namespace] || {};
if (options.searchValue && FUZZY_NAMESPACES.includes(namespace)) {
commands = fuzzyLookup(options.searchValue, commands, (c) => c.name);
} else {
// we have to sort the commands by category to avoid navigation issues with the arrows
if (namespaceConfig.categories) {
let commandsSorted = [];
this.categoryKeys = namespaceConfig.categories;
if (!this.categoryKeys.includes("default")) {
this.categoryKeys.push("default");
}
for (const category of this.categoryKeys) {
commandsSorted = commandsSorted.concat(
commands.filter(commandsWithinCategory(category, this.categoryKeys))
);
}
commands = commandsSorted;
}
}
this.state.commands = markRaw(
commands.slice(0, 100).map((command) => ({
...command,
keyId: this.keyId++,
splitName: splitCommandName(command.name, options.searchValue),
}))
);
this.selectCommand(this.state.commands.length ? 0 : -1);
this.mouseSelectionActive = false;
this.state.emptyMessage = (
namespaceConfig.emptyMessage || DEFAULT_EMPTY_MESSAGE
).toString();
}
selectCommand(index) {
if (index === -1 || index >= this.state.commands.length) {
this.state.selectedCommand = null;
return;
}
this.state.selectedCommand = markRaw(this.state.commands[index]);
}
selectCommandAndScrollTo(type) {
// In case the mouse is on the palette command, it avoids the selection
// of a command caused by a scroll.
this.mouseSelectionActive = false;
const index = this.state.commands.indexOf(this.state.selectedCommand);
if (index === -1) {
return;
}
let nextIndex;
if (type === "NEXT") {
nextIndex = index < this.state.commands.length - 1 ? index + 1 : 0;
} else if (type === "PREV") {
nextIndex = index > 0 ? index - 1 : this.state.commands.length - 1;
}
this.selectCommand(nextIndex);
const command = this.listboxRef.el.querySelector(`#o_command_${nextIndex}`);
scrollTo(command, { scrollable: this.listboxRef.el });
}
onCommandClicked(event, index) {
event.preventDefault(); // Prevent redirect for commands with href
this.selectCommand(index);
const ctrlKey = isMacOS() ? event.metaKey : event.ctrlKey;
this.executeSelectedCommand(ctrlKey);
}
/**
* Execute the action related to the order.
* If this action returns a config, then we will use it in the command palette,
* otherwise we close the command palette.
* @param {CommandItem} command
*/
async executeCommand(command) {
const config = await command.action();
if (config) {
this.setCommandPaletteConfig(config);
} else {
this.props.close();
}
}
async executeSelectedCommand(ctrlKey) {
await this.searchValuePromise;
const selectedCommand = this.state.selectedCommand;
if (selectedCommand) {
if (!ctrlKey) {
this.executeCommand(selectedCommand);
} else if (selectedCommand.href) {
window.open(selectedCommand.href, "_blank");
}
}
}
onCommandMouseEnter(index) {
if (this.mouseSelectionActive) {
this.selectCommand(index);
} else {
this.mouseSelectionActive = true;
}
}
async search(searchValue) {
await this.setCommands(this.state.namespace, {
searchValue,
activeElement: this.activeElement,
sessionId: this._sessionId,
});
if (this.inputRef.el) {
this.inputRef.el.focus();
}
}
debounceSearch(value) {
const { namespace, searchValue } = this.processSearchValue(value);
if (namespace !== "default" && this.state.namespace !== namespace) {
this.switchNamespace(namespace);
}
this.state.searchValue = searchValue;
this.searchValuePromise = this.lastDebounceSearch(searchValue).catch(() => {
this.searchValuePromise = null;
});
}
onSearchInput(ev) {
this.debounceSearch(ev.target.value);
}
onKeyDown(ev) {
if (ev.key.toLowerCase() === "backspace" && !ev.target.value.length && !ev.repeat) {
this.switchNamespace("default");
this.state.searchValue = "";
this.searchValuePromise = this.lastDebounceSearch("").catch(() => {
this.searchValuePromise = null;
});
}
}
/**
* Close the palette on outside click.
*/
onWindowMouseDown(ev) {
if (!this.root.el.contains(ev.target)) {
this.props.close();
}
}
switchNamespace(namespace) {
if (this.lastDebounceSearch) {
this.lastDebounceSearch.cancel();
}
const namespaceConfig = this.configByNamespace[namespace] || {};
this.lastDebounceSearch = debounce(
(value) => this.search(value),
namespaceConfig.debounceDelay || 0
);
this.state.namespace = namespace;
this.state.placeholder = namespaceConfig.placeholder || DEFAULT_PLACEHOLDER.toString();
}
processSearchValue(searchValue) {
let namespace = "default";
if (searchValue.length && this.providersByNamespace[searchValue[0]]) {
namespace = searchValue[0];
searchValue = searchValue.slice(1);
}
return { namespace, searchValue };
}
get isMacOS() {
return isMacOS();
}
get isMobileOS() {
return isMobileOS();
}
}
CommandPalette.lastSessionId = 0;
CommandPalette.template = "web.CommandPalette";
CommandPalette.components = { Dialog };

View file

@ -0,0 +1,55 @@
.o_command_palette {
$-app-icon-size: 1.8rem;
> .modal-body {
padding: 0;
}
&_listbox {
max-height: 50vh;
.o_command {
&.focused {
background: rgba($o-brand-primary, 0.25);
}
&_hotkey {
align-items: center;
justify-content: space-between;
background-color: inherit;
padding: 0.5rem 1.3em;
display: flex;
> icon {
position: relative;
top: 0.4em;
}
}
a {
text-decoration: none;
color: inherit;
}
}
}
.o_favorite {
color: $o-main-favorite-color;
}
.o_app_icon {
height: $-app-icon-size;
width: $-app-icon-size;
}
.o_command{
cursor: pointer;
.text-ellipsis {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.o_command_focus {
white-space: nowrap;
opacity: 0.9;
}
}
}

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CommandPalette" owl="1">
<Dialog header="false" footer="false" size="'md'" contentClass="'o_command_palette mt-5'">
<div t-ref="root">
<div class="o_command_palette_search input-group mb-2 px-4 py-3 border-bottom">
<span t-if="state.namespace !== 'default'" class="o_namespace d-flex align-items-center me-1" t-out="state.namespace"/>
<input class="form-control border-0 p-0" type="text" data-allow-hotkeys="true" t-att-value="state.searchValue" t-ref="autofocus" t-att-placeholder="state.placeholder" t-on-input="onSearchInput" t-on-keydown="onKeyDown"
role="combobox"
t-attf-aria-activedescendant="o_command_{{state.commands.length ? state.commands.indexOf(state.selectedCommand) : 'empty'}}"
aria-expanded="true"
aria-autocomplete="list"
aria-haspopup="listbox"
/>
<div class="input-group-text border-0 bg-transparent">
<i t-att-title="state.placeholder" role="img" t-att-aria-label="state.placeholder" class="oi oi-search"/>
</div>
</div>
<div t-ref="listbox" role="listbox" class="o_command_palette_listbox position-relative overflow-auto">
<div t-if="!state.commands.length" id="o_command_empty" role="option" aria-selected="true" class="o_command_palette_listbox_empty px-4 py-3 fst-italic" t-out="state.emptyMessage"/>
<t t-if="!isFuzzySearch" t-foreach="commandsByCategory" t-as="category" t-key="category.keyId">
<div class="o_command_category px-0">
<t t-foreach="category.commands" t-as="command" t-key="command.keyId">
<t t-set="commandIndex" t-value="state.commands.indexOf(command)"/>
<div t-attf-id="o_command_{{commandIndex}}" class="o_command"
role="option"
t-att-aria-selected="state.selectedCommand === command ? 'true' : 'false'"
t-att-class="{ focused: state.selectedCommand === command }"
t-on-click="(event) => this.onCommandClicked(event, commandIndex)"
t-on-mouseenter="() => this.onCommandMouseEnter(commandIndex)"
t-on-close="() => this.props.closeMe()">
<a t-att-href="command.href">
<t t-component="command.Component || DefaultCommandItem" name="command.name" searchValue="state.searchValue" t-props="command.props" executeCommand="() => this.executeCommand(command)">
<t t-set-slot="name">
<span class="o_command_name text-ellipsis" t-att-title="command.name">
<t t-foreach="command.splitName" t-as="name" t-key="name_index">
<b t-if="name_index % 2" t-out="name" class="text-primary"/>
<t t-else="" t-out="name"/>
</t>
</span>
</t>
<t t-set-slot="focusMessage">
<small t-if="!isMobileOS and command.href and state.selectedCommand === command" class="o_command_focus text-muted"><kbd><t t-if="isMacOS">CMD</t><t t-else="">CTRL</t></kbd>+<kbd></kbd><span class="ms-1">new tab</span></small>
</t>
</t>
</a>
</div>
</t>
</div>
<hr class="my-2 mx-0" t-if="!category_last" />
</t>
</div>
<div t-if="state.FooterComponent" class="o_command_palette_footer mt-2 px-4 py-2 border-top bg-100 text-muted">
<t t-component="state.FooterComponent" switchNamespace="(namespace) => this.debounceSearch(namespace.concat(this.state.searchValue))"/>
</div>
</div>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,227 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { CommandPalette } from "./command_palette";
import { Component, EventBus } from "@odoo/owl";
/**
* @typedef {import("./command_palette").CommandPaletteConfig} CommandPaletteConfig
*/
/**
* @typedef {{
* name: string;
* action: ()=>(void | CommandPaletteConfig);
* category?: string;
* href?: string;
* }} Command
*/
/**
* @typedef {import("../hotkeys/hotkey_service").HotkeyOptions & {
* category?: string;
* isAvailable: ()=>(boolean);
* }} CommandOptions
*/
/**
* @typedef {Command & CommandOptions & {
* removeHotkey?: ()=>void;
* }} CommandRegistration
*/
const commandCategoryRegistry = registry.category("command_categories");
const commandProviderRegistry = registry.category("command_provider");
const commandSetupRegistry = registry.category("command_setup");
class DefaultFooter extends Component {
setup() {
this.elements = commandSetupRegistry
.getEntries()
.map((el) => ({ namespace: el[0], name: el[1].name }))
.filter((el) => el.name);
}
onClick(namespace) {
this.props.switchNamespace(namespace);
}
}
DefaultFooter.template = "web.DefaultFooter";
export const commandService = {
dependencies: ["dialog", "hotkey", "ui"],
start(env, { dialog, hotkey: hotkeyService, ui }) {
/** @type {Map<CommandRegistration>} */
const registeredCommands = new Map();
let nextToken = 0;
let isPaletteOpened = false;
const bus = new EventBus();
hotkeyService.add("control+k", openMainPalette, {
bypassEditableProtection: true,
global: true,
});
/**
* @param {CommandPaletteConfig} config command palette config merged with default config
* @param {Function} onClose called when the command palette is closed
* @returns the actual command palette config if the command palette is already open
*/
function openMainPalette(config = {}, onClose) {
const configByNamespace = {};
for (const provider of commandProviderRegistry.getAll()) {
const namespace = provider.namespace || "default";
if (!configByNamespace[namespace]) {
configByNamespace[namespace] = {
categories: [],
};
}
}
for (const [category, el] of commandCategoryRegistry.getEntries()) {
const namespace = el.namespace || "default";
if (namespace in configByNamespace) {
configByNamespace[namespace].categories.push(category);
}
}
for (const [
namespace,
{ emptyMessage, debounceDelay, placeholder },
] of commandSetupRegistry.getEntries()) {
if (namespace in configByNamespace) {
if (emptyMessage) {
configByNamespace[namespace].emptyMessage = emptyMessage;
}
if (debounceDelay !== undefined) {
configByNamespace[namespace].debounceDelay = debounceDelay;
}
if (placeholder) {
configByNamespace[namespace].placeholder = placeholder;
}
}
}
config = Object.assign(
{
configByNamespace,
FooterComponent: DefaultFooter,
providers: commandProviderRegistry.getAll(),
},
config
);
return openPalette(config, onClose);
}
/**
* @param {CommandPaletteConfig} config
* @param {Function} onClose called when the command palette is closed
*/
function openPalette(config, onClose) {
if (isPaletteOpened) {
bus.trigger("SET-CONFIG", config);
return;
}
// Open Command Palette dialog
isPaletteOpened = true;
dialog.add(
CommandPalette,
{
config,
bus,
},
{
onClose: () => {
isPaletteOpened = false;
if (onClose) {
onClose();
}
},
}
);
}
/**
* @param {Command} command
* @param {CommandOptions} options
* @returns {number} token
*/
function registerCommand(command, options) {
if (!command.name || !command.action || typeof command.action !== "function") {
throw new Error("A Command must have a name and an action function.");
}
const registration = Object.assign({}, command, options);
if (registration.hotkey) {
const action = async () => {
const commandService = env.services.command;
const config = await command.action();
if (!isPaletteOpened && config) {
commandService.openPalette(config);
}
};
registration.removeHotkey = hotkeyService.add(registration.hotkey, action, {
activeElement: registration.activeElement,
global: registration.global,
validate: registration.isAvailable,
});
}
const token = nextToken++;
registeredCommands.set(token, registration);
if (!options.activeElement) {
// Due to the way elements are mounted in the DOM by Owl (bottom-to-top),
// we need to wait the next micro task tick to set the context activate
// element of the subscription.
Promise.resolve().then(() => {
registration.activeElement = ui.activeElement;
});
}
return token;
}
/**
* Unsubscribes the token corresponding subscription.
*
* @param {number} token
*/
function unregisterCommand(token) {
const cmd = registeredCommands.get(token);
if (cmd && cmd.removeHotkey) {
cmd.removeHotkey();
}
registeredCommands.delete(token);
}
return {
/**
* @param {string} name
* @param {()=>(void | CommandPaletteConfig)} action
* @param {CommandOptions} [options]
* @returns {() => void}
*/
add(name, action, options = {}) {
const token = registerCommand({ name, action }, options);
return () => {
unregisterCommand(token);
};
},
/**
* @param {HTMLElement} activeElement
* @returns {Command[]}
*/
getCommands(activeElement) {
return [...registeredCommands.values()].filter(
(command) => command.activeElement === activeElement || command.global
);
},
openMainPalette,
openPalette,
};
},
};
registry.category("services").add("command", commandService);

View file

@ -0,0 +1,101 @@
/** @odoo-module **/
import { isMacOS } from "@web/core/browser/feature_detection";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { capitalize } from "@web/core/utils/strings";
import { getVisibleElements } from "@web/core/utils/ui";
import { DefaultCommandItem } from "./command_palette";
import { Component } from "@odoo/owl";
const commandSetupRegistry = registry.category("command_setup");
commandSetupRegistry.add("default", {
emptyMessage: _lt("No command found"),
placeholder: _lt("Search for a command..."),
});
export class HotkeyCommandItem extends Component {
setup() {
useHotkey(this.props.hotkey, this.props.executeCommand);
}
getKeysToPress(command) {
const { hotkey } = command;
let result = hotkey.split("+");
if (isMacOS()) {
result = result
.map((x) => x.replace("control", "command"))
.map((x) => x.replace("alt", "control"));
}
return result.map((key) => key.toUpperCase());
}
}
HotkeyCommandItem.template = "web.HotkeyCommandItem";
const commandCategoryRegistry = registry.category("command_categories");
const commandProviderRegistry = registry.category("command_provider");
commandProviderRegistry.add("command", {
provide: (env, options = {}) => {
const commands = env.services.command
.getCommands(options.activeElement)
.map((cmd) => {
cmd.category = commandCategoryRegistry.contains(cmd.category)
? cmd.category
: "default";
return cmd;
})
.filter((command) => command.isAvailable === undefined || command.isAvailable());
return commands.map((command) => ({
Component: command.hotkey ? HotkeyCommandItem : DefaultCommandItem,
action: command.action,
category: command.category,
name: command.name,
props: {
hotkey: command.hotkey,
hotkeyOptions: command.hotkeyOptions,
},
}));
},
});
commandProviderRegistry.add("data-hotkeys", {
provide: (env, options = {}) => {
const commands = [];
const overlayModifier = registry.category("services").get("hotkey").overlayModifier;
// Also retrieve all hotkeyables elements
for (const el of getVisibleElements(
options.activeElement,
"[data-hotkey]:not(:disabled)"
)) {
const closest = el.closest("[data-command-category]");
const category = closest ? closest.dataset.commandCategory : "default";
const description =
el.title ||
el.dataset.bsOriginalTitle || // LEGACY: bootstrap moves title to data-bs-original-title
el.dataset.tooltip ||
el.placeholder ||
(el.innerText &&
`${el.innerText.slice(0, 50)}${el.innerText.length > 50 ? "..." : ""}`) ||
env._t("no description provided");
commands.push({
Component: HotkeyCommandItem,
action: () => {
// AAB: not sure it is enough, we might need to trigger all events that occur when you actually click
el.focus();
el.click();
},
category,
name: capitalize(description.trim().toLowerCase()),
props: {
hotkey: `${overlayModifier}+${el.dataset.hotkey}`,
},
});
}
return commands;
},
});

View file

@ -0,0 +1,89 @@
/** @odoo-module */
import { Dialog } from "../dialog/dialog";
import { _lt } from "../l10n/translation";
import { useChildRef } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export class ConfirmationDialog extends Component {
setup() {
this.env.dialogData.close = () => this._cancel();
this.modalRef = useChildRef();
this.isConfirmedOrCancelled = false; // ensures we do not confirm and/or cancel twice
}
async _cancel() {
if (this.isConfirmedOrCancelled) {
return;
}
this.isConfirmedOrCancelled = true;
this.disableButtons();
if (this.props.cancel) {
try {
await this.props.cancel();
} catch (e) {
this.props.close();
throw e;
}
}
this.props.close();
}
async _confirm() {
if (this.isConfirmedOrCancelled) {
return;
}
this.isConfirmedOrCancelled = true;
this.disableButtons();
if (this.props.confirm) {
try {
await this.props.confirm();
} catch (e) {
this.props.close();
throw e;
}
}
this.props.close();
}
disableButtons() {
if (!this.modalRef.el) {
return; // safety belt for stable versions
}
for (const button of [...this.modalRef.el.querySelectorAll(".modal-footer button")]) {
button.disabled = true;
}
}
}
ConfirmationDialog.template = "web.ConfirmationDialog";
ConfirmationDialog.components = { Dialog };
ConfirmationDialog.props = {
close: Function,
title: {
validate: (m) => {
return (
typeof m === "string" || (typeof m === "object" && typeof m.toString === "function")
);
},
optional: true,
},
body: String,
confirm: { type: Function, optional: true },
confirmLabel: { type: String, optional: true },
cancel: { type: Function, optional: true },
cancelLabel: { type: String, optional: true },
};
ConfirmationDialog.defaultProps = {
confirmLabel: _lt("Ok"),
cancelLabel: _lt("Cancel"),
title: _lt("Confirmation"),
};
export class AlertDialog extends ConfirmationDialog {}
AlertDialog.template = "web.AlertDialog";
AlertDialog.props = {
...ConfirmationDialog.props,
contentClass: { type: String, optional: true },
};
AlertDialog.defaultProps = {
...ConfirmationDialog.defaultProps,
title: _lt("Alert"),
};

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.ConfirmationDialog" owl="1">
<Dialog size="'md'" title="props.title" modalRef="modalRef">
<p t-out="props.body" style="white-space: pre-wrap;"/>
<t t-set-slot="footer" owl="1">
<button class="btn btn-primary" t-on-click="_confirm" t-esc="props.confirmLabel"/>
<button t-if="props.cancel" class="btn btn-secondary" t-on-click="_cancel" t-esc="props.cancelLabel"/>
</t>
</Dialog>
</t>
<t t-name="web.AlertDialog" owl="1">
<Dialog size="'sm'" title="props.title" contentClass="props.contentClass">
<p t-out="props.body" style="white-space: pre-wrap;"/>
<t t-set-slot="footer" owl="1">
<button class="btn btn-primary" t-on-click="_confirm" t-esc="props.confirmLabel"/>
<button t-if="props.cancel" class="btn btn-secondary" t-on-click="_cancel" t-esc="props.cancelLabel"/>
</t>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,29 @@
/** @odoo-module **/
import { evaluateExpr } from "./py_js/py";
/**
* @typedef {{[key: string]: any}} Context
* @typedef {Context | string | undefined} ContextDescription
*/
/**
* Create an evaluated context from an arbitrary list of context representations.
* The evaluated context in construction is used along the way to evaluate further parts.
*
* @param {ContextDescription[]} contexts
* @param {Context} [initialEvaluationContext] optional evaluation context to start from.
* @returns {Context}
*/
export function makeContext(contexts, initialEvaluationContext) {
const evaluationContext = Object.assign({}, initialEvaluationContext);
const context = {};
for (let ctx of contexts) {
if (ctx !== "") {
ctx = typeof ctx === "string" ? evaluateExpr(ctx, evaluationContext) : ctx;
Object.assign(context, ctx);
Object.assign(evaluationContext, context); // is this behavior really wanted ?
}
}
return context;
}

View file

@ -0,0 +1,455 @@
/** @odoo-module **/
import {
Component,
onMounted,
onWillUnmount,
onWillUpdateProps,
useExternalListener,
useRef,
useState,
} from "@odoo/owl";
import { isMobileOS } from "@web/core/browser/feature_detection";
import {
formatDate,
formatDateTime,
luxonToMoment,
luxonToMomentFormat,
momentToLuxon,
parseDate,
parseDateTime,
} from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { useAutofocus } from "@web/core/utils/hooks";
import { pick } from "../utils/objects";
const { DateTime } = luxon;
let datePickerId = 0;
/**
* @param {unknown} value1
* @param {unknown} value2
*/
function areEqual(value1, value2) {
if (value1 && value2) {
// Only compare date values
return Number(value1) === Number(value2);
} else {
return value1 === value2;
}
}
/**
* @template {(...args: any[]) => any} F
* @template T
* @param {F} fn
* @param {T} defaultValue
* @returns {[any, null] | [null, Error]}
*/
function wrapError(fn, defaultValue) {
return (...args) => {
const result = [defaultValue, null];
try {
result[0] = fn(...args);
} catch (_err) {
result[1] = _err;
}
return result;
};
}
/**
* Date picker
*
* This component exposes the API of the tempusdominus datepicker library.
* As such, its template is a simple input that will open the TD datepicker
* when clicked on. The component will also synchronize any user-input value
* with the library widget and vice-versa.
*
* Note that all props given to this component will be passed as arguments to
* instantiate the picker widget. Also any luxon date is automatically
* stringified since tempusdominus only works with moment objects.
* @extends Component
*/
export class DatePicker extends Component {
setup() {
this.rootRef = useRef("root");
this.inputRef = useRef("input");
this.hiddenInputRef = useRef("hiddenInput");
this.state = useState({ warning: false });
// Picker variables
this.datePickerId = `o_datepicker_${datePickerId++}`;
/**
* Manually keep track of the "open" state to write the date in the
* static format just before bootstrap parses it.
*/
this.isPickerOpen = false;
this.isPickerChanged = false;
/** @type {DateTime | null} */
this.pickerDate = this.props.date;
this.ignorePickerEvents = true;
this.initFormat();
this.setDateAndFormat(this.props);
useAutofocus();
useExternalListener(window, "click", this.onWindowClick, { capture: true });
useExternalListener(window, "scroll", this.onWindowScroll, { capture: true });
onMounted(this.onMounted);
onWillUpdateProps(this.onWillUpdateProps);
onWillUnmount(this.onWillUnmount);
}
onMounted() {
this.bootstrapDateTimePicker(this.props);
this.updateInput(this.date);
this.addPickerListener("show", () => {
this.isPickerOpen = true;
this.inputRef.el.select();
});
this.addPickerListener("change", ({ date }) => {
if (date && this.isPickerOpen) {
const { locale } = this.getOptions();
this.isPickerChanged = true;
this.pickerDate = momentToLuxon(date).setLocale(locale);
this.updateInput(this.pickerDate);
}
});
this.addPickerListener("hide", () => {
this.isPickerOpen = false;
this.onDateChange();
this.isPickerChanged = false;
});
this.addPickerListener("error", () => false);
this.ignorePickerEvents = false;
}
onWillUpdateProps(nextProps) {
this.ignorePickerEvents = true;
this.setDateAndFormat(nextProps);
const shouldUpdate =
this.props.revId !== nextProps.revId ||
Object.entries(pick(nextProps, "date", "format")).some(
([key, val]) => !areEqual(this.props[key], val)
);
if (shouldUpdate && !areEqual(this.pickerDate, nextProps.date)) {
if (nextProps.date) {
this.bootstrapDateTimePicker("date", luxonToMoment(nextProps.date));
} else {
this.bootstrapDateTimePicker("clear");
}
}
if (shouldUpdate) {
this.updateInput(this.date);
}
if (this.isPickerOpen) {
this.bootstrapDateTimePicker("hide");
this.bootstrapDateTimePicker("show");
}
this.ignorePickerEvents = false;
}
onWillUnmount() {
window.$(this.rootRef.el).off(); // Removes all jQuery events
this.bootstrapDateTimePicker("destroy");
}
//---------------------------------------------------------------------
// Protected
//---------------------------------------------------------------------
/**
*
* @param {string} type
* @param {(ev: JQuery.Event) => any} listener
*/
addPickerListener(type, listener) {
return window.$(this.rootRef.el).on(`${type}.datetimepicker`, (ev) => {
if (this.ignorePickerEvents) {
return false;
}
return listener(ev);
});
}
getOptions() {
return {
format: this.format,
locale: this.props.locale || (this.date && this.date.locale),
};
}
/**
* Initialises formatting and parsing parameters
*/
initFormat() {
this.defaultFormat = localization.dateFormat;
this.formatValue = wrapError(formatDate, "");
this.parseValue = wrapError(parseDate, false);
this.isLocal = false;
}
/**
* Sets the current date value. If a locale is provided, the given date
* will first be set in that locale.
* @param {Object} params
* @param {DateTime} params.date
* @param {string} [params.locale]
* @param {string} [params.format]
*/
setDateAndFormat({ date, locale, format }) {
this.date = date && locale ? date.setLocale(locale) : date;
// Fallback to default localization format in `@web/core/l10n/dates.js`.
this.format = format || this.defaultFormat;
this.staticFormat = "yyyy-MM-dd";
}
/**
* Updates the input element with the current formatted date value.
*
* @param {DateTime} value
*/
updateInput(value) {
value = value || false;
const options = this.getOptions();
const [formattedValue, error] = this.formatValue(value, options);
if (!error) {
this.inputRef.el.value = formattedValue;
[this.hiddenInputRef.el.value] = this.formatValue(value, {
...options,
format: this.staticFormat,
});
this.props.onUpdateInput(formattedValue);
}
return formattedValue;
}
//---------------------------------------------------------------------
// Bootstrap datepicker
//---------------------------------------------------------------------
/**
* Handles bootstrap datetimepicker calls.
* @param {string | Object} commandOrParams
*/
bootstrapDateTimePicker(commandOrParams, ...commandArgs) {
if (typeof commandOrParams === "object") {
const params = {
...commandOrParams,
date: this.date || null,
format: luxonToMomentFormat(this.staticFormat),
locale: commandOrParams.locale || (this.date && this.date.locale),
};
for (const prop in params) {
if (params[prop] instanceof DateTime) {
params[prop] = luxonToMoment(params[prop]);
}
}
commandOrParams = params;
}
window.$(this.rootRef.el).datetimepicker(commandOrParams, ...commandArgs);
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* Called either when the input value has changed or when the boostrap
* datepicker is closed. The onDateTimeChanged prop is only called if the
* date value has changed.
*/
onDateChange() {
const [value, error] = this.isPickerChanged
? [this.pickerDate, null]
: this.parseValue(this.inputRef.el.value, this.getOptions());
this.state.warning = value && value > DateTime.local();
if (error || areEqual(this.date, value)) {
// Force current value
this.updateInput(this.date);
} else {
this.props.onDateTimeChanged(value);
}
if (this.pickerDate) {
this.inputRef.el.select();
}
}
onInputChange() {
this.onDateChange();
}
/**
* @param {InputEvent} ev
*/
onInputInput(ev) {
this.isPickerChanged = false;
return this.props.onInput(ev);
}
/**
* @param {KeyboardEvent} ev
*/
onInputKeydown(ev) {
switch (ev.key) {
case "Escape": {
if (this.isPickerOpen) {
ev.preventDefault();
ev.stopPropagation();
this.bootstrapDateTimePicker("hide");
this.inputRef.el.select();
}
break;
}
case "Tab": {
this.bootstrapDateTimePicker("hide");
break;
}
case "Enter": {
this.onInputChange();
break;
}
}
}
/**
* @param {PointerEvent} ev
*/
onWindowClick({ target }) {
if (target.closest(".bootstrap-datetimepicker-widget")) {
return;
} else if (this.rootRef.el.contains(target)) {
this.bootstrapDateTimePicker("toggle");
} else {
this.bootstrapDateTimePicker("hide");
}
}
/**
* @param {Event} ev
*/
onWindowScroll(ev) {
if (!isMobileOS() && ev.target !== this.inputRef.el) {
this.bootstrapDateTimePicker("hide");
}
}
}
DatePicker.defaultProps = {
calendarWeeks: true,
icons: {
clear: "fa fa-delete",
close: "fa fa-check primary",
date: "fa fa-calendar",
down: "fa fa-chevron-down",
next: "fa fa-chevron-right",
previous: "fa fa-chevron-left",
time: "fa fa-clock-o",
today: "fa fa-calendar-check-o",
up: "fa fa-chevron-up",
},
inputId: "",
maxDate: DateTime.fromObject({ year: 9999, month: 12, day: 31 }),
minDate: DateTime.fromObject({ year: 1000 }),
useCurrent: false,
widgetParent: "body",
onInput: () => {},
onUpdateInput: () => {},
revId: 0,
};
DatePicker.props = {
// Components props
onDateTimeChanged: Function,
date: { type: [DateTime, { value: false }], optional: true },
warn_future: { type: Boolean, optional: true },
// Bootstrap datepicker options
buttons: {
type: Object,
shape: {
showClear: Boolean,
showClose: Boolean,
showToday: Boolean,
},
optional: true,
},
calendarWeeks: { type: Boolean, optional: true },
format: { type: String, optional: true },
icons: {
type: Object,
shape: {
clear: String,
close: String,
date: String,
down: String,
next: String,
previous: String,
time: String,
today: String,
up: String,
},
optional: true,
},
inputId: { type: String, optional: true },
keyBinds: { validate: (kb) => typeof kb === "object" || kb === null, optional: true },
locale: { type: String, optional: true },
maxDate: { type: DateTime, optional: true },
minDate: { type: DateTime, optional: true },
readonly: { type: Boolean, optional: true },
useCurrent: { type: Boolean, optional: true },
widgetParent: { type: String, optional: true },
daysOfWeekDisabled: { type: Array, optional: true },
placeholder: { type: String, optional: true },
onInput: { type: Function, optional: true },
onUpdateInput: { type: Function, optional: true },
revId: { type: Number, optional: true },
};
DatePicker.template = "web.DatePicker";
/**
* Date/time picker
*
* Similar to the DatePicker component, adding the handling of more specific
* time values: hour-minute-second.
*
* Once again, refer to the tempusdominus documentation for implementation
* details.
* @extends DatePicker
*/
export class DateTimePicker extends DatePicker {
/**
* @override
*/
initFormat() {
this.defaultFormat = localization.dateTimeFormat;
this.formatValue = wrapError(formatDateTime, "");
this.parseValue = wrapError(parseDateTime, false);
this.isLocal = true;
}
/**
* @override
*/
setDateAndFormat(nextProps) {
super.setDateAndFormat(nextProps);
this.staticFormat += ` ${/h/.test(this.format) ? "hh" : "HH"}:mm:ss`;
}
}
DateTimePicker.defaultProps = {
...DatePicker.defaultProps,
buttons: {
showClear: false,
showClose: true,
showToday: false,
},
};

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DatePicker" owl="1">
<div class="o_datepicker" aria-atomic="true" t-att-data-target-input="'#' + datePickerId" t-ref="root">
<input type="text"
t-ref="input"
t-att-id="props.inputId"
class="o_datepicker_input o_input datetimepicker-input"
t-att-name="props.name"
t-att-placeholder="props.placeholder"
t-att-readonly="props.readonly"
autocomplete="off"
t-on-change="onInputChange"
t-on-input="onInputInput"
t-on-keydown="onInputKeydown"
/>
<input type="hidden" t-ref="hiddenInput" t-att-id="datePickerId" />
<span
t-if="props.warn_future and state.warning"
class="fa fa-exclamation-triangle text-danger o_tz_warning o_datepicker_warning"
data-tooltip="This date is on the future. Make sure it is what you expected."
/>
<span class="o_datepicker_button" />
</div>
</t>
</templates>

View file

@ -0,0 +1,89 @@
/** @odoo-module **/
import { registry } from "../registry";
import { memoize } from "../utils/functions";
import { useEffect, useEnv, useSubEnv } from "@odoo/owl";
const debugRegistry = registry.category("debug");
const getAccessRights = memoize(async function getAccessRights(orm) {
const rightsToCheck = {
"ir.ui.view": "write",
"ir.rule": "read",
"ir.model.access": "read",
};
const proms = Object.entries(rightsToCheck).map(([model, operation]) => {
return orm.call(model, "check_access_rights", [], {
operation,
raise_exception: false,
});
});
const [canEditView, canSeeRecordRules, canSeeModelAccess] = await Promise.all(proms);
const accessRights = { canEditView, canSeeRecordRules, canSeeModelAccess };
return accessRights;
});
class DebugContext {
constructor(env, defaultCategories) {
this.orm = env.services.orm;
this.categories = new Map(defaultCategories.map((cat) => [cat, [{}]]));
}
activateCategory(category, context) {
const contexts = this.categories.get(category) || new Set();
contexts.add(context);
this.categories.set(category, contexts);
return () => {
contexts.delete(context);
if (contexts.size === 0) {
this.categories.delete(category);
}
};
}
async getItems(env) {
const accessRights = await getAccessRights(this.orm);
return [...this.categories.entries()]
.flatMap(([category, contexts]) => {
return debugRegistry
.category(category)
.getAll()
.map((factory) => factory(Object.assign({ env, accessRights }, ...contexts)));
})
.filter(Boolean)
.sort((x, y) => {
const xSeq = x.sequence || 1000;
const ySeq = y.sequence || 1000;
return xSeq - ySeq;
});
}
}
const debugContextSymbol = Symbol("debugContext");
export function createDebugContext(env, { categories = [] } = {}) {
return { [debugContextSymbol]: new DebugContext(env, categories) };
}
export function useOwnDebugContext({ categories = [] } = {}) {
useSubEnv(createDebugContext(useEnv(), { categories }));
}
export function useEnvDebugContext() {
const debugContext = useEnv()[debugContextSymbol];
if (!debugContext) {
throw new Error("There is no debug context available in the current environment.");
}
return debugContext;
}
export function useDebugCategory(category, context = {}) {
const env = useEnv();
if (env.debug) {
const debugContext = useEnvDebugContext();
useEffect(
() => debugContext.activateCategory(category, context),
() => []
);
}
}

View file

@ -0,0 +1,61 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { DebugMenuBasic } from "@web/core/debug/debug_menu_basic";
import { useCommand } from "@web/core/commands/command_hook";
import { useService } from "@web/core/utils/hooks";
import { useEnvDebugContext } from "./debug_context";
export class DebugMenu extends DebugMenuBasic {
setup() {
super.setup();
const debugContext = useEnvDebugContext();
this.command = useService("command");
useCommand(
this.env._t("Debug tools..."),
async () => {
const items = await debugContext.getItems(this.env);
let index = 0;
const defaultCategories = items
.filter((item) => item.type === "separator")
.map(() => (index += 1));
const provider = {
async provide() {
const categories = [...defaultCategories];
let category = categories.shift();
const result = [];
items.forEach((item) => {
if (item.type === "item") {
result.push({
name: item.description.toString(),
action: item.callback,
category,
});
} else if (item.type === "separator") {
category = categories.shift();
}
});
return result;
},
};
const configByNamespace = {
default: {
categories: defaultCategories,
emptyMessage: this.env._t("No debug command found"),
placeholder: this.env._t("Choose a debug command..."),
},
};
const commandPaletteConfig = {
configByNamespace,
providers: [provider],
};
return commandPaletteConfig;
},
{
category: "debug",
}
);
}
}
DebugMenu.components = { Dropdown, DropdownItem };

View file

@ -0,0 +1,6 @@
.o_dialog {
.o_debug_manager .dropdown-toggle {
padding: 0 4px;
margin: 2px 10px 2px 0;
}
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DebugMenu" owl="1">
<Dropdown class="'o_debug_manager'"
beforeOpen="getElements"
position="'bottom-end'"
togglerClass="`o-dropdown--narrow ${env.inDialog?'btn btn-link':''}`"
>
<t t-set-slot="toggler">
<i class="fa fa-bug" role="img" aria-label="Open developer tools"/>
</t>
<t t-foreach="elements" t-as="element" t-key="element_index">
<DropdownItem
t-if="element.type == 'item'"
onSelected="element.callback"
href="element.href"
>
<t t-esc="element.description"/>
</DropdownItem>
<div t-if="element.type == 'separator'" role="separator" class="dropdown-divider"/>
<t t-if="element.type == 'component'" t-component="element.Component" t-props="element.props"/>
</t>
</Dropdown>
</t>
</templates>

View file

@ -0,0 +1,22 @@
/** @odoo-module **/
import { useEnvDebugContext } from "./debug_context";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { Component } from "@odoo/owl";
export class DebugMenuBasic extends Component {
setup() {
const debugContext = useEnvDebugContext();
// Needs to be bound to this for use in template
this.getElements = async () => {
this.elements = await debugContext.getItems(this.env);
};
}
}
DebugMenuBasic.components = {
Dropdown,
DropdownItem,
};
DebugMenuBasic.template = "web.DebugMenu";

View file

@ -0,0 +1,80 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { routeToUrl } from "@web/core/browser/router_service";
import { registry } from "@web/core/registry";
function activateAssetsDebugging({ env }) {
return {
type: "item",
description: env._t("Activate Assets Debugging"),
callback: () => {
browser.location.search = "?debug=assets";
},
sequence: 410,
};
}
function activateTestsAssetsDebugging({ env }) {
return {
type: "item",
description: env._t("Activate Tests Assets Debugging"),
callback: () => {
browser.location.search = "?debug=assets,tests";
},
sequence: 420,
};
}
export function regenerateAssets({ env }) {
return {
type: "item",
description: env._t("Regenerate Assets Bundles"),
callback: async () => {
await env.services.orm.call(
"ir.attachment",
"regenerate_assets_bundles",
);
browser.location.reload();
},
sequence: 430,
};
}
export function becomeSuperuser({ env }) {
const becomeSuperuserURL = browser.location.origin + "/web/become";
if (!env.services.user.isAdmin) {
return false;
}
return {
type: "item",
description: env._t("Become Superuser"),
href: becomeSuperuserURL,
callback: () => {
browser.open(becomeSuperuserURL, "_self");
},
sequence: 440,
};
}
function leaveDebugMode({ env }) {
return {
type: "item",
description: env._t("Leave the Developer Tools"),
callback: () => {
const route = env.services.router.current;
route.search.debug = "";
browser.location.href = browser.location.origin + routeToUrl(route);
},
sequence: 450,
};
}
registry
.category("debug")
.category("default")
.add("activateAssetsDebugging", activateAssetsDebugging)
.add("regenerateAssets", regenerateAssets)
.add("becomeSuperuser", becomeSuperuser)
.add("leaveDebugMode", leaveDebugMode)
.add("activateTestsAssetsDebugging", activateTestsAssetsDebugging);

View file

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DebugMenu.SetDefaultDialog" owl="1">
<Dialog title="title">
<table style="width: 100%">
<tr>
<td>
<label for="formview_default_fields"
class="oe_label oe_align_right">
Default:
</label>
</td>
<td class="oe_form_required">
<select id="formview_default_fields" class="o_input" t-model="state.fieldToSet">
<option value=""/>
<option t-foreach="defaultFields" t-as="field" t-att-value="field.name" t-key="field.name">
<t t-esc="field.string"/> = <t t-esc="field.displayed"/>
</option>
</select>
</td>
</tr>
<tr t-if="conditions.length">
<td>
<label for="formview_default_conditions"
class="oe_label oe_align_right">
Condition:
</label>
</td>
<td>
<select id="formview_default_conditions" class="o_input" t-model="state.condition">
<option value=""/>
<option t-foreach="conditions" t-as="cond" t-att-value="cond.name + '=' + cond.value" t-key="cond.name">
<t t-esc="cond.string"/>=<t t-esc="cond.displayed"/>
</option>
</select>
</td>
</tr>
<tr>
<td colspan="2">
<input type="radio" id="formview_default_self"
value="self" name="scope" t-model="state.scope"/>
<label for="formview_default_self" class="oe_label"
style="display: inline;">
Only you
</label>
<br/>
<input type="radio" id="formview_default_all"
value="all" name="scope" t-model="state.scope"/>
<label for="formview_default_all" class="oe_label"
style="display: inline;">
All users
</label>
</td>
</tr>
</table>
<t t-set-slot="footer">
<button class="btn btn-secondary" t-on-click="props.close">Close</button>
<button class="btn btn-secondary" t-on-click="saveDefault">Save default</button>
</t>
</Dialog>
</t>
<t t-name="web.DebugMenu.GetMetadataDialog" owl="1">
<Dialog title="title">
<table class="table table-sm table-striped">
<tr>
<th>ID:</th>
<td><t t-esc="state.id"/></td>
</tr>
<tr>
<th>XML ID:</th>
<td>
<t t-if='state.xmlids.length > 1'>
<t t-foreach="state.xmlids" t-as="imd" t-key="imd['xmlid']">
<div
t-att-class='"p-0 " + (imd["xmlid"] === state.xmlid ? "fw-bolder " : "") + (imd["noupdate"] === true ? "fst-italic " : "")'
t-esc="imd['xmlid']" />
</t>
</t>
<t t-elif="state.xmlid" t-esc="state.xmlid"/>
<t t-else="">
/ <a t-on-click="onClickCreateXmlid"> (create)</a>
</t>
</td>
</tr>
<tr>
<th>No Update:</th>
<td>
<t t-esc="state.noupdate"/>
<t t-if="state.xmlid">
<a t-on-click="toggleNoupdate"> (change)</a>
</t>
</td>
</tr>
<tr>
<th>Creation User:</th>
<td><t t-esc="state.creator"/></td>
</tr>
<tr>
<th>Creation Date:</th>
<td><t t-esc="state.createDate"/></td>
</tr>
<tr>
<th>Latest Modification by:</th>
<td><t t-esc="state.lastModifiedBy"/></td>
</tr>
<tr>
<th>Latest Modification Date:</th>
<td><t t-esc="state.writeDate"/></td>
</tr>
</table>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,61 @@
/** @odoo-module */
import { registry } from "../registry";
import { browser } from "../browser/browser";
import { routeToUrl } from "../browser/router_service";
const commandProviderRegistry = registry.category("command_provider");
commandProviderRegistry.add("debug", {
provide: (env, options) => {
const result = [];
if (env.debug) {
if (!env.debug.includes("assets")) {
result.push({
action() {
browser.location.search = "?debug=assets";
},
category: "debug",
name: env._t("Activate debug mode (with assets)"),
});
}
result.push({
action() {
const route = env.services.router.current;
route.search.debug = "";
browser.location.href = browser.location.origin + routeToUrl(route);
},
category: "debug",
name: env._t("Deactivate debug mode"),
});
result.push({
action() {
const runTestsURL = browser.location.origin + "/web/tests?debug=assets";
browser.open(runTestsURL);
},
category: "debug",
name: env._t("Run JS Tests"),
});
result.push({
action() {
const runTestsURL = browser.location.origin + "/web/tests/mobile?debug=assets";
browser.open(runTestsURL);
},
category: "debug",
name: env._t("Run JS Mobile Tests"),
});
} else {
const debugKey = "debug";
if (options.searchValue.toLowerCase() === debugKey) {
result.push({
action() {
browser.location.search = "?debug=assets";
},
category: "debug",
name: `${env._t("Activate debug mode (with assets)")} (${debugKey})`,
});
}
}
return result;
},
});

View file

@ -0,0 +1,13 @@
/** @odoo-module **/
export function editModelDebug(env, title, model, id) {
return env.services.action.doAction({
res_model: model,
res_id: id,
name: title,
type: "ir.actions.act_window",
views: [[false, "form"]],
view_mode: "form",
target: "new",
});
}

View file

@ -0,0 +1,32 @@
/** @odoo-module **/
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useBus, useService } from "@web/core/utils/hooks";
import { Component } from "@odoo/owl";
export class ProfilingItem extends Component {
setup() {
this.profiling = useService("profiling");
useBus(this.props.bus, "UPDATE", this.render);
}
changeParam(param, ev) {
this.profiling.setParam(param, ev.target.value);
}
toggleParam(param) {
const value = this.profiling.state.params.execution_context_qweb;
this.profiling.setParam(param, !value);
}
openProfiles() {
if (this.env.services.action) {
// using doAction in the backend to preserve breadcrumbs and stuff
this.env.services.action.doAction("base.action_menu_ir_profile");
} else {
// No action service means we are in the frontend.
window.location = "/web/#action=base.action_menu_ir_profile";
}
}
}
ProfilingItem.components = { DropdownItem };
ProfilingItem.template = "web.DebugMenu.ProfilingItem";

View file

@ -0,0 +1,18 @@
.o_debug_manager {
.dropdown-menu .o_debug_profiling_item_wrapper.dropdown-item.focus {
background: inherit;
}
.o_debug_profiling_item {
cursor: auto;
}
.form-switch {
cursor: pointer;
}
}
.o_debug_recording {
animation: 2s flash infinite;
}

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DebugMenu.ProfilingItem" owl="1">
<DropdownItem class="o_debug_profiling_item_wrapper">
<div class="o_debug_profiling_item d-flex justify-content-between">
<div class="align-self-center">
<div t-if="profiling.state.isEnabled" class="alert alert-info py-2 me-3">Recording...</div>
<span class="o_profiling_switch">
<span class="form-check form-switch" t-on-click.stop.prevent="() => this.profiling.toggleProfiling()">
<input type="checkbox" class="form-check-input" id="enable_profiling" t-att-checked="profiling.state.isEnabled"/>
<label class="form-check-label">Enable profiling</label>
</span>
</span>
<t t-if="profiling.state.isEnabled">
<span class="o_profiling_switch form-check form-switch mt-2" t-on-click.stop.prevent="() => this.profiling.toggleCollector('sql')">
<input type="checkbox" class="form-check-input" id="profile_sql"
t-att-checked="profiling.isCollectorEnabled('sql')"/>
<label class="form-check-label" for="profile_sql">Record sql</label>
</span>
<span class="o_profiling_switch form-check form-switch mt-2" t-on-click.stop.prevent="() => this.profiling.toggleCollector('traces_async')">
<input type="checkbox" class="form-check-input" id="profile_traces_async"
t-att-checked="profiling.isCollectorEnabled('traces_async')"/>
<label class="form-check-label" for="profile_traces_async">Record traces</label>
</span>
<div t-if="profiling.isCollectorEnabled('traces_async')" class="input-group input-group-sm mt-2" t-on-click.stop.prevent="">
<div class="input-group-text">Interval</div>
<select class="profile_param form-select" t-on-change="(ev) => this.changeParam('traces_async_interval', ev)">
<t t-set="interval" t-value="profiling.state.params.traces_async_interval"/>
<option value="">Default</option>
<option value="0.001" t-att-selected="interval === '0.001'">0.001</option>
<option value="0.01" t-att-selected="interval === '0.01'">0.01</option>
<option value="0.1" t-att-selected="interval === '0.1'">0.1</option>
<option value="1" t-att-selected="interval === '1'">1</option>
</select>
</div>
<div class="input-group input-group-sm mt-2">
<div class="input-group-text">Entry Count</div>
<input type="number" class="form-control" t-on-click.stop.prevent="" t-on-change="(ev) => this.changeParam('entry_count_limit', ev)" t-att-value="profiling.state.params.entry_count_limit || '0'" placeholder="None"/>
</div>
<span t-if="profiling.isCollectorEnabled('sql') || profiling.isCollectorEnabled('traces_async')" class="o_profiling_switch form-check form-switch mt-2" t-on-click.stop.prevent="(ev) => this.toggleParam('execution_context_qweb', ev)">
<input type="checkbox" class="form-check-input" id="profile_execution_context_qweb"
t-att-checked="!!profiling.state.params.execution_context_qweb"/>
<label class="form-check-label" for="profile_execution_context_qweb">Add qweb directive context</label>
</span>
<span class="o_profiling_switch form-check form-switch mt-2" t-on-click.stop.prevent="() => this.profiling.toggleCollector('qweb')">
<input type="checkbox" class="form-check-input" id="profile_qweb"
t-att-checked="profiling.isCollectorEnabled('qweb')"/>
<label class="form-check-label" for="profile_qweb">Record qweb</label>
</span>
</t>
</div>
<a href="#" t-on-click.prevent="openProfiles" t-attf-class="btn btn-light align-self-baseline {{profiling.state.isEnabled ? 'py-2' : ''}}">
<i class="o_open_profiling oi oi-launch" />
</a>
</div>
</DropdownItem>
</t>
</templates>

View file

@ -0,0 +1,336 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { loadBundle } from "@web/core/assets";
import { renderToString } from "@web/core/utils/render";
import { useDebounced } from "@web/core/utils/timing";
import { Component, useState, useRef, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
class MenuItem extends Component {}
MenuItem.template = "web.ProfilingQwebView.menuitem";
function processValue(value) {
const data = JSON.parse(value);
for (const line of data[0].results.data) {
line.xpath = line.xpath.replace(/([^\]])\//g, "$1[1]/").replace(/([^\]])$/g, "$1[1]");
}
return data;
}
/**
* This widget is intended to be used on Text fields. It will provide Ace Editor
* for display XML and Python profiling.
*/
export class ProfilingQwebView extends Component {
setup() {
super.setup();
this.orm = useService("orm");
this.ace = useRef("ace");
this.selector = useRef("selector");
this.value = processValue(this.props.value);
this.state = useState({
viewID: this.profile.data.length ? this.profile.data[0].view_id : 0,
view: null,
});
this.renderProfilingInformation = useDebounced(this.renderProfilingInformation, 100);
onWillStart(async () => {
await loadBundle({
jsLibs: [
"/web/static/lib/ace/ace.js",
[
"/web/static/lib/ace/mode-python.js",
"/web/static/lib/ace/mode-xml.js",
"/web/static/lib/ace/mode-qweb.js",
],
],
});
await this._fetchViewData();
this.state.view = this.viewObjects.find((view) => view.id === this.state.viewID);
});
onMounted(() => {
this._startAce(this.ace.el);
this._renderView();
});
onWillUnmount(() => {
if (this.aceEditor) {
this.aceEditor.destroy();
}
this._unmoutInfo();
});
}
/**
* Return JSON values to render the view
*
* @returns {archs, data: {template, xpath, directive, time, duration, query }[]}
*/
get profile() {
return this.value ? this.value[0].results : { archs: {}, data: [] };
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Return association of view key, view name, query number and total delay
*
* @private
* @returns {Promise<viewObjects>}
*/
async _fetchViewData() {
const viewIDs = Array.from(new Set(this.profile.data.map((line) => line.view_id)));
const viewObjects = await this.orm.call("ir.ui.view", "search_read", [], {
fields: ["id", "display_name", "key"],
domain: [["id", "in", viewIDs]],
});
for (const view of viewObjects) {
view.delay = 0;
view.query = 0;
const lines = this.profile.data.filter((l) => l.view_id === view.id);
const root = lines.find((l) => l.xpath === "");
if (root) {
view.delay += root.delay;
view.query += root.query;
} else {
view.delay = lines.map((l) => l.delay).reduce((a, b) => a + b);
view.query = lines.map((l) => l.query).reduce((a, b) => a + b);
}
view.delay = Math.ceil(view.delay * 10) / 10;
}
this.viewObjects = viewObjects;
}
/**
* Format delay to readable.
*
* @private
* @param {number} delay
* @returns {string}
*/
_formatDelay(delay) {
return delay ? _.str.sprintf("%.1f", Math.ceil(delay * 10) / 10) : ".";
}
/**
* Starts the ace library on the given DOM element. This initializes the
* ace editor in readonly mode.
*
* @private
* @param {Node} node - the DOM element the ace library must initialize on
*/
_startAce(node) {
this.aceEditor = window.ace.edit(node);
this.aceEditor.setOptions({
maxLines: Infinity,
showPrintMargin: false,
highlightActiveLine: false,
highlightGutterLine: true,
readOnly: true,
});
this.aceEditor.renderer.setOptions({
displayIndentGuides: true,
showGutter: true,
});
this.aceEditor.renderer.$cursorLayer.element.style.display = "none";
this.aceEditor.$blockScrolling = true;
this.aceSession = this.aceEditor.getSession();
this.aceSession.setOptions({
useWorker: false,
mode: "ace/mode/qweb",
tabSize: 2,
useSoftTabs: true,
});
// Ace render 3 times when change the value and 1 time per click.
this.aceEditor.renderer.on("afterRender", this.renderProfilingInformation.bind(this));
}
renderProfilingInformation() {
this._unmoutInfo();
const flat = {};
const arch = [{ xpath: "", children: [] }];
const rows = this.ace.el.querySelectorAll(".ace_gutter .ace_gutter-cell");
const elems = this.ace.el.querySelectorAll(
".ace_tag-open, .ace_end-tag-close, .ace_end-tag-open, .ace_qweb"
);
elems.forEach((node) => {
const parent = arch[arch.length - 1];
let xpath = parent.xpath;
if (node.classList.contains("ace_end-tag-close")) {
// Close tag.
let previous = node;
while ((previous = previous.previousElementSibling)) {
if (previous && previous.classList.contains("ace_tag-name")) {
break;
}
}
const tag = previous && previous.textContent;
if (parent.tag === tag) {
// can be different when scroll because ace does not display the previous lines.
arch.pop();
}
} else if (node.classList.contains("ace_end-tag-open")) {
// Auto close tag.
const tag = node.nextElementSibling && node.nextElementSibling.textContent;
if (parent.tag === tag) {
// can be different when scroll because ace does not display the previous lines.
arch.pop();
}
} else if (node.classList.contains("ace_qweb")) {
// QWeb attribute.
const directive = node.textContent;
parent.directive.push({
el: node,
directive: directive,
});
// Compute delay and query number.
let delay = 0;
let query = 0;
for (const line of this.profile.data) {
if (
line.view_id === this.state.viewID &&
line.xpath === xpath &&
line.directive.includes(directive)
) {
delay += line.delay;
query += line.query;
}
}
// Render delay and query number in span visible on hover.
if ((delay || query) && !node.querySelector(".o_info")) {
this._renderHover(delay, query, node);
}
} else if (node.classList.contains("ace_tag-open")) {
// Open tag.
const nodeTagName = node.nextElementSibling;
const aceLine = nodeTagName.parentNode;
const index = [].indexOf.call(aceLine.parentNode.children, aceLine);
const row = rows[index];
// Add a children to the arch and compute the xpath.
xpath += "/" + nodeTagName.textContent;
let i = 1;
while (flat[xpath + "[" + i + "]"]) {
i++;
}
xpath += "[" + i + "]";
flat[xpath] = {
xpath: xpath,
tag: nodeTagName.textContent,
children: [],
directive: [],
};
arch.push(flat[xpath]);
parent.children.push(flat[xpath]);
// Compute delay and query number.
const closed = !!row.querySelector(".ace_closed");
const delays = [];
const querys = [];
const groups = {};
let displayDetail = false;
for (const line of this.profile.data) {
if (
line.view_id === this.state.viewID &&
(closed ? line.xpath.startsWith(xpath) : line.xpath === xpath)
) {
delays.push(line.delay);
querys.push(line.query);
const directive = line.directive.split("=")[0];
if (!groups[directive]) {
groups[directive] = {
delays: [],
querys: [],
};
} else {
displayDetail = true;
}
groups[directive].delays.push(this._formatDelay(line.delay));
groups[directive].querys.push(line.query);
}
}
// Display delay and query number in front of the line.
if (delays.length && !row.querySelector(".o_info")) {
this._renderInfo(delays, querys, displayDetail, groups, row);
}
}
node.setAttribute("data-xpath", xpath);
});
}
/**
* Set the view ID and send atch to ACE.
*
* @private
*/
_renderView() {
const view = this.viewObjects.find((view) => view.id === this.state.viewID);
if (view) {
const arch = this.profile.archs[view.id] || "";
if (this.aceSession.getValue() !== arch) {
this.aceSession.setValue(arch);
}
} else {
this.aceSession.setValue("");
}
this.state.view = view;
}
_unmoutInfo() {
if (this.hover) {
if (this.ace.el.querySelector(".o_ace_hover")) {
this.ace.el.querySelector(".o_ace_hover").remove();
}
}
if (this.info) {
if (this.ace.el.querySelector(".o_ace_info")) {
this.ace.el.querySelector(".o_ace_info").remove();
}
}
}
_renderHover(delay, query, node) {
const xml = renderToString("web.ProfilingQwebView.hover", {
delay: this._formatDelay(delay),
query: query,
});
const div = new DOMParser().parseFromString(xml, "text/html").querySelector("div");
node.insertBefore(div, node.firstChild);
}
_renderInfo(delays, querys, displayDetail, groups, node) {
const xml = renderToString("web.ProfilingQwebView.info", {
delay: this._formatDelay(delays.reduce((a, b) => a + b, 0)),
query: querys.reduce((a, b) => a + b, 0) || ".",
displayDetail: displayDetail,
groups: groups,
});
const div = new DOMParser().parseFromString(xml, "text/html").querySelector("div");
node.insertBefore(div, node.firstChild);
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onSelectView(ev) {
this.state.viewID = +ev.currentTarget.dataset.id;
this._renderView();
}
}
ProfilingQwebView.template = "web.ProfilingQwebView";
ProfilingQwebView.components = { MenuItem };
registry.category("fields").add("profiling_qweb_view", ProfilingQwebView);

View file

@ -0,0 +1,129 @@
.o_form_view .o_ace_view_editor {
background: transparent;
}
.o_profiling_qweb_view {
user-select: none;
.o_select_view_profiling {
margin-bottom: 10px;
.dropdown-menu {
overflow: auto;
max-height: 240px;
}
a {
margin: 3px 0;
display: block;
.o_delay, .o_query {
font-size: 0.8em;
display: inline-block;
color: $body-color;
text-align: right;
width: 50px;
margin-right: 10px;
white-space: nowrap;
}
.o_key {
display: inline-block;
margin-left: 10px;
font-size: 0.8em;
}
}
}
.ace_editor {
overflow: visible;
.ace_qweb, .ace_tag-name {
cursor: default;
pointer-events: all;
position: relative;
.o_info {
display: none;
left: 8px;
top: 14px;
width: 100px;
.o_delay span, .o_query span {
text-align: left;
display: inline-block;
width: 40px;
}
}
&:hover .o_info {
display: block;
&:hover {
display: none;
}
}
}
.ace_gutter {
overflow: visible;
}
.ace_gutter-layer {
width: 134px !important;
overflow: visible;
}
.ace_gutter-cell .o_info {
display: block;
float: left;
font-size: 0.8em;
white-space: nowrap;
.o_more {
float: left;
position: relative;
span {
color: orange !important;
cursor: default;
margin-left: -12px;
}
.o_detail {
left: 30px;
top: -30px;
min-width: 120px;
display: none;
th {
text-align: center;
}
td {
min-width: 60px;
vertical-align: top;
text-align: left;
}
tr td:first-child {
padding-right: 10px;
white-space: nowrap;
}
tr th:last-child, tr td:last-child {
padding-left: 10px;
}
}
&:hover > .o_detail {
display: block;
&:hover {
display: none;
}
}
}
.o_delay, .o_query {
display: block;
float: left;
margin-right: 10px;
width: 30px;
}
}
.ace_line {
border-bottom: 1px #dddddd dotted;
}
.ace_scrollbar-h {
z-index: 3;
}
.o_detail {
position: absolute;
z-index: 1;
background: #ffedcb;
color: orange !important;
border: 1px orange solid;
padding: 6px;
white-space: normal;
text-align: right;
}
}
}

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ProfilingQwebView" owl="1">
<div class="oe_form_field o_ace_view_editor oe_ace_open o_profiling_qweb_view">
<div class="o_select_view_profiling" t-ref="selector">
<a role="button" class="dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" href="#"><MenuItem view="state.view" t-if="state.view"/></a>
<div class="dropdown-menu" role="menu">
<t t-foreach="viewObjects" t-as="view" t-key="view_index">
<a role="menuitem" href="#" t-att-data-id="view.id" t-on-click.prevent="_onSelectView">
<MenuItem view="view"/>
</a>
</t>
</div>
</div>
<div class="ace-view-editor" t-ref="ace"/>
<small class="text-muted">
It is possible that the "t-call" time does not correspond to the overall time of the
template. Because the global time (in the drop down) does not take into account the
duration which is not in the rendering (look for the template, read, inheritance,
compilation...). During rendering, the global time also takes part of the time to make
the profile as well as some part not logged in the function generated by the qweb.
</small>
</div>
</t>
<t t-name="web.ProfilingQwebView.menuitem" owl="1">
<div class="o_delay"><t t-if="props.view.delay" t-esc="props.view.delay"/> ms</div>
<div class="o_query"><t t-if="props.view.delay" t-esc="props.view.query"/> query</div>
<t t-esc="props.view.display_name"/>
<div class="o_key text-muted">(<t t-esc="props.view.id"/>, <t t-esc="props.view.key"/>)</div>
</t>
<t t-name="web.ProfilingQwebView.hover" owl="1">
<div class="o_info o_detail">
<div class="o_delay"><t t-esc="delay"/> <span>ms</span></div>
<div class="o_query"><t t-esc="query"/> <span>query</span></div>
</div>
</t>
<t t-name="web.ProfilingQwebView.info" owl="1">
<div class="o_info">
<div t-if="displayDetail" class="o_more">
<span>*</span>
<table class="o_detail">
<thead>
<tr><th></th><th>ms</th><th>query</th></tr>
</thead>
<tbody>
<tr t-foreach="groups" t-as="directive" t-key="directive_index">
<td><t t-esc="directive"/></td>
<td><t t-esc="groups[directive].delays.join(' ')"/></td>
<td><t t-esc="groups[directive].querys.join(' ')"/></td>
</tr>
</tbody>
</table>
</div>
<div class="o_delay"><t t-esc="delay"/></div>
<div class="o_query"><t t-esc="query"/></div>
</div>
</t>
</templates>

View file

@ -0,0 +1,114 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { ProfilingItem } from "./profiling_item";
import { session } from "@web/session";
import { profilingSystrayItem } from "./profiling_systray_item";
import { EventBus, reactive } from "@odoo/owl";
const systrayRegistry = registry.category("systray");
const profilingService = {
dependencies: ["orm"],
start(env, { orm }) {
// Only set up profiling when in debug mode
if (!env.debug) {
return;
}
function notify() {
if (systrayRegistry.contains("web.profiling") && state.isEnabled === false) {
systrayRegistry.remove("web.profiling", profilingSystrayItem);
}
if (!systrayRegistry.contains("web.profiling") && state.isEnabled === true) {
systrayRegistry.add("web.profiling", profilingSystrayItem, { sequence: 99 });
}
bus.trigger("UPDATE");
}
const state = reactive(
{
session: session.profile_session || false,
collectors: session.profile_collectors || ["sql", "traces_async"],
params: session.profile_params || {},
get isEnabled() {
return Boolean(state.session);
},
},
notify
);
const bus = new EventBus();
notify();
async function setProfiling(params) {
const kwargs = Object.assign(
{
collectors: state.collectors,
params: state.params,
profile: state.isEnabled,
},
params
);
const resp = await orm.call("ir.profile", "set_profiling", [], kwargs);
if (resp.type) {
// most likely an "ir.actions.act_window"
env.services.action.doAction(resp);
} else {
state.session = resp.session;
state.collectors = resp.collectors;
state.params = resp.params;
}
}
function profilingSeparator() {
return {
type: "separator",
sequence: 500,
};
}
function profilingItem() {
return {
type: "component",
Component: ProfilingItem,
props: { bus },
sequence: 510,
};
}
registry
.category("debug")
.category("default")
.add("profilingSeparator", profilingSeparator)
.add("profilingItem", profilingItem);
return {
state,
async toggleProfiling() {
await setProfiling({ profile: !state.isEnabled });
},
async toggleCollector(collector) {
const nextCollectors = state.collectors.slice();
const index = nextCollectors.indexOf(collector);
if (index >= 0) {
nextCollectors.splice(index, 1);
} else {
nextCollectors.push(collector);
}
await setProfiling({ collectors: nextCollectors });
},
async setParam(key, value) {
const nextParams = Object.assign({}, state.params);
nextParams[key] = value;
await setProfiling({ params: nextParams });
},
isCollectorEnabled(collector) {
return state.collectors.includes(collector);
},
};
},
};
registry.category("services").add("profiling", profilingService);

View file

@ -0,0 +1,10 @@
/** @odoo-module */
import { Component } from "@odoo/owl";
class ProfilingSystrayItem extends Component {}
ProfilingSystrayItem.template = "web.ProfilingSystrayItem";
export const profilingSystrayItem = {
Component: ProfilingSystrayItem,
};

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ProfilingSystrayItem" owl="1">
<div class="d-flex align-items-center">
<i class="fa fa-circle text-danger o_debug_recording" />
</div>
</t>
</templates>

View file

@ -0,0 +1,61 @@
/** @odoo-module **/
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { useActiveElement } from "../ui/ui_service";
import { useForwardRefToParent } from "@web/core/utils/hooks";
import { Component, useChildSubEnv, useState } from "@odoo/owl";
export class Dialog extends Component {
setup() {
this.modalRef = useForwardRefToParent("modalRef");
useActiveElement("modalRef");
this.data = useState(this.env.dialogData);
useHotkey("escape", () => {
this.data.close();
});
this.id = `dialog_${this.data.id}`;
useChildSubEnv({ inDialog: true, dialogId: this.id, closeDialog: this.data.close });
owl.onWillDestroy(() => {
if (this.env.isSmall) {
this.data.scrollToOrigin();
}
});
}
get isFullscreen() {
return this.props.fullscreen || this.env.isSmall;
}
}
Dialog.template = "web.Dialog";
Dialog.props = {
contentClass: { type: String, optional: true },
bodyClass: { type: String, optional: true },
fullscreen: { type: Boolean, optional: true },
footer: { type: Boolean, optional: true },
header: { type: Boolean, optional: true },
size: { type: String, optional: true, validate: (s) => ["sm", "md", "lg", "xl", "fullscreen"].includes(s) },
technical: { type: Boolean, optional: true },
title: { type: String, optional: true },
modalRef: { type: Function, optional: true },
slots: {
type: Object,
shape: {
default: Object, // Content is not optional
header: { type: Object, optional: true },
footer: { type: Object, optional: true },
},
},
withBodyPadding: { type: Boolean, optional: true },
};
Dialog.defaultProps = {
contentClass: "",
bodyClass: "",
fullscreen: false,
footer: true,
header: true,
size: "lg",
technical: true,
title: "Odoo",
withBodyPadding: true,
};

View file

@ -0,0 +1,19 @@
.modal.o_technical_modal .modal-footer {
// FIXME: These selectors should not be necessary if we used buttons
// as direct children of the modal-footer as normally required
footer, .o_form_buttons_edit, .o_form_buttons_view {
display: flex;
flex-wrap: wrap;
flex: 1 1 auto;
justify-content: space-around;
gap: map-get($spacers, 1);
@include media-breakpoint-up(md) {
justify-content: flex-start;
}
}
button {
margin: 0; // Reset boostrap.
}
}

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Dialog" owl="1">
<div class="o_dialog" t-att-id="id" t-att-class="{ o_inactive_modal: !data.isActive }">
<div role="dialog" class="modal d-block"
tabindex="-1"
t-att-class="{ o_technical_modal: props.technical, o_modal_full: isFullscreen }"
t-ref="modalRef"
>
<div class="modal-dialog" t-attf-class="modal-{{props.size}}">
<div class="modal-content" t-att-class="props.contentClass">
<header t-if="props.header" class="modal-header">
<t t-slot="header" close="data.close" isFullscreen="isFullscreen">
<t t-call="web.Dialog.header">
<t t-set="close" t-value="data.close"/>
<t t-set="fullscreen" t-value="isFullscreen"/>
</t>
</t>
</header>
<!-- FIXME: WOWL there is a bug on t-portal on owl, in which t-portal don't work on multinode.
To avoid this we place the footer before the body -->
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-md-start flex-wrap gap-1" style="order:2">
<t t-slot="footer" close="data.close">
<button class="btn btn-primary o-default-button" t-on-click="data.close">
<t>Ok</t>
</button>
</t>
</footer>
<main class="modal-body" t-attf-class="{{ props.bodyClass }} {{ !props.withBodyPadding ? 'p-0': '' }}">
<t t-slot="default" close="data.close" />
</main>
</div>
</div>
</div>
</div>
</t>
<t t-name="web.Dialog.header" owl="1">
<t t-if="fullscreen">
<button class="btn fa fa-arrow-left" data-bs-dismiss="modal" aria-label="Close" t-on-click="close" />
</t>
<h4 class="modal-title text-break" t-att-class="{ 'me-auto': fullscreen }">
<t t-esc="props.title"/>
</h4>
<t t-if="!fullscreen">
<button type="button" class="btn-close" aria-label="Close" tabindex="-1" t-on-click="close"></button>
</t>
</t>
</templates>

View file

@ -0,0 +1,30 @@
/** @odoo-module **/
import { ErrorHandler, WithEnv } from "../utils/components";
import { Component, xml } from "@odoo/owl";
export class DialogContainer extends Component {
handleError(error, dialog) {
dialog.props.close();
Promise.resolve().then(() => {
throw error;
});
}
}
DialogContainer.components = { ErrorHandler, WithEnv };
//Legacy : The div wrapping the t-foreach, is placed to avoid owl to delete non-owl dialogs.
//This div can be removed after removing all legacy dialogs.
DialogContainer.template = xml`
<div class="o_dialog_container" t-att-class="{'modal-open': Object.keys(props.dialogs).length > 0}">
<div>
<t t-foreach="Object.values(props.dialogs)" t-as="dialog" t-key="dialog.id">
<ErrorHandler onError="(error) => this.handleError(error, dialog)">
<WithEnv env="{ dialogData: dialog.dialogData }">
<t t-component="dialog.class" t-props="dialog.props"/>
</WithEnv>
</ErrorHandler>
</t>
</div>
</div>
`;

View file

@ -0,0 +1,82 @@
/** @odoo-module **/
import { registry } from "../registry";
import { DialogContainer } from "./dialog_container";
import { markRaw, reactive } from "@odoo/owl";
/**
* @typedef {{
* onClose?(): void;
* }} DialogServiceInterfaceAddOptions
*/
/**
* @typedef {{
* add(
* Component: any,
* props: {},
* options?: DialogServiceInterfaceAddOptions
* ): () => void;
* }} DialogServiceInterface
*/
export const dialogService = {
/** @returns {DialogServiceInterface} */
start(env) {
const dialogs = reactive({});
let dialogId = 0;
registry.category("main_components").add("DialogContainer", {
Component: DialogContainer,
props: { dialogs },
});
function add(dialogClass, props, options = {}) {
for (const dialog of Object.values(dialogs)) {
dialog.dialogData.isActive = false;
}
const id = ++dialogId;
function close() {
if (dialogs[id]) {
delete dialogs[id];
Object.values(dialogs).forEach((dialog, i, dialogArr) => {
dialog.dialogData.isActive = i === dialogArr.length - 1;
});
if (options.onClose) {
options.onClose();
}
}
}
const dialog = {
id,
class: dialogClass,
props: markRaw({ ...props, close }),
dialogData: {
isActive: true,
close,
id,
},
};
const scrollOrigin = { top: window.scrollY, left: window.scrollX };
dialog.dialogData.scrollToOrigin = () => {
if (!Object.keys(dialogs).length) {
window.scrollTo(scrollOrigin);
}
};
dialogs[id] = dialog;
return close;
}
function closeAll() {
for (const id in dialogs) {
dialogs[id].dialogData.close();
}
}
return { add, closeAll };
},
};
registry.category("services").add("dialog", dialogService);

View file

@ -0,0 +1,304 @@
/** @odoo-module **/
import { shallowEqual } from "@web/core/utils/arrays";
import { evaluate, formatAST, parseExpr } from "./py_js/py";
import { toPyValue } from "./py_js/py_utils";
/**
* @typedef {import("./py_js/py_parser").AST} AST
* @typedef {[string, string, any]} Condition
* @typedef {("&" | "|" | "!" | Condition)[]} DomainListRepr
* @typedef {DomainListRepr | string | Domain} DomainRepr
*/
export class InvalidDomainError extends Error {}
/**
* Javascript representation of an Odoo domain
*/
export class Domain {
/**
* Combine various domains together with a given operator
* @param {DomainRepr[]} domains
* @param {"AND" | "OR"} operator
* @returns {Domain}
*/
static combine(domains, operator) {
if (domains.length === 0) {
return new Domain([]);
}
const domain1 = domains[0] instanceof Domain ? domains[0] : new Domain(domains[0]);
if (domains.length === 1) {
return domain1;
}
const domain2 = Domain.combine(domains.slice(1), operator);
const result = new Domain([]);
const astValues1 = domain1.ast.value;
const astValues2 = domain2.ast.value;
const op = operator === "AND" ? "&" : "|";
const combinedAST = { type: 4 /* List */, value: astValues1.concat(astValues2) };
result.ast = normalizeDomainAST(combinedAST, op);
return result;
}
/**
* Combine various domains together with `AND` operator
* @param {DomainRepr} domains
* @returns {Domain}
*/
static and(domains) {
return Domain.combine(domains, "AND");
}
/**
* Combine various domains together with `OR` operator
* @param {DomainRepr} domains
* @returns {Domain}
*/
static or(domains) {
return Domain.combine(domains, "OR");
}
/**
* Return the negation of the domain
* @returns {Domain}
*/
static not(domain) {
const result = new Domain(domain);
result.ast.value.unshift({ type: 1, value: "!" });
return result;
}
/**
* @param {DomainRepr} [descr]
*/
constructor(descr = []) {
if (descr instanceof Domain) {
/** @type {AST} */
return new Domain(descr.toString());
} else {
const rawAST = typeof descr === "string" ? parseExpr(descr) : toAST(descr);
this.ast = normalizeDomainAST(rawAST);
}
}
/**
* Check if the set of records represented by a domain contains a record
*
* @param {Object} record
* @returns {boolean}
*/
contains(record) {
const expr = evaluate(this.ast, record);
return matchDomain(record, expr);
}
/**
* @returns {string}
*/
toString() {
return formatAST(this.ast);
}
/**
* @param {Object} context
* @returns {DomainListRepr}
*/
toList(context) {
return evaluate(this.ast, context);
}
}
const TRUE_LEAF = [1, "=", 1];
const FALSE_LEAF = [0, "=", 1];
const TRUE_DOMAIN = new Domain([TRUE_LEAF]);
const FALSE_DOMAIN = new Domain([FALSE_LEAF]);
Domain.TRUE = TRUE_DOMAIN;
Domain.FALSE = FALSE_DOMAIN;
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
/**
* @param {DomainListRepr} domain
* @returns {AST}
*/
function toAST(domain) {
const elems = domain.map((elem) => {
switch (elem) {
case "!":
case "&":
case "|":
return { type: 1 /* String */, value: elem };
default:
return {
type: 10 /* Tuple */,
value: elem.map(toPyValue),
};
}
});
return { type: 4 /* List */, value: elems };
}
/**
* Normalizes a domain
*
* @param {AST} domain
* @param {'&' | '|'} [op]
* @returns {AST}
*/
function normalizeDomainAST(domain, op = "&") {
if (domain.type !== 4 /* List */) {
if (domain.type === 10 /* Tuple */) {
const value = domain.value;
/* Tuple contains at least one Tuple and optionally string */
if (
value.findIndex((e) => e.type === 10) === -1 ||
!value.every((e) => e.type === 10 || e.type === 1)
) {
throw new InvalidDomainError("Invalid domain AST");
}
} else {
throw new InvalidDomainError("Invalid domain AST");
}
}
if (domain.value.length === 0) {
return domain;
}
let expected = 1;
for (const child of domain.value) {
if (child.type === 1 /* String */ && (child.value === "&" || child.value === "|")) {
expected++;
} else if (child.type !== 1 /* String */ || child.value !== "!") {
expected--;
}
}
const values = domain.value.slice();
while (expected < 0) {
expected++;
values.unshift({ type: 1 /* String */, value: op });
}
if (expected > 0) {
throw new InvalidDomainError(
`invalid domain ${formatAST(domain)} (missing ${expected} segment(s))`
);
}
return { type: 4 /* List */, value: values };
}
/**
* @param {Object} record
* @param {Condition | boolean} condition
* @returns {boolean}
*/
function matchCondition(record, condition) {
if (typeof condition === "boolean") {
return condition;
}
const [field, operator, value] = condition;
if (typeof field === "string") {
const names = field.split(".");
if (names.length >= 2) {
return matchCondition(record[names[0]], [names.slice(1).join("."), operator, value]);
}
}
const fieldValue = typeof field === "number" ? field : record[field];
switch (operator) {
case "=?":
if ([false, null].includes(value)) {
return true;
}
// eslint-disable-next-line no-fallthrough
case "=":
case "==":
if (Array.isArray(fieldValue) && Array.isArray(value)) {
return shallowEqual(fieldValue, value);
}
return fieldValue === value;
case "!=":
case "<>":
return !matchCondition(record, [field, "==", value]);
case "<":
return fieldValue < value;
case "<=":
return fieldValue <= value;
case ">":
return fieldValue > value;
case ">=":
return fieldValue >= value;
case "in": {
const val = Array.isArray(value) ? value : [value];
const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
return fieldVal.some((fv) => val.includes(fv));
}
case "not in": {
const val = Array.isArray(value) ? value : [value];
const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
return !fieldVal.some((fv) => val.includes(fv));
}
case "like":
if (fieldValue === false) {
return false;
}
return fieldValue.indexOf(value) >= 0;
case "=like":
if (fieldValue === false) {
return false;
}
return new RegExp(value.replace(/%/g, ".*")).test(fieldValue);
case "ilike":
if (fieldValue === false) {
return false;
}
return fieldValue.toLowerCase().indexOf(value.toLowerCase()) >= 0;
case "=ilike":
if (fieldValue === false) {
return false;
}
return new RegExp(value.replace(/%/g, ".*"), "i").test(fieldValue);
}
throw new InvalidDomainError("could not match domain");
}
/**
* @param {Object} record
* @returns {Object}
*/
function makeOperators(record) {
const match = matchCondition.bind(null, record);
return {
"!": (x) => !match(x),
"&": (a, b) => match(a) && match(b),
"|": (a, b) => match(a) || match(b),
};
}
/**
*
* @param {Object} record
* @param {DomainListRepr} domain
* @returns {boolean}
*/
function matchDomain(record, domain) {
if (domain.length === 0) {
return true;
}
const operators = makeOperators(record);
const reversedDomain = Array.from(domain).reverse();
const condStack = [];
for (const item of reversedDomain) {
if (item in operators) {
const operator = operators[item];
const operands = condStack.splice(-operator.length);
condStack.push(operator(...operands));
} else {
condStack.push(item);
}
}
return matchCondition(record, condStack.pop());
}

View file

@ -0,0 +1,226 @@
/** @odoo-module **/
import { Domain } from "@web/core/domain";
import { DomainSelectorRootNode } from "./domain_selector_root_node";
import { Component } from "@odoo/owl";
export class DomainSelector extends Component {
setup() {
this.nextNodeId = 0;
}
buildTree() {
try {
const domain = new Domain(this.props.value);
const ctx = {
parent: null,
index: 0,
domain: domain.toList(),
get currentElement() {
return ctx.domain[ctx.index];
},
next() {
ctx.index++;
},
getFullDomain() {
return rootNode.computeDomain().toString();
},
};
const rootNode = this.makeRootNode(ctx);
ctx.parent = rootNode;
this.traverseNode(ctx);
return ctx.parent;
} catch (_e) {
// WOWL TODO: rethrow error when not the expected type
return false;
}
}
traverseNode(ctx, negate = false) {
if (ctx.index < ctx.domain.length) {
if (ctx.currentElement === "!") {
ctx.next();
this.traverseNode(ctx, !negate);
} else if (
typeof ctx.currentElement === "string" &&
["&", "|"].includes(ctx.currentElement)
) {
this.traverseBranchNode(ctx, negate);
} else {
this.traverseLeafNode(ctx, negate);
}
}
}
traverseBranchNode(ctx, negate) {
if (ctx.parent.type !== "branch" || ctx.parent.operator !== ctx.currentElement) {
const node = this.makeBranchNode(ctx, ctx.currentElement, [], negate);
ctx.parent.operands.push(node);
ctx = Object.assign(Object.create(ctx), { parent: node });
}
ctx.next();
this.traverseNode(ctx);
ctx.next();
this.traverseNode(ctx);
}
traverseLeafNode(ctx, negate) {
const condition = ctx.currentElement;
const [leftOperand, operator, rightOperand] = condition;
const node = this.makeLeafNode(ctx, operator, [leftOperand, rightOperand], negate);
ctx.parent.operands.push(node);
}
makeBranchNode(ctx, operator, operands, negate) {
const updateDomain = () => this.props.update(ctx.getFullDomain());
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "branch",
id: this.nextNodeId++,
operator,
operands,
computeDomain() {
let domain = Domain.combine(
this.operands.map((operand) => operand.computeDomain()),
this.operator === "&" ? "AND" : "OR"
);
if (negate) {
domain = Domain.not(domain);
}
return domain;
},
update(operator) {
this.operator = operator;
updateDomain();
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
updateDomain();
},
delete() {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this), 1);
updateDomain();
},
};
}
makeLeafNode(ctx, operator, operands, negate) {
const updateDomain = () => this.props.update(ctx.getFullDomain());
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "leaf",
id: this.nextNodeId++,
operator,
operands,
computeDomain() {
let domain = new Domain([[this.operands[0], this.operator, this.operands[1]]]);
if (negate) {
domain = Domain.not(domain);
}
return domain;
},
update(changes) {
if ("fieldName" in changes) {
this.operands[0] = changes.fieldName;
}
if ("operator" in changes) {
this.operator = changes.operator;
}
if ("value" in changes) {
this.operands[1] = changes.value;
}
updateDomain();
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
updateDomain();
},
delete() {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this), 1);
updateDomain();
},
};
}
makeRootNode(ctx) {
const updateDomain = (...args) => this.props.update(...args);
const makeFakeNode = this.makeFakeNode.bind(this);
return {
type: "root",
id: this.nextNodeId++,
operator: "&",
operands: [],
computeDomain() {
return Domain.combine(
this.operands.map((operand) => operand.computeDomain()),
"AND"
);
},
update(newValue, fromDebug) {
if (typeof newValue === "string") {
updateDomain(newValue, fromDebug);
} else if (this.operands.length) {
this.operands[0].update(newValue);
}
},
insert(newNodeType) {
const newNode = makeFakeNode(ctx, newNodeType);
if (ctx.parent) {
const operands = ctx.parent.operands;
operands.splice(operands.indexOf(this) + 1, 0, newNode);
} else {
this.operands.push(newNode);
}
updateDomain(ctx.getFullDomain());
},
delete() {},
};
}
makeFakeNode(ctx, type) {
const [field, op, value] = this.props.defaultLeafValue;
if (type === "branch") {
return this.makeBranchNode(ctx, ctx.parent.operator === "&" ? "|" : "&", [
this.makeLeafNode(ctx, op, [field, value]),
this.makeLeafNode(ctx, op, [field, value]),
]);
} else {
return this.makeLeafNode(ctx, op, [field, value]);
}
}
resetDomain() {
this.props.update("[]");
}
}
Object.assign(DomainSelector, {
template: "web._DomainSelector",
components: {
DomainSelectorRootNode,
},
props: {
className: { type: String, optional: true },
resModel: String,
value: String,
debugValue: { type: String, optional: true },
readonly: { type: Boolean, optional: true },
update: { type: Function, optional: true },
isDebugMode: { type: Boolean, optional: true },
defaultLeafValue: { type: Array, optional: true },
},
defaultProps: {
readonly: true,
update: () => {},
isDebugMode: false,
defaultLeafValue: ["id", "=", 1],
},
});

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web._DomainSelector" owl="1">
<t t-set="tree" t-value="buildTree()"/>
<t t-if="tree">
<DomainSelectorRootNode
value="props.value"
node="tree"
readonly="props.readonly"
resModel="props.resModel"
isDebugMode="props.isDebugMode"
debugValue="props.debugValue"
className="props.className"
/>
</t>
<t t-else="">
<div t-att-class="props.className">
This domain is not supported.
<t t-if="!props.readonly">
<button class="btn btn-sm btn-primary ms-2" t-on-click="() => this.resetDomain()">Reset domain</button>
</t>
</div>
</t>
</t>
</templates>

View file

@ -0,0 +1,30 @@
/** @odoo-module **/
import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator";
import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
import { DomainSelectorLeafNode } from "./domain_selector_leaf_node";
import { Component, useRef } from "@odoo/owl";
export class DomainSelectorBranchNode extends Component {
setup() {
this.root = useRef("root");
}
onHoverDeleteNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_btns", hovering);
}
onHoverInsertLeafNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
}
onHoverInsertBranchNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
}
}
DomainSelectorBranchNode.template = "web.DomainSelectorBranchNode";
DomainSelectorBranchNode.components = {
DomainSelectorBranchNode,
DomainSelectorBranchOperator,
DomainSelectorControlPanel,
DomainSelectorLeafNode,
};

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBranchNode" owl="1">
<div class="o_domain_node o_domain_tree" t-ref="root">
<div class="o_domain_tree_header o_domain_selector_row">
<t t-if="!props.readonly">
<DomainSelectorControlPanel
node="props.node"
onHoverDeleteNodeBtn.bind="onHoverDeleteNodeBtn"
onHoverInsertLeafNodeBtn.bind="onHoverInsertLeafNodeBtn"
onHoverInsertBranchNodeBtn.bind="onHoverInsertBranchNodeBtn"
/>
</t>
<DomainSelectorBranchOperator node="props.node" readonly="props.readonly" />
<span class="ml4">of:</span>
</div>
<div class="o_domain_node_children_container">
<t t-foreach="props.node.operands" t-as="subNode" t-key="subNode.id">
<t t-if="subNode.type === 'branch'">
<DomainSelectorBranchNode t-props="{ ...props, node: subNode }" />
</t>
<t t-elif="subNode.type === 'leaf'">
<DomainSelectorLeafNode t-props="{ ...props, node: subNode }" />
</t>
</t>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,24 @@
/** @odoo-module **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { Component } from "@odoo/owl";
export class DomainSelectorBranchOperator extends Component {
onOperatorSelected(operator) {
this.props.node.update(operator);
}
}
DomainSelectorBranchOperator.components = {
Dropdown,
DropdownItem,
};
DomainSelectorBranchOperator.template = "web.DomainSelectorBranchOperator";
DomainSelectorBranchOperator.props = {
node: Object,
readonly: Boolean,
showCaret: {
type: Boolean,
optional: true,
},
};

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBranchOperator" owl="1">
<t t-if="!props.readonly">
<div class="d-inline-flex o_domain_tree_operator_selector" aria-atomic="true">
<Dropdown togglerClass="'btn btn-link btn-sm btn-primary py-0 px-1 o_domain_tree_operator_caret'" showCaret="props.showCaret">
<t t-set-slot="toggler">
<t t-if="props.node.operator === '&amp;'">all</t>
<t t-elif="props.node.operator === '|'">any</t>
<t t-elif="props.node.operator === '!'">none</t>
</t>
<DropdownItem onSelected="() => this.onOperatorSelected('&amp;')">all</DropdownItem>
<DropdownItem onSelected="() => this.onOperatorSelected('|')">any</DropdownItem>
</Dropdown>
</div>
</t>
<t t-else="">
<strong>
<t t-if="props.node.operator === '&amp;'">all</t>
<t t-elif="props.node.operator === '|'">any</t>
<t t-elif="props.node.operator === '!'">none</t>
</strong>
</t>
</t>
</templates>

View file

@ -0,0 +1,39 @@
/** @odoo-module **/
import { Component, toRaw } from "@odoo/owl";
export class DomainSelectorControlPanel extends Component {
deleteNode() {
this.props.node.delete();
}
insertNode(newNodeType) {
toRaw(this.props.node).insert(newNodeType); // FIXME WOWL reactivity
}
onEnterDeleteNodeBtn() {
this.props.onHoverDeleteNodeBtn(true);
}
onLeaveDeleteNodeBtn() {
this.props.onHoverDeleteNodeBtn(false);
}
onEnterInsertLeafNodeBtn() {
this.props.onHoverInsertLeafNodeBtn(true);
}
onLeaveInsertLeafNodeBtn() {
this.props.onHoverInsertLeafNodeBtn(false);
}
onEnterInsertBranchNodeBtn() {
this.props.onHoverInsertBranchNodeBtn(true);
}
onLeaveInsertBranchNodeBtn() {
this.props.onHoverInsertBranchNodeBtn(false);
}
}
DomainSelectorControlPanel.template = "web.DomainSelectorControlPanel";
DomainSelectorControlPanel.props = {
node: Object,
onHoverDeleteNodeBtn: Function,
onHoverInsertLeafNodeBtn: Function,
onHoverInsertBranchNodeBtn: Function,
};

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorControlPanel" owl="1">
<div class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node">
<button
class="btn btn-link text-danger o_domain_delete_node_button"
title="Delete node"
aria-label="Delete node"
t-on-click="deleteNode"
t-on-mouseenter="onEnterDeleteNodeBtn"
t-on-mouseleave="onLeaveDeleteNodeBtn"
>
<i class="fa fa-times"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add node"
aria-label="Add node"
t-on-click="() => this.insertNode('leaf')"
t-on-mouseenter="onEnterInsertLeafNodeBtn"
t-on-mouseleave="onLeaveInsertLeafNodeBtn"
>
<i class="fa fa-plus-circle"/>
</button>
<button
class="btn o_domain_add_node_button"
title="Add branch"
aria-label="Add branch"
data-branch="1"
t-on-click="() => this.insertNode('branch')"
t-on-mouseenter="onEnterInsertBranchNodeBtn"
t-on-mouseleave="onLeaveInsertBranchNodeBtn"
>
<i class="fa fa-ellipsis-h"/>
</button>
</div>
</t>
</templates>

View file

@ -0,0 +1,137 @@
/** @odoo-module **/
import { useModelField } from "@web/core/model_field_selector/model_field_hook";
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
import { registry } from "@web/core/registry";
import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
import { DomainSelectorDefaultField } from "./fields/domain_selector_default_field";
import { Component, onWillStart, onWillUpdateProps, useRef } from "@odoo/owl";
export class DomainSelectorLeafNode extends Component {
setup() {
this.root = useRef("root");
this.modelField = useModelField();
this.fieldInfo = {
type: "integer",
string: "ID",
};
onWillStart(async () => {
this.fieldInfo = await this.loadField(this.props.resModel, this.props.node.operands[0]);
});
onWillUpdateProps(async (nextProps) => {
this.fieldInfo = await this.loadField(nextProps.resModel, nextProps.node.operands[0]);
});
}
get displayedOperator() {
const op = this.getOperatorInfo(this.props.node.operator);
return op ? op.label : "?";
}
get isValueHidden() {
const op = this.getOperatorInfo(this.props.node.operator);
return op ? op.hideValue : false;
}
async loadField(resModel, fieldName) {
const chain = await this.modelField.loadChain(resModel, fieldName);
if (!chain[chain.length - 1].field && chain.length > 1) {
return chain[chain.length - 2].field;
}
return (
chain[chain.length - 1].field || {
type: "integer",
string: "ID",
}
);
}
findOperator(operatorList, opToFind) {
return operatorList.find((o) =>
o.matches({
field: this.fieldInfo,
value: this.props.node.operands[1],
operator: opToFind,
})
);
}
getOperators(field) {
const operators = field.getOperators();
if (this.findOperator(operators, this.props.node.operator)) {
return operators;
}
return operators.concat(
this.findOperator(
registry.category("domain_selector/operator").getAll(),
this.props.node.operator
)
);
}
getFieldComponent(type) {
return registry.category("domain_selector/fields").get(type, DomainSelectorDefaultField);
}
getOperatorInfo(operator) {
const op = this.findOperator(
this.getFieldComponent(this.fieldInfo.type).getOperators(),
operator
);
if (op) {
return op;
}
return this.findOperator(
registry.category("domain_selector/operator").getAll(),
this.props.node.operator
);
}
async onFieldChange(fieldName) {
const changes = { fieldName };
const fieldInfo = await this.loadField(this.props.resModel, fieldName);
const component = this.getFieldComponent(fieldInfo.type);
Object.assign(changes, component.onDidTypeChange(fieldInfo));
if (!this.findOperator(component.getOperators(), this.props.node.operator)) {
const operatorInfo = component.getOperators()[0];
changes.operator = operatorInfo.value;
Object.assign(
changes,
operatorInfo.onDidChange(this.getOperatorInfo(this.props.node.operator), () =>
component.onDidTypeChange(this.fieldInfo)
)
);
}
this.props.node.update(changes);
}
onOperatorChange(ev) {
const component = this.getFieldComponent(this.fieldInfo.type);
const operatorInfo = component.getOperators()[parseInt(ev.target.value, 10)];
const changes = { operator: operatorInfo.value };
Object.assign(
changes,
operatorInfo.onDidChange(this.getOperatorInfo(this.props.node.operator), () =>
component.onDidTypeChange(this.fieldInfo)
)
);
this.props.node.update(changes);
}
onHoverDeleteNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_btns", hovering);
}
onHoverInsertLeafNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
}
onHoverInsertBranchNodeBtn(hovering) {
this.root.el.classList.toggle("o_hover_add_node", hovering);
this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
}
}
Object.assign(DomainSelectorLeafNode, {
template: "web.DomainSelectorLeafNode",
components: {
DomainSelectorControlPanel,
ModelFieldSelector,
},
});

View file

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorLeafNode" owl="1">
<div class="o_domain_node o_domain_leaf o_domain_selector_row" t-ref="root">
<t t-if="props.readonly">
<div class="o_domain_leaf_info">
<ModelFieldSelector fieldName="props.node.operands[0].toString()" resModel="props.resModel" readonly="props.readonly" isDebugMode="props.isDebugMode" />
<t t-if="typeof props.node.operands[1] === 'string'">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<span class="o_domain_leaf_value text-primary"> "<t t-esc="props.node.operands[1]" />"</span>
</t>
<t t-elif="typeof props.node.operands[1] === 'number'">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<span class="o_domain_leaf_value text-primary"> <t t-esc="props.node.operands[1]" /></span>
</t>
<t t-elif="typeof props.node.operands[1] === 'boolean'">
is
<t t-if="(props.node.operator === '=' and props.node.operands[1] === false) or (props.node.operator === '!=' and props.node.operands[1] === true)">
not
</t>
set
</t>
<t t-elif="Array.isArray(props.node.operands[1])">
<span class="o_domain_leaf_operator"> <t t-esc="displayedOperator" /></span>
<t t-foreach="props.node.operands[1]" t-as="value" t-key="value_index">
<span class="o_domain_leaf_value text-primary"> "<t t-esc="value"/>"</span>
<t t-if="!value_last"> or </t>
</t>
</t>
</div>
</t>
<t t-else="">
<DomainSelectorControlPanel
node="props.node"
onHoverDeleteNodeBtn.bind="onHoverDeleteNodeBtn"
onHoverInsertLeafNodeBtn.bind="onHoverInsertLeafNodeBtn"
onHoverInsertBranchNodeBtn.bind="onHoverInsertBranchNodeBtn"
/>
<div class="o_domain_leaf_edition">
<ModelFieldSelector fieldName="props.node.operands[0].toString()" resModel="props.resModel" readonly="props.readonly" update="(name) => this.onFieldChange(name)" isDebugMode="props.isDebugMode" />
<t t-set="field" t-value="getFieldComponent(fieldInfo.type)" />
<t t-if="field">
<div>
<select class="o_domain_leaf_operator_select o_input text-truncate pe-1" t-on-change="onOperatorChange">
<t t-foreach="getOperators(field)" t-as="operator" t-key="operator.value">
<option
t-att-value="operator_index"
t-att-selected="operator.matches({ fieldInfo, operator: props.node.operator, value: props.node.operands[1] })"
t-esc="operator.label"
/>
</t>
</select>
</div>
<t t-if="!isValueHidden">
<t
t-component="field"
t-key="fieldInfo.type"
field="fieldInfo"
operator="getOperatorInfo(props.node.operator)"
value="props.node.operands[1]"
update="(changes) => props.node.update(changes)"
/>
</t>
</t>
</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,167 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
function onDidChange(action) {
return function (oldOperator, fieldChange) {
if (this.category !== oldOperator.category) {
return action(fieldChange);
}
return {};
};
}
function matchValue() {
return function ({ operator }) {
return operator === this.value;
};
}
const dso = registry.category("domain_selector/operator");
dso.add("=", {
category: "equality",
label: "=",
value: "=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches({ operator, value }) {
return operator === this.value && typeof value !== "boolean";
},
});
dso.add("!=", {
category: "equality",
label: _lt("is not ="),
value: "!=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches({ operator, value }) {
return operator === this.value && typeof value !== "boolean";
},
});
dso.add(">", {
category: "comparison",
label: ">",
value: ">",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add(">=", {
category: "comparison",
label: ">=",
value: ">=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("<", {
category: "comparison",
label: "<",
value: "<",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("<=", {
category: "comparison",
label: "<=",
value: "<=",
onDidChange: onDidChange((fieldChange) => fieldChange()),
matches: matchValue(),
});
dso.add("ilike", {
category: "like",
label: _lt("contains"),
value: "ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("not ilike", {
category: "like",
label: _lt("does not contain"),
value: "not ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("like", {
category: "like",
label: _lt("like"),
value: "like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("not like", {
category: "like",
label: _lt("not like"),
value: "not like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("=like", {
category: "like",
label: _lt("=like"),
value: "=like",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("=ilike", {
category: "like",
label: _lt("=ilike"),
value: "=ilike",
onDidChange: onDidChange(() => ({ value: "" })),
matches: matchValue(),
});
dso.add("child_of", {
category: "relation",
label: _lt("child of"),
value: "child_of",
onDidChange: onDidChange(() => ({ value: 1 })),
matches: matchValue(),
});
dso.add("parent_of", {
category: "relation",
label: _lt("parent of"),
value: "parent_of",
onDidChange: onDidChange(() => ({ value: 1 })),
matches: matchValue(),
});
dso.add("in", {
category: "in",
label: _lt("in"),
value: "in",
onDidChange: onDidChange(() => ({ value: [] })),
matches: matchValue(),
});
dso.add("not in", {
category: "in",
label: _lt("not in"),
value: "not in",
onDidChange: onDidChange(() => ({ value: [] })),
matches: matchValue(),
});
dso.add("set", {
category: "set",
label: _lt("is set"),
value: "set",
hideValue: true,
onDidChange() {
return {
operator: "!=",
value: false,
};
},
matches({ operator, value }) {
return operator === "!=" && typeof value === "boolean";
},
});
dso.add("not set", {
category: "set",
label: _lt("is not set"),
value: "not set",
hideValue: true,
onDidChange() {
return {
operator: "=",
value: false,
};
},
matches({ operator, value }) {
return operator === "=" && typeof value === "boolean";
},
});

View file

@ -0,0 +1,33 @@
/** @odoo-module **/
import { DomainSelectorBranchNode } from "./domain_selector_branch_node";
import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator";
import { DomainSelectorLeafNode } from "./domain_selector_leaf_node";
import { Component } from "@odoo/owl";
export class DomainSelectorRootNode extends Component {
get hasNode() {
return this.props.node.operands.length > 0;
}
get node() {
return this.props.node.operands[0];
}
insertNode(newNodeType) {
this.props.node.insert(newNodeType);
}
onOperatorSelected(ev) {
this.props.node.update(ev.detail.payload.operator);
}
onChange(ev) {
this.props.node.update(ev.target.value, true);
}
}
DomainSelectorRootNode.template = "web.DomainSelectorRootNode";
DomainSelectorRootNode.components = {
DomainSelectorBranchNode,
DomainSelectorBranchOperator,
DomainSelectorLeafNode,
};

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorRootNode" owl="1">
<div
class="o_domain_node o_domain_tree o_domain_selector"
aria-atomic="true"
t-attf-class="{{ props.className }} {{ props.readonly ? 'o_read_mode' : 'o_edit_mode'}}"
>
<t t-if="!hasNode">
<span>Match <strong>all records</strong></span>
<t t-if="!props.readonly">
<button
class="btn btn-sm btn-primary o_domain_add_first_node_button ms-1"
t-on-click="() => this.insertNode('leaf')"
>
<i class="fa fa-plus"/> Add filter
</button>
</t>
</t>
<t t-else="">
<t t-if="node.type === 'leaf'">
Match records with the following rule:
<div class="o_domain_node_children_container">
<DomainSelectorLeafNode t-props="{ ...props, node }" />
</div>
</t>
<t t-else="">
<span>Match records with </span>
<DomainSelectorBranchOperator node="node" readonly="props.readonly" showCaret="true" />
<span> of the following rules:</span>
<div class="o_domain_node_children_container">
<t t-foreach="node.operands" t-as="subNode" t-key="subNode.id">
<t t-if="subNode.type === 'branch'">
<DomainSelectorBranchNode t-props="{ ...props, node: subNode }" />
</t>
<t t-elif="subNode.type === 'leaf'">
<DomainSelectorLeafNode t-props="{ ...props, node: subNode }" />
</t>
</t>
</div>
</t>
</t>
<t t-if="props.isDebugMode and !props.readonly">
<label class="o_domain_debug_container">
<span class="small"># Code editor</span>
<textarea type="text" class="o_domain_debug_input" t-att-value="props.debugValue or props.value" t-on-change="onChange" />
</label>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,47 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
export class DomainSelectorBooleanField extends Component {
onChange(ev) {
this.props.update({
value: ev.target.value === "1",
});
}
}
Object.assign(DomainSelectorBooleanField, {
template: "web.DomainSelectorBooleanField",
onDidTypeChange() {
return { value: true };
},
getOperators() {
return [
{
category: "equality",
label: _lt("is"),
value: "=",
onDidChange() {},
matches({ operator }) {
return operator === this.value;
},
},
{
category: "equality",
label: _lt("is not"),
value: "!=",
onDidChange() {},
matches({ operator }) {
return operator === this.value;
},
},
];
},
});
dsf.add("boolean", DomainSelectorBooleanField);

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorBooleanField" owl="1">
<div class="o_ds_value_cell">
<select class="o_input" t-on-change="onChange">
<option value="1" t-att-selected="props.value">set (true)</option>
<option value="0" t-att-selected="!props.value">not set (false)</option>
</select>
</div>
</t>
</templates>

View file

@ -0,0 +1,52 @@
/** @odoo-module **/
import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorDateTimeField extends Component {
get component() {
const { DatePicker, DateTimePicker } = this.constructor.components;
return this.props.field.type === "date" ? DatePicker : DateTimePicker;
}
get deserializedValue() {
const deserialize =
this.props.field.type === "date" ? deserializeDate : deserializeDateTime;
return this.props.value ? deserialize(this.props.value) : luxon.DateTime.local();
}
onChange(value) {
if (!this.deserializedValue.isValid && !value) {
return;
}
const serialize = this.props.field.type === "date" ? serializeDate : serializeDateTime;
this.props.update({ value: serialize(value || luxon.DateTime.local()) });
}
}
Object.assign(DomainSelectorDateTimeField, {
template: "web.DomainSelectorDateTimeField",
components: {
DatePicker,
DateTimePicker,
},
onDidTypeChange(field) {
const serialize = field.type === "date" ? serializeDate : serializeDateTime;
return { value: serialize(luxon.DateTime.local()) };
},
getOperators() {
return ["=", "!=", ">", "<", ">=", "<=", "set", "not set"].map((key) => dso.get(key));
},
});
dsf.add("date", DomainSelectorDateTimeField);
dsf.add("datetime", DomainSelectorDateTimeField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorDateTimeField" owl="1">
<div class="o_ds_value_cell">
<t t-component="component" date="deserializedValue" onDateTimeChanged.bind="onChange" />
</div>
</t>
</templates>

View file

@ -0,0 +1,23 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { Component } from "@odoo/owl";
const dso = registry.category("domain_selector/operator");
export class DomainSelectorDefaultField extends Component {}
Object.assign(DomainSelectorDefaultField, {
template: "web.DomainSelectorDefaultField",
components: {
DomainSelectorFieldInput,
},
onDidTypeChange() {
return { value: "" };
},
getOperators() {
return dso.getAll();
},
});

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorDefaultField" owl="1">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</templates>

View file

@ -0,0 +1,22 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const parsers = registry.category("parsers");
export class DomainSelectorFieldInput extends Component {
parseValue(value) {
const parser = parsers.get(this.props.field.type, (value) => value);
try {
return parser(value);
} catch (_) {
return value;
}
}
onChange(ev) {
this.props.update({ value: this.parseValue(ev.target.value) });
}
}
DomainSelectorFieldInput.template = "web.DomainSelectorFieldInput";

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorFieldInput" owl="1">
<input type="text" class="o_input o_domain_leaf_value_input" t-att-value="props.value" t-on-change="onChange" />
</t>
</templates>

View file

@ -0,0 +1,25 @@
/** @odoo-module **/
import { Component, useRef } from "@odoo/owl";
export class DomainSelectorFieldInputWithTags extends Component {
setup() {
this.inputRef = useRef("input");
}
removeTag(tagIndex) {
const value = [...this.props.value];
value.splice(tagIndex, 1);
this.props.update({ value });
}
addTag(value) {
this.props.update({ value: this.props.value.concat(value) });
}
onBtnClick() {
const value = this.inputRef.el.value;
this.inputRef.el.value = "";
this.addTag(value);
}
}
DomainSelectorFieldInputWithTags.template = "web.DomainSelectorFieldInputWithTags";

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorFieldInputWithTags" owl="1">
<div class="o_ds_value_cell">
<div class="o_domain_leaf_value_input">
<t t-foreach="props.value" t-as="tag" t-key="tag_index">
<span class="badge rounded-pill">
<t t-esc="tag" /> <i
class="o_domain_leaf_value_remove_tag_button fa fa-times"
role="img"
aria-label="Remove tag"
title="Remove tag"
t-on-click="() => this.removeTag(tag_index)"
/>
</span>
</t>
</div>
<div class="o_domain_leaf_value_tags">
<input type="text" class="o_input" placeholder="Add new value" t-ref="input" />
<button
class="btn btn-sm btn-primary fa fa-plus o_domain_leaf_value_add_tag_button"
aria-label="Add tag"
title="Add tag"
t-on-click="onBtnClick"
/>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,39 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorNumberField extends Component {}
Object.assign(DomainSelectorNumberField, {
template: "web.DomainSelectorNumberField",
components: {
DomainSelectorFieldInput,
},
onDidTypeChange() {
return { value: 0 };
},
getOperators() {
return [
"=",
"!=",
">",
"<",
">=",
"<=",
"ilike",
"not ilike",
"set",
"not set",
].map((key) => dso.get(key));
},
});
dsf.add("integer", DomainSelectorNumberField);
dsf.add("float", DomainSelectorNumberField);
dsf.add("monetary", DomainSelectorNumberField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorNumberField" owl="1">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</templates>

View file

@ -0,0 +1,29 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { Component } from "@odoo/owl";
const dso = registry.category("domain_selector/operator");
export class DomainSelectorRelationField extends Component {}
Object.assign(DomainSelectorRelationField, {
template: "web.DomainSelectorRelationField",
components: {
DomainSelectorFieldInput,
},
onDidTypeChange() {
return { value: "0" };
},
getOperators() {
return ["=", "!=", "ilike", "not ilike", "set", "not set"].map((key) => dso.get(key));
},
});
registry
.category("domain_selector/fields")
.add("one2many", DomainSelectorRelationField)
.add("many2one", DomainSelectorRelationField)
.add("many2many", DomainSelectorRelationField);

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorRelationField" owl="1">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</templates>

View file

@ -0,0 +1,44 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { formatAST, toPyValue } from "@web/core/py_js/py_utils";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorSelectionField extends Component {
get options() {
return [
[false, ""],
...this.props.field.selection.filter((option) => !this.props.value.includes(option[0])),
];
}
get formattedValue() {
const ast = toPyValue(this.props.value);
return formatAST(ast);
}
onChange(ev) {
this.props.update({ value: ev.target.value });
}
onChangeMulti(ev) {
this.props.update({ value: evaluateExpr(ev.target.value) });
}
}
Object.assign(DomainSelectorSelectionField, {
template: "web.DomainSelectorSelectionField",
onDidTypeChange(field) {
return { value: field.selection[0][0] };
},
getOperators() {
return ["=", "!=", "set", "not set"].map((key) => dso.get(key));
},
});
dsf.add("selection", DomainSelectorSelectionField);

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorSelectionField" owl="1">
<div class="o_ds_value_cell">
<t t-if="props.operator.category === 'in'">
<input type="text" class="o_input o_domain_leaf_value_input" t-att-value="formattedValue" t-on-change="onChangeMulti" />
</t>
<t t-else="">
<select class="o_input o_domain_leaf_value_input" t-on-change="onChange">
<t t-foreach="props.field.selection" t-as="option" t-key="option[0]">
<option
t-att-value="option[0]"
t-att-selected="option[0] === props.value"
t-esc="option[1]"
/>
</t>
</select>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,32 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { DomainSelectorFieldInput } from "./domain_selector_field_input";
import { DomainSelectorFieldInputWithTags } from "./domain_selector_field_input_with_tags";
import { Component } from "@odoo/owl";
const dsf = registry.category("domain_selector/fields");
const dso = registry.category("domain_selector/operator");
export class DomainSelectorTextField extends Component {}
Object.assign(DomainSelectorTextField, {
template: "web.DomainSelectorTextField",
components: {
DomainSelectorFieldInput,
DomainSelectorFieldInputWithTags,
},
onDidTypeChange() {
return { value: "" };
},
getOperators() {
return ["=", "!=", "ilike", "not ilike", "set", "not set", "in", "not in"].map((key) =>
dso.get(key)
);
},
});
dsf.add("char", DomainSelectorTextField);
dsf.add("html", DomainSelectorTextField);
dsf.add("text", DomainSelectorTextField);

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorTextField" owl="1">
<t t-if="props.operator.category === 'in'">
<DomainSelectorFieldInputWithTags t-props="props" />
</t>
<t t-else="">
<div class="o_ds_value_cell">
<DomainSelectorFieldInput t-props="props" />
</div>
</t>
</t>
</templates>

View file

@ -0,0 +1,62 @@
/** @odoo-module **/
import { Dialog } from "../dialog/dialog";
import { DomainSelector } from "../domain_selector/domain_selector";
import { _t } from "../l10n/translation";
import { Component, useState } from "@odoo/owl";
export class DomainSelectorDialog extends Component {
setup() {
this.state = useState({
value: this.props.initialValue,
});
}
get dialogTitle() {
return _t("Domain");
}
get domainSelectorProps() {
return {
className: this.props.className,
resModel: this.props.resModel,
readonly: this.props.readonly,
isDebugMode: this.props.isDebugMode,
defaultLeafValue: this.props.defaultLeafValue,
value: this.state.value,
update: (value) => {
this.state.value = value;
},
};
}
async onSave() {
await this.props.onSelected(this.state.value);
this.props.close();
}
onDiscard() {
this.props.close();
}
}
DomainSelectorDialog.template = "web.DomainSelectorDialog";
DomainSelectorDialog.components = {
Dialog,
DomainSelector,
};
DomainSelectorDialog.props = {
close: Function,
className: { type: String, optional: true },
resModel: String,
readonly: { type: Boolean, optional: true },
isDebugMode: { type: Boolean, optional: true },
defaultLeafValue: { type: Array, optional: true },
initialValue: { type: String, optional: true },
onSelected: { type: Function, optional: true },
};
DomainSelectorDialog.defaultProps = {
initialValue: "",
onSelected: () => {},
readonly: true,
isDebugMode: false,
};

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainSelectorDialog" owl="1">
<Dialog title="dialogTitle">
<DomainSelector t-props="domainSelectorProps" />
<t t-set-slot="footer">
<t t-if="props.readonly">
<button class="btn btn-secondary" t-on-click="() => props.close()">Close</button>
</t>
<t t-else="">
<button class="btn btn-primary" t-on-click="onSave">Save</button>
<button class="btn btn-secondary" t-on-click="onDiscard">Discard</button>
</t>
</t>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,367 @@
/** @odoo-module **/
import { useBus, useService } from "@web/core/utils/hooks";
import { usePosition } from "../position_hook";
import { useDropdownNavigation } from "./dropdown_navigation_hook";
import { localization } from "../l10n/localization";
import {
Component,
EventBus,
onWillStart,
status,
useEffect,
useExternalListener,
useRef,
useState,
useChildSubEnv,
} from "@odoo/owl";
const DIRECTION_CARET_CLASS = {
bottom: "dropdown",
top: "dropup",
left: "dropstart",
right: "dropend",
};
export const DROPDOWN = Symbol("Dropdown");
/**
* @typedef DropdownState
* @property {boolean} open
* @property {boolean} groupIsOpen
*/
/**
* @typedef DropdownStateChangedPayload
* @property {Dropdown} emitter
* @property {DropdownState} newState
*/
/**
* @extends Component
*/
export class Dropdown extends Component {
setup() {
this.state = useState({
open: this.props.startOpen,
groupIsOpen: this.props.startOpen,
});
this.rootRef = useRef("root");
// Set up beforeOpen ---------------------------------------------------
onWillStart(() => {
if (this.state.open && this.props.beforeOpen) {
return this.props.beforeOpen();
}
});
// Set up dynamic open/close behaviours --------------------------------
if (!this.props.manualOnly) {
// Close on outside click listener
useExternalListener(window, "click", this.onWindowClicked, { capture: true });
// Listen to all dropdowns state changes
useBus(Dropdown.bus, "state-changed", ({ detail }) =>
this.onDropdownStateChanged(detail)
);
}
// Set up UI active element related behavior ---------------------------
this.ui = useService("ui");
useEffect(
() => {
Promise.resolve().then(() => {
this.myActiveEl = this.ui.activeElement;
});
},
() => []
);
// Set up nested dropdowns ---------------------------------------------
this.parentDropdown = this.env[DROPDOWN];
useChildSubEnv({
[DROPDOWN]: {
close: this.close.bind(this),
closeAllParents: () => {
this.close();
if (this.parentDropdown) {
this.parentDropdown.closeAllParents();
}
},
},
});
// Set up key navigation -----------------------------------------------
useDropdownNavigation();
// Set up toggler and positioning --------------------------------------
/** @type {string} **/
const position =
this.props.position || (this.parentDropdown ? "right-start" : "bottom-start");
let [direction] = position.split("-");
if (["left", "right"].includes(direction) && localization.direction === "rtl") {
direction = direction === "left" ? "right" : "left";
}
const positioningOptions = {
popper: "menuRef",
position,
};
this.directionCaretClass = DIRECTION_CARET_CLASS[direction];
this.togglerRef = useRef("togglerRef");
if (this.props.toggler === "parent") {
// Add parent click listener to handle toggling
useEffect(
() => {
const onClick = (ev) => {
if (this.rootRef.el.contains(ev.target)) {
// ignore clicks inside the dropdown
return;
}
this.toggle();
};
if (this.rootRef.el.parentElement.tabIndex === -1) {
// If the parent is not focusable, make it focusable programmatically.
// This code may look weird, but an element with a negative tabIndex is
// focusable programmatically ONLY if its tabIndex is explicitly set.
this.rootRef.el.parentElement.tabIndex = -1;
}
this.rootRef.el.parentElement.addEventListener("click", onClick);
return () => {
this.rootRef.el.parentElement.removeEventListener("click", onClick);
};
},
() => []
);
useEffect(
(open) => {
this.rootRef.el.parentElement.ariaExpanded = open ? "true" : "false";
},
() => [this.state.open]
);
// Position menu relatively to parent element
usePosition(() => this.rootRef.el.parentElement, positioningOptions);
} else {
// Position menu relatively to inner toggler
const togglerRef = useRef("togglerRef");
usePosition(() => togglerRef.el, positioningOptions);
}
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
* Changes the dropdown state and notifies over the Dropdown bus.
*
* All state changes must trigger on the bus, except when reacting to
* another dropdown state change.
*
* @see onDropdownStateChanged()
*
* @param {Partial<DropdownState>} stateSlice
*/
async changeStateAndNotify(stateSlice) {
if (stateSlice.open && this.props.beforeOpen) {
await this.props.beforeOpen();
if (status(this) === "destroyed") {
return;
}
}
// Update the state
Object.assign(this.state, stateSlice);
// Notify over the bus
/** @type DropdownStateChangedPayload */
const stateChangedPayload = {
emitter: this,
newState: { ...this.state },
};
Dropdown.bus.trigger("state-changed", stateChangedPayload);
}
/**
* Closes the dropdown.
*
* @returns {Promise<void>}
*/
close() {
return this.changeStateAndNotify({ open: false, groupIsOpen: false });
}
/**
* Opens the dropdown.
*
* @returns {Promise<void>}
*/
open() {
return this.changeStateAndNotify({ open: true, groupIsOpen: true });
}
/**
* Toggles the dropdown open state.
*
* @returns {Promise<void>}
*/
toggle() {
const toggled = !this.state.open;
return this.changeStateAndNotify({ open: toggled, groupIsOpen: toggled });
}
get showCaret() {
return this.props.showCaret === undefined ? this.parentDropdown : this.props.showCaret;
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
/**
* Dropdowns react to each other state changes through this method.
*
* All state changes must trigger on the bus, except when reacting to
* another dropdown state change.
*
* @see changeStateAndNotify()
*
* @param {DropdownStateChangedPayload} args
*/
onDropdownStateChanged(args) {
if (!this.rootRef.el || this.rootRef.el.contains(args.emitter.rootRef.el)) {
// Do not listen to events emitted by self or children
return;
}
// Emitted by direct siblings ?
if (args.emitter.rootRef.el.parentElement === this.rootRef.el.parentElement) {
// Sync the group status
this.state.groupIsOpen = args.newState.groupIsOpen;
// Another dropdown is now open ? Close myself without notifying siblings.
if (this.state.open && args.newState.open) {
this.state.open = false;
}
} else {
// Another dropdown is now open ? Close myself and notify the world (i.e. siblings).
if (this.state.open && args.newState.open) {
this.close();
}
}
}
/**
* Toggles the dropdown on its toggler click.
*/
onTogglerClick() {
this.toggle();
}
/**
* Opens the dropdown the mouse enters its toggler.
* NB: only if its siblings dropdown group is opened and if not a sub dropdown.
*/
onTogglerMouseEnter() {
if (this.state.groupIsOpen && !this.state.open) {
this.togglerRef.el.focus();
this.open();
}
}
/**
* Return true if both active element are same.
*/
isInActiveElement() {
return this.ui.activeElement === this.myActiveEl;
}
/**
* Used to close ourself on outside click.
*
* @param {MouseEvent} ev
*/
onWindowClicked(ev) {
// Return if already closed
if (!this.state.open) {
return;
}
// Return if it's a different ui active element
if (!this.isInActiveElement()) {
return;
}
if (ev.target.closest(".bootstrap-datetimepicker-widget")) {
return;
}
// Close if we clicked outside the dropdown, or outside the parent
// element if it is the toggler
const rootEl =
this.props.toggler === "parent" ? this.rootRef.el.parentElement : this.rootRef.el;
const gotClickedInside = rootEl.contains(ev.target);
if (!gotClickedInside) {
this.close();
}
}
}
Dropdown.bus = new EventBus();
Dropdown.props = {
class: {
type: String,
optional: true,
},
toggler: {
type: String,
optional: true,
validate: (prop) => ["parent"].includes(prop),
},
skipTogglerTabbing: {
type: Boolean,
optional: true,
},
startOpen: {
type: Boolean,
optional: true,
},
manualOnly: {
type: Boolean,
optional: true,
},
menuClass: {
type: String,
optional: true,
},
beforeOpen: {
type: Function,
optional: true,
},
togglerClass: {
type: String,
optional: true,
},
hotkey: {
type: String,
optional: true,
},
tooltip: {
type: String,
optional: true,
},
title: {
type: String,
optional: true,
},
position: {
type: String,
optional: true,
},
slots: {
type: Object,
optional: true,
},
showCaret: {
type: Boolean,
optional: true,
},
};
Dropdown.template = "web.Dropdown";

View file

@ -0,0 +1,67 @@
// = New Owl Dropdown specific rules
// -----------------------------------------------------------------------------
.o-dropdown {
&--menu {
// Makes sure the dropdown menus are already in a
// fixed position before the positioning computations begin.
position: fixed;
.dropdown-toggle:focus, .dropdown-item:focus {
background-color: transparent;
outline: none;
}
.dropdown-toggle.focus, .dropdown-item.focus {
background-color: $dropdown-link-hover-bg;
}
.dropdown-item:not(.disabled):not(:disabled) {
// Needed 'cause <DropdownItem> generate <span> html tags that for
// browsers are normally not clickable.
&, label {
cursor: pointer;
}
}
// Correctly align sub dropdowns items with its parent's
.o-dropdown > .o-dropdown--menu {
// the value comes from bootstrap's ".dropdown-menu" padding and border style
--o-dropdown--submenu-margin: calc(-.5rem - 1px);
margin-top: var(--o-dropdown--submenu-margin);
margin-bottom: var(--o-dropdown--submenu-margin);
}
}
/*!rtl:begin:ignore*/
&.dropup > .o-dropdown--menu,
&.dropdown > .o-dropdown--menu,
&.dropstart > .o-dropdown--menu,
&.dropend > .o-dropdown--menu {
// Also makes sure that left/right stuffs are not polluted
// by bootstrap rules processed by rtlcss
left: auto;
right: auto;
margin-left: 0;
margin-right: 0;
}
/*!rtl:end:ignore*/
&--no-caret {
> .dropdown-toggle {
&::before, &::after {
content: normal;
}
}
}
button.dropdown-toggle {
&.active, &:hover, &:focus, &:active {
outline: none;
box-shadow: none !important;
}
&.dropdown-item:not(.o_menu_item) {
&::after, &::before {
// Changes the placement of bootstrap dropdown carets for subdropdowns
@include o-position-absolute($right: 0, $top: 0);
transform: translate(-.6em, .6em) #{"/*rtl:translate(.6em, .6em) scaleX(-1)*/"};
}
}
}
}

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.Dropdown" owl="1">
<div
class="o-dropdown dropdown"
t-att-class="props.class"
t-attf-class="
{{ directionCaretClass || ''}}
{{ state.open ? 'show' : ''}}
{{ !showCaret ? 'o-dropdown--no-caret' : '' }}
"
t-ref="root"
>
<button
t-if="props.toggler !== 'parent'"
class="dropdown-toggle"
t-attf-class="
{{props.togglerClass || ''}}
{{parentDropdown ? 'dropdown-item' : ''}}
"
t-on-click.stop="onTogglerClick"
t-on-mouseenter="onTogglerMouseEnter"
t-att-title="props.title"
t-att-data-hotkey="props.hotkey"
t-att-data-tooltip="props.tooltip"
t-att-tabindex="props.skipTogglerTabbing ? -1 : 0"
t-att-aria-expanded="state.open ? 'true' : 'false'"
t-ref="togglerRef"
>
<t t-slot="toggler" />
</button>
<div
t-if="state.open"
class="o-dropdown--menu dropdown-menu d-block"
t-att-class="props.menuClass"
role="menu"
t-ref="menuRef"
>
<t t-slot="default" />
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,92 @@
/** @odoo-module **/
import { DROPDOWN } from "./dropdown";
import { Component } from "@odoo/owl";
/**
* @enum {string}
*/
const ParentClosingMode = {
None: "none",
ClosestParent: "closest",
AllParents: "all",
};
export class DropdownItem extends Component {
/**
* Tells the parent dropdown that an item was selected and closes the
* parent(s) dropdown according the parentClosingMode prop.
*
* @param {MouseEvent} ev
*/
onClick(ev) {
const { href, onSelected, parentClosingMode } = this.props;
if (href) {
ev.preventDefault();
}
if (onSelected) {
onSelected();
}
const dropdown = this.env[DROPDOWN];
if (!dropdown) {
return;
}
const { ClosestParent, AllParents } = ParentClosingMode;
switch (parentClosingMode) {
case ClosestParent:
dropdown.close();
break;
case AllParents:
dropdown.closeAllParents();
break;
}
}
get dataAttributes() {
const { dataset } = this.props;
if (this.props.dataset) {
const attributes = Object.entries(dataset).map(([key, value]) => {
return [`data-${key.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`)}`, value];
});
return Object.fromEntries(attributes);
}
return {};
}
}
DropdownItem.template = "web.DropdownItem";
DropdownItem.props = {
onSelected: {
type: Function,
optional: true,
},
class: {
type: [String, Object],
optional: true,
},
parentClosingMode: {
type: ParentClosingMode,
optional: true,
},
hotkey: {
type: String,
optional: true,
},
href: {
type: String,
optional: true,
},
slots: {
type: Object,
optional: true,
},
title: {
type: String,
optional: true,
},
dataset: {
type: Object,
optional: true,
},
};
DropdownItem.defaultProps = {
parentClosingMode: ParentClosingMode.AllParents,
};

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DropdownItem" owl="1">
<t
t-tag="props.href ? 'a' : 'span'"
t-att-href="props.href"
class="dropdown-item"
t-att-class="props.class"
role="menuitem"
t-on-click.stop="onClick"
t-att-title="props.title"
t-att-data-hotkey="props.hotkey"
t-att="dataAttributes"
tabindex="0"
>
<t t-slot="default" />
</t>
</t>
</templates>

View file

@ -0,0 +1,273 @@
/** @odoo-module */
import { useService } from "@web/core/utils/hooks";
import { browser } from "../browser/browser";
import { localization } from "@web/core/l10n/localization";
import { scrollTo } from "../utils/scrolling";
import { useComponent, useEffect, useRef } from "@odoo/owl";
/**
* @typedef {{
* el: HTMLElement,
* isActive: boolean,
* makeOnlyActive: ()=>void,
* navTarget: HTMLElement,
* isSubDropdown: boolean,
* isSubDropdownOpen: boolean,
* closeSubDropdown: ()=>void,
* openSubDropdown: (immediate?:boolean)=>void,
* }} MenuElement
*/
const ACTIVE_MENU_ELEMENT_CLASS = "focus";
const MENU_ELEMENTS_SELECTORS = [":scope > .dropdown-item", ":scope > .dropdown"];
const NEXT_ACTIVE_INDEX_FNS = {
FIRST: () => 0,
LAST: (list) => list.length - 1,
NEXT: (list, prevActiveIndex) => (prevActiveIndex + 1) % list.length,
PREV: (list, prevActiveIndex) => (prevActiveIndex <= 0 ? list.length : prevActiveIndex) - 1,
};
export function useDropdownNavigation() {
/** @type {import("./dropdown").Dropdown} */
const comp = useComponent();
// As this navigation hook relies on clicking ".dropdown-toggle" elements,
// it is incompatible with a toggler="parent" strategy for subdropdowns.
if (comp.parentDropdown && comp.props.toggler === "parent") {
throw new Error("A nested Dropdown must use its standard toggler");
}
// Needed to avoid unwanted mouseclick behavior on a subdropdown toggler.
const originalOnTogglerClick = comp.onTogglerClick.bind(comp);
comp.onTogglerClick = (ev) => {
if (comp.parentDropdown && !ev.__fromDropdownNavigation) {
return;
}
originalOnTogglerClick();
};
// Needed to avoid unwanted mouseenter behavior on a subdropdown toggler.
const originalOnTogglerMouseEnter = comp.onTogglerMouseEnter.bind(comp);
comp.onTogglerMouseEnter = () => {
if (comp.parentDropdown) {
return;
}
originalOnTogglerMouseEnter();
};
// Needed to avoid unwanted selection when the mouse pointer is not in use
// but still somewhere in the middle of the dropdown menu list.
let mouseSelectionActive = true;
// Set up menu elements logic ----------------------------------------------
const menuRef = useRef("menuRef");
/** @type {MenuElement[]} */
let menuElements = [];
useEffect(() => {
if (!comp.state.open) {
return;
}
// Prepare MenuElements
const addedListeners = [];
/** @type {NodeListOf<HTMLElement>} */
const queryResult = menuRef.el.querySelectorAll(MENU_ELEMENTS_SELECTORS.join());
for (const el of queryResult) {
const isSubDropdown = el.classList.contains("dropdown");
const isSubDropdownOpen = () => el.classList.contains("show");
const navTarget = isSubDropdown ? el.querySelector(":scope > .dropdown-toggle") : el;
let subDropdownTimeout;
const closeSubDropdown = () => {
browser.clearTimeout(subDropdownTimeout);
subDropdownTimeout = browser.setTimeout(() => {
if (isSubDropdownOpen()) {
const ev = new MouseEvent("click", { bubbles: false });
ev.__fromDropdownNavigation = true;
navTarget.dispatchEvent(ev);
}
}, 200);
};
const openSubDropdown = (immediate = false) => {
browser.clearTimeout(subDropdownTimeout);
subDropdownTimeout = browser.setTimeout(
() => {
if (!isSubDropdownOpen()) {
const ev = new MouseEvent("click", { bubbles: false });
ev.__fromDropdownNavigation = true;
navTarget.dispatchEvent(ev);
}
},
immediate ? 0 : 200
);
};
const makeOnlyActive = () => {
// Make all others inactive
for (const menuElement of menuElements) {
if (menuElement.el === el) {
continue;
}
menuElement.navTarget.classList.remove(ACTIVE_MENU_ELEMENT_CLASS);
if (menuElement.isSubDropdown) {
menuElement.closeSubDropdown();
}
}
// Make myself active
navTarget.classList.add(ACTIVE_MENU_ELEMENT_CLASS);
navTarget.focus();
};
/** @type {MenuElement} */
const menuElement = {
el,
get isActive() {
return navTarget.classList.contains(ACTIVE_MENU_ELEMENT_CLASS);
},
makeOnlyActive,
navTarget,
get isSubDropdownOpen() {
return isSubDropdownOpen();
},
isSubDropdown,
closeSubDropdown,
openSubDropdown,
};
menuElements.push(menuElement);
// Set up selection listeners
const elementListeners = {
mouseenter: () => {
if (!mouseSelectionActive) {
mouseSelectionActive = true;
} else {
makeOnlyActive();
if (isSubDropdown) {
openSubDropdown();
}
}
},
};
for (const [eventType, listener] of Object.entries(elementListeners)) {
navTarget.addEventListener(eventType, listener);
}
addedListeners.push([navTarget, elementListeners]);
}
return () => {
menuElements = [];
mouseSelectionActive = true;
// Clear mouse selection listeners
for (const [navTarget, listeners] of addedListeners) {
for (const [eventType, listener] of Object.entries(listeners)) {
navTarget.removeEventListener(eventType, listener);
}
}
};
});
// Set up active menu element helpers --------------------------------------
/**
* @returns {MenuElement|undefined}
*/
const getActiveMenuElement = () => {
return menuElements.find((menuElement) => menuElement.isActive);
};
/**
* @param {MenuElement|keyof NEXT_ACTIVE_INDEX_FNS} menuElement
*/
const setActiveMenuElement = (menuElement) => {
if (menuElements.length) {
if (typeof menuElement === "string") {
const prevIndex = menuElements.indexOf(getActiveMenuElement());
const nextIndex = NEXT_ACTIVE_INDEX_FNS[menuElement](menuElements, prevIndex);
menuElement = menuElements[nextIndex];
}
menuElement.makeOnlyActive();
scrollTo(menuElement.el, { scrollable: menuElement.el.parentElement });
}
};
// Set up nested dropdowns - active first menu element behavior ------------
useEffect(
(open) => {
// If we just opened and we are a subdropdown, make active our first menu element.
if (open && comp.parentDropdown) {
setActiveMenuElement("FIRST");
}
},
() => [comp.state.open]
);
// Set up keyboard navigation ----------------------------------------------
const hotkeyService = useService("hotkey");
const closeAndRefocus = () => {
const toFocus =
comp.props.toggler === "parent"
? comp.rootRef.el.parentElement
: comp.rootRef.el.querySelector(":scope > .dropdown-toggle");
comp.close().then(() => {
toFocus.focus();
});
};
const closeSubDropdown = comp.parentDropdown ? closeAndRefocus : () => {};
const openSubDropdown = () => {
const menuElement = getActiveMenuElement();
// Active menu element is a sub dropdown
if (menuElement && menuElement.isSubDropdown) {
menuElement.openSubDropdown(true);
}
};
const selectActiveMenuElement = () => {
const menuElement = getActiveMenuElement();
if (menuElement) {
if (menuElement.isSubDropdown) {
menuElement.openSubDropdown(true);
} else {
menuElement.navTarget.click();
}
}
};
let hotkeyRemoves = [];
const hotkeyCallbacks = {
home: () => setActiveMenuElement("FIRST"),
end: () => setActiveMenuElement("LAST"),
tab: () => setActiveMenuElement("NEXT"),
"shift+tab": () => setActiveMenuElement("PREV"),
arrowdown: () => setActiveMenuElement("NEXT"),
arrowup: () => setActiveMenuElement("PREV"),
arrowleft: localization.direction === "rtl" ? openSubDropdown : closeSubDropdown,
arrowright: localization.direction === "rtl" ? closeSubDropdown : openSubDropdown,
enter: selectActiveMenuElement,
escape: closeAndRefocus,
};
useEffect(
(open) => {
if (!open) {
return;
}
// Subscribe keynav
for (const [hotkey, callback] of Object.entries(hotkeyCallbacks)) {
const callbackWrapper = () => {
const hasOpenedSubDropdown = menuElements.some((m) => m.isSubDropdownOpen);
// Leave priority to last opened sub dropdown
if (!hasOpenedSubDropdown) {
mouseSelectionActive = false;
callback.call(comp);
}
};
hotkeyRemoves.push(
hotkeyService.add(hotkey, callbackWrapper, { allowRepeat: true })
);
}
return () => {
// Unsubscribe keynav
for (const removeHotkey of hotkeyRemoves) {
removeHotkey();
}
hotkeyRemoves = [];
};
},
() => [comp.state.open]
);
}

View file

@ -0,0 +1,27 @@
/** @odoo-module **/
import { Component, xml, onWillDestroy } from "@odoo/owl";
export class EffectContainer extends Component {
setup() {
this.effect = null;
const listenerRef = this.props.bus.addEventListener("UPDATE", (ev) => {
this.effect = ev.detail;
this.render();
});
onWillDestroy(() => {
this.props.bus.removeEventListener("UPDATE", listenerRef);
});
}
removeEffect() {
this.effect = null;
this.render();
}
}
EffectContainer.template = xml`
<div class="o_effects_manager">
<t t-if="effect">
<t t-component="effect.Component" t-props="effect.props" t-key="effect.id" close="() => this.removeEffect()"/>
</t>
</div>`;

View file

@ -0,0 +1,98 @@
/** @odoo-module **/
import { registry } from "../registry";
import { EffectContainer } from "./effect_container";
import { RainbowMan } from "./rainbow_man";
import { EventBus } from "@odoo/owl";
const effectRegistry = registry.category("effects");
// -----------------------------------------------------------------------------
// RainbowMan effect
// -----------------------------------------------------------------------------
/**
* Handles effect of type "rainbow_man". If the effects aren't disabled, returns
* the RainbowMan component to instantiate and its props. If the effects are
* disabled, displays the message in a notification.
*
* @param {Object} env
* @param {Object} [params={}]
* @param {string} [params.message="Well Done!"]
* The message in the notice the rainbowman holds or the content of the notification if effects are disabled
* Can be a simple a string
* Can be a string representation of html (prefer component if you want interactions in the DOM)
* @param {string} [params.img_url="/web/static/img/smile.svg"]
* The url of the image to display inside the rainbow
* @param {"slow"|"medium"|"fast"|"no"} [params.fadeout="medium"]
* Delay for rainbowman to disappear
* 'fast' will make rainbowman dissapear quickly
* 'medium' and 'slow' will wait little longer before disappearing (can be used when options.message is longer)
* 'no' will keep rainbowman on screen until user clicks anywhere outside rainbowman
* @param {Component} [params.Component]
* Custom Component class to instantiate inside the Rainbow Man
* @param {Object} [params.props]
* If params.Component is given, its props can be passed with this argument
*/
function rainbowMan(env, params = {}) {
let message = params.message;
if (message instanceof jQuery) {
console.log(
"Providing a jQuery element to an effect is deprecated. Note that all event handlers will be lost."
);
message = message.html();
} else if (message instanceof Element) {
console.log(
"Providing an HTML element to an effect is deprecated. Note that all event handlers will be lost."
);
message = message.outerHTML;
} else if (!message) {
message = env._t("Well Done!");
}
if (env.services.user.showEffect) {
/** @type {import("./rainbow_man").RainbowManProps} */
const props = {
imgUrl: params.img_url || "/web/static/img/smile.svg",
fadeout: params.fadeout || "medium",
message,
Component: params.Component,
props: params.props,
};
return { Component: RainbowMan, props };
}
env.services.notification.add(message);
}
effectRegistry.add("rainbow_man", rainbowMan);
// -----------------------------------------------------------------------------
// Effect service
// -----------------------------------------------------------------------------
export const effectService = {
start(env) {
const bus = new EventBus();
registry.category("main_components").add("EffectContainer", {
Component: EffectContainer,
props: { bus },
});
let effectId = 0;
/**
* @param {Object} [params] various params depending on the type of effect
* @param {string} [params.type="rainbow_man"] the effect to display
*/
function add(params = {}) {
const type = params.type || "rainbow_man";
const effect = effectRegistry.get(type);
const { Component, props } = effect(env, params) || {};
if (Component) {
bus.trigger("UPDATE", { Component, props, id: effectId++ });
}
}
return { add };
},
};
registry.category("services").add("effect", effectService);

View file

@ -0,0 +1,66 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { Component, useEffect, useExternalListener, useState } from "@odoo/owl";
/**
* @typedef Common
* @property {string} [fadeout='medium'] Delay for rainbowman to disappear.
* - 'fast' will make rainbowman dissapear quickly,
* - 'medium' and 'slow' will wait little longer before disappearing
* (can be used when props.message is longer),
* - 'no' will keep rainbowman on screen until user clicks anywhere outside rainbowman
* @property {string} [imgUrl] URL of the image to be displayed
*
* @typedef Simple
* @property {string} message Message to be displayed on rainbowman card
*
* @typedef Custom
* @property {Component} Component
* @property {any} [props]
*
* @typedef {Common & (Simple | Custom)} RainbowManProps
*/
/**
* The RainbowMan widget is the widget displayed by default as a 'fun/rewarding'
* effect in some cases. For example, when the user marked a large deal as won,
* or when he cleared its inbox.
*
* This widget is mostly a picture and a message with a rainbow animation around
* If you want to display a RainbowMan, you probably do not want to do it by
* importing this file. The usual way to do that would be to use the effect
* service (by triggering the 'show_effect' event)
*/
export class RainbowMan extends Component {
setup() {
useExternalListener(document.body, "click", this.closeRainbowMan);
this.state = useState({ isFading: false });
this.delay = RainbowMan.rainbowFadeouts[this.props.fadeout];
if (this.delay) {
useEffect(
() => {
const timeout = browser.setTimeout(() => {
this.state.isFading = true;
}, this.delay);
return () => browser.clearTimeout(timeout);
},
() => []
);
}
}
onAnimationEnd(ev) {
if (this.delay && ev.animationName === "reward-fading-reverse") {
ev.stopPropagation();
this.closeRainbowMan();
}
}
closeRainbowMan() {
this.props.close();
}
}
RainbowMan.template = "web.RainbowMan";
RainbowMan.rainbowFadeouts = { slow: 4500, medium: 3500, fast: 2000, no: false };

View file

@ -0,0 +1,146 @@
.o_reward {
$-reward-base-time: 1.4s;
will-change: transform;
z-index: $zindex-modal;
animation: reward-fading $-reward-base-time * 0.5 ease-in-out forwards;
.o_reward_box {
transform-box: fill-box;
}
&.o_reward_fading {
animation: reward-fading-reverse $-reward-base-time * 0.4 ease-in-out forwards;
.o_reward_face_group {
animation: reward-jump-reverse $-reward-base-time * 0.4 ease-in-out forwards;
}
.o_reward_rainbow_line {
animation: reward-rainbow-reverse $-reward-base-time * 0.5 ease-out forwards;
}
}
.o_reward_rainbow_man {
max-width: 400px;
}
.o_reward_rainbow_line {
animation: reward-rainbow $-reward-base-time * 0.8 ease-out 1 forwards;
}
.o_reward_face_group {
animation: reward-jump $-reward-base-time * 0.8 ease-in-out 1;
}
.o_reward_face_wrap {
animation: reward-rotate $-reward-base-time * 0.8 cubic-bezier(.51,.92,.24,1.15) 1;
}
.o_reward_face {
animation: reward-float $-reward-base-time ease-in-out $-reward-base-time infinite alternate;
}
.o_reward_star_01, .o_reward_star_03 {
animation: reward-stars $-reward-base-time ease-in-out infinite alternate-reverse;
}
.o_reward_star_02, .o_reward_star_04 {
animation: reward-stars $-reward-base-time * 1.2 ease-in-out infinite alternate;
}
.o_reward_thumbup {
animation: reward-scale $-reward-base-time * 0.5 ease-in-out 0s infinite alternate;
}
.o_reward_shadow_container {
animation: reward-float $-reward-base-time ease-in-out infinite alternate;
}
.o_reward_shadow {
animation: reward-scale $-reward-base-time ease-in-out infinite alternate;
}
.o_reward_msg_container {
aspect-ratio: 1 / 1;
animation: reward-float-reverse $-reward-base-time ease-in-out infinite alternate-reverse;
}
}
@keyframes reward-fading {
0% {
opacity: 0;
}
}
@keyframes reward-fading-reverse {
100% {
opacity: 0;
}
}
@keyframes reward-jump {
0% {
transform: scale(0.5);
}
50% {
transform: scale(1.05);
}
}
@keyframes reward-jump-reverse {
50% {
transform: scale(1.05);
}
to {
transform: scale(0.5);
}
}
@keyframes reward-rainbow {
to {
stroke-dashoffset: 0;
}
}
@keyframes reward-rainbow-reverse {
from {
stroke-dashoffset: 0;
}
}
@keyframes reward-float {
to {
transform: translateY(5px);
}
}
@keyframes reward-float-reverse {
from {
transform: translateY(5px);
}
}
@keyframes reward-stars {
from {
transform: scale(0.3) rotate(0deg);
}
50% {
transform: scale(1) rotate(20deg);
}
to {
transform: scale(0.3) rotate(80deg);
}
}
@keyframes reward-scale {
from {
transform: scale(.8);
}
}
@keyframes reward-rotate {
from {
transform: scale(.5) rotate(-30deg);
}
}

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.RainbowMan" owl="1">
<div class="o_reward position-absolute top-0 bottom-0 start-0 end-0 w-100 h-100" t-att-class="{ o_reward_fading: state.isFading }" t-on-animationend="onAnimationEnd">
<svg class="o_reward_rainbow_man position-absolute top-0 bottom-0 start-0 end-0 m-auto overflow-visible" viewBox="0 0 400 400">
<defs>
<radialGradient id="o_reward_gradient_bg" cx="200" cy="200" r="200" gradientUnits="userSpaceOnUse">
<stop offset="0.3" stop-color="#edeff4"/>
<stop offset="1" stop-color="#edeff4" stop-opacity="0"/>
</radialGradient>
<symbol id="o_reward_star">
<path d="M33 15.9C26.3558 13.6951 21.1575 8.4597 19 1.8 19 1.2477 18.5523.8 18 .8 17.4477.8 17 1.2477 17 1.8 14.6431 8.6938 9.0262 13.9736 2 15.9 1.3649 15.9.85 16.4149.85 17.05.85 17.6851 1.3649 18.2 2 18.2 8.6215 20.3845 13.8155 25.5785 16 32.2 16 32.7523 16.4477 33.2 17 33.2 17.5523 33.2 18 32.7523 18 32.2 20.3569 25.3062 25.9738 20.0264 33 18.1 33.6351 18.1 34.15 17.5851 34.15 16.95 34.15 16.3149 33.6351 15.8 33 15.8" fill="#FFFFFF"/>
</symbol>
<symbol id="o_reward_thumb">
<path d="M10 52C6 51 3 48 3 44 2 42 3 39 5 38 3 36 2 34 2 32 2 29 3 27 5 26 3 24 2 21 2 19 2 15 7 12 10 12L23 12C23 11 23 11 23 11L23 10C23 8 24 6 25 4 27 2 29 2 31 2 33 2 35 2 36 4 38 5 39 7 39 10L39 38C39 41 37 45 35 47 32 50 28 51 25 52L10 52 10 52Z" fill="#FBFBFC"/>
<polygon fill="#ECF1FF" points="25 11 25 51 5 52 5 12"/>
<path d="M31 0C28 0 26 1 24 3 22 5 21 7 21 10L10 10C8 10 6 11 4 12 2 14 1 16 1 19 1 21 1 24 2 26 1 27 1 29 1 32 1 34 1 36 2 38 1 40 0 42 1 45 1 50 5 53 10 54L25 54C29 54 33 52 36 49 39 46 41 42 41 38L41 10C41 4 36 0 31 0M31 4C34 4 37 6 37 10L37 38C37 41 35 44 33 46 31 48 28 49 25 50L10 50C7 49 5 47 5 44 4 41 6 38 9 37L9 37C6 37 5 35 5 32 5 28 6 26 9 26L9 26C6 26 5 22 5 19 5 16 8 14 11 14L23 14C24 14 25 12 25 11L25 10C25 7 28 4 31 4" fill="#A1ACBA"/>
</symbol>
</defs>
<rect width="400" height="400" fill="url(#o_reward_gradient_bg)"/>
<g transform="translate(47 45) scale(0.9)" class="o_reward_rainbow">
<path d="M270,170a100,100,0,0,0-200,0" class="o_reward_rainbow_line" stroke="#FF9E80" stroke-linecap="round" stroke-width="21" fill="none" stroke-dasharray="600 600" stroke-dashoffset="-600"/>
<path d="M290,170a120,120,0,0,0-240,0" class="o_reward_rainbow_line" stroke="#FFE57F" stroke-linecap="round" stroke-width="21" fill="none" stroke-dasharray="600 600" stroke-dashoffset="-600"/>
<path d="M310,170a140,140,0,0,0-280,0" class="o_reward_rainbow_line" stroke="#80D8FF" stroke-linecap="round" stroke-width="21" fill="none" stroke-dasharray="600 600" stroke-dashoffset="-600"/>
<path d="M330,170a160,160,0,0,0-320,0" class="o_reward_rainbow_line" stroke="#C794BA" stroke-linecap="round" stroke-width="21" fill="none" stroke-dasharray="600 600" stroke-dashoffset="-600"/>
</g>
<g transform="translate(80 125)">
<use href="#o_reward_star" transform-origin="center" class="o_reward_box o_reward_star_01"/>
</g>
<g transform="translate(140 75)">
<use href="#o_reward_star" transform-origin="center" class="o_reward_box o_reward_star_02"/>
</g>
<g transform="translate(230 90)">
<use href="#o_reward_star" transform-origin="center" class="o_reward_box o_reward_star_03"/>
</g>
<g transform="translate(275 120)">
<use href="#o_reward_star" transform-origin="center" class="o_reward_box o_reward_star_04"/>
</g>
<g class="o_reward_face_group o_reward_box" transform-origin="center top">
<g class="o_reward_shadow_container o_reward_box">
<ellipse class="o_reward_shadow o_reward_box" cx="200" cy="105%" rx="100" ry="6" fill="#000" opacity="0.25" transform-origin="center"/>
</g>
<g class="o_reward_face_wrap o_reward_box" transform-origin="center">
<image class="o_reward_face" x="132" y="125" width="136" height="136" t-attf-href="{{props.imgUrl}}"/>
</g>
<g transform="translate(258 174)">
<use href="#o_reward_thumb" class="o_reward_box o_reward_thumbup" transform-origin="center"/>
</g>
</g>
</svg>
<div class="o_reward_rainbow_man o_reward_msg_container position-absolute top-0 bottom-0 start-0 end-0 m-auto">
<div class="o_reward_face_group h-100 w-75 mx-auto">
<svg viewBox="0 0 42 60" preserveAspectRatio="xMinYMax meet" width="37" height="65%" class="overflow-visible position-relative ms-5">
<g class="o_reward_box">
<use href="#o_reward_thumb" x="-60%" y="0" transform="rotate(-90) scale(1 -1)" transform-origin="center"/>
</g>
</svg>
<div class="o_reward_msg mx-4">
<div class="o_reward_msg_card">
<div class="o_reward_msg_content text-muted px-3 py-4 bg-white d-inline-block border border-light border-top-0">
<t t-if="!props.Component">
<t t-out="props.message"/>
</t>
<t t-else="" t-component="props.Component" t-props="props.props"/>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show more