Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
225
frontend/web/static/src/core/action_swiper/action_swiper.js
Normal file
225
frontend/web/static/src/core/action_swiper/action_swiper.js
Normal file
@@ -0,0 +1,225 @@
|
||||
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";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
|
||||
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 {
|
||||
static template = "web.ActionSwiper";
|
||||
static 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 },
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onLeftSwipe: undefined,
|
||||
onRightSwipe: undefined,
|
||||
animationOnMove: true,
|
||||
animationType: "bounce",
|
||||
swipeDistanceRatio: 2,
|
||||
};
|
||||
|
||||
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(Promise.resolve());
|
||||
this._reset();
|
||||
}, 500);
|
||||
} else if (this.props.animationType === "forwards") {
|
||||
this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`;
|
||||
this.actionTimeoutId = browser.setTimeout(async () => {
|
||||
const prom = new Deferred();
|
||||
await action(prom);
|
||||
this.state.isSwiping = true;
|
||||
this.state.containerStyle = `transform: translateX(${-this.swipedDistance}px)`;
|
||||
this.resetTimeoutId = browser.setTimeout(() => {
|
||||
prom.resolve();
|
||||
this._reset();
|
||||
}, 100);
|
||||
}, 100);
|
||||
} else {
|
||||
return action(Promise.resolve());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
27
frontend/web/static/src/core/action_swiper/action_swiper.xml
Normal file
27
frontend/web/static/src/core/action_swiper/action_swiper.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.ActionSwiper">
|
||||
<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>
|
||||
9
frontend/web/static/src/core/anchor_scroll_prevention.js
Normal file
9
frontend/web/static/src/core/anchor_scroll_prevention.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { browser } from "./browser/browser";
|
||||
|
||||
browser.addEventListener("click", (ev) => {
|
||||
const href = ev.target.closest("a")?.getAttribute("href");
|
||||
if (href && href === "#") {
|
||||
ev.preventDefault(); // single hash in href are just a way to activate A-tags node
|
||||
return;
|
||||
}
|
||||
});
|
||||
262
frontend/web/static/src/core/assets.js
Normal file
262
frontend/web/static/src/core/assets.js
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Component, onWillStart, whenReady, xml } from "@odoo/owl";
|
||||
import { session } from "@web/session";
|
||||
import { registry } from "./registry";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* cssLibs: string[];
|
||||
* jsLibs: string[];
|
||||
* }} BundleFileNames
|
||||
*/
|
||||
|
||||
export const globalBundleCache = new Map();
|
||||
export const assetCacheByDocument = new WeakMap();
|
||||
|
||||
function getGlobalBundleCache() {
|
||||
return globalBundleCache;
|
||||
}
|
||||
|
||||
function getAssetCache(targetDoc) {
|
||||
if (!assetCacheByDocument.has(targetDoc)) {
|
||||
assetCacheByDocument.set(targetDoc, new Map());
|
||||
}
|
||||
return assetCacheByDocument.get(targetDoc);
|
||||
}
|
||||
|
||||
export function computeBundleCacheMap(targetDoc) {
|
||||
const cacheMap = getGlobalBundleCache();
|
||||
for (const script of targetDoc.head.querySelectorAll("script[src]")) {
|
||||
cacheMap.set(script.getAttribute("src"), Promise.resolve());
|
||||
}
|
||||
for (const link of targetDoc.head.querySelectorAll("link[rel=stylesheet][href]")) {
|
||||
cacheMap.set(link.getAttribute("href"), Promise.resolve());
|
||||
}
|
||||
}
|
||||
|
||||
whenReady(() => computeBundleCacheMap(document));
|
||||
|
||||
/**
|
||||
* @param {HTMLLinkElement | HTMLScriptElement} el
|
||||
* @param {(event: Event) => any} onLoad
|
||||
* @param {(error: Error) => any} onError
|
||||
*/
|
||||
const onLoadAndError = (el, onLoad, onError) => {
|
||||
const onLoadListener = (event) => {
|
||||
removeListeners();
|
||||
onLoad(event);
|
||||
};
|
||||
|
||||
const onErrorListener = (error) => {
|
||||
removeListeners();
|
||||
onError(error);
|
||||
};
|
||||
|
||||
const removeListeners = () => {
|
||||
el.removeEventListener("load", onLoadListener);
|
||||
el.removeEventListener("error", onErrorListener);
|
||||
};
|
||||
|
||||
el.addEventListener("load", onLoadListener);
|
||||
el.addEventListener("error", onErrorListener);
|
||||
|
||||
window.addEventListener("pagehide", () => {
|
||||
removeListeners();
|
||||
});
|
||||
};
|
||||
|
||||
/** @type {typeof assets["getBundle"]} */
|
||||
export function getBundle() {
|
||||
return assets.getBundle(...arguments);
|
||||
}
|
||||
|
||||
/** @type {typeof assets["loadBundle"]} */
|
||||
export function loadBundle() {
|
||||
return assets.loadBundle(...arguments);
|
||||
}
|
||||
|
||||
/** @type {typeof assets["loadJS"]} */
|
||||
export function loadJS() {
|
||||
return assets.loadJS(...arguments);
|
||||
}
|
||||
|
||||
/** @type {typeof assets["loadCSS"]} */
|
||||
export function loadCSS() {
|
||||
return assets.loadCSS(...arguments);
|
||||
}
|
||||
|
||||
export class AssetsLoadingError extends Error {}
|
||||
|
||||
/**
|
||||
* Utility component that loads an asset bundle before instanciating a component
|
||||
*/
|
||||
export class LazyComponent extends Component {
|
||||
static template = xml`<t t-component="Component" t-props="componentProps"/>`;
|
||||
static props = {
|
||||
Component: String,
|
||||
bundle: String,
|
||||
props: { type: [Object, Function], optional: true },
|
||||
};
|
||||
setup() {
|
||||
onWillStart(async () => {
|
||||
await loadBundle(this.props.bundle);
|
||||
this.Component = registry.category("lazy_components").get(this.props.Component);
|
||||
});
|
||||
}
|
||||
|
||||
get componentProps() {
|
||||
return typeof this.props.props === "function" ? this.props.props() : this.props.props;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
|
||||
/**
|
||||
* 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<BundleFileNames>}
|
||||
*/
|
||||
getBundle(bundleName) {
|
||||
const cacheMap = getGlobalBundleCache();
|
||||
if (cacheMap.has(bundleName)) {
|
||||
return cacheMap.get(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 promise = fetch(url)
|
||||
.then(async (response) => {
|
||||
const cssLibs = [];
|
||||
const jsLibs = [];
|
||||
if (!response.bodyUsed) {
|
||||
const result = await response.json();
|
||||
for (const { src, type } of Object.values(result)) {
|
||||
if (type === "link" && src) {
|
||||
cssLibs.push(src);
|
||||
} else if (type === "script" && src) {
|
||||
jsLibs.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { cssLibs, jsLibs };
|
||||
})
|
||||
.catch((reason) => {
|
||||
cacheMap.delete(bundleName);
|
||||
throw new AssetsLoadingError(`The loading of ${url} failed`, { cause: reason });
|
||||
});
|
||||
cacheMap.set(bundleName, promise);
|
||||
return promise;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 {string} bundleName
|
||||
* @param {Object} options
|
||||
* @param {Document} [options.targetDoc=document] document to which the bundle will be applied (e.g. iframe document)
|
||||
* @param {Boolean} [options.css=true] apply bundle css on targetDoc
|
||||
* @param {Boolean} [options.js=true] apply bundle js on targetDoc
|
||||
* @returns {Promise<void[]>}
|
||||
*/
|
||||
loadBundle(bundleName, { targetDoc = document, css = true, js = true } = {}) {
|
||||
if (typeof bundleName !== "string") {
|
||||
throw new Error(
|
||||
`loadBundle(bundleName:string) accepts only bundleName argument as a string ! Not ${JSON.stringify(
|
||||
bundleName
|
||||
)} as ${typeof bundleName}`
|
||||
);
|
||||
}
|
||||
return getBundle(bundleName).then(({ cssLibs, jsLibs }) => {
|
||||
const promises = [];
|
||||
if (css && cssLibs) {
|
||||
promises.push(...cssLibs.map((url) => assets.loadCSS(url, { targetDoc })));
|
||||
}
|
||||
if (js && jsLibs) {
|
||||
promises.push(...jsLibs.map((url) => assets.loadJS(url, { targetDoc })));
|
||||
}
|
||||
return Promise.all(promises);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the given url as a stylesheet.
|
||||
*
|
||||
* @param {string} url the url of the stylesheet
|
||||
* @param {number} [retryCount]
|
||||
* @param {Object} options
|
||||
* @param {number} [retryCount]
|
||||
* @param {Document} [options.targetDoc=document] document to which the bundle will be applied (e.g. iframe document)
|
||||
* @returns {Promise<void>} resolved when the stylesheet has been loaded
|
||||
*/
|
||||
loadCSS(url, { retryCount = 0, targetDoc = document } = {}) {
|
||||
const cacheMap = getAssetCache(targetDoc);
|
||||
if (cacheMap.has(url)) {
|
||||
return cacheMap.get(url);
|
||||
}
|
||||
const linkEl = targetDoc.createElement("link");
|
||||
linkEl.setAttribute("href", url);
|
||||
linkEl.type = "text/css";
|
||||
linkEl.rel = "stylesheet";
|
||||
const promise = new Promise((resolve, reject) =>
|
||||
onLoadAndError(linkEl, resolve, async (error) => {
|
||||
cacheMap.delete(url);
|
||||
if (retryCount < assets.retries.count) {
|
||||
const delay = assets.retries.delay + assets.retries.extraDelay * retryCount;
|
||||
await new Promise((res) => setTimeout(res, delay));
|
||||
linkEl.remove();
|
||||
loadCSS(url, { retryCount: retryCount + 1, targetDoc })
|
||||
.then(resolve)
|
||||
.catch((reason) => {
|
||||
cacheMap.delete(url);
|
||||
reject(reason);
|
||||
});
|
||||
} else {
|
||||
reject(
|
||||
new AssetsLoadingError(`The loading of ${url} failed`, { cause: error })
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
cacheMap.set(url, promise);
|
||||
targetDoc.head.appendChild(linkEl);
|
||||
return promise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the given url inside a script tag.
|
||||
*
|
||||
* @param {string} url the url of the script
|
||||
* @param {Document} targetDoc document to which the bundle will be applied (e.g. iframe document)
|
||||
* @returns {Promise<void>} resolved when the script has been loaded
|
||||
*/
|
||||
loadJS(url, { targetDoc = document } = {}) {
|
||||
const cacheMap = getAssetCache(targetDoc);
|
||||
if (cacheMap.has(url)) {
|
||||
return cacheMap.get(url);
|
||||
}
|
||||
const scriptEl = targetDoc.createElement("script");
|
||||
scriptEl.setAttribute("src", url);
|
||||
scriptEl.type = url.includes("web/static/lib/pdfjs/") ? "module" : "text/javascript";
|
||||
const promise = new Promise((resolve, reject) =>
|
||||
onLoadAndError(scriptEl, resolve, (error) => {
|
||||
cacheMap.delete(url);
|
||||
reject(new AssetsLoadingError(`The loading of ${url} failed`, { cause: error }));
|
||||
})
|
||||
);
|
||||
cacheMap.set(url, promise);
|
||||
targetDoc.head.appendChild(scriptEl);
|
||||
return promise;
|
||||
},
|
||||
};
|
||||
501
frontend/web/static/src/core/autocomplete/autocomplete.js
Normal file
501
frontend/web/static/src/core/autocomplete/autocomplete.js
Normal file
@@ -0,0 +1,501 @@
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { useAutofocus, useForwardRefToParent, useService } from "@web/core/utils/hooks";
|
||||
import { isScrollableY, scrollTo } from "@web/core/utils/scrolling";
|
||||
import { useDebounced } from "@web/core/utils/timing";
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
import { usePosition } from "@web/core/position/position_hook";
|
||||
import { Component, onWillUpdateProps, useExternalListener, useRef, useState } from "@odoo/owl";
|
||||
import { mergeClasses } from "@web/core/utils/classname";
|
||||
|
||||
export class AutoComplete extends Component {
|
||||
static template = "web.AutoComplete";
|
||||
static props = {
|
||||
value: { type: String, optional: true },
|
||||
id: { type: String, optional: true },
|
||||
sources: {
|
||||
type: Array,
|
||||
element: {
|
||||
type: Object,
|
||||
shape: {
|
||||
placeholder: { type: String, optional: true },
|
||||
options: [Array, Function],
|
||||
optionSlot: { type: String, optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
placeholder: { type: String, optional: true },
|
||||
title: { type: String, optional: true },
|
||||
autocomplete: { type: String, optional: true },
|
||||
autoSelect: { type: Boolean, optional: true },
|
||||
resetOnSelect: { type: Boolean, optional: true },
|
||||
onInput: { type: Function, optional: true },
|
||||
onCancel: { type: Function, optional: true },
|
||||
onChange: { type: Function, optional: true },
|
||||
onBlur: { type: Function, optional: true },
|
||||
onFocus: { type: Function, optional: true },
|
||||
searchOnInputClick: { type: Boolean, optional: true },
|
||||
input: { type: Function, optional: true },
|
||||
inputDebounceDelay: { type: Number, optional: true },
|
||||
dropdown: { type: Boolean, optional: true },
|
||||
autofocus: { type: Boolean, optional: true },
|
||||
class: { type: String, optional: true },
|
||||
slots: { type: Object, optional: true },
|
||||
menuPositionOptions: { type: Object, optional: true },
|
||||
menuCssClass: { type: [String, Array, Object], optional: true },
|
||||
selectOnBlur: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
value: "",
|
||||
placeholder: "",
|
||||
title: "",
|
||||
autocomplete: "new-password",
|
||||
autoSelect: false,
|
||||
dropdown: true,
|
||||
onInput: () => {},
|
||||
onCancel: () => {},
|
||||
onChange: () => {},
|
||||
onBlur: () => {},
|
||||
onFocus: () => {},
|
||||
searchOnInputClick: true,
|
||||
inputDebounceDelay: 250,
|
||||
menuPositionOptions: {},
|
||||
menuCssClass: {},
|
||||
};
|
||||
|
||||
get timeout() {
|
||||
return this.props.inputDebounceDelay;
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.nextSourceId = 0;
|
||||
this.nextOptionId = 0;
|
||||
this.sources = [];
|
||||
this.inEdition = false;
|
||||
this.mouseSelectionActive = false;
|
||||
this.isOptionSelected = false;
|
||||
|
||||
this.state = useState({
|
||||
navigationRev: 0,
|
||||
optionsRev: 0,
|
||||
open: false,
|
||||
activeSourceOption: null,
|
||||
value: this.props.value,
|
||||
});
|
||||
|
||||
this.inputRef = useForwardRefToParent("input");
|
||||
this.listRef = useRef("sourcesList");
|
||||
if (this.props.autofocus) {
|
||||
useAutofocus({ refName: "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.timeout);
|
||||
|
||||
useExternalListener(window, "scroll", this.externalClose, true);
|
||||
useExternalListener(window, "pointerdown", this.externalClose, true);
|
||||
useExternalListener(window, "mousemove", () => (this.mouseSelectionActive = true), true);
|
||||
|
||||
this.hotkey = useService("hotkey");
|
||||
this.hotkeysToRemove = [];
|
||||
|
||||
onWillUpdateProps((nextProps) => {
|
||||
if (this.props.value !== nextProps.value || this.forceValFromProp) {
|
||||
this.forceValFromProp = false;
|
||||
if (!this.inEdition) {
|
||||
this.state.value = nextProps.value;
|
||||
this.inputRef.el.value = nextProps.value;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
|
||||
// position and size
|
||||
if (this.props.dropdown) {
|
||||
usePosition("sourcesList", () => this.targetDropdown, this.dropdownOptions);
|
||||
} else {
|
||||
this.open(false);
|
||||
}
|
||||
}
|
||||
|
||||
get targetDropdown() {
|
||||
return this.inputRef.el;
|
||||
}
|
||||
|
||||
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 dropdownOptions() {
|
||||
return {
|
||||
position: "bottom-start",
|
||||
...this.props.menuPositionOptions,
|
||||
};
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!this.state.activeSourceOption) {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
this.mouseSelectionActive = false;
|
||||
}
|
||||
|
||||
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);
|
||||
this.scroll();
|
||||
}
|
||||
get displayOptions() {
|
||||
return !this.props.dropdown || (this.isOpened && this.hasOptions);
|
||||
}
|
||||
loadOptions(options, request) {
|
||||
if (typeof options === "function") {
|
||||
return options(request);
|
||||
} else {
|
||||
return options;
|
||||
}
|
||||
}
|
||||
makeOption(option) {
|
||||
return {
|
||||
cssClass: "",
|
||||
data: {},
|
||||
...option,
|
||||
id: ++this.nextOptionId,
|
||||
unselectable: !option.onSelect,
|
||||
};
|
||||
}
|
||||
makeSource(source) {
|
||||
return {
|
||||
id: ++this.nextSourceId,
|
||||
options: [],
|
||||
isLoading: false,
|
||||
placeholder: source.placeholder,
|
||||
optionSlot: source.optionSlot,
|
||||
};
|
||||
}
|
||||
|
||||
isActiveSourceOption([sourceIndex, optionIndex]) {
|
||||
return (
|
||||
this.state.activeSourceOption &&
|
||||
this.state.activeSourceOption[0] === sourceIndex &&
|
||||
this.state.activeSourceOption[1] === optionIndex
|
||||
);
|
||||
}
|
||||
|
||||
selectOption(option) {
|
||||
this.inEdition = false;
|
||||
if (option.unselectable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.resetOnSelect) {
|
||||
this.inputRef.el.value = "";
|
||||
}
|
||||
this.isOptionSelected = true;
|
||||
this.forceValFromProp = true;
|
||||
option.onSelect();
|
||||
this.close();
|
||||
}
|
||||
|
||||
navigate(direction) {
|
||||
let step = Math.sign(direction);
|
||||
if (!step) {
|
||||
this.state.activeSourceOption = null;
|
||||
step = 1;
|
||||
} else {
|
||||
this.state.navigationRev++;
|
||||
}
|
||||
|
||||
do {
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (this.activeOption?.unselectable);
|
||||
}
|
||||
|
||||
onInputBlur() {
|
||||
if (this.ignoreBlur) {
|
||||
this.ignoreBlur = false;
|
||||
return;
|
||||
}
|
||||
// If selectOnBlur is true, we select the first element
|
||||
// of the autocomplete suggestions list, if this element exists
|
||||
if (this.props.selectOnBlur && !this.isOptionSelected && this.sources[0]) {
|
||||
const firstOption = this.sources[0].options[0];
|
||||
if (firstOption) {
|
||||
this.state.activeSourceOption = firstOption.unselectable ? null : [0, 0];
|
||||
this.selectOption(this.activeOption);
|
||||
}
|
||||
}
|
||||
this.props.onBlur({
|
||||
inputValue: this.inputRef.el.value,
|
||||
});
|
||||
this.inEdition = false;
|
||||
this.isOptionSelected = false;
|
||||
}
|
||||
onInputClick() {
|
||||
if (!this.isOpened && this.props.searchOnInputClick) {
|
||||
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,
|
||||
isOptionSelected: this.ignoreBlur,
|
||||
});
|
||||
}
|
||||
async onInput() {
|
||||
this.inEdition = true;
|
||||
this.pendingPromise = this.pendingPromise || new Deferred();
|
||||
this.loadingPromise = this.pendingPromise;
|
||||
this.debouncedProcessInput();
|
||||
}
|
||||
|
||||
onInputFocus(ev) {
|
||||
this.inputRef.el.setSelectionRange(0, this.inputRef.el.value.length);
|
||||
this.props.onFocus(ev);
|
||||
}
|
||||
|
||||
get autoCompleteRootClass() {
|
||||
let classList = "";
|
||||
if (this.props.class) {
|
||||
classList += this.props.class;
|
||||
}
|
||||
if (this.props.dropdown) {
|
||||
classList += " dropdown";
|
||||
}
|
||||
return classList;
|
||||
}
|
||||
|
||||
get ulDropdownClass() {
|
||||
return mergeClasses(this.props.menuCssClass, {
|
||||
"dropdown-menu ui-autocomplete": this.props.dropdown,
|
||||
"list-group": !this.props.dropdown,
|
||||
});
|
||||
}
|
||||
|
||||
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":
|
||||
case "shift+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);
|
||||
}
|
||||
this.scroll();
|
||||
break;
|
||||
case "arrowdown":
|
||||
this.navigate(+1);
|
||||
if (!this.isOpened) {
|
||||
this.open(true);
|
||||
}
|
||||
this.scroll();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
onOptionMouseEnter(indices) {
|
||||
if (!this.mouseSelectionActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [sourceIndex, optionIndex] = indices;
|
||||
if (this.sources[sourceIndex].options[optionIndex]?.unselectable) {
|
||||
this.state.activeSourceOption = null;
|
||||
} else {
|
||||
this.state.activeSourceOption = indices;
|
||||
}
|
||||
}
|
||||
onOptionMouseLeave() {
|
||||
this.state.activeSourceOption = null;
|
||||
}
|
||||
onOptionClick(option) {
|
||||
this.selectOption(option);
|
||||
this.inputRef.el.focus();
|
||||
}
|
||||
onOptionPointerDown(option, ev) {
|
||||
this.ignoreBlur = true;
|
||||
if (option.unselectable) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
externalClose(ev) {
|
||||
if (this.isOpened && !this.root.el.contains(ev.target)) {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
scroll() {
|
||||
if (!this.activeSourceOptionId) {
|
||||
return;
|
||||
}
|
||||
if (isScrollableY(this.listRef.el)) {
|
||||
const element = this.listRef.el.querySelector(`#${this.activeSourceOptionId}`);
|
||||
if (element) {
|
||||
scrollTo(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
frontend/web/static/src/core/autocomplete/autocomplete.scss
Normal file
53
frontend/web/static/src/core/autocomplete/autocomplete.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
.o-autocomplete {
|
||||
.o-autocomplete--dropdown-menu {
|
||||
// Needed because they are rendered at a lower stacking context compared to modals.
|
||||
z-index: $zindex-modal + 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
.o-autocomplete--input {
|
||||
width: 100%;
|
||||
}
|
||||
.o-autocomplete--mark {
|
||||
padding: 0.1875em 0;
|
||||
}
|
||||
|
||||
.ui-menu-item {
|
||||
> span {
|
||||
--dropdown-link-hover-color: var(--dropdown-color);
|
||||
--dropdown-link-hover-bg: var(--dropdown-bg);
|
||||
}
|
||||
|
||||
> a.ui-state-active {
|
||||
margin: 0;
|
||||
border: none;
|
||||
font-weight: $font-weight-normal;
|
||||
color: $dropdown-link-hover-color;
|
||||
background-color: $dropdown-link-hover-bg;
|
||||
}
|
||||
|
||||
&.o_m2o_dropdown_option, &.o_m2o_start_typing, &.o_m2o_no_result {
|
||||
text-indent: $o-dropdown-hpadding * .5;
|
||||
}
|
||||
|
||||
&.o_m2o_dropdown_option, &.o_calendar_dropdown_option {
|
||||
> a {
|
||||
color: $link-color;
|
||||
&.ui-state-active:not(.o_m2o_start_typing) {
|
||||
color: $link-hover-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.o_m2o_start_typing, &.o_m2o_no_result {
|
||||
font-style: italic;
|
||||
a.ui-menu-item-wrapper, a.ui-state-active, a.ui-state-active:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.o_m2o_start_typing > a.ui-state-active {
|
||||
color: $dropdown-link-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
frontend/web/static/src/core/autocomplete/autocomplete.xml
Normal file
88
frontend/web/static/src/core/autocomplete/autocomplete.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.AutoComplete">
|
||||
<div class="o-autocomplete" t-ref="root" t-att-class="autoCompleteRootClass">
|
||||
<input
|
||||
type="text"
|
||||
t-att-id="props.id"
|
||||
class="o-autocomplete--input o_input pe-3 text-truncate"
|
||||
t-att-autocomplete="props.autocomplete"
|
||||
t-att-placeholder="props.placeholder"
|
||||
t-att-title="props.title"
|
||||
role="combobox"
|
||||
t-att-aria-activedescendant="activeSourceOptionId"
|
||||
t-att-aria-expanded="displayOptions ? 'true' : 'false'"
|
||||
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-on-focus="onInputFocus"
|
||||
t-ref="input"
|
||||
/>
|
||||
<t t-if="displayOptions">
|
||||
<ul
|
||||
role="menu"
|
||||
class="o-autocomplete--dropdown-menu ui-widget show"
|
||||
t-att-class="ulDropdownClass"
|
||||
t-ref="sourcesList">
|
||||
<t t-foreach="sources" t-as="source" t-key="source.id">
|
||||
<t t-if="source.isLoading">
|
||||
<li class="ui-menu-item"
|
||||
t-att-class="{
|
||||
'o-autocomplete--dropdown-item': props.dropdown,
|
||||
'd-block': !props.dropdown
|
||||
}">
|
||||
<a
|
||||
t-attf-id="{{props.id or 'autocomplete'}}_{{source_index}}_loading"
|
||||
role="option"
|
||||
href="#"
|
||||
class="o_loading 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 d-block"
|
||||
t-att-class="option.cssClass"
|
||||
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="(ev) => this.onOptionPointerDown(option, ev)"
|
||||
>
|
||||
<t t-tag="option.unselectable ? 'span' : 'a'"
|
||||
class="dropdown-item ui-menu-item-wrapper text-truncate"
|
||||
t-attf-id="{{props.id or 'autocomplete'}}_{{source_index}}_{{option_index}}"
|
||||
t-att-role="!option.unselectable and 'option'"
|
||||
t-att-href="!option.unselectable and '#'"
|
||||
t-att-class="{ 'ui-state-active': isActiveSourceOption([source_index, option_index]) }"
|
||||
t-att-aria-selected="isActiveSourceOption([source_index, option_index]) ? 'true' : 'false'"
|
||||
>
|
||||
<t t-slot="{{ source.optionSlot }}" label="option.label" data="option.data">
|
||||
<t t-if="!option.labelTermOrder" t-out="option.label"/>
|
||||
<t t-else="">
|
||||
<t t-foreach="option.labelTermOrder.labelBits" t-as="bit" t-key="bit.id">
|
||||
<t t-if="!option.labelTermOrder.searchTermIndexes.includes(bit.id)" t-esc="bit.bit"/>
|
||||
<mark t-else="" class="o-autocomplete--mark" t-esc="bit.bit"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
13
frontend/web/static/src/core/avatar/avatar.scss
Normal file
13
frontend/web/static/src/core/avatar/avatar.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
// Avatar
|
||||
.o_avatar img,
|
||||
.o_avatar .o_avatar_empty,
|
||||
img.o_avatar {
|
||||
height: var(--Avatar-size, #{$o-avatar-size});
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_avatar_empty {
|
||||
background: $o-black;
|
||||
opacity: .1;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
$o-avatar-size: 1.7145em !default;
|
||||
8
frontend/web/static/src/core/badge/badge.scss
Normal file
8
frontend/web/static/src/core/badge/badge.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.badge {
|
||||
@for $size from 1 through length($o-colors) {
|
||||
&.o_badge_color_#{$size - 1} {
|
||||
background-color: adjust-color(nth($o-colors, $size), $lightness: 25%, $saturation: 15%) !important;
|
||||
color: adjust-color(nth($o-colors, $size), $lightness: -40%, $saturation: -15%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
153
frontend/web/static/src/core/barcode/ZXingBarcodeDetector.js
Normal file
153
frontend/web/static/src/core/barcode/ZXingBarcodeDetector.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Builder for BarcodeDetector-like polyfill class using ZXing library.
|
||||
*
|
||||
* @param {ZXing} ZXing Zxing library
|
||||
* @returns {class} ZxingBarcodeDetector class
|
||||
*/
|
||||
export function buildZXingBarcodeDetector(ZXing) {
|
||||
const ZXingFormats = new Map([
|
||||
["aztec", ZXing.BarcodeFormat.AZTEC],
|
||||
["code_39", ZXing.BarcodeFormat.CODE_39],
|
||||
["code_128", ZXing.BarcodeFormat.CODE_128],
|
||||
["data_matrix", ZXing.BarcodeFormat.DATA_MATRIX],
|
||||
["ean_8", ZXing.BarcodeFormat.EAN_8],
|
||||
["ean_13", ZXing.BarcodeFormat.EAN_13],
|
||||
["itf", ZXing.BarcodeFormat.ITF],
|
||||
["pdf417", ZXing.BarcodeFormat.PDF_417],
|
||||
["qr_code", ZXing.BarcodeFormat.QR_CODE],
|
||||
["upc_a", ZXing.BarcodeFormat.UPC_A],
|
||||
["upc_e", ZXing.BarcodeFormat.UPC_E],
|
||||
]);
|
||||
|
||||
const allSupportedFormats = Array.from(ZXingFormats.keys());
|
||||
|
||||
/**
|
||||
* ZXingBarcodeDetector class
|
||||
*
|
||||
* BarcodeDetector-like polyfill class using ZXing library.
|
||||
* API follows the Shape Detection Web API (specifically Barcode Detection).
|
||||
*/
|
||||
class ZXingBarcodeDetector {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.formats list of codes' formats to detect
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
const formats = opts.formats || allSupportedFormats;
|
||||
const hints = new Map([
|
||||
[
|
||||
ZXing.DecodeHintType.POSSIBLE_FORMATS,
|
||||
formats.map((format) => ZXingFormats.get(format)),
|
||||
],
|
||||
// Enable Scanning at 90 degrees rotation
|
||||
// https://github.com/zxing-js/library/issues/291
|
||||
[ZXing.DecodeHintType.TRY_HARDER, true],
|
||||
]);
|
||||
this.reader = new ZXing.MultiFormatReader();
|
||||
this.reader.setHints(hints);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect codes in image.
|
||||
*
|
||||
* @param {HTMLVideoElement} video source video element
|
||||
* @returns {Promise<Array>} array of detected codes
|
||||
*/
|
||||
async detect(video) {
|
||||
if (!(video instanceof HTMLVideoElement)) {
|
||||
throw new DOMException(
|
||||
"imageDataFrom() requires an HTMLVideoElement",
|
||||
"InvalidArgumentError"
|
||||
);
|
||||
}
|
||||
if (!isVideoElementReady(video)) {
|
||||
throw new DOMException("HTMLVideoElement is not ready", "InvalidStateError");
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
let barcodeArea;
|
||||
if (this.cropArea && (this.cropArea.x || this.cropArea.y)) {
|
||||
barcodeArea = this.cropArea;
|
||||
} else {
|
||||
barcodeArea = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
};
|
||||
}
|
||||
canvas.width = barcodeArea.width;
|
||||
canvas.height = barcodeArea.height;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
ctx.drawImage(
|
||||
video,
|
||||
barcodeArea.x,
|
||||
barcodeArea.y,
|
||||
barcodeArea.width,
|
||||
barcodeArea.height,
|
||||
0,
|
||||
0,
|
||||
barcodeArea.width,
|
||||
barcodeArea.height
|
||||
);
|
||||
|
||||
const luminanceSource = new ZXing.HTMLCanvasElementLuminanceSource(canvas);
|
||||
const binaryBitmap = new ZXing.BinaryBitmap(new ZXing.HybridBinarizer(luminanceSource));
|
||||
try {
|
||||
const result = this.reader.decodeWithState(binaryBitmap);
|
||||
const { resultPoints } = result;
|
||||
const boundingBox = DOMRectReadOnly.fromRect({
|
||||
x: resultPoints[0].x,
|
||||
y: resultPoints[0].y,
|
||||
height: Math.max(1, Math.abs(resultPoints[1].y - resultPoints[0].y)),
|
||||
width: Math.max(1, Math.abs(resultPoints[1].x - resultPoints[0].x)),
|
||||
});
|
||||
const cornerPoints = resultPoints;
|
||||
const format = Array.from(ZXingFormats).find(
|
||||
([k, val]) => val === result.getBarcodeFormat()
|
||||
);
|
||||
const rawValue = result.getText();
|
||||
return [
|
||||
{
|
||||
boundingBox,
|
||||
cornerPoints,
|
||||
format,
|
||||
rawValue,
|
||||
},
|
||||
];
|
||||
} catch (err) {
|
||||
if (err.name === "NotFoundException") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
setCropArea(cropArea) {
|
||||
this.cropArea = cropArea;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported codes formats
|
||||
*
|
||||
* @static
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
ZXingBarcodeDetector.getSupportedFormats = async () => allSupportedFormats;
|
||||
|
||||
return ZXingBarcodeDetector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for HTMLVideoElement readiness.
|
||||
*
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
|
||||
*/
|
||||
const HAVE_NOTHING = 0;
|
||||
const HAVE_METADATA = 1;
|
||||
export function isVideoElementReady(video) {
|
||||
return ![HAVE_NOTHING, HAVE_METADATA].includes(video.readyState);
|
||||
}
|
||||
60
frontend/web/static/src/core/barcode/barcode_dialog.js
Normal file
60
frontend/web/static/src/core/barcode/barcode_dialog.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { BarcodeVideoScanner, isBarcodeScannerSupported } from "./barcode_video_scanner";
|
||||
|
||||
export class BarcodeDialog extends Component {
|
||||
static template = "web.BarcodeDialog";
|
||||
static components = {
|
||||
BarcodeVideoScanner,
|
||||
Dialog,
|
||||
};
|
||||
static props = ["facingMode", "close", "onResult", "onError"];
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
barcodeScannerSupported: isBarcodeScannerSupported(),
|
||||
errorMessage: _t("Check your browser permissions"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection success handler
|
||||
*
|
||||
* @param {string} result found code
|
||||
*/
|
||||
onResult(result) {
|
||||
this.props.close();
|
||||
this.props.onResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection error handler
|
||||
*
|
||||
* @param {Error} error
|
||||
*/
|
||||
onError(error) {
|
||||
this.state.barcodeScannerSupported = false;
|
||||
this.state.errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the BarcodeScanning dialog and begins code detection using the device's camera.
|
||||
*
|
||||
* @returns {Promise<string>} resolves when a {qr,bar}code has been detected
|
||||
*/
|
||||
export async function scanBarcode(env, facingMode = "environment") {
|
||||
let res;
|
||||
let rej;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
res = resolve;
|
||||
rej = reject;
|
||||
});
|
||||
env.services.dialog.add(BarcodeDialog, {
|
||||
facingMode,
|
||||
onResult: (result) => res(result),
|
||||
onError: (error) => rej(error),
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
10
frontend/web/static/src/core/barcode/barcode_dialog.scss
Normal file
10
frontend/web/static/src/core/barcode/barcode_dialog.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.modal .o-barcode-modal .modal-body {
|
||||
overflow: hidden;
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
video {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
15
frontend/web/static/src/core/barcode/barcode_dialog.xml
Normal file
15
frontend/web/static/src/core/barcode/barcode_dialog.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.BarcodeDialog">
|
||||
<Dialog title.translate="Barcode Scanner" fullscreen="true" footer="false" contentClass="'o-barcode-modal'">
|
||||
<BarcodeVideoScanner t-if="state.barcodeScannerSupported"
|
||||
t-props="props" onError.bind="onError" onResult.bind="onResult"/>
|
||||
<div t-else="" t-ref="videoPreview"
|
||||
class="h-100 d-flex flex-column justify-content-center align-items-center gap-1">
|
||||
<i class="fa fa-2x fa-camera text-muted"></i>
|
||||
<strong>Unable to access camera</strong>
|
||||
<span class="text-muted" t-out="state.errorMessage"/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
234
frontend/web/static/src/core/barcode/barcode_video_scanner.js
Normal file
234
frontend/web/static/src/core/barcode/barcode_video_scanner.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/* global BarcodeDetector */
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { delay } from "@web/core/utils/concurrency";
|
||||
import { loadJS } from "@web/core/assets";
|
||||
import { isVideoElementReady, buildZXingBarcodeDetector } from "./ZXingBarcodeDetector";
|
||||
import { CropOverlay } from "./crop_overlay";
|
||||
import { Component, onMounted, onWillStart, onWillUnmount, status, useRef, useState } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { pick } from "@web/core/utils/objects";
|
||||
|
||||
export class BarcodeVideoScanner extends Component {
|
||||
static template = "web.BarcodeVideoScanner";
|
||||
static components = {
|
||||
CropOverlay,
|
||||
};
|
||||
static props = {
|
||||
cssClass: { type: String, optional: true },
|
||||
facingMode: {
|
||||
type: String,
|
||||
validate: (fm) => ["environment", "left", "right", "user"].includes(fm),
|
||||
},
|
||||
close: { type: Function, optional: true },
|
||||
onReady: { type: Function, optional: true },
|
||||
onResult: Function,
|
||||
onError: Function,
|
||||
placeholder: { type: String, optional: true },
|
||||
delayBetweenScan: { type: Number, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
cssClass: "w-100 h-100",
|
||||
};
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
setup() {
|
||||
this.videoPreviewRef = useRef("videoPreview");
|
||||
this.detectorTimeout = null;
|
||||
this.stream = null;
|
||||
this.detector = null;
|
||||
this.overlayInfo = {};
|
||||
this.zoomRatio = 1;
|
||||
this.scanPaused = false;
|
||||
this.state = useState({
|
||||
isReady: false,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
let DetectorClass;
|
||||
// Use Barcode Detection API if available.
|
||||
// As support is still bleeding edge (mainly Chrome on Android),
|
||||
// also provides a fallback using ZXing library.
|
||||
if ("BarcodeDetector" in window) {
|
||||
DetectorClass = BarcodeDetector;
|
||||
} else {
|
||||
await loadJS("/web/static/lib/zxing-library/zxing-library.js");
|
||||
DetectorClass = buildZXingBarcodeDetector(window.ZXing);
|
||||
}
|
||||
const formats = await DetectorClass.getSupportedFormats();
|
||||
this.detector = new DetectorClass({ formats });
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const constraints = {
|
||||
video: { facingMode: this.props.facingMode },
|
||||
audio: false,
|
||||
};
|
||||
|
||||
try {
|
||||
this.stream = await browser.navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (err) {
|
||||
const errors = {
|
||||
NotFoundError: _t("No device can be found."),
|
||||
NotAllowedError: _t("Odoo needs your authorization first."),
|
||||
};
|
||||
const errorMessage = _t("Could not start scanning. %(message)s", {
|
||||
message: errors[err.name] || err.message,
|
||||
});
|
||||
this.props.onError(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
if (!this.videoPreviewRef.el) {
|
||||
this.cleanStreamAndTimeout();
|
||||
const errorMessage = _t("Barcode Video Scanner could not be mounted properly.");
|
||||
this.props.onError(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
this.videoPreviewRef.el.srcObject = this.stream;
|
||||
const ready = await this.isVideoReady();
|
||||
if (!ready) {
|
||||
return;
|
||||
}
|
||||
const { height, width } = getComputedStyle(this.videoPreviewRef.el);
|
||||
const divWidth = width.slice(0, -2);
|
||||
const divHeight = height.slice(0, -2);
|
||||
const tracks = this.stream.getVideoTracks();
|
||||
if (tracks.length) {
|
||||
const [track] = tracks;
|
||||
const settings = track.getSettings();
|
||||
this.zoomRatio = Math.min(divWidth / settings.width, divHeight / settings.height);
|
||||
this.addZoomSlider(track, settings);
|
||||
}
|
||||
this.detectorTimeout = setTimeout(this.detectCode.bind(this), 100);
|
||||
});
|
||||
|
||||
onWillUnmount(() => this.cleanStreamAndTimeout());
|
||||
}
|
||||
|
||||
cleanStreamAndTimeout() {
|
||||
clearTimeout(this.detectorTimeout);
|
||||
this.detectorTimeout = null;
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
isZXingBarcodeDetector() {
|
||||
return this.detector && this.detector.__proto__.constructor.name === "ZXingBarcodeDetector";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for camera preview element readiness
|
||||
*
|
||||
* @returns {Promise} resolves when the video element is ready
|
||||
*/
|
||||
async isVideoReady() {
|
||||
// FIXME: even if it shouldn't happened, a timeout could be useful here.
|
||||
while (!isVideoElementReady(this.videoPreviewRef.el)) {
|
||||
await delay(10);
|
||||
if (status(this) === "destroyed"){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.state.isReady = true;
|
||||
if (this.props.onReady) {
|
||||
this.props.onReady();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onResize(overlayInfo) {
|
||||
this.overlayInfo = overlayInfo;
|
||||
if (this.isZXingBarcodeDetector()) {
|
||||
// TODO need refactoring when ZXing will support multiple result in one scan
|
||||
// https://github.com/zxing-js/library/issues/346
|
||||
this.detector.setCropArea(this.adaptValuesWithRatio(this.overlayInfo, true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to detect codes in the current camera preview's frame
|
||||
*/
|
||||
async detectCode() {
|
||||
let barcodeDetected = false;
|
||||
let codes = [];
|
||||
try {
|
||||
codes = await this.detector.detect(this.videoPreviewRef.el);
|
||||
} catch (err) {
|
||||
this.props.onError(err);
|
||||
}
|
||||
for (const code of codes) {
|
||||
if (
|
||||
!this.isZXingBarcodeDetector() &&
|
||||
this.overlayInfo.x !== undefined &&
|
||||
this.overlayInfo.y !== undefined
|
||||
) {
|
||||
const { x, y, width, height } = this.adaptValuesWithRatio(code.boundingBox);
|
||||
if (
|
||||
x < this.overlayInfo.x ||
|
||||
x + width > this.overlayInfo.x + this.overlayInfo.width ||
|
||||
y < this.overlayInfo.y ||
|
||||
y + height > this.overlayInfo.y + this.overlayInfo.height
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
barcodeDetected = true;
|
||||
this.barcodeDetected(code.rawValue);
|
||||
break;
|
||||
}
|
||||
if (this.stream && (!barcodeDetected || !this.props.delayBetweenScan)) {
|
||||
this.detectorTimeout = setTimeout(this.detectCode.bind(this), 100);
|
||||
}
|
||||
}
|
||||
|
||||
barcodeDetected(barcode) {
|
||||
if (this.props.delayBetweenScan && !this.scanPaused) {
|
||||
this.scanPaused = true;
|
||||
this.detectorTimeout = setTimeout(() => {
|
||||
this.scanPaused = false;
|
||||
this.detectorTimeout = setTimeout(this.detectCode.bind(this), 100);
|
||||
}, this.props.delayBetweenScan);
|
||||
}
|
||||
this.props.onResult(barcode);
|
||||
}
|
||||
|
||||
adaptValuesWithRatio(domRect, dividerRatio = false) {
|
||||
const newObject = pick(domRect, "x", "y", "width", "height");
|
||||
for (const key of Object.keys(newObject)) {
|
||||
if (dividerRatio) {
|
||||
newObject[key] /= this.zoomRatio;
|
||||
} else {
|
||||
newObject[key] *= this.zoomRatio;
|
||||
}
|
||||
}
|
||||
return newObject;
|
||||
}
|
||||
|
||||
addZoomSlider(track, settings) {
|
||||
const zoom = track.getCapabilities().zoom;
|
||||
if (zoom?.min !== undefined && zoom?.max !== undefined) {
|
||||
const inputElement = document.createElement("input");
|
||||
inputElement.type = "range";
|
||||
inputElement.min = zoom.min;
|
||||
inputElement.max = zoom.max;
|
||||
inputElement.step = zoom.step || 1;
|
||||
inputElement.value = settings.zoom;
|
||||
inputElement.classList.add("align-self-end", "m-5", "z-1");
|
||||
inputElement.addEventListener("input", async (event) => {
|
||||
await track?.applyConstraints({ advanced: [{ zoom: inputElement.value }] });
|
||||
});
|
||||
this.videoPreviewRef.el.parentElement.appendChild(inputElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for BarcodeScanner support
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isBarcodeScannerSupported() {
|
||||
return Boolean(browser.navigator.mediaDevices && browser.navigator.mediaDevices.getUserMedia);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.BarcodeVideoScanner">
|
||||
<CropOverlay onResize.bind="this.onResize" isReady="state.isReady">
|
||||
<video t-ref="videoPreview" muted="true" autoplay="true" playsinline="true" t-att-class="props.cssClass"/>
|
||||
</CropOverlay>
|
||||
</t>
|
||||
</templates>
|
||||
158
frontend/web/static/src/core/barcode/crop_overlay.js
Normal file
158
frontend/web/static/src/core/barcode/crop_overlay.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Component, useRef, onPatched } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { isIOS } from "@web/core/browser/feature_detection";
|
||||
import { clamp } from "@web/core/utils/numbers";
|
||||
|
||||
export class CropOverlay extends Component {
|
||||
static template = "web.CropOverlay";
|
||||
static props = {
|
||||
onResize: Function,
|
||||
isReady: Boolean,
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.localStorageKey = "o-barcode-scanner-overlay";
|
||||
this.cropContainerRef = useRef("crop-container");
|
||||
this.isMoving = false;
|
||||
this.boundaryOverlay = {};
|
||||
this.relativePosition = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
onPatched(() => {
|
||||
this.setupCropRect();
|
||||
});
|
||||
this.isIOS = isIOS();
|
||||
}
|
||||
|
||||
setupCropRect() {
|
||||
if (!this.props.isReady) {
|
||||
return;
|
||||
}
|
||||
this.computeDefaultPoint();
|
||||
this.computeOverlayPosition();
|
||||
this.calculateAndSetTransparentRect();
|
||||
this.executeOnResizeCallback();
|
||||
}
|
||||
|
||||
boundPoint(pointValue, boundaryRect) {
|
||||
return {
|
||||
x: clamp(pointValue.x, boundaryRect.left, boundaryRect.left + boundaryRect.width),
|
||||
y: clamp(pointValue.y, boundaryRect.top, boundaryRect.top + boundaryRect.height),
|
||||
};
|
||||
}
|
||||
|
||||
calculateAndSetTransparentRect() {
|
||||
const cropTransparentRect = this.getTransparentRec(
|
||||
this.relativePosition,
|
||||
this.boundaryOverlay
|
||||
);
|
||||
this.setCropValue(cropTransparentRect, this.relativePosition);
|
||||
}
|
||||
|
||||
computeOverlayPosition() {
|
||||
const cropOverlayElement = this.cropContainerRef.el.querySelector(".o_crop_overlay");
|
||||
this.boundaryOverlay = cropOverlayElement.getBoundingClientRect();
|
||||
}
|
||||
|
||||
executeOnResizeCallback() {
|
||||
const transparentRec = this.getTransparentRec(this.relativePosition, this.boundaryOverlay);
|
||||
browser.localStorage.setItem(this.localStorageKey, JSON.stringify(transparentRec));
|
||||
this.props.onResize({
|
||||
...transparentRec,
|
||||
width: this.boundaryOverlay.width - 2 * transparentRec.x,
|
||||
height: this.boundaryOverlay.height - 2 * transparentRec.y,
|
||||
});
|
||||
}
|
||||
|
||||
computeDefaultPoint() {
|
||||
const firstChildComputedStyle = getComputedStyle(this.cropContainerRef.el.firstChild);
|
||||
const elementWidth = firstChildComputedStyle.width.slice(0, -2);
|
||||
const elementHeight = firstChildComputedStyle.height.slice(0, -2);
|
||||
|
||||
const stringSavedPoint = browser.localStorage.getItem(this.localStorageKey);
|
||||
if (stringSavedPoint) {
|
||||
const savedPoint = JSON.parse(stringSavedPoint);
|
||||
this.relativePosition = {
|
||||
x: clamp(savedPoint.x, 0, elementWidth),
|
||||
y: clamp(savedPoint.y, 0, elementHeight),
|
||||
};
|
||||
} else {
|
||||
const stepWidth = elementWidth / 10;
|
||||
const width = stepWidth * 8;
|
||||
const height = width / 4;
|
||||
const startY = elementHeight / 2 - height / 2;
|
||||
this.relativePosition = {
|
||||
x: stepWidth + width,
|
||||
y: startY + height,
|
||||
};
|
||||
}
|
||||
}
|
||||
getTransparentRec(point, rect) {
|
||||
const middleX = rect.width / 2;
|
||||
const middleY = rect.height / 2;
|
||||
const newDeltaX = Math.abs(point.x - middleX);
|
||||
const newDeltaY = Math.abs(point.y - middleY);
|
||||
return {
|
||||
x: middleX - newDeltaX,
|
||||
y: middleY - newDeltaY,
|
||||
};
|
||||
}
|
||||
|
||||
setCropValue(point, iconPoint) {
|
||||
if (!iconPoint) {
|
||||
iconPoint = point;
|
||||
}
|
||||
this.cropContainerRef.el.style.setProperty("--o-crop-x", `${point.x}px`);
|
||||
this.cropContainerRef.el.style.setProperty("--o-crop-y", `${point.y}px`);
|
||||
this.cropContainerRef.el.style.setProperty("--o-crop-icon-x", `${iconPoint.x}px`);
|
||||
this.cropContainerRef.el.style.setProperty("--o-crop-icon-y", `${iconPoint.y}px`);
|
||||
}
|
||||
|
||||
pointerDown(event) {
|
||||
if (event.target.matches("input")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.target.matches(".o_crop_icon")) {
|
||||
this.computeOverlayPosition();
|
||||
this.isMoving = true;
|
||||
}
|
||||
}
|
||||
|
||||
pointerMove(event) {
|
||||
if (!this.isMoving) {
|
||||
return;
|
||||
}
|
||||
let eventPosition;
|
||||
if (event.touches && event.touches.length) {
|
||||
eventPosition = event.touches[0];
|
||||
} else {
|
||||
eventPosition = event;
|
||||
}
|
||||
const { clientX, clientY } = eventPosition;
|
||||
const restrictedPosition = this.boundPoint(
|
||||
{
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
},
|
||||
this.boundaryOverlay
|
||||
);
|
||||
this.relativePosition = {
|
||||
x: restrictedPosition.x - this.boundaryOverlay.left,
|
||||
y: restrictedPosition.y - this.boundaryOverlay.top,
|
||||
};
|
||||
this.calculateAndSetTransparentRect(this.relativePosition);
|
||||
}
|
||||
|
||||
pointerUp(event) {
|
||||
this.isMoving = false;
|
||||
this.executeOnResizeCallback();
|
||||
}
|
||||
}
|
||||
45
frontend/web/static/src/core/barcode/crop_overlay.scss
Normal file
45
frontend/web/static/src/core/barcode/crop_overlay.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
.o_crop_container {
|
||||
position: relative;
|
||||
|
||||
> * {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.o_crop_overlay::after {
|
||||
content: '';
|
||||
display: block;
|
||||
}
|
||||
|
||||
.o_crop_overlay:not(.o_crop_overlay_ios) {
|
||||
background-color: RGB(0 0 0 / 0.75);
|
||||
mix-blend-mode: darken;
|
||||
|
||||
&::after {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
clip-path: inset(var(--o-crop-y, 0px) var(--o-crop-x, 0px));
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.o_crop_overlay.o_crop_overlay_ios {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
inset: var(--o-crop-y, 0px) var(--o-crop-x, 0px);
|
||||
border: 1px solid black;
|
||||
}
|
||||
}
|
||||
|
||||
.o_crop_icon {
|
||||
--o-crop-icon-width: 20px;
|
||||
--o-crop-icon-height: 20px;
|
||||
position: absolute;
|
||||
width: var(--o-crop-icon-width);
|
||||
height: var(--o-crop-icon-height);
|
||||
left: calc(var(--o-crop-icon-x, 0px) - (var(--o-crop-icon-width) / 2));
|
||||
top: calc(var(--o-crop-icon-y, 0px) - (var(--o-crop-icon-height) / 2));
|
||||
}
|
||||
}
|
||||
17
frontend/web/static/src/core/barcode/crop_overlay.xml
Normal file
17
frontend/web/static/src/core/barcode/crop_overlay.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.CropOverlay">
|
||||
<div t-ref="crop-container"
|
||||
t-on-mousedown="pointerDown" t-on-touchstart="pointerDown"
|
||||
t-on-mousemove="pointerMove" t-on-touchmove="pointerMove"
|
||||
t-on-mouseup="pointerUp" t-on-touchend="pointerUp"
|
||||
class="d-grid align-content-center justify-content-center h-100 o_crop_container"
|
||||
>
|
||||
<t t-slot="default"/>
|
||||
<t t-if="props.isReady">
|
||||
<div class="o_crop_overlay" t-att-class="{'o_crop_overlay_ios': isIOS}"/>
|
||||
<img class="o_crop_icon" src="/web/static/img/transform.svg" draggable="false"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
317
frontend/web/static/src/core/bottom_sheet/bottom_sheet.js
Normal file
317
frontend/web/static/src/core/bottom_sheet/bottom_sheet.js
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* BottomSheet
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
import { Component, useState, useRef, onMounted, useExternalListener } from "@odoo/owl";
|
||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||
import { useForwardRefToParent } from "@web/core/utils/hooks";
|
||||
import { useThrottleForAnimation } from "@web/core/utils/timing";
|
||||
import { compensateScrollbar } from "@web/core/utils/scrolling";
|
||||
import { getViewportDimensions, useViewportChange } from "@web/core/utils/dvu";
|
||||
import { clamp } from "@web/core/utils/numbers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
export class BottomSheet extends Component {
|
||||
static template = "web.BottomSheet";
|
||||
|
||||
static defaultProps = {
|
||||
class: "",
|
||||
};
|
||||
|
||||
static props = {
|
||||
// Main props
|
||||
component: { type: Function },
|
||||
componentProps: { optional: true, type: Object },
|
||||
close: { type: Function },
|
||||
|
||||
class: { optional: true },
|
||||
role: { optional: true, type: String },
|
||||
|
||||
// Technical props
|
||||
ref: { optional: true, type: Function },
|
||||
slots: { optional: true, type: Object },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.maxHeightPercent = 90;
|
||||
|
||||
this.state = useState({
|
||||
isPositionedReady: false, // Sheet is ready for display
|
||||
isSnappingEnabled: false,
|
||||
isDismissing: false, // Sheet is being dismissed
|
||||
progress: 0, // Visual progress (0-1)
|
||||
});
|
||||
|
||||
// Measurements and configuration
|
||||
this.measurements = {
|
||||
viewportHeight: 0,
|
||||
naturalHeight: 0,
|
||||
maxHeight: 0,
|
||||
dismissThreshold: 0,
|
||||
};
|
||||
|
||||
// Popover Ref Requirement
|
||||
useForwardRefToParent("ref");
|
||||
|
||||
// References
|
||||
this.containerRef = useRef("container");
|
||||
this.scrollRailRef = useRef("scrollRail");
|
||||
this.sheetRef = useRef("sheet");
|
||||
this.sheetBodyRef = useRef("ref");
|
||||
|
||||
// Create throttled version for onScroll
|
||||
this.throttledOnScroll = useThrottleForAnimation(this.onScroll.bind(this));
|
||||
|
||||
// Adapt dimensions when mobile virtual-keyboards or browsers bars toggle
|
||||
useViewportChange(() => {
|
||||
if (this.state.isPositionedReady && !this.state.isDismissing) {
|
||||
this.updateDimensions();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle "ESC" key press.
|
||||
useHotkey("escape", () => this.slideOut());
|
||||
|
||||
// Handle mobile "back" gesture and "back" navigation button.
|
||||
// Push a history state when the BottomSheet opens, intercept the browser's
|
||||
// history events, prevents navigation by pushing another state and closes the sheet.
|
||||
window.history.pushState({ bottomSheet: true }, "");
|
||||
this.handlePopState = () => {
|
||||
if (this.state.isPositionedReady && !this.state.isDismissing) {
|
||||
window.history.pushState({ bottomSheet: true }, "");
|
||||
this.slideOut();
|
||||
}
|
||||
};
|
||||
useExternalListener(window, "popstate", this.handlePopState);
|
||||
|
||||
onMounted(() => {
|
||||
const isReduced =
|
||||
browser.matchMedia(`(prefers-reduced-motion: reduce)`) === true ||
|
||||
browser.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true;
|
||||
|
||||
this.prefersReducedMotion =
|
||||
isReduced || getComputedStyle(this.containerRef.el).animationName === "none";
|
||||
|
||||
this.initializeSheet();
|
||||
compensateScrollbar(this.scrollRailRef.el, true, true, "padding-right");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main initialization method for the sheet
|
||||
* Sets up measurements, snap points, and event handlers
|
||||
*/
|
||||
initializeSheet() {
|
||||
if (!this.containerRef.el || !this.scrollRailRef.el || !this.sheetRef.el) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Take measurements
|
||||
this.measureDimensions();
|
||||
|
||||
// Step 2: Apply Dimensions
|
||||
this.applyDimensions();
|
||||
|
||||
// Step 3: Set initial position
|
||||
this.positionSheet();
|
||||
|
||||
// Step 4: Setup event handlers after everything has been properly resized and positioned
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Step 5: Mark as ready
|
||||
this.state.isPositionedReady = true;
|
||||
|
||||
if (this.prefersReducedMotion) {
|
||||
this.state.isSnappingEnabled = true;
|
||||
} else {
|
||||
this.sheetRef.el?.addEventListener(
|
||||
"animationend",
|
||||
() => (this.state.isSnappingEnabled = true),
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
this.sheetRef.el?.addEventListener(
|
||||
"animationcancel",
|
||||
() => (this.state.isSnappingEnabled = true),
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates dimensions when viewport changes
|
||||
* Recalculates measurements and snap points while preserving extended state
|
||||
*/
|
||||
updateDimensions() {
|
||||
// Temporarily disable snapping during update
|
||||
this.state.isSnappingEnabled = false;
|
||||
|
||||
// Update measurements with new viewport dimensions
|
||||
this.measureDimensions();
|
||||
this.applyDimensions();
|
||||
|
||||
// // Update scroll position
|
||||
const scrollTop = this.scrollRailRef.el.scrollTop;
|
||||
|
||||
// Update progress value
|
||||
this.updateProgressValue(scrollTop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes measurements of viewport and sheet dimensions
|
||||
* Calculates natural height and other key measurements
|
||||
*/
|
||||
measureDimensions() {
|
||||
const viewportHeight = getViewportDimensions().height;
|
||||
|
||||
// Calculate heights based on percentages
|
||||
const maxHeightPx = (this.maxHeightPercent / 100) * viewportHeight;
|
||||
|
||||
// Reset any previously set constraints to measure natural height
|
||||
const sheet = this.sheetRef.el;
|
||||
sheet.style.removeProperty("min-height");
|
||||
sheet.style.removeProperty("height");
|
||||
|
||||
const naturalHeight = sheet.offsetHeight;
|
||||
const initialHeightPx = Math.min(naturalHeight, maxHeightPx);
|
||||
|
||||
// Store all measurements
|
||||
this.measurements = {
|
||||
viewportHeight,
|
||||
naturalHeight,
|
||||
initialHeight: initialHeightPx,
|
||||
maxHeight: maxHeightPx,
|
||||
dismissThreshold: Math.min(initialHeightPx * 0.3, 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies calculated dimensions to the DOM elements
|
||||
* Sets CSS variables and styles based on measurements and snap points
|
||||
*/
|
||||
applyDimensions() {
|
||||
const rail = this.scrollRailRef.el;
|
||||
|
||||
// Convert heights to dvh percentages for CSS variables
|
||||
const heightPercent = Math.min(
|
||||
(this.measurements.initialHeight / this.measurements.viewportHeight) * 100,
|
||||
this.maxHeightPercent
|
||||
);
|
||||
|
||||
// Set CSS variables for heights
|
||||
rail.style.setProperty("--sheet-height", `${heightPercent}dvh`);
|
||||
rail.style.setProperty("--sheet-max-height", `${this.measurements.viewportHeight}px`);
|
||||
rail.style.setProperty("--dismiss-height", `${this.measurements.initialHeight || 0}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the initial position of the sheet
|
||||
* Configures initial scroll position and overflow behavior
|
||||
*/
|
||||
positionSheet() {
|
||||
const scrollRail = this.scrollRailRef.el;
|
||||
const bodyContent = this.sheetBodyRef.el;
|
||||
|
||||
const scrollValue = this.measurements.maxHeight;
|
||||
|
||||
// Configure body content overflow
|
||||
if (bodyContent) {
|
||||
bodyContent.style.overflowY = "auto";
|
||||
}
|
||||
|
||||
// Set scroll position
|
||||
scrollRail.scrollTop = scrollValue || 0;
|
||||
scrollRail.style.containerType = "scroll-state size";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event handlers for scroll and touch events
|
||||
*/
|
||||
setupEventHandlers() {
|
||||
const scrollRail = this.scrollRailRef.el;
|
||||
|
||||
// Add scroll event listener
|
||||
scrollRail.addEventListener("scroll", this.throttledOnScroll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles scroll events on the rail element
|
||||
* Updates progress, handles position snapping, and triggers dismissal
|
||||
*/
|
||||
onScroll() {
|
||||
if (!this.scrollRailRef.el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = this.scrollRailRef.el.scrollTop;
|
||||
|
||||
// Update progress value for visual effects
|
||||
this.updateProgressValue(scrollTop);
|
||||
|
||||
// Check for dismissal condition
|
||||
if (scrollTop < this.measurements.dismissThreshold) {
|
||||
this.slideOut();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates and updates the progress value based on scroll position
|
||||
*
|
||||
* @param {number} scrollTop - Current scroll position
|
||||
*/
|
||||
updateProgressValue(scrollTop) {
|
||||
const initialPosition = this.measurements.naturalHeight;
|
||||
const progress = clamp(scrollTop / initialPosition, 0, 1);
|
||||
|
||||
if (Math.abs(this.state.progress - progress) > 0.01) {
|
||||
this.state.progress = progress;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates the slide out animation and dismissal
|
||||
*/
|
||||
slideOut() {
|
||||
// Prevent duplicate calls
|
||||
if (this.state.isDismissing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.prefersReducedMotion) {
|
||||
this.props.close?.();
|
||||
} else {
|
||||
this.sheetRef.el?.addEventListener("animationend", () => this.props.close?.(), {
|
||||
once: true,
|
||||
});
|
||||
this.sheetRef.el?.addEventListener("animationcancel", () => this.props.close?.(), {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Update state to trigger animation
|
||||
this.state.isDismissing = true;
|
||||
this.state.isSnappingEnabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the sheet (public API)
|
||||
*/
|
||||
close() {
|
||||
this.slideOut();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles back button press (public API)
|
||||
*/
|
||||
back() {
|
||||
if (this.props.onBack) {
|
||||
this.props.onBack();
|
||||
} else {
|
||||
this.slideOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
343
frontend/web/static/src/core/bottom_sheet/bottom_sheet.scss
Normal file
343
frontend/web/static/src/core/bottom_sheet/bottom_sheet.scss
Normal file
@@ -0,0 +1,343 @@
|
||||
.o_bottom_sheet {
|
||||
// =============================================
|
||||
// Layout and inner elements
|
||||
// =============================================
|
||||
--BottomSheet-slideIn-duration: #{$o_BottomSheet_slideIn_duration};
|
||||
--BottomSheet-slideIn-easing: #{$o_BottomSheet_slideIn_easing};
|
||||
--BottomSheet-slideOut-duration: #{$o_BottomSheet_slideOut_duration};
|
||||
--BottomSheet-slideOut-easing: #{$o_BottomSheet_slideOut_easing};
|
||||
|
||||
--BottomSheet-Sheet-borderColor: #{$o_BottomSheet_Sheet_borderColor};
|
||||
|
||||
@mixin has-more-content-visual {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
height: map-get($spacers, 4);
|
||||
background: linear-gradient(transparent, #00000050);
|
||||
z-index: $zindex-offcanvas;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100dvh;
|
||||
z-index: $zindex-offcanvas;
|
||||
opacity: 0;
|
||||
transform-style: preserve-3d;
|
||||
contain: layout paint size;
|
||||
|
||||
// Workaround
|
||||
animation-name: has-animation;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
// Main scroll container for gesture handling
|
||||
.o_bottom_sheet_rail {
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
touch-action: pan-y;
|
||||
pointer-events: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.o_bottom_sheet_rail_prevent_overscroll,
|
||||
&.o_bottom_sheet_rail_prevent_overscroll * {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@include has-more-content-visual;
|
||||
opacity: 0;
|
||||
transition: opacity var(--BottomSheet-slideIn-duration, 500ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Set snapping behaviors
|
||||
.o_bottom_sheet_dismiss, .o_bottom_sheet_spacer, .o_bottom_sheet_sheet {
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
// Backdrop overlay
|
||||
.o_bottom_sheet_backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
z-index: $zindex-offcanvas - 1;
|
||||
backdrop-filter: blur(0px) grayscale(0%);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss area
|
||||
.o_bottom_sheet_dismiss {
|
||||
height: var(--dismiss-height, 50dvh);
|
||||
}
|
||||
|
||||
// Spacer area
|
||||
.o_bottom_sheet_spacer {
|
||||
height: calc(100dvh - var(--sheet-height, 50dvh));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// The actual sheet
|
||||
.o_bottom_sheet_sheet {
|
||||
--offcanvas-box-shadow: #{$box-shadow};
|
||||
|
||||
margin: 0 auto;
|
||||
min-height: var(--sheet-height);
|
||||
max-height: var(--sheet-max-height);
|
||||
border-radius: $border-radius-xl $border-radius-xl 0 0;
|
||||
border-bottom-width: 0;
|
||||
visibility: visible;
|
||||
transition: none;
|
||||
contain: content;
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
user-select: none;
|
||||
background-color: $dropdown-bg;
|
||||
|
||||
.o_bottom_sheet_body {
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// States
|
||||
// =============================================
|
||||
@keyframes bottom-sheet-in {
|
||||
from { transform: translateY(100%) translateZ(0); }
|
||||
to { transform: translateY(0) translateZ(0); }
|
||||
}
|
||||
|
||||
@keyframes bottom-sheet-out {
|
||||
from { transform: translateY(0) translateZ(0); }
|
||||
to { transform: translateY(100%) translateZ(0); }
|
||||
}
|
||||
|
||||
// BottomSheet is ready to be rendered on screen
|
||||
&.o_bottom_sheet_ready {
|
||||
opacity: 1;
|
||||
|
||||
.o_bottom_sheet_sheet {
|
||||
animation: var(--BottomSheet-slideIn-duration, 500ms) bottom-sheet-in var(--BottomSheet-slideIn-easing, ease-out) forwards;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bottom_sheet_backdrop {
|
||||
opacity: MAX(var(--BottomSheet-progress, 0), 0.2);
|
||||
backdrop-filter: blur(.5px) grayscale(50%);
|
||||
}
|
||||
}
|
||||
|
||||
// User interactions are now allowed
|
||||
&.o_bottom_sheet_snapping .o_bottom_sheet_rail {
|
||||
// Enable snap behavior
|
||||
scroll-snap-type: y mandatory;
|
||||
|
||||
.o_bottom_sheet_backdrop {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
// Provide a visual safenet in case of elastic
|
||||
// overscroll (mostly iOS).
|
||||
&:before {
|
||||
position: fixed;
|
||||
inset: auto auto 0 50%;
|
||||
height: calc(var(--sheet-height) - #{$border-radius-xl * 2});
|
||||
width: calc(100% - #{$border-width * 2});
|
||||
max-width: map-get($grid-breakpoints, sm) - ($border-width * 2);
|
||||
background: $offcanvas-bg-color;
|
||||
z-index: $zindex-offcanvas;
|
||||
transform: translateY(calc((1 - var(--BottomSheet-progress)) * 150%)) translateX(-50%);
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::after {
|
||||
@container scroll-state(scrollable: bottom) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dismissing the sheet
|
||||
&.o_bottom_sheet_dismissing {
|
||||
.o_bottom_sheet_sheet {
|
||||
animation: var(--BottomSheet-slideOut-duration, 300ms) bottom-sheet-out var(--BottomSheet-slideOut-easing, ease-in) forwards;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_bottom_sheet_backdrop {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(0) grayscale(0%);
|
||||
transition: all var(--BottomSheet-slideOut-duration, 300ms) var(--BottomSheet-slideOut-easing, ease-in);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When bottom sheet is open, apply styles to the body
|
||||
@at-root .bottom-sheet-open {
|
||||
overflow: hidden;
|
||||
|
||||
// Scale down the main content
|
||||
.o_navbar, .o_action_manager {
|
||||
transition: transform $o_BottomSheet_slideIn_duration ease;
|
||||
transform: scale(.95) translateZ(0);
|
||||
transform-origin: center top;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid blank on the side
|
||||
&:not(.o_home_menu_background) .o_main_navbar {
|
||||
box-shadow: 20px 0 0 $o-navbar-background, -20px 0 0 $o-navbar-background;
|
||||
}
|
||||
|
||||
&:not(.bottom-sheet-open-multiple):has(.o_bottom_sheet_dismissing) {
|
||||
.o_navbar, .o_action_manager {
|
||||
transition: transform $o_BottomSheet_slideOut_duration ease;
|
||||
transform: scale(1) translateZ(0);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Inner components design
|
||||
// =============================================
|
||||
.o_bottom_sheet .o_bottom_sheet_sheet {
|
||||
--BottomSheet-Entry-paddingX: #{$list-group-item-padding-x};
|
||||
|
||||
%BottomSheet-Entry-active {
|
||||
position: relative;
|
||||
border: $border-width solid $list-group-active-border-color;
|
||||
border-radius: $border-radius-lg;
|
||||
color: var(--BottomSheetStatusBar__entry-color--active, #{color-contrast($component-active-bg)});
|
||||
|
||||
&:not(.focus) {
|
||||
background: var(--BottomSheetStatusBar__entry-background--active, #{rgba($component-active-bg, .5)});
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@include o-position-absolute(50%, $list-group-item-padding-x);
|
||||
transform: translateY(-50%);
|
||||
color: $o-action;
|
||||
// .fa
|
||||
text-rendering: auto;
|
||||
font: normal normal normal 14px/1 FontAwesome;
|
||||
// .fa-check
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
// TreeEntry
|
||||
--treeEntry-padding-v: 1.4rem;
|
||||
|
||||
// Dropdown
|
||||
.dropdown-divider {
|
||||
--dropdown-divider-bg: #{$border-color};
|
||||
margin: map-get($spacers, 2) ($offcanvas-padding-x * .5);
|
||||
}
|
||||
|
||||
.dropdown-item, .dropdown-header {
|
||||
--dropdown-item-padding-y: #{map-get($spacers, 3)};
|
||||
--dropdown-item-padding-x: var(--BottomSheet-Entry-paddingX);
|
||||
--dropdown-header-padding-y: var(--dropdown-item-padding-y);
|
||||
--dropdown-header-padding-x: var(--dropdown-item-padding-x);
|
||||
|
||||
font-size: $h5-font-size;
|
||||
font-weight: $o-font-weight-medium;
|
||||
text-align: start !important;
|
||||
}
|
||||
|
||||
.dropdown-item .o_stat_value {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.o_bottom_sheet_body:not(.o_custom_bottom_sheet) {
|
||||
// Dropdown
|
||||
.dropdown-item {
|
||||
&.active, &.selected {
|
||||
@extend %BottomSheet-Entry-active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_accordion_toggle {
|
||||
&::after {
|
||||
// Reset original style
|
||||
border: unset;
|
||||
transform: unset;
|
||||
|
||||
@include o-position-absolute(var(--dropdown-item-padding-y), $list-group-item-padding-x);
|
||||
padding-block: map-get($spacers, 2);
|
||||
// .fa
|
||||
text-rendering: auto;
|
||||
font: normal normal normal 14px/1 FontAwesome;
|
||||
// .fa-caret-down
|
||||
content: "\f0d7";
|
||||
}
|
||||
&.open::after {
|
||||
// .fa-caret-up
|
||||
content: "\f0d8";
|
||||
}
|
||||
}
|
||||
|
||||
.o_kanban_card_manage_settings:has(.o_colorlist) {
|
||||
&, > div:last-child {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.row.o_kanban_card_manage_settings:last-child {
|
||||
&:has(:not(.o_field_boolean_favorite)) {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
.o_field_kanban_color_picker {
|
||||
padding: map-get($spacers, 3);
|
||||
}
|
||||
}
|
||||
|
||||
div[class*="col-"] + div[class*="col-"] {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
> div[class*="col-"] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
||||
a {
|
||||
margin: 0;
|
||||
padding: #{map-get($spacers, 3)} var(--BottomSheet-Entry-paddingX);
|
||||
font-size: $h5-font-size;
|
||||
font-weight: $o-font-weight-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
$o_BottomSheet_Sheet_borderColor: transparent !default;
|
||||
|
||||
$o_BottomSheet_slideIn_duration: 400ms !default;
|
||||
$o_BottomSheet_slideIn_easing: $o-easing-enter !default;
|
||||
|
||||
$o_BottomSheet_slideOut_duration: 200ms !default;
|
||||
$o_BottomSheet_slideOut_easing: $o-easing-exit !default;
|
||||
70
frontend/web/static/src/core/bottom_sheet/bottom_sheet.xml
Normal file
70
frontend/web/static/src/core/bottom_sheet/bottom_sheet.xml
Normal file
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.BottomSheet">
|
||||
<div
|
||||
class="o_bottom_sheet"
|
||||
t-att-class="{
|
||||
'o_bottom_sheet_ready': state.isPositionedReady,
|
||||
'o_bottom_sheet_dismissing': state.isDismissing,
|
||||
'o_bottom_sheet_snapping': state.isSnappingEnabled,
|
||||
}"
|
||||
t-attf-style="--BottomSheet-progress: {{state.progress}}"
|
||||
t-ref="container"
|
||||
>
|
||||
<!-- Scroll container that handles drag gestures -->
|
||||
<div
|
||||
class="o_bottom_sheet_rail"
|
||||
t-att-class="{
|
||||
o_bottom_sheet_rail_prevent_overscroll : props.preventDismissOnContentScroll
|
||||
}"
|
||||
t-ref="scrollRail"
|
||||
>
|
||||
<!-- Backdrop overlay -->
|
||||
<div class="o_bottom_sheet_backdrop" t-on-click="slideOut"/>
|
||||
|
||||
<!-- Dismiss area - used for scroll snap -->
|
||||
<div class="o_bottom_sheet_dismiss"/>
|
||||
|
||||
<!-- Spacer area - used for scroll snap -->
|
||||
<div class="o_bottom_sheet_spacer"/>
|
||||
|
||||
<!-- Sheet container -->
|
||||
<div
|
||||
class="o_bottom_sheet_sheet offcanvas position-relative overflow-hidden"
|
||||
role="dialog"
|
||||
t-ref="sheet"
|
||||
>
|
||||
|
||||
<!-- Handle bar -->
|
||||
<div
|
||||
tabindex="-1"
|
||||
role="button"
|
||||
class="o_bottom_sheet_handle text-center d-flex align-items-center justify-content-center opacity-50 py-2"
|
||||
t-on-click.stop="slideOut"
|
||||
>
|
||||
<div class="o_bottom_sheet_handle_bar pt-1 mx-auto rounded-pill d-inline-block px-5 bg-dark"/>
|
||||
</div>
|
||||
|
||||
<!-- Body content -->
|
||||
<div
|
||||
class="o_bottom_sheet_body offcanvas-body"
|
||||
role="menu"
|
||||
t-att-class="props.class"
|
||||
t-ref="ref"
|
||||
>
|
||||
|
||||
<!-- Render dynamic component if provided -->
|
||||
<t t-if="props.component">
|
||||
<t t-component="props.component" t-props="props.componentProps || {}"/>
|
||||
</t>
|
||||
|
||||
<!-- Render slot content if provided -->
|
||||
<t t-else="">
|
||||
<t t-slot="default" close="close" back="back"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { markRaw } from "@odoo/owl";
|
||||
import { BottomSheet } from "@web/core/bottom_sheet/bottom_sheet";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* env?: object;
|
||||
* onClose?: () => void;
|
||||
* class?: string;
|
||||
* role?: string;
|
||||
* ref?: Function;
|
||||
* useBottomSheet?: Boolean;
|
||||
* }} PopoverServiceAddOptions
|
||||
*
|
||||
* @typedef {ReturnType<popoverService["start"]>["add"]} PopoverServiceAddFunction
|
||||
*/
|
||||
|
||||
export const popoverService = {
|
||||
dependencies: ["overlay"],
|
||||
start(_, { overlay }) {
|
||||
let bottomSheetCount = 0;
|
||||
/**
|
||||
* Signals the manager to add a popover.
|
||||
*
|
||||
* @param {HTMLElement} target
|
||||
* @param {typeof import("@odoo/owl").Component} component
|
||||
* @param {object} [props]
|
||||
* @param {PopoverServiceAddOptions} [options]
|
||||
* @returns {() => void}
|
||||
*/
|
||||
const add = (target, component, props = {}, options = {}) => {
|
||||
function removeAndUpdateCount() {
|
||||
_remove();
|
||||
bottomSheetCount--;
|
||||
if (bottomSheetCount === 0) {
|
||||
document.body.classList.remove("bottom-sheet-open");
|
||||
} else if (bottomSheetCount === 1) {
|
||||
document.body.classList.remove("bottom-sheet-open-multiple");
|
||||
}
|
||||
}
|
||||
const _remove = overlay.add(
|
||||
BottomSheet,
|
||||
{
|
||||
close: removeAndUpdateCount,
|
||||
component,
|
||||
componentProps: markRaw(props),
|
||||
ref: options.ref,
|
||||
class: options.class,
|
||||
role: options.role,
|
||||
},
|
||||
{
|
||||
env: options.env,
|
||||
onRemove: options.onClose,
|
||||
rootId: target.getRootNode()?.host?.id,
|
||||
}
|
||||
);
|
||||
bottomSheetCount++;
|
||||
if (bottomSheetCount === 1) {
|
||||
document.body.classList.add("bottom-sheet-open");
|
||||
} else if (bottomSheetCount > 1) {
|
||||
document.body.classList.add("bottom-sheet-open-multiple");
|
||||
}
|
||||
|
||||
return removeAndUpdateCount;
|
||||
};
|
||||
|
||||
return { add };
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bottom_sheet", popoverService);
|
||||
112
frontend/web/static/src/core/browser/browser.js
Normal file
112
frontend/web/static/src/core/browser/browser.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 {
|
||||
localStorage = makeRAMLocalStorage();
|
||||
sessionStorage = makeRAMLocalStorage();
|
||||
}
|
||||
|
||||
export const browser = {
|
||||
addEventListener: window.addEventListener.bind(window),
|
||||
dispatchEvent: window.dispatchEvent.bind(window),
|
||||
AnalyserNode: window.AnalyserNode,
|
||||
Audio: window.Audio,
|
||||
AudioBufferSourceNode: window.AudioBufferSourceNode,
|
||||
AudioContext: window.AudioContext,
|
||||
AudioWorkletNode: window.AudioWorkletNode,
|
||||
BeforeInstallPromptEvent: window.BeforeInstallPromptEvent?.bind(window),
|
||||
GainNode: window.GainNode,
|
||||
MediaStreamAudioSourceNode: window.MediaStreamAudioSourceNode,
|
||||
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,
|
||||
matchMedia: window.matchMedia.bind(window),
|
||||
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,
|
||||
BroadcastChannel: window.BroadcastChannel,
|
||||
visualViewport: window.visualViewport,
|
||||
};
|
||||
|
||||
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] ?? null;
|
||||
},
|
||||
clear() {
|
||||
store = {};
|
||||
},
|
||||
removeItem(key) {
|
||||
delete store[key];
|
||||
window.dispatchEvent(new StorageEvent("storage", { key, newValue: null }));
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key() {
|
||||
return "";
|
||||
},
|
||||
};
|
||||
}
|
||||
37
frontend/web/static/src/core/browser/cookie.js
Normal file
37
frontend/web/static/src/core/browser/cookie.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Utils 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;
|
||||
|
||||
export const cookie = {
|
||||
get _cookieMonster() {
|
||||
return document.cookie;
|
||||
},
|
||||
set _cookieMonster(value) {
|
||||
document.cookie = value;
|
||||
},
|
||||
get(str) {
|
||||
const parts = this._cookieMonster.split("; ");
|
||||
for (const part of parts) {
|
||||
const [key, value] = part.split(/=(.*)/);
|
||||
if (key === str) {
|
||||
return value || "";
|
||||
}
|
||||
}
|
||||
},
|
||||
set(key, value, ttl = COOKIE_TTL) {
|
||||
let fullCookie = [];
|
||||
if (value !== undefined) {
|
||||
fullCookie.push(`${key}=${value}`);
|
||||
}
|
||||
fullCookie = fullCookie.concat(["path=/", `max-age=${Math.floor(ttl)}`]);
|
||||
this._cookieMonster = fullCookie.join("; ");
|
||||
},
|
||||
delete(key) {
|
||||
this.set(key, "kill", 0);
|
||||
},
|
||||
};
|
||||
83
frontend/web/static/src/core/browser/feature_detection.js
Normal file
83
frontend/web/static/src/core/browser/feature_detection.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { browser } from "./browser";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Feature detection
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True if the browser is based on Chromium (Google Chrome, Opera, Edge).
|
||||
*/
|
||||
export function isBrowserChrome() {
|
||||
return /Chrome/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isBrowserFirefox() {
|
||||
return /Firefox/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the browser is Microsoft Edge.
|
||||
*/
|
||||
export function isBrowserMicrosoftEdge() {
|
||||
return /Edg/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* true if the browser is based on Safari (Safari, Epiphany)
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isBrowserSafari() {
|
||||
return !isBrowserChrome() && browser.navigator.userAgent?.includes("Safari");
|
||||
}
|
||||
|
||||
export function isAndroid() {
|
||||
return /Android/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isIOS() {
|
||||
let isIOSPlatform = false;
|
||||
if ("platform" in browser.navigator) {
|
||||
isIOSPlatform = browser.navigator.platform === "MacIntel";
|
||||
}
|
||||
return (
|
||||
/(iPad|iPhone|iPod)/i.test(browser.navigator.userAgent) ||
|
||||
(isIOSPlatform && maxTouchPoints() > 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function isOtherMobileOS() {
|
||||
return /(webOS|BlackBerry|Windows Phone)/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isMacOS() {
|
||||
return /Mac/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isMobileOS() {
|
||||
return isAndroid() || isIOS() || isOtherMobileOS();
|
||||
}
|
||||
|
||||
export function isIosApp() {
|
||||
return /OdooMobile \(iOS\)/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isAndroidApp() {
|
||||
return /OdooMobile.+Android/i.test(browser.navigator.userAgent);
|
||||
}
|
||||
|
||||
export function isDisplayStandalone() {
|
||||
return browser.matchMedia("(display-mode: standalone)").matches;
|
||||
}
|
||||
|
||||
export function hasTouch() {
|
||||
return browser.ontouchstart !== undefined || browser.matchMedia("(pointer:coarse)").matches;
|
||||
}
|
||||
|
||||
export function maxTouchPoints() {
|
||||
return browser.navigator.maxTouchPoints || 1;
|
||||
}
|
||||
|
||||
export function isVirtualKeyboardSupported() {
|
||||
return "virtualKeyboard" in browser.navigator;
|
||||
}
|
||||
423
frontend/web/static/src/core/browser/router.js
Normal file
423
frontend/web/static/src/core/browser/router.js
Normal file
@@ -0,0 +1,423 @@
|
||||
import { EventBus } from "@odoo/owl";
|
||||
import { omit, pick } from "../utils/objects";
|
||||
import { compareUrls, objectToUrlEncodedString } from "../utils/urls";
|
||||
import { browser } from "./browser";
|
||||
import { isDisplayStandalone } from "@web/core/browser/feature_detection";
|
||||
import { slidingWindow } from "@web/core/utils/arrays";
|
||||
import { isNumeric } from "@web/core/utils/strings";
|
||||
|
||||
// Keys that are serialized in the URL as path segments instead of query string
|
||||
export const PATH_KEYS = ["resId", "action", "active_id", "model"];
|
||||
|
||||
export const routerBus = new EventBus();
|
||||
|
||||
function isScopedApp() {
|
||||
return browser.location.href.includes("/scoped_app") && isDisplayStandalone();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
/**
|
||||
* @param {object} values An object with the values of the new state
|
||||
* @param {boolean} replace whether the values should replace the state or be
|
||||
* layered on top of the current state
|
||||
* @returns {object} the next state of the router
|
||||
*/
|
||||
function computeNextState(values, replace) {
|
||||
const nextState = replace ? pick(state, ..._lockedKeys) : { ...state };
|
||||
Object.assign(nextState, values);
|
||||
// Update last entry in the actionStack
|
||||
if (nextState.actionStack?.length) {
|
||||
Object.assign(nextState.actionStack.at(-1), pick(nextState, ...PATH_KEYS));
|
||||
}
|
||||
return sanitizeSearch(nextState);
|
||||
}
|
||||
|
||||
function sanitize(obj, valueToRemove) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj)
|
||||
.filter(([, v]) => v !== valueToRemove)
|
||||
.map(([k, v]) => [k, cast(v)])
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeSearch(search) {
|
||||
return sanitize(search);
|
||||
}
|
||||
|
||||
function sanitizeHash(hash) {
|
||||
return sanitize(hash, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @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)) : {};
|
||||
}
|
||||
|
||||
function pathFromActionState(state) {
|
||||
const path = [];
|
||||
const { action, model, active_id, resId } = state;
|
||||
if (active_id && typeof active_id === "number") {
|
||||
path.push(active_id);
|
||||
}
|
||||
if (action) {
|
||||
if (typeof action === "number" || action.includes(".")) {
|
||||
path.push(`action-${action}`);
|
||||
} else {
|
||||
path.push(action);
|
||||
}
|
||||
} else if (model) {
|
||||
if (model.includes(".")) {
|
||||
path.push(model);
|
||||
} else {
|
||||
// A few models don't have a dot at all, we need to distinguish
|
||||
// them from action paths (eg: website)
|
||||
path.push(`m-${model}`);
|
||||
}
|
||||
}
|
||||
if (resId && (typeof resId === "number" || resId === "new")) {
|
||||
path.push(resId);
|
||||
}
|
||||
return path.join("/");
|
||||
}
|
||||
|
||||
export function startUrl() {
|
||||
return isScopedApp() ? "scoped_app" : "odoo";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ [key: string]: any }} state
|
||||
* @returns
|
||||
*/
|
||||
function stateToUrl(state) {
|
||||
let path = "";
|
||||
const pathKeysToOmit = [..._hiddenKeysFromUrl];
|
||||
const actionStack = (state.actionStack || [state]).map((a) => ({ ...a }));
|
||||
if (actionStack.at(-1)?.action !== "menu") {
|
||||
for (const [prevAct, currentAct] of slidingWindow(actionStack, 2).reverse()) {
|
||||
const { action: prevAction, resId: prevResId, active_id: prevActiveId } = prevAct;
|
||||
const { action: currentAction, active_id: currentActiveId } = currentAct;
|
||||
// actions would typically map to a path like `active_id/action/res_id`
|
||||
if (currentActiveId === prevResId) {
|
||||
// avoid doubling up when the active_id is the same as the previous action's res_id
|
||||
delete currentAct.active_id;
|
||||
}
|
||||
if (prevAction === currentAction && !prevResId && currentActiveId === prevActiveId) {
|
||||
//avoid doubling up the action and the active_id when a single-record action is preceded by a multi-record action
|
||||
delete currentAct.action;
|
||||
delete currentAct.active_id;
|
||||
}
|
||||
}
|
||||
const pathSegments = actionStack.map(pathFromActionState).filter(Boolean);
|
||||
if (pathSegments.length) {
|
||||
path = `/${pathSegments.join("/")}`;
|
||||
}
|
||||
}
|
||||
if (state.active_id && typeof state.active_id !== "number") {
|
||||
pathKeysToOmit.splice(pathKeysToOmit.indexOf("active_id"), 1);
|
||||
}
|
||||
if (state.resId && typeof state.resId !== "number" && state.resId !== "new") {
|
||||
pathKeysToOmit.splice(pathKeysToOmit.indexOf("resId"), 1);
|
||||
}
|
||||
const search = objectToUrlEncodedString(omit(state, ...pathKeysToOmit));
|
||||
const start_url = startUrl();
|
||||
return `/${start_url}${path}${search ? `?${search}` : ""}`;
|
||||
}
|
||||
|
||||
function urlToState(urlObj) {
|
||||
const { pathname, hash, search } = urlObj;
|
||||
const state = parseSearchQuery(search);
|
||||
|
||||
// ** url-retrocompatibility **
|
||||
// If the url contains a hash, it can be for two motives:
|
||||
// 1. It is an anchor link, in that case, we ignore it, as it will not have a keys/values format
|
||||
// the sanitizeHash function will remove it from the hash object.
|
||||
// 2. It has one or more keys/values, in that case, we merge it with the search.
|
||||
if (pathname === "/web") {
|
||||
const sanitizedHash = sanitizeHash(parseHash(hash));
|
||||
// Old urls used "id", it is now resId for clarity. Remap to the new name.
|
||||
if (sanitizedHash.id) {
|
||||
sanitizedHash.resId = sanitizedHash.id;
|
||||
delete sanitizedHash.id;
|
||||
delete sanitizedHash.view_type;
|
||||
} else if (sanitizedHash.view_type === "form") {
|
||||
sanitizedHash.resId = "new";
|
||||
delete sanitizedHash.view_type;
|
||||
}
|
||||
Object.assign(state, sanitizedHash);
|
||||
const url = browser.location.origin + router.stateToUrl(state);
|
||||
urlObj.href = url;
|
||||
}
|
||||
|
||||
const [prefix, ...splitPath] = urlObj.pathname.split("/").filter(Boolean);
|
||||
|
||||
if (["odoo", "scoped_app"].includes(prefix)) {
|
||||
const actionParts = [...splitPath.entries()].filter(
|
||||
([_, part]) => !isNumeric(part) && part !== "new"
|
||||
);
|
||||
const actions = [];
|
||||
for (const [i, part] of actionParts) {
|
||||
const action = {};
|
||||
const [left, right] = [splitPath[i - 1], splitPath[i + 1]];
|
||||
if (isNumeric(left)) {
|
||||
action.active_id = parseInt(left);
|
||||
}
|
||||
|
||||
if (right === "new") {
|
||||
action.resId = "new";
|
||||
} else if (isNumeric(right)) {
|
||||
action.resId = parseInt(right);
|
||||
}
|
||||
|
||||
if (part.startsWith("action-")) {
|
||||
// numeric id or xml_id
|
||||
const actionId = part.slice(7);
|
||||
action.action = isNumeric(actionId) ? parseInt(actionId) : actionId;
|
||||
} else if (part.startsWith("m-")) {
|
||||
action.model = part.slice(2);
|
||||
} else if (part.includes(".")) {
|
||||
action.model = part;
|
||||
} else {
|
||||
// action tag or path
|
||||
action.action = part;
|
||||
}
|
||||
|
||||
if (action.resId && action.action) {
|
||||
actions.push(omit(action, "resId"));
|
||||
}
|
||||
// Don't create actions for models without resId unless they're the last one.
|
||||
// If the last one is a model but doesn't have a view_type, the action service will not mount it anyway.
|
||||
if (action.action || action.resId || i === splitPath.length - 1) {
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
const activeAction = actions.at(-1);
|
||||
if (activeAction) {
|
||||
Object.assign(state, activeAction);
|
||||
state.actionStack = actions;
|
||||
}
|
||||
if (prefix === "scoped_app" && !isDisplayStandalone()) {
|
||||
// make sure /scoped_app are redirected to /odoo when using the browser instead of the PWA
|
||||
const url = browser.location.origin + router.stateToUrl(state);
|
||||
urlObj.href = url;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
let state;
|
||||
let pushTimeout;
|
||||
let pushArgs;
|
||||
let _lockedKeys;
|
||||
let _hiddenKeysFromUrl = new Set();
|
||||
|
||||
export function startRouter() {
|
||||
const url = new URL(browser.location);
|
||||
state = router.urlToState(url);
|
||||
// ** url-retrocompatibility **
|
||||
if (browser.location.pathname === "/web") {
|
||||
// Change the url of the current history entry to the canonical url.
|
||||
// This change should be done only at the first load, and not when clicking on old style internal urls.
|
||||
// Or when clicking back/forward on the browser.
|
||||
browser.history.replaceState(browser.history.state, null, url.href);
|
||||
}
|
||||
pushTimeout = null;
|
||||
pushArgs = {
|
||||
replace: false,
|
||||
reload: false,
|
||||
state: {},
|
||||
};
|
||||
_lockedKeys = new Set(["debug", "lang"]);
|
||||
_hiddenKeysFromUrl = new Set([...PATH_KEYS, "actionStack"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user navigates history using the back/forward button, the browser
|
||||
* dispatches a popstate event with the state that was in the history for the
|
||||
* corresponding history entry. We just adopt that state so that the webclient
|
||||
* can use that previous state without forcing a full page reload.
|
||||
*/
|
||||
browser.addEventListener("popstate", (ev) => {
|
||||
browser.clearTimeout(pushTimeout);
|
||||
if (!ev.state) {
|
||||
// We are coming from a click on an anchor.
|
||||
// Add the current state to the history entry so that a future loadstate behaves as expected.
|
||||
browser.history.replaceState({ nextState: state }, "", browser.location.href);
|
||||
return;
|
||||
}
|
||||
state = ev.state?.nextState || router.urlToState(new URL(browser.location));
|
||||
// Some client actions want to handle loading their own state. This is a ugly hack to allow not
|
||||
// reloading the webclient's state when they manipulate history.
|
||||
if (!ev.state?.skipRouteChange && !router.skipLoad) {
|
||||
routerBus.trigger("ROUTE_CHANGE");
|
||||
}
|
||||
router.skipLoad = false;
|
||||
});
|
||||
|
||||
/**
|
||||
* When the user navigates the history using the back/forward button, some browsers (Safari iOS and
|
||||
* Safari MacOS) can restore the page using the `bfcache` (especially when we come back from an
|
||||
* external website). Unfortunately, Odoo wasn't designed to be compatible with this cache, which
|
||||
* leads to inconsistencies. When the `bfcache` is used to restore a page, we reload the current
|
||||
* page, to be sure that all the elements have been rendered correctly.
|
||||
*/
|
||||
browser.addEventListener("pageshow", (ev) => {
|
||||
if (ev.persisted) {
|
||||
browser.clearTimeout(pushTimeout);
|
||||
routerBus.trigger("ROUTE_CHANGE");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* When clicking internal links, do a loadState instead of a full page reload.
|
||||
* This also alows the mobile app to not open an in-app browser for them.
|
||||
*/
|
||||
browser.addEventListener("click", (ev) => {
|
||||
if (ev.defaultPrevented || ev.target.closest("[contenteditable]")) {
|
||||
return;
|
||||
}
|
||||
const a = ev.target.closest("a");
|
||||
const href = a?.getAttribute("href");
|
||||
if (href && !href.startsWith("#")) {
|
||||
let url;
|
||||
try {
|
||||
// ev.target.href is the full url including current path
|
||||
url = new URL(a.href);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
browser.location.host === url.host &&
|
||||
browser.location.pathname.startsWith("/odoo") &&
|
||||
(["/web", "/odoo"].includes(url.pathname) || url.pathname.startsWith("/odoo/")) &&
|
||||
a.target !== "_blank"
|
||||
) {
|
||||
ev.preventDefault();
|
||||
state = router.urlToState(url);
|
||||
if (url.pathname.startsWith("/odoo") && url.hash) {
|
||||
browser.history.pushState({}, "", url.href);
|
||||
}
|
||||
new Promise((res) => setTimeout(res, 0)).then(() => routerBus.trigger("ROUTE_CHANGE"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {string} mode
|
||||
*/
|
||||
function makeDebouncedPush(mode) {
|
||||
function doPush() {
|
||||
// Calculates new route based on aggregated search and options
|
||||
const nextState = computeNextState(pushArgs.state, pushArgs.replace);
|
||||
const url = browser.location.origin + router.stateToUrl(nextState);
|
||||
if (!compareUrls(url + browser.location.hash, browser.location.href)) {
|
||||
// If the route changed: pushes or replaces browser state
|
||||
if (mode === "push") {
|
||||
// Because doPush is delayed, the history entry will have the wrong name.
|
||||
// We set the document title to what it was at the time of the pushState
|
||||
// call, then push, which generates the history entry with the right title
|
||||
// then restore the title to what it's supposed to be
|
||||
const originalTitle = document.title;
|
||||
document.title = pushArgs.title;
|
||||
browser.history.pushState({ nextState }, "", url);
|
||||
document.title = originalTitle;
|
||||
} else {
|
||||
browser.history.replaceState({ nextState }, "", url);
|
||||
}
|
||||
} else {
|
||||
// URL didn't change but state might have, update it in place
|
||||
browser.history.replaceState({ nextState }, "", browser.location.href);
|
||||
}
|
||||
state = nextState;
|
||||
if (pushArgs.reload) {
|
||||
browser.location.reload();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {object} state
|
||||
* @param {object} options
|
||||
*/
|
||||
return function pushOrReplaceState(state, options = {}) {
|
||||
pushArgs.replace ||= options.replace;
|
||||
pushArgs.reload ||= options.reload;
|
||||
pushArgs.title = document.title;
|
||||
Object.assign(pushArgs.state, state);
|
||||
browser.clearTimeout(pushTimeout);
|
||||
const push = () => {
|
||||
doPush();
|
||||
pushTimeout = null;
|
||||
pushArgs = {
|
||||
replace: false,
|
||||
reload: false,
|
||||
state: {},
|
||||
};
|
||||
};
|
||||
if (options.sync) {
|
||||
push();
|
||||
} else {
|
||||
pushTimeout = browser.setTimeout(() => {
|
||||
push();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const router = {
|
||||
get current() {
|
||||
return state;
|
||||
},
|
||||
// state <-> url conversions can be patched if needed in a custom webclient.
|
||||
stateToUrl,
|
||||
urlToState,
|
||||
// TODO: stop debouncing these and remove the ugly hack to have the correct title for history entries
|
||||
pushState: makeDebouncedPush("push"),
|
||||
replaceState: makeDebouncedPush("replace"),
|
||||
cancelPushes: () => browser.clearTimeout(pushTimeout),
|
||||
addLockedKey: (key) => _lockedKeys.add(key),
|
||||
hideKeyFromUrl: (key) => _hiddenKeysFromUrl.add(key),
|
||||
skipLoad: false,
|
||||
};
|
||||
|
||||
startRouter();
|
||||
|
||||
export function objectToQuery(obj) {
|
||||
const query = {};
|
||||
Object.entries(obj).forEach(([k, v]) => {
|
||||
query[k] = v ? String(v) : v;
|
||||
});
|
||||
return query;
|
||||
}
|
||||
60
frontend/web/static/src/core/browser/title_service.js
Normal file
60
frontend/web/static/src/core/browser/title_service.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { registry } from "../registry";
|
||||
|
||||
export const titleService = {
|
||||
start() {
|
||||
const titleCounters = {};
|
||||
const titleParts = {};
|
||||
|
||||
function getParts() {
|
||||
return Object.assign({}, titleParts);
|
||||
}
|
||||
|
||||
function setCounters(counters) {
|
||||
for (const key in counters) {
|
||||
const val = counters[key];
|
||||
if (!val) {
|
||||
delete titleCounters[key];
|
||||
} else {
|
||||
titleCounters[key] = val;
|
||||
}
|
||||
}
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
function setParts(parts) {
|
||||
for (const key in parts) {
|
||||
const val = parts[key];
|
||||
if (!val) {
|
||||
delete titleParts[key];
|
||||
} else {
|
||||
titleParts[key] = val;
|
||||
}
|
||||
}
|
||||
updateTitle();
|
||||
}
|
||||
|
||||
function updateTitle() {
|
||||
const counter = Object.values(titleCounters).reduce((acc, count) => acc + count, 0);
|
||||
const name = Object.values(titleParts).join(" - ") || "Odoo";
|
||||
if (!counter) {
|
||||
document.title = name;
|
||||
} else {
|
||||
document.title = `(${counter}) ${name}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
get current() {
|
||||
return document.title;
|
||||
},
|
||||
getParts,
|
||||
setCounters,
|
||||
setParts,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("title", titleService);
|
||||
98
frontend/web/static/src/core/checkbox/checkbox.js
Normal file
98
frontend/web/static/src/core/checkbox/checkbox.js
Normal file
@@ -0,0 +1,98 @@
|
||||
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 {
|
||||
static template = "web.CheckBox";
|
||||
static nextId = 1;
|
||||
static defaultProps = {
|
||||
onChange: () => {},
|
||||
};
|
||||
static 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,
|
||||
},
|
||||
indeterminate: {
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
3
frontend/web/static/src/core/checkbox/checkbox.scss
Normal file
3
frontend/web/static/src/core/checkbox/checkbox.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.o-checkbox {
|
||||
width: fit-content;
|
||||
}
|
||||
22
frontend/web/static/src/core/checkbox/checkbox.xml
Normal file
22
frontend/web/static/src/core/checkbox/checkbox.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CheckBox">
|
||||
<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-att-indeterminate="props.indeterminate"
|
||||
t-on-change="onChange"
|
||||
/>
|
||||
<label t-att-for="props.id or id" class="form-check-label">
|
||||
<t t-slot="default"/>
|
||||
</label>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
180
frontend/web/static/src/core/code_editor/code_editor.js
Normal file
180
frontend/web/static/src/core/code_editor/code_editor.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Component, onMounted, onWillStart, useEffect, useRef, useState, status } from "@odoo/owl";
|
||||
import { loadBundle } from "@web/core/assets";
|
||||
|
||||
export class CodeEditor extends Component {
|
||||
static template = "web.CodeEditor";
|
||||
static components = {};
|
||||
static props = {
|
||||
mode: {
|
||||
type: String,
|
||||
optional: true,
|
||||
validate: (mode) => CodeEditor.MODES.includes(mode),
|
||||
},
|
||||
value: { validate: (v) => typeof v === "string", optional: true },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
onChange: { type: Function, optional: true },
|
||||
onBlur: { type: Function, optional: true },
|
||||
class: { type: String, optional: true },
|
||||
theme: {
|
||||
type: String,
|
||||
optional: true,
|
||||
validate: (theme) => CodeEditor.THEMES.includes(theme),
|
||||
},
|
||||
maxLines: { type: Number, optional: true },
|
||||
sessionId: { type: [Number, String], optional: true },
|
||||
initialCursorPosition: { type: Object, optional: true },
|
||||
showLineNumbers: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
readonly: false,
|
||||
value: "",
|
||||
onChange: () => {},
|
||||
class: "",
|
||||
theme: "",
|
||||
sessionId: 1,
|
||||
showLineNumbers: true,
|
||||
};
|
||||
|
||||
static MODES = ["javascript", "xml", "qweb", "scss", "python"];
|
||||
static THEMES = ["", "monokai"];
|
||||
|
||||
setup() {
|
||||
this.editorRef = useRef("editorRef");
|
||||
this.state = useState({
|
||||
activeMode: undefined,
|
||||
});
|
||||
|
||||
onWillStart(async () => await loadBundle("web.ace_lib"));
|
||||
|
||||
const sessions = {};
|
||||
// The ace library triggers the "change" event even if the change is
|
||||
// programmatic. Even worse, it triggers 2 "change" events in that case,
|
||||
// one with the empty string, and one with the new value. We only want
|
||||
// to notify the parent of changes done by the user, in the UI, so we
|
||||
// use this flag to filter out noisy "change" events.
|
||||
let ignoredAceChange = false;
|
||||
useEffect(
|
||||
(el) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
// keep in closure
|
||||
const aceEditor = window.ace.edit(el);
|
||||
this.aceEditor = aceEditor;
|
||||
|
||||
this.aceEditor.setOptions({
|
||||
maxLines: this.props.maxLines,
|
||||
showPrintMargin: false,
|
||||
useWorker: false,
|
||||
});
|
||||
this.aceEditor.$blockScrolling = true;
|
||||
|
||||
this.aceEditor.on("changeMode", () => {
|
||||
this.state.activeMode = this.aceEditor.getSession().$modeId.split("/").at(-1);
|
||||
});
|
||||
|
||||
const session = aceEditor.getSession();
|
||||
if (!sessions[this.props.sessionId]) {
|
||||
sessions[this.props.sessionId] = session;
|
||||
}
|
||||
session.setValue(this.props.value);
|
||||
session.on("change", () => {
|
||||
if (this.props.onChange && !ignoredAceChange) {
|
||||
this.props.onChange(
|
||||
this.aceEditor.getValue(),
|
||||
this.aceEditor.getCursorPosition()
|
||||
);
|
||||
}
|
||||
});
|
||||
this.aceEditor.on("blur", () => {
|
||||
if (this.props.onBlur) {
|
||||
this.props.onBlur();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
aceEditor.destroy();
|
||||
};
|
||||
},
|
||||
() => [this.editorRef.el]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
(theme) => this.aceEditor.setTheme(theme ? `ace/theme/${theme}` : ""),
|
||||
() => [this.props.theme]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
(readonly, showLineNumbers) => {
|
||||
this.aceEditor.setOptions({
|
||||
readOnly: readonly,
|
||||
highlightActiveLine: !readonly,
|
||||
highlightGutterLine: !readonly,
|
||||
});
|
||||
|
||||
this.aceEditor.renderer.setOptions({
|
||||
displayIndentGuides: !readonly,
|
||||
showGutter: !readonly && showLineNumbers,
|
||||
});
|
||||
|
||||
this.aceEditor.renderer.$cursorLayer.element.style.display = readonly
|
||||
? "none"
|
||||
: "block";
|
||||
},
|
||||
() => [this.props.readonly, this.props.showLineNumbers]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
(sessionId, mode, value) => {
|
||||
let session = sessions[sessionId];
|
||||
if (session) {
|
||||
if (session.getValue() !== value) {
|
||||
ignoredAceChange = true;
|
||||
session.setValue(value);
|
||||
ignoredAceChange = false;
|
||||
}
|
||||
} else {
|
||||
session = new window.ace.EditSession(value);
|
||||
session.setUndoManager(new window.ace.UndoManager());
|
||||
session.setOptions({
|
||||
useWorker: false,
|
||||
tabSize: 2,
|
||||
useSoftTabs: true,
|
||||
});
|
||||
session.on("change", () => {
|
||||
if (this.props.onChange && !ignoredAceChange) {
|
||||
this.props.onChange(
|
||||
this.aceEditor.getValue(),
|
||||
this.aceEditor.getCursorPosition()
|
||||
);
|
||||
}
|
||||
});
|
||||
sessions[sessionId] = session;
|
||||
}
|
||||
session.setMode(mode ? `ace/mode/${mode}` : "");
|
||||
this.aceEditor.setSession(session);
|
||||
},
|
||||
() => [this.props.sessionId, this.props.mode, this.props.value]
|
||||
);
|
||||
|
||||
const initialCursorPosition = this.props.initialCursorPosition;
|
||||
if (initialCursorPosition) {
|
||||
onMounted(() => {
|
||||
// Wait for ace to be fully operational
|
||||
window.requestAnimationFrame(() => {
|
||||
if (status(this) != "destroyed" && this.aceEditor) {
|
||||
this.aceEditor.focus();
|
||||
const { row, column } = initialCursorPosition;
|
||||
const pos = {
|
||||
row: row || 0,
|
||||
column: column || 0,
|
||||
};
|
||||
this.aceEditor.selection.moveToPosition(pos);
|
||||
this.aceEditor.renderer.scrollCursorIntoView(pos, 0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
8
frontend/web/static/src/core/code_editor/code_editor.xml
Normal file
8
frontend/web/static/src/core/code_editor/code_editor.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
|
||||
<t t-name="web.CodeEditor">
|
||||
<div t-ref="editorRef" class="w-100" t-att-class="props.class" t-att-data-mode="state.activeMode"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
362
frontend/web/static/src/core/color_picker/color_picker.js
Normal file
362
frontend/web/static/src/core/color_picker/color_picker.js
Normal file
@@ -0,0 +1,362 @@
|
||||
import { Component, useEffect, useRef, useState } from "@odoo/owl";
|
||||
import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { isCSSColor, isColorGradient, normalizeCSSColor } from "@web/core/utils/colors";
|
||||
import { cookie } from "@web/core/browser/cookie";
|
||||
import { POSITION_BUS } from "../position/position_hook";
|
||||
import { registry } from "../registry";
|
||||
|
||||
// These colors are already normalized as per normalizeCSSColor in @web/legacy/js/widgets/colorpicker
|
||||
export const DEFAULT_COLORS = [
|
||||
["#000000", "#424242", "#636363", "#9C9C94", "#CEC6CE", "#EFEFEF", "#F7F7F7", "#FFFFFF"],
|
||||
["#FF0000", "#FF9C00", "#FFFF00", "#00FF00", "#00FFFF", "#0000FF", "#9C00FF", "#FF00FF"],
|
||||
["#F7C6CE", "#FFE7CE", "#FFEFC6", "#D6EFD6", "#CEDEE7", "#CEE7F7", "#D6D6E7", "#E7D6DE"],
|
||||
["#E79C9C", "#FFC69C", "#FFE79C", "#B5D6A5", "#A5C6CE", "#9CC6EF", "#B5A5D6", "#D6A5BD"],
|
||||
["#E76363", "#F7AD6B", "#FFD663", "#94BD7B", "#73A5AD", "#6BADDE", "#8C7BC6", "#C67BA5"],
|
||||
["#CE0000", "#E79439", "#EFC631", "#6BA54A", "#4A7B8C", "#3984C6", "#634AA5", "#A54A7B"],
|
||||
["#9C0000", "#B56308", "#BD9400", "#397B21", "#104A5A", "#085294", "#311873", "#731842"],
|
||||
["#630000", "#7B3900", "#846300", "#295218", "#083139", "#003163", "#21104A", "#4A1031"],
|
||||
];
|
||||
|
||||
export const DEFAULT_GRAYSCALES = {
|
||||
solid: ["black", "900", "800", "600", "400", "200", "100", "white"],
|
||||
};
|
||||
|
||||
// These CSS variables are defined in html_editor.
|
||||
// Using ColorPicker without html_editor installed is extremely unlikely.
|
||||
export const DEFAULT_THEME_COLOR_VARS = [
|
||||
"o-color-1",
|
||||
"o-color-2",
|
||||
"o-color-3",
|
||||
"o-color-4",
|
||||
"o-color-5",
|
||||
];
|
||||
|
||||
export class ColorPicker extends Component {
|
||||
static template = "web.ColorPicker";
|
||||
static components = { CustomColorPicker };
|
||||
static props = {
|
||||
state: {
|
||||
type: Object,
|
||||
shape: {
|
||||
selectedColor: String,
|
||||
selectedColorCombination: { type: String, optional: true },
|
||||
getTargetedElements: { type: Function, optional: true },
|
||||
defaultTab: String,
|
||||
selectedTab: { type: String, optional: true },
|
||||
// todo: remove the `mode` prop in master
|
||||
mode: { type: String, optional: true },
|
||||
},
|
||||
},
|
||||
getUsedCustomColors: Function,
|
||||
applyColor: Function,
|
||||
applyColorPreview: Function,
|
||||
applyColorResetPreview: Function,
|
||||
editColorCombination: { type: Function, optional: true },
|
||||
setOnCloseCallback: { type: Function, optional: true },
|
||||
setOperationCallbacks: { type: Function, optional: true },
|
||||
enabledTabs: { type: Array, optional: true },
|
||||
colorPrefix: { type: String },
|
||||
cssVarColorPrefix: { type: String, optional: true },
|
||||
defaultOpacity: { type: Number, optional: true },
|
||||
grayscales: { type: Object, optional: true },
|
||||
noTransparency: { type: Boolean, optional: true },
|
||||
close: { type: Function, optional: true },
|
||||
className: { type: String, optional: true },
|
||||
useDefaultThemeColors: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
close: () => {},
|
||||
defaultOpacity: 100,
|
||||
enabledTabs: ["solid", "custom"],
|
||||
cssVarColorPrefix: "",
|
||||
setOnCloseCallback: () => {},
|
||||
useDefaultThemeColors: true,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.tabs = registry
|
||||
.category("color_picker_tabs")
|
||||
.getAll()
|
||||
.filter((tab) => this.props.enabledTabs.includes(tab.id));
|
||||
this.root = useRef("root");
|
||||
|
||||
this.DEFAULT_COLORS = DEFAULT_COLORS;
|
||||
this.grayscales = Object.assign({}, DEFAULT_GRAYSCALES, this.props.grayscales);
|
||||
this.DEFAULT_THEME_COLOR_VARS = this.props.useDefaultThemeColors
|
||||
? DEFAULT_THEME_COLOR_VARS
|
||||
: [];
|
||||
this.defaultColorSet = this.getDefaultColorSet();
|
||||
this.defaultColor = this.props.state.selectedColor;
|
||||
this.focusedBtn = null;
|
||||
this.onApplyCallback = () => {};
|
||||
this.onPreviewRevertCallback = () => {};
|
||||
this.getPreviewColor = () => {};
|
||||
|
||||
this.state = useState({
|
||||
activeTab: this.props.state.selectedTab || this.getDefaultTab(),
|
||||
currentCustomColor: this.props.state.selectedColor,
|
||||
currentColorPreview: undefined,
|
||||
showGradientPicker: false,
|
||||
});
|
||||
this.usedCustomColors = this.props.getUsedCustomColors();
|
||||
useEffect(
|
||||
() => {
|
||||
// Recompute the positioning of the popover if any.
|
||||
this.env[POSITION_BUS]?.trigger("update");
|
||||
},
|
||||
() => [this.state.activeTab]
|
||||
);
|
||||
}
|
||||
|
||||
getDefaultTab() {
|
||||
if (this.props.enabledTabs.includes(this.props.state.defaultTab)) {
|
||||
return this.props.state.defaultTab;
|
||||
}
|
||||
return this.props.enabledTabs[0];
|
||||
}
|
||||
|
||||
get selectedColor() {
|
||||
return this.props.state.selectedColor;
|
||||
}
|
||||
|
||||
get isDarkTheme() {
|
||||
return cookie.get("color_scheme") === "dark";
|
||||
}
|
||||
|
||||
setTab(tab) {
|
||||
this.state.activeTab = tab;
|
||||
// Reset the preview revert callback, as it is tab-specific.
|
||||
this.setOperationCallbacks({ onPreviewRevertCallback: () => {} });
|
||||
this.applyColorResetPreview();
|
||||
}
|
||||
|
||||
processColorFromEvent(ev) {
|
||||
const target = this.getTarget(ev);
|
||||
let color = target.dataset.color || "";
|
||||
if (color && isColorCombination(color)) {
|
||||
return color;
|
||||
}
|
||||
if (color && !isCSSColor(color) && !isColorGradient(color)) {
|
||||
color = this.props.colorPrefix + color;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
/**
|
||||
* @param {Object} cbs - callbacks
|
||||
* @param {Function} cbs.onApplyCallback
|
||||
* @param {Function} cbs.onPreviewRevertCallback
|
||||
*/
|
||||
setOperationCallbacks(cbs) {
|
||||
// The gradient colorpicker has a nested ColorPicker. We need to use the
|
||||
// `setOperationCallbacks` from the parent ColorPicker for it to be
|
||||
// impacted.
|
||||
if (this.props.setOperationCallbacks) {
|
||||
this.props.setOperationCallbacks(cbs);
|
||||
}
|
||||
if (cbs.onApplyCallback) {
|
||||
this.onApplyCallback = cbs.onApplyCallback;
|
||||
}
|
||||
if (cbs.onPreviewRevertCallback) {
|
||||
this.onPreviewRevertCallback = cbs.onPreviewRevertCallback;
|
||||
}
|
||||
if (cbs.getPreviewColor) {
|
||||
this.getPreviewColor = cbs.getPreviewColor;
|
||||
}
|
||||
}
|
||||
|
||||
applyColor(color) {
|
||||
this.state.currentCustomColor = color;
|
||||
this.props.applyColor(color);
|
||||
this.defaultColorSet = this.getDefaultColorSet();
|
||||
this.onApplyCallback();
|
||||
}
|
||||
|
||||
onColorApply(ev) {
|
||||
if (!this.isColorButton(this.getTarget(ev))) {
|
||||
return;
|
||||
}
|
||||
const color = this.processColorFromEvent(ev);
|
||||
this.applyColor(color);
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
applyColorResetPreview() {
|
||||
this.props.applyColorResetPreview();
|
||||
this.state.currentColorPreview = undefined;
|
||||
this.onPreviewRevertCallback();
|
||||
}
|
||||
|
||||
onColorPreview(ev) {
|
||||
const color = ev.hex || ev.gradient || this.processColorFromEvent(ev);
|
||||
this.props.applyColorPreview(color);
|
||||
this.state.currentColorPreview = this.getPreviewColor();
|
||||
}
|
||||
|
||||
onColorHover(ev) {
|
||||
if (!this.isColorButton(this.getTarget(ev))) {
|
||||
return;
|
||||
}
|
||||
this.onColorPreview(ev);
|
||||
}
|
||||
|
||||
onColorHoverOut(ev) {
|
||||
if (!this.isColorButton(this.getTarget(ev))) {
|
||||
return;
|
||||
}
|
||||
this.applyColorResetPreview();
|
||||
}
|
||||
getTarget(ev) {
|
||||
const target = ev.target.closest(`[data-color]`);
|
||||
return this.root.el.contains(target) ? target : ev.target;
|
||||
}
|
||||
|
||||
onColorFocusin(ev) {
|
||||
// In the editor color picker, the preview and reset reapply the
|
||||
// selection, which can remove the focus from the current button (if the
|
||||
// node is recreated). We need to force the focus and break the infinite
|
||||
// loop that it could trigger.
|
||||
if (this.focusedBtn === ev.target) {
|
||||
this.focusedBtn = null;
|
||||
return;
|
||||
}
|
||||
this.focusedBtn = ev.target;
|
||||
this.onColorHover(ev);
|
||||
if (document.activeElement !== ev.target) {
|
||||
// The focus was lost during revert. Reset it where it should be.
|
||||
ev.target.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onColorFocusout(ev) {
|
||||
if (!ev.relatedTarget || !this.isColorButton(ev.relatedTarget)) {
|
||||
// Do not trigger a revert if we are in the focus loop (i.e. focus
|
||||
// a button > selection is reset > focusout). Otherwise, the
|
||||
// relatedTarget should always be one of the colorpicker's buttons.
|
||||
return;
|
||||
}
|
||||
const activeEl = document.activeElement;
|
||||
this.applyColorResetPreview();
|
||||
if (document.activeElement !== activeEl) {
|
||||
// The focus was lost during revert. Reset it where it should be.
|
||||
ev.relatedTarget.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultColorSet() {
|
||||
if (!this.props.state.selectedColor) {
|
||||
return;
|
||||
}
|
||||
let defaultColors = this.props.enabledTabs.includes("solid")
|
||||
? this.DEFAULT_THEME_COLOR_VARS
|
||||
: [];
|
||||
for (const grayscale of Object.values(this.grayscales)) {
|
||||
defaultColors = defaultColors.concat(grayscale);
|
||||
}
|
||||
|
||||
const targetedElement =
|
||||
this.props.state.getTargetedElements?.()[0] || document.documentElement;
|
||||
const selectedColor = this.props.state.selectedColor.toUpperCase();
|
||||
const htmlStyle =
|
||||
targetedElement.ownerDocument.defaultView.getComputedStyle(targetedElement);
|
||||
|
||||
for (const color of defaultColors) {
|
||||
const cssVar = normalizeCSSColor(htmlStyle.getPropertyValue(`--${color}`));
|
||||
if (cssVar?.toUpperCase() === selectedColor) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
colorPickerNavigation(ev) {
|
||||
const { target, key } = ev;
|
||||
if (!target.classList.contains("o_color_button")) {
|
||||
return;
|
||||
}
|
||||
if (!["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetBtn;
|
||||
if (key === "ArrowRight") {
|
||||
targetBtn = target.nextElementSibling;
|
||||
} else if (key === "ArrowLeft") {
|
||||
targetBtn = target.previousElementSibling;
|
||||
} else if (key === "ArrowUp" || key === "ArrowDown") {
|
||||
const buttonIndex = [...target.parentElement.children].indexOf(target);
|
||||
const nbColumns = getComputedStyle(target).getPropertyValue(
|
||||
"--o-color-picker-grid-columns"
|
||||
);
|
||||
targetBtn =
|
||||
target.parentElement.children[
|
||||
buttonIndex + (key === "ArrowUp" ? -1 : 1) * nbColumns
|
||||
];
|
||||
if (!targetBtn) {
|
||||
const row =
|
||||
key === "ArrowUp"
|
||||
? target.parentElement.previousElementSibling
|
||||
: target.parentElement.nextElementSibling;
|
||||
if (row?.matches(".o_color_section, .o_colorpicker_section")) {
|
||||
targetBtn = row.children[buttonIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetBtn && targetBtn.classList.contains("o_color_button")) {
|
||||
targetBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
isColorButton(targetEl) {
|
||||
return targetEl.tagName === "BUTTON" && !targetEl.matches(".o_colorpicker_ignore");
|
||||
}
|
||||
}
|
||||
|
||||
export function useColorPicker(refName, props, options = {}) {
|
||||
// Callback to be overridden by child components (e.g. custom color picker).
|
||||
let onCloseCallback = () => {};
|
||||
const setOnCloseCallback = (cb) => {
|
||||
onCloseCallback = cb;
|
||||
};
|
||||
props.setOnCloseCallback = setOnCloseCallback;
|
||||
if (options.onClose) {
|
||||
const onClose = options.onClose;
|
||||
options.onClose = () => {
|
||||
onCloseCallback();
|
||||
onClose();
|
||||
};
|
||||
}
|
||||
|
||||
const colorPicker = usePopover(ColorPicker, options);
|
||||
const root = useRef(refName);
|
||||
|
||||
function onClick() {
|
||||
colorPicker.isOpen ? colorPicker.close() : colorPicker.open(root.el, props);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
(el) => {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.addEventListener("click", onClick);
|
||||
return () => {
|
||||
el.removeEventListener("click", onClick);
|
||||
};
|
||||
},
|
||||
() => [root.el]
|
||||
);
|
||||
|
||||
return colorPicker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given string is a color combination.
|
||||
*
|
||||
* @param {string} color
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isColorCombination(color) {
|
||||
return color.startsWith("o_cc");
|
||||
}
|
||||
94
frontend/web/static/src/core/color_picker/color_picker.scss
Normal file
94
frontend/web/static/src/core/color_picker/color_picker.scss
Normal file
@@ -0,0 +1,94 @@
|
||||
$o-we-toolbar-bg: #FFF !default;
|
||||
$o-we-toolbar-color-text: #2b2b33 !default; // Same as $o-we-bg-light
|
||||
$o-we-item-spacing: 8px !default;
|
||||
$o-we-color-success: #00ff9e !default;
|
||||
|
||||
.o_font_color_selector {
|
||||
@include o-input-number-no-arrows();
|
||||
--bg: #{$o-we-toolbar-bg};
|
||||
--text-rgb: #{red($o-we-toolbar-color-text)}, #{green($o-we-toolbar-color-text)}, #{blue($o-we-toolbar-color-text)};
|
||||
--border-rgb: var(--text-rgb);
|
||||
width: 208px;
|
||||
max-height: inherit;
|
||||
overflow-y: auto;
|
||||
border-radius: inherit;
|
||||
background-color: inherit;
|
||||
box-shadow: $box-shadow;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_color_button {
|
||||
width: 23px;
|
||||
height: 22px;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--border-rgb), .5);
|
||||
margin: 0.5px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.o_color_picker_button {
|
||||
@extend %o-preview-alpha-background;
|
||||
|
||||
&:not(.selected):focus,
|
||||
&:not(.selected):hover {
|
||||
outline: solid $o-enterprise-action-color;
|
||||
z-index: 1;
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.o_font_color_selector {
|
||||
.btn-tab {
|
||||
min-width: 57px;
|
||||
padding: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.o_color_picker_button.selected {
|
||||
border: 3px solid $o-enterprise-action-color !important;
|
||||
}
|
||||
}
|
||||
|
||||
.o_font_color_selector .o_colorpicker_section {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.o_font_color_selector {
|
||||
--o-color-picker-grid-columns: 8;
|
||||
.o_colorpicker_section, .o_color_section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--o-color-picker-grid-columns), 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.o_font_color_selector .o_colorpicker_widget {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
.o_hex_input {
|
||||
border: 1px solid !important;
|
||||
padding: 0 2px !important;
|
||||
width: 10ch !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
@each $color, $value in $grays {
|
||||
@include print-variable($color, $value);
|
||||
}
|
||||
}
|
||||
|
||||
.color-combination-button.selected h1 {
|
||||
&::before {
|
||||
content: "\f00c";
|
||||
margin-right: $o-we-item-spacing;
|
||||
font-size: 0.8em;
|
||||
font-family: FontAwesome;
|
||||
color: $o-we-color-success;
|
||||
}
|
||||
}
|
||||
51
frontend/web/static/src/core/color_picker/color_picker.xml
Normal file
51
frontend/web/static/src/core/color_picker/color_picker.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.ColorPicker">
|
||||
<div t-attf-class="o_font_color_selector user-select-none {{this.props.className}}"
|
||||
t-on-pointerdown.stop="() => {}" data-prevent-closing-overlay="true" t-ref="root">
|
||||
<div class="px-1 mb-1 pt-2 d-flex">
|
||||
<t t-foreach="this.tabs" t-as="tab" t-key="tab.id">
|
||||
<button t-attf-class="btn btn-sm text-truncate btn-tab #{tab.id}-tab #{ isDarkTheme ? 'btn-secondary' : 'btn-light'} #{state.activeTab === tab.id ? 'active' : ''}"
|
||||
t-on-click="() => this.setTab(tab.id)">
|
||||
<t t-out="tab.name" />
|
||||
</button>
|
||||
</t>
|
||||
<div class="flex-grow-1" />
|
||||
<button t-attf-class="btn btn-sm fa fa-trash #{ isDarkTheme ? 'btn-secondary' : 'btn-light'}"
|
||||
title="Reset"
|
||||
t-on-click="onColorApply"
|
||||
t-on-mouseover="onColorHover"
|
||||
t-on-mouseout="onColorHoverOut"
|
||||
t-on-focusin="onColorFocusin"
|
||||
t-on-focusout="onColorFocusout"/>
|
||||
</div>
|
||||
<t t-set="activeTab" t-value="this.tabs.find((tab) => state.activeTab === tab.id)" />
|
||||
<t t-component="activeTab.component" t-props="{
|
||||
applyColor: this.applyColor.bind(this),
|
||||
onColorClick: this.onColorApply.bind(this),
|
||||
onColorPointerOver: this.onColorHover.bind(this),
|
||||
onColorPointerOut: this.onColorHoverOut.bind(this),
|
||||
onColorPointerLeave: this.applyColorResetPreview.bind(this),
|
||||
onFocusin: this.onColorFocusin.bind(this),
|
||||
onFocusout: this.onColorFocusout.bind(this),
|
||||
colorPickerNavigation: this.colorPickerNavigation.bind(this),
|
||||
onColorPreview: this.onColorPreview.bind(this),
|
||||
setOnCloseCallback: this.props.setOnCloseCallback.bind(this),
|
||||
setOperationCallbacks: this.setOperationCallbacks.bind(this),
|
||||
editColorCombination: this.props.editColorCombination,
|
||||
selectedColorCombination: this.props.state.selectedColorCombination,
|
||||
defaultOpacity: this.props.defaultOpacity,
|
||||
noTransparency: this.props.noTransparency,
|
||||
defaultThemeColorVars: this.DEFAULT_THEME_COLOR_VARS,
|
||||
defaultColorSet: this.defaultColorSet,
|
||||
cssVarColorPrefix: this.props.cssVarColorPrefix,
|
||||
defaultColors: this.DEFAULT_COLORS,
|
||||
getUsedCustomColors: this.props.getUsedCustomColors,
|
||||
grayscales: this.grayscales,
|
||||
currentCustomColor: this.state.currentCustomColor,
|
||||
currentColorPreview: this.state.currentColorPreview,
|
||||
selectedColor: this.props.state.selectedColor,
|
||||
}" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,689 @@
|
||||
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import {
|
||||
convertCSSColorToRgba,
|
||||
convertHslToRgb,
|
||||
convertRgbaToCSSColor,
|
||||
convertRgbToHsl,
|
||||
normalizeCSSColor,
|
||||
} from "@web/core/utils/colors";
|
||||
import { uniqueId } from "@web/core/utils/functions";
|
||||
import { clamp } from "@web/core/utils/numbers";
|
||||
import { debounce, useThrottleForAnimation } from "@web/core/utils/timing";
|
||||
|
||||
import { Component, onMounted, onWillUpdateProps, useExternalListener, useRef } from "@odoo/owl";
|
||||
|
||||
const ARROW_KEYS = ["arrowup", "arrowdown", "arrowleft", "arrowright"];
|
||||
const SLIDER_KEYS = [...ARROW_KEYS, "pageup", "pagedown", "home", "end"];
|
||||
|
||||
const DEFAULT_COLOR = "#FF0000";
|
||||
|
||||
export class CustomColorPicker extends Component {
|
||||
static template = "web.CustomColorPicker";
|
||||
static props = {
|
||||
document: { type: true, optional: true },
|
||||
defaultColor: { type: String, optional: true },
|
||||
selectedColor: { type: String, optional: true },
|
||||
noTransparency: { type: Boolean, optional: true },
|
||||
stopClickPropagation: { type: Boolean, optional: true },
|
||||
onColorSelect: { type: Function, optional: true },
|
||||
onColorPreview: { type: Function, optional: true },
|
||||
onInputEnter: { type: Function, optional: true },
|
||||
defaultOpacity: { type: Number, optional: true },
|
||||
setOnCloseCallback: { type: Function, optional: true },
|
||||
setOperationCallbacks: { type: Function, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
document: window.document,
|
||||
defaultColor: DEFAULT_COLOR,
|
||||
defaultOpacity: 100,
|
||||
noTransparency: false,
|
||||
stopClickPropagation: false,
|
||||
onColorSelect: () => {},
|
||||
onColorPreview: () => {},
|
||||
onInputEnter: () => {},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.pickerFlag = false;
|
||||
this.sliderFlag = false;
|
||||
this.opacitySliderFlag = false;
|
||||
if (this.props.defaultOpacity > 0 && this.props.defaultOpacity <= 1) {
|
||||
this.props.defaultOpacity *= 100;
|
||||
}
|
||||
if (this.props.defaultColor.length <= 7) {
|
||||
const opacityHex = Math.round((this.props.defaultOpacity / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
this.props.defaultColor += opacityHex;
|
||||
}
|
||||
this.colorComponents = {};
|
||||
this.uniqueId = uniqueId("colorpicker");
|
||||
this.selectedHexValue = "";
|
||||
this.shouldSetSelectedColor = false;
|
||||
this.lastFocusedSliderEl = undefined;
|
||||
if (!this.props.selectedColor) {
|
||||
this.props.selectedColor = this.props.defaultColor;
|
||||
}
|
||||
this.debouncedOnChangeInputs = debounce(this.onChangeInputs.bind(this), 10, true);
|
||||
|
||||
this.elRef = useRef("el");
|
||||
this.colorPickerAreaRef = useRef("colorPickerArea");
|
||||
this.colorPickerPointerRef = useRef("colorPickerPointer");
|
||||
this.colorSliderRef = useRef("colorSlider");
|
||||
this.colorSliderPointerRef = useRef("colorSliderPointer");
|
||||
this.opacitySliderRef = useRef("opacitySlider");
|
||||
this.opacitySliderPointerRef = useRef("opacitySliderPointer");
|
||||
|
||||
// Need to be bound on all documents to work in all possible cases (we
|
||||
// have to be able to start dragging/moving from the colorpicker to
|
||||
// anywhere on the screen, crossing iframes).
|
||||
const documents = [
|
||||
window.top,
|
||||
...Array.from(window.top.frames).filter((frame) => {
|
||||
try {
|
||||
const document = frame.document;
|
||||
return !!document;
|
||||
} catch {
|
||||
// We cannot access the document (cross origin).
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
].map((w) => w.document);
|
||||
this.throttleOnPointerMove = useThrottleForAnimation((ev) => {
|
||||
this.onPointerMovePicker(ev);
|
||||
this.onPointerMoveSlider(ev);
|
||||
this.onPointerMoveOpacitySlider(ev);
|
||||
});
|
||||
|
||||
for (const doc of documents) {
|
||||
useExternalListener(doc, "pointermove", this.throttleOnPointerMove);
|
||||
useExternalListener(doc, "pointerup", this.onPointerUp.bind(this));
|
||||
useExternalListener(doc, "keydown", this.onEscapeKeydown.bind(this), { capture: true });
|
||||
}
|
||||
// Apply the previewed custom color when the popover is closed.
|
||||
this.props.setOnCloseCallback?.(() => {
|
||||
if (this.shouldSetSelectedColor) {
|
||||
this._colorSelected();
|
||||
}
|
||||
});
|
||||
this.props.setOperationCallbacks?.({
|
||||
getPreviewColor: () => {
|
||||
if (this.shouldSetSelectedColor) {
|
||||
return this.colorComponents.hex;
|
||||
}
|
||||
},
|
||||
onApplyCallback: () => {
|
||||
this.shouldSetSelectedColor = false;
|
||||
},
|
||||
// Reapply the current custom color preview after reverting a preview.
|
||||
// Typical usecase: 1) modify the custom color, 2) hover one of the
|
||||
// black-white tints, 3) hover out.
|
||||
onPreviewRevertCallback: () => {
|
||||
if (this.previewActive && this.shouldSetSelectedColor) {
|
||||
this.props.onColorPreview(this.colorComponents);
|
||||
}
|
||||
},
|
||||
});
|
||||
onMounted(async () => {
|
||||
const rgba =
|
||||
convertCSSColorToRgba(this.props.selectedColor) ||
|
||||
convertCSSColorToRgba(this.props.defaultColor);
|
||||
if (rgba) {
|
||||
this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity);
|
||||
}
|
||||
|
||||
this.previewActive = true;
|
||||
this._updateUI();
|
||||
});
|
||||
onWillUpdateProps((newProps) => {
|
||||
const newSelectedColor = newProps.selectedColor
|
||||
? newProps.selectedColor
|
||||
: newProps.defaultColor;
|
||||
if (normalizeCSSColor(newSelectedColor) !== this.colorComponents.cssColor) {
|
||||
this.setSelectedColor(newSelectedColor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the currently selected color
|
||||
*
|
||||
* @param {string} color rgb[a]
|
||||
*/
|
||||
setSelectedColor(color) {
|
||||
const rgba = convertCSSColorToRgba(color);
|
||||
if (rgba) {
|
||||
const oldPreviewActive = this.previewActive;
|
||||
this.previewActive = false;
|
||||
this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity);
|
||||
this.previewActive = oldPreviewActive;
|
||||
this._updateUI();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {string[]} allowedKeys
|
||||
* @returns {string[]} allowed keys + modifiers
|
||||
*/
|
||||
getAllowedHotkeys(allowedKeys) {
|
||||
return allowedKeys.flatMap((key) => [key, `control+${key}`]);
|
||||
}
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
setLastFocusedSliderEl(el) {
|
||||
this.lastFocusedSliderEl = el;
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.elRef.el;
|
||||
}
|
||||
/**
|
||||
* @param {string} hotkey
|
||||
* @param {number} value
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.min=0]
|
||||
* @param {number} [options.max=100]
|
||||
* @param {number} [options.defaultStep=10] - default step
|
||||
* @param {number} [options.modifierStep=1] - step when holding ctrl+key
|
||||
* @param {number} [options.leap=20] - step for pageup / pagedown
|
||||
* @returns {number} updated and clamped value
|
||||
*/
|
||||
handleRangeKeydownValue(
|
||||
hotkey,
|
||||
value,
|
||||
{ min = 0, max = 100, defaultStep = 10, modifierStep = 1, leap = 20 } = {}
|
||||
) {
|
||||
let step = defaultStep;
|
||||
if (hotkey.startsWith("control+")) {
|
||||
step = modifierStep;
|
||||
}
|
||||
const mainKey = hotkey.replace("control+", "");
|
||||
if (mainKey === "pageup" || mainKey === "pagedown") {
|
||||
step = leap;
|
||||
}
|
||||
if (["arrowup", "arrowright", "pageup"].includes(mainKey)) {
|
||||
value += step;
|
||||
} else if (["arrowdown", "arrowleft", "pagedown"].includes(mainKey)) {
|
||||
value -= step;
|
||||
} else if (mainKey === "home") {
|
||||
value = min;
|
||||
} else if (mainKey === "end") {
|
||||
value = max;
|
||||
}
|
||||
return clamp(value, min, max);
|
||||
}
|
||||
/**
|
||||
* Selects and applies a currently previewed color if "Enter" was pressed.
|
||||
*
|
||||
* @param {String} hotkey
|
||||
*/
|
||||
selectColorOnEnter(hotkey) {
|
||||
if (hotkey === "enter" && this.shouldSetSelectedColor) {
|
||||
this.pickerFlag = false;
|
||||
this.sliderFlag = false;
|
||||
this.opacitySliderFlag = false;
|
||||
this._colorSelected();
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Updates input values, color preview, picker and slider pointer positions.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateUI() {
|
||||
// Update inputs
|
||||
for (const [color, value] of Object.entries(this.colorComponents)) {
|
||||
const input = this.el.querySelector(`.o_${color}_input`);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Update picker area and picker pointer position
|
||||
const colorPickerArea = this.colorPickerAreaRef.el;
|
||||
colorPickerArea.style.backgroundColor = `hsl(${this.colorComponents.hue}, 100%, 50%)`;
|
||||
const top = ((100 - this.colorComponents.lightness) * colorPickerArea.clientHeight) / 100;
|
||||
const left = (this.colorComponents.saturation * colorPickerArea.clientWidth) / 100;
|
||||
|
||||
const colorpickerPointer = this.colorPickerPointerRef.el;
|
||||
colorpickerPointer.style.top = top - 5 + "px";
|
||||
colorpickerPointer.style.left = left - 5 + "px";
|
||||
colorpickerPointer.setAttribute(
|
||||
"aria-label",
|
||||
_t("Saturation: %(saturationLvl)s %. Brightness: %(brightnessLvl)s %", {
|
||||
saturationLvl: this.colorComponents.saturation?.toFixed(2) || "0",
|
||||
brightnessLvl: this.colorComponents.lightness?.toFixed(2) || "0",
|
||||
})
|
||||
);
|
||||
|
||||
// Update color slider position
|
||||
const colorSlider = this.colorSliderRef.el;
|
||||
const height = colorSlider.clientHeight;
|
||||
const y = (this.colorComponents.hue * height) / 360;
|
||||
this.colorSliderPointerRef.el.style.bottom = `${Math.round(y - 4)}px`;
|
||||
this.colorSliderPointerRef.el.setAttribute(
|
||||
"aria-valuenow",
|
||||
this.colorComponents.hue.toFixed(2)
|
||||
);
|
||||
|
||||
if (!this.props.noTransparency) {
|
||||
// Update opacity slider position
|
||||
const opacitySlider = this.opacitySliderRef.el;
|
||||
const heightOpacity = opacitySlider.clientHeight;
|
||||
const z = heightOpacity * (1 - this.colorComponents.opacity / 100.0);
|
||||
this.opacitySliderPointerRef.el.style.top = `${Math.round(z - 2)}px`;
|
||||
this.opacitySliderPointerRef.el.setAttribute(
|
||||
"aria-valuenow",
|
||||
this.colorComponents.opacity.toFixed(2)
|
||||
);
|
||||
|
||||
// Add gradient color on opacity slider
|
||||
const sliderColor = this.colorComponents.hex.slice(0, 7);
|
||||
opacitySlider.style.background = `linear-gradient(${sliderColor} 0%, transparent 100%)`;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates colors according to given hex value. Opacity is left unchanged.
|
||||
*
|
||||
* @private
|
||||
* @param {string} hex - hexadecimal code
|
||||
*/
|
||||
_updateHex(hex) {
|
||||
const rgb = convertCSSColorToRgba(hex);
|
||||
if (!rgb) {
|
||||
return;
|
||||
}
|
||||
Object.assign(
|
||||
this.colorComponents,
|
||||
{ hex: hex },
|
||||
rgb,
|
||||
convertRgbToHsl(rgb.red, rgb.green, rgb.blue)
|
||||
);
|
||||
this._updateCssColor();
|
||||
}
|
||||
/**
|
||||
* Updates colors according to given RGB values.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} r
|
||||
* @param {integer} g
|
||||
* @param {integer} b
|
||||
* @param {integer} [a]
|
||||
*/
|
||||
_updateRgba(r, g, b, a) {
|
||||
// Remove full transparency in case some lightness is added
|
||||
const opacity = a || this.colorComponents.opacity;
|
||||
if (opacity < 0.1 && (r > 0.1 || g > 0.1 || b > 0.1)) {
|
||||
a = this.props.defaultOpacity;
|
||||
}
|
||||
|
||||
const hex = convertRgbaToCSSColor(r, g, b, a);
|
||||
if (!hex) {
|
||||
return;
|
||||
}
|
||||
Object.assign(
|
||||
this.colorComponents,
|
||||
{ red: r, green: g, blue: b },
|
||||
a === undefined ? {} : { opacity: a },
|
||||
{ hex: hex },
|
||||
convertRgbToHsl(r, g, b)
|
||||
);
|
||||
this._updateCssColor();
|
||||
}
|
||||
/**
|
||||
* Updates colors according to given HSL values.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} h
|
||||
* @param {integer} s
|
||||
* @param {integer} l
|
||||
*/
|
||||
_updateHsl(h, s, l) {
|
||||
// Remove full darkness/brightness and non-saturation in case hue is changed
|
||||
if (0.1 < Math.abs(h - this.colorComponents.hue)) {
|
||||
if (l < 0.1 || 99.9 < l) {
|
||||
l = 50;
|
||||
}
|
||||
if (s < 0.1) {
|
||||
s = 100;
|
||||
}
|
||||
}
|
||||
// Remove full transparency in case some lightness is added
|
||||
let a = this.colorComponents.opacity;
|
||||
if (a < 0.1 && l > 0.1) {
|
||||
a = this.props.defaultOpacity;
|
||||
}
|
||||
|
||||
const rgb = convertHslToRgb(h, s, l);
|
||||
if (!rgb) {
|
||||
return;
|
||||
}
|
||||
// We receive an hexa as we ignore the opacity
|
||||
const hex = convertRgbaToCSSColor(rgb.red, rgb.green, rgb.blue, a);
|
||||
Object.assign(
|
||||
this.colorComponents,
|
||||
{ hue: h, saturation: s, lightness: l },
|
||||
rgb,
|
||||
{ hex: hex },
|
||||
{ opacity: a }
|
||||
);
|
||||
this._updateCssColor();
|
||||
}
|
||||
/**
|
||||
* Updates color opacity.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} a
|
||||
*/
|
||||
_updateOpacity(a) {
|
||||
if (a < 0 || a > 100) {
|
||||
return;
|
||||
}
|
||||
Object.assign(this.colorComponents, { opacity: a });
|
||||
const r = this.colorComponents.red;
|
||||
const g = this.colorComponents.green;
|
||||
const b = this.colorComponents.blue;
|
||||
Object.assign(this.colorComponents, { hex: convertRgbaToCSSColor(r, g, b, a) });
|
||||
this._updateCssColor();
|
||||
}
|
||||
/**
|
||||
* Trigger an event to annonce that the widget value has changed
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_colorSelected() {
|
||||
this.props.onColorSelect(this.colorComponents);
|
||||
}
|
||||
/**
|
||||
* Updates css color representation.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_updateCssColor() {
|
||||
const r = this.colorComponents.red;
|
||||
const g = this.colorComponents.green;
|
||||
const b = this.colorComponents.blue;
|
||||
const a = this.colorComponents.opacity;
|
||||
Object.assign(this.colorComponents, { cssColor: convertRgbaToCSSColor(r, g, b, a) });
|
||||
if (this.previewActive) {
|
||||
this.props.onColorPreview(this.colorComponents);
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onKeydown(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
if (ev.target.tagName === "INPUT") {
|
||||
this.onChangeInputs(ev);
|
||||
}
|
||||
ev.preventDefault();
|
||||
this.props.onInputEnter(ev);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onClick(ev) {
|
||||
if (this.props.stopClickPropagation) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
//TODO: we should remove it with legacy web_editor
|
||||
ev.__isColorpickerClick = true;
|
||||
|
||||
if (ev.target.dataset.colorMethod === "hex" && !this.selectedHexValue) {
|
||||
ev.target.select();
|
||||
this.selectedHexValue = ev.target.value;
|
||||
return;
|
||||
}
|
||||
this.selectedHexValue = "";
|
||||
}
|
||||
onPointerUp() {
|
||||
if (this.pickerFlag || this.sliderFlag || this.opacitySliderFlag) {
|
||||
this.shouldSetSelectedColor = true;
|
||||
this._updateCssColor();
|
||||
}
|
||||
this.pickerFlag = false;
|
||||
this.sliderFlag = false;
|
||||
this.opacitySliderFlag = false;
|
||||
|
||||
if (this.lastFocusedSliderEl) {
|
||||
this.lastFocusedSliderEl.focus();
|
||||
this.lastFocusedSliderEl = undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Removes the close callback on Escape, so that a preview is cancelled with
|
||||
* escape instead of being applied.
|
||||
*
|
||||
* @param {KeydownEvent} ev
|
||||
*/
|
||||
onEscapeKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
if (hotkey === "escape") {
|
||||
this.props.setOnCloseCallback?.(() => {});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates color when the user starts clicking on the picker.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onPointerDownPicker(ev) {
|
||||
this.pickerFlag = true;
|
||||
ev.preventDefault();
|
||||
this.onPointerMovePicker(ev);
|
||||
this.setLastFocusedSliderEl(this.colorPickerPointerRef.el);
|
||||
}
|
||||
/**
|
||||
* Updates saturation and lightness values on pointer drag over picker.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onPointerMovePicker(ev) {
|
||||
if (!this.pickerFlag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colorPickerArea = this.colorPickerAreaRef.el;
|
||||
const rect = colorPickerArea.getClientRects()[0];
|
||||
const top = ev.pageY - rect.top;
|
||||
const left = ev.pageX - rect.left;
|
||||
let saturation = Math.round((100 * left) / colorPickerArea.clientWidth);
|
||||
let lightness = Math.round(
|
||||
(100 * (colorPickerArea.clientHeight - top)) / colorPickerArea.clientHeight
|
||||
);
|
||||
saturation = clamp(saturation, 0, 100);
|
||||
lightness = clamp(lightness, 0, 100);
|
||||
|
||||
this._updateHsl(this.colorComponents.hue, saturation, lightness);
|
||||
this._updateUI();
|
||||
}
|
||||
/**
|
||||
* Updates saturation and lightness values on arrow keydown over picker.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onPickerKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
this.selectColorOnEnter(hotkey);
|
||||
if (!this.getAllowedHotkeys(ARROW_KEYS).includes(hotkey)) {
|
||||
return;
|
||||
}
|
||||
let saturation = this.colorComponents.saturation;
|
||||
let lightness = this.colorComponents.lightness;
|
||||
let step = 10;
|
||||
if (hotkey.startsWith("control+")) {
|
||||
step = 1;
|
||||
}
|
||||
const mainKey = hotkey.replace("control+", "");
|
||||
if (mainKey === "arrowup") {
|
||||
lightness += step;
|
||||
} else if (mainKey === "arrowdown") {
|
||||
lightness -= step;
|
||||
} else if (mainKey === "arrowright") {
|
||||
saturation += step;
|
||||
} else if (mainKey === "arrowleft") {
|
||||
saturation -= step;
|
||||
}
|
||||
lightness = clamp(lightness, 0, 100);
|
||||
saturation = clamp(saturation, 0, 100);
|
||||
|
||||
this._updateHsl(this.colorComponents.hue, saturation, lightness);
|
||||
this._updateUI();
|
||||
this.shouldSetSelectedColor = true;
|
||||
}
|
||||
/**
|
||||
* Updates color when user starts clicking on slider.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onPointerDownSlider(ev) {
|
||||
this.sliderFlag = true;
|
||||
ev.preventDefault();
|
||||
this.onPointerMoveSlider(ev);
|
||||
this.setLastFocusedSliderEl(this.colorSliderPointerRef.el);
|
||||
}
|
||||
/**
|
||||
* Updates hue value on pointer drag over slider.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onPointerMoveSlider(ev) {
|
||||
if (!this.sliderFlag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colorSlider = this.colorSliderRef.el;
|
||||
const colorSliderRects = colorSlider.getClientRects();
|
||||
const y = colorSliderRects[0].height - (ev.pageY - colorSliderRects[0].top);
|
||||
let hue = Math.round((360 * y) / colorSlider.clientHeight);
|
||||
hue = clamp(hue, 0, 360);
|
||||
|
||||
this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness);
|
||||
this._updateUI();
|
||||
}
|
||||
/**
|
||||
* Updates hue value on arrow keydown on slider.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onSliderKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
this.selectColorOnEnter(hotkey);
|
||||
if (!this.getAllowedHotkeys(SLIDER_KEYS).includes(hotkey)) {
|
||||
return;
|
||||
}
|
||||
const hue = this.handleRangeKeydownValue(hotkey, this.colorComponents.hue, {
|
||||
min: 0,
|
||||
max: 360,
|
||||
leap: 30,
|
||||
});
|
||||
this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness);
|
||||
this._updateUI();
|
||||
this.shouldSetSelectedColor = true;
|
||||
}
|
||||
/**
|
||||
* Updates opacity when user starts clicking on opacity slider.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onPointerDownOpacitySlider(ev) {
|
||||
this.opacitySliderFlag = true;
|
||||
ev.preventDefault();
|
||||
this.onPointerMoveOpacitySlider(ev);
|
||||
this.setLastFocusedSliderEl(this.opacitySliderPointerRef.el);
|
||||
}
|
||||
/**
|
||||
* Updates opacity value on pointer drag over opacity slider.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onPointerMoveOpacitySlider(ev) {
|
||||
if (!this.opacitySliderFlag || this.props.noTransparency) {
|
||||
return;
|
||||
}
|
||||
|
||||
const opacitySlider = this.opacitySliderRef.el;
|
||||
const y = ev.pageY - opacitySlider.getClientRects()[0].top;
|
||||
let opacity = Math.round(100 * (1 - y / opacitySlider.clientHeight));
|
||||
opacity = clamp(opacity, 0, 100);
|
||||
|
||||
this._updateOpacity(opacity);
|
||||
this._updateUI();
|
||||
}
|
||||
/**
|
||||
* Updates opacity value on arrow keydown on opacity slider.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onOpacitySliderKeydown(ev) {
|
||||
const hotkey = getActiveHotkey(ev);
|
||||
this.selectColorOnEnter(hotkey);
|
||||
if (!this.getAllowedHotkeys(SLIDER_KEYS).includes(hotkey)) {
|
||||
return;
|
||||
}
|
||||
const opacity = this.handleRangeKeydownValue(hotkey, this.colorComponents.opacity);
|
||||
|
||||
this._updateOpacity(opacity);
|
||||
this._updateUI();
|
||||
this.shouldSetSelectedColor = true;
|
||||
}
|
||||
/**
|
||||
* Called when input value is changed -> Updates UI: Set picker and slider
|
||||
* position and set colors.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onChangeInputs(ev) {
|
||||
switch (ev.target.dataset.colorMethod) {
|
||||
case "hex":
|
||||
// Handled by the "input" event (see "onHexColorInput").
|
||||
return;
|
||||
case "hsl":
|
||||
this._updateHsl(
|
||||
parseInt(this.el.querySelector(".o_hue_input").value),
|
||||
parseInt(this.el.querySelector(".o_saturation_input").value),
|
||||
parseInt(this.el.querySelector(".o_lightness_input").value)
|
||||
);
|
||||
break;
|
||||
}
|
||||
this._updateUI();
|
||||
this._colorSelected();
|
||||
}
|
||||
/**
|
||||
* Called when the hex color input's input event is triggered.
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
onHexColorInput(ev) {
|
||||
const hexColorValue = ev.target.value.replaceAll("#", "");
|
||||
if (hexColorValue.length === 6 || hexColorValue.length === 8) {
|
||||
this._updateHex(`#${hexColorValue}`);
|
||||
this._updateUI();
|
||||
this._colorSelected();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// COLOR PICKER
|
||||
.o_colorpicker_widget {
|
||||
.o_color_pick_area {
|
||||
height: 125px;
|
||||
background-image: linear-gradient(to bottom, hsl(0, 0%, 100%) 0%, hsla(0, 0%, 100%, 0) 50%, hsla(0, 0%, 0%, 0) 50%, hsl(0, 0%, 0%) 100%),
|
||||
linear-gradient(to right, hsl(0, 0%, 50%) 0%, hsla(0, 0%, 50%, 0) 100%);
|
||||
cursor: crosshair;
|
||||
}
|
||||
.o_color_slider {
|
||||
background: linear-gradient(#F00 0%, #F0F 16.66%, #00F 33.33%, #0FF 50%, #0F0 66.66%, #FF0 83.33%, #F00 100%);
|
||||
}
|
||||
.o_opacity_slider, .o_color_preview {
|
||||
@extend %o-preview-alpha-background;
|
||||
}
|
||||
.o_color_slider, .o_opacity_slider {
|
||||
width: 4%;
|
||||
margin-right: 2%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_slider_pointer, .o_opacity_pointer {
|
||||
@include o-position-absolute($left: -50%);
|
||||
width: 200%;
|
||||
height: 8px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.o_slider_pointer, .o_opacity_pointer, .o_picker_pointer {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(white, 0.9),
|
||||
0 0 0 1px var(--bg, $white),
|
||||
0 0 0 3px var(--o-color-picker-active-color, $o-enterprise-action-color);
|
||||
}
|
||||
}
|
||||
.o_slider_pointer, .o_opacity_pointer, .o_picker_pointer, .o_color_preview {
|
||||
box-shadow: inset 0 0 0 1px rgba(white, 0.9);
|
||||
border: 1px solid black;
|
||||
}
|
||||
.o_color_picker_inputs {
|
||||
font-size: 10px;
|
||||
|
||||
input {
|
||||
font-family: monospace !important; // FIXME: the monospace font used in the editor has not consistent ch units on Firefox
|
||||
height: 18px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.o_hex_div input {
|
||||
width: 9ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web.CustomColorPicker">
|
||||
<div class="o_colorpicker_widget" t-ref="el" t-on-click="onClick" t-on-keydown="onKeydown" >
|
||||
<div class="d-flex justify-content-between align-items-stretch mb-2">
|
||||
<div t-ref="colorPickerArea"
|
||||
role="application"
|
||||
aria-label="Saturation and brightness color picker."
|
||||
aria-roledescription="Use up and down arrow keys to adapt the brightness. Use left and right arrow keys to adapt the saturation. Press the Control or Command key at the same time to have finer control."
|
||||
aria-activedescendant="picker_pointer"
|
||||
class="o_color_pick_area position-relative w-75"
|
||||
t-att-style="props.noTransparency ? 'width: 89%;' : None"
|
||||
t-on-pointerdown="onPointerDownPicker">
|
||||
<div t-ref="colorPickerPointer" id="picker_pointer" class="o_picker_pointer rounded-circle p-1 position-absolute" tabindex="0" t-on-keydown="onPickerKeydown"/>
|
||||
</div>
|
||||
<div t-ref="colorSlider" class="o_color_slider position-relative" t-on-pointerdown="onPointerDownSlider">
|
||||
<div t-ref="colorSliderPointer" class="o_slider_pointer" tabindex="0" t-on-keydown="onSliderKeydown" role="slider" aria-label="Hue" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="360"/>
|
||||
</div>
|
||||
<div t-ref="opacitySlider" class="o_opacity_slider position-relative" t-if="!props.noTransparency" t-on-pointerdown="onPointerDownOpacitySlider">
|
||||
<div t-ref="opacitySliderPointer" class="o_opacity_pointer" tabindex="0" t-on-keydown="onOpacitySliderKeydown" role="slider" aria-label="Opacity" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="100"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_color_picker_inputs d-flex justify-content-between mb-2" t-on-change="debouncedOnChangeInputs">
|
||||
<t t-set="input_classes" t-value="'p-0 border-0 text-center font-monospace bg-transparent'" />
|
||||
|
||||
<div class="o_hex_div d-flex align-items-baseline me-1">
|
||||
<input type="text" t-attf-class="o_hex_input {{input_classes}}" data-color-method="hex" size="1"
|
||||
t-on-input="onHexColorInput"/>
|
||||
<label class="flex-grow-0 ms-1 mb-0">hex</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { isColorGradient } from "@web/core/utils/colors";
|
||||
import { CustomColorPicker } from "../custom_color_picker/custom_color_picker";
|
||||
|
||||
export class ColorPickerCustomTab extends Component {
|
||||
static template = "web.ColorPickerCustomTab";
|
||||
static components = { CustomColorPicker };
|
||||
static props = {
|
||||
applyColor: Function,
|
||||
colorPickerNavigation: Function,
|
||||
onColorClick: Function,
|
||||
onColorPreview: Function,
|
||||
onColorPointerOver: Function,
|
||||
onColorPointerOut: Function,
|
||||
onFocusin: Function,
|
||||
onFocusout: Function,
|
||||
getUsedCustomColors: { type: Function, optional: true },
|
||||
currentColorPreview: { type: String, optional: true },
|
||||
currentCustomColor: { type: String, optional: true },
|
||||
defaultColorSet: { type: String | Boolean, optional: true },
|
||||
defaultOpacity: { type: Number, optional: true },
|
||||
grayscales: { type: Object, optional: true },
|
||||
cssVarColorPrefix: { type: String, optional: true },
|
||||
noTransparency: { type: Boolean, optional: true },
|
||||
setOnCloseCallback: { type: Function, optional: true },
|
||||
setOperationCallbacks: { type: Function, optional: true },
|
||||
"*": { optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.usedCustomColors = this.props.getUsedCustomColors();
|
||||
}
|
||||
|
||||
isValidCustomColor(color) {
|
||||
return color && color.slice(7, 9) !== "00" && !isColorGradient(color);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("color_picker_tabs").add("web.custom", {
|
||||
id: "custom",
|
||||
name: _t("Custom"),
|
||||
component: ColorPickerCustomTab,
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.ColorPickerCustomTab">
|
||||
<div class="d-flex flex-column align-items-start p-2 pb-0" t-on-keydown="props.colorPickerNavigation">
|
||||
<div class="o_colorpicker_section" t-on-click="props.onColorClick"
|
||||
t-on-mouseover="props.onColorPointerOver" t-on-mouseout="props.onColorPointerOut"
|
||||
t-on-focusin="props.onFocusin" t-on-focusout="props.onColorPointerOut">
|
||||
<t t-foreach="usedCustomColors" t-as="color" t-key="color_index">
|
||||
<button t-if="color.toLowerCase() !== props.currentCustomColor?.toLowerCase()"
|
||||
class="o_color_button o_color_picker_button btn p-0" t-att-data-color="color"
|
||||
t-attf-style="background-color: {{color}}"/>
|
||||
</t>
|
||||
<button t-if="!props.defaultColorSet and isValidCustomColor(props.currentCustomColor)"
|
||||
class="o_color_button o_color_picker_button btn p-0"
|
||||
t-att-class="{'selected': props.defaultColorSet === false and !props.currentColorPreview}"
|
||||
t-attf-style="background-color: {{props.currentCustomColor}}"
|
||||
t-att-data-color="props.currentCustomColor"/>
|
||||
<button t-if="!!props.currentColorPreview" class="o_color_button o_color_picker_button btn p-0"
|
||||
t-att-class="{ 'selected': !!props.currentColorPreview }"
|
||||
t-attf-style="background-color: {{props.currentColorPreview}}"
|
||||
t-att-data-color="props.currentColorPreview"/>
|
||||
</div>
|
||||
<t t-foreach="Object.values(props.grayscales)" t-as="grayscaleColors" t-key="grayscaleColors">
|
||||
<div class="o_colorpicker_section" t-on-click="props.onColorClick"
|
||||
t-on-mouseover="props.onColorPointerOver"
|
||||
t-on-mouseout="props.onColorPointerOut"
|
||||
t-on-focusin="props.onFocusin"
|
||||
t-on-focusout="props.onColorPointerOut">
|
||||
<t t-foreach="grayscaleColors" t-as="color" t-key="color">
|
||||
<button t-att-data-color="color" class="o_color_button o_color_picker_button btn p-0"
|
||||
t-att-class="{'selected': color === props.defaultColorSet and !props.currentColorPreview}"
|
||||
t-attf-style="background-color: var(--{{props.cssVarColorPrefix+color}})"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<CustomColorPicker
|
||||
selectedColor="props.currentCustomColor"
|
||||
setOnCloseCallback.bind="props.setOnCloseCallback"
|
||||
setOperationCallbacks.bind="props.setOperationCallbacks"
|
||||
onColorSelect.bind="(color) => this.props.applyColor(color.hex)"
|
||||
onColorPreview.bind="props.onColorPreview"
|
||||
noTransparency="props.noTransparency"
|
||||
defaultOpacity="props.defaultOpacity"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class ColorPickerSolidTab extends Component {
|
||||
static template = "web.ColorPickerSolidTab";
|
||||
static props = {
|
||||
colorPickerNavigation: Function,
|
||||
onColorClick: Function,
|
||||
onColorPointerOver: Function,
|
||||
onColorPointerOut: Function,
|
||||
onFocusin: Function,
|
||||
onFocusout: Function,
|
||||
currentCustomColor: { type: String, optional: true },
|
||||
defaultColorSet: { type: String | Boolean, optional: true },
|
||||
cssVarColorPrefix: { type: String, optional: true },
|
||||
defaultColors: Array,
|
||||
defaultThemeColorVars: Array,
|
||||
"*": { optional: true },
|
||||
};
|
||||
}
|
||||
|
||||
registry.category("color_picker_tabs").add("web.solid", {
|
||||
id: "solid",
|
||||
name: _t("Solid"),
|
||||
component: ColorPickerSolidTab,
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.ColorPickerSolidTab">
|
||||
<div class="d-flex flex-column align-items-center p-1"
|
||||
t-on-click="props.onColorClick" t-on-mouseover="props.onColorPointerOver"
|
||||
t-on-mouseout="props.onColorPointerOut" t-on-focusin="props.onFocusin" t-on-focusout="props.onFocusout" t-ref="solidTabRef"
|
||||
t-on-keydown="props.colorPickerNavigation">
|
||||
<div class="o_colorpicker_section">
|
||||
<t t-foreach="props.defaultThemeColorVars" t-as="color" t-key="color">
|
||||
<button t-att-data-color="color" t-att-class="{'selected': color === props.defaultColorSet}"
|
||||
t-att-style="`background-color: var(--${props.cssVarColorPrefix + color})` + (color_index === 3 ? '; grid-column: 5' : '')"
|
||||
class="btn p-0 o_color_button o_color_picker_button"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_color_section">
|
||||
<t t-foreach="props.defaultColors" t-as="line" t-key="line_index">
|
||||
<t t-foreach="line" t-as="color" t-key="color_index">
|
||||
<button class="o_color_button o_color_picker_button btn p-0"
|
||||
t-att-class="{'selected': color === props.currentCustomColor.toUpperCase() and !props.defaultColorSet}"
|
||||
t-att-data-color="color" t-attf-style="background-color: {{color}}"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
62
frontend/web/static/src/core/colorlist/colorlist.js
Normal file
62
frontend/web/static/src/core/colorlist/colorlist.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
import { Component, useRef, useState, useExternalListener } from "@odoo/owl";
|
||||
|
||||
export class ColorList extends Component {
|
||||
static COLORS = [
|
||||
_t("No color"),
|
||||
_t("Red"),
|
||||
_t("Orange"),
|
||||
_t("Yellow"),
|
||||
_t("Cyan"),
|
||||
_t("Purple"),
|
||||
_t("Almond"),
|
||||
_t("Teal"),
|
||||
_t("Blue"),
|
||||
_t("Raspberry"),
|
||||
_t("Green"),
|
||||
_t("Violet"),
|
||||
];
|
||||
static template = "web.ColorList";
|
||||
static defaultProps = {
|
||||
forceExpanded: false,
|
||||
isExpanded: false,
|
||||
};
|
||||
static 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 },
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
75
frontend/web/static/src/core/colorlist/colorlist.scss
Normal file
75
frontend/web/static/src/core/colorlist/colorlist.scss
Normal file
@@ -0,0 +1,75 @@
|
||||
@mixin o-colorlist($-entry, $-child) {
|
||||
width: var(--ColorListField-width, none);
|
||||
padding: var(--ColorListField-padding, 0);
|
||||
box-sizing: content-box;
|
||||
margin-bottom: var(--ColorListField-marginBottom, 0);
|
||||
|
||||
.o_bottom_sheet_body & {
|
||||
--ColorListField-padding: #{map-get($spacers, 3)} var(--BottomSheet-Entry-paddingX);
|
||||
--fieldWidget-display: block;
|
||||
--ColorListField-Entry-fontSize: 1.4em;
|
||||
|
||||
grid-template-columns: repeat(var(--ColorListField-columns, 6), 1fr);
|
||||
}
|
||||
|
||||
#{$-entry} {
|
||||
#{$-child} {
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: var(--ColorListField-Entry-minWidth, o-to-rem(20px));
|
||||
aspect-ratio: var(--ColorListField-Entry-aspectRatio, 1);
|
||||
border-radius: var(--ColorListField-Entry-borderRadius, 100%);
|
||||
font-size: var(--ColorListField-Entry-fontSize, smaller);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:first-child #{$-child}::after {
|
||||
box-shadow: inset 0 0 0 $border-width var(--dropdown-color);
|
||||
border-radius: inherit;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
&.active {
|
||||
#{$-child}::after {
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color, #{$body-color});
|
||||
font: normal normal normal 1em/1 FontAwesome;
|
||||
content: "\f00c";
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
opacity: $o-opacity-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_colorlist {
|
||||
@include o-colorlist("button", "&" );
|
||||
grid-template-columns: repeat(auto-fit, $o-bubble-color-size-xl);
|
||||
|
||||
:not(.o_field_widget) > & {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
> button {
|
||||
aspect-ratio: 1;
|
||||
|
||||
// No Color
|
||||
&.o_colorlist_item_color_0 {
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px $gray-500;
|
||||
}
|
||||
|
||||
// Set all the colors but the "no-color" one
|
||||
@for $size from 2 through length($o-colors) {
|
||||
&.o_colorlist_item_color_#{$size - 1} {
|
||||
@include o-print-color(nth($o-colors, $size), background-color, bg-opacity);
|
||||
@include o-print-color(color-contrast(nth($o-colors, $size)), color, text-opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
frontend/web/static/src/core/colorlist/colorlist.xml
Normal file
15
frontend/web/static/src/core/colorlist/colorlist.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.ColorList">
|
||||
<div class="o_colorlist d-grid gap-3 gap-md-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="border-0 rounded-circle 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="border-0 rounded-circle o_colorlist_item_color_{{ colorId }} {{ colorId === props.selectedColor ? 'active' : '' }}"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
217
frontend/web/static/src/core/colors/colors.js
Normal file
217
frontend/web/static/src/core/colors/colors.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import { clamp } from "@web/core/utils/numbers";
|
||||
/**
|
||||
* Lists of colors that contrast well with each other to be used in various
|
||||
* visualizations (eg. graphs/charts), both in bright and dark themes.
|
||||
*/
|
||||
|
||||
const COLORS_ENT_BRIGHT = ["#875A7B", "#A5D8D7", "#DCD0D9"];
|
||||
const COLORS_ENT_DARK = ["#6B3E66", "#147875", "#5A395A"];
|
||||
const COLORS_SM = [
|
||||
"#4EA7F2", // Blue
|
||||
"#EA6175", // Red
|
||||
"#43C5B1", // Teal
|
||||
"#F4A261", // Orange
|
||||
"#8481DD", // Purple
|
||||
"#FFD86D", // Yellow
|
||||
];
|
||||
const COLORS_MD = [
|
||||
"#4EA7F2", // Blue #1
|
||||
"#3188E6", // Blue #2
|
||||
"#43C5B1", // Teal #1
|
||||
"#00A78D", // Teal #2
|
||||
"#EA6175", // Red #1
|
||||
"#CE4257", // Red #2
|
||||
"#F4A261", // Orange #1
|
||||
"#F48935", // Orange #2
|
||||
"#8481DD", // Purple #1
|
||||
"#5752D1", // Purple #2
|
||||
"#FFD86D", // Yellow #1
|
||||
"#FFBC2C", // Yellow #2
|
||||
];
|
||||
const COLORS_LG = [
|
||||
"#4EA7F2", // Blue #1
|
||||
"#3188E6", // Blue #2
|
||||
"#056BD9", // Blue #3
|
||||
"#A76DBC", // Violet #1
|
||||
"#7F4295", // Violet #2
|
||||
"#6D2387", // Violet #3
|
||||
"#EA6175", // Red #1
|
||||
"#CE4257", // Red #2
|
||||
"#982738", // Red #3
|
||||
"#43C5B1", // Teal #1
|
||||
"#00A78D", // Teal #2
|
||||
"#0E8270", // Teal #3
|
||||
"#F4A261", // Orange #1
|
||||
"#F48935", // Orange #2
|
||||
"#BE5D10", // Orange #3
|
||||
"#8481DD", // Purple #1
|
||||
"#5752D1", // Purple #2
|
||||
"#3A3580", // Purple #3
|
||||
"#A4A8B6", // Gray #1
|
||||
"#7E8290", // Gray #2
|
||||
"#545B70", // Gray #3
|
||||
"#FFD86D", // Yellow #1
|
||||
"#FFBC2C", // Yellow #2
|
||||
"#C08A16", // Yellow #3
|
||||
];
|
||||
const COLORS_XL = [
|
||||
"#4EA7F2", // Blue #1
|
||||
"#3188E6", // Blue #2
|
||||
"#056BD9", // Blue #3
|
||||
"#155193", // Blue #4
|
||||
"#A76DBC", // Violet #1
|
||||
"#7F4295", // Violet #1
|
||||
"#6D2387", // Violet #1
|
||||
"#4F1565", // Violet #1
|
||||
"#EA6175", // Red #1
|
||||
"#CE4257", // Red #2
|
||||
"#982738", // Red #3
|
||||
"#791B29", // Red #4
|
||||
"#43C5B1", // Teal #1
|
||||
"#00A78D", // Teal #2
|
||||
"#0E8270", // Teal #3
|
||||
"#105F53", // Teal #4
|
||||
"#F4A261", // Orange #1
|
||||
"#F48935", // Orange #2
|
||||
"#BE5D10", // Orange #3
|
||||
"#7D380D", // Orange #4
|
||||
"#8481DD", // Purple #1
|
||||
"#5752D1", // Purple #2
|
||||
"#3A3580", // Purple #3
|
||||
"#26235F", // Purple #4
|
||||
"#A4A8B6", // Grey #1
|
||||
"#7E8290", // Grey #2
|
||||
"#545B70", // Grey #3
|
||||
"#3F4250", // Grey #4
|
||||
"#FFD86D", // Yellow #1
|
||||
"#FFBC2C", // Yellow #2
|
||||
"#C08A16", // Yellow #3
|
||||
"#936A12", // Yellow #4
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} colorScheme
|
||||
* @param {string} paletteName
|
||||
* @returns {array}
|
||||
*/
|
||||
export function getColors(colorScheme, paletteName) {
|
||||
switch (paletteName) {
|
||||
case "odoo":
|
||||
return colorScheme === "dark" ? COLORS_ENT_DARK : COLORS_ENT_BRIGHT;
|
||||
case "sm":
|
||||
return COLORS_SM;
|
||||
case "md":
|
||||
return COLORS_MD;
|
||||
case "lg":
|
||||
return COLORS_LG;
|
||||
default:
|
||||
return COLORS_XL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} index
|
||||
* @param {string} colorScheme
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getColor(index, colorScheme, paletteSizeOrName) {
|
||||
let paletteName;
|
||||
if (paletteSizeOrName === "odoo") {
|
||||
paletteName = "odoo";
|
||||
} else if (paletteSizeOrName <= 6 || paletteSizeOrName === "sm") {
|
||||
paletteName = "sm";
|
||||
} else if (paletteSizeOrName <= 12 || paletteSizeOrName === "md") {
|
||||
paletteName = "md";
|
||||
} else if (paletteSizeOrName <= 24 || paletteSizeOrName === "lg") {
|
||||
paletteName = "lg";
|
||||
} else {
|
||||
paletteName = "xl";
|
||||
}
|
||||
const colors = getColors(colorScheme, paletteName);
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
export const DEFAULT_BG = "#d3d3d3";
|
||||
|
||||
export function getBorderWhite(colorScheme) {
|
||||
return colorScheme === "dark" ? "rgba(38, 42, 54, .2)" : "rgba(249,250,251, .2)";
|
||||
}
|
||||
|
||||
const RGB_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i;
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
* @param {number} opacity
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hexToRGBA(hex, opacity) {
|
||||
const rgb = RGB_REGEX.exec(hex)
|
||||
.slice(1, 4)
|
||||
.map((n) => parseInt(n, 16))
|
||||
.join(",");
|
||||
return `rgba(${rgb},${opacity})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to return custom colors depending on the color scheme
|
||||
* @param {string} colorScheme
|
||||
* @param {string} brightModeColor
|
||||
* @param {string} darkModeColor
|
||||
* @returns {string|Number|Boolean}
|
||||
*/
|
||||
|
||||
export function getCustomColor(colorScheme, brightModeColor, darkModeColor) {
|
||||
if (darkModeColor === undefined) {
|
||||
return brightModeColor;
|
||||
} else {
|
||||
return colorScheme === "dark" ? darkModeColor : brightModeColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to lighten a color
|
||||
* @param {string} color
|
||||
* @param {number} factor
|
||||
* @returns {string}
|
||||
*/
|
||||
export function lightenColor(color, factor) {
|
||||
factor = clamp(factor, 0, 1);
|
||||
|
||||
let r = parseInt(color.substring(1, 3), 16);
|
||||
let g = parseInt(color.substring(3, 5), 16);
|
||||
let b = parseInt(color.substring(5, 7), 16);
|
||||
|
||||
r = Math.round(r + (255 - r) * factor);
|
||||
g = Math.round(g + (255 - g) * factor);
|
||||
b = Math.round(b + (255 - b) * factor);
|
||||
|
||||
r = r.toString(16).padStart(2, "0");
|
||||
g = g.toString(16).padStart(2, "0");
|
||||
b = b.toString(16).padStart(2, "0");
|
||||
|
||||
return `#${r}${g}${b}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to darken a color
|
||||
* @param {string} color
|
||||
* @param {number} factor
|
||||
* @returns {string}
|
||||
*/
|
||||
export function darkenColor(color, factor) {
|
||||
factor = clamp(factor, 0, 1);
|
||||
|
||||
let r = parseInt(color.substring(1, 3), 16);
|
||||
let g = parseInt(color.substring(3, 5), 16);
|
||||
let b = parseInt(color.substring(5, 7), 16);
|
||||
|
||||
r = Math.round(r * (1 - factor));
|
||||
g = Math.round(g * (1 - factor));
|
||||
b = Math.round(b * (1 - factor));
|
||||
|
||||
r = r.toString(16).padStart(2, "0");
|
||||
g = g.toString(16).padStart(2, "0");
|
||||
b = b.toString(16).padStart(2, "0");
|
||||
|
||||
return `#${r}${g}${b}`;
|
||||
}
|
||||
11
frontend/web/static/src/core/commands/command_category.js
Normal file
11
frontend/web/static/src/core/commands/command_category.js
Normal file
@@ -0,0 +1,11 @@
|
||||
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("default", {}, { sequence: 50 })
|
||||
.add("view_switcher", {}, { sequence: 100 })
|
||||
.add("debug", {}, { sequence: 110 })
|
||||
.add("disabled", {});
|
||||
23
frontend/web/static/src/core/commands/command_hook.js
Normal file
23
frontend/web/static/src/core/commands/command_hook.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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 | import("@web/core/commands/command_palette").CommandPaletteConfig)} action
|
||||
* @param {CommandOptions} [options]
|
||||
*/
|
||||
export function useCommand(name, action, options = {}) {
|
||||
const commandService = useService("command");
|
||||
useEffect(
|
||||
() => commandService.add(name, action, options),
|
||||
() => []
|
||||
);
|
||||
}
|
||||
35
frontend/web/static/src/core/commands/command_items.xml
Normal file
35
frontend/web/static/src/core/commands/command_items.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.DefaultFooter" owl="1">
|
||||
<span>
|
||||
<span class="fw-bolder text-primary">TIP</span> — search for
|
||||
<t t-foreach="elements" t-as="element" t-key="element.namespace">
|
||||
<t t-if="!(element_first || element_last)">, </t>
|
||||
<t t-if="element_last and !element_first"> and </t>
|
||||
<span class="o_namespace btn-link text-primary cursor-pointer" t-on-click="() => this.onClick(element.namespace)">
|
||||
<span t-out="element.namespace" class="fw-bolder text-primary"/><t t-out="element.name"/>
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<t t-name="web.DefaultCommandItem">
|
||||
<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">
|
||||
<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>
|
||||
388
frontend/web/static/src/core/commands/command_palette.js
Normal file
388
frontend/web/static/src/core/commands/command_palette.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||
import { _t } 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 { highlightText } from "@web/core/utils/html";
|
||||
|
||||
import {
|
||||
Component,
|
||||
onWillStart,
|
||||
onWillDestroy,
|
||||
EventBus,
|
||||
useRef,
|
||||
useState,
|
||||
markRaw,
|
||||
useExternalListener,
|
||||
} from "@odoo/owl";
|
||||
|
||||
const DEFAULT_PLACEHOLDER = _t("Search...");
|
||||
const DEFAULT_EMPTY_MESSAGE = _t("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 class DefaultCommandItem extends Component {
|
||||
static template = "web.DefaultCommandItem";
|
||||
static props = {
|
||||
slots: { type: Object, optional: true },
|
||||
// Props send by the command palette:
|
||||
hotkey: { type: String, optional: true },
|
||||
hotkeyOptions: { type: String, optional: true },
|
||||
name: { type: String, optional: true },
|
||||
searchValue: { type: String, optional: true },
|
||||
executeCommand: { type: Function, optional: true },
|
||||
};
|
||||
}
|
||||
|
||||
export class CommandPalette extends Component {
|
||||
static template = "web.CommandPalette";
|
||||
static components = { Dialog };
|
||||
static lastSessionId = 0;
|
||||
static props = {
|
||||
bus: { type: EventBus, optional: true },
|
||||
close: Function,
|
||||
config: Object,
|
||||
closeMe: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
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,
|
||||
name: this.categoryNames[category],
|
||||
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"];
|
||||
this.categoryNames = {};
|
||||
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;
|
||||
this.categoryNames = namespaceConfig.categoryNames || {};
|
||||
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++,
|
||||
text: highlightText(options.searchValue, command.name, "fw-bolder text-primary"),
|
||||
}))
|
||||
);
|
||||
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) {
|
||||
this.state.isLoading = true;
|
||||
try {
|
||||
await this.setCommands(this.state.namespace, {
|
||||
searchValue,
|
||||
activeElement: this.activeElement,
|
||||
sessionId: this._sessionId,
|
||||
});
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
53
frontend/web/static/src/core/commands/command_palette.scss
Normal file
53
frontend/web/static/src/core/commands/command_palette.scss
Normal file
@@ -0,0 +1,53 @@
|
||||
.o_command_palette {
|
||||
$-app-icon-size: 1.8rem;
|
||||
top: 120px;
|
||||
position: absolute;
|
||||
|
||||
> .modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&_listbox {
|
||||
max-height: 50vh;
|
||||
|
||||
.o_command {
|
||||
&.focused {
|
||||
background: rgba($o-component-active-bg, .65);
|
||||
}
|
||||
|
||||
&_hotkey {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: inherit;
|
||||
padding: 0.5rem 1.3em;
|
||||
display: flex;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
61
frontend/web/static/src/core/commands/command_palette.xml
Normal file
61
frontend/web/static/src/core/commands/command_palette.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CommandPalette">
|
||||
<Dialog header="false" footer="false" size="'md'" contentClass="'o_command_palette'">
|
||||
<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 fs-4 text-muted opacity-75" 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-if="state.isLoading" title="Loading..." role="img" aria-label="Loading..." class="fa fa-circle-o-notch fa-spin"/>
|
||||
<i t-else="" 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">
|
||||
<span t-if="category.name" class="text-uppercase fw-bold px-3 text-muted smaller opacity-50" t-out="category.name"/>
|
||||
<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-att-class="command.className">
|
||||
<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-out="command.text"/>
|
||||
</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 rounded-bottom bg-100 text-muted">
|
||||
<t t-component="state.FooterComponent" switchNamespace="(namespace) => this.debounceSearch(namespace.concat(this.state.searchValue))"/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
261
frontend/web/static/src/core/commands/command_service.js
Normal file
261
frontend/web/static/src/core/commands/command_service.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { CommandPalette } from "./command_palette";
|
||||
|
||||
import { Component, EventBus } from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* @typedef {import("./command_palette").CommandPaletteConfig} CommandPaletteConfig
|
||||
* @typedef {import("../hotkeys/hotkey_service").HotkeyOptions} HotkeyOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* name: string;
|
||||
* action: ()=>(void | CommandPaletteConfig);
|
||||
* category?: string;
|
||||
* href?: string;
|
||||
* className?: string;
|
||||
* }} Command
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* category?: string;
|
||||
* isAvailable?: ()=>(boolean);
|
||||
* global?: boolean;
|
||||
* hotkey?: string;
|
||||
* hotkeyOptions?: HotkeyOptions
|
||||
* }} 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 {
|
||||
static template = "web.DefaultFooter";
|
||||
static props = {
|
||||
switchNamespace: { type: Function },
|
||||
};
|
||||
setup() {
|
||||
this.elements = commandSetupRegistry
|
||||
.getEntries()
|
||||
.map((el) => ({ namespace: el[0], name: el[1].name }))
|
||||
.filter((el) => el.name);
|
||||
}
|
||||
|
||||
onClick(namespace) {
|
||||
this.props.switchNamespace(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
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: [],
|
||||
categoryNames: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const [category, el] of commandCategoryRegistry.getEntries()) {
|
||||
const namespace = el.namespace || "default";
|
||||
const name = el.name;
|
||||
if (namespace in configByNamespace) {
|
||||
configByNamespace[namespace].categories.push(category);
|
||||
configByNamespace[namespace].categoryNames[category] = name;
|
||||
}
|
||||
}
|
||||
|
||||
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.identifier) {
|
||||
const commandsArray = Array.from(registeredCommands.values());
|
||||
const sameName = commandsArray.find((com) => com.name === registration.name);
|
||||
if (sameName) {
|
||||
if (registration.identifier !== sameName.identifier) {
|
||||
registration.name += ` (${registration.identifier})`;
|
||||
sameName.name += ` (${sameName.identifier})`;
|
||||
}
|
||||
} else {
|
||||
const sameFullName = commandsArray.find(
|
||||
(com) => com.name === registration.name + `(${registration.identifier})`
|
||||
);
|
||||
if (sameFullName) {
|
||||
registration.name += ` (${registration.identifier})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
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, {
|
||||
...options.hotkeyOptions,
|
||||
global: registration.global,
|
||||
isAvailable: (...args) => {
|
||||
let available = true;
|
||||
if (registration.isAvailable) {
|
||||
available = registration.isAvailable(...args);
|
||||
}
|
||||
if (available && options.hotkeyOptions?.isAvailable) {
|
||||
available = options.hotkeyOptions?.isAvailable(...args);
|
||||
}
|
||||
return available;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
109
frontend/web/static/src/core/commands/default_providers.js
Normal file
109
frontend/web/static/src/core/commands/default_providers.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { isMacOS } from "@web/core/browser/feature_detection";
|
||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||
import { _t } 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: _t("No command found"),
|
||||
placeholder: _t("Search for a command..."),
|
||||
});
|
||||
|
||||
export class HotkeyCommandItem extends Component {
|
||||
static template = "web.HotkeyCommandItem";
|
||||
static props = ["hotkey", "hotkeyOptions?", "name?", "searchValue?", "executeCommand", "slots"];
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
// Filter out same category dupplicate commands
|
||||
const uniqueCommands = commands.filter((obj, index) => {
|
||||
return (
|
||||
index ===
|
||||
commands.findIndex((o) => obj.name === o.name && obj.category === o.category)
|
||||
);
|
||||
});
|
||||
return uniqueCommands.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";
|
||||
if (category === "disabled") {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 ? "..." : ""}`) ||
|
||||
_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,103 @@
|
||||
import { Dialog } from "../dialog/dialog";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useChildRef } from "@web/core/utils/hooks";
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export const deleteConfirmationMessage = _t(
|
||||
`Ready to make your record disappear into thin air? Are you sure?
|
||||
It will be gone forever!
|
||||
|
||||
Think twice before you click that 'Delete' button!`
|
||||
);
|
||||
|
||||
export class ConfirmationDialog extends Component {
|
||||
static template = "web.ConfirmationDialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function,
|
||||
title: {
|
||||
validate: (m) => {
|
||||
return (
|
||||
typeof m === "string" ||
|
||||
(typeof m === "object" && typeof m.toString === "function")
|
||||
);
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
body: { type: String, optional: true },
|
||||
confirm: { type: Function, optional: true },
|
||||
confirmLabel: { type: String, optional: true },
|
||||
confirmClass: { type: String, optional: true },
|
||||
cancel: { type: Function, optional: true },
|
||||
cancelLabel: { type: String, optional: true },
|
||||
dismiss: { type: Function, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
confirmLabel: _t("Ok"),
|
||||
cancelLabel: _t("Cancel"),
|
||||
confirmClass: "btn-primary",
|
||||
title: _t("Confirmation"),
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.env.dialogData.dismiss = () => this._dismiss();
|
||||
this.modalRef = useChildRef();
|
||||
this.isProcess = false;
|
||||
}
|
||||
|
||||
async _cancel() {
|
||||
return this.execButton(this.props.cancel);
|
||||
}
|
||||
|
||||
async _confirm() {
|
||||
return this.execButton(this.props.confirm);
|
||||
}
|
||||
|
||||
async _dismiss() {
|
||||
return this.execButton(this.props.dismiss || this.props.cancel);
|
||||
}
|
||||
|
||||
setButtonsDisabled(disabled) {
|
||||
this.isProcess = disabled;
|
||||
if (!this.modalRef.el) {
|
||||
return; // safety belt for stable versions
|
||||
}
|
||||
for (const button of [...this.modalRef.el.querySelectorAll(".modal-footer button")]) {
|
||||
button.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
async execButton(callback) {
|
||||
if (this.isProcess) {
|
||||
return;
|
||||
}
|
||||
this.setButtonsDisabled(true);
|
||||
if (callback) {
|
||||
let shouldClose;
|
||||
try {
|
||||
shouldClose = await callback();
|
||||
} catch (e) {
|
||||
this.props.close();
|
||||
throw e;
|
||||
}
|
||||
if (shouldClose === false) {
|
||||
this.setButtonsDisabled(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class AlertDialog extends ConfirmationDialog {
|
||||
static template = "web.AlertDialog";
|
||||
static props = {
|
||||
...ConfirmationDialog.props,
|
||||
contentClass: { type: String, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
...ConfirmationDialog.defaultProps,
|
||||
title: _t("Alert"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.ConfirmationDialog">
|
||||
<Dialog size="'md'" title="props.title" modalRef="modalRef">
|
||||
<p t-out="props.body" class="text-prewrap"/>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn" t-att-class="props.confirmClass" t-on-click="_confirm" t-esc="props.confirmLabel" data-hotkey="q"/>
|
||||
<button t-if="props.cancel" class="btn btn-secondary" t-on-click="_cancel" t-esc="props.cancelLabel" data-hotkey="x"/>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
<t t-name="web.AlertDialog">
|
||||
<Dialog size="'sm'" title="props.title" contentClass="props.contentClass">
|
||||
<p t-out="props.body" class="text-prewrap"/>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn" t-att-class="props.confirmClass" 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>
|
||||
85
frontend/web/static/src/core/context.js
Normal file
85
frontend/web/static/src/core/context.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { evaluateExpr, parseExpr } from "./py_js/py";
|
||||
import { BUILTINS } from "./py_js/py_builtin";
|
||||
import { evaluate } from "./py_js/py_interpreter";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* lang?: string;
|
||||
* tz?: string;
|
||||
* uid?: number | false;
|
||||
* [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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a partial list of variable names found in the AST.
|
||||
* Note that it is not complete. It is used as an heuristic to avoid
|
||||
* evaluating expressions that we know for sure will fail.
|
||||
*
|
||||
* @param {AST} ast
|
||||
* @returns string[]
|
||||
*/
|
||||
function getPartialNames(ast) {
|
||||
if (ast.type === 5) {
|
||||
return [ast.value];
|
||||
}
|
||||
if (ast.type === 6) {
|
||||
return getPartialNames(ast.right);
|
||||
}
|
||||
if (ast.type === 14 || ast.type === 7) {
|
||||
return getPartialNames(ast.left).concat(getPartialNames(ast.right));
|
||||
}
|
||||
if (ast.type === 15) {
|
||||
return getPartialNames(ast.obj);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow to evaluate a context with an incomplete evaluation context. The evaluated context only
|
||||
* contains keys whose values are static or can be evaluated with the given evaluation context.
|
||||
*
|
||||
* @param {string} context
|
||||
* @param {Context} [evaluationContext={}]
|
||||
* @returns {Context}
|
||||
*/
|
||||
export function evalPartialContext(_context, evaluationContext = {}) {
|
||||
const ast = parseExpr(_context);
|
||||
const context = {};
|
||||
for (const key in ast.value) {
|
||||
const value = ast.value[key];
|
||||
if (
|
||||
getPartialNames(value).some((name) => !(name in evaluationContext || name in BUILTINS))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
context[key] = evaluate(value, evaluationContext);
|
||||
} catch {
|
||||
// ignore this key as we can't evaluate its value
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
48
frontend/web/static/src/core/copy_button/copy_button.js
Normal file
48
frontend/web/static/src/core/copy_button/copy_button.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { Tooltip } from "@web/core/tooltip/tooltip";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { Component, useRef } from "@odoo/owl";
|
||||
|
||||
export class CopyButton extends Component {
|
||||
static template = "web.CopyButton";
|
||||
static props = {
|
||||
className: { type: String, optional: true },
|
||||
copyText: { type: String, optional: true },
|
||||
disabled: { type: Boolean, optional: true },
|
||||
successText: { type: String, optional: true },
|
||||
icon: { type: String, optional: true },
|
||||
content: { type: [String, Object, Function], optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.button = useRef("button");
|
||||
this.popover = usePopover(Tooltip);
|
||||
}
|
||||
|
||||
showTooltip() {
|
||||
this.popover.open(this.button.el, { tooltip: this.props.successText });
|
||||
browser.setTimeout(this.popover.close, 800);
|
||||
}
|
||||
|
||||
async onClick() {
|
||||
let write, content;
|
||||
if (typeof this.props.content === "function") {
|
||||
content = this.props.content();
|
||||
} else {
|
||||
content = this.props.content;
|
||||
}
|
||||
// any kind of content can be copied into the clipboard using
|
||||
// the appropriate native methods
|
||||
if (typeof content === "string" || content instanceof String) {
|
||||
write = (value) => browser.navigator.clipboard.writeText(value);
|
||||
} else {
|
||||
write = (value) => browser.navigator.clipboard.write(value);
|
||||
}
|
||||
try {
|
||||
await write(content);
|
||||
} catch (error) {
|
||||
return browser.console.warn(error);
|
||||
}
|
||||
this.showTooltip();
|
||||
}
|
||||
}
|
||||
18
frontend/web/static/src/core/copy_button/copy_button.xml
Normal file
18
frontend/web/static/src/core/copy_button/copy_button.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CopyButton">
|
||||
<button
|
||||
type="button"
|
||||
class="text-nowrap"
|
||||
t-ref="button"
|
||||
t-att-disabled="props.disabled"
|
||||
t-attf-class="btn o_clipboard_button {{ props.className || '' }}"
|
||||
t-on-click.stop="onClick"
|
||||
>
|
||||
<span class="mx-1" t-attf-class="fa {{ props.icon || 'fa-clipboard' }}"/>
|
||||
<span t-if="props.copyText" t-esc="props.copyText"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
98
frontend/web/static/src/core/currency.js
Normal file
98
frontend/web/static/src/core/currency.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { user } from "@web/core/user";
|
||||
import { formatFloat, humanNumber } from "@web/core/utils/numbers";
|
||||
import { nbsp } from "@web/core/utils/strings";
|
||||
import { session } from "@web/session";
|
||||
|
||||
export const currencies = session.currencies || {};
|
||||
// to make sure code is reading currencies from here
|
||||
delete session.currencies;
|
||||
|
||||
export function getCurrency(id) {
|
||||
return currencies[id];
|
||||
}
|
||||
|
||||
export async function getCurrencyRates() {
|
||||
const rates = reactive({});
|
||||
|
||||
function recordsToRates(records) {
|
||||
return Object.fromEntries(records.map((r) => [r.id, r.inverse_rate]));
|
||||
}
|
||||
|
||||
const model = "res.currency";
|
||||
const method = "read";
|
||||
const url = `/web/dataset/call_kw/${model}/${method}`;
|
||||
const context = {
|
||||
...user.context,
|
||||
to_currency: user.activeCompany.currency_id,
|
||||
};
|
||||
const params = {
|
||||
model,
|
||||
method,
|
||||
args: [Object.keys(currencies).map(Number), ["inverse_rate"]],
|
||||
kwargs: { context },
|
||||
};
|
||||
const records = await rpc(url, params, {
|
||||
cache: {
|
||||
type: "disk",
|
||||
update: "once",
|
||||
callback: (records, hasChanged) => {
|
||||
if (hasChanged) {
|
||||
Object.assign(rates, recordsToRates(records));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Object.assign(rates, recordsToRates(records));
|
||||
return rates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing a monetary value. The result takes into account
|
||||
* the user settings (to display the correct decimal separator, currency, ...).
|
||||
*
|
||||
* @param {number} value the value that should be formatted
|
||||
* @param {number} [currencyId] the id of the 'res.currency' to use
|
||||
* @param {Object} [options]
|
||||
* additional options to override the values in the python description of the
|
||||
* field.
|
||||
* @param {Object} [options.data] a mapping of field names to field values,
|
||||
* required with options.currencyField
|
||||
* @param {boolean} [options.noSymbol] this currency has not a sympbol
|
||||
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
|
||||
* to a human readable format.
|
||||
* @param {number} [options.minDigits] see @humanNumber
|
||||
* @param {boolean} [options.trailingZeros] if false, numbers will have zeros
|
||||
* to the right of the last non-zero digit hidden
|
||||
* @param {[number, number]} [options.digits] the number of digits that should
|
||||
* be used, instead of the default digits precision in the field. The first
|
||||
* number is always ignored (legacy constraint)
|
||||
* @param {number} [options.minDigits] the minimum number of decimal digits to display.
|
||||
* Displays maximum 6 decimal places if no precision is provided.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatCurrency(amount, currencyId, options = {}) {
|
||||
const currency = getCurrency(currencyId);
|
||||
|
||||
const digits = (options.digits !== undefined)? options.digits : (currency && currency.digits)
|
||||
|
||||
let formattedAmount;
|
||||
if (options.humanReadable) {
|
||||
formattedAmount = humanNumber(amount, {
|
||||
decimals: digits ? digits[1] : 2,
|
||||
minDigits: options.minDigits,
|
||||
});
|
||||
} else {
|
||||
formattedAmount = formatFloat(amount, { digits, minDigits: options.minDigits, trailingZeros: options.trailingZeros });
|
||||
}
|
||||
|
||||
if (!currency || options.noSymbol) {
|
||||
return formattedAmount;
|
||||
}
|
||||
const formatted = [currency.symbol, formattedAmount];
|
||||
if (currency.position === "after") {
|
||||
formatted.reverse();
|
||||
}
|
||||
return formatted.join(nbsp);
|
||||
}
|
||||
48
frontend/web/static/src/core/datetime/datetime_input.js
Normal file
48
frontend/web/static/src/core/datetime/datetime_input.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { omit } from "../utils/objects";
|
||||
import { DateTimePicker } from "./datetime_picker";
|
||||
import { useDateTimePicker } from "./datetime_picker_hook";
|
||||
|
||||
/**
|
||||
* @typedef {import("./datetime_picker").DateTimePickerProps & {
|
||||
* format?: string;
|
||||
* id?: string;
|
||||
* onApply?: (value: DateTime) => any;
|
||||
* onChange?: (value: DateTime) => any;
|
||||
* placeholder?: string;
|
||||
* }} DateTimeInputProps
|
||||
*/
|
||||
|
||||
const dateTimeInputOwnProps = {
|
||||
format: { type: String, optional: true },
|
||||
id: { type: String, optional: true },
|
||||
class: { type: String, optional: true },
|
||||
onChange: { type: Function, optional: true },
|
||||
onApply: { type: Function, optional: true },
|
||||
placeholder: { type: String, optional: true },
|
||||
disabled: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
/** @extends {Component<DateTimeInputProps>} */
|
||||
export class DateTimeInput extends Component {
|
||||
static props = {
|
||||
...DateTimePicker.props,
|
||||
...dateTimeInputOwnProps,
|
||||
};
|
||||
|
||||
static template = "web.DateTimeInput";
|
||||
|
||||
setup() {
|
||||
const getPickerProps = () => omit(this.props, ...Object.keys(dateTimeInputOwnProps));
|
||||
|
||||
useDateTimePicker({
|
||||
format: this.props.format,
|
||||
showSeconds: this.props.rounding <= 0,
|
||||
get pickerProps() {
|
||||
return getPickerProps();
|
||||
},
|
||||
onApply: (...args) => this.props.onApply?.(...args),
|
||||
onChange: (...args) => this.props.onChange?.(...args),
|
||||
});
|
||||
}
|
||||
}
|
||||
15
frontend/web/static/src/core/datetime/datetime_input.xml
Normal file
15
frontend/web/static/src/core/datetime/datetime_input.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.DateTimeInput">
|
||||
<input
|
||||
type="text"
|
||||
t-ref="start-date"
|
||||
t-att-id="props.id"
|
||||
class="o_datetime_input o_input cursor-pointer"
|
||||
t-att-class="props.class"
|
||||
autocomplete="off"
|
||||
t-att-placeholder="props.placeholder"
|
||||
t-att-disabled="props.disabled"
|
||||
/>
|
||||
</t>
|
||||
</templates>
|
||||
646
frontend/web/static/src/core/datetime/datetime_picker.js
Normal file
646
frontend/web/static/src/core/datetime/datetime_picker.js
Normal file
@@ -0,0 +1,646 @@
|
||||
import { Component, onWillRender, onWillUpdateProps, useState } from "@odoo/owl";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { MAX_VALID_DATE, MIN_VALID_DATE, clampDate, isInRange, today } from "../l10n/dates";
|
||||
import { localization } from "../l10n/localization";
|
||||
import { ensureArray } from "../utils/arrays";
|
||||
import { TimePicker } from "@web/core/time_picker/time_picker";
|
||||
import { Time } from "@web/core/l10n/time";
|
||||
|
||||
const { DateTime, Info } = luxon;
|
||||
|
||||
/**
|
||||
* @typedef DateItem
|
||||
* @property {string} id
|
||||
* @property {boolean} includesToday
|
||||
* @property {boolean} isOutOfRange
|
||||
* @property {boolean} isValid
|
||||
* @property {string} label
|
||||
* @property {DateRange} range
|
||||
* @property {string} extraClass
|
||||
*
|
||||
* @typedef {"today" | NullableDateTime} DateLimit
|
||||
*
|
||||
* @typedef {[DateTime, DateTime]} DateRange
|
||||
*
|
||||
* @typedef {luxon["DateTime"]["prototype"]} DateTime
|
||||
*
|
||||
* @typedef DateTimePickerProps
|
||||
* @property {number} [focusedDateIndex=0]
|
||||
* @property {boolean} [showWeekNumbers=true]
|
||||
* @property {DaysOfWeekFormat} [daysOfWeekFormat="narrow"]
|
||||
* @property {DateLimit} [maxDate]
|
||||
* @property {PrecisionLevel} [maxPrecision="decades"]
|
||||
* @property {DateLimit} [minDate]
|
||||
* @property {PrecisionLevel} [minPrecision="days"]
|
||||
* @property {() => any} [onReset]
|
||||
* @property {(value: DateTime | DateRange, unit: "date" | "time") => any} [onSelect]
|
||||
* @property {() => any} [onToggleRange]
|
||||
* @property {boolean} [range]
|
||||
* @property {number} [rounding=5] the rounding in minutes, pass 0 to show seconds, pass 1 to avoid
|
||||
* rounding minutes without displaying seconds.
|
||||
* @property {() => boolean} [showRangeToggler]
|
||||
* @property {{ buttons?: any }} [slots]
|
||||
* @property {"date" | "datetime"} [type]
|
||||
* @property {NullableDateTime | NullableDateRange} [value]
|
||||
* @property {(date: DateTime) => boolean} [isDateValid]
|
||||
* @property {(date: DateTime) => string} [dayCellClass]
|
||||
*
|
||||
* @typedef {DateItem | MonthItem} Item
|
||||
*
|
||||
* @typedef MonthItem
|
||||
* @property {[string, string][]} daysOfWeek
|
||||
* @property {string} id
|
||||
* @property {number} number
|
||||
* @property {WeekItem[]} weeks
|
||||
*
|
||||
* @typedef {import("@web/core/l10n/dates").NullableDateTime} NullableDateTime
|
||||
*
|
||||
* @typedef {import("@web/core/l10n/dates").NullableDateRange} NullableDateRange
|
||||
*
|
||||
* @typedef PrecisionInfo
|
||||
* @property {(date: DateTime, params: Partial<DateTimePickerProps>) => string} getTitle
|
||||
* @property {(date: DateTime, params: Partial<DateTimePickerProps>) => Item[]} getItems
|
||||
* @property {string} mainTitle
|
||||
* @property {string} nextTitle
|
||||
* @property {string} prevTitle
|
||||
* @property {Record<string, number>} step
|
||||
*
|
||||
* @typedef {"days" | "months" | "years" | "decades"} PrecisionLevel
|
||||
*
|
||||
* @typedef {"short" | "narrow"} DaysOfWeekFormat
|
||||
*
|
||||
* @typedef WeekItem
|
||||
* @property {DateItem[]} days
|
||||
* @property {number} number
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {DateTime} date
|
||||
*/
|
||||
const getStartOfDecade = (date) => Math.floor(date.year / 10) * 10;
|
||||
|
||||
/**
|
||||
* @param {DateTime} date
|
||||
*/
|
||||
const getStartOfCentury = (date) => Math.floor(date.year / 100) * 100;
|
||||
|
||||
/**
|
||||
* @param {DateTime} date
|
||||
*/
|
||||
const getStartOfWeek = (date) => {
|
||||
const { weekStart } = localization;
|
||||
return date.set({ weekday: date.weekday < weekStart ? weekStart - 7 : weekStart });
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
*/
|
||||
const numberRange = (min, max) => [...Array(max - min)].map((_, i) => i + min);
|
||||
|
||||
/**
|
||||
* @param {NullableDateTime | "today"} value
|
||||
* @param {NullableDateTime | "today"} defaultValue
|
||||
*/
|
||||
const parseLimitDate = (value, defaultValue) =>
|
||||
clampDate(value === "today" ? today() : value || defaultValue, MIN_VALID_DATE, MAX_VALID_DATE);
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {boolean} [params.isOutOfRange=false]
|
||||
* @param {boolean} [params.isValid=true]
|
||||
* @param {keyof DateTime} params.label
|
||||
* @param {string} [params.extraClass]
|
||||
* @param {[DateTime, DateTime]} params.range
|
||||
* @returns {DateItem}
|
||||
*/
|
||||
const toDateItem = ({ isOutOfRange = false, isValid = true, label, range, extraClass }) => ({
|
||||
id: range[0].toISODate(),
|
||||
includesToday: isInRange(today(), range),
|
||||
isOutOfRange,
|
||||
isValid,
|
||||
label: String(range[0][label]),
|
||||
range,
|
||||
extraClass,
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {DateItem[]} weekDayItems
|
||||
* @returns {WeekItem}
|
||||
*/
|
||||
const toWeekItem = (weekDayItems) => ({
|
||||
number: weekDayItems[3].range[0].weekNumber,
|
||||
days: weekDayItems,
|
||||
});
|
||||
|
||||
/**
|
||||
* Precision levels
|
||||
* @type {Map<PrecisionLevel, PrecisionInfo>}
|
||||
*/
|
||||
const PRECISION_LEVELS = new Map()
|
||||
.set("days", {
|
||||
mainTitle: _t("Select month"),
|
||||
nextTitle: _t("Next month"),
|
||||
prevTitle: _t("Previous month"),
|
||||
step: { month: 1 },
|
||||
getTitle: (date) => `${date.monthLong} ${date.year}`,
|
||||
getItems: (date, { maxDate, minDate, showWeekNumbers, isDateValid, dayCellClass }) => {
|
||||
const startDates = [date];
|
||||
|
||||
/** @type {WeekItem[]} */
|
||||
const lastWeeks = [];
|
||||
let shouldAddLastWeek = false;
|
||||
|
||||
const dayItems = startDates.map((date, i) => {
|
||||
const monthRange = [date.startOf("month"), date.endOf("month")];
|
||||
/** @type {WeekItem[]} */
|
||||
const weeks = [];
|
||||
|
||||
// Generate 6 weeks for current month
|
||||
let startOfNextWeek = getStartOfWeek(monthRange[0]);
|
||||
for (let w = 0; w < WEEKS_PER_MONTH; w++) {
|
||||
const weekDayItems = [];
|
||||
// Generate all days of the week
|
||||
for (let d = 0; d < DAYS_PER_WEEK; d++) {
|
||||
const day = startOfNextWeek.plus({ day: d });
|
||||
const range = [day, day.endOf("day")];
|
||||
const dayItem = toDateItem({
|
||||
isOutOfRange: !isInRange(day, monthRange),
|
||||
isValid: isInRange(range, [minDate, maxDate]) && isDateValid?.(day),
|
||||
label: "day",
|
||||
range,
|
||||
extraClass: dayCellClass?.(day) || "",
|
||||
});
|
||||
weekDayItems.push(dayItem);
|
||||
if (d === DAYS_PER_WEEK - 1) {
|
||||
startOfNextWeek = day.plus({ day: 1 });
|
||||
}
|
||||
if (w === WEEKS_PER_MONTH - 1) {
|
||||
shouldAddLastWeek = true;
|
||||
}
|
||||
}
|
||||
|
||||
const weekItem = toWeekItem(weekDayItems);
|
||||
if (w === WEEKS_PER_MONTH - 1) {
|
||||
lastWeeks.push(weekItem);
|
||||
} else {
|
||||
weeks.push(weekItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate days of week labels
|
||||
const daysOfWeek = weeks[0].days.map((d) => [
|
||||
d.range[0].weekdayShort,
|
||||
d.range[0].weekdayLong,
|
||||
Info.weekdays("narrow", { locale: d.range[0].locale })[d.range[0].weekday - 1],
|
||||
]);
|
||||
if (showWeekNumbers) {
|
||||
daysOfWeek.unshift(["", _t("Week numbers"), ""]);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `__month__${i}`,
|
||||
number: monthRange[0].month,
|
||||
daysOfWeek,
|
||||
weeks,
|
||||
};
|
||||
});
|
||||
|
||||
if (shouldAddLastWeek) {
|
||||
// Add last empty week item if the other month has an extra week
|
||||
for (let i = 0; i < dayItems.length; i++) {
|
||||
dayItems[i].weeks.push(lastWeeks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return dayItems;
|
||||
},
|
||||
})
|
||||
.set("months", {
|
||||
mainTitle: _t("Select year"),
|
||||
nextTitle: _t("Next year"),
|
||||
prevTitle: _t("Previous year"),
|
||||
step: { year: 1 },
|
||||
getTitle: (date) => String(date.year),
|
||||
getItems: (date, { maxDate, minDate }) => {
|
||||
const startOfYear = date.startOf("year");
|
||||
return numberRange(0, 12).map((i) => {
|
||||
const startOfMonth = startOfYear.plus({ month: i });
|
||||
const range = [startOfMonth, startOfMonth.endOf("month")];
|
||||
return toDateItem({
|
||||
isValid: isInRange(range, [minDate, maxDate]),
|
||||
label: "monthShort",
|
||||
range,
|
||||
});
|
||||
});
|
||||
},
|
||||
})
|
||||
.set("years", {
|
||||
mainTitle: _t("Select decade"),
|
||||
nextTitle: _t("Next decade"),
|
||||
prevTitle: _t("Previous decade"),
|
||||
step: { year: 10 },
|
||||
getTitle: (date) => `${getStartOfDecade(date) - 1} - ${getStartOfDecade(date) + 10}`,
|
||||
getItems: (date, { maxDate, minDate }) => {
|
||||
const startOfDecade = date.startOf("year").set({ year: getStartOfDecade(date) });
|
||||
return numberRange(-GRID_MARGIN, GRID_COUNT + GRID_MARGIN).map((i) => {
|
||||
const startOfYear = startOfDecade.plus({ year: i });
|
||||
const range = [startOfYear, startOfYear.endOf("year")];
|
||||
return toDateItem({
|
||||
isOutOfRange: i < 0 || i >= GRID_COUNT,
|
||||
isValid: isInRange(range, [minDate, maxDate]),
|
||||
label: "year",
|
||||
range,
|
||||
});
|
||||
});
|
||||
},
|
||||
})
|
||||
.set("decades", {
|
||||
mainTitle: _t("Select century"),
|
||||
nextTitle: _t("Next century"),
|
||||
prevTitle: _t("Previous century"),
|
||||
step: { year: 100 },
|
||||
getTitle: (date) => `${getStartOfCentury(date) - 10} - ${getStartOfCentury(date) + 100}`,
|
||||
getItems: (date, { maxDate, minDate }) => {
|
||||
const startOfCentury = date.startOf("year").set({ year: getStartOfCentury(date) });
|
||||
return numberRange(-GRID_MARGIN, GRID_COUNT + GRID_MARGIN).map((i) => {
|
||||
const startOfDecade = startOfCentury.plus({ year: i * 10 });
|
||||
const range = [startOfDecade, startOfDecade.plus({ year: 10, millisecond: -1 })];
|
||||
return toDateItem({
|
||||
label: "year",
|
||||
isOutOfRange: i < 0 || i >= GRID_COUNT,
|
||||
isValid: isInRange(range, [minDate, maxDate]),
|
||||
range,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Other constants
|
||||
const GRID_COUNT = 10;
|
||||
const GRID_MARGIN = 1;
|
||||
const NULLABLE_DATETIME_PROPERTY = [DateTime, { value: false }, { value: null }];
|
||||
|
||||
const DAYS_PER_WEEK = 7;
|
||||
const WEEKS_PER_MONTH = 6;
|
||||
|
||||
/** @extends {Component<DateTimePickerProps>} */
|
||||
export class DateTimePicker extends Component {
|
||||
static props = {
|
||||
focusedDateIndex: { type: Number, optional: true },
|
||||
showWeekNumbers: { type: Boolean, optional: true },
|
||||
daysOfWeekFormat: { type: String, optional: true },
|
||||
maxDate: { type: [NULLABLE_DATETIME_PROPERTY, { value: "today" }], optional: true },
|
||||
maxPrecision: {
|
||||
type: [...PRECISION_LEVELS.keys()].map((value) => ({ value })),
|
||||
optional: true,
|
||||
},
|
||||
minDate: { type: [NULLABLE_DATETIME_PROPERTY, { value: "today" }], optional: true },
|
||||
minPrecision: {
|
||||
type: [...PRECISION_LEVELS.keys()].map((value) => ({ value })),
|
||||
optional: true,
|
||||
},
|
||||
onReset: { type: Function, optional: true },
|
||||
onSelect: { type: Function, optional: true },
|
||||
onToggleRange: { type: Function, optional: true },
|
||||
range: { type: Boolean, optional: true },
|
||||
rounding: { type: Number, optional: true },
|
||||
showRangeToggler: { type: Boolean, optional: true },
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: { buttons: { type: Object, optional: true } },
|
||||
optional: true,
|
||||
},
|
||||
type: { type: [{ value: "date" }, { value: "datetime" }], optional: true },
|
||||
value: {
|
||||
type: [
|
||||
NULLABLE_DATETIME_PROPERTY,
|
||||
{ type: Array, element: NULLABLE_DATETIME_PROPERTY },
|
||||
],
|
||||
optional: true,
|
||||
},
|
||||
isDateValid: { type: Function, optional: true },
|
||||
dayCellClass: { type: Function, optional: true },
|
||||
tz: { type: String, optional: true },
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
focusedDateIndex: 0,
|
||||
daysOfWeekFormat: "narrow",
|
||||
maxPrecision: "decades",
|
||||
minPrecision: "days",
|
||||
rounding: 5,
|
||||
showWeekNumbers: true,
|
||||
type: "datetime",
|
||||
};
|
||||
|
||||
static template = "web.DateTimePicker";
|
||||
static components = { TimePicker };
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Getters
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
get activePrecisionLevel() {
|
||||
return PRECISION_LEVELS.get(this.state.precision);
|
||||
}
|
||||
|
||||
get isLastPrecisionLevel() {
|
||||
return (
|
||||
this.allowedPrecisionLevels.indexOf(this.state.precision) ===
|
||||
this.allowedPrecisionLevels.length - 1
|
||||
);
|
||||
}
|
||||
|
||||
get titles() {
|
||||
return ensureArray(this.title);
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
setup() {
|
||||
/** @type {PrecisionLevel[]} */
|
||||
this.allowedPrecisionLevels = [];
|
||||
/** @type {Item[]} */
|
||||
this.items = [];
|
||||
this.title = "";
|
||||
this.shouldAdjustFocusDate = false;
|
||||
|
||||
this.state = useState({
|
||||
/** @type {DateTime | null} */
|
||||
focusDate: null,
|
||||
/** @type {DateTime | null} */
|
||||
hoveredDate: null,
|
||||
/** @type {Time[]} */
|
||||
timeValues: [],
|
||||
/** @type {PrecisionLevel} */
|
||||
precision: this.props.minPrecision,
|
||||
});
|
||||
|
||||
this.onPropsUpdated(this.props);
|
||||
onWillUpdateProps((nextProps) => this.onPropsUpdated(nextProps));
|
||||
|
||||
onWillRender(() => this.onWillRender());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateTimePickerProps} props
|
||||
*/
|
||||
onPropsUpdated(props) {
|
||||
/** @type {[NullableDateTime] | NullableDateRange} */
|
||||
this.values = ensureArray(props.value).map((value) =>
|
||||
value && !value.isValid ? null : value
|
||||
);
|
||||
this.allowedPrecisionLevels = this.filterPrecisionLevels(
|
||||
props.minPrecision,
|
||||
props.maxPrecision
|
||||
);
|
||||
|
||||
this.maxDate = parseLimitDate(props.maxDate, MAX_VALID_DATE);
|
||||
this.minDate = parseLimitDate(props.minDate, MIN_VALID_DATE);
|
||||
if (this.props.type === "date") {
|
||||
this.maxDate = this.maxDate.endOf("day");
|
||||
this.minDate = this.minDate.startOf("day");
|
||||
}
|
||||
|
||||
if (this.maxDate < this.minDate) {
|
||||
throw new Error(`DateTimePicker error: given "maxDate" comes before "minDate".`);
|
||||
}
|
||||
|
||||
this.state.timeValues = this.getTimeValues(props);
|
||||
this.shouldAdjustFocusDate = !props.range;
|
||||
this.adjustFocus(this.values, props.focusedDateIndex);
|
||||
}
|
||||
|
||||
onWillRender() {
|
||||
const { dayCellClass, focusedDateIndex, isDateValid, range, showWeekNumbers } = this.props;
|
||||
const { focusDate, hoveredDate } = this.state;
|
||||
const precision = this.activePrecisionLevel;
|
||||
const getterParams = {
|
||||
maxDate: this.maxDate,
|
||||
minDate: this.minDate,
|
||||
showWeekNumbers: showWeekNumbers ?? !range,
|
||||
isDateValid,
|
||||
dayCellClass,
|
||||
};
|
||||
|
||||
this.title = precision.getTitle(focusDate);
|
||||
this.items = precision.getItems(focusDate, getterParams);
|
||||
|
||||
this.selectedRange = [...this.values];
|
||||
if (range && focusedDateIndex > 0 && (!this.values[1] || hoveredDate > this.values[0])) {
|
||||
this.selectedRange[1] = hoveredDate;
|
||||
}
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Methods
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {NullableDateTime[]} values
|
||||
* @param {number} focusedDateIndex
|
||||
*/
|
||||
adjustFocus(values, focusedDateIndex) {
|
||||
if (!this.shouldAdjustFocusDate && this.state.focusDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateToFocus =
|
||||
values[focusedDateIndex] || values[focusedDateIndex === 1 ? 0 : 1] || today();
|
||||
|
||||
this.shouldAdjustFocusDate = false;
|
||||
this.state.focusDate = this.clamp(dateToFocus.startOf("month"));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateTime} value
|
||||
*/
|
||||
clamp(value) {
|
||||
return clampDate(value, this.minDate, this.maxDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PrecisionLevel} minPrecision
|
||||
* @param {PrecisionLevel} maxPrecision
|
||||
*/
|
||||
filterPrecisionLevels(minPrecision, maxPrecision) {
|
||||
const levels = [...PRECISION_LEVELS.keys()];
|
||||
return levels.slice(levels.indexOf(minPrecision), levels.indexOf(maxPrecision) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns various flags indicating what ranges the current date item belongs
|
||||
* to. Note that these ranges are computed differently according to the current
|
||||
* value mode (range or single date). This is done to simplify CSS selectors.
|
||||
* - Selected Range:
|
||||
* > range: current values with hovered date applied
|
||||
* > single date: just the hovered date
|
||||
* - Highlighted Range:
|
||||
* > range: union of selection range and current values
|
||||
* > single date: just the current value
|
||||
* - Current Range (range only):
|
||||
* > range: current start date or current end date.
|
||||
* @param {DateItem} item
|
||||
*/
|
||||
getActiveRangeInfo({ range }) {
|
||||
const result = {
|
||||
isSelected: isInRange(this.selectedRange, range),
|
||||
isSelectStart: false,
|
||||
isSelectEnd: false,
|
||||
isHighlighted: isInRange(this.state.hoveredDate, range),
|
||||
};
|
||||
|
||||
if (this.props.range) {
|
||||
if (result.isSelected) {
|
||||
const [selectStart, selectEnd] = this.selectedRange.sort();
|
||||
result.isSelectStart = !selectStart || isInRange(selectStart, range);
|
||||
result.isSelectEnd = !selectEnd || isInRange(selectEnd, range);
|
||||
}
|
||||
} else {
|
||||
result.isSelectStart = result.isSelectEnd = result.isSelected;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateTimePickerProps} props
|
||||
*/
|
||||
getTimeValues(props) {
|
||||
const timeValues = this.values.map(
|
||||
(val, index) =>
|
||||
new Time({
|
||||
hour:
|
||||
index === 1 && !this.values[1]
|
||||
? (val || DateTime.local()).hour + 1
|
||||
: (val || DateTime.local()).hour,
|
||||
minute: val?.minute || 0,
|
||||
second: val?.second || 0,
|
||||
})
|
||||
);
|
||||
|
||||
if (props.range) {
|
||||
return timeValues;
|
||||
} else {
|
||||
const values = [];
|
||||
values[props.focusedDateIndex] = timeValues[props.focusedDateIndex];
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateItem} item
|
||||
*/
|
||||
isSelectedDate({ range }) {
|
||||
return this.values.some((value) => isInRange(value, range));
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to the next panel (e.g. next month if precision is "days").
|
||||
* If an event is given it will be prevented.
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
next(ev) {
|
||||
ev.preventDefault();
|
||||
const { step } = this.activePrecisionLevel;
|
||||
this.state.focusDate = this.clamp(this.state.focusDate.plus(step));
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes to the previous panel (e.g. previous month if precision is "days").
|
||||
* If an event is given it will be prevented.
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
previous(ev) {
|
||||
ev.preventDefault();
|
||||
const { step } = this.activePrecisionLevel;
|
||||
this.state.focusDate = this.clamp(this.state.focusDate.minus(step));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} valueIndex
|
||||
* @param {Time} newTime
|
||||
*/
|
||||
onTimeChange(valueIndex, newTime) {
|
||||
this.state.timeValues[valueIndex] = newTime;
|
||||
const value = this.values[valueIndex] || today();
|
||||
this.validateAndSelect(value, valueIndex, "time");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateTime} value
|
||||
* @param {number} valueIndex
|
||||
* @param {"date" | "time"} unit
|
||||
*/
|
||||
validateAndSelect(value, valueIndex, unit) {
|
||||
if (!this.props.onSelect) {
|
||||
// No onSelect handler
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = [...this.values];
|
||||
result[valueIndex] = value;
|
||||
|
||||
if (this.props.type === "datetime") {
|
||||
// Adjusts result according to the current time values
|
||||
const { hour, minute, second } = this.state.timeValues[valueIndex];
|
||||
result[valueIndex] = result[valueIndex].set({ hour, minute, second });
|
||||
}
|
||||
if (!isInRange(result[valueIndex], [this.minDate, this.maxDate])) {
|
||||
// Date is outside range defined by min and max dates
|
||||
return false;
|
||||
}
|
||||
this.props.onSelect(result.length === 2 ? result : result[0], unit);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the zoom has occurred
|
||||
* @param {DateTime} date
|
||||
*/
|
||||
zoomIn(date) {
|
||||
const index = this.allowedPrecisionLevels.indexOf(this.state.precision) - 1;
|
||||
if (index in this.allowedPrecisionLevels) {
|
||||
this.state.focusDate = this.clamp(date);
|
||||
this.state.precision = this.allowedPrecisionLevels[index];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the zoom has occurred
|
||||
*/
|
||||
zoomOut() {
|
||||
const index = this.allowedPrecisionLevels.indexOf(this.state.precision) + 1;
|
||||
if (index in this.allowedPrecisionLevels) {
|
||||
this.state.precision = this.allowedPrecisionLevels[index];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Happens when a date item is selected:
|
||||
* - first tries to zoom in on the item
|
||||
* - if could not zoom in: date is considered as final value and triggers a hard select
|
||||
* @param {DateItem} dateItem
|
||||
*/
|
||||
zoomOrSelect(dateItem) {
|
||||
if (!dateItem.isValid) {
|
||||
// Invalid item
|
||||
return;
|
||||
}
|
||||
if (this.zoomIn(dateItem.range[0])) {
|
||||
// Zoom was successful
|
||||
return;
|
||||
}
|
||||
const [value] = dateItem.range;
|
||||
const valueIndex = this.props.focusedDateIndex;
|
||||
const isValid = this.validateAndSelect(value, valueIndex, "date");
|
||||
this.shouldAdjustFocusDate = isValid && !this.props.range;
|
||||
}
|
||||
}
|
||||
92
frontend/web/static/src/core/datetime/datetime_picker.scss
Normal file
92
frontend/web/static/src/core/datetime/datetime_picker.scss
Normal file
@@ -0,0 +1,92 @@
|
||||
.o_datetime_picker {
|
||||
--DateTimePicker__Template-rows: 3;
|
||||
--DateTimePicker__Template-columns: 4;
|
||||
--DateTimePicker__Day-template-rows: 6;
|
||||
|
||||
width: $o-datetime-picker-width;
|
||||
|
||||
// Day
|
||||
.o_selected {
|
||||
color: $o-component-active-color;
|
||||
background: $o-component-active-bg;
|
||||
}
|
||||
|
||||
.o_select_start,
|
||||
.o_select_end {
|
||||
--selected-day-color: #{mix(lighten($o-component-active-border, 10%), $o-component-active-bg, 15%)};
|
||||
--percent: calc(100% / sqrt(2));
|
||||
background:
|
||||
#{$o-component-active-bg}
|
||||
radial-gradient(
|
||||
circle,
|
||||
var(--selected-day-color) 0% var(--percent),
|
||||
transparent var(--percent) 100%
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
.o_select_start{
|
||||
border-top-left-radius: 50%;
|
||||
border-bottom-left-radius: 50%;
|
||||
}
|
||||
|
||||
.o_select_end {
|
||||
border-top-right-radius: 50%;
|
||||
border-bottom-right-radius: 50%;
|
||||
}
|
||||
|
||||
.o_today > div {
|
||||
aspect-ratio: 1;
|
||||
background-color: $o-calendar-today-background-color;
|
||||
color: $o-calendar-today-color;
|
||||
}
|
||||
|
||||
// Grids
|
||||
|
||||
.o_date_picker {
|
||||
grid-template-rows: repeat(var(--DateTimePicker__Day-template-rows), 1fr);
|
||||
grid-template-columns: repeat(var(--DateTimePicker__Day-template-columns), 1fr);
|
||||
}
|
||||
|
||||
.o_date_item_picker {
|
||||
grid-template-rows: repeat(var(--DateTimePicker__Template-rows), 1fr);
|
||||
grid-template-columns: repeat(var(--DateTimePicker__Template-columns), 1fr);
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
.o_date_item_picker .o_datetime_button {
|
||||
&.o_selected:not(.o_select_start, .o_select_end) {
|
||||
background: $o-component-active-bg;
|
||||
color: $o-component-active-color;
|
||||
}
|
||||
}
|
||||
|
||||
.o_center {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.o_date_item_cell {
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
|
||||
&:hover, &:focus {
|
||||
--DateTimePicker__date-cell-border-color-hover: #{$o-component-active-border};
|
||||
}
|
||||
|
||||
&:not([disabled])::before {
|
||||
@include o-position-absolute(0,0,0,0);
|
||||
|
||||
content: '';
|
||||
aspect-ratio: 1;
|
||||
border: $border-width solid var(--DateTimePicker__date-cell-border-color-hover);
|
||||
border-radius: $border-radius-pill;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_week_number_cell {
|
||||
font-variant: tabular-nums;
|
||||
}
|
||||
}
|
||||
153
frontend/web/static/src/core/datetime/datetime_picker.xml
Normal file
153
frontend/web/static/src/core/datetime/datetime_picker.xml
Normal file
@@ -0,0 +1,153 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.DateTimePicker.Days">
|
||||
<div class="d-flex gap-3">
|
||||
<t t-foreach="items" t-as="month" t-key="month.id">
|
||||
<div
|
||||
class="o_date_picker d-grid flex-grow-1 rounded overflow-auto"
|
||||
t-on-pointerleave="() => (state.hoveredDate = null)"
|
||||
>
|
||||
<t t-foreach="month.daysOfWeek" t-as="dayOfWeek" t-key="dayOfWeek[0]">
|
||||
<div
|
||||
class="o_day_of_week_cell fw-bolder d-flex align-items-center justify-content-center"
|
||||
t-att-title="dayOfWeek[1]"
|
||||
>
|
||||
<div class="text-nowrap overflow-hidden" t-esc="props.daysOfWeekFormat === 'narrow' ? dayOfWeek[2] : dayOfWeek[0]"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="month.weeks" t-as="week" t-key="week.number">
|
||||
<t t-if="props.showWeekNumbers">
|
||||
<div
|
||||
class="o_week_number_cell d-flex justify-content-end align-items-center pe-3 fw-bolder"
|
||||
t-esc="week.number"
|
||||
/>
|
||||
</t>
|
||||
<t t-foreach="week.days" t-as="itemInfo" t-key="itemInfo.id">
|
||||
<t t-set="arInfo" t-value="getActiveRangeInfo(itemInfo)" />
|
||||
<div
|
||||
class="o_date_item_cell o_datetime_button o_center"
|
||||
t-att-class="{
|
||||
'o_out_of_range text-muted': itemInfo.isOutOfRange,
|
||||
o_selected: arInfo.isSelected,
|
||||
o_select_start: arInfo.isSelectStart,
|
||||
o_select_end: arInfo.isSelectEnd,
|
||||
o_highlighted: arInfo.isHighlighted,
|
||||
'o_today fw-bolder': itemInfo.includesToday,
|
||||
[itemInfo.extraClass]: true,
|
||||
'opacity-50': !itemInfo.isValid,
|
||||
'cursor-pointer': itemInfo.isValid,
|
||||
}"
|
||||
t-att-disabled="!itemInfo.isValid"
|
||||
t-on-pointerenter="() => (state.hoveredDate = itemInfo.range[0])"
|
||||
t-on-click="() => this.zoomOrSelect(itemInfo)"
|
||||
>
|
||||
<div t-att-class="{'w-75 align-content-center text-center rounded-circle': itemInfo.includesToday}">
|
||||
<t t-esc="itemInfo.label" />
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="props.type === 'datetime'" class="d-flex gap-3 justify-content-between">
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<TimePicker
|
||||
t-if="state.timeValues[0]"
|
||||
value="state.timeValues[0]"
|
||||
onChange="(newTime) => this.onTimeChange(0, newTime)"
|
||||
minutesRounding="props.rounding"
|
||||
showSeconds="props.rounding === 0"
|
||||
inputCssClass="'o_input'"
|
||||
/>
|
||||
<i t-if="state.timeValues[0] and state.timeValues[1]" class="fa fa-long-arrow-right"/>
|
||||
<TimePicker
|
||||
t-if="state.timeValues[1]"
|
||||
value="state.timeValues[1]"
|
||||
onChange="(newTime) => this.onTimeChange(1, newTime)"
|
||||
minutesRounding="props.rounding"
|
||||
showSeconds="props.rounding === 0"
|
||||
inputCssClass="'o_input'"
|
||||
/>
|
||||
</div>
|
||||
<t t-slot="buttons" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web.DateTimePicker.Grid">
|
||||
<div class="o_date_item_picker d-grid">
|
||||
<t t-foreach="items" t-as="itemInfo" t-key="itemInfo.id">
|
||||
<t t-set="arInfo" t-value="getActiveRangeInfo(itemInfo)" />
|
||||
<div
|
||||
class="o_date_item_cell o_datetime_button o_center cursor-pointer"
|
||||
t-att-class="{
|
||||
o_selected: arInfo.isSelected,
|
||||
o_select_start: arInfo.isSelectStart,
|
||||
o_select_end: arInfo.isSelectEnd,
|
||||
o_highlighted: arInfo.isHighlighted,
|
||||
o_today: itemInfo.includesToday,
|
||||
'opacity-50': !itemInfo.isValid,
|
||||
}"
|
||||
t-att-disabled="!itemInfo.isValid"
|
||||
t-on-click="() => this.zoomOrSelect(itemInfo)"
|
||||
>
|
||||
<t t-esc="itemInfo.label" />
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web.DateTimePicker">
|
||||
<div
|
||||
class="o_datetime_picker d-flex flex-column gap-2 user-select-none p-2"
|
||||
t-attf-style="--DateTimePicker__Day-template-columns: {{ props.showWeekNumbers ? 8 : 7 }}"
|
||||
>
|
||||
<nav class="o_datetime_picker_header d-flex">
|
||||
<button
|
||||
class="o_previous btn btn-sm opacity-75 opacity-100-hover"
|
||||
t-on-click="previous"
|
||||
tabindex="-1"
|
||||
>
|
||||
<i class="oi oi-chevron-left" t-att-title="activePrecisionLevel.prevTitle" />
|
||||
</button>
|
||||
<button
|
||||
class="o_next btn btn-sm opacity-75 opacity-100-hover"
|
||||
t-on-click="next"
|
||||
tabindex="-1"
|
||||
>
|
||||
<i class="oi oi-chevron-right" t-att-title="activePrecisionLevel.nextTitle" />
|
||||
</button>
|
||||
<button
|
||||
class="o_zoom_out o_datetime_button btn opacity-75 opacity-100-hover text-truncate"
|
||||
tabindex="-1"
|
||||
t-att-class="{ 'disabled': isLastPrecisionLevel }"
|
||||
t-att-title="!isLastPrecisionLevel and activePrecisionLevel.mainTitle"
|
||||
t-on-click="zoomOut"
|
||||
>
|
||||
<t t-foreach="titles" t-as="title" t-key="title">
|
||||
<strong
|
||||
class="o_header_part"
|
||||
t-esc="title"
|
||||
/>
|
||||
</t>
|
||||
</button>
|
||||
<button
|
||||
t-if="props.showRangeToggler"
|
||||
class="o_toggle_range btn btn-secondary btn-sm ms-auto"
|
||||
tabindex="-1"
|
||||
title="Toggle date range mode"
|
||||
t-on-click="props.onToggleRange"
|
||||
t-att-class="{'active': props.range}"
|
||||
>
|
||||
<i class="fa fa-calendar-plus-o" />
|
||||
</button>
|
||||
</nav>
|
||||
<t t-if="state.precision === 'days'">
|
||||
<t t-call="web.DateTimePicker.Days" />
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="web.DateTimePicker.Grid" />
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { onWillDestroy, useRef } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
* @typedef {import("./datetimepicker_service").DateTimePickerServiceParams & {
|
||||
* endDateRefName?: string;
|
||||
* startDateRefName?: string;
|
||||
* }} DateTimePickerHookParams
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {DateTimePickerHookParams} params
|
||||
*/
|
||||
export function useDateTimePicker(params) {
|
||||
function getInputs() {
|
||||
return inputRefs.map((ref) => ref.el);
|
||||
}
|
||||
|
||||
const inputRefs = [
|
||||
useRef(params.startDateRefName || "start-date"),
|
||||
useRef(params.endDateRefName || "end-date"),
|
||||
];
|
||||
|
||||
// Need original object since 'pickerProps' (or any other param) can be defined
|
||||
// as getters
|
||||
const serviceParams = Object.assign(Object.create(params), {
|
||||
getInputs,
|
||||
useOwlHooks: true,
|
||||
});
|
||||
|
||||
const picker = useService("datetime_picker").create(serviceParams);
|
||||
onWillDestroy(() => {
|
||||
picker.disable();
|
||||
});
|
||||
return picker;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useHotkey } from "../hotkeys/hotkey_hook";
|
||||
import { DateTimePicker } from "./datetime_picker";
|
||||
|
||||
/**
|
||||
* @typedef {import("./datetime_picker").DateTimePickerProps} DateTimePickerProps
|
||||
*
|
||||
* @typedef DateTimePickerPopoverProps
|
||||
* @property {() => void} close
|
||||
* @property {DateTimePickerProps} pickerProps
|
||||
*/
|
||||
|
||||
/** @extends {Component<DateTimePickerPopoverProps>} */
|
||||
export class DateTimePickerPopover extends Component {
|
||||
static components = { DateTimePicker };
|
||||
|
||||
static props = {
|
||||
close: Function, // Given by the Popover service
|
||||
pickerProps: { type: Object, shape: DateTimePicker.props },
|
||||
};
|
||||
|
||||
static template = "web.DateTimePickerPopover";
|
||||
|
||||
//-------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
//-------------------------------------------------------------------------
|
||||
|
||||
setup() {
|
||||
useHotkey("enter", () => this.props.close());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.DateTimePickerPopover">
|
||||
<DateTimePicker t-props="props.pickerProps">
|
||||
<t t-set-slot="buttons">
|
||||
<div class="o_datetime_buttons d-flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
title="Clear"
|
||||
tabindex="-1"
|
||||
t-on-click="props.pickerProps.onReset"
|
||||
>
|
||||
<i class="fa fa-eraser" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
tabindex="-1"
|
||||
t-on-click="props.close"
|
||||
>
|
||||
<span>Apply</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</DateTimePicker>
|
||||
</t>
|
||||
</templates>
|
||||
551
frontend/web/static/src/core/datetime/datetimepicker_service.js
Normal file
551
frontend/web/static/src/core/datetime/datetimepicker_service.js
Normal file
@@ -0,0 +1,551 @@
|
||||
import { markRaw, onPatched, onWillRender, reactive, useEffect, useRef } from "@odoo/owl";
|
||||
import { areDatesEqual, formatDate, formatDateTime, parseDate, parseDateTime } from "../l10n/dates";
|
||||
import { makePopover } from "../popover/popover_hook";
|
||||
import { registry } from "../registry";
|
||||
import { ensureArray, zip, zipWith } from "../utils/arrays";
|
||||
import { shallowEqual } from "../utils/objects";
|
||||
import { DateTimePicker } from "./datetime_picker";
|
||||
import { DateTimePickerPopover } from "./datetime_picker_popover";
|
||||
|
||||
/**
|
||||
* @typedef {luxon["DateTime"]["prototype"]} DateTime
|
||||
*
|
||||
* @typedef {import("./datetime_picker").DateTimePickerProps} DateTimePickerProps
|
||||
* @typedef {import("../popover/popover_hook").PopoverHookReturnType} PopoverHookReturnType
|
||||
* @typedef {import("../popover/popover_service").PopoverServiceAddOptions} PopoverServiceAddOptions
|
||||
* @typedef {import("@odoo/owl").Component} Component
|
||||
* @typedef {ReturnType<typeof import("@odoo/owl").useRef>} OwlRef
|
||||
*
|
||||
* @typedef {{
|
||||
* createPopover?: (component: Component, options: PopoverServiceAddOptions) => PopoverHookReturnType;
|
||||
* ensureVisibility?: () => boolean;
|
||||
* format?: string;
|
||||
* getInputs?: () => HTMLElement[];
|
||||
* onApply?: (value: DateTimePickerProps["value"]) => any;
|
||||
* onChange?: (value: DateTimePickerProps["value"]) => any;
|
||||
* onClose?: () => any;
|
||||
* pickerProps?: DateTimePickerProps;
|
||||
* showSeconds?: boolean;
|
||||
* target: HTMLElement | string;
|
||||
* useOwlHooks?: boolean;
|
||||
* }} DateTimePickerServiceParams
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template {object} T
|
||||
* @param {T} obj
|
||||
*/
|
||||
function markValuesRaw(obj) {
|
||||
/** @type {T} */
|
||||
const copy = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === "object") {
|
||||
copy[key] = markRaw(value);
|
||||
} else {
|
||||
copy[key] = value;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, any>} props
|
||||
*/
|
||||
function stringifyProps(props) {
|
||||
const copy = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
copy[key] = JSON.stringify(value);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
const FOCUS_CLASSNAME = "text-primary";
|
||||
|
||||
const formatters = {
|
||||
date: formatDate,
|
||||
datetime: formatDateTime,
|
||||
};
|
||||
const listenedElements = new WeakSet();
|
||||
const parsers = {
|
||||
date: parseDate,
|
||||
datetime: parseDateTime,
|
||||
};
|
||||
|
||||
export const datetimePickerService = {
|
||||
dependencies: ["popover"],
|
||||
start(env, { popover: popoverService }) {
|
||||
const dateTimePickerList = new Set();
|
||||
return {
|
||||
/**
|
||||
* @param {DateTimePickerServiceParams} [params]
|
||||
*/
|
||||
create(params = {}) {
|
||||
/**
|
||||
* Wrapper method on the "onApply" callback to only call it when the
|
||||
* value has changed, and set other internal variables accordingly.
|
||||
*/
|
||||
async function apply() {
|
||||
const { value } = pickerProps;
|
||||
const stringValue = JSON.stringify(value);
|
||||
if (
|
||||
stringValue === lastAppliedStringValue ||
|
||||
stringValue === stringProps.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAppliedStringValue = stringValue;
|
||||
inputsChanged = ensureArray(value).map(() => false);
|
||||
|
||||
await params.onApply?.(value);
|
||||
|
||||
stringProps.value = stringValue;
|
||||
}
|
||||
|
||||
function enable() {
|
||||
for (const [el, value] of zip(
|
||||
getInputs(),
|
||||
ensureArray(pickerProps.value),
|
||||
true
|
||||
)) {
|
||||
updateInput(el, value);
|
||||
if (el && !el.disabled && !el.readOnly && !listenedElements.has(el)) {
|
||||
listenedElements.add(el);
|
||||
el.addEventListener("change", onInputChange);
|
||||
el.addEventListener("click", onInputClick);
|
||||
el.addEventListener("focus", onInputFocus);
|
||||
el.addEventListener("keydown", onInputKeydown);
|
||||
}
|
||||
}
|
||||
const calendarIconGroupEl = getInput(0)?.parentElement.querySelector(
|
||||
".o_input_group_date_icon"
|
||||
);
|
||||
if (calendarIconGroupEl) {
|
||||
calendarIconGroupEl.classList.add("cursor-pointer");
|
||||
calendarIconGroupEl.addEventListener("click", () => open(0));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the current focused input (indicated by `pickerProps.focusedDateIndex`)
|
||||
* is actually focused.
|
||||
*/
|
||||
function focusActiveInput() {
|
||||
const inputEl = getInput(pickerProps.focusedDateIndex);
|
||||
if (!inputEl) {
|
||||
shouldFocus = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const { activeElement } = inputEl.ownerDocument;
|
||||
if (activeElement !== inputEl) {
|
||||
inputEl.focus();
|
||||
}
|
||||
setInputFocus(inputEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} valueIndex
|
||||
* @returns {HTMLInputElement | null}
|
||||
*/
|
||||
function getInput(valueIndex) {
|
||||
const el = getInputs()[valueIndex];
|
||||
if (el?.isConnected) {
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate root element to attach the popover:
|
||||
* - if the value is a range: the closest common parent of the two inputs
|
||||
* - if not: the first input
|
||||
*/
|
||||
function getPopoverTarget() {
|
||||
const target = getTarget();
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
if (pickerProps.range) {
|
||||
let parentElement = getInput(0).parentElement;
|
||||
const inputEls = getInputs();
|
||||
while (
|
||||
parentElement &&
|
||||
!inputEls.every((inputEl) => parentElement.contains(inputEl))
|
||||
) {
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
return parentElement || getInput(0);
|
||||
} else {
|
||||
return getInput(0);
|
||||
}
|
||||
}
|
||||
|
||||
function getTarget() {
|
||||
return targetRef ? targetRef.el : params.target;
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return popover.isOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs "change" event handler. This will trigger an "onApply" callback if
|
||||
* one of the following is true:
|
||||
* - there is only one input;
|
||||
* - the popover is closed;
|
||||
* - the other input has also changed.
|
||||
*
|
||||
* @param {Event} ev
|
||||
*/
|
||||
function onInputChange(ev) {
|
||||
updateValueFromInputs();
|
||||
inputsChanged[ev.target === getInput(1) ? 1 : 0] = true;
|
||||
if (!isOpen() || inputsChanged.every(Boolean)) {
|
||||
saveAndClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
function onInputClick({ target }) {
|
||||
open(target === getInput(1) ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FocusEvent} ev
|
||||
*/
|
||||
function onInputFocus({ target }) {
|
||||
pickerProps.focusedDateIndex = target === getInput(1) ? 1 : 0;
|
||||
setInputFocus(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
function onInputKeydown(ev) {
|
||||
if (ev.key == "Enter" && ev.ctrlKey) {
|
||||
ev.preventDefault();
|
||||
updateValueFromInputs();
|
||||
return open(ev.target === getInput(1) ? 1 : 0);
|
||||
}
|
||||
switch (ev.key) {
|
||||
case "Enter":
|
||||
case "Escape": {
|
||||
return saveAndClose();
|
||||
}
|
||||
case "Tab": {
|
||||
if (
|
||||
!getInput(0) ||
|
||||
!getInput(1) ||
|
||||
ev.target !== getInput(ev.shiftKey ? 1 : 0)
|
||||
) {
|
||||
return saveAndClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} inputIndex Input from which to open the picker
|
||||
*/
|
||||
function open(inputIndex) {
|
||||
pickerProps.focusedDateIndex = inputIndex;
|
||||
|
||||
if (!isOpen()) {
|
||||
const popoverTarget = getPopoverTarget();
|
||||
if (ensureVisibility()) {
|
||||
const { marginBottom } = popoverTarget.style;
|
||||
// Adds enough space for the popover to be displayed below the target
|
||||
// even on small screens.
|
||||
popoverTarget.style.marginBottom = `100vh`;
|
||||
popoverTarget.scrollIntoView(true);
|
||||
restoreTargetMargin = async () => {
|
||||
popoverTarget.style.marginBottom = marginBottom;
|
||||
};
|
||||
}
|
||||
for (const picker of dateTimePickerList) {
|
||||
picker.close();
|
||||
}
|
||||
popover.open(popoverTarget, { pickerProps });
|
||||
}
|
||||
|
||||
focusActiveInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {"format" | "parse"} T
|
||||
* @param {T} operation
|
||||
* @param {T extends "format" ? DateTime : string} value
|
||||
* @returns {[T extends "format" ? string : DateTime, null] | [null, Error]}
|
||||
*/
|
||||
function safeConvert(operation, value) {
|
||||
const { type } = pickerProps;
|
||||
const convertFn = (operation === "format" ? formatters : parsers)[type];
|
||||
const options = { tz: pickerProps.tz, format: params.format };
|
||||
if (operation === "format") {
|
||||
options.showSeconds = params.showSeconds ?? true;
|
||||
}
|
||||
try {
|
||||
return [convertFn(value, options), null];
|
||||
} catch (error) {
|
||||
if (error?.name === "ConversionError") {
|
||||
return [null, error];
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper method to ensure the "onApply" callback is called, either:
|
||||
* - by closing the popover (if any);
|
||||
* - or by directly calling "apply", without updating the values.
|
||||
*/
|
||||
function saveAndClose() {
|
||||
if (isOpen()) {
|
||||
// apply will be done in the "onClose" callback
|
||||
popover.close();
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates class names on given inputs according to the currently selected input.
|
||||
*
|
||||
* @param {HTMLInputElement | null} input
|
||||
*/
|
||||
function setFocusClass(input) {
|
||||
for (const el of getInputs()) {
|
||||
if (el) {
|
||||
el.classList.toggle(FOCUS_CLASSNAME, isOpen() && el === input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies class names to all inputs according to whether they are focused or not.
|
||||
*
|
||||
* @param {HTMLInputElement} inputEl
|
||||
*/
|
||||
function setInputFocus(inputEl) {
|
||||
inputEl.selectionStart = 0;
|
||||
inputEl.selectionEnd = inputEl.value.length;
|
||||
|
||||
setFocusClass(inputEl);
|
||||
|
||||
shouldFocus = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the given input with the given value.
|
||||
*
|
||||
* @param {HTMLInputElement} el
|
||||
* @param {DateTime} value
|
||||
*/
|
||||
function updateInput(el, value) {
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const [formattedValue] = safeConvert("format", value);
|
||||
el.value = formattedValue || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DateTimePickerProps["value"]} value
|
||||
* @param {"date" | "time"} unit
|
||||
* @param {"input" | "picker"} source
|
||||
*/
|
||||
function updateValue(value, unit, source) {
|
||||
if (source === "input" && areDatesEqual(pickerProps.value, value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pickerProps.value = value;
|
||||
|
||||
if (pickerProps.range && unit !== "time" && source === "picker") {
|
||||
if (!value[0]) {
|
||||
pickerProps.focusedDateIndex = 0;
|
||||
} else if (
|
||||
pickerProps.focusedDateIndex === 0 ||
|
||||
(value[0] && value[1] && value[1] < value[0])
|
||||
) {
|
||||
// If selecting either:
|
||||
// - the first value
|
||||
// - OR a second value before the first:
|
||||
// Then:
|
||||
// - Set the DATE (year + month + day) of all values
|
||||
// to the one that has been selected.
|
||||
const { year, month, day } = value[pickerProps.focusedDateIndex];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
value[i] = value[i] && value[i].set({ year, month, day });
|
||||
}
|
||||
pickerProps.focusedDateIndex = 1;
|
||||
} else {
|
||||
// If selecting the second value after the first:
|
||||
// - simply toggle the focus index
|
||||
pickerProps.focusedDateIndex =
|
||||
pickerProps.focusedDateIndex === 1 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
params.onChange?.(value);
|
||||
}
|
||||
|
||||
function updateValueFromInputs() {
|
||||
const values = zipWith(
|
||||
getInputs(),
|
||||
ensureArray(pickerProps.value),
|
||||
(el, currentValue) => {
|
||||
if (!el || el.tagName?.toLowerCase() !== "input") {
|
||||
return currentValue;
|
||||
}
|
||||
const [parsedValue, error] = safeConvert("parse", el.value);
|
||||
if (error) {
|
||||
updateInput(el, currentValue);
|
||||
return currentValue;
|
||||
} else {
|
||||
return parsedValue;
|
||||
}
|
||||
}
|
||||
);
|
||||
updateValue(values.length === 2 ? values : values[0], "date", "input");
|
||||
}
|
||||
|
||||
const createPopover =
|
||||
params.createPopover ||
|
||||
function defaultCreatePopover(...args) {
|
||||
return makePopover(popoverService.add, ...args);
|
||||
};
|
||||
const ensureVisibility =
|
||||
params.ensureVisibility ||
|
||||
function defaultEnsureVisibility() {
|
||||
return env.isSmall;
|
||||
};
|
||||
const getInputs =
|
||||
params.getInputs ||
|
||||
function defaultGetInputs() {
|
||||
return [getTarget(), null];
|
||||
};
|
||||
|
||||
// Hook variables
|
||||
|
||||
/** @type {DateTimePickerProps} */
|
||||
const rawPickerProps = {
|
||||
...DateTimePicker.defaultProps,
|
||||
onReset: () => {
|
||||
updateValue(
|
||||
ensureArray(pickerProps.value).length === 2 ? [false, false] : false,
|
||||
"date",
|
||||
"picker"
|
||||
);
|
||||
saveAndClose();
|
||||
},
|
||||
onSelect: (value, unit) => {
|
||||
value &&= markRaw(value);
|
||||
updateValue(value, unit, "picker");
|
||||
if (!pickerProps.range && pickerProps.type === "date") {
|
||||
saveAndClose();
|
||||
}
|
||||
},
|
||||
...markValuesRaw(params.pickerProps),
|
||||
};
|
||||
const pickerProps = reactive(rawPickerProps, () => {
|
||||
// Update inputs
|
||||
for (const [el, value] of zip(
|
||||
getInputs(),
|
||||
ensureArray(pickerProps.value),
|
||||
true
|
||||
)) {
|
||||
if (el) {
|
||||
updateInput(el, value);
|
||||
// Apply changes immediately if the popover is already closed.
|
||||
// Otherwise ´apply()´ will be called later on close.
|
||||
if (!isOpen()) {
|
||||
apply();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shouldFocus = true;
|
||||
});
|
||||
const popover = createPopover(DateTimePickerPopover, {
|
||||
async onClose() {
|
||||
updateValueFromInputs();
|
||||
setFocusClass(null);
|
||||
restoreTargetMargin?.();
|
||||
restoreTargetMargin = null;
|
||||
await apply();
|
||||
params.onClose?.();
|
||||
},
|
||||
});
|
||||
|
||||
/** @type {boolean[]} */
|
||||
let inputsChanged = [];
|
||||
let lastAppliedStringValue = "";
|
||||
/** @type {(() => void) | null} */
|
||||
let restoreTargetMargin = null;
|
||||
let shouldFocus = false;
|
||||
/** @type {Partial<DateTimePickerProps>} */
|
||||
let stringProps = {};
|
||||
/** @type {OwlRef | null} */
|
||||
let targetRef = null;
|
||||
|
||||
if (params.useOwlHooks) {
|
||||
if (typeof params.target === "string") {
|
||||
targetRef = useRef(params.target);
|
||||
}
|
||||
|
||||
onWillRender(function computeBasePickerProps() {
|
||||
const nextProps = markValuesRaw(params.pickerProps);
|
||||
const oldStringProps = stringProps;
|
||||
|
||||
stringProps = stringifyProps(nextProps);
|
||||
lastAppliedStringValue = stringProps.value;
|
||||
|
||||
if (shallowEqual(oldStringProps, stringProps)) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputsChanged = ensureArray(nextProps.value).map(() => false);
|
||||
|
||||
for (const [key, value] of Object.entries(nextProps)) {
|
||||
if (!areDatesEqual(pickerProps[key], value)) {
|
||||
pickerProps[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(enable, getInputs);
|
||||
|
||||
// Note: this `onPatched` callback must be called after the `useEffect` since
|
||||
// the effect may change input values that will be selected by the patch callback.
|
||||
onPatched(function focusIfNeeded() {
|
||||
if (isOpen() && shouldFocus) {
|
||||
focusActiveInput();
|
||||
}
|
||||
});
|
||||
} else if (typeof params.target === "string") {
|
||||
throw new Error(
|
||||
`datetime picker service error: cannot use target as ref name when not using Owl hooks`
|
||||
);
|
||||
}
|
||||
const picker = {
|
||||
enable,
|
||||
disable: () => dateTimePickerList.delete(picker),
|
||||
isOpen,
|
||||
open,
|
||||
close: () => popover.close(),
|
||||
state: pickerProps,
|
||||
};
|
||||
dateTimePickerList.add(picker);
|
||||
return picker;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("datetime_picker", datetimePickerService);
|
||||
83
frontend/web/static/src/core/debug/debug_context.js
Normal file
83
frontend/web/static/src/core/debug/debug_context.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { user } from "@web/core/user";
|
||||
import { registry } from "../registry";
|
||||
|
||||
import { useEffect, useEnv, useSubEnv } from "@odoo/owl";
|
||||
const debugRegistry = registry.category("debug");
|
||||
|
||||
const getAccessRights = async () => {
|
||||
const rightsToCheck = {
|
||||
"ir.ui.view": "write",
|
||||
"ir.rule": "read",
|
||||
"ir.model.access": "read",
|
||||
};
|
||||
const proms = Object.entries(rightsToCheck).map(([model, operation]) => {
|
||||
return user.checkAccessRight(model, operation);
|
||||
});
|
||||
const [canEditView, canSeeRecordRules, canSeeModelAccess] = await Promise.all(proms);
|
||||
const accessRights = { canEditView, canSeeRecordRules, canSeeModelAccess };
|
||||
return accessRights;
|
||||
};
|
||||
|
||||
class DebugContext {
|
||||
constructor(defaultCategories) {
|
||||
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();
|
||||
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({ categories = [] } = {}) {
|
||||
return { [debugContextSymbol]: new DebugContext(categories) };
|
||||
}
|
||||
|
||||
export function useOwnDebugContext({ categories = [] } = {}) {
|
||||
useSubEnv(createDebugContext({ 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),
|
||||
() => []
|
||||
);
|
||||
}
|
||||
}
|
||||
61
frontend/web/static/src/core/debug/debug_menu.js
Normal file
61
frontend/web/static/src/core/debug/debug_menu.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
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 {
|
||||
static components = { Dropdown, DropdownItem };
|
||||
static props = {};
|
||||
setup() {
|
||||
super.setup();
|
||||
const debugContext = useEnvDebugContext();
|
||||
this.command = useService("command");
|
||||
useCommand(
|
||||
_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: _t("No debug command found"),
|
||||
placeholder: _t("Choose a debug command..."),
|
||||
},
|
||||
};
|
||||
const commandPaletteConfig = {
|
||||
configByNamespace,
|
||||
providers: [provider],
|
||||
};
|
||||
return commandPaletteConfig;
|
||||
},
|
||||
{
|
||||
category: "debug",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
6
frontend/web/static/src/core/debug/debug_menu.scss
Normal file
6
frontend/web/static/src/core/debug/debug_menu.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.o_dialog {
|
||||
.o_debug_manager .dropdown-toggle {
|
||||
padding: 0 4px;
|
||||
margin: 2px 10px 2px 0;
|
||||
}
|
||||
}
|
||||
34
frontend/web/static/src/core/debug/debug_menu.xml
Normal file
34
frontend/web/static/src/core/debug/debug_menu.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.DebugMenu">
|
||||
<div class="o_debug_manager">
|
||||
<Dropdown
|
||||
beforeOpen.bind="loadGroupedItems"
|
||||
position="'bottom-end'"
|
||||
>
|
||||
<button t-att-class="`o-dropdown--narrow ${env.inDialog?'btn btn-link':''}`">
|
||||
<i class="fa fa-bug" role="img" aria-label="Open developer tools"/>
|
||||
</button>
|
||||
<t t-set-slot="content">
|
||||
<t t-foreach="sectionEntries" t-as="entry" t-key="entry[0]">
|
||||
<div class="dropdown-menu_group dropdown-header">
|
||||
<t t-esc="getSectionLabel(entry[0])"/>
|
||||
</div>
|
||||
<t t-foreach="entry[1]" t-as="element" t-key="element_index">
|
||||
<DropdownItem
|
||||
t-if="element.type == 'item'"
|
||||
onSelected="element.callback"
|
||||
attrs="{ href: element.href }"
|
||||
>
|
||||
<span t-att-class="entry[0] and 'ps-3'" t-esc="element.description"/>
|
||||
</DropdownItem>
|
||||
<t t-if="element.type == 'component'" t-component="element.Component" t-props="element.props"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
44
frontend/web/static/src/core/debug/debug_menu_basic.js
Normal file
44
frontend/web/static/src/core/debug/debug_menu_basic.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEnvDebugContext } from "./debug_context";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { groupBy, sortBy } from "@web/core/utils/arrays";
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const debugSectionRegistry = registry.category("debug_section");
|
||||
|
||||
debugSectionRegistry
|
||||
.add("record", { label: _t("Record"), sequence: 10 })
|
||||
.add("records", { label: _t("Records"), sequence: 10 })
|
||||
.add("ui", { label: _t("User Interface"), sequence: 20 })
|
||||
.add("security", { label: _t("Security"), sequence: 30 })
|
||||
.add("testing", { label: _t("Tours & Testing"), sequence: 40 })
|
||||
.add("tools", { label: _t("Tools"), sequence: 50 });
|
||||
|
||||
export class DebugMenuBasic extends Component {
|
||||
static template = "web.DebugMenu";
|
||||
static components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
};
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.debugContext = useEnvDebugContext();
|
||||
}
|
||||
|
||||
async loadGroupedItems() {
|
||||
const items = await this.debugContext.getItems(this.env);
|
||||
const sections = groupBy(items, (item) => item.section || "");
|
||||
this.sectionEntries = sortBy(
|
||||
Object.entries(sections),
|
||||
([section]) => debugSectionRegistry.get(section, { sequence: 50 }).sequence
|
||||
);
|
||||
}
|
||||
|
||||
getSectionLabel(section) {
|
||||
return debugSectionRegistry.get(section, { label: section }).label;
|
||||
}
|
||||
}
|
||||
70
frontend/web/static/src/core/debug/debug_menu_items.js
Normal file
70
frontend/web/static/src/core/debug/debug_menu_items.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { router } from "@web/core/browser/router";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
function activateTestsAssetsDebugging({ env }) {
|
||||
if (String(router.current.debug).includes("tests")) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "item",
|
||||
description: _t("Activate Test Mode"),
|
||||
callback: () => {
|
||||
router.pushState({ debug: "assets,tests" }, { reload: true });
|
||||
},
|
||||
sequence: 580,
|
||||
section: "tools",
|
||||
};
|
||||
}
|
||||
|
||||
export function regenerateAssets({ env }) {
|
||||
return {
|
||||
type: "item",
|
||||
description: _t("Regenerate Assets"),
|
||||
callback: async () => {
|
||||
await env.services.orm.call("ir.attachment", "regenerate_assets_bundles");
|
||||
browser.location.reload();
|
||||
},
|
||||
sequence: 550,
|
||||
section: "tools",
|
||||
};
|
||||
}
|
||||
|
||||
export function becomeSuperuser({ env }) {
|
||||
const becomeSuperuserURL = browser.location.origin + "/web/become";
|
||||
if (!user.isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
type: "item",
|
||||
description: _t("Become Superuser"),
|
||||
href: becomeSuperuserURL,
|
||||
callback: () => {
|
||||
browser.open(becomeSuperuserURL, "_self");
|
||||
},
|
||||
sequence: 560,
|
||||
section: "tools",
|
||||
};
|
||||
}
|
||||
|
||||
function leaveDebugMode() {
|
||||
return {
|
||||
type: "item",
|
||||
description: _t("Leave Debug Mode"),
|
||||
callback: () => {
|
||||
router.pushState({ debug: 0 }, { reload: true });
|
||||
},
|
||||
sequence: 650,
|
||||
};
|
||||
}
|
||||
|
||||
registry
|
||||
.category("debug")
|
||||
.category("default")
|
||||
.add("regenerateAssets", regenerateAssets)
|
||||
.add("becomeSuperuser", becomeSuperuser)
|
||||
.add("activateTestsAssetsDebugging", activateTestsAssetsDebugging)
|
||||
.add("leaveDebugMode", leaveDebugMode);
|
||||
124
frontend/web/static/src/core/debug/debug_menu_items.xml
Normal file
124
frontend/web/static/src/core/debug/debug_menu_items.xml
Normal file
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.DebugMenu.SetDefaultDialog">
|
||||
<Dialog title.translate="Set Default Values">
|
||||
<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">
|
||||
<Dialog title.translate="Metadata">
|
||||
<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>
|
||||
|
||||
<t t-name="web.DebugMenu.GetViewDialog">
|
||||
<Dialog title.translate="Computed Arch">
|
||||
<pre t-esc="props.arch"/>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary o-default-button" t-on-click="() => props.close()">Close</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
</templates>
|
||||
56
frontend/web/static/src/core/debug/debug_providers.js
Normal file
56
frontend/web/static/src/core/debug/debug_providers.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "../registry";
|
||||
import { browser } from "../browser/browser";
|
||||
import { router } from "../browser/router";
|
||||
|
||||
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() {
|
||||
router.pushState({ debug: "assets" }, { reload: true });
|
||||
},
|
||||
category: "debug",
|
||||
name: _t("Activate debug mode (with assets)"),
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
action() {
|
||||
router.pushState({ debug: 0 }, { reload: true });
|
||||
},
|
||||
category: "debug",
|
||||
name: _t("Deactivate debug mode"),
|
||||
});
|
||||
result.push({
|
||||
action() {
|
||||
browser.open("/web/tests?debug=assets");
|
||||
},
|
||||
category: "debug",
|
||||
name: _t("Run Unit Tests"),
|
||||
});
|
||||
} else {
|
||||
const debugKey = "debug";
|
||||
if (options.searchValue.toLowerCase() === debugKey) {
|
||||
result.push({
|
||||
action() {
|
||||
router.pushState({ debug: "1" }, { reload: true });
|
||||
},
|
||||
category: "debug",
|
||||
name: `${_t("Activate debug mode")} (${debugKey})`,
|
||||
});
|
||||
result.push({
|
||||
action() {
|
||||
router.pushState({ debug: "assets" }, { reload: true });
|
||||
},
|
||||
category: "debug",
|
||||
name: `${_t("Activate debug mode (with assets)")} (${debugKey})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
11
frontend/web/static/src/core/debug/debug_utils.js
Normal file
11
frontend/web/static/src/core/debug/debug_utils.js
Normal file
@@ -0,0 +1,11 @@
|
||||
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: "current",
|
||||
});
|
||||
}
|
||||
145
frontend/web/static/src/core/dialog/dialog.js
Normal file
145
frontend/web/static/src/core/dialog/dialog.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||
import { useActiveElement } from "../ui/ui_service";
|
||||
import { useForwardRefToParent } from "@web/core/utils/hooks";
|
||||
import { Component, onWillDestroy, useChildSubEnv, useExternalListener, useState } from "@odoo/owl";
|
||||
import { throttleForAnimation } from "@web/core/utils/timing";
|
||||
import { makeDraggableHook } from "../utils/draggable_hook_builder_owl";
|
||||
|
||||
const useDialogDraggable = makeDraggableHook({
|
||||
name: "useDialogDraggable",
|
||||
onWillStartDrag({ ctx, addCleanup, addStyle, getRect }) {
|
||||
const { height, width } = getRect(ctx.current.element);
|
||||
ctx.current.container = document.createElement("div");
|
||||
addStyle(ctx.current.container, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
bottom: `${70 - height}px`,
|
||||
left: `${70 - width}px`,
|
||||
right: `${70 - width}px`,
|
||||
});
|
||||
ctx.current.element.after(ctx.current.container);
|
||||
addCleanup(() => ctx.current.container.remove());
|
||||
},
|
||||
onDrop({ ctx, getRect }) {
|
||||
const { top, left } = getRect(ctx.current.element);
|
||||
return {
|
||||
left: left - ctx.current.elementRect.left,
|
||||
top: top - ctx.current.elementRect.top,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export class Dialog extends Component {
|
||||
static template = "web.Dialog";
|
||||
static 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", "fs", "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 },
|
||||
onExpand: { type: Function, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
contentClass: "",
|
||||
bodyClass: "",
|
||||
fullscreen: false,
|
||||
footer: true,
|
||||
header: true,
|
||||
size: "lg",
|
||||
technical: true,
|
||||
title: "Odoo",
|
||||
withBodyPadding: true,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.modalRef = useForwardRefToParent("modalRef");
|
||||
useActiveElement("modalRef");
|
||||
this.data = useState(this.env.dialogData);
|
||||
useHotkey("escape", () => this.onEscape());
|
||||
useHotkey(
|
||||
"control+enter",
|
||||
() => {
|
||||
const btns = document.querySelectorAll(
|
||||
".o_dialog:not(.o_inactive_modal) .modal-footer button"
|
||||
);
|
||||
const firstVisibleBtn = Array.from(btns).find((btn) => {
|
||||
const styles = getComputedStyle(btn);
|
||||
return styles.display !== "none";
|
||||
});
|
||||
if (firstVisibleBtn) {
|
||||
firstVisibleBtn.click();
|
||||
}
|
||||
},
|
||||
{ bypassEditableProtection: true }
|
||||
);
|
||||
this.id = `dialog_${this.data.id}`;
|
||||
useChildSubEnv({ inDialog: true, dialogId: this.id });
|
||||
this.isMovable = this.props.header;
|
||||
if (this.isMovable) {
|
||||
this.position = useState({ left: 0, top: 0 });
|
||||
useDialogDraggable({
|
||||
enable: () => !this.env.isSmall,
|
||||
ref: this.modalRef,
|
||||
elements: ".modal-content",
|
||||
handle: ".modal-header",
|
||||
ignore: "button, input",
|
||||
edgeScrolling: { enabled: false },
|
||||
onDrop: ({ top, left }) => {
|
||||
this.position.left += left;
|
||||
this.position.top += top;
|
||||
},
|
||||
});
|
||||
const throttledResize = throttleForAnimation(this.onResize.bind(this));
|
||||
useExternalListener(window, "resize", throttledResize);
|
||||
}
|
||||
onWillDestroy(() => {
|
||||
if (this.env.isSmall) {
|
||||
this.data.scrollToOrigin();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isFullscreen() {
|
||||
return this.props.fullscreen || this.env.isSmall;
|
||||
}
|
||||
|
||||
get contentStyle() {
|
||||
if (this.isMovable) {
|
||||
return `top: ${this.position.top}px; left: ${this.position.left}px;`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
onResize() {
|
||||
this.position.left = 0;
|
||||
this.position.top = 0;
|
||||
}
|
||||
|
||||
onEscape() {
|
||||
return this.dismiss();
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
if (this.data.dismiss) {
|
||||
await this.data.dismiss();
|
||||
}
|
||||
return this.data.close({ dismiss: true });
|
||||
}
|
||||
}
|
||||
82
frontend/web/static/src/core/dialog/dialog.scss
Normal file
82
frontend/web/static/src/core/dialog/dialog.scss
Normal file
@@ -0,0 +1,82 @@
|
||||
.modal.o_technical_modal {
|
||||
.modal-content {
|
||||
.modal-header .modal-title {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
text-align: left;
|
||||
|
||||
button {
|
||||
margin: 0; // Reset boostrap.
|
||||
}
|
||||
|
||||
button.o-default-button:not(:only-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.btn {
|
||||
width: 45%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
&.o_modal_full {
|
||||
.modal-dialog {
|
||||
margin: 0px;
|
||||
height: 100%;
|
||||
|
||||
.modal-content {
|
||||
height: 100%;
|
||||
border: none;
|
||||
|
||||
.modal-body {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal.o_inactive_modal {
|
||||
z-index: $zindex-modal-backdrop - 1;
|
||||
}
|
||||
|
||||
.o_dialog > .modal {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
.modal-fs {
|
||||
width: calc(100% - #{2 * $modal-dialog-margin-y-sm-up});
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.modal {
|
||||
&.o_modal_full .modal-content {
|
||||
.modal-header {
|
||||
align-items: center;
|
||||
height: $o-navbar-height;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@include o-webclient-padding($top: 1rem, $bottom: 0.5rem);
|
||||
box-shadow: 0 1rem 2rem black;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
frontend/web/static/src/core/dialog/dialog.xml
Normal file
47
frontend/web/static/src/core/dialog/dialog.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.Dialog">
|
||||
<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, o_inactive_modal: !data.isActive }"
|
||||
t-ref="modalRef"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered" t-attf-class="modal-{{props.size}}">
|
||||
<div class="modal-content" t-att-class="props.contentClass" t-att-style="contentStyle">
|
||||
<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="fullscreen" t-value="isFullscreen"/>
|
||||
<t t-set="onExpand" t-value="props.onExpand"/>
|
||||
</t>
|
||||
</t>
|
||||
</header>
|
||||
<main class="modal-body" t-attf-class="{{ props.bodyClass }} {{ !props.withBodyPadding ? 'p-0': '' }}">
|
||||
<t t-slot="default" close="() => this.data.close()" />
|
||||
</main>
|
||||
<footer t-if="props.footer" class="modal-footer d-empty-none justify-content-around justify-content-md-start flex-wrap gap-1 w-100">
|
||||
<t t-slot="footer" close="() => this.data.close()"/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web.Dialog.header">
|
||||
<t t-if="fullscreen">
|
||||
<button class="btn oi oi-arrow-left" aria-label="Close" tabindex="-1" t-on-click="dismiss" />
|
||||
</t>
|
||||
<h4 class="modal-title text-break flex-grow-1" t-att-class="{ 'me-auto': fullscreen }">
|
||||
<t t-esc="props.title"/>
|
||||
</h4>
|
||||
<t t-if="onExpand">
|
||||
<button type="button" class="fa fa-expand btn opacity-75 opacity-100-hover o_expand_button" aria-label="Expand" tabindex="-1" t-on-click="onExpand"/>
|
||||
</t>
|
||||
<t t-if="!fullscreen">
|
||||
<button type="button" class="btn-close" aria-label="Close" tabindex="-1" t-on-click="dismiss"></button>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
103
frontend/web/static/src/core/dialog/dialog_service.js
Normal file
103
frontend/web/static/src/core/dialog/dialog_service.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Component, markRaw, reactive, useChildSubEnv, xml } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
class DialogWrapper extends Component {
|
||||
static template = xml`<t t-component="props.subComponent" t-props="props.subProps" />`;
|
||||
static props = ["*"];
|
||||
setup() {
|
||||
useChildSubEnv({ dialogData: this.props.subEnv });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* onClose?(): void;
|
||||
* }} DialogServiceInterfaceAddOptions
|
||||
*/
|
||||
/**
|
||||
* @typedef {{
|
||||
* add(
|
||||
* Component: typeof import("@odoo/owl").Component,
|
||||
* props: {},
|
||||
* options?: DialogServiceInterfaceAddOptions
|
||||
* ): () => void;
|
||||
* }} DialogServiceInterface
|
||||
*/
|
||||
|
||||
export const dialogService = {
|
||||
dependencies: ["overlay"],
|
||||
/** @returns {DialogServiceInterface} */
|
||||
start(env, { overlay }) {
|
||||
const stack = [];
|
||||
let nextId = 0;
|
||||
|
||||
const deactivate = () => {
|
||||
for (const subEnv of stack) {
|
||||
subEnv.isActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
const add = (dialogClass, props, options = {}) => {
|
||||
const id = nextId++;
|
||||
const close = (params) => remove(params);
|
||||
const subEnv = reactive({
|
||||
id,
|
||||
close,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
deactivate();
|
||||
stack.push(subEnv);
|
||||
document.body.classList.add("modal-open");
|
||||
let isBeingClosed = false;
|
||||
|
||||
const scrollOrigin = { top: window.scrollY, left: window.scrollX };
|
||||
subEnv.scrollToOrigin = () => {
|
||||
if (!stack.length) {
|
||||
window.scrollTo(scrollOrigin);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = overlay.add(
|
||||
DialogWrapper,
|
||||
{
|
||||
subComponent: dialogClass,
|
||||
subProps: markRaw({ ...props, close }),
|
||||
subEnv,
|
||||
},
|
||||
{
|
||||
onRemove: async (closeParams) => {
|
||||
if (isBeingClosed) {
|
||||
return;
|
||||
}
|
||||
isBeingClosed = true;
|
||||
await options.onClose?.(closeParams);
|
||||
stack.splice(
|
||||
stack.findIndex((d) => d.id === id),
|
||||
1
|
||||
);
|
||||
deactivate();
|
||||
if (stack.length) {
|
||||
stack.at(-1).isActive = true;
|
||||
} else {
|
||||
document.body.classList.remove("modal-open");
|
||||
}
|
||||
},
|
||||
rootId: options.context?.root?.el?.getRootNode()?.host?.id,
|
||||
}
|
||||
);
|
||||
|
||||
return remove;
|
||||
};
|
||||
|
||||
function closeAll(params) {
|
||||
for (const dialog of [...stack].reverse()) {
|
||||
dialog.close(params);
|
||||
}
|
||||
}
|
||||
|
||||
return { add, closeAll };
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("dialog", dialogService);
|
||||
426
frontend/web/static/src/core/domain.js
Normal file
426
frontend/web/static/src/core/domain.js
Normal file
@@ -0,0 +1,426 @@
|
||||
import { shallowEqual } from "@web/core/utils/arrays";
|
||||
import { evaluate, formatAST, parseExpr } from "./py_js/py";
|
||||
import { toPyValue } from "./py_js/py_utils";
|
||||
import { escapeRegExp } from "@web/core/utils/strings";
|
||||
|
||||
/**
|
||||
* @typedef {import("./py_js/py_parser").AST} AST
|
||||
* @typedef {[string | 0 | 1, 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new domain with `neutralized` leaves (for the leaves that are applied on the field that are part of
|
||||
* keysToRemove).
|
||||
* @param {DomainRepr} domain
|
||||
* @param {string[]} keysToRemove
|
||||
* @return {Domain}
|
||||
*/
|
||||
static removeDomainLeaves(domain, keysToRemove) {
|
||||
function processLeaf(elements, idx, operatorCtx, newDomain) {
|
||||
const leaf = elements[idx];
|
||||
if (leaf.type === 10) {
|
||||
if (keysToRemove.includes(leaf.value[0].value)) {
|
||||
if (operatorCtx === "&") {
|
||||
newDomain.ast.value.push(...Domain.TRUE.ast.value);
|
||||
} else if (operatorCtx === "|") {
|
||||
newDomain.ast.value.push(...Domain.FALSE.ast.value);
|
||||
}
|
||||
} else {
|
||||
newDomain.ast.value.push(leaf);
|
||||
}
|
||||
return 1;
|
||||
} else if (leaf.type === 1) {
|
||||
// Special case to avoid OR ('|') that can never resolve to true
|
||||
if (
|
||||
leaf.value === "|" &&
|
||||
elements[idx + 1].type === 10 &&
|
||||
elements[idx + 2].type === 10 &&
|
||||
keysToRemove.includes(elements[idx + 1].value[0].value) &&
|
||||
keysToRemove.includes(elements[idx + 2].value[0].value)
|
||||
) {
|
||||
newDomain.ast.value.push(...Domain.TRUE.ast.value);
|
||||
return 3;
|
||||
}
|
||||
newDomain.ast.value.push(leaf);
|
||||
if (leaf.value === "!") {
|
||||
return 1 + processLeaf(elements, idx + 1, "&", newDomain);
|
||||
}
|
||||
const firstLeafSkip = processLeaf(elements, idx + 1, leaf.value, newDomain);
|
||||
const secondLeafSkip = processLeaf(
|
||||
elements,
|
||||
idx + 1 + firstLeafSkip,
|
||||
leaf.value,
|
||||
newDomain
|
||||
);
|
||||
return 1 + firstLeafSkip + secondLeafSkip;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
domain = new Domain(domain);
|
||||
if (domain.ast.value.length === 0) {
|
||||
return domain;
|
||||
}
|
||||
const newDomain = new Domain([]);
|
||||
processLeaf(domain.ast.value, 0, "&", newDomain);
|
||||
return newDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DomainRepr} [descr]
|
||||
*/
|
||||
constructor(descr = []) {
|
||||
if (descr instanceof Domain) {
|
||||
/** @type {AST} */
|
||||
return new Domain(descr.toString());
|
||||
} else {
|
||||
let rawAST;
|
||||
try {
|
||||
rawAST = typeof descr === "string" ? parseExpr(descr) : toAST(descr);
|
||||
} catch (error) {
|
||||
throw new InvalidDomainError(`Invalid domain representation: ${descr.toString()}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
this.ast = normalizeDomainAST(rawAST);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the set of records represented by a domain contains a record
|
||||
* Warning: smart dates (see parseSmartDateInput) are not handled here.
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the domain into a human-readable format for JSON representation.
|
||||
* If the domain does not contain any contextual value, it is converted to a list.
|
||||
* Otherwise, it is returned as a string.
|
||||
*
|
||||
* The string format is less readable due to escaped double quotes.
|
||||
* Example: "[\"&\",[\"user_id\",\"=\",uid],[\"team_id\",\"!=\",false]]"
|
||||
* @returns {DomainListRepr | string}
|
||||
*/
|
||||
toJson() {
|
||||
try {
|
||||
// Attempt to evaluate the domain without context
|
||||
const evaluatedAsList = this.toList({});
|
||||
const evaluatedDomain = new Domain(evaluatedAsList);
|
||||
if (evaluatedDomain.toString() === this.toString()) {
|
||||
return evaluatedAsList;
|
||||
}
|
||||
return this.toString();
|
||||
} catch {
|
||||
// The domain couldn't be evaluated due to contextual values
|
||||
return this.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Condition} */
|
||||
const TRUE_LEAF = [1, "=", 1];
|
||||
/** @type {Condition} */
|
||||
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) {
|
||||
switch (child.type) {
|
||||
case 1 /* String */:
|
||||
if (child.value === "&" || child.value === "|") {
|
||||
expected++;
|
||||
} else if (child.value !== "!") {
|
||||
throw new InvalidDomainError("Invalid domain AST");
|
||||
}
|
||||
break;
|
||||
case 4: /* list */
|
||||
case 10 /* tuple */:
|
||||
if (child.value.length === 3) {
|
||||
expected--;
|
||||
break;
|
||||
}
|
||||
throw new InvalidDomainError("Invalid domain AST");
|
||||
default:
|
||||
throw new InvalidDomainError("Invalid domain AST");
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
let likeRegexp, ilikeRegexp;
|
||||
if (["like", "not like", "ilike", "not ilike"].includes(operator)) {
|
||||
likeRegexp = new RegExp(`(.*)${escapeRegExp(value).replaceAll("%", "(.*)")}(.*)`, "g");
|
||||
ilikeRegexp = new RegExp(`(.*)${escapeRegExp(value).replaceAll("%", "(.*)")}(.*)`, "gi");
|
||||
}
|
||||
const fieldValue = typeof field === "number" ? field : record[field];
|
||||
const isNot = operator.startsWith("not ");
|
||||
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":
|
||||
case "not in": {
|
||||
const val = Array.isArray(value) ? value : [value];
|
||||
const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue];
|
||||
return Boolean(fieldVal.some((fv) => val.includes(fv))) != isNot;
|
||||
}
|
||||
case "like":
|
||||
case "not like":
|
||||
if (fieldValue === false) {
|
||||
return isNot;
|
||||
}
|
||||
return Boolean(fieldValue.match(likeRegexp)) != isNot;
|
||||
case "=like":
|
||||
case "not =like":
|
||||
if (fieldValue === false) {
|
||||
return isNot;
|
||||
}
|
||||
return (
|
||||
Boolean(new RegExp(escapeRegExp(value).replace(/%/g, ".*")).test(fieldValue)) !=
|
||||
isNot
|
||||
);
|
||||
case "ilike":
|
||||
case "not ilike":
|
||||
if (fieldValue === false) {
|
||||
return isNot;
|
||||
}
|
||||
return Boolean(fieldValue.match(ilikeRegexp)) != isNot;
|
||||
case "=ilike":
|
||||
case "not =ilike":
|
||||
if (fieldValue === false) {
|
||||
return isNot;
|
||||
}
|
||||
return (
|
||||
Boolean(
|
||||
new RegExp(escapeRegExp(value).replace(/%/g, ".*"), "i").test(fieldValue)
|
||||
) != isNot
|
||||
);
|
||||
case "any":
|
||||
case "not any":
|
||||
return true;
|
||||
case "child_of":
|
||||
case "parent_of":
|
||||
return true;
|
||||
}
|
||||
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) {
|
||||
const operator = typeof item === "string" && operators[item];
|
||||
if (operator) {
|
||||
const operands = condStack.splice(-operator.length);
|
||||
condStack.push(operator(...operands));
|
||||
} else {
|
||||
condStack.push(item);
|
||||
}
|
||||
}
|
||||
return matchCondition(record, condStack.pop());
|
||||
}
|
||||
156
frontend/web/static/src/core/domain_selector/domain_selector.js
Normal file
156
frontend/web/static/src/core/domain_selector/domain_selector.js
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
|
||||
import { CheckBox } from "@web/core/checkbox/checkbox";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { getDomainDisplayedOperators } from "@web/core/domain_selector/domain_selector_operator_editor";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
|
||||
import {
|
||||
areEqualTrees,
|
||||
condition,
|
||||
connector,
|
||||
formatValue,
|
||||
} from "@web/core/tree_editor/condition_tree";
|
||||
import { domainFromTree } from "@web/core/tree_editor/domain_from_tree";
|
||||
import { TreeEditor } from "@web/core/tree_editor/tree_editor";
|
||||
import { getOperatorEditorInfo } from "@web/core/tree_editor/tree_editor_operator_editor";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { getDefaultCondition } from "./utils";
|
||||
|
||||
const ARCHIVED_CONDITION = condition("active", "in", [true, false]);
|
||||
const ARCHIVED_DOMAIN = `[("active", "in", [True, False])]`;
|
||||
|
||||
export class DomainSelector extends Component {
|
||||
static template = "web.DomainSelector";
|
||||
static components = { TreeEditor, CheckBox };
|
||||
static props = {
|
||||
domain: String,
|
||||
resModel: String,
|
||||
className: { type: String, optional: true },
|
||||
defaultConnector: { type: [{ value: "&" }, { value: "|" }], optional: true },
|
||||
isDebugMode: { type: Boolean, optional: true },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
update: { type: Function, optional: true },
|
||||
debugUpdate: { type: Function, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
isDebugMode: false,
|
||||
readonly: true,
|
||||
update: () => {},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.fieldService = useService("field");
|
||||
this.treeProcessor = useService("tree_processor");
|
||||
|
||||
this.tree = null;
|
||||
this.showArchivedCheckbox = false;
|
||||
this.includeArchived = false;
|
||||
|
||||
onWillStart(() => this.onPropsUpdated(this.props));
|
||||
onWillUpdateProps((np) => this.onPropsUpdated(np));
|
||||
}
|
||||
|
||||
async onPropsUpdated(p) {
|
||||
let domain;
|
||||
let isSupported = true;
|
||||
try {
|
||||
domain = new Domain(p.domain);
|
||||
} catch {
|
||||
isSupported = false;
|
||||
}
|
||||
if (!isSupported) {
|
||||
this.tree = null;
|
||||
this.showArchivedCheckbox = false;
|
||||
this.includeArchived = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [tree, { fieldDef: activeFieldDef }] = await Promise.all([
|
||||
this.treeProcessor.treeFromDomain(p.resModel, domain, !p.isDebugMode),
|
||||
this.fieldService.loadFieldInfo(p.resModel, "active"),
|
||||
]);
|
||||
|
||||
this.tree = tree;
|
||||
this.showArchivedCheckbox = this.getShowArchivedCheckBox(Boolean(activeFieldDef), p);
|
||||
|
||||
this.includeArchived = false;
|
||||
if (this.showArchivedCheckbox) {
|
||||
if (this.tree.type === "connector" && this.tree.value === "&") {
|
||||
this.tree.children = this.tree.children.filter((child) => {
|
||||
if (areEqualTrees(child, ARCHIVED_CONDITION)) {
|
||||
this.includeArchived = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (this.tree.children.length === 1) {
|
||||
this.tree = this.tree.children[0];
|
||||
}
|
||||
} else if (areEqualTrees(this.tree, ARCHIVED_CONDITION)) {
|
||||
this.includeArchived = true;
|
||||
this.tree = connector("&");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getShowArchivedCheckBox(hasActiveField, props) {
|
||||
return hasActiveField;
|
||||
}
|
||||
|
||||
getDefaultCondition(fieldDefs) {
|
||||
return getDefaultCondition(fieldDefs);
|
||||
}
|
||||
|
||||
getDefaultOperator(fieldDef) {
|
||||
return getDomainDisplayedOperators(fieldDef)[0];
|
||||
}
|
||||
|
||||
getOperatorEditorInfo(fieldDef) {
|
||||
const operators = getDomainDisplayedOperators(fieldDef);
|
||||
return getOperatorEditorInfo(operators, fieldDef);
|
||||
}
|
||||
|
||||
getPathEditorInfo(resModel, defaultCondition) {
|
||||
const { isDebugMode } = this.props;
|
||||
return {
|
||||
component: ModelFieldSelector,
|
||||
extractProps: ({ update, value: path }) => ({
|
||||
path,
|
||||
update,
|
||||
resModel,
|
||||
isDebugMode,
|
||||
readonly: false,
|
||||
}),
|
||||
isSupported: (path) => [0, 1].includes(path) || typeof path === "string",
|
||||
defaultValue: () => defaultCondition.path,
|
||||
stringify: (path) => formatValue(path),
|
||||
message: _t("Invalid field chain"),
|
||||
};
|
||||
}
|
||||
|
||||
toggleIncludeArchived() {
|
||||
this.includeArchived = !this.includeArchived;
|
||||
this.update(this.tree);
|
||||
}
|
||||
|
||||
resetDomain() {
|
||||
this.props.update("[]");
|
||||
}
|
||||
|
||||
onDomainInput(domain) {
|
||||
if (this.props.debugUpdate) {
|
||||
this.props.debugUpdate(domain);
|
||||
}
|
||||
}
|
||||
|
||||
onDomainChange(domain) {
|
||||
this.props.update(domain, true);
|
||||
}
|
||||
update(tree) {
|
||||
const archiveDomain = this.includeArchived ? ARCHIVED_DOMAIN : `[]`;
|
||||
const domain = tree
|
||||
? Domain.and([domainFromTree(tree), archiveDomain]).toString()
|
||||
: archiveDomain;
|
||||
this.props.update(domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.DomainSelector">
|
||||
<div class="o_domain_selector w-100" aria-atomic="true" t-att-class="props.className">
|
||||
<t t-if="tree">
|
||||
<TreeEditor resModel="props.resModel"
|
||||
tree="tree"
|
||||
update.bind="update"
|
||||
readonly="props.readonly"
|
||||
isDebugMode="props.isDebugMode"
|
||||
defaultConnector="props.defaultConnector"
|
||||
getDefaultCondition.bind="getDefaultCondition"
|
||||
getOperatorEditorInfo.bind="getOperatorEditorInfo"
|
||||
getDefaultOperator.bind="getDefaultOperator"
|
||||
getPathEditorInfo.bind="getPathEditorInfo"
|
||||
>
|
||||
<CheckBox
|
||||
t-if="showArchivedCheckbox"
|
||||
value="includeArchived"
|
||||
disabled="props.readonly"
|
||||
className="'form-switch'"
|
||||
onChange.bind="toggleIncludeArchived"
|
||||
>
|
||||
Include archived
|
||||
</CheckBox>
|
||||
</TreeEditor>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_domain_selector_row d-flex align-items-center">
|
||||
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 t-if="props.isDebugMode and (!tree or !props.readonly)">
|
||||
<label class="o_domain_selector_debug_container d-block mt-3 border rounded p-3 bg-100 text-muted font-monospace">
|
||||
<span class="small"># Code editor</span>
|
||||
<textarea class="pt-2 border-0 bg-transparent text-body" type="text" t-att-readonly="props.readonly" spellcheck="false" t-att-value="props.domain" t-on-input="(ev) => this.onDomainInput(ev.target.value)" t-on-change="(ev) => this.onDomainChange(ev.target.value)" />
|
||||
</label>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,62 @@
|
||||
export function getDomainDisplayedOperators(fieldDef) {
|
||||
if (!fieldDef) {
|
||||
fieldDef = {};
|
||||
}
|
||||
const { type, is_property } = fieldDef;
|
||||
|
||||
if (is_property) {
|
||||
switch (type) {
|
||||
case "many2many":
|
||||
case "tags":
|
||||
return ["in", "not in", "set", "not set"];
|
||||
case "many2one":
|
||||
case "selection":
|
||||
return ["=", "!=", "set", "not set"];
|
||||
}
|
||||
}
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return ["set", "not set"];
|
||||
case "selection":
|
||||
return ["=", "!=", "in", "not in", "set", "not set"];
|
||||
case "char":
|
||||
case "text":
|
||||
case "html":
|
||||
return ["=", "!=", "ilike", "not ilike", "starts with", "set", "not set"];
|
||||
case "date":
|
||||
case "datetime":
|
||||
return ["in range", "=", "<", ">", "set", "not set"];
|
||||
case "integer":
|
||||
case "float":
|
||||
case "monetary":
|
||||
return ["=", "!=", "<", ">", "between"];
|
||||
case "many2one":
|
||||
case "many2many":
|
||||
case "one2many":
|
||||
return ["in", "not in", "ilike", "not ilike", "set", "not set"];
|
||||
case "json":
|
||||
return ["=", "!=", "ilike", "not ilike", "set", "not set"];
|
||||
case "binary":
|
||||
case "properties":
|
||||
return ["set", "not set"];
|
||||
case undefined:
|
||||
return ["="];
|
||||
default:
|
||||
return [
|
||||
"=",
|
||||
"!=",
|
||||
"<",
|
||||
">",
|
||||
"ilike",
|
||||
"not ilike",
|
||||
"like",
|
||||
"not like",
|
||||
"=like",
|
||||
"=ilike",
|
||||
"in",
|
||||
"not in",
|
||||
"set",
|
||||
"not set",
|
||||
];
|
||||
}
|
||||
}
|
||||
26
frontend/web/static/src/core/domain_selector/utils.js
Normal file
26
frontend/web/static/src/core/domain_selector/utils.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getDomainDisplayedOperators } from "@web/core/domain_selector/domain_selector_operator_editor";
|
||||
import { condition } from "@web/core/tree_editor/condition_tree";
|
||||
import { domainFromTree } from "@web/core/tree_editor/domain_from_tree";
|
||||
import { getDefaultValue } from "@web/core/tree_editor/tree_editor_value_editors";
|
||||
import { getDefaultPath } from "@web/core/tree_editor/utils";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export function getDefaultCondition(fieldDefs) {
|
||||
const defaultPath = getDefaultPath(fieldDefs);
|
||||
const fieldDef = fieldDefs[defaultPath];
|
||||
const operator = getDomainDisplayedOperators(fieldDef)[0];
|
||||
const value = getDefaultValue(fieldDef, operator);
|
||||
return condition(fieldDef.name, operator, value);
|
||||
}
|
||||
|
||||
export function getDefaultDomain(fieldDefs) {
|
||||
return domainFromTree(getDefaultCondition(fieldDefs));
|
||||
}
|
||||
|
||||
export function useGetDefaultLeafDomain() {
|
||||
const fieldService = useService("field");
|
||||
return async (resModel) => {
|
||||
const fieldDefs = await fieldService.loadFields(resModel);
|
||||
return getDefaultDomain(fieldDefs);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Component, useRef, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { user } from "@web/core/user";
|
||||
|
||||
export class DomainSelectorDialog extends Component {
|
||||
static template = "web.DomainSelectorDialog";
|
||||
static components = {
|
||||
Dialog,
|
||||
DomainSelector,
|
||||
};
|
||||
static props = {
|
||||
close: Function,
|
||||
onConfirm: Function,
|
||||
resModel: String,
|
||||
className: { type: String, optional: true },
|
||||
defaultConnector: { type: [{ value: "&" }, { value: "|" }], optional: true },
|
||||
domain: String,
|
||||
isDebugMode: { type: Boolean, optional: true },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
text: { type: String, optional: true },
|
||||
confirmButtonText: { type: String, optional: true },
|
||||
disableConfirmButton: { type: Function, optional: true },
|
||||
discardButtonText: { type: String, optional: true },
|
||||
title: { type: String, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
isDebugMode: false,
|
||||
readonly: false,
|
||||
context: {},
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.state = useState({ domain: this.props.domain });
|
||||
this.confirmButtonRef = useRef("confirm");
|
||||
}
|
||||
|
||||
get confirmButtonText() {
|
||||
return this.props.confirmButtonText || _t("Confirm");
|
||||
}
|
||||
|
||||
get dialogTitle() {
|
||||
return this.props.title || _t("Domain");
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
if (this.props.disableConfirmButton) {
|
||||
return this.props.disableConfirmButton(this.state.domain);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get discardButtonText() {
|
||||
return this.props.discardButtonText || _t("Discard");
|
||||
}
|
||||
|
||||
get domainSelectorProps() {
|
||||
return {
|
||||
className: this.props.className,
|
||||
resModel: this.props.resModel,
|
||||
readonly: this.props.readonly,
|
||||
isDebugMode: this.props.isDebugMode,
|
||||
defaultConnector: this.props.defaultConnector,
|
||||
domain: this.state.domain,
|
||||
update: (domain) => {
|
||||
this.state.domain = domain;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async onConfirm() {
|
||||
this.confirmButtonRef.el.disabled = true;
|
||||
let domain;
|
||||
let isValid;
|
||||
try {
|
||||
const evalContext = { ...user.context, ...this.props.context };
|
||||
domain = new Domain(this.state.domain).toList(evalContext);
|
||||
} catch {
|
||||
isValid = false;
|
||||
}
|
||||
if (isValid === undefined) {
|
||||
isValid = await rpc("/web/domain/validate", {
|
||||
model: this.props.resModel,
|
||||
domain,
|
||||
});
|
||||
}
|
||||
if (!isValid) {
|
||||
if (this.confirmButtonRef.el) {
|
||||
this.confirmButtonRef.el.disabled = false;
|
||||
}
|
||||
this.notification.add(_t("Domain is invalid. Please correct it"), {
|
||||
type: "danger",
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.props.onConfirm(this.state.domain);
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
onDiscard() {
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.DomainSelectorDialog">
|
||||
<Dialog title="dialogTitle" size="'xl'">
|
||||
<div t-if="props.text" class="mb-3" t-out="props.text"/>
|
||||
<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-att-disabled="disabled" t-on-click="onConfirm" t-ref="confirm"><t t-esc="confirmButtonText"/></button>
|
||||
<button class="btn btn-secondary" t-on-click="onDiscard"><t t-esc="discardButtonText"/></button>
|
||||
</t>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useComponent, useEffect, useEnv } from "@odoo/owl";
|
||||
import { DROPDOWN_GROUP } from "@web/core/dropdown/dropdown_group";
|
||||
|
||||
/**
|
||||
* @typedef DropdownGroupState
|
||||
* @property {boolean} isInGroup
|
||||
* @property {boolean} isOpen
|
||||
*/
|
||||
|
||||
/**
|
||||
* Will add (and remove) a dropdown from a parent
|
||||
* DropdownGroup component, allowing it to know
|
||||
* if it's in a group and if the group is open.
|
||||
*
|
||||
* @returns {DropdownGroupState}
|
||||
*/
|
||||
export function useDropdownGroup() {
|
||||
const env = useEnv();
|
||||
|
||||
const group = {
|
||||
isInGroup: DROPDOWN_GROUP in env,
|
||||
get isOpen() {
|
||||
return this.isInGroup && [...env[DROPDOWN_GROUP]].some((dropdown) => dropdown.isOpen);
|
||||
},
|
||||
};
|
||||
|
||||
if (group.isInGroup) {
|
||||
const dropdown = useComponent();
|
||||
useEffect(() => {
|
||||
env[DROPDOWN_GROUP].add(dropdown.state);
|
||||
return () => env[DROPDOWN_GROUP].delete(dropdown.state);
|
||||
});
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { EventBus, onWillDestroy, useChildSubEnv, useEffect, useEnv } from "@odoo/owl";
|
||||
import { localization } from "@web/core/l10n/localization";
|
||||
import { useBus, useService } from "@web/core/utils/hooks";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
|
||||
export const DROPDOWN_NESTING = Symbol("dropdownNesting");
|
||||
const BUS = new EventBus();
|
||||
|
||||
class DropdownNestingState {
|
||||
constructor({ parent, close }) {
|
||||
this._isOpen = false;
|
||||
this.parent = parent;
|
||||
this.children = new Set();
|
||||
this.close = close;
|
||||
|
||||
parent?.children.add(this);
|
||||
}
|
||||
|
||||
set isOpen(value) {
|
||||
this._isOpen = value;
|
||||
if (this._isOpen) {
|
||||
BUS.trigger("dropdown-opened", this);
|
||||
}
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.parent?.children.delete(this);
|
||||
}
|
||||
|
||||
closeAllParents() {
|
||||
this.close();
|
||||
if (this.parent) {
|
||||
this.parent.closeAllParents();
|
||||
}
|
||||
}
|
||||
|
||||
closeChildren() {
|
||||
this.children.forEach((child) => child.close());
|
||||
}
|
||||
|
||||
shouldIgnoreChanges(other) {
|
||||
return (
|
||||
other === this ||
|
||||
other.activeEl !== this.activeEl ||
|
||||
[...this.children].some((child) => child.shouldIgnoreChanges(other))
|
||||
);
|
||||
}
|
||||
|
||||
handleChange(other) {
|
||||
// Prevents closing the dropdown when a change is coming from itself or from a children.
|
||||
if (this.shouldIgnoreChanges(other)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (other.isOpen && this.isOpen) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook is used to manage communication between dropdowns.
|
||||
*
|
||||
* When a dropdown is open, every other dropdown that is not a parent
|
||||
* is closed. It also uses the current's ui active element to only
|
||||
* close itself when the active element is the same as the current
|
||||
* dropdown to separate dropdowns in different dialogs.
|
||||
*
|
||||
* @param {import("@web/core/dropdown/dropdown_hooks").DropdownState} state
|
||||
* @returns
|
||||
*/
|
||||
export function useDropdownNesting(state) {
|
||||
const env = useEnv();
|
||||
const current = new DropdownNestingState({
|
||||
parent: env[DROPDOWN_NESTING],
|
||||
close: () => state.close(),
|
||||
});
|
||||
|
||||
// Set up UI active element related behavior ---------------------------
|
||||
const uiService = useService("ui");
|
||||
useEffect(
|
||||
() => {
|
||||
Promise.resolve().then(() => {
|
||||
current.activeEl = uiService.activeElement;
|
||||
});
|
||||
},
|
||||
() => []
|
||||
);
|
||||
|
||||
useChildSubEnv({ [DROPDOWN_NESTING]: current });
|
||||
useBus(BUS, "dropdown-opened", ({ detail: other }) => current.handleChange(other));
|
||||
|
||||
effect(
|
||||
(state) => {
|
||||
current.isOpen = state.isOpen;
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
onWillDestroy(() => {
|
||||
current.remove();
|
||||
});
|
||||
|
||||
const isDropdown = (target) => target && target.classList.contains("o-dropdown");
|
||||
const isRTL = () => localization.direction === "rtl";
|
||||
|
||||
return {
|
||||
get hasParent() {
|
||||
return Boolean(current.parent);
|
||||
},
|
||||
/**@type {import("@web/core/navigation/navigation").NavigationOptions} */
|
||||
navigationOptions: {
|
||||
onUpdated: (navigator) => {
|
||||
if (current.parent && !navigator.activeItem) {
|
||||
navigator.items[0]?.setActive();
|
||||
}
|
||||
},
|
||||
hotkeys: {
|
||||
escape: () => current.close(),
|
||||
arrowleft: {
|
||||
isAvailable: () => true,
|
||||
callback: (navigator) => {
|
||||
if (isRTL() && isDropdown(navigator.activeItem?.target)) {
|
||||
navigator.activeItem?.select();
|
||||
} else if (current.parent) {
|
||||
current.close();
|
||||
}
|
||||
},
|
||||
},
|
||||
arrowright: {
|
||||
isAvailable: () => true,
|
||||
callback: (navigator) => {
|
||||
if (isRTL() && current.parent) {
|
||||
current.close();
|
||||
} else if (isDropdown(navigator.activeItem?.target)) {
|
||||
navigator.activeItem?.select();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Component, onMounted, onRendered, onWillDestroy, onWillStart, xml } from "@odoo/owl";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
export class DropdownPopover extends Component {
|
||||
static components = { DropdownItem };
|
||||
static template = xml`
|
||||
<t t-if="this.props.items">
|
||||
<t t-foreach="this.props.items" t-as="item" t-key="this.getKey(item, item_index)">
|
||||
<DropdownItem class="item.class" onSelected="() => item.onSelected()" t-out="item.label"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-slot="content" />
|
||||
`;
|
||||
static props = {
|
||||
// Popover service
|
||||
close: { type: Function, optional: true },
|
||||
|
||||
// Events & Handlers
|
||||
beforeOpen: { type: Function, optional: true },
|
||||
onOpened: { type: Function, optional: true },
|
||||
onClosed: { type: Function, optional: true },
|
||||
|
||||
// Rendering & Context
|
||||
refresher: Object,
|
||||
slots: Object,
|
||||
items: { type: Array, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
onRendered(() => {
|
||||
// Note that the Dropdown component and the DropdownPopover component
|
||||
// are not in the same context.
|
||||
// So when the Dropdown component is re-rendered, the DropdownPopover
|
||||
// component must also re-render itself.
|
||||
// This is why we subscribe to this reactive, which is changed when
|
||||
// the Dropdown component is re-rendered.
|
||||
this.props.refresher.token;
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.props.beforeOpen?.();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this.props.onOpened?.();
|
||||
});
|
||||
|
||||
onWillDestroy(() => {
|
||||
this.props.onClosed?.();
|
||||
});
|
||||
}
|
||||
|
||||
getKey(item, index) {
|
||||
return "id" in item ? item.id : index;
|
||||
}
|
||||
}
|
||||
38
frontend/web/static/src/core/dropdown/accordion_item.js
Normal file
38
frontend/web/static/src/core/dropdown/accordion_item.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Component, onPatched, useState } from "@odoo/owl";
|
||||
|
||||
export const ACCORDION = Symbol("Accordion");
|
||||
export class AccordionItem extends Component {
|
||||
static template = "web.AccordionItem";
|
||||
static components = {};
|
||||
static props = {
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
description: String,
|
||||
selected: {
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
},
|
||||
class: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
};
|
||||
static defaultProps = {
|
||||
class: "",
|
||||
selected: false,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
open: false,
|
||||
});
|
||||
this.parentComponent = this.env[ACCORDION];
|
||||
onPatched(() => {
|
||||
this.parentComponent?.accordionStateChanged?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
12
frontend/web/static/src/core/dropdown/accordion_item.scss
Normal file
12
frontend/web/static/src/core/dropdown/accordion_item.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.o_accordion_toggle {
|
||||
@include caret();
|
||||
|
||||
&.open {
|
||||
@include caret('up');
|
||||
}
|
||||
|
||||
&::after {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
transform: translate(-0.6em, 0.8em) /*rtl:translate(0.6em, 0.8em) scaleX(-1)*/;
|
||||
}
|
||||
}
|
||||
21
frontend/web/static/src/core/dropdown/accordion_item.xml
Normal file
21
frontend/web/static/src/core/dropdown/accordion_item.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.AccordionItem">
|
||||
<div class="o_accordion position-relative">
|
||||
<button class="o_menu_item o_accordion_toggle dropdown-item o-navigable" tabindex="0"
|
||||
t-att-class="{'selected': props.selected, 'open': state.open}"
|
||||
t-attf-class="{{ props.class }}"
|
||||
t-att-aria-expanded="state.open ? 'true' : 'false'"
|
||||
t-esc="props.description"
|
||||
t-on-click="() => state.open = !state.open"
|
||||
/>
|
||||
<t t-if="state.open">
|
||||
<div class="o_accordion_values ms-4 border-start">
|
||||
<t t-slot="default"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
12
frontend/web/static/src/core/dropdown/checkbox_item.js
Normal file
12
frontend/web/static/src/core/dropdown/checkbox_item.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
|
||||
export class CheckboxItem extends DropdownItem {
|
||||
static template = "web.CheckboxItem";
|
||||
static props = {
|
||||
...DropdownItem.props,
|
||||
checked: {
|
||||
type: Boolean,
|
||||
optional: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
388
frontend/web/static/src/core/dropdown/dropdown.js
Normal file
388
frontend/web/static/src/core/dropdown/dropdown.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
Component,
|
||||
onMounted,
|
||||
onRendered,
|
||||
onWillUpdateProps,
|
||||
reactive,
|
||||
status,
|
||||
useEffect,
|
||||
xml,
|
||||
} from "@odoo/owl";
|
||||
import { useDropdownGroup } from "@web/core/dropdown/_behaviours/dropdown_group_hook";
|
||||
import { useDropdownNesting } from "@web/core/dropdown/_behaviours/dropdown_nesting";
|
||||
import { DropdownPopover } from "@web/core/dropdown/_behaviours/dropdown_popover";
|
||||
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
|
||||
import { useNavigation } from "@web/core/navigation/navigation";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { mergeClasses } from "@web/core/utils/classname";
|
||||
import { useChildRef, useService } from "@web/core/utils/hooks";
|
||||
import { deepMerge } from "@web/core/utils/objects";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
import { utils } from "@web/core/ui/ui_service";
|
||||
import { hasTouch } from "@web/core/browser/feature_detection";
|
||||
|
||||
export function getFirstElementOfNode(node) {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
if (node.el) {
|
||||
return node.el.nodeType === Node.ELEMENT_NODE ? node.el : null;
|
||||
}
|
||||
if (node.bdom || node.child) {
|
||||
return getFirstElementOfNode(node.bdom || node.child);
|
||||
}
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const el = getFirstElementOfNode(child);
|
||||
if (el) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dropdown component allows to define a menu that will
|
||||
* show itself when a target is toggled.
|
||||
*
|
||||
* Items are defined using DropdownItems. Dropdowns are
|
||||
* also allowed as items to be able to create nested
|
||||
* dropdown menus.
|
||||
*/
|
||||
export class Dropdown extends Component {
|
||||
static template = xml`<t t-slot="default"/>`;
|
||||
static components = {};
|
||||
static props = {
|
||||
menuClass: { optional: true },
|
||||
position: { type: String, optional: true },
|
||||
slots: {
|
||||
type: Object,
|
||||
shape: {
|
||||
default: { optional: true },
|
||||
content: { optional: true },
|
||||
},
|
||||
},
|
||||
|
||||
items: {
|
||||
optional: true,
|
||||
type: Array,
|
||||
elements: {
|
||||
type: Object,
|
||||
shape: {
|
||||
label: String,
|
||||
onSelected: Function,
|
||||
class: { optional: true },
|
||||
"*": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
menuRef: { type: Function, optional: true }, // to be used with useChildRef
|
||||
disabled: { type: Boolean, optional: true },
|
||||
holdOnHover: { type: Boolean, optional: true },
|
||||
focusToggleOnClosed: { type: Boolean, optional: true },
|
||||
|
||||
beforeOpen: { type: Function, optional: true },
|
||||
onOpened: { type: Function, optional: true },
|
||||
onStateChanged: { type: Function, optional: true },
|
||||
|
||||
/** Manual state handling, @see useDropdownState */
|
||||
state: {
|
||||
type: Object,
|
||||
shape: {
|
||||
isOpen: Boolean,
|
||||
close: Function,
|
||||
open: Function,
|
||||
"*": true,
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
manual: { type: Boolean, optional: true },
|
||||
|
||||
/** When true, do not add optional styling css classes on the target*/
|
||||
noClasses: { type: Boolean, optional: true },
|
||||
|
||||
/**
|
||||
* Override the internal navigation hook options
|
||||
* @type {import("@web/core/navigation/navigation").NavigationOptions}
|
||||
*/
|
||||
navigationOptions: { type: Object, optional: true },
|
||||
bottomSheet: { type: Boolean, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
disabled: false,
|
||||
holdOnHover: false,
|
||||
focusToggleOnClosed: true,
|
||||
menuClass: "",
|
||||
state: undefined,
|
||||
noClasses: false,
|
||||
navigationOptions: {},
|
||||
bottomSheet: true,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.menuRef = this.props.menuRef || useChildRef();
|
||||
|
||||
this.state = this.props.state || useDropdownState();
|
||||
this.nesting = useDropdownNesting(this.state);
|
||||
this.group = useDropdownGroup();
|
||||
|
||||
this.navigation = useNavigation(this.menuRef, {
|
||||
shouldRegisterHotkeys: false,
|
||||
isNavigationAvailable: () => this.state.isOpen,
|
||||
getItems: () => {
|
||||
if (this.state.isOpen && this.menuRef.el) {
|
||||
return this.menuRef.el.querySelectorAll(
|
||||
":scope .o-navigable, :scope .o-dropdown"
|
||||
);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
// Using deepMerge allows to keep entries of both option.hotkeys
|
||||
...deepMerge(this.nesting.navigationOptions, this.props.navigationOptions),
|
||||
});
|
||||
|
||||
this.uiService = useService("ui");
|
||||
|
||||
const getPosition = () => this.position;
|
||||
const options = {
|
||||
animation: false,
|
||||
arrow: false,
|
||||
closeOnClickAway: (target) => this.popoverCloseOnClickAway(target),
|
||||
closeOnEscape: false, // Handled via navigation and prevents closing root of nested dropdown
|
||||
env: this.__owl__.childEnv,
|
||||
holdOnHover: this.props.holdOnHover,
|
||||
onClose: () => this.state.close(),
|
||||
onPositioned: (el, { direction }) => this.setTargetDirectionClass(direction),
|
||||
popoverClass: mergeClasses(
|
||||
"o-dropdown--menu dropdown-menu mx-0",
|
||||
{ "o-dropdown--menu-submenu": this.hasParent },
|
||||
this.props.menuClass
|
||||
),
|
||||
role: "menu",
|
||||
get position() {
|
||||
return getPosition();
|
||||
},
|
||||
ref: this.menuRef,
|
||||
setActiveElement: false,
|
||||
};
|
||||
if (this.isBottomSheet) {
|
||||
Object.assign(options, {
|
||||
useBottomSheet: true,
|
||||
class: mergeClasses("o-dropdown--menu dropdown-menu show", this.props.menuClass),
|
||||
});
|
||||
}
|
||||
this.popover = usePopover(DropdownPopover, options);
|
||||
|
||||
// As the popover is in another context we need to force
|
||||
// its re-rendering when the dropdown re-renders
|
||||
onRendered(() => (this.popoverRefresher ? this.popoverRefresher.token++ : null));
|
||||
|
||||
onMounted(() => this.onStateChanged(this.state));
|
||||
effect((state) => this.onStateChanged(state), [this.state]);
|
||||
|
||||
useEffect(
|
||||
(target) => this.setTargetElement(target),
|
||||
() => [this.target]
|
||||
);
|
||||
|
||||
onWillUpdateProps(({ disabled }) => {
|
||||
if (disabled) {
|
||||
this.closePopover();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isBottomSheet() {
|
||||
return utils.isSmall() && hasTouch() && this.props.bottomSheet;
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
get position() {
|
||||
return this.props.position || (this.hasParent ? "right-start" : "bottom-start");
|
||||
}
|
||||
|
||||
get hasParent() {
|
||||
return this.nesting.hasParent;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
get target() {
|
||||
const target = getFirstElementOfNode(this.__owl__.bdom);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
"Could not find a valid dropdown toggler, prefer a single html element and put any dynamic content inside of it."
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
handleClick(event) {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
if (this.state.isOpen && !this.hasParent) {
|
||||
this.state.close();
|
||||
} else {
|
||||
this.state.open();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter() {
|
||||
if (this.props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasParent || this.group.isOpen) {
|
||||
this.target.focus();
|
||||
this.state.open();
|
||||
}
|
||||
}
|
||||
|
||||
onStateChanged(state) {
|
||||
if (state.isOpen) {
|
||||
this.openPopover();
|
||||
} else {
|
||||
this.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
popoverCloseOnClickAway(target) {
|
||||
const rootNode = target.getRootNode();
|
||||
if (rootNode instanceof ShadowRoot) {
|
||||
target = rootNode.host;
|
||||
}
|
||||
return this.uiService.getActiveElementOf(target) === this.activeEl;
|
||||
}
|
||||
|
||||
setTargetElement(target) {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.ariaExpanded = false;
|
||||
const optionalClasses = [];
|
||||
const requiredClasses = [];
|
||||
optionalClasses.push("o-dropdown");
|
||||
|
||||
if (this.hasParent) {
|
||||
requiredClasses.push("o-dropdown--has-parent");
|
||||
}
|
||||
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
if (!["input", "textarea", "table", "thead", "tbody", "tr", "th", "td"].includes(tagName)) {
|
||||
optionalClasses.push("dropdown-toggle");
|
||||
if (this.hasParent) {
|
||||
optionalClasses.push("o-dropdown-item", "dropdown-item");
|
||||
requiredClasses.push("o-navigable");
|
||||
|
||||
if (!target.classList.contains("o-dropdown--no-caret")) {
|
||||
requiredClasses.push("o-dropdown-caret");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target.classList.add(...requiredClasses);
|
||||
if (!this.props.noClasses) {
|
||||
target.classList.add(...optionalClasses);
|
||||
}
|
||||
|
||||
this.defaultDirection = this.position.split("-")[0];
|
||||
this.setTargetDirectionClass(this.defaultDirection);
|
||||
|
||||
if (!this.props.manual) {
|
||||
target.addEventListener("click", this.handleClick.bind(this));
|
||||
target.addEventListener("mouseenter", this.handleMouseEnter.bind(this));
|
||||
|
||||
return () => {
|
||||
target.removeEventListener("click", this.handleClick.bind(this));
|
||||
target.removeEventListener("mouseenter", this.handleMouseEnter.bind(this));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setTargetDirectionClass(direction) {
|
||||
if (!this.target || this.props.noClasses) {
|
||||
return;
|
||||
}
|
||||
const directionClasses = {
|
||||
bottom: "dropdown",
|
||||
top: "dropup",
|
||||
left: "dropstart",
|
||||
right: "dropend",
|
||||
};
|
||||
this.target.classList.remove(...Object.values(directionClasses));
|
||||
this.target.classList.add(directionClasses[direction]);
|
||||
}
|
||||
|
||||
openPopover() {
|
||||
if (this.popover.isOpen || status(this) !== "mounted") {
|
||||
return;
|
||||
}
|
||||
if (!this.target || !this.target.isConnected) {
|
||||
this.state.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.popoverRefresher = reactive({ token: 0 });
|
||||
const props = {
|
||||
beforeOpen: () => this.props.beforeOpen?.(),
|
||||
onOpened: () => this.onOpened(),
|
||||
onClosed: () => this.onClosed(),
|
||||
refresher: this.popoverRefresher,
|
||||
items: this.props.items,
|
||||
slots: this.props.slots,
|
||||
};
|
||||
this.popover.open(this.target, props);
|
||||
}
|
||||
|
||||
closePopover() {
|
||||
this.popover.close();
|
||||
if (this.props.focusToggleOnClosed && !this.group.isInGroup) {
|
||||
this._focusedElBeforeOpen?.focus();
|
||||
this._focusedElBeforeOpen = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onOpened() {
|
||||
this._focusedElBeforeOpen = document.activeElement;
|
||||
this.activeEl = this.uiService.activeElement;
|
||||
this.navigation.registerHotkeys();
|
||||
this.navigation.update();
|
||||
this.props.onOpened?.();
|
||||
this.props.onStateChanged?.(true);
|
||||
|
||||
if (this.target) {
|
||||
this.target.ariaExpanded = true;
|
||||
this.target.classList.add("show");
|
||||
}
|
||||
|
||||
this.observer = new MutationObserver(() => this.navigation.update());
|
||||
this.observer.observe(this.menuRef.el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClosed() {
|
||||
this.navigation.unregisterHotkeys();
|
||||
this.navigation.update();
|
||||
this.props.onStateChanged?.(false);
|
||||
delete this.activeEl;
|
||||
|
||||
if (this.target) {
|
||||
this.target.ariaExpanded = false;
|
||||
this.target.classList.remove("show");
|
||||
this.setTargetDirectionClass(this.defaultDirection);
|
||||
}
|
||||
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user