mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-19 15:12:01 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
444
odoo-bringout-oca-ocb-web/web/static/src/boot.js
Normal file
444
odoo-bringout-oca-ocb-web/web/static/src/boot.js
Normal 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));
|
||||
}
|
||||
})();
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
284
odoo-bringout-oca-ocb-web/web/static/src/core/assets.js
Normal file
284
odoo-bringout-oca-ocb-web/web/static/src/core/assets.js
Normal 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 },
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
.o-autocomplete {
|
||||
.o-autocomplete--input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
102
odoo-bringout-oca-ocb-web/web/static/src/core/browser/browser.js
Normal file
102
odoo-bringout-oca-ocb-web/web/static/src/core/browser/browser.js
Normal 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 "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o-checkbox {
|
||||
width: fit-content;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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),
|
||||
() => []
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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"),
|
||||
};
|
||||
|
|
@ -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>
|
||||
29
odoo-bringout-oca-ocb-web/web/static/src/core/context.js
Normal file
29
odoo-bringout-oca-ocb-web/web/static/src/core/context.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
() => []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.o_dialog {
|
||||
.o_debug_manager .dropdown-toggle {
|
||||
padding: 0 4px;
|
||||
margin: 2px 10px 2px 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
class ProfilingSystrayItem extends Component {}
|
||||
ProfilingSystrayItem.template = "web.ProfilingSystrayItem";
|
||||
|
||||
export const profilingSystrayItem = {
|
||||
Component: ProfilingSystrayItem,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
`;
|
||||
|
|
@ -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);
|
||||
304
odoo-bringout-oca-ocb-web/web/static/src/core/domain.js
Normal file
304
odoo-bringout-oca-ocb-web/web/static/src/core/domain.js
Normal 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());
|
||||
}
|
||||
|
|
@ -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],
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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 === '&'">all</t>
|
||||
<t t-elif="props.node.operator === '|'">any</t>
|
||||
<t t-elif="props.node.operator === '!'">none</t>
|
||||
</t>
|
||||
<DropdownItem onSelected="() => this.onOperatorSelected('&')">all</DropdownItem>
|
||||
<DropdownItem onSelected="() => this.onOperatorSelected('|')">any</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<strong>
|
||||
<t t-if="props.node.operator === '&'">all</t>
|
||||
<t t-elif="props.node.operator === '|'">any</t>
|
||||
<t t-elif="props.node.operator === '!'">none</t>
|
||||
</strong>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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";
|
||||
|
|
@ -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)*/"};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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>`;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue