@@ -62,7 +62,7 @@
-
+
ID:
@@ -92,7 +92,7 @@
Creation Date:
-
+
Latest Modification by:
@@ -100,7 +100,7 @@
Latest Modification Date:
-
+
diff --git a/addons/web/static/src/core/dialog/dialog.js b/addons/web/static/src/core/dialog/dialog.js
index 2e4a5a3d34c..a501adf19a8 100644
--- a/addons/web/static/src/core/dialog/dialog.js
+++ b/addons/web/static/src/core/dialog/dialog.js
@@ -2,17 +2,29 @@
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { useActiveElement } from "../ui/ui_service";
+import { useForwardRefToParent } from "@web/core/utils/hooks";
-const { Component, useRef, useChildSubEnv, useState } = owl;
+const { Component, useChildSubEnv, useState } = owl;
export class Dialog extends Component {
setup() {
- this.modalRef = useRef("modal");
- useActiveElement("modal");
+ this.modalRef = useForwardRefToParent("modalRef");
+ useActiveElement("modalRef");
this.data = useState(this.env.dialogData);
useHotkey("escape", () => {
this.data.close();
});
- useChildSubEnv({ inDialog: true });
+ this.id = `dialog_${this.data.id}`;
+ useChildSubEnv({ inDialog: true, dialogId: this.id });
+
+ owl.onWillDestroy(() => {
+ if (this.env.isSmall) {
+ this.data.scrollToOrigin();
+ }
+ });
+ }
+
+ get isFullscreen() {
+ return this.props.fullscreen || this.env.isSmall;
}
}
Dialog.template = "web.Dialog";
@@ -24,6 +36,7 @@ Dialog.props = {
size: { type: String, optional: true, validate: (s) => ["sm", "md", "lg", "xl"].includes(s) },
technical: { type: Boolean, optional: true },
title: { type: String, optional: true },
+ modalRef: { type: Function, optional: true },
slots: {
type: Object,
shape: {
diff --git a/addons/web/static/src/core/dialog/dialog.xml b/addons/web/static/src/core/dialog/dialog.xml
index 5cacc2f9d94..cb900b1d1d9 100644
--- a/addons/web/static/src/core/dialog/dialog.xml
+++ b/addons/web/static/src/core/dialog/dialog.xml
@@ -2,30 +2,37 @@
-
+
diff --git a/addons/web/static/src/core/dialog/dialog_service.js b/addons/web/static/src/core/dialog/dialog_service.js
index 18be68c8aba..34dc5f3bd57 100644
--- a/addons/web/static/src/core/dialog/dialog_service.js
+++ b/addons/web/static/src/core/dialog/dialog_service.js
@@ -3,7 +3,7 @@
import { registry } from "../registry";
import { DialogContainer } from "./dialog_container";
-const { reactive } = owl;
+const { markRaw, reactive } = owl;
/**
* @typedef {{
@@ -22,7 +22,7 @@ const { reactive } = owl;
export const dialogService = {
/** @returns {DialogServiceInterface} */
- start() {
+ start(env) {
const dialogs = reactive({});
let dialogId = 0;
@@ -51,9 +51,21 @@ export const dialogService = {
const dialog = {
id,
class: dialogClass,
- props: { ...props, close },
- dialogData: { isActive: true, close },
+ props: markRaw({ ...props, close }),
+ dialogData: {
+ isActive: true,
+ close,
+ id,
+ },
};
+ if (env.isSmall) {
+ const scrollOrigin = { top: window.scrollY, left: window.scrollX };
+ dialog.dialogData.scrollToOrigin = () => {
+ if (!Object.keys(dialogs).length) {
+ window.scrollTo(scrollOrigin);
+ }
+ };
+ }
dialogs[id] = dialog;
return close;
diff --git a/addons/web/static/src/core/domain.js b/addons/web/static/src/core/domain.js
index af1e0f5c3a4..58bfb2326e1 100644
--- a/addons/web/static/src/core/domain.js
+++ b/addons/web/static/src/core/domain.js
@@ -1,5 +1,6 @@
/** @odoo-module **/
+import { shallowEqual } from "@web/core/utils/arrays";
import { evaluate, formatAST, parseExpr } from "./py_js/py";
import { toPyValue } from "./py_js/py_utils";
@@ -187,14 +188,25 @@ function matchCondition(record, condition) {
return condition;
}
const [field, operator, value] = condition;
+
+ if (typeof field === "string") {
+ const names = field.split(".");
+ if (names.length >= 2) {
+ return matchCondition(record[names[0]], [names.slice(1).join("."), operator, value]);
+ }
+ }
+
const fieldValue = typeof field === "number" ? field : record[field];
switch (operator) {
case "=":
case "==":
+ if (Array.isArray(fieldValue) && Array.isArray(value)) {
+ return shallowEqual(fieldValue, value);
+ }
return fieldValue === value;
case "!=":
case "<>":
- return fieldValue !== value;
+ return JSON.stringify(fieldValue) !== JSON.stringify(value);
case "<":
return fieldValue < value;
case "<=":
diff --git a/addons/web/static/src/core/domain_selector/domain_selector.js b/addons/web/static/src/core/domain_selector/domain_selector.js
new file mode 100644
index 00000000000..15557df18b0
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector.js
@@ -0,0 +1,207 @@
+/** @odoo-module **/
+
+import { Domain } from "@web/core/domain";
+import { DomainSelectorRootNode } from "./domain_selector_root_node";
+
+const { Component } = owl;
+
+export class DomainSelector extends Component {
+ setup() {
+ this.nextNodeId = 0;
+ }
+
+ buildTree() {
+ try {
+ const domain = new Domain(this.props.value);
+ const ctx = {
+ parent: null,
+ index: 0,
+ domain: domain.toList(),
+ get currentElement() {
+ return ctx.domain[ctx.index];
+ },
+ next() {
+ ctx.index++;
+ },
+ getFullDomain() {
+ return rootNode.computeDomain().toString();
+ },
+ };
+
+ const rootNode = this.makeRootNode(ctx);
+ ctx.parent = rootNode;
+ this.traverseNode(ctx);
+
+ return ctx.parent;
+ } catch (_e) {
+ // WOWL TODO: rethrow error when not the expected type
+ return false;
+ }
+ }
+
+ traverseNode(ctx) {
+ if (ctx.index < ctx.domain.length) {
+ if (typeof ctx.currentElement === "string" && ["&", "|"].includes(ctx.currentElement)) {
+ this.traverseBranchNode(ctx);
+ } else {
+ this.traverseLeafNode(ctx);
+ }
+ }
+ }
+ traverseBranchNode(ctx) {
+ if (ctx.parent.type !== "branch" || ctx.parent.operator !== ctx.currentElement) {
+ const node = this.makeBranchNode(ctx, ctx.currentElement, []);
+ ctx.parent.operands.push(node);
+ ctx = Object.assign(Object.create(ctx), { parent: node });
+ }
+ ctx.next();
+ this.traverseNode(ctx);
+ ctx.next();
+ this.traverseNode(ctx);
+ }
+ traverseLeafNode(ctx) {
+ const condition = ctx.currentElement;
+ const [leftOperand, operator, rightOperand] = condition;
+ const node = this.makeLeafNode(ctx, operator, [leftOperand, rightOperand]);
+ ctx.parent.operands.push(node);
+ }
+
+ makeBranchNode(ctx, operator, operands) {
+ const updateDomain = () => this.props.update(ctx.getFullDomain());
+ const makeFakeNode = this.makeFakeNode.bind(this);
+
+ return {
+ type: "branch",
+ id: this.nextNodeId++,
+ operator,
+ operands,
+ computeDomain() {
+ return Domain.combine(
+ this.operands.map((operand) => operand.computeDomain()),
+ this.operator === "&" ? "AND" : "OR"
+ );
+ },
+ update(operator) {
+ this.operator = operator;
+ updateDomain();
+ },
+ insert(newNodeType) {
+ const newNode = makeFakeNode(ctx, newNodeType);
+ const operands = ctx.parent.operands;
+ operands.splice(operands.indexOf(this) + 1, 0, newNode);
+ updateDomain();
+ },
+ delete() {
+ const operands = ctx.parent.operands;
+ operands.splice(operands.indexOf(this), 1);
+ updateDomain();
+ },
+ };
+ }
+ makeLeafNode(ctx, operator, operands) {
+ const updateDomain = () => this.props.update(ctx.getFullDomain());
+ const makeFakeNode = this.makeFakeNode.bind(this);
+
+ return {
+ type: "leaf",
+ id: this.nextNodeId++,
+ operator,
+ operands,
+ computeDomain() {
+ return new Domain([[this.operands[0], this.operator, this.operands[1]]]);
+ },
+ update(changes) {
+ if ("fieldName" in changes) {
+ this.operands[0] = changes.fieldName;
+ }
+ if ("operator" in changes) {
+ this.operator = changes.operator;
+ }
+ if ("value" in changes) {
+ this.operands[1] = changes.value;
+ }
+ updateDomain();
+ },
+ insert(newNodeType) {
+ const newNode = makeFakeNode(ctx, newNodeType);
+ const operands = ctx.parent.operands;
+ operands.splice(operands.indexOf(this) + 1, 0, newNode);
+ updateDomain();
+ },
+ delete() {
+ const operands = ctx.parent.operands;
+ operands.splice(operands.indexOf(this), 1);
+ updateDomain();
+ },
+ };
+ }
+ makeRootNode(ctx) {
+ const updateDomain = (...args) => this.props.update(...args);
+ const makeFakeNode = this.makeFakeNode.bind(this);
+
+ return {
+ type: "root",
+ id: this.nextNodeId++,
+ operator: "&",
+ operands: [],
+ computeDomain() {
+ return Domain.combine(
+ this.operands.map((operand) => operand.computeDomain()),
+ "AND"
+ );
+ },
+ update(newValue, fromDebug) {
+ if (typeof newValue === "string") {
+ updateDomain(newValue, fromDebug);
+ } else if (this.operands.length) {
+ this.operands[0].update(newValue);
+ }
+ },
+ insert(newNodeType) {
+ const newNode = makeFakeNode(ctx, newNodeType);
+ if (ctx.parent) {
+ const operands = ctx.parent.operands;
+ operands.splice(operands.indexOf(this) + 1, 0, newNode);
+ } else {
+ this.operands.push(newNode);
+ }
+ updateDomain(ctx.getFullDomain());
+ },
+ delete() {},
+ };
+ }
+
+ makeFakeNode(ctx, type) {
+ const [field, op, value] = this.props.defaultLeafValue;
+ if (type === "branch") {
+ return this.makeBranchNode(ctx, ctx.parent.operator === "&" ? "|" : "&", [
+ this.makeLeafNode(ctx, op, [field, value]),
+ this.makeLeafNode(ctx, op, [field, value]),
+ ]);
+ } else {
+ return this.makeLeafNode(ctx, op, [field, value]);
+ }
+ }
+}
+
+Object.assign(DomainSelector, {
+ template: "web._DomainSelector",
+ components: {
+ DomainSelectorRootNode,
+ },
+ props: {
+ className: { type: String, optional: true },
+ resModel: String,
+ value: String,
+ readonly: { type: Boolean, optional: true },
+ update: { type: Function, optional: true },
+ isDebugMode: { type: Boolean, optional: true },
+ defaultLeafValue: { type: Array, optional: true },
+ },
+ defaultProps: {
+ readonly: true,
+ update: () => {},
+ isDebugMode: false,
+ defaultLeafValue: ["id", "=", 1],
+ },
+});
diff --git a/addons/web/static/src/core/domain_selector/domain_selector.xml b/addons/web/static/src/core/domain_selector/domain_selector.xml
new file mode 100644
index 00000000000..4b0fe8f42a6
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+ This domain is not supported.
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_node.js b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.js
new file mode 100644
index 00000000000..3ec2351b66c
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.js
@@ -0,0 +1,30 @@
+/** @odoo-module **/
+
+import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator";
+import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
+import { DomainSelectorLeafNode } from "./domain_selector_leaf_node";
+
+const { Component, useRef } = owl;
+
+export class DomainSelectorBranchNode extends Component {
+ setup() {
+ this.root = useRef("root");
+ }
+ onHoverDeleteNodeBtn(hovering) {
+ this.root.el.classList.toggle("o_hover_btns", hovering);
+ }
+ onHoverInsertLeafNodeBtn(hovering) {
+ this.root.el.classList.toggle("o_hover_add_node", hovering);
+ }
+ onHoverInsertBranchNodeBtn(hovering) {
+ this.root.el.classList.toggle("o_hover_add_node", hovering);
+ this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
+ }
+}
+DomainSelectorBranchNode.template = "web.DomainSelectorBranchNode";
+DomainSelectorBranchNode.components = {
+ DomainSelectorBranchNode,
+ DomainSelectorBranchOperator,
+ DomainSelectorControlPanel,
+ DomainSelectorLeafNode,
+};
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_node.xml b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.xml
new file mode 100644
index 00000000000..359ccb224c7
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_node.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+ of:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.js b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.js
new file mode 100644
index 00000000000..539ed130048
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.js
@@ -0,0 +1,20 @@
+/** @odoo-module **/
+import { Dropdown } from "@web/core/dropdown/dropdown";
+import { DropdownItem } from "@web/core/dropdown/dropdown_item";
+
+const { Component } = owl;
+
+export class DomainSelectorBranchOperator extends Component {
+ onOperatorSelected(operator) {
+ this.props.node.update(operator);
+ }
+}
+DomainSelectorBranchOperator.components = {
+ Dropdown,
+ DropdownItem,
+};
+DomainSelectorBranchOperator.template = "web.DomainSelectorBranchOperator";
+DomainSelectorBranchOperator.props = {
+ node: Object,
+ readonly: Boolean,
+};
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.xml b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.xml
new file mode 100644
index 00000000000..2d2a31ab6e6
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_branch_operator.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+ All
+ Any
+ None
+
+ All
+ Any
+
+
+
+
+
+ ALL
+ ANY
+ NONE
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_control_panel.js b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.js
new file mode 100644
index 00000000000..501d08abc79
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.js
@@ -0,0 +1,39 @@
+/** @odoo-module **/
+
+const { Component, toRaw } = owl;
+
+export class DomainSelectorControlPanel extends Component {
+ deleteNode() {
+ this.props.node.delete();
+ }
+
+ insertNode(newNodeType) {
+ toRaw(this.props.node).insert(newNodeType); // FIXME WOWL reactivity
+ }
+
+ onEnterDeleteNodeBtn() {
+ this.props.onHoverDeleteNodeBtn(true);
+ }
+ onLeaveDeleteNodeBtn() {
+ this.props.onHoverDeleteNodeBtn(false);
+ }
+ onEnterInsertLeafNodeBtn() {
+ this.props.onHoverInsertLeafNodeBtn(true);
+ }
+ onLeaveInsertLeafNodeBtn() {
+ this.props.onHoverInsertLeafNodeBtn(false);
+ }
+ onEnterInsertBranchNodeBtn() {
+ this.props.onHoverInsertBranchNodeBtn(true);
+ }
+ onLeaveInsertBranchNodeBtn() {
+ this.props.onHoverInsertBranchNodeBtn(false);
+ }
+}
+DomainSelectorControlPanel.template = "web.DomainSelectorControlPanel";
+DomainSelectorControlPanel.props = {
+ node: Object,
+ onHoverDeleteNodeBtn: Function,
+ onHoverInsertLeafNodeBtn: Function,
+ onHoverInsertBranchNodeBtn: Function,
+};
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_control_panel.xml b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.xml
new file mode 100644
index 00000000000..c60a708ce69
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_control_panel.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.js b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.js
new file mode 100644
index 00000000000..2a1a9d3bf9b
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.js
@@ -0,0 +1,101 @@
+/** @odoo-module **/
+
+import { useModelField } from "@web/core/model_field_selector/model_field_hook";
+import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector";
+import { registry } from "@web/core/registry";
+import { DomainSelectorControlPanel } from "./domain_selector_control_panel";
+
+const { Component, onWillStart, onWillUpdateProps, useRef } = owl;
+
+export class DomainSelectorLeafNode extends Component {
+ setup() {
+ this.root = useRef("root");
+ this.modelField = useModelField();
+ this.fieldInfo = {
+ type: "integer",
+ string: "ID",
+ };
+ onWillStart(async () => {
+ this.fieldInfo = await this.loadField(this.props.resModel, this.props.node.operands[0]);
+ });
+ onWillUpdateProps(async (nextProps) => {
+ this.fieldInfo = await this.loadField(nextProps.resModel, nextProps.node.operands[0]);
+ });
+ }
+
+ get displayedOperator() {
+ const op = this.getOperatorInfo(this.props.node.operator);
+ return op ? op.label : "?";
+ }
+ get isValueHidden() {
+ return this.getOperatorInfo(this.props.node.operator).hideValue;
+ }
+
+ async loadField(resModel, fieldName) {
+ const chain = await this.modelField.loadChain(resModel, fieldName);
+ if (!chain[chain.length - 1].field && chain.length > 1) {
+ return chain[chain.length - 2].field;
+ }
+ return (
+ chain[chain.length - 1].field || {
+ type: "integer",
+ string: "ID",
+ }
+ );
+ }
+
+ getFieldComponent(type) {
+ return registry.category("domain_selector/fields").get(type, null);
+ }
+ getOperatorInfo(operator) {
+ return this.getFieldComponent(this.fieldInfo.type)
+ .getOperators()
+ .find((op) =>
+ op.matches({
+ field: this.fieldInfo,
+ operator,
+ value: this.props.node.operands[1],
+ })
+ );
+ }
+
+ async onFieldChange(fieldName) {
+ const changes = { fieldName };
+ const fieldInfo = await this.loadField(this.props.resModel, fieldName);
+ const component = this.getFieldComponent(fieldInfo.type);
+ Object.assign(changes, component.onDidTypeChange(fieldInfo));
+ this.props.node.update(changes);
+ }
+ onOperatorChange(ev) {
+ const component = this.getFieldComponent(this.fieldInfo.type);
+ const operatorInfo = component.getOperators()[parseInt(ev.target.value, 10)];
+ const changes = { operator: operatorInfo.value };
+ Object.assign(
+ changes,
+ operatorInfo.onDidChange(
+ this.getOperatorInfo(this.props.node.operator),
+ component.onDidTypeChange
+ )
+ );
+ this.props.node.update(changes);
+ }
+
+ onHoverDeleteNodeBtn(hovering) {
+ this.root.el.classList.toggle("o_hover_btns", hovering);
+ }
+ onHoverInsertLeafNodeBtn(hovering) {
+ this.root.el.classList.toggle("o_hover_add_node", hovering);
+ }
+ onHoverInsertBranchNodeBtn(hovering) {
+ this.root.el.classList.toggle("o_hover_add_node", hovering);
+ this.root.el.classList.toggle("o_hover_add_inset_node", hovering);
+ }
+}
+
+Object.assign(DomainSelectorLeafNode, {
+ template: "web.DomainSelectorLeafNode",
+ components: {
+ DomainSelectorControlPanel,
+ ModelFieldSelector,
+ },
+});
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.xml b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.xml
new file mode 100644
index 00000000000..19bd49e603a
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_leaf_node.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+ " "
+
+
+
+
+
+
+ is
+
+ not
+
+ set
+
+
+
+
+ " "
+ or
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_operators.js b/addons/web/static/src/core/domain_selector/domain_selector_operators.js
new file mode 100644
index 00000000000..54e35368c65
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_operators.js
@@ -0,0 +1,166 @@
+/** @odoo-module **/
+
+import { _lt } from "@web/core/l10n/translation";
+import { registry } from "@web/core/registry";
+
+function onDidChange(action) {
+ return function (oldOperator, fieldChange) {
+ if (this.category !== oldOperator.category) {
+ return action(fieldChange);
+ }
+ return {};
+ };
+}
+
+function matchValue() {
+ return function ({ operator }) {
+ return operator === this.value;
+ };
+}
+
+const dso = registry.category("domain_selector/operator");
+dso.add("=", {
+ category: "equality",
+ label: "=",
+ value: "=",
+ onDidChange: onDidChange((fieldChange) => fieldChange()),
+ matches({ operator, value }) {
+ return operator === this.value && typeof value !== "boolean";
+ },
+});
+dso.add("!=", {
+ category: "equality",
+ label: _lt("is not ="),
+ value: "!=",
+ onDidChange: onDidChange((fieldChange) => fieldChange()),
+ matches({ operator, value }) {
+ return operator === this.value && typeof value !== "boolean";
+ },
+});
+dso.add(">", {
+ category: "comparison",
+ label: ">",
+ value: ">",
+ onDidChange: onDidChange((fieldChange) => fieldChange()),
+ matches: matchValue(),
+});
+dso.add(">=", {
+ category: "comparison",
+ label: ">=",
+ value: ">=",
+ onDidChange: onDidChange((fieldChange) => fieldChange()),
+ matches: matchValue(),
+});
+dso.add("<", {
+ category: "comparison",
+ label: "<",
+ value: "<",
+ onDidChange: onDidChange((fieldChange) => fieldChange()),
+ matches: matchValue(),
+});
+dso.add("<=", {
+ category: "comparison",
+ label: "<=",
+ value: "<=",
+ onDidChange: onDidChange((fieldChange) => fieldChange()),
+ matches: matchValue(),
+});
+dso.add("ilike", {
+ category: "like",
+ label: _lt("contains"),
+ value: "ilike",
+ onDidChange: onDidChange(() => ({ value: "" })),
+ matches: matchValue(),
+});
+dso.add("not ilike", {
+ category: "like",
+ label: _lt("does not contain"),
+ value: "not ilike",
+ onDidChange: onDidChange(() => ({ value: "" })),
+ matches: matchValue(),
+});
+dso.add("like", {
+ category: "like",
+ label: _lt("like"),
+ value: "like",
+ onDidChange: onDidChange(() => ({ value: "" })),
+});
+dso.add("not like", {
+ category: "like",
+ label: _lt("not like"),
+ value: "not like",
+ onDidChange: onDidChange(() => ({ value: "" })),
+ matches: matchValue(),
+});
+dso.add("=like", {
+ category: "like",
+ label: _lt("=like"),
+ value: "=like",
+ onDidChange: onDidChange(() => ({ value: "" })),
+ matches: matchValue(),
+});
+dso.add("=ilike", {
+ category: "like",
+ label: _lt("=ilike"),
+ value: "=ilike",
+ onDidChange: onDidChange(() => ({ value: "" })),
+ matches: matchValue(),
+});
+dso.add("child_of", {
+ category: "relation",
+ label: _lt("child of"),
+ value: "child_of",
+ onDidChange: onDidChange(() => ({ value: 1 })),
+ matches: matchValue(),
+});
+dso.add("parent_of", {
+ category: "relation",
+ label: _lt("parent of"),
+ value: "parent_of",
+ onDidChange: onDidChange(() => ({ value: 1 })),
+ matches: matchValue(),
+});
+dso.add("in", {
+ category: "in",
+ label: _lt("in"),
+ value: "in",
+ onDidChange: onDidChange(() => ({ value: [] })),
+ matches: matchValue(),
+});
+dso.add("not in", {
+ category: "in",
+ label: _lt("not in"),
+ value: "not in",
+ onDidChange: onDidChange(() => ({ value: [] })),
+ matches: matchValue(),
+});
+dso.add("set", {
+ category: "set",
+ label: _lt("is set"),
+ value: "set",
+ hideValue: true,
+ onDidChange() {
+ return {
+ operator: "!=",
+ value: false,
+ };
+ },
+ matches({ operator, value }) {
+ return operator === "!=" && typeof value === "boolean";
+ },
+});
+dso.add("not set", {
+ category: "set",
+ label: _lt("is not set"),
+ value: "not set",
+ hideValue: true,
+ onDidChange() {
+ return {
+ operator: "=",
+ value: false,
+ };
+ },
+ matches({ operator, value }) {
+ return operator === "=" && typeof value === "boolean";
+ },
+});
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_root_node.js b/addons/web/static/src/core/domain_selector/domain_selector_root_node.js
new file mode 100644
index 00000000000..0d5ecee2251
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_root_node.js
@@ -0,0 +1,33 @@
+/** @odoo-module **/
+
+import { DomainSelectorBranchNode } from "./domain_selector_branch_node";
+import { DomainSelectorBranchOperator } from "./domain_selector_branch_operator";
+import { DomainSelectorLeafNode } from "./domain_selector_leaf_node";
+
+const { Component } = owl;
+
+export class DomainSelectorRootNode extends Component {
+ get hasNode() {
+ return this.props.node.operands.length > 0;
+ }
+ get node() {
+ return this.props.node.operands[0];
+ }
+
+ insertNode(newNodeType) {
+ this.props.node.insert(newNodeType);
+ }
+
+ onOperatorSelected(ev) {
+ this.props.node.update(ev.detail.payload.operator);
+ }
+ onChange(ev) {
+ this.props.node.update(ev.target.value, true);
+ }
+}
+DomainSelectorRootNode.template = "web.DomainSelectorRootNode";
+DomainSelectorRootNode.components = {
+ DomainSelectorBranchNode,
+ DomainSelectorBranchOperator,
+ DomainSelectorLeafNode,
+};
diff --git a/addons/web/static/src/core/domain_selector/domain_selector_root_node.xml b/addons/web/static/src/core/domain_selector/domain_selector_root_node.xml
new file mode 100644
index 00000000000..a42f52d1527
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/domain_selector_root_node.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ Match All records
+
+
+ Add filter
+
+
+
+
+
+ Match records with the following rule:
+
+
+
+
+
+ Match records with
+
+ of the following rules:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ # Code editor
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.js
new file mode 100644
index 00000000000..7661d2e478c
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.js
@@ -0,0 +1,47 @@
+/** @odoo-module **/
+
+import { _lt } from "@web/core/l10n/translation";
+import { registry } from "@web/core/registry";
+
+const { Component } = owl;
+
+const dsf = registry.category("domain_selector/fields");
+
+export class DomainSelectorBooleanField extends Component {
+ onChange(ev) {
+ this.props.update({
+ value: ev.target.value === "1",
+ });
+ }
+}
+Object.assign(DomainSelectorBooleanField, {
+ template: "web.DomainSelectorBooleanField",
+
+ onDidTypeChange() {
+ return { value: true };
+ },
+ getOperators() {
+ return [
+ {
+ category: "equality",
+ label: _lt("is"),
+ value: "=",
+ onDidChange() {},
+ matches({ operator }) {
+ return operator === this.value;
+ },
+ },
+ {
+ category: "equality",
+ label: _lt("is not"),
+ value: "!=",
+ onDidChange() {},
+ matches({ operator }) {
+ return operator === this.value;
+ },
+ },
+ ];
+ },
+});
+
+dsf.add("boolean", DomainSelectorBooleanField);
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.xml
new file mode 100644
index 00000000000..7d2d8d0803e
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_boolean_field.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ set (true)
+ not set (false)
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js
new file mode 100644
index 00000000000..cd3b62a497e
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.js
@@ -0,0 +1,47 @@
+/** @odoo-module **/
+
+import { DatePicker, DateTimePicker } from "@web/core/datepicker/datepicker";
+import { serializeDate, serializeDateTime } from "@web/core/l10n/dates";
+import { registry } from "@web/core/registry";
+
+const { Component } = owl;
+
+const parsers = registry.category("parsers");
+const dsf = registry.category("domain_selector/fields");
+const dso = registry.category("domain_selector/operator");
+
+export class DomainSelectorDateTimeField extends Component {
+ get component() {
+ const { DatePicker, DateTimePicker } = this.constructor.components;
+ return this.props.field.type === "date" ? DatePicker : DateTimePicker;
+ }
+ get parser() {
+ return parsers.get(this.props.field.type);
+ }
+ get parsedValue() {
+ return this.props.value ? this.parser(this.props.value) : luxon.DateTime.local();
+ }
+
+ onChange(value) {
+ const serialize = this.props.field.type === "date" ? serializeDate : serializeDateTime;
+ this.props.update({ value: serialize(value) });
+ }
+}
+Object.assign(DomainSelectorDateTimeField, {
+ template: "web.DomainSelectorDateTimeField",
+ components: {
+ DatePicker,
+ DateTimePicker,
+ },
+
+ onDidTypeChange(field) {
+ const serialize = field.type === "date" ? serializeDate : serializeDateTime;
+ return { value: serialize(luxon.DateTime.local()) };
+ },
+ getOperators() {
+ return ["=", "!=", ">", "<", ">=", "<=", "set", "not set"].map((key) => dso.get(key));
+ },
+});
+
+dsf.add("date", DomainSelectorDateTimeField);
+dsf.add("datetime", DomainSelectorDateTimeField);
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml
new file mode 100644
index 00000000000..a1f20a38d20
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_datetime_field.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.js
new file mode 100644
index 00000000000..a5dbdaf9be3
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.js
@@ -0,0 +1,18 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+
+const { Component } = owl;
+const parsers = registry.category("parsers");
+
+export class DomainSelectorFieldInput extends Component {
+ parseValue(value) {
+ const parser = parsers.get(this.props.field.type, (value) => value);
+ return parser(value);
+ }
+
+ onChange(ev) {
+ this.props.update({ value: this.parseValue(ev.target.value) });
+ }
+}
+DomainSelectorFieldInput.template = "web.DomainSelectorFieldInput";
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.xml
new file mode 100644
index 00000000000..9f5c3aed451
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.js
new file mode 100644
index 00000000000..5d946388906
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.js
@@ -0,0 +1,25 @@
+/** @odoo-module **/
+
+const { Component, useRef } = owl;
+
+export class DomainSelectorFieldInputWithTags extends Component {
+ setup() {
+ this.inputRef = useRef("input");
+ }
+
+ removeTag(tagIndex) {
+ const value = [...this.props.value];
+ value.splice(tagIndex, 1);
+ this.props.update({ value });
+ }
+ addTag(value) {
+ this.props.update({ value: this.props.value.concat(value) });
+ }
+
+ onBtnClick() {
+ const value = this.inputRef.el.value;
+ this.inputRef.el.value = "";
+ this.addTag(value);
+ }
+}
+DomainSelectorFieldInputWithTags.template = "web.DomainSelectorFieldInputWithTags";
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.xml
new file mode 100644
index 00000000000..be682f47125
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_field_input_with_tags.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.js
new file mode 100644
index 00000000000..74209e1b4a2
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.js
@@ -0,0 +1,39 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { DomainSelectorFieldInput } from "./domain_selector_field_input";
+
+const { Component } = owl;
+
+const dsf = registry.category("domain_selector/fields");
+const dso = registry.category("domain_selector/operator");
+
+export class DomainSelectorNumberField extends Component {}
+Object.assign(DomainSelectorNumberField, {
+ template: "web.DomainSelectorNumberField",
+ components: {
+ DomainSelectorFieldInput,
+ },
+
+ onDidTypeChange() {
+ return { value: 0 };
+ },
+ getOperators() {
+ return [
+ "=",
+ "!=",
+ ">",
+ "<",
+ ">=",
+ "<=",
+ "ilike",
+ "not ilike",
+ "set",
+ "not set",
+ ].map((key) => dso.get(key));
+ },
+});
+
+dsf.add("integer", DomainSelectorNumberField);
+dsf.add("float", DomainSelectorNumberField);
+dsf.add("monetary", DomainSelectorNumberField);
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.xml
new file mode 100644
index 00000000000..d6b94178301
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_number_field.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.js
new file mode 100644
index 00000000000..f6a5bb945d5
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.js
@@ -0,0 +1,29 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { DomainSelectorFieldInput } from "./domain_selector_field_input";
+
+const { Component } = owl;
+
+const dso = registry.category("domain_selector/operator");
+
+export class DomainSelectorRelationField extends Component {}
+Object.assign(DomainSelectorRelationField, {
+ template: "web.DomainSelectorRelationField",
+ components: {
+ DomainSelectorFieldInput,
+ },
+
+ onDidTypeChange() {
+ return { value: "0" };
+ },
+ getOperators() {
+ return ["=", "!=", "ilike", "not ilike", "set", "not set"].map((key) => dso.get(key));
+ },
+});
+
+registry
+ .category("domain_selector/fields")
+ .add("one2many", DomainSelectorRelationField)
+ .add("many2one", DomainSelectorRelationField)
+ .add("many2many", DomainSelectorRelationField);
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.xml
new file mode 100644
index 00000000000..7d7b0728582
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_relation_field.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.js
new file mode 100644
index 00000000000..27821b335d4
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.js
@@ -0,0 +1,26 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+
+const { Component } = owl;
+
+const dsf = registry.category("domain_selector/fields");
+const dso = registry.category("domain_selector/operator");
+
+export class DomainSelectorSelectionField extends Component {
+ onChange(ev) {
+ this.props.update({ value: ev.target.value });
+ }
+}
+Object.assign(DomainSelectorSelectionField, {
+ template: "web.DomainSelectorSelectionField",
+
+ onDidTypeChange(field) {
+ return { value: field.selection[0][0] };
+ },
+ getOperators() {
+ return ["=", "!=", "set", "not set"].map((key) => dso.get(key));
+ },
+});
+
+dsf.add("selection", DomainSelectorSelectionField);
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.xml
new file mode 100644
index 00000000000..a779098d0b2
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_selection_field.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.js b/addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.js
new file mode 100644
index 00000000000..03d76a21877
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.js
@@ -0,0 +1,32 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { DomainSelectorFieldInput } from "./domain_selector_field_input";
+import { DomainSelectorFieldInputWithTags } from "./domain_selector_field_input_with_tags";
+
+const { Component } = owl;
+
+const dsf = registry.category("domain_selector/fields");
+const dso = registry.category("domain_selector/operator");
+
+export class DomainSelectorTextField extends Component {}
+Object.assign(DomainSelectorTextField, {
+ template: "web.DomainSelectorTextField",
+ components: {
+ DomainSelectorFieldInput,
+ DomainSelectorFieldInputWithTags,
+ },
+
+ onDidTypeChange() {
+ return { value: "" };
+ },
+ getOperators() {
+ return ["=", "!=", "ilike", "not ilike", "set", "not set", "in", "not in"].map((key) =>
+ dso.get(key)
+ );
+ },
+});
+
+dsf.add("char", DomainSelectorTextField);
+dsf.add("html", DomainSelectorTextField);
+dsf.add("text", DomainSelectorTextField);
diff --git a/addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.xml b/addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.xml
new file mode 100644
index 00000000000..07399ec1ef0
--- /dev/null
+++ b/addons/web/static/src/core/domain_selector/fields/domain_selector_text_field.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/dropdown/dropdown.scss b/addons/web/static/src/core/dropdown/dropdown.scss
index d75e89f2e94..328aff055ac 100644
--- a/addons/web/static/src/core/dropdown/dropdown.scss
+++ b/addons/web/static/src/core/dropdown/dropdown.scss
@@ -12,7 +12,9 @@
.dropdown-item:not(.disabled):not(:disabled) {
// Needed 'cause
generate html tags that for browsers are
// normally not clickable.
- cursor: pointer;
+ &,label {
+ cursor: pointer;
+ }
}
button.dropdown-toggle {
diff --git a/addons/web/static/src/core/dropdown/dropdown.xml b/addons/web/static/src/core/dropdown/dropdown.xml
index 7d8021175a8..b442305bae8 100644
--- a/addons/web/static/src/core/dropdown/dropdown.xml
+++ b/addons/web/static/src/core/dropdown/dropdown.xml
@@ -19,7 +19,7 @@
{{props.togglerClass || ''}}
{{parentDropdown ? 'dropdown-item' : ''}}
"
- t-on-click="onTogglerClick"
+ t-on-click.stop="onTogglerClick"
t-on-mouseenter="onTogglerMouseEnter"
t-att-title="props.title"
t-att-data-hotkey="props.hotkey"
diff --git a/addons/web/static/src/core/dropdown/dropdown_item.xml b/addons/web/static/src/core/dropdown/dropdown_item.xml
index 6dc718b8e0c..38d7c332393 100644
--- a/addons/web/static/src/core/dropdown/dropdown_item.xml
+++ b/addons/web/static/src/core/dropdown/dropdown_item.xml
@@ -7,7 +7,7 @@
t-att-href="props.href"
class="dropdown-item"
t-att-class="props.class"
- t-on-click="onClick"
+ t-on-click.stop="onClick"
t-att-title="props.title"
t-att-data-hotkey="props.hotkey"
t-att="dataAttributes"
diff --git a/addons/web/static/src/core/errors/error_dialogs.js b/addons/web/static/src/core/errors/error_dialogs.js
index c8a3f1d569f..c5dcffd6fef 100644
--- a/addons/web/static/src/core/errors/error_dialogs.js
+++ b/addons/web/static/src/core/errors/error_dialogs.js
@@ -111,7 +111,7 @@ export class WarningDialog extends Component {
if (this.props.exceptionName && odooExceptionTitleMap.has(this.props.exceptionName)) {
return odooExceptionTitleMap.get(this.props.exceptionName).toString();
}
- return this.env._t("Odoo Warning");
+ return this.props.title || this.env._t("Odoo Warning");
}
}
WarningDialog.template = "web.WarningDialog";
diff --git a/addons/web/static/src/core/file_input/file_input.js b/addons/web/static/src/core/file_input/file_input.js
index 70cb4d29489..522968920a0 100644
--- a/addons/web/static/src/core/file_input/file_input.js
+++ b/addons/web/static/src/core/file_input/file_input.js
@@ -2,7 +2,7 @@
import { useService } from "@web/core/utils/hooks";
-const { Component, useRef } = owl;
+const { Component, onMounted, useRef } = owl;
/**
* Custom file input
@@ -13,19 +13,25 @@ const { Component, useRef } = owl;
* @extends Component
*
* Props:
- * @param {string} [props.accepted_file_extensions='*'] Comma-separated
+ * @param {string} [props.acceptedFileExtensions='*'] Comma-separated
* list of authorized file extensions (default to all).
- * @param {string} [props.action='/web/binary/upload'] Route called when
+ * @param {string} [props.route='/web/binary/upload'] Route called when
* a file is uploaded in the input.
- * @param {string} [props.id]
- * @param {string} [props.model]
- * @param {string} [props.multi_upload=false] Whether the input should allow
+ * @param {string} [props.resId]
+ * @param {string} [props.resModel]
+ * @param {string} [props.multiUpload=false] Whether the input should allow
* to upload multiple files at once.
*/
export class FileInput extends Component {
setup() {
this.http = useService("http");
this.fileInputRef = useRef("file-input");
+
+ onMounted(() => {
+ if (this.props.autoOpen) {
+ this.onTriggerClicked();
+ }
+ });
}
//--------------------------------------------------------------------------
@@ -33,26 +39,25 @@ export class FileInput extends Component {
//--------------------------------------------------------------------------
/**
- * Upload an attachment to the given action with the given parameters:
+ * Upload an attachment to the given route with the given parameters:
* - ufile: list of files contained in the file input
* - csrf_token: CSRF token provided by the odoo global object
- * - model: a specific model which will be given when creating the attachment
- * - id: the id of the model target instance
- * @private
+ * - resModel: a specific model which will be given when creating the attachment
+ * - resId: the id of the resModel target instance
*/
async onFileInputChange() {
- const { action, model, id } = this.props;
+ const { resId, resModel, route } = this.props;
const params = {
csrf_token: odoo.csrf_token,
ufile: [...this.fileInputRef.el.files],
};
- if (model) {
- params.model = model;
+ if (resModel) {
+ params.model = resModel;
}
- if (id) {
- params.id = id;
+ if (resId) {
+ params.id = resId;
}
- const fileData = await this.http.post(action, params, "text");
+ const fileData = await this.http.post(route, params, "text");
const parsedFileData = JSON.parse(fileData);
if (parsedFileData.error) {
throw new Error(parsedFileData.error);
@@ -62,7 +67,6 @@ export class FileInput extends Component {
/**
* Redirect clicks from the trigger element to the input.
- * @private
*/
onTriggerClicked() {
this.fileInputRef.el.click();
@@ -70,18 +74,21 @@ export class FileInput extends Component {
}
FileInput.defaultProps = {
- accepted_file_extensions: "*",
- action: "/web/binary/upload",
- multi_upload: false,
+ acceptedFileExtensions: "*",
+ hidden: false,
+ multiUpload: false,
onUpload: () => {},
+ route: "/web/binary/upload_attachment",
};
FileInput.props = {
- accepted_file_extensions: { type: String, optional: 1 },
- action: { type: String, optional: 1 },
- id: { type: Number, optional: 1 },
- model: { type: String, optional: 1 },
- multi_upload: { type: Boolean, optional: 1 },
- onUpload: { type: Function, optional: 1 },
- slots: { type: Object, optional: 1 },
+ acceptedFileExtensions: { type: String, optional: true },
+ autoOpen: { type: Boolean, optional: true },
+ hidden: { type: Boolean, optional: true },
+ multiUpload: { type: Boolean, optional: true },
+ onUpload: { type: Function, optional: true },
+ resId: { type: Number, optional: true },
+ resModel: { type: String, optional: true },
+ route: { type: String, optional: true },
+ "*": true,
};
FileInput.template = "web.FileInput";
diff --git a/addons/web/static/src/core/file_input/file_input.xml b/addons/web/static/src/core/file_input/file_input.xml
index aec7608927d..e8322906704 100644
--- a/addons/web/static/src/core/file_input/file_input.xml
+++ b/addons/web/static/src/core/file_input/file_input.xml
@@ -2,14 +2,17 @@
-
-
+
+
Choose File
-
diff --git a/addons/web/static/src/core/hotkeys/hotkey_hook.js b/addons/web/static/src/core/hotkeys/hotkey_hook.js
index 1ab5a2511c4..bd8e19dfbe3 100644
--- a/addons/web/static/src/core/hotkeys/hotkey_hook.js
+++ b/addons/web/static/src/core/hotkeys/hotkey_hook.js
@@ -9,15 +9,8 @@ const { useEffect } = owl;
* when the caller component will mount/unmount.
*
* @param {string} hotkey
- * @param {()=>void} callback
- * @param {Object} options additional options
- * @param {boolean} [options.allowRepeat=false]
- * allow registration to perform multiple times when hotkey is held down
- * @param {boolean} [options.bypassEditableProtection=false]
- * if true the hotkey service will call this registration
- * even if an editable element is focused
- * @param {boolean} [options.global=false]
- * allow registration to perform no matter the UI active element
+ * @param {import("./hotkey_service").HotkeyCallback} callback
+ * @param {import("./hotkey_service").HotkeyOptions} [options] additional options
*/
export function useHotkey(hotkey, callback, options = {}) {
const hotkeyService = useService("hotkey");
diff --git a/addons/web/static/src/core/hotkeys/hotkey_service.js b/addons/web/static/src/core/hotkeys/hotkey_service.js
index 3ecf33357f1..c6301aead52 100644
--- a/addons/web/static/src/core/hotkeys/hotkey_service.js
+++ b/addons/web/static/src/core/hotkeys/hotkey_service.js
@@ -5,6 +5,29 @@ import { registry } from "../registry";
import { browser } from "../browser/browser";
import { getVisibleElements } from "../utils/ui";
+/**
+ * @typedef {(context: { area: HTMLElement, target: EventTarget }) => void} HotkeyCallback
+ *
+ * @typedef {Object} HotkeyOptions
+ * @property {boolean} [allowRepeat]
+ * allow registration to perform multiple times when hotkey is held down
+ * @property {boolean} [bypassEditableProtection]
+ * if true the hotkey service will call this registration
+ * even if an editable element is focused
+ * @property {boolean} [global]
+ * allow registration to perform no matter the UI active element
+ * @property {() => HTMLElement} [area]
+ * adds a restricted operating area for this hotkey
+ * @property {(target: EventTarget) => boolean} [validate]
+ * adds a validation before calling the hotkey registration's callback
+ *
+ * @typedef {HotkeyOptions & {
+ * hotkey: string,
+ * callback: HotkeyCallback,
+ * activeElement: HTMLElement,
+ * }} HotkeyRegistration
+ */
+
const ALPHANUM_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789".split("");
const NAV_KEYS = [
"arrowleft",
@@ -19,16 +42,56 @@ const NAV_KEYS = [
"enter",
"tab",
"delete",
+ "space",
];
const MODIFIERS = ["alt", "control", "shift"];
const AUTHORIZED_KEYS = [...ALPHANUM_KEYS, ...NAV_KEYS, "escape"];
+/**
+ * Get the actual hotkey being pressed.
+ *
+ * @param {KeyboardEvent} ev
+ * @returns {string} the active hotkey, in lowercase
+ */
+export function getActiveHotkey(ev) {
+ const hotkey = [];
+
+ // ------- Modifiers -------
+ // Modifiers are pushed in ascending order to the hotkey.
+ if (isMacOS() ? ev.ctrlKey : ev.altKey) {
+ hotkey.push("alt");
+ }
+ if (isMacOS() ? ev.metaKey : ev.ctrlKey) {
+ hotkey.push("control");
+ }
+ if (ev.shiftKey) {
+ hotkey.push("shift");
+ }
+
+ // ------- Key -------
+ let key = ev.key.toLowerCase();
+ // Identify if the user has tapped on the number keys above the text keys.
+ if (ev.code && ev.code.indexOf("Digit") === 0) {
+ key = ev.code.slice(-1);
+ }
+ // Prefer physical keys for non-latin keyboard layout.
+ if (!AUTHORIZED_KEYS.includes(key) && ev.code && ev.code.indexOf("Key") === 0) {
+ key = ev.code.slice(-1).toLowerCase();
+ }
+ // Make sure we do not duplicate a modifier key
+ if (!MODIFIERS.includes(key)) {
+ hotkey.push(key);
+ }
+ return hotkey.join("+");
+}
+
export const hotkeyService = {
dependencies: ["ui"],
// Be aware that all odoo hotkeys are designed with this modifier in mind,
// so changing the overlay modifier may conflict with some shortcuts.
overlayModifier: "alt",
start(env, { ui }) {
+ /** @type {Map} */
const registrations = new Map();
let nextToken = 0;
let overlaysVisible = false;
@@ -111,6 +174,7 @@ export const hotkeyService = {
activeElement,
hotkey,
isRepeated: event.repeat,
+ target: event.target,
shouldProtectEditable,
};
const dispatched = dispatch(infos);
@@ -140,33 +204,60 @@ export const hotkeyService = {
* activeElement: HTMLElement,
* hotkey: string,
* isRepeated: boolean,
+ * target: EventTarget,
* shouldProtectEditable: boolean,
* }} infos
* @returns {boolean} true if has been dispatched
*/
function dispatch(infos) {
- const { activeElement, hotkey, isRepeated, shouldProtectEditable } = infos;
+ const { activeElement, hotkey, isRepeated, target, shouldProtectEditable } = infos;
// Prepare registrations and the common filter
const reversedRegistrations = Array.from(registrations.values()).reverse();
const domRegistrations = getDomRegistrations(hotkey, activeElement);
const allRegistrations = reversedRegistrations.concat(domRegistrations);
- // Dispatch actual hotkey to first matching registration
- const match = allRegistrations.find(
+ // Find all candidates
+ const candidates = allRegistrations.filter(
(reg) =>
reg.hotkey === hotkey &&
(reg.allowRepeat || !isRepeated) &&
(reg.bypassEditableProtection || !shouldProtectEditable) &&
- (reg.global || reg.activeElement === activeElement)
+ (reg.global || reg.activeElement === activeElement) &&
+ (!reg.validate || reg.validate(target)) &&
+ (!reg.area ||
+ (target instanceof Node && reg.area() && reg.area().contains(target)))
);
- if (match) {
- match.callback();
+
+ // First candidate
+ let winner = candidates.shift();
+ if (winner && winner.area) {
+ // If there is an area, find the closest one
+ for (const candidate of candidates) {
+ if (candidate.area() && winner.area().contains(candidate.area())) {
+ winner = candidate;
+ }
+ }
+ }
+
+ // Dispatch actual hotkey to the matching registration
+ if (winner) {
+ winner.callback({
+ area: winner.area && winner.area(),
+ target,
+ });
return true;
}
return false;
}
+ /**
+ * Get a list of registrations from the [data-hotkey] defined in the DOM
+ *
+ * @param {string} hotkey
+ * @param {HTMLElement} activeElement
+ * @returns {HotkeyRegistration[]}
+ */
function getDomRegistrations(hotkey, activeElement) {
const overlayModParts = hotkeyService.overlayModifier.split("+");
if (!overlayModParts.every((el) => hotkey.includes(el))) {
@@ -194,7 +285,7 @@ export const hotkeyService = {
/**
* Add the hotkey overlays respecting the ui active element.
- * @param {Element} activeElement
+ * @param {HTMLElement} activeElement
*/
function addHotkeyOverlays(activeElement) {
for (const el of getVisibleElements(activeElement, "[data-hotkey]:not(:disabled)")) {
@@ -247,57 +338,12 @@ export const hotkeyService = {
overlaysVisible = false;
}
- /**
- * Get the actual hotkey being pressed.
- *
- * @param {KeyboardEvent} ev
- * @returns {string} the active hotkey, in lowercase
- */
- function getActiveHotkey(ev) {
- const hotkey = [];
-
- // ------- Modifiers -------
- // Modifiers are pushed in ascending order to the hotkey.
- if (isMacOS() ? ev.ctrlKey : ev.altKey) {
- hotkey.push("alt");
- }
- if (isMacOS() ? ev.metaKey : ev.ctrlKey) {
- hotkey.push("control");
- }
- if (ev.shiftKey) {
- hotkey.push("shift");
- }
-
- // ------- Key -------
- let key = ev.key.toLowerCase();
- // Identify if the user has tapped on the number keys above the text keys.
- if (ev.code && ev.code.indexOf("Digit") === 0) {
- key = ev.code.slice(-1);
- }
- // Prefer physical keys for non-latin keyboard layout.
- if (!AUTHORIZED_KEYS.includes(key) && ev.code && ev.code.indexOf("Key") === 0) {
- key = ev.code.slice(-1).toLowerCase();
- }
- // Make sure we do not duplicate a modifier key
- if (!MODIFIERS.includes(key)) {
- hotkey.push(key);
- }
- return hotkey.join("+");
- }
-
/**
* Registers a new hotkey.
*
* @param {string} hotkey
- * @param {()=>void} callback
- * @param {Object} options additional options
- * @param {boolean} [options.allowRepeat=false]
- * allow registration to perform multiple times when hotkey is held down
- * @param {boolean} [options.bypassEditableProtection=false]
- * if true the hotkey service will call this registration
- * even if an editable element is focused
- * @param {boolean} [options.global=false]
- * allow registration to perform no matter the UI active element
+ * @param {HotkeyCallback} callback
+ * @param {HotkeyOptions} [options]
* @returns {number} registration token
*/
function registerHotkey(hotkey, callback, options = {}) {
@@ -336,6 +382,7 @@ export const hotkeyService = {
// Add registration
const token = nextToken++;
+ /** @type {HotkeyRegistration} */
const registration = {
hotkey: hotkey.toLowerCase(),
callback,
@@ -343,8 +390,9 @@ export const hotkeyService = {
allowRepeat: options && options.allowRepeat,
bypassEditableProtection: options && options.bypassEditableProtection,
global: options && options.global,
+ area: options && options.area,
+ validate: options && options.validate,
};
- registrations.set(token, registration);
// 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 owner of the registration.
@@ -352,6 +400,7 @@ export const hotkeyService = {
registration.activeElement = ui.activeElement;
});
+ registrations.set(token, registration);
return token;
}
@@ -367,11 +416,8 @@ export const hotkeyService = {
return {
/**
* @param {string} hotkey
- * @param {() => void} callback
- * @param {Object} options
- * @param {boolean} [options.allowRepeat=false]
- * @param {boolean} [options.bypassEditableProtection=false]
- * @param {boolean} [options.global=false]
+ * @param {HotkeyCallback} callback
+ * @param {HotkeyOptions} [options]
* @returns {() => void}
*/
add(hotkey, callback, options = {}) {
@@ -391,3 +437,4 @@ export const hotkeyService = {
};
registry.category("services").add("hotkey", hotkeyService);
+/** @typedef {ReturnType} HotkeyService */
diff --git a/addons/web/static/src/core/l10n/dates.js b/addons/web/static/src/core/l10n/dates.js
index a6ace94719a..8a737a2e4d8 100644
--- a/addons/web/static/src/core/l10n/dates.js
+++ b/addons/web/static/src/core/l10n/dates.js
@@ -51,6 +51,15 @@ const smartDateUnits = {
};
const smartDateRegex = new RegExp(`^([+-])(\\d+)([${Object.keys(smartDateUnits).join("")}]?)$`);
+/**
+ * @param {any} d1
+ * @param {any} d2
+ * @returns {boolean}
+ */
+export function areDateEquals(d1, d2) {
+ return d1 instanceof DateTime && d2 instanceof DateTime ? d1.equals(d2) : d1 === d2;
+}
+
/**
* Smart date inputs are shortcuts to write dates quicker.
* These shortcuts should respect the format ^[+-]\d+[dmwy]?$
@@ -86,14 +95,10 @@ function parseSmartDateInput(value) {
* Returns false otherwise.
*
* @param {DateTime | false} date
- * @returns {DateTime | false}
+ * @returns {boolean}
*/
-function constrain(dt) {
- let valid = dt !== false;
- valid = valid && dt.isValid;
- valid = valid && dt.year >= 1000;
- valid = valid && dt.year < 10000;
- return valid ? dt : false;
+function isValidDateTime(dt) {
+ return dt && dt.isValid && dt.year >= 1000 && dt.year < 10000;
}
/**
@@ -187,7 +192,7 @@ export function formatDateTime(value, options = {}) {
return "";
}
const format = options.format || localization.dateTimeFormat;
- const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem;
+ const numberingSystem = options.numberingSystem || Settings.defaultNumberingSystem || "latn";
const zone = options.timezone ? "system" : "utc";
value = value.setZone(zone, { keepLocaltime: options.timezone });
return value.toFormat(format, { numberingSystem });
@@ -208,6 +213,9 @@ export function formatDateTime(value, options = {}) {
* @returns {DateTime | false} Luxon DateTime object
*/
export function parseDate(value, options = {}) {
+ if (!value) {
+ return false;
+ }
return parseDateTime(value, { timezone: false, ...options }).startOf("day");
}
@@ -256,46 +264,75 @@ export function parseDateTime(value, options = {}) {
return false;
}
- const valueDigitsOnly = value.replace(nonDigitsRegex, "");
+ const fmt = options.format || localization.dateTimeFormat;
const parseOpts = {
setZone: true,
zone: options.timezone ? "system" : "utc",
locale: options.locale,
- numberingSystem: options.numberingSystem || Settings.defaultNumberingSystem,
+ numberingSystem: options.numberingSystem || Settings.defaultNumberingSystem || "latn",
};
- let result = constrain(parseSmartDateInput(value));
+ // Base case: try parsing with the given format and options
+ let result = DateTime.fromFormat(value, fmt, parseOpts);
- if (!result) {
- const fmt = options.format || localization.dateTimeFormat;
+ // Try parsing as a smart date
+ if (!isValidDateTime(result)) {
+ result = parseSmartDateInput(value);
+ }
+
+ // Try parsing with partial date parts
+ if (!isValidDateTime(result)) {
const fmtWoZero = stripAlphaDupes(fmt);
+ result = DateTime.fromFormat(value, fmtWoZero, parseOpts);
+ }
+ // Try parsing with custom shorthand date parts
+ if (!isValidDateTime(result)) {
// Luxon is not permissive regarding delimiting characters in the format.
// So if the value to parse has less characters than the format, we would
// try to parse without the delimiting characters.
- const woSeps = {
- val: valueDigitsOnly,
- fmt: fmt.replace(nonAlphaRegex, "").slice(0, valueDigitsOnly.length),
- };
+ const digitList = value.split(nonDigitsRegex).filter(Boolean);
+ const fmtList = fmt.split(nonAlphaRegex).filter(Boolean);
+ const valWoSeps = digitList.join("");
- result =
- constrain(DateTime.fromFormat(value, fmt, parseOpts)) ||
- constrain(DateTime.fromFormat(value, fmtWoZero, parseOpts)) ||
- constrain(DateTime.fromFormat(woSeps.val, woSeps.fmt, parseOpts));
+ // This is the weird part: we try to adapt the given format to comply with
+ // the amount of digits in the given value. To do this we split the format
+ // and the value on non-letter and non-digit characters respectively. This
+ // should create the same amount of grouping parameters, and the format
+ // groups are trimmed according to the length of their corresponding
+ // digit group. The 'carry' variable allows for the length of a digit
+ // group to overflow to the next format group. This is typically the case
+ // when the given value doesn't have non-digit separators and generates
+ // one big digit group instead.
+ let carry = 0;
+ const fmtWoSeps = fmtList
+ .map((part, i) => {
+ const digitLength = (digitList[i] || "").length;
+ const actualPart = part.slice(0, digitLength + carry);
+ carry += digitLength - actualPart.length;
+ return actualPart;
+ })
+ .join("");
+
+ result = DateTime.fromFormat(valWoSeps, fmtWoSeps, parseOpts);
}
- if (!result) {
- if (valueDigitsOnly.length > 4) {
- // Also try some fallback formats, but only if value counts more than
- // four digit characters as this could get misinterpreted as the time of
- // the actual date.
- result =
- constrain(DateTime.fromISO(value, parseOpts)) || // ISO8601
- constrain(DateTime.fromSQL(value, parseOpts)); // last try: SQL
+ // Try with defaul ISO or SQL formats
+ if (!isValidDateTime(result)) {
+ // Also try some fallback formats, but only if value counts more than
+ // four digit characters as this could get misinterpreted as the time of
+ // the actual date.
+ const valueDigits = value.replace(nonDigitsRegex, "");
+ if (valueDigits.length > 4) {
+ result = DateTime.fromISO(value, parseOpts); // ISO8601
+ if (!isValidDateTime(result)) {
+ result = DateTime.fromSQL(value, parseOpts); // last try: SQL
+ }
}
}
- if (!result) {
+ // No working parsing methods: throw an error
+ if (!isValidDateTime(result)) {
throw new Error(sprintf(_t("'%s' is not a correct date or datetime"), value));
}
@@ -307,45 +344,45 @@ export function parseDateTime(value, options = {}) {
* @param {string} value
* @returns {DateTime | false}
*/
-export const deserializeDate = (value) => {
+export function deserializeDate(value) {
return parseDate(value, {
format: SERVER_DATE_FORMAT,
numberingSystem: "latn",
});
-};
+}
/**
* Returns a datetime object parsed from the given serialized string.
* @param {string} value
* @returns {DateTime | false}
*/
-export const deserializeDateTime = (value) => {
+export function deserializeDateTime(value) {
return parseDateTime(value, {
format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}`,
numberingSystem: "latn",
});
-};
+}
/**
* Returns a serialized string representing the given date.
* @param {DateTime} value
* @returns {string}
*/
-export const serializeDate = (value) => {
+export function serializeDate(value) {
return formatDate(value, {
format: SERVER_DATE_FORMAT,
numberingSystem: "latn",
});
-};
+}
/**
* Returns a serialized string representing the given datetime.
* @param {DateTime} value
* @returns {string}
*/
-export const serializeDateTime = (value) => {
+export function serializeDateTime(value) {
return formatDateTime(value, {
format: `${SERVER_DATE_FORMAT} ${SERVER_TIME_FORMAT}`,
numberingSystem: "latn",
});
-};
+}
diff --git a/addons/web/static/src/core/macro.js b/addons/web/static/src/core/macro.js
index 3fd121ca403..7511e564dab 100644
--- a/addons/web/static/src/core/macro.js
+++ b/addons/web/static/src/core/macro.js
@@ -4,7 +4,9 @@ import { browser } from "@web/core/browser/browser";
// rough approximation of a visible element. not perfect (does not take into
// account opacity = 0 for example), but good enough for our purpose
-const isVisible = (e) => e.offsetWidth > 0 || e.offsetHeight > 0;
+function isVisible(e) {
+ return e.offsetWidth > 0 || e.offsetHeight > 0;
+}
export const ACTION_HELPERS = {
click(el, _step) {
diff --git a/addons/web/static/src/core/model_field_selector/model_field_hook.js b/addons/web/static/src/core/model_field_selector/model_field_hook.js
new file mode 100644
index 00000000000..b19fdb78d25
--- /dev/null
+++ b/addons/web/static/src/core/model_field_selector/model_field_hook.js
@@ -0,0 +1,43 @@
+/** @odoo-module **/
+
+import { useService } from "@web/core/utils/hooks";
+
+export function useModelField() {
+ const orm = useService("orm");
+
+ const loadModelFields = (resModel) => {
+ // should be cached
+ return orm.call(resModel, "fields_get", [
+ false,
+ ["store", "searchable", "type", "string", "relation", "selection", "related"],
+ ]);
+ };
+
+ const loadChain = async (resModel, fieldName) => {
+ const fieldNameChain = fieldName.length ? fieldName.split(".") : [];
+ let currentNode = {
+ resModel,
+ field: null,
+ };
+ const chain = [currentNode];
+ for (const fieldName of fieldNameChain) {
+ const fieldsInfo = await loadModelFields(currentNode.resModel);
+ Object.assign(currentNode, {
+ field: { ...fieldsInfo[fieldName], name: fieldName },
+ });
+ if (fieldsInfo[fieldName].relation) {
+ currentNode = {
+ resModel: fieldsInfo[fieldName].relation,
+ field: null,
+ };
+ chain.push(currentNode);
+ }
+ }
+ return chain;
+ };
+
+ return {
+ loadModelFields,
+ loadChain,
+ };
+}
diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector.js b/addons/web/static/src/core/model_field_selector/model_field_selector.js
new file mode 100644
index 00000000000..3c1473e9538
--- /dev/null
+++ b/addons/web/static/src/core/model_field_selector/model_field_selector.js
@@ -0,0 +1,120 @@
+/** @odoo-module **/
+
+import { usePopover } from "@web/core/popover/popover_hook";
+import { useModelField } from "./model_field_hook";
+import { ModelFieldSelectorPopover } from "./model_field_selector_popover";
+
+const { Component, onWillStart, onWillUpdateProps } = owl;
+
+function useUniquePopover() {
+ const popover = usePopover();
+ let remove = null;
+ return Object.assign(Object.create(popover), {
+ add(target, component, props, options) {
+ if (remove) {
+ remove();
+ }
+ remove = popover.add(target, component, props, options);
+ return () => {
+ remove();
+ remove = null;
+ };
+ },
+ });
+}
+
+export class ModelFieldSelector extends Component {
+ setup() {
+ this.popover = useUniquePopover();
+ this.modelField = useModelField();
+ this.chain = [];
+
+ onWillStart(async () => {
+ this.chain = await this.loadChain(this.props.resModel, this.props.fieldName);
+ });
+ onWillUpdateProps(async (nextProps) => {
+ this.chain = await this.loadChain(nextProps.resModel, nextProps.fieldName);
+ });
+ }
+
+ get fieldNameChain() {
+ return this.getFieldNameChain(this.props.fieldName);
+ }
+
+ getFieldNameChain(fieldName) {
+ return fieldName.length ? fieldName.split(".") : [];
+ }
+
+ async loadChain(resModel, fieldName) {
+ if ("01".includes(fieldName)) {
+ return [{ resModel, field: { string: fieldName } }];
+ }
+ const fieldNameChain = this.getFieldNameChain(fieldName);
+ let currentNode = {
+ resModel,
+ field: null,
+ };
+ const chain = [currentNode];
+ for (const fieldName of fieldNameChain) {
+ const fieldsInfo = await this.modelField.loadModelFields(currentNode.resModel);
+ Object.assign(currentNode, {
+ field: { ...fieldsInfo[fieldName], name: fieldName },
+ });
+ if (fieldsInfo[fieldName].relation) {
+ currentNode = {
+ resModel: fieldsInfo[fieldName].relation,
+ field: null,
+ };
+ chain.push(currentNode);
+ }
+ }
+ return chain;
+ }
+ update(chain) {
+ this.props.update(chain.join("."));
+ }
+
+ onFieldSelectorClick(ev) {
+ if (this.props.readonly) {
+ return;
+ }
+ this.popover.add(
+ ev.currentTarget,
+ this.constructor.components.Popover,
+ {
+ chain: this.chain,
+ update: this.update.bind(this),
+ showSearchInput: this.props.showSearchInput,
+ isDebugMode: this.props.isDebugMode,
+ loadChain: this.loadChain.bind(this),
+ filter: this.props.filter,
+ },
+ {
+ closeOnClickAway: true,
+ }
+ );
+ }
+}
+
+Object.assign(ModelFieldSelector, {
+ template: "web._ModelFieldSelector",
+ components: {
+ Popover: ModelFieldSelectorPopover,
+ },
+ props: {
+ fieldName: String,
+ resModel: String,
+ readonly: { type: Boolean, optional: true },
+ showSearchInput: { type: Boolean, optional: true },
+ isDebugMode: { type: Boolean, optional: true },
+ update: { type: Function, optional: true },
+ filter: { type: Function, optional: true },
+ },
+ defaultProps: {
+ readonly: true,
+ isDebugMode: false,
+ showSearchInput: true,
+ update: () => {},
+ filter: () => true,
+ },
+});
diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector.xml b/addons/web/static/src/core/model_field_selector/model_field_selector.xml
new file mode 100644
index 00000000000..713a8de381b
--- /dev/null
+++ b/addons/web/static/src/core/model_field_selector/model_field_selector.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector_popover.js b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.js
new file mode 100644
index 00000000000..39629cce948
--- /dev/null
+++ b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.js
@@ -0,0 +1,107 @@
+/** @odoo-module **/
+
+import { sortBy } from "../utils/arrays";
+import { useModelField } from "./model_field_hook";
+
+import { fuzzyLookup } from "@web/core/utils/search";
+
+const { Component, onWillStart } = owl;
+
+export class ModelFieldSelectorPopover extends Component {
+ setup() {
+ this.chain = Array.from(this.props.chain);
+ this.modelField = useModelField();
+ this.fields = {};
+ this.fieldKeys = [];
+ this.searchValue = "";
+ this.fullFieldName = this.fieldNameChain.join(".");
+
+ onWillStart(async () => {
+ await this.loadFields();
+ });
+ }
+
+ get currentNode() {
+ return this.chain[this.chain.length - 1];
+ }
+ get currentFieldName() {
+ const nodes = this.chain.filter((node) => node.field);
+ return nodes.length ? nodes[nodes.length - 1].field.string : "";
+ }
+
+ get fieldNameChain() {
+ return this.chain.filter((node) => node.field).map((node) => node.field.name);
+ }
+
+ async loadFields() {
+ this.fields = await this.modelField.loadModelFields(this.currentNode.resModel);
+ this.fieldKeys = this.sortedKeys(this.fields);
+ }
+ sortedKeys(obj) {
+ const keys = Object.keys(obj);
+ return sortBy(keys, (key) => obj[key].string);
+ }
+ async update() {
+ const fieldNameChain = this.fieldNameChain;
+ this.fullFieldName = fieldNameChain.join(".");
+ await this.props.update(fieldNameChain);
+ await this.loadFields();
+ this.render();
+ }
+
+ onSearch(ev) {
+ this.searchValue = ev.target.value;
+ let fieldKeys = this.sortedKeys(this.fields);
+ if (this.searchValue) {
+ fieldKeys = fuzzyLookup(this.searchValue, fieldKeys, (key) => this.fields[key].string);
+ }
+ this.fieldKeys = fieldKeys;
+ this.render();
+ }
+ onPreviousBtnClick() {
+ this.searchValue = "";
+ if (this.chain.length > 1) {
+ this.chain.pop();
+ }
+ this.currentNode.field = null;
+ this.update();
+ }
+ onFieldSelected(field) {
+ this.searchValue = "";
+ this.currentNode.field = field;
+ if (!field.relation) {
+ this.props.close();
+ } else {
+ this.chain.push({
+ resModel: field.relation,
+ field: null,
+ });
+ }
+ this.update();
+ }
+ async onFieldNameChange(ev) {
+ this.fullFieldName = ev.target.value.replace(/\s+/g, "");
+ try {
+ this.chain = await this.props.loadChain(this.fullFieldName);
+ this.update();
+ } catch (_error) {
+ // WOWL TODO: rethrow error when not the expected type
+ this.chain = [{ resModel: this.props.chain[0], field: null }];
+ await this.props.update([]);
+ this.render();
+ }
+ }
+}
+
+Object.assign(ModelFieldSelectorPopover, {
+ template: "web.ModelFieldSelectorPopover",
+ props: {
+ chain: Array,
+ update: Function,
+ showSearchInput: Boolean,
+ isDebugMode: Boolean,
+ loadChain: Function,
+ filter: Function,
+ close: Function,
+ },
+});
diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector_popover.scss b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.scss
new file mode 100644
index 00000000000..25fd6916b69
--- /dev/null
+++ b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.scss
@@ -0,0 +1,83 @@
+
+.o_field_selector_popover {
+ width: 265px;
+ background: white;
+
+ &:focus {
+ outline: none;
+ }
+
+ .o_field_selector_popover_header {
+ color: white;
+ background: theme-color('primary');
+ font-weight: bold;
+ padding: 5px 0 5px 0.4em;
+
+ .o_field_selector_title {
+ width: 100%;
+ @include o-text-overflow;
+ padding: 0px 35px;
+ text-align: center;
+ }
+ .o_field_selector_search {
+ padding-right: 0.4rem;
+ > .o_input {
+ padding: 5px 0.4rem;
+ }
+ }
+ .o_field_selector_popover_option {
+ @include o-position-absolute($top: 0);
+ padding: 8px;
+
+ &.o_field_selector_prev_page {
+ left: 0;
+ }
+ &.o_field_selector_close {
+ right: 0;
+ }
+ &:hover {
+ background: darken(theme-color('primary'), 10%);
+ }
+ }
+ }
+ .o_field_selector_popover_body {
+ .o_field_selector_page {
+ position: relative;
+ max-height: 320px;
+ overflow: auto;
+ margin: 0;
+ padding: 0;
+
+ > .o_field_selector_item {
+ list-style: none;
+ position: relative;
+ padding: 5px 0 5px 0.4em;
+ cursor: pointer;
+ font-family: Arial;
+ font-size: 13px;
+ color: #444;
+ border-bottom: 1px solid #eee;
+ &.active {
+ background: #f5f5f5;
+ }
+ .o_field_selector_item_title {
+ font-size: 12px;
+ }
+ .o_field_selector_relation_icon {
+ @include o-position-absolute($top: 0, $right: 0, $bottom: 0);
+ display: flex;
+ align-items: center;
+ padding: 10px;
+ }
+ }
+ }
+ }
+ .o_field_selector_popover_footer {
+ background: theme-color('primary');
+ padding: 5px 0.4em;
+
+ > input {
+ width: 100%;
+ }
+ }
+}
diff --git a/addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml
new file mode 100644
index 00000000000..8b3dc043a16
--- /dev/null
+++ b/addons/web/static/src/core/model_field_selector/model_field_selector_popover.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/notebook/notebook.js b/addons/web/static/src/core/notebook/notebook.js
new file mode 100644
index 00000000000..e6f6bc59942
--- /dev/null
+++ b/addons/web/static/src/core/notebook/notebook.js
@@ -0,0 +1,160 @@
+/** @odoo-module **/
+
+import { scrollTo } from "@web/core/utils/scrolling";
+
+const { Component, onWillDestroy, onWillUpdateProps, useEffect, useRef, useState } = owl;
+
+/**
+ * A notebook component that will render only the current page and allow
+ * switching between its pages.
+ *
+ * You can also set pages using a template component. Use an array with
+ * the `pages` props to do such rendering.
+ *
+ * Pages can also specify their index in the notebook.
+ *
+ * e.g.:
+ * PageTemplate.template = xml`
+
+
`;
+
+ * `pages` could be:
+ * [
+ * {
+ * Component: PageTemplate,
+ * props: {
+ * title: "Some Title 1",
+ * isVisible: bool,
+ * text: "Text Content 1",
+ * },
+ * name: 'some_name' // optional
+ * },
+ * {
+ * Component: PageTemplate,
+ * props: {
+ * title: "Some Title 2",
+ * isVisible: bool,
+ * text: "Text Content 2",
+ * },
+ * },
+ * ]
+ *
+ *
+ *
+ * Page Content 1
+ *
+ *
+ * Page Content 2
+ *
+ *
+ *
+ * @extends Component
+ */
+
+export class Notebook extends Component {
+ setup() {
+ this.activePane = useRef("activePane");
+ this.anchorTarget = null;
+ this.pages = this.computePages(this.props);
+ this.state = useState({ currentPage: null });
+ this.state.currentPage = this.props.defaultPage || this.computeActivePage();
+ const onAnchorClicked = this.onAnchorClicked.bind(this);
+ this.env.bus.addEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked);
+ useEffect(
+ () => {
+ this.props.onPageUpdate(this.state.currentPage);
+ if (this.anchorTarget) {
+ const matchingEl = this.activePane.el.querySelector(`#${this.anchorTarget}`);
+ scrollTo(matchingEl, { isAnchor: true });
+ this.anchorTarget = null;
+ }
+ },
+ () => [this.state.currentPage]
+ );
+ onWillUpdateProps((nextProps) => {
+ this.pages = this.computePages(nextProps);
+ this.state.currentPage = this.computeActivePage();
+ });
+ onWillDestroy(() => {
+ this.env.bus.removeEventListener("SCROLLER:ANCHOR_LINK_CLICKED", onAnchorClicked);
+ });
+ }
+
+ get navItems() {
+ return this.pages.filter((e) => e[1].isVisible);
+ }
+
+ get page() {
+ const page = this.pages.find((e) => e[0] === this.state.currentPage)[1];
+ return page.Component && page;
+ }
+
+ onAnchorClicked(ev) {
+ if (!this.props.anchors) {
+ return;
+ }
+ const id = ev.detail.detail.id.substring(1);
+ if (this.props.anchors[id]) {
+ if (this.state.currentPage !== this.props.anchors[id].target) {
+ ev.preventDefault();
+ ev.detail.detail.originalEv.preventDefault();
+ this.anchorTarget = id;
+ this.state.currentPage = this.props.anchors[id].target;
+ }
+ }
+ }
+
+ computePages(props) {
+ if (!props.slots && !props.pages) {
+ return [];
+ }
+ const pages = [];
+ const pagesWithIndex = [];
+ for (const [k, v] of Object.entries({ ...props.slots, ...props.pages })) {
+ if (v.props) {
+ v.isVisible = v.props.isVisible;
+ v.title = v.props.title;
+ v.index = v.props.index;
+ }
+ const id = v.id || k;
+ if (v.index) {
+ pagesWithIndex.push([id, v]);
+ } else {
+ pages.push([id, v]);
+ }
+ }
+ for (const page of pagesWithIndex) {
+ pages.splice(page[1].index, 0, page);
+ }
+ return pages;
+ }
+
+ computeActivePage() {
+ if (!this.pages.length) {
+ return null;
+ }
+ const current = this.state.currentPage;
+ if (!current || (current && !this.pages.find((e) => e[0] === current)[1].isVisible)) {
+ const candidate = this.pages.find((v) => v[1].isVisible);
+ return candidate ? candidate[0] : null;
+ }
+ return current;
+ }
+}
+
+Notebook.template = "web.Notebook";
+Notebook.defaultProps = {
+ className: "",
+ orientation: "horizontal",
+ onPageUpdate: () => {},
+};
+Notebook.props = {
+ slots: { type: Object, optional: true },
+ pages: { type: Object, optional: true },
+ class: { optional: true },
+ className: { type: String, optional: true },
+ anchors: { type: Object, optional: true },
+ defaultPage: { type: String, optional: true },
+ orientation: { type: String, optional: true },
+ onPageUpdate: { type: Function, optional: true },
+};
diff --git a/addons/web/static/src/core/notebook/notebook.scss b/addons/web/static/src/core/notebook/notebook.scss
new file mode 100644
index 00000000000..77553ab1d36
--- /dev/null
+++ b/addons/web/static/src/core/notebook/notebook.scss
@@ -0,0 +1,18 @@
+.o_notebook {
+ &.horizontal {
+ flex-direction: column;
+
+ .nav.nav-tabs {
+ flex-flow: row nowrap;
+ }
+
+ }
+
+ &.vertical {
+ flex-direction: row;
+
+ .nav.nav-pills {
+ width: max-content;
+ }
+ }
+}
diff --git a/addons/web/static/src/core/notebook/notebook.xml b/addons/web/static/src/core/notebook/notebook.xml
new file mode 100644
index 00000000000..2604f6a501a
--- /dev/null
+++ b/addons/web/static/src/core/notebook/notebook.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/notifications/notification_container.js b/addons/web/static/src/core/notifications/notification_container.js
index 03ee640d5f2..0eb9617186f 100644
--- a/addons/web/static/src/core/notifications/notification_container.js
+++ b/addons/web/static/src/core/notifications/notification_container.js
@@ -15,7 +15,7 @@ NotificationContainer.template = xml`
-
+
`;
diff --git a/addons/web/static/src/core/orm_service.js b/addons/web/static/src/core/orm_service.js
index efe349ad013..56933502625 100644
--- a/addons/web/static/src/core/orm_service.js
+++ b/addons/web/static/src/core/orm_service.js
@@ -125,6 +125,14 @@ export class ORM {
return this.call(model, "create", [state], { context: ctx });
}
+ nameGet(model, ids, ctx) {
+ validatePrimitiveList("ids", "number", ids);
+ if (!ids.length) {
+ return Promise.resolve([]);
+ }
+ return this.call(model, "name_get", [ids], { context: ctx });
+ }
+
read(model, ids, fields, ctx) {
validatePrimitiveList("ids", "number", ids);
if (fields) {
@@ -169,6 +177,14 @@ export class ORM {
return this.call(model, "search_read", [], kwargs);
}
+ searchCount(model, domain, ctx = {}) {
+ validateArray('domain', domain);
+ const kwargs = {
+ context: ctx,
+ };
+ return this.call(model, 'search_count', [domain], kwargs);
+ }
+
unlink(model, ids, ctx) {
validatePrimitiveList("ids", "number", ids);
if (!ids.length) {
@@ -187,7 +203,7 @@ export class ORM {
fields,
context: ctx,
};
- assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit"]);
+ assignOptions(kwargs, options, ["lazy", "offset", "orderby", "limit", "expand"]);
return this.call(model, "web_read_group", [], kwargs);
}
@@ -223,6 +239,7 @@ export const ormService = {
async: [
"call",
"create",
+ "name_get",
"read",
"readGroup",
"search",
diff --git a/addons/web/static/src/core/pager/pager.js b/addons/web/static/src/core/pager/pager.js
index 108c347332b..014a15ba832 100644
--- a/addons/web/static/src/core/pager/pager.js
+++ b/addons/web/static/src/core/pager/pager.js
@@ -50,7 +50,7 @@ export class Pager extends Component {
return parts.join("-");
}
/**
- * @returns {boolean} true iff there is only one page
+ * @returns {boolean} true if there is only one page
*/
get isSinglePage() {
return this.minimum === 1 && this.maximum === this.props.total;
@@ -68,15 +68,14 @@ export class Pager extends Component {
} else if (minimum < 0 && this.props.limit > 1) {
minimum = this.props.total - (this.props.total % this.props.limit || this.props.limit);
}
-
- this.update(minimum, this.props.limit);
+ this.update(minimum, this.props.limit, true);
}
/**
* @param {string} value
* @returns {{ minimum: number, maximum: number }}
*/
parse(value) {
- let [minimum, maximum] = value.trim().split(/\s*[\-\s,;]\s*/);
+ let [minimum, maximum] = value.trim().split(/\s*[-\s,;]\s*/);
const clamp = (value) => Math.min(Math.max(value, 1), this.props.total);
return {
minimum: clamp(parseInt(minimum, 10)) - 1,
@@ -96,10 +95,11 @@ export class Pager extends Component {
/**
* @param {number} offset
* @param {number} limit
+ * @param {Boolean} hasNavigated
*/
- async update(offset, limit) {
+ async update(offset, limit, hasNavigated) {
this.state.isDisabled = true;
- await this.props.onUpdate({ offset, limit });
+ await this.props.onUpdate({ offset, limit }, hasNavigated);
this.state.isDisabled = false;
this.state.isEditing = false;
}
diff --git a/addons/web/static/src/core/tooltip/tooltip.scss b/addons/web/static/src/core/tooltip/tooltip.scss
index 98933b8ad56..faacce47bf4 100644
--- a/addons/web/static/src/core/tooltip/tooltip.scss
+++ b/addons/web/static/src/core/tooltip/tooltip.scss
@@ -1,5 +1,5 @@
.o-tooltip {
- font-size: smaller;
+ font-size: small;
.o-tooltip--string {
background-color: $o-tooltip-title-background-color;
@@ -8,18 +8,21 @@
}
.o-tooltip--help {
+ font-size: smaller;
white-space: pre-line;
padding: 8px;
margin-bottom: 0px;
}
.o-tooltip--technical {
+ font-size: smaller;
padding: 8px;
margin: 0 0 0 15px;
list-style-type: circle;
.o-tooltip--technical--title {
font-weight: bold;
+ margin-right: 4px;
}
}
}
diff --git a/addons/web/static/src/core/tooltip/tooltip.xml b/addons/web/static/src/core/tooltip/tooltip.xml
index f1c83547117..bff6a9f28c7 100644
--- a/addons/web/static/src/core/tooltip/tooltip.xml
+++ b/addons/web/static/src/core/tooltip/tooltip.xml
@@ -3,9 +3,7 @@
-
-
-
+
diff --git a/addons/web/static/src/core/tooltip/tooltip_hook.js b/addons/web/static/src/core/tooltip/tooltip_hook.js
new file mode 100644
index 00000000000..25618a21664
--- /dev/null
+++ b/addons/web/static/src/core/tooltip/tooltip_hook.js
@@ -0,0 +1,14 @@
+/** @odoo-module */
+
+import { useService } from "@web/core/utils/hooks";
+
+const { useEffect, useRef } = owl;
+
+export function useTooltip(refName, params) {
+ const tooltip = useService("tooltip");
+ const ref = useRef(refName);
+ useEffect(
+ (el) => tooltip.add(el, params),
+ () => [ref.el]
+ );
+}
diff --git a/addons/web/static/src/core/tooltip/tooltip_service.js b/addons/web/static/src/core/tooltip/tooltip_service.js
index bc591385fc8..90ca17af804 100644
--- a/addons/web/static/src/core/tooltip/tooltip_service.js
+++ b/addons/web/static/src/core/tooltip/tooltip_service.js
@@ -19,6 +19,9 @@ const { whenReady } = owl;
* "data-tooltip-position":
* Do something
*
+ * The opening delay can be modified with the "data-tooltip-delay" attribute (default: 400):
+ * Do something
+ *
* For advanced tooltips containing dynamic and/or html content, the
* "data-tooltip-template" and "data-tooltip-info" attributes can be used.
* For example, let's suppose the following qweb template:
@@ -42,9 +45,9 @@ export const tooltipService = {
let openTooltipTimeout;
let closeTooltip;
let target = null;
-
let positionX;
let positionY;
+ const elementsWithTooltips = new Map();
/**
* Closes the currently opened tooltip if any, or prevent it from opening.
@@ -57,54 +60,100 @@ export const tooltipService = {
}
/**
- * Opens the tooltip after a delay, if the event's target has a data-tooltip
- * attribute.
+ * Checks that the target is in the DOM and we're hovering the target.
+ * @returns {boolean}
+ */
+ function shouldCleanup() {
+ if (!target) {
+ return false;
+ }
+ if (!document.body.contains(target)) {
+ return true; // target is no longer in the DOM
+ }
+ const targetRect = target.getBoundingClientRect();
+ if (
+ positionX < targetRect.left ||
+ positionX > targetRect.right ||
+ positionY < targetRect.top ||
+ positionY > targetRect.bottom
+ ) {
+ return true; // mouse is no longer hovering the target
+ }
+ return false;
+ }
+
+ /**
+ * Checks whether there is a tooltip registered on the event target, and
+ * if there is, creates a timeout to open the corresponding tooltip
+ * after a delay.
+ *
+ * @param {HTMLElement} el the element on which to add the tooltip
+ * @param {object} param1
+ * @param {string} [param1.tooltip] the string to add as a tooltip, if
+ * no tooltip template is specified
+ * @param {string} [param1.template] the name of the template to use for
+ * tooltip, if any
+ * @param {object} [param1.info] info for the tooltip template
+ * @param {'top'|'bottom'|'left'|'right'} param1.position
+ * @param {number} [param1.delay] delay after which the popover should
+ * open
+ */
+ function openTooltip(el, { tooltip = "", template, info, position, delay = OPEN_DELAY }) {
+ target = el;
+ cleanup();
+ if (!tooltip && !template) {
+ return;
+ }
+
+ openTooltipTimeout = browser.setTimeout(() => {
+ if (shouldCleanup()) {
+ cleanup();
+ } else {
+ closeTooltip = popover.add(
+ target,
+ Tooltip,
+ { tooltip, template, info },
+ { position }
+ );
+ }
+ }, delay);
+ }
+
+ /**
+ * Checks whether there is a tooltip registered on the event target, and
+ * if there is, creates a timeout to open the corresponding tooltip
+ * after a delay.
*
* @param {MouseEvent} ev a "mouseenter" event
*/
function onMouseenter(ev) {
- let el = ev.target;
- if (!el.matches("[data-tooltip], [data-tooltip-template]")) {
- return;
+ // set mouse position in case the target contains disabled elements which won't trigger mousemove
+ positionX = ev.x;
+ positionY = ev.y;
+ /** @type {HTMLElement} */
+ const el = ev.target;
+ if (elementsWithTooltips.has(el)) {
+ openTooltip(el, elementsWithTooltips.get(el));
+ } else if (el.matches("[data-tooltip], [data-tooltip-template]")) {
+ const {
+ tooltip,
+ tooltipInfo,
+ tooltipDelay,
+ tooltipTemplate: template,
+ tooltipPosition: position,
+ } = el.dataset;
+ const delay = parseInt(tooltipDelay);
+ const info = tooltipInfo ? JSON.parse(tooltipInfo) : null;
+ openTooltip(el, { tooltip, template, delay, info, position });
}
- target = el;
- const dataset = el.dataset;
- const tooltip = dataset.tooltip;
- let template, info;
- if ("tooltipTemplate" in dataset) {
- template = dataset.tooltipTemplate;
- info = dataset.tooltipInfo ? JSON.parse(dataset.tooltipInfo) : null;
- }
- cleanup();
- openTooltipTimeout = browser.setTimeout(() => {
- const position = dataset.tooltipPosition;
- closeTooltip = popover.add(
- target,
- Tooltip,
- { tooltip, template, info },
- { position }
- );
- }, OPEN_DELAY);
}
whenReady(() => {
// Regularly check that the target is still in the DOM and we're still
// hovering it, because if not, we have to close the tooltipd
browser.setInterval(() => {
- if (!target) {
- return;
- }
- if (!document.body.contains(target)) {
- return cleanup(); // target is no longer in the DOM
- }
- const targetRect = target.getBoundingClientRect();
- if (
- positionX < targetRect.left ||
- positionX > targetRect.right ||
- positionY < targetRect.top ||
- positionY > targetRect.bottom
- ) {
- return cleanup(); // mouse is no longer hovering the target
+ if (shouldCleanup()) {
+ cleanup();
}
}, CLOSE_DELAY);
@@ -119,6 +168,18 @@ export const tooltipService = {
// the "data-tooltip" or "data-tooltip-template" attribute, to open the tooltip.
document.body.addEventListener("mouseenter", onMouseenter, { capture: true });
});
+
+ return {
+ add(el, params) {
+ elementsWithTooltips.set(el, params);
+ return () => {
+ elementsWithTooltips.delete(el);
+ if (target === el) {
+ cleanup();
+ }
+ };
+ },
+ };
},
};
diff --git a/addons/web/static/src/core/ui/ui_service.js b/addons/web/static/src/core/ui/ui_service.js
index 70e5245e707..6ad6ce1e9ec 100644
--- a/addons/web/static/src/core/ui/ui_service.js
+++ b/addons/web/static/src/core/ui/ui_service.js
@@ -55,7 +55,7 @@ export const MEDIAS_BREAKPOINTS = [
*
* @returns {MediaQueryList[]}
*/
- export function getMediaQueryLists() {
+export function getMediaQueryLists() {
return MEDIAS_BREAKPOINTS.map(({ minWidth, maxWidth }) => {
if (!maxWidth) {
return window.matchMedia(`(min-width: ${minWidth}px)`);
@@ -72,11 +72,9 @@ const MEDIAS = getMediaQueryLists();
export const uiService = {
getSize() {
- return MEDIAS.findIndex(media => media.matches);
+ return MEDIAS.findIndex((media) => media.matches);
},
start(env) {
- let ui = {};
-
// block/unblock code
const bus = new EventBus();
registry.category("main_components").add("BlockUI", { Component: BlockUI, props: { bus } });
@@ -101,18 +99,6 @@ export const uiService = {
}
}
- Object.assign(ui, {
- bus,
- block,
- unblock,
- });
-
- Object.defineProperty(ui, "isBlocked", {
- get() {
- return blockCount > 0;
- },
- });
-
// UI active element code
let activeElems = [document];
@@ -132,34 +118,34 @@ export const uiService = {
}
}
- Object.assign(ui, {
+ const ui = {
+ bus,
+ size: this.getSize(),
+ get activeElement() {
+ return activeElems[activeElems.length - 1];
+ },
+ get isBlocked() {
+ return blockCount > 0;
+ },
+ get isSmall() {
+ return ui.size <= SIZES.SM;
+ },
+ block,
+ unblock,
activateElement,
deactivateElement,
getActiveElementOf,
- });
- Object.defineProperty(ui, "activeElement", {
- get() {
- return activeElems[activeElems.length - 1];
- },
- });
+ };
// listen to media query status changes
- function updateSize() {
+ const updateSize = () => {
ui.size = this.getSize();
- }
- browser.addEventListener("resize", debounce(updateSize.bind(this), 100));
+ };
+ browser.addEventListener("resize", debounce(updateSize, 100));
- Object.assign(ui, {
- size: this.getSize(),
- });
- Object.defineProperty(ui, "isSmall", {
- get() {
- return ui.size <= SIZES.SM;
- },
- });
Object.defineProperty(env, "isSmall", {
get() {
- return ui.size <= SIZES.SM;
+ return ui.isSmall;
},
});
diff --git a/addons/web/static/src/core/utils/arrays.js b/addons/web/static/src/core/utils/arrays.js
index f73d4cc121c..da7e771c7b3 100644
--- a/addons/web/static/src/core/utils/arrays.js
+++ b/addons/web/static/src/core/utils/arrays.js
@@ -151,6 +151,24 @@ export function cartesian() {
}
}
+/**
+ * Shallow compares two arrays.
+ * @param {any[]} arrayA
+ * @param {any[]} arrayB
+ * @returns {boolean} true iff arrayA and arrayB are shallow equal
+ */
+export function shallowEqual(arrayA, arrayB) {
+ if (arrayA.length !== arrayB.length) {
+ return false;
+ }
+ for (let i = 0; i < arrayA.length; i++) {
+ if (arrayA[i] !== arrayB[i]) {
+ return false;
+ }
+ }
+ return true;
+}
+
/**
* Returns all initial sections of a given array, e.g. for [1, 2] the array
* [[], [1], [1, 2]] is returned.
diff --git a/addons/web/static/src/core/utils/concurrency.js b/addons/web/static/src/core/utils/concurrency.js
index b673cb6b8b4..9fa431c121a 100644
--- a/addons/web/static/src/core/utils/concurrency.js
+++ b/addons/web/static/src/core/utils/concurrency.js
@@ -154,3 +154,18 @@ export class Race {
return this.currentProm;
}
}
+
+/**
+ * Deferred is basically a resolvable/rejectable extension of Promise.
+ */
+export class Deferred {
+ constructor() {
+ let resolve;
+ let reject;
+ const prom = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return Object.assign(prom, { resolve, reject });
+ }
+}
diff --git a/addons/web/static/src/core/utils/hooks.js b/addons/web/static/src/core/utils/hooks.js
index 48825e95ec8..381b80061cb 100644
--- a/addons/web/static/src/core/utils/hooks.js
+++ b/addons/web/static/src/core/utils/hooks.js
@@ -2,7 +2,7 @@
import { SERVICES_METADATA } from "@web/env";
-const { status, useComponent, useEffect, useRef } = owl;
+const { status, useComponent, useEffect, useRef, onWillUnmount } = owl;
/**
* This file contains various custom hooks.
@@ -31,7 +31,7 @@ const { status, useComponent, useEffect, useRef } = owl;
*/
export function useAutofocus() {
const comp = useComponent();
- let ref = useRef("autofocus");
+ const ref = useRef("autofocus");
// Prevent autofocus in mobile
if (comp.env.isSmall) {
return ref;
@@ -110,7 +110,7 @@ export function useBus(bus, eventName, callback) {
*
* @param {string} eventName the name of the event
* @param {string} [querySelector] a JS native selector for event delegation
- * @param {function} handler the event handler (will be bound to the component)
+ * @param {function} [handler] the event handler (will be bound to the component)
* @param {Object} [options] to be passed to addEventListener as options.
* Useful for listening in the capture phase
*/
@@ -190,7 +190,7 @@ export function useService(serviceName) {
} else {
const methods = SERVICES_METADATA[serviceName];
const result = Object.create(service);
- for (let method of methods) {
+ for (const method of methods) {
result[method] = _protectMethod(component, service, service[method]);
}
return result;
@@ -198,3 +198,62 @@ export function useService(serviceName) {
}
return service;
}
+
+/**
+ * @typedef {Function} ForwardRef
+ * @property {HTMLElement | undefined} el
+ */
+
+/**
+ * Use a ref that was forwarded by a child @see useForwardRefToParent
+ *
+ * @returns {ForwardRef} a ref that can be called to set its value to that of a
+ * child ref, but can otherwise be used as a normal ref object
+ */
+export function useChildRef() {
+ let defined = false;
+ return function ref(value) {
+ if (defined) {
+ return;
+ }
+ Object.defineProperty(ref, "el", {
+ get() {
+ return value.el;
+ },
+ });
+ defined = true;
+ };
+}
+/**
+ * Forwards the given refName to the parent by calling the corresponding
+ * ForwardRef received as prop. @see useChildRef
+ *
+ * @param {string} refName name of the ref to forward
+ * @returns {{el: HTMLelement | null}} the same ref that is forwarded to the
+ * parent
+ */
+export function useForwardRefToParent(refName) {
+ const component = useComponent();
+ const ref = useRef(refName);
+ if (component.props[refName]) {
+ component.props[refName](ref);
+ }
+ return ref;
+}
+/**
+ * Use the dialog service while also automatically closing the dialogs opened
+ * by the current component when it is unmounted.
+ */
+export function useOwnedDialogs() {
+ const dialogService = useService("dialog");
+ const cbs = [];
+ onWillUnmount(() => {
+ cbs.forEach((cb) => cb());
+ });
+ const addDialog = (...args) => {
+ const close = dialogService.add(...args);
+ cbs.push(close);
+ return close;
+ };
+ return addDialog;
+}
diff --git a/addons/web/static/src/core/utils/numbers.js b/addons/web/static/src/core/utils/numbers.js
index 38a9d09e7da..a7bc46b8676 100644
--- a/addons/web/static/src/core/utils/numbers.js
+++ b/addons/web/static/src/core/utils/numbers.js
@@ -23,10 +23,10 @@ export function computeVariation(value, comparisonValue) {
/**
* Returns value clamped to the inclusive range of min and max.
- *
+ *
* @param {Number} num
* @param {Number} min
- * @param {Number} max
+ * @param {Number} max
* @returns {Number}
*/
export function clamp(num, min, max) {
diff --git a/addons/web/static/src/core/utils/objects.js b/addons/web/static/src/core/utils/objects.js
index 4e7638b0b53..eaf24f0bf8d 100644
--- a/addons/web/static/src/core/utils/objects.js
+++ b/addons/web/static/src/core/utils/objects.js
@@ -21,3 +21,13 @@ export function shallowEqual(obj1, obj2) {
export function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
+
+/**
+ * @template {T}
+ * @param {T} object
+ * @param {...(keyof T)} properties
+ * @returns {Partial}
+ */
+export function pluck(object, ...properties) {
+ return Object.fromEntries(Object.entries(object).filter(([k]) => properties.includes(k)));
+}
diff --git a/addons/web/static/src/core/utils/render.js b/addons/web/static/src/core/utils/render.js
index 42820e40b24..d5ff1c1a90d 100644
--- a/addons/web/static/src/core/utils/render.js
+++ b/addons/web/static/src/core/utils/render.js
@@ -1,15 +1,15 @@
/** @odoo-module **/
-const { blockDom } = owl;
+const { blockDom, markup } = owl;
/**
- * loads a template using getTemplate, and render the template immediately;
+ * renders a template with an (optional) context and returns a Markup string,
+ * suitable to be inserted in a template with a t-out directive
*
* @param {string} template
* @param {Object} context
* @returns string: the html of the template
*/
-
export function renderToString(template, context = {}) {
const app = renderToString.app;
if (!app) {
@@ -21,3 +21,15 @@ export function renderToString(template, context = {}) {
blockDom.mount(bdom, div);
return div.innerHTML;
}
+
+/**
+ * renders a template with an (optional) context and returns a Markup string,
+ * suitable to be inserted in a template with a t-out directive
+ *
+ * @param {string} template
+ * @param {Object} context
+ * @returns string: the html of the template, as a markup string
+ */
+export function renderToMarkup(template, context = {}) {
+ return markup(renderToString(template, context));
+}
diff --git a/addons/web/static/src/core/utils/sortable.js b/addons/web/static/src/core/utils/sortable.js
new file mode 100644
index 00000000000..e3c78a3f694
--- /dev/null
+++ b/addons/web/static/src/core/utils/sortable.js
@@ -0,0 +1,552 @@
+/** @odoo-module **/
+
+import { clamp } from "@web/core/utils/numbers";
+import { debounce } from "@web/core/utils/timing";
+
+const { useEffect, useEnv, useExternalListener, onWillUnmount } = owl;
+
+/**
+ * @typedef SortableParams
+ *
+ * MANDATORY
+ *
+ * @property {{ el: HTMLElement | null }} ref
+ * @property {string} elements defines sortable elements
+ *
+ * OPTIONAL
+ *
+ * @property {boolean | () => boolean} [enable] whether the sortable system should
+ * be enabled.
+ * @property {string | () => string} [groups] defines parent groups of sortable
+ * elements. This allows to add `onGroupEnter` and `onGroupLeave` callbacks to
+ * work on group elements during the dragging sequence.
+ * @property {string | () => string} [handle] additional selector for when the dragging
+ * sequence must be initiated when dragging on a certain part of the element.
+ * @property {boolean | () => boolean} [connectGroups] whether elements can be dragged
+ * accross different parent groups. Note that it requires a `groups` param to work.
+ * @property {string | () => string} [cursor] cursor style during the dragging sequence.
+ *
+ * HANDLERS (also optional)
+ *
+ * @property {(group: HTMLElement | null, element: HTMLElement) => any} [onStart]
+ * called when a dragging sequence is initiated.
+ * @property {(element: HTMLElement) => any} [onElementEnter] called when the cursor
+ * enters another sortable element.
+ * @property {(element: HTMLElement) => any} [onElementLeave] called when the cursor
+ * leaves another sortable element.
+ * @property {(group: HTMLElement) => any} [onGroupEnter] (if a `groups` is specified):
+ * will be called when the cursor enters another group element.
+ * @property {(group: HTMLElement) => any} [onGroupLeave] (if a `groups` is specified):
+ * will be called when the cursor leaves another group element.
+ * @property {(group: HTMLElement | null, element: HTMLElement) => any} [onStop]
+ * called when the dragging sequence ends, regardless of the reason.
+ * @property {(params: DropParams) => any} [onDrop] called when the dragging sequence
+ * ends on a mouseup action AND the dragged element has been moved elsewhere. The
+ * callback will be given an object with any useful element regarding the new position
+ * of the dragged element (@see DropParams ).
+ */
+
+/**
+ * @typedef DropParams
+ * @property {HTMLElement} element
+ * @property {HTMLElement | null} group
+ * @property {HTMLElement | null} previous
+ * @property {HTMLElement | null} next
+ * @property {HTMLElement | null} parent
+ */
+
+const LEFT_CLICK = 0;
+const MANDATORY_SORTABLE_PARAMS = ["ref", "elements"];
+const SORTABLE_PARAMS = {
+ enable: ["boolean", "function"],
+ ref: ["object"],
+ elements: ["string"],
+ groups: ["string", "function"],
+ handle: ["string", "function"],
+ connectGroups: ["boolean", "function"],
+ cursor: ["string"],
+};
+
+/**
+ * Cancels the default behavior and propagation of a given event.
+ * @param {Event} ev
+ */
+function cancelEvent(ev) {
+ ev.stopPropagation();
+ ev.stopImmediatePropagation();
+ ev.preventDefault();
+}
+
+/**
+ * @param {SortableParams} params
+ * @returns {[string, string | boolean][]}
+ */
+function computeParams(params) {
+ const computedParams = { enable: true };
+ for (const prop in SORTABLE_PARAMS) {
+ if (prop in params) {
+ computedParams[prop] = params[prop];
+ if (typeof params[prop] === "function") {
+ computedParams[prop] = computedParams[prop]();
+ }
+ }
+ }
+ return Object.entries(computedParams);
+}
+
+/**
+ * Converts a CSS pixel value to a number, removing the 'px' part.
+ * @param {string} val
+ * @returns {number}
+ */
+function cssValueToNumber(val) {
+ return Number(val.slice(0, -2));
+}
+
+/**
+ * Basic error builder for the sortable hook.
+ * @param {string} reason
+ * @returns {Error}
+ */
+function sortableError(reason) {
+ return new Error(`Unable to use sortable feature: ${reason}.`);
+}
+
+/**
+ * Sortable feature hook.
+ *
+ * This hook needs 2 things to work:
+ *
+ * 1) a `ref` object (@see owl.useRef) which will be used as the root element to
+ * calculate boundaries of dragged elements;
+ *
+ * 2) an `elements` selector string or function that will determine which elements
+ * are sortable in the reference element.
+ *
+ * All other parameters are optional and define the constraints of the dragged elements
+ * (and the appearance of the cursor during a dragging sequence), or the different
+ * available handlers triggered during the drag sequence.
+ * @see SortableParams
+ *
+ * @param {SortableParams} params
+ */
+export function useSortable(params) {
+ const env = useEnv();
+ const { ref } = params;
+ /** @type {(() => any)[]} */
+ const cleanups = [];
+
+ // Basic error handling asserting that the parameters are valid.
+ for (const prop in SORTABLE_PARAMS) {
+ if (params[prop] && !SORTABLE_PARAMS[prop].includes(typeof params[prop])) {
+ throw sortableError(`invalid type for property "${prop}" in parameters`);
+ } else if (!params[prop] && MANDATORY_SORTABLE_PARAMS.includes(prop)) {
+ throw sortableError(`missing required property "${prop}" in parameters`);
+ }
+ }
+
+ /**
+ * Stores the current element selector.
+ * @type {string | null}
+ */
+ let groupSelector = null;
+ /**
+ * Stores the current group selector (optional).
+ * @type {string | null}
+ */
+ let elementSelector = null;
+ /**
+ * Stores the full selector used to initiate a drag sequence.
+ * @type {string | null}
+ */
+ let fullSelector = null;
+
+ /**
+ * Stores the style of the cursor, if defined.
+ * @type {string | null}
+ */
+ let cursor = null;
+ /**
+ * Stores whether the sortable elements can be dragged in different groups.
+ * @type {boolean}
+ */
+ let connectGroups = false;
+ /**
+ * Stores the position and dimensions of the confining element (ref or
+ * parent).
+ * @type {DOMRect | null}
+ */
+ let currentContainerRect = null;
+
+ /**
+ * Stores the current dragged element.
+ * @type {HTMLElement | null}
+ */
+ let currentElement = null;
+ /**
+ * Stores the dimensions and position of the dragged element.
+ * @type {DOMRect | null}
+ */
+ let currentElementRect = null;
+ /**
+ * Stores the group in which the current element originated.
+ * @type {HTMLElement | null}
+ */
+ let currentGroup = null;
+ /**
+ * Stores the ghost element taking place of the actual dragged element.
+ * @type {HTMLElement | null}
+ */
+ let ghost = null;
+
+ /**
+ * Stores whether a drag sequence can be initiated.
+ * This is determined by both the given ref being in the document and the
+ * `setup` function returning the required params (namely: `elements`).
+ * @type {boolean}
+ */
+ let enabled = false;
+ /**
+ * Stores whether a drag sequence has been initiated.
+ * @type {boolean}
+ */
+ let started = false;
+
+ /**
+ * These 2 variables store the initial offset between the initial mousedown
+ * position and the top-left corner of the dragged element.
+ */
+ /** @type {number} */
+ let offsetX = 0;
+ /** @type {number} */
+ let offsetY = 0;
+
+ /**
+ * Adds an event listener to be cleaned up after the next drag sequence
+ * has stopped. An additionnal `timeout` param allows the handler to be
+ * delayed after a timeout.
+ * @param {EventTarget} el
+ * @param {string} event
+ * @param {(...args: any[]) => any} callback
+ * @param {boolean | Record} [options]
+ */
+ const addListener = (el, event, callback, options) => {
+ el.addEventListener(event, callback, options);
+ cleanups.push(() => el.removeEventListener(event, callback, options));
+ };
+
+ /**
+ * Adds style to an element to be cleaned up after the next drag sequence has
+ * stopped.
+ * @param {HTMLElement} el
+ * @param {Record} style
+ */
+ const addStyle = (el, style) => {
+ const originalStyle = el.getAttribute("style");
+ cleanups.push(() =>
+ originalStyle ? el.setAttribute("style", originalStyle) : el.removeAttribute("style")
+ );
+ for (const key in style) {
+ el.style[key] = style[key];
+ }
+ };
+
+ /**
+ * Safely executes a handler from the `params`, so that the drag sequence can
+ * be interrupted if an error occurs.
+ * @param {string} callbackName
+ * @param {...any} args
+ */
+ const execHandler = (callbackName, ...args) => {
+ if (typeof params[callbackName] === "function") {
+ try {
+ params[callbackName](...args);
+ } catch (err) {
+ dragStop(true, true);
+ throw err;
+ }
+ }
+ };
+
+ /**
+ * Element "mouseenter" event handler.
+ * @param {MouseEvent} ev
+ */
+ const onElementMouseenter = (ev) => {
+ const element = ev.currentTarget;
+ if (connectGroups || !groupSelector || currentGroup === element.closest(groupSelector)) {
+ const pos = ghost.compareDocumentPosition(element);
+ if (pos === 2 /* BEFORE */) {
+ element.before(ghost);
+ } else if (pos === 4 /* AFTER */) {
+ element.after(ghost);
+ }
+ }
+ execHandler("onElementEnter", element);
+ };
+
+ /**
+ * Element "mouseleave" event handler.
+ * @param {MouseEvent} ev
+ */
+ const onElementMouseleave = (ev) => {
+ const element = ev.currentTarget;
+ execHandler("onElementLeave", element);
+ };
+
+ /**
+ * Group "mouseenter" event handler.
+ * @param {MouseEvent} ev
+ */
+ const onGroupMouseenter = (ev) => {
+ const group = ev.currentTarget;
+ group.appendChild(ghost);
+ execHandler("onGroupEnter", group);
+ };
+
+ /**
+ * Group "mouseleave" event handler.
+ * @param {MouseEvent} ev
+ */
+ const onGroupMouseleave = (ev) => {
+ const group = ev.currentTarget;
+ execHandler("onGroupLeave", group);
+ };
+
+ /**
+ * Window "keydown" event handler.
+ * @param {KeyboardEvent} ev
+ */
+ const onKeydown = (ev) => {
+ if (!enabled || !started) {
+ return;
+ }
+ switch (ev.key) {
+ case "Escape":
+ case "Tab": {
+ cancelEvent(ev);
+ dragStop(true);
+ }
+ }
+ };
+
+ /**
+ * Global (= ref) "mousedown" event handler.
+ * @param {MouseEvent} ev
+ */
+ const onMousedown = (ev) => {
+ // A drag sequence can still be in progress if the mouseup occurred
+ // outside of the window.
+ dragStop(true);
+
+ if (ev.button !== LEFT_CLICK || !enabled || !ev.target.closest(fullSelector)) {
+ return;
+ }
+
+ currentElement = ev.target.closest(elementSelector);
+ currentGroup = groupSelector && ev.target.closest(groupSelector);
+ offsetX = ev.clientX;
+ offsetY = ev.clientY;
+ };
+
+ /**
+ * Window "mousemove" event handler.
+ * @param {MouseEvent} ev
+ */
+ const onMousemove = (ev) => {
+ if (!enabled || !currentElement) {
+ return;
+ }
+ if (started) {
+ // Updates the position of the dragged element.
+ currentElement.style.left = `${clamp(
+ ev.clientX - offsetX,
+ currentContainerRect.x,
+ currentContainerRect.x + currentContainerRect.width - currentElementRect.width
+ )}px`;
+ currentElement.style.top = `${clamp(
+ ev.clientY - offsetY,
+ currentContainerRect.y,
+ currentContainerRect.y + currentContainerRect.height - currentElementRect.height
+ )}px`;
+ } else {
+ dragStart();
+ }
+ };
+
+ /**
+ * Window "mouseup" event handler.
+ */
+ const onMouseup = () => dragStop(false);
+
+ /**
+ * Main entry function to start a drag sequence.
+ */
+ const dragStart = () => {
+ started = true;
+
+ // Calculates the bounding rectangles of the current element, and of the
+ // container element (`parentElement` or `ref.el`).
+ const container = connectGroups || !groupSelector ? ref.el : currentGroup;
+ const containerStyle = getComputedStyle(container);
+ const [pleft, pright, ptop, pbottom] = [
+ "padding-left",
+ "padding-right",
+ "padding-top",
+ "padding-bottom",
+ ].map((prop) => cssValueToNumber(containerStyle.getPropertyValue(prop)));
+
+ currentElementRect = currentElement.getBoundingClientRect();
+ currentContainerRect = container.getBoundingClientRect();
+ const { x, y, width, height } = currentElementRect;
+
+ // Reduces the container's dimensions according to its padding.
+ currentContainerRect.x += pleft;
+ currentContainerRect.width -= pleft + pright;
+ currentContainerRect.y += ptop;
+ currentContainerRect.height -= ptop + pbottom;
+
+ // Prepares the ghost element
+ ghost = currentElement.cloneNode(true);
+ ghost.style.visibility = "hidden";
+ cleanups.push(() => ghost.remove());
+
+ // Binds handlers on eligible groups, if the elements are not confined to
+ // their parents and a 'groupSelector' has been provided.
+ if (connectGroups && groupSelector) {
+ for (const siblingGroup of ref.el.querySelectorAll(groupSelector)) {
+ addListener(siblingGroup, "mouseenter", onGroupMouseenter);
+ addListener(siblingGroup, "mouseleave", onGroupMouseleave);
+ addStyle(siblingGroup, { "pointer-events": "auto" });
+ }
+ }
+
+ // Binds handlers on eligible elements
+ for (const siblingElement of ref.el.querySelectorAll(elementSelector)) {
+ if (siblingElement !== currentElement && siblingElement !== ghost) {
+ addListener(siblingElement, "mouseenter", onElementMouseenter);
+ addListener(siblingElement, "mouseleave", onElementMouseleave);
+ addStyle(siblingElement, { "pointer-events": "auto" });
+ }
+ }
+
+ execHandler("onStart", currentGroup, currentElement);
+
+ // Ghost is initially added right after the current element.
+ currentElement.after(ghost);
+
+ // Adjusts the offset
+ offsetX -= x;
+ offsetY -= y;
+
+ addStyle(currentElement, {
+ position: "fixed",
+ "pointer-events": "none",
+ "z-index": 1000,
+ width: `${width}px`,
+ height: `${height}px`,
+ left: `${x}px`,
+ top: `${y}px`,
+ });
+
+ const bodyStyle = {
+ "pointer-events": "none",
+ "user-select": "none",
+ };
+ if (cursor) {
+ bodyStyle.cursor = cursor;
+ }
+ addStyle(document.body, bodyStyle);
+ };
+
+ /**
+ * Main exit function to stop a drag sequence. Note that it can be called
+ * even if a drag sequence did not start yet to perform a cleanup of all
+ * current context variables.
+ * @param {boolean} cancelled
+ * @param {boolean} [inErrorState] can be set to true when an error
+ * occurred to avoid falling into an infinite loop if the error
+ * originated from one of the handlers.
+ */
+ const dragStop = (cancelled, inErrorState) => {
+ if (started) {
+ if (!inErrorState) {
+ execHandler("onStop", currentGroup, currentElement);
+ const previous = ghost.previousElementSibling;
+ const next = ghost.nextElementSibling;
+ if (!cancelled && previous !== currentElement && next !== currentElement) {
+ execHandler("onDrop", {
+ group: currentGroup,
+ element: currentElement,
+ previous,
+ next,
+ parent: groupSelector && ghost.closest(groupSelector),
+ });
+ }
+ }
+ }
+
+ // Performes all registered clean-ups.
+ while (cleanups.length) {
+ cleanups.pop()();
+ }
+
+ currentContainerRect = null;
+
+ currentElement = null;
+ currentElementRect = null;
+ currentGroup = null;
+ ghost = null;
+
+ started = false;
+ };
+
+ // OWL HOOKS
+
+ // Effect depending on the params to update them.
+ useEffect(
+ (...deps) => {
+ const actualParams = Object.fromEntries(deps);
+ enabled = Boolean(ref.el && !env.isSmall && actualParams.enable);
+ if (!enabled) {
+ return;
+ }
+
+ // Selectors
+ elementSelector = actualParams.elements;
+ groupSelector = actualParams.groups || null;
+ if (!elementSelector) {
+ throw sortableError(`no value found by "elements" selector: ${elementSelector}`);
+ }
+ const allSelectors = [elementSelector];
+ cursor = actualParams.cursor;
+ if (groupSelector) {
+ allSelectors.unshift(groupSelector);
+ }
+ if (actualParams.handle) {
+ allSelectors.push(actualParams.handle);
+ }
+ fullSelector = allSelectors.join(" ");
+
+ // Connection accross groups
+ connectGroups = actualParams.connectGroups;
+ },
+ () => computeParams(params)
+ );
+ // Effect depending on the `ref.el` to add triggering mouse events listener.
+ useEffect(
+ (el) => {
+ if (el) {
+ el.addEventListener("mousedown", onMousedown);
+ return () => el.removeEventListener("mousedown", onMousedown);
+ }
+ },
+ () => [ref.el]
+ );
+ // Other global mouse event listeners.
+ const debouncedMousemove = debounce(onMousemove, "animationFrame", true);
+ useExternalListener(window, "mousemove", debouncedMousemove);
+ useExternalListener(window, "mouseup", onMouseup);
+ useExternalListener(window, "keydown", onKeydown, true);
+ onWillUnmount(() => dragStop(true));
+}
diff --git a/addons/web/static/src/core/utils/strings.js b/addons/web/static/src/core/utils/strings.js
index 879355ccebc..a53307c68a9 100644
--- a/addons/web/static/src/core/utils/strings.js
+++ b/addons/web/static/src/core/utils/strings.js
@@ -1,5 +1,7 @@
/** @odoo-module **/
+export const nbsp = "\u00a0";
+
/**
* Escapes a string for HTML.
* Note that it doesn't work for escaping node attributes.
@@ -91,9 +93,9 @@ export function intersperse(str, indices, separator = "") {
export function sprintf(s, ...values) {
if (values.length === 1 && Object.prototype.toString.call(values[0]) === "[object Object]") {
const valuesDict = values[0];
- s = s.replace(/\%\(?([^\)]+)\)s/g, (match, value) => valuesDict[value]);
+ s = s.replace(/%\(?([^)]+)\)s/g, (match, value) => valuesDict[value]);
} else if (values.length > 0) {
- s = s.replace(/\%s/g, () => values.shift());
+ s = s.replace(/%s/g, () => values.shift());
}
return s;
}
@@ -108,6 +110,7 @@ export function capitalize(s) {
return s ? s[0].toUpperCase() + s.slice(1) : "";
}
+/* eslint-disable */
// prettier-ignore
const diacriticsMap = {
'\u0041': 'A','\u24B6': 'A','\uFF21': 'A','\u00C0': 'A','\u00C1': 'A','\u00C2': 'A','\u1EA6': 'A','\u1EA4': 'A','\u1EAA': 'A','\u1EA8': 'A',
diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js
index 5d9101335fa..b40470dab23 100644
--- a/addons/web/static/src/core/utils/timing.js
+++ b/addons/web/static/src/core/utils/timing.js
@@ -1,6 +1,8 @@
/** @odoo-module **/
-import { browser } from "../browser/browser";
+import { browser } from "@web/core/browser/browser";
+
+const { onWillUnmount, useComponent } = owl;
/**
* Creates a version of the function where only the last call between two
@@ -31,50 +33,6 @@ export function throttleForAnimation(func) {
);
}
-/**
- * Creates and returns a new debounced version of the passed function (func)
- * which will postpone its execution until after wait milliseconds
- * have elapsed since the last time it was invoked. The debounced function
- * will return a Promise that will be resolved when the function (func)
- * has been fully executed. If `immediate` is passed, trigger the function
- * on the leading edge, instead of the trailing.
- *
- * @template T the return type of the original function
- * @param {(...args) => T} func the function to debounce
- * @param {number} wait how long should elapse before the function is called
- * @param {boolean} [immediate=false] whether the function should be called on
- * the leading edge instead of the trailing edge.
- * @returns {{ (...args): Promise, cancel: () => void }} the debounced function
- */
-export function debounce(func, wait, immediate = false) {
- let timeout;
- const funcName = func.name ? func.name + " (debounce)" : "debounce";
- return Object.assign(
- {
- [funcName](...args) {
- return new Promise((resolve) => {
- const callNow = immediate && !timeout;
- browser.clearTimeout(timeout);
- timeout = browser.setTimeout(() => {
- timeout = null;
- if (!immediate) {
- Promise.resolve(func.apply(this, args)).then(resolve);
- }
- }, wait);
- if (callNow) {
- Promise.resolve(func.apply(this, args)).then(resolve);
- }
- });
- },
- }[funcName],
- {
- cancel() {
- browser.clearTimeout(timeout);
- },
- }
- );
-}
-
/**
* Returns a function, that, as long as it continues to be invoked, will be
* triggered every N milliseconds.
@@ -82,10 +40,10 @@ export function debounce(func, wait, immediate = false) {
* @deprecated this function has behaviour that is unexpected considering its
* name, prefer _.throttle until this function is rewritten
* @param {Function} func
- * @param {number} wait
+ * @param {number} delay
* @returns {Function}
*/
-export function throttle(func, wait) {
+export function throttle(func, delay) {
let waiting = false;
const funcName = func.name ? func.name + " (throttle)" : "throttle";
return {
@@ -96,8 +54,69 @@ export function throttle(func, wait) {
browser.setTimeout(function () {
waiting = false;
func.call(context, ...args);
- }, wait);
+ }, delay);
}
},
}[funcName];
}
+
+/**
+ * Creates and returns a new debounced version of the passed function (func)
+ * which will postpone its execution until after 'delay' milliseconds
+ * have elapsed since the last time it was invoked. The debounced function
+ * will return a Promise that will be resolved when the function (func)
+ * has been fully executed. If `immediate` is passed, trigger the function
+ * on the leading edge, instead of the trailing.
+ *
+ * @template T the return type of the original function
+ * @param {(...args) => T} func the function to debounce
+ * @param {number | "animationFrame"} delay how long should elapse before the function
+ * is called. If 'animationFrame' is given instead of a number, 'requestAnimationFrame'
+ * will be used instead of 'setTimeout'.
+ * @param {boolean} [immediate=false] whether the function should be called on
+ * the leading edge instead of the trailing edge.
+ * @returns {{ (...args): Promise, cancel: () => void }} the debounced function
+ */
+export function debounce(func, delay, immediate = false) {
+ let handle;
+ const funcName = func.name ? func.name + " (debounce)" : "debounce";
+ const useAnimationFrame = delay === "animationFrame";
+ const setFnName = useAnimationFrame ? "requestAnimationFrame" : "setTimeout";
+ const clearFnName = useAnimationFrame ? "cancelAnimationFrame" : "clearTimeout";
+ return Object.assign(
+ {
+ [funcName](...args) {
+ return new Promise((resolve) => {
+ const callNow = immediate && !handle;
+ browser[clearFnName](handle);
+ handle = browser[setFnName](() => {
+ handle = null;
+ if (!immediate) {
+ Promise.resolve(func.apply(this, args)).then(resolve);
+ }
+ }, delay);
+ if (callNow) {
+ Promise.resolve(func.apply(this, args)).then(resolve);
+ }
+ });
+ },
+ }[funcName],
+ {
+ cancel() {
+ browser[clearFnName](handle);
+ },
+ }
+ );
+}
+
+/**
+ * Hook that returns a debounced version of the given function, and cancels
+ * the potential pending execution on willUnmount.
+ * @see debounce
+ */
+export function useDebounced(callback, delay) {
+ const component = useComponent();
+ const debounced = debounce(callback.bind(component), delay);
+ onWillUnmount(() => debounced.cancel());
+ return debounced;
+}
diff --git a/addons/web/static/src/core/utils/ui.js b/addons/web/static/src/core/utils/ui.js
index 418d04295e2..09740b0f4da 100644
--- a/addons/web/static/src/core/utils/ui.js
+++ b/addons/web/static/src/core/utils/ui.js
@@ -1,13 +1,15 @@
/** @odoo-module **/
/**
- * @param {Document} activeElement
- * @param {DOMString} selector
+ * @param {Element} activeElement
+ * @param {String} selector
* @returns all selected and visible elements present in the activeElement
*/
export function getVisibleElements(activeElement, selector) {
const visibleElements = [];
- for (const el of activeElement.querySelectorAll(selector)) {
+ /** @type {NodeListOf} */
+ const elements = activeElement.querySelectorAll(selector);
+ for (const el of elements) {
const isVisible = el.offsetWidth > 0 && el.offsetHeight > 0;
if (isVisible) {
visibleElements.push(el);
@@ -15,3 +17,45 @@ export function getVisibleElements(activeElement, selector) {
}
return visibleElements;
}
+
+// -----------------------------------------------------------------------------
+// Get Tabable Elements
+// -----------------------------------------------------------------------------
+// TODISCUSS:
+// - leave the following in this file ?
+// - redefine this selector in tests env with ":not(#qunit *)" ?
+
+// Following selector is based on this spec: https://html.spec.whatwg.org/multipage/interaction.html#dom-tabindex
+let TABABLE_SELECTOR = "[tabindex], a, area, button, frame, iframe, input, object, select, textarea, details > summary:nth-child(1),"
+ .split(",")
+ .join(':not([tabindex="-1"]):not(:disabled),');
+TABABLE_SELECTOR = TABABLE_SELECTOR.slice(0, TABABLE_SELECTOR.length - 1);
+
+export function getTabableElements(container = document.body) {
+ const elements = container.querySelectorAll(TABABLE_SELECTOR);
+ const byTabIndex = {};
+ for (const el of [...elements]) {
+ if (!byTabIndex[el.tabIndex]) {
+ byTabIndex[el.tabIndex] = [];
+ }
+ byTabIndex[el.tabIndex].push(el);
+ }
+
+ const withTabIndexZero = byTabIndex[0] || [];
+ delete byTabIndex[0];
+ return [...Object.values(byTabIndex).flat(), ...withTabIndexZero];
+}
+
+export function getNextTabableElement(container = document.body) {
+ const tabableElements = getTabableElements(container);
+ const index = tabableElements.indexOf(document.activeElement);
+ return index === -1 ? tabableElements[0] : tabableElements[index + 1] || null;
+}
+
+export function getPreviousTabableElement(container = document.body) {
+ const tabableElements = getTabableElements(container);
+ const index = tabableElements.indexOf(document.activeElement);
+ return index === -1
+ ? tabableElements[tabableElements.length - 1]
+ : tabableElements[index - 1] || null;
+}
diff --git a/addons/web/static/src/core/utils/urls.js b/addons/web/static/src/core/utils/urls.js
index fb78bd9ad5c..48fc9e2196f 100644
--- a/addons/web/static/src/core/utils/urls.js
+++ b/addons/web/static/src/core/utils/urls.js
@@ -3,7 +3,7 @@
import { browser } from "../browser/browser";
/**
- * Trasnforms a key value mapping to a string formatted as url hash, e.g.
+ * Transforms a key value mapping to a string formatted as url hash, e.g.
* {a: "x", b: 2} -> "a=x&b=2"
*
* @param {Object} obj
@@ -11,7 +11,7 @@ import { browser } from "../browser/browser";
*/
export function objectToUrlEncodedString(obj) {
return Object.entries(obj)
- .map(([k, v]) => (v ? `${k}=${encodeURIComponent(v)}` : k))
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v || "")}`)
.join("&");
}
diff --git a/addons/web/static/src/core/utils/xml.js b/addons/web/static/src/core/utils/xml.js
index 91bcccf8b62..01cd2a3b26b 100644
--- a/addons/web/static/src/core/utils/xml.js
+++ b/addons/web/static/src/core/utils/xml.js
@@ -1,5 +1,12 @@
/** @odoo-module **/
+/**
+ * XML document to create new elements from. The fact that this is a "text/xml"
+ * document ensures that tagNames and attribute names are case sensitive.
+ */
+const parser = new DOMParser();
+const xmlDocument = parser.parseFromString(" ", "text/xml");
+
function hasParsingError(parsedDocument) {
return parsedDocument.getElementsByTagName("parsererror").length > 0;
}
@@ -11,29 +18,37 @@ export class XMLParser {
*/
parse() {}
+ /**
+ * @param {Element | string} xml
+ * @param {(el: Element, visitChildren: () => any) => any} callback
+ */
visitXML(xml, callback) {
- function visit(xml) {
- if (xml) {
+ const visit = (el) => {
+ if (el) {
let didVisitChildren = false;
const visitChildren = () => {
- for (let child of xml.children) {
+ for (const child of el.children) {
visit(child);
}
didVisitChildren = true;
};
- const shouldVisitChildren = callback(xml, visitChildren);
+ const shouldVisitChildren = callback(el, visitChildren);
if (shouldVisitChildren !== false && !didVisitChildren) {
visitChildren();
}
}
- }
+ };
const xmlDoc = typeof xml === "string" ? this.parseXML(xml) : xml;
visit(xmlDoc);
}
+ /**
+ * @param {string} arch
+ * @returns {Element}
+ */
parseXML(arch) {
- const parser = new DOMParser();
- const xml = parser.parseFromString(arch, "text/xml");
+ const cleanedArch = arch.replace(/ /g, "");
+ const xml = parser.parseFromString(cleanedArch, "text/xml");
if (hasParsingError(xml)) {
throw new Error(
`An error occured while parsing ${arch}: ${xml.getElementsByTagName("parsererror")}`
@@ -42,3 +57,92 @@ export class XMLParser {
return xml.documentElement;
}
}
+
+/**
+ * @param {Element} parent
+ * @param {Node | Node[] | void} node
+ */
+export function append(parent, node) {
+ const nodes = Array.isArray(node) ? node : [node];
+ parent.append(...nodes.filter(Boolean));
+ return parent;
+}
+
+/**
+ * Combines the existing value of a node attribute with new given parts. The glue
+ * is the string used to join the parts.
+ *
+ * @param {Element} el
+ * @param {string} attr
+ * @param {string | string[]} parts
+ * @param {string} [glue=" "]
+ */
+export function combineAttributes(el, attr, parts, glue = " ") {
+ const allValues = [];
+ if (el.hasAttribute(attr)) {
+ allValues.push(el.getAttribute(attr));
+ }
+ allValues.push(...(Array.isArray(parts) ? parts : [parts]));
+ el.setAttribute(attr, allValues.join(glue));
+}
+
+/**
+ * XML equivalent of `document.createElement`.
+ *
+ * @param {string} tagName
+ * @param {...(Iterable | Record)} args
+ * @returns {Element}
+ */
+export function createElement(tagName, ...args) {
+ const el = xmlDocument.createElement(tagName);
+ for (const arg of args) {
+ if (!arg) {
+ continue;
+ }
+ if (Symbol.iterator in arg) {
+ // Children list
+ el.append(...arg);
+ } else if (typeof arg === "object") {
+ // Attributes
+ for (const name in arg) {
+ el.setAttribute(name, arg[name]);
+ }
+ }
+ }
+ return el;
+}
+
+/**
+ * XML equivalent of `document.createTextNode`.
+ *
+ * @param {string} data
+ * @returns {Text}
+ */
+export function createTextNode(data) {
+ return xmlDocument.createTextNode(data);
+}
+
+/**
+ * Removes the given attributes on the given element and returns them as a dictionnary.
+ * @param {Element} el
+ * @param {string[]} attributes
+ * @returns {Record}
+ */
+export function extractAttributes(el, attributes) {
+ const attrs = Object.create(null);
+ for (const attr of attributes) {
+ attrs[attr] = el.getAttribute(attr) || "";
+ el.removeAttribute(attr);
+ }
+ return attrs;
+}
+
+/**
+ * @param {Node} [node]
+ * @param {boolean} [lower=false]
+ * @returns {string}
+ */
+export function getTag(node, lower = false) {
+ const tag = (node && node.nodeName) || "";
+ return lower ? tag.toLowerCase() : tag;
+}
diff --git a/addons/web/static/src/env.js b/addons/web/static/src/env.js
index bee4ef2fd4d..721fec8b261 100644
--- a/addons/web/static/src/env.js
+++ b/addons/web/static/src/env.js
@@ -118,7 +118,7 @@ async function _startServices(env, toStart) {
}
if (value instanceof Promise) {
proms.push(
- new Promise(async (resolve) => {
+ new Promise((resolve) => {
value
.then((val) => {
services[name] = val || null;
diff --git a/addons/web/static/src/fields/fields.js b/addons/web/static/src/fields/fields.js
deleted file mode 100644
index c046f062537..00000000000
--- a/addons/web/static/src/fields/fields.js
+++ /dev/null
@@ -1,113 +0,0 @@
-/** @odoo-module **/
-import { registry } from "@web/core/registry";
-import legacyFieldRegistry from "web.field_registry";
-import owlFieldRegistry from "web.field_registry_owl";
-import { ComponentAdapter } from "web.OwlCompatibility";
-
-const { Component, useEffect, xml } = owl;
-
-const fieldRegistry = registry.category("fields");
-
-function getFieldFromRegistry(registry, { fieldType, viewType, fieldName, fieldsDescription }) {
- if (viewType && fieldType) {
- const specificType = `${viewType}.${fieldType}`;
- if (registry.contains(specificType)) {
- return registry.get(specificType);
- }
- }
- if (!registry.contains(fieldType)) {
- const field = fieldsDescription[fieldName];
- fieldType = field && field.type;
- }
- return registry.get(fieldType, null);
-}
-
-class Field extends Component {
- static getTangibleField({ record, type, name }) {
- const FieldClass = getFieldFromRegistry(fieldRegistry, {
- fieldName: name,
- fieldType: type,
- viewType: record.viewtype,
- fieldsDescription: record.fields,
- });
- return { FieldClass };
- }
-
- setup() {
- const { record, type, name } = this.props;
- this.FieldComponent = Field.getTangibleField({ record, type, name }).FieldClass;
- }
-}
-
-Field.template = xml/* xml */ `
-
-`;
-
-class FieldSupportsLegacy extends Field {
- static getTangibleField({ record, type, fieldName }) {
- let { FieldClass } = super.getTangibleField(...arguments);
- if (!FieldClass) {
- FieldClass = getFieldFromRegistry(owlFieldRegistry, {
- fieldName,
- fieldType: type,
- viewType: record.viewType,
- fieldsDescription: record.fields,
- });
- if (FieldClass) {
- return { FieldClass, isOwlLegacy: true };
- } else {
- FieldClass = getFieldFromRegistry(legacyFieldRegistry, {
- fieldName,
- fieldType: type,
- viewType: record.viewType,
- fieldsDescription: record.fields,
- });
- return { FieldClass, isLegacy: true };
- }
- }
- return {};
- }
-
- setup() {
- super.setup();
- this.renderId = 0;
- useEffect(() => {
- this.renderId++;
- });
- if (!this.FieldComponent) {
- this.env = Component.env;
- const { record, type, name } = this.props;
- const { FieldClass, isOwlLegacy, isLegacy } = FieldSupportsLegacy.getTangibleField({
- record,
- type,
- name,
- });
- this.FieldComponent = FieldClass;
- this.isOwlLegacy = isOwlLegacy;
- this.isLegacy = isLegacy;
- }
- }
-
- get legacyProps() {
- const legacyRecord = Object.assign({}, this.props.model._legacyRecord_);
- legacyRecord.data = Object.assign({}, legacyRecord.data, this.props.record.data);
- return {
- fieldName: this.props.name,
- record: legacyRecord,
- };
- }
-
- get widgetArgs() {
- const { fieldName, record } = this.legacyProps;
- return [fieldName, record];
- }
-}
-FieldSupportsLegacy.template = xml/* xml */ `
-
-
-
-
-`;
-FieldSupportsLegacy.components = { ComponentAdapter };
-
-export { FieldSupportsLegacy as Field };
diff --git a/addons/web/static/src/legacy/action_adapters.js b/addons/web/static/src/legacy/action_adapters.js
index 9765d443cf2..1fdd239e091 100644
--- a/addons/web/static/src/legacy/action_adapters.js
+++ b/addons/web/static/src/legacy/action_adapters.js
@@ -7,10 +7,9 @@ import { objectToQuery } from "../core/browser/router_service";
import { useDebugCategory } from "../core/debug/debug_context";
import { Dialog } from "../core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
-import { ViewNotFoundError } from "../webclient/actions/action_service";
+import { ViewNotFoundError } from "@web/views/view";
import { cleanDomFromBootstrap, wrapSuccessOrFail, useLegacyRefs } from "./utils";
import { mapDoActionOptionAPI } from "./backend_utils";
-import { setScrollPosition } from "@web/webclient/actions/scrolling";
const {
Component,
@@ -94,7 +93,11 @@ class ActionAdapter extends ComponentAdapter {
});
this.onScrollTo = (payload) => {
- setScrollPosition(this.wowlEnv, { left: payload.left, top: payload.top });
+ const contentEl = this.el.querySelector(".o_content");
+ if (contentEl) {
+ contentEl.scrollLeft = payload.left || 0;
+ contentEl.scrollTop = payload.top || 0;
+ }
};
}
@@ -301,7 +304,7 @@ export class ViewAdapter extends ActionAdapter {
useDebugCategory("action", debugContext);
useDebugCategory("view", debugContext);
if (this.props.viewInfo.type === "form") {
- useDebugCategory("form", debugContext);
+ useDebugCategory("form_legacy", debugContext);
}
this.env = Component.env;
}
@@ -376,21 +379,21 @@ export class ViewAdapter extends ActionAdapter {
async _trigger_up(ev) {
const payload = ev.data;
if (ev.name === "switch_view") {
- try {
- const props = {};
- if (payload.mode) {
- props.mode = payload.mode;
+ if (payload.view_type === "form") {
+ if (payload.res_id) {
+ return this.props.selectRecord(payload.res_id, { mode: payload.mode });
+ } else {
+ return this.props.createRecord();
}
- // if (payload.res_id) {
- // if make 'open a record, come back, and create a new record' crash
- props.resId = payload.res_id;
- // }
- await this.actionService.switchView(payload.view_type, props);
- } catch (e) {
- if (e instanceof ViewNotFoundError) {
- return;
+ } else {
+ try {
+ await this.actionService.switchView(payload.view_type);
+ } catch (e) {
+ if (typeof e === "object" && e instanceof ViewNotFoundError) {
+ return;
+ }
+ throw e;
}
- throw e;
}
} else if (ev.name === "execute_action") {
const buttonContext = new Context(payload.action_data.context).eval();
diff --git a/addons/web/static/src/legacy/backend_utils.js b/addons/web/static/src/legacy/backend_utils.js
index 6fcaad96ba1..1382cbfe9b9 100644
--- a/addons/web/static/src/legacy/backend_utils.js
+++ b/addons/web/static/src/legacy/backend_utils.js
@@ -91,6 +91,9 @@ function searchModelStateFromLegacy(state) {
newState.sections = sections;
//! Can be undefined. See search_model.__legacyParseSearchPanelArchAnyway
newState.searchPanelInfo = searchPanelInfo;
+ if (newState.searchPanelInfo) {
+ newState.searchPanelInfo.loaded = true;
+ }
}
for (const [key, extension] of Object.entries(ActionModel.registry.entries())) {
@@ -166,12 +169,15 @@ export function searchModelStateToLegacy(state) {
filter.orderedBy = item.orderBy;
delete filter.orderBy;
break;
- case "filter":
+ case "filter": {
let context = item.context;
try {
context = makeContext([context]);
- } catch (_e) {}
+ } catch (_e) {
+ // pass
+ }
filter.context = context;
+ }
}
filters[item.id] = filter;
}
diff --git a/addons/web/static/src/legacy/debug_manager.js b/addons/web/static/src/legacy/debug_manager.js
index ec823a43bc6..7492e3caaab 100644
--- a/addons/web/static/src/legacy/debug_manager.js
+++ b/addons/web/static/src/legacy/debug_manager.js
@@ -1,12 +1,11 @@
/** @odoo-module **/
-import { _lt } from "@web/core/l10n/translation";
import { Dialog } from "@web/core/dialog/dialog";
-import { FormViewDialog } from 'web.view_dialogs';
+import { FormViewDialog } from "web.view_dialogs";
import { formatDateTime, parseDateTime } from "@web/core/l10n/dates";
-import { formatMany2one } from "@web/fields/formatters";
+import { formatMany2one } from "@web/views/fields/formatters";
import { registry } from "@web/core/registry";
-import { standaloneAdapter } from 'web.OwlCompatibility';
+import { standaloneAdapter } from "web.OwlCompatibility";
const { Component, onWillStart, useState } = owl;
@@ -14,7 +13,7 @@ const debugRegistry = registry.category("debug");
class GetMetadataDialog extends Component {
setup() {
- super.setup();
+ this.title = this.env._t("View Metadata");
this.state = useState({});
onWillStart(this.onWillStart);
}
@@ -25,7 +24,7 @@ class GetMetadataDialog extends Component {
async onClickCreateXmlid() {
const context = Object.assign({}, this.context, {
- default_module: '__custom__',
+ default_module: "__custom__",
default_res_id: this.state.id,
default_model: this.props.res_model,
});
@@ -34,9 +33,9 @@ class GetMetadataDialog extends Component {
context: context,
on_saved: () => this.getMetadata(),
disable_multiple_selection: true,
- res_model: 'ir.model.data',
+ res_model: "ir.model.data",
});
- dialog.on('dialog_form_loaded', this, () => {
+ dialog.on("dialog_form_loaded", this, () => {
dialog.$el.find('[name="name"]').focus();
});
await dialog.open();
@@ -61,16 +60,20 @@ class GetMetadataDialog extends Component {
this.state.creator = formatMany2one(metadata.create_uid);
this.state.lastModifiedBy = formatMany2one(metadata.write_uid);
this.state.noupdate = metadata.noupdate;
- this.state.create_date = formatDateTime(parseDateTime(metadata.create_date), { timezone: true });
- this.state.write_date = formatDateTime(parseDateTime(metadata.write_date), { timezone: true });
+ this.state.createDate = formatDateTime(parseDateTime(metadata.create_date), {
+ timezone: true,
+ });
+ this.state.writeDate = formatDateTime(parseDateTime(metadata.write_date), {
+ timezone: true,
+ });
}
}
GetMetadataDialog.template = "web.DebugMenu.GetMetadataDialog";
GetMetadataDialog.components = { Dialog };
-GetMetadataDialog.title = _lt("View Metadata");
+
class SetDefaultDialog extends Component {
setup() {
- super.setup();
+ this.title = this.env._t("Set Defaults");
this.state = {
fieldToSet: "",
condition: "",
@@ -212,11 +215,9 @@ class SetDefaultDialog extends Component {
}
SetDefaultDialog.template = "web.DebugMenu.SetDefaultDialog";
SetDefaultDialog.components = { Dialog };
-SetDefaultDialog.title = _lt("Set Default");
// Form view items
-
-export function setDefaults({ action, component, env }) {
+function setDefaults({ action, component, env }) {
return {
type: "item",
description: env._t("Set Defaults"),
@@ -282,7 +283,7 @@ function manageAttachments({ action, component, env }) {
}
debugRegistry
- .category("form")
+ .category("form_legacy")
.add("setDefaults", setDefaults)
.add("viewMetadata", viewMetadata)
.add("manageAttachments", manageAttachments);
diff --git a/addons/web/static/src/legacy/js/components/action_menus.js b/addons/web/static/src/legacy/js/components/action_menus.js
index 221540d4ce8..0a07ab92a99 100644
--- a/addons/web/static/src/legacy/js/components/action_menus.js
+++ b/addons/web/static/src/legacy/js/components/action_menus.js
@@ -202,7 +202,7 @@ odoo.define('web.ActionMenus', function (require) {
},
},
};
- ActionMenus.template = 'web.ActionMenus';
+ ActionMenus.template = 'web.Legacy.ActionMenus';
return ActionMenus;
});
diff --git a/addons/web/static/src/legacy/js/fields/basic_fields.js b/addons/web/static/src/legacy/js/fields/basic_fields.js
index 44dea68bd42..bfafef604a9 100644
--- a/addons/web/static/src/legacy/js/fields/basic_fields.js
+++ b/addons/web/static/src/legacy/js/fields/basic_fields.js
@@ -1631,7 +1631,7 @@ var FieldText = InputField.extend(TranslatableFieldMixin, {
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
-
+
/**
* @private
* @override
@@ -4155,7 +4155,7 @@ var FieldColorPicker = FieldInteger.extend({
if (!$colorpicker.length) {
return;
}
- $colorpicker.html(qweb.render('KanbanColorPicker', { colors: this.RECORD_COLORS }));
+ $colorpicker.html(qweb.render('web.Legacy.KanbanColorPicker', { colors: this.RECORD_COLORS }));
$colorpicker.on('click', 'a', this._onColorChanged.bind(this));
},
/**
diff --git a/addons/web/static/src/legacy/js/fields/basic_fields_owl.js b/addons/web/static/src/legacy/js/fields/basic_fields_owl.js
index c57b68d3a18..9f2fb02af85 100644
--- a/addons/web/static/src/legacy/js/fields/basic_fields_owl.js
+++ b/addons/web/static/src/legacy/js/fields/basic_fields_owl.js
@@ -152,7 +152,7 @@ odoo.define('web.basic_fields_owl', function (require) {
FieldBoolean.components = { CustomCheckbox };
FieldBoolean.description = _lt("Checkbox");
FieldBoolean.supportedFieldTypes = ['boolean'];
- FieldBoolean.template = 'web.FieldBoolean';
+ FieldBoolean.template = 'web.LegacyFieldBoolean';
FieldBoolean.isQuickEditable = true;
diff --git a/addons/web/static/src/legacy/js/fields/relational_fields.js b/addons/web/static/src/legacy/js/fields/relational_fields.js
index 1fd63768b23..de9d2aee02b 100644
--- a/addons/web/static/src/legacy/js/fields/relational_fields.js
+++ b/addons/web/static/src/legacy/js/fields/relational_fields.js
@@ -1363,7 +1363,7 @@ var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
if (extraInfo.subFieldName) {
parts.push(`[name="${extraInfo.subFieldName}"]`);
}
-
+
if (parts.length) {
const el = this.el.querySelector(parts.join(' '));
if (el) {
@@ -2041,7 +2041,7 @@ var FieldOne2Many = FieldX2Many.extend({
/**
* @private
- * @param {*} data
+ * @param {*} data
*/
_addCreateRecordRow(data) {
const self = this;
@@ -3308,7 +3308,7 @@ var FieldStatus = AbstractField.extend({
*/
var FieldSelection = AbstractField.extend({
description: _lt("Selection"),
- template: 'FieldSelection',
+ template: 'web.Legacy.FieldSelection',
specialData: "_fetchSpecialRelation",
supportedFieldTypes: ['selection'],
events: _.extend({}, AbstractField.prototype.events, {
diff --git a/addons/web/static/src/legacy/js/views/kanban/kanban_controller.js b/addons/web/static/src/legacy/js/views/kanban/kanban_controller.js
index 223705f4b38..84aad47500d 100644
--- a/addons/web/static/src/legacy/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/legacy/js/views/kanban/kanban_controller.js
@@ -238,7 +238,7 @@ var KanbanController = BasicController.extend({
const state = this.model.get(this.handle, {raw: true});
if (!state.count || state.isSample) {
const classesList = [
- 'o_kanban_view',
+ 'o_legacy_kanban_view',
'o_kanban_group',
'o_kanban_header',
'o_column_quick_create',
diff --git a/addons/web/static/src/legacy/js/views/kanban/kanban_record.js b/addons/web/static/src/legacy/js/views/kanban/kanban_record.js
index a797371373c..a507ab98a8d 100644
--- a/addons/web/static/src/legacy/js/views/kanban/kanban_record.js
+++ b/addons/web/static/src/legacy/js/views/kanban/kanban_record.js
@@ -614,7 +614,7 @@ var KanbanRecord = Widget.extend(WidgetAdapterMixin, {
if (!$colorpicker.length) {
return;
}
- $colorpicker.html(QWeb.render('KanbanColorPicker', { colors: KANBAN_RECORD_COLORS}));
+ $colorpicker.html(QWeb.render('web.Legacy.KanbanColorPicker', { colors: KANBAN_RECORD_COLORS}));
$colorpicker.on('click', 'a', this._onColorChanged.bind(this));
},
/**
diff --git a/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js b/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js
index 5703e804ada..80f2a2a5128 100644
--- a/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/legacy/js/views/kanban/kanban_renderer.js
@@ -83,7 +83,7 @@ function transformQwebTemplate(node, fields) {
}
var KanbanRenderer = BasicRenderer.extend({
- className: 'o_kanban_view',
+ className: 'o_legacy_kanban_view',
config: { // the KanbanRecord and KanbanColumn classes to use (may be overridden)
KanbanColumn: KanbanColumn,
KanbanRecord: KanbanRecord,
@@ -99,7 +99,7 @@ var KanbanRenderer = BasicRenderer.extend({
'keydown .o_kanban_record' : '_onRecordKeyDown'
}),
sampleDataTargets: [
- '.o_kanban_counter',
+ '.o_legacy_kanban_counter',
'.o_kanban_record',
'.o_kanban_toggle_fold',
'.o_column_folded',
diff --git a/addons/web/static/src/legacy/js/views/view_dialogs.js b/addons/web/static/src/legacy/js/views/view_dialogs.js
index 669b09d8185..23b7b5fa502 100644
--- a/addons/web/static/src/legacy/js/views/view_dialogs.js
+++ b/addons/web/static/src/legacy/js/views/view_dialogs.js
@@ -400,7 +400,7 @@ var SelectCreateDialog = ViewDialog.extend({
var view = new ViewClass(fieldsViews[this.viewType], _.extend(viewOptions, {
action: {
controlPanelFieldsView: fieldsViews.search,
- help: markup(_.str.sprintf("%s
", _t("No records found!"))),
+ help: markup(_.str.sprintf("%s
", _.escape(_t("No records found!")))),
},
action_buttons: false,
dynamicFilters: this.options.dynamicFilters,
diff --git a/addons/web/static/src/legacy/legacy_fields.js b/addons/web/static/src/legacy/legacy_fields.js
new file mode 100644
index 00000000000..8f086f22cb4
--- /dev/null
+++ b/addons/web/static/src/legacy/legacy_fields.js
@@ -0,0 +1,314 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { sprintf } from "@web/core/utils/strings";
+import { Domain } from "web.Domain";
+import legacyFieldRegistry from "web.field_registry";
+import { ComponentAdapter } from "web.OwlCompatibility";
+import viewUtils from "web.viewUtils";
+
+const { Component, useEffect, xml } = owl;
+const fieldRegistry = registry.category("fields");
+
+const legacyFieldTemplate = xml`
+ `;
+
+// -----------------------------------------------------------------------------
+// FieldAdapter
+// -----------------------------------------------------------------------------
+
+class FieldAdapter extends ComponentAdapter {
+ setup() {
+ super.setup();
+ this.wowlEnv = this.env;
+ this.env = Component.env;
+ useEffect(() => {
+ // if classNames are given to the field, we only want
+ // to add those classes to the legacy field without
+ // the parent element affecting the style of the field
+ const fieldClassnames =
+ this.props.fieldParams.options &&
+ this.props.fieldParams.options.attrs &&
+ this.props.fieldParams.options.attrs.class;
+ if (fieldClassnames) {
+ for (const className of fieldClassnames.split(" ")) {
+ this.widgetEl.parentElement.classList.remove(className);
+ }
+ this.widgetEl.parentElement.style.display = "contents";
+ }
+ this.widgetEl.parentElement.classList.remove("o_text_overflow");
+ this.widgetEl.classList.add("o_legacy_field_widget");
+ });
+ this.lastFieldChangedEvent = null;
+ }
+
+ /**
+ * @override
+ */
+ get widgetArgs() {
+ const { name, record, options } = this.props.fieldParams;
+ return [name, record, options];
+ }
+
+ updateWidget(nextProps) {
+ const { name, record, options } = nextProps.fieldParams;
+ if (this.widget.mode !== options.mode) {
+ // the mode changed, we need to instantiate a new FieldWidget
+ if (this.oldWidget) {
+ this.widget.destroy(); // we were already updating -> abort, and start over
+ } else {
+ this.oldWidget = this.widget;
+ }
+ this.widget = new this.props.Component(this, name, record, options);
+ return this.widget._widgetRenderAndInsert(() => {});
+ } else {
+ // the mode is the same, simply reset the FieldWidget with the new record
+ return this.widget.reset(record, this.lastFieldChangedEvent);
+ }
+ }
+
+ renderWidget() {
+ if (this.oldWidget) {
+ const parentEl = this.oldWidget.el.parentElement;
+ parentEl.replaceChild(this.widget.el, this.oldWidget.el);
+ this.widgetEl = this.widget.el;
+ if (this.widget.on_attach_callback) {
+ this.widget.on_attach_callback();
+ }
+ this.oldWidget.destroy();
+ this.oldWidget = null;
+ }
+ }
+
+ async _trigger_up(ev) {
+ const evType = ev.name;
+ const payload = ev.data;
+ if (evType === "set_dirty") {
+ // TODO (not yet handled in the Record datapoint)
+ } else if (evType === "field_changed") {
+ const { name, record } = this.props.fieldParams;
+ const proms = [];
+ for (const fieldName in payload.changes) {
+ let value = payload.changes[fieldName];
+ const fieldType = record.fields[name].type;
+ if (fieldType === "many2one" && value) {
+ value = [value.id, value.display_name];
+ } else if (fieldType === "one2many" || fieldType === "many2many") {
+ // TODO map all operations
+ if (value.operation === "ADD_M2M") {
+ const valueIds = value.ids; // can be a lot of stuff
+ let newIds = [];
+ if (Array.isArray(valueIds)) {
+ if (typeof valueIds[0] === "number") {
+ newIds = valueIds; // not sure if it is a real case: a list of ids
+ } else if (valueIds.length && "id" in valueIds[0]) {
+ newIds = valueIds.map((r) => r.id);
+ }
+ } else if ("id" in valueIds) {
+ newIds = [valueIds.id];
+ }
+ value = {
+ resIds: [...record.data[name].res_ids, ...newIds],
+ operation: "REPLACE_WITH",
+ };
+ }
+ } else if (fieldType === "date" || fieldType === "datetime") {
+ // from moment to luxon
+ value = value ? luxon.DateTime.fromISO(value.toISOString()) : false;
+ }
+ proms.push(this.props.update(fieldName, value));
+ }
+ this.lastFieldChangedEvent = ev;
+ await Promise.all(proms);
+ if (payload.onSuccess) {
+ payload.onSuccess();
+ }
+ // TODO: handle onFailure?
+ } else if (evType === "reload") {
+ const record = this.props.record;
+ if (payload.db_id === record.id) {
+ await record.load();
+ } else {
+ await record.model.root.load();
+ }
+ record.model.notify();
+ if (payload.onSuccess) {
+ payload.onSuccess();
+ }
+ } else if (evType === "history_back") {
+ return this.wowlEnv.config.historyBack();
+ }
+ super._trigger_up(...arguments);
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Helpers to map wowl datapoints to basic model datapoints
+// -----------------------------------------------------------------------------
+
+let nextId = 1;
+function createMany2OneDatapoint([resId, displayName]) {
+ return {
+ id: `m2o_${nextId++}`,
+ data: { id: resId, display_name: displayName },
+ fields: {
+ display_name: { type: "char" },
+ id: { type: "integer" },
+ },
+ res_id: resId,
+ ref: resId,
+ };
+}
+
+function mapDatapoint(datapoint) {
+ return {
+ id: datapoint.id,
+ model: datapoint.resModel,
+ fields: datapoint.fields,
+ fieldsInfo: {
+ default: datapoint.activeFields, // TODO: how to handle viewTypes?
+ },
+ getContext: () => [], // TODO: not yet handled in wowl
+ getDomain: () => [], // TODO: not yet handled in wowl
+ getFieldNames: () => datapoint.fieldNames,
+ };
+}
+
+function mapStaticListDatapoint(staticList) {
+ return {
+ ...mapDatapoint(staticList),
+ res_ids: staticList.currentIds,
+ data: staticList.records.map(mapRecordDatapoint),
+ groupedBy: [],
+ orderedBy: staticList.orderBy,
+ };
+}
+
+export function mapRecordDatapoint(record) {
+ const data = Object.assign({}, record.data);
+ for (const fieldName of record.fieldNames) {
+ const fieldType = record.fields[fieldName].type;
+ switch (fieldType) {
+ case "one2many":
+ case "many2many":
+ data[fieldName] = mapStaticListDatapoint(data[fieldName]);
+ break;
+ case "many2one":
+ data[fieldName] = data[fieldName] && createMany2OneDatapoint(data[fieldName]);
+ break;
+ case "date":
+ case "datetime":
+ // from luxon to moment
+ data[fieldName] = data[fieldName] ? moment(data[fieldName].toISO()) : false;
+ }
+ }
+ data.id = record.resId;
+ const basicModelRecord = {
+ ...mapDatapoint(record),
+ res_id: record.resId,
+ data,
+ };
+ Object.defineProperty(basicModelRecord, "evalContext", {
+ get: () => record.evalContext,
+ });
+ Object.defineProperty(basicModelRecord, "isDirty", {
+ get: () => record.isDirty,
+ });
+ Object.defineProperty(basicModelRecord, "isNew", {
+ get: () => record.isDirty,
+ });
+ basicModelRecord.evalModifiers = (modifiers) => {
+ let evalContext = null;
+ const evaluated = {};
+ for (const k of ["invisible", "column_invisible", "readonly", "required"]) {
+ const mod = modifiers[k];
+ if (mod === undefined || mod === false || mod === true) {
+ if (k in modifiers) {
+ evaluated[k] = !!mod;
+ }
+ continue;
+ }
+ try {
+ evalContext = evalContext || basicModelRecord.evalContext;
+ evaluated[k] = new Domain(mod, evalContext).compute(evalContext);
+ } catch (e) {
+ throw new Error(sprintf('for modifier "%s": %s', k, e.message));
+ }
+ }
+ return evaluated;
+ };
+ return basicModelRecord;
+}
+
+// -----------------------------------------------------------------------------
+// Register legacy field widgets to the wowl field registry (wrapped in a Component)
+// -----------------------------------------------------------------------------
+
+function registerField(name, LegacyFieldWidget) {
+ class LegacyField extends Component {
+ setup() {
+ this.FieldWidget = LegacyFieldWidget;
+ }
+
+ get fieldParams() {
+ const { name, record } = this.props;
+ if (record.model.__bm__) {
+ const legacyRecord = record.model.__bm__.get(record.__bm_handle__);
+ const options = {
+ viewType: legacyRecord.viewType,
+ hasReadonlyModifier: record.isReadonly(name),
+ mode: record.mode,
+ };
+ return { name, record: legacyRecord, options };
+ } else {
+ const legacyRecord = mapRecordDatapoint(record);
+ const fieldInfo = record.activeFields[name];
+ const views = {};
+ for (const viewType in fieldInfo.views || {}) {
+ let arch = fieldInfo.views[viewType].arch;
+ if (viewType === "list") {
+ const editable = fieldInfo.views.list.editable;
+ arch = ` `;
+ }
+ // FIXME: need the legacy fieldInfo here
+ views[viewType] = {
+ ...fieldInfo.views[viewType],
+ arch: viewUtils.parseArch(arch),
+ };
+ }
+ const options = {
+ attrs: {
+ ...fieldInfo.rawAttrs,
+ modifiers: JSON.parse(fieldInfo.rawAttrs.modifiers || "{}"),
+ options: fieldInfo.options,
+ widget: fieldInfo.widget,
+ views,
+ mode: fieldInfo.viewMode,
+ },
+ mode: record.mode,
+ };
+ return { name, record: legacyRecord, options };
+ }
+ }
+
+ update(fieldName, value) {
+ if (fieldName === this.props.name) {
+ return this.props.update(value);
+ } else {
+ return this.props.record.update({ [fieldName]: value });
+ }
+ }
+ }
+ LegacyField.template = legacyFieldTemplate;
+ LegacyField.components = { FieldAdapter };
+ LegacyField.fieldsToFetch = LegacyFieldWidget.prototype.fieldsToFetch || {};
+ if (!fieldRegistry.contains(name)) {
+ fieldRegistry.add(name, LegacyField);
+ }
+}
+
+// register fields already in the legacy registry, and listens to future registrations
+for (const [name, FieldWidget] of Object.entries(legacyFieldRegistry.entries())) {
+ registerField(name, FieldWidget);
+}
+legacyFieldRegistry.onAdd(registerField);
diff --git a/addons/web/static/src/legacy/legacy_fields.scss b/addons/web/static/src/legacy/legacy_fields.scss
new file mode 100644
index 00000000000..8224d205d8f
--- /dev/null
+++ b/addons/web/static/src/legacy/legacy_fields.scss
@@ -0,0 +1,3 @@
+.o_debug .o_legacy_field_widget > * {
+ background-color: rgba(255, 0, 0, 0.1);
+}
diff --git a/addons/web/static/src/legacy/legacy_view_widgets.js b/addons/web/static/src/legacy/legacy_view_widgets.js
new file mode 100644
index 00000000000..89b6970c050
--- /dev/null
+++ b/addons/web/static/src/legacy/legacy_view_widgets.js
@@ -0,0 +1,101 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { ComponentAdapter } from "web.OwlCompatibility";
+import legacyWidgetRegistry from "web.widget_registry";
+import { mapRecordDatapoint } from "./legacy_fields";
+
+const { Component, useEffect, xml } = owl;
+const viewWidgetRegistry = registry.category("view_widgets");
+
+const legacyWidgetTemplate = xml`
+ `;
+
+// ----------------------------------------------------------------------------
+// ViewWidgetAdapter
+// ----------------------------------------------------------------------------
+
+class ViewWidgetAdapter extends ComponentAdapter {
+ setup() {
+ super.setup();
+ this.wowlEnv = this.env;
+ this.env = Component.env;
+ useEffect(() => {
+ this.widgetEl.parentElement.classList.remove("o_text_overflow");
+ this.widgetEl.classList.add("o_legacy_widget");
+ });
+ }
+
+ /**
+ * @override
+ */
+ get widgetArgs() {
+ return this.getParams(this.props);
+ }
+
+ getParams(props) {
+ const { record, node, options, readonly } = props.viewWidgetParams;
+ return [record, node, { mode: readonly ? "readonly" : "edit", ...options }];
+ }
+
+ updateWidget(nextProps) {
+ if (this.oldWidget) {
+ this.widget.destroy(); // we were already updating -> abort, and start over
+ } else {
+ this.oldWidget = this.widget;
+ }
+ this.widget = new this.props.Component(this, ...this.getParams(nextProps));
+ return this.widget._widgetRenderAndInsert(() => {});
+ }
+
+ renderWidget() {
+ if (this.oldWidget) {
+ const parentEl = this.oldWidget.el.parentElement;
+ parentEl.replaceChild(this.widget.el, this.oldWidget.el);
+ this.widgetEl = this.widget.el;
+ if (this.widget.on_attach_callback) {
+ this.widget.on_attach_callback();
+ }
+ this.oldWidget.destroy();
+ this.oldWidget = null;
+ }
+ }
+}
+
+// ----------------------------------------------------------------------------
+// Register legacy widgets to the wowl widget registry (wrapped in a Component)
+// ----------------------------------------------------------------------------
+
+function registerWidget(name, LegacyWidgetWidget) {
+ class LegacyViewWidget extends Component {
+ setup() {
+ this.ViewWidget = LegacyWidgetWidget;
+ }
+ get viewWidgetParams() {
+ const { record } = this.props;
+ let legacyRecord;
+ if (record.model.__bm__) {
+ legacyRecord = record.model.__bm__.get(record.__bm_handle__);
+ } else {
+ legacyRecord = mapRecordDatapoint(record);
+ }
+ const options = {
+ viewType: legacyRecord.viewType,
+ mode: record.mode,
+ };
+ return { ...this.props, record: legacyRecord, options };
+ }
+ }
+ LegacyViewWidget.template = legacyWidgetTemplate;
+ LegacyViewWidget.components = { ViewWidgetAdapter };
+ if (!viewWidgetRegistry.contains(name)) {
+ console.log(`View widgets: using legacy ${name} Widget`);
+ viewWidgetRegistry.add(name, LegacyViewWidget);
+ }
+}
+
+// register widgets already in the legacy registry, and listens to future registrations
+for (const [name, Widget] of Object.entries(legacyWidgetRegistry.entries())) {
+ registerWidget(name, Widget);
+}
+legacyWidgetRegistry.onAdd(registerWidget);
diff --git a/addons/web/static/src/legacy/legacy_views.js b/addons/web/static/src/legacy/legacy_views.js
index b8c167c87e6..f326d98ebf2 100644
--- a/addons/web/static/src/legacy/legacy_views.js
+++ b/addons/web/static/src/legacy/legacy_views.js
@@ -27,11 +27,14 @@ function getJsClassWidget(fieldsInfo) {
const legacyViewTemplate = xml`
`;
+ widget="widget" onReverseBreadcrumb="onReverseBreadcrumb" selectRecord="props.selectRecord" createRecord="props.createRecord"/>`;
// registers a view from the legacy view registry to the wowl one, but wrapped
// into an Owl Component
function registerView(name, LegacyView) {
+ if (viewRegistry.contains(name)) {
+ return;
+ }
class Controller extends LegacyComponent {
setup() {
this.vm = useService("view");
@@ -51,11 +54,13 @@ function registerView(name, LegacyView) {
// always add user context to the action context
this.user = useService("user");
- const action = Object.assign({}, this.props.action, {
- context: Object.assign({}, this.user.context, this.props.action.context),
- });
+ const action = Object.assign({}, this.props.action);
+ action.context = Object.assign({}, this.user.context, action.context);
- const { actionFlags, breadcrumbs } = this.env.config;
+ const { actionFlags, breadcrumbs, noBreadcrumbs } = this.env.config;
+ if (noBreadcrumbs) {
+ action.context.no_breadcrumbs = true;
+ }
this.viewParams = Object.assign({}, actionFlags, {
action,
// legacy views automatically add the last part of the breadcrumbs
@@ -90,6 +95,11 @@ function registerView(name, LegacyView) {
this.onReverseBreadcrumb =
this.props.state && this.props.state.__on_reverse_breadcrumb__;
useSetupAction({
+ rootRef: {
+ get el() {
+ return legacyRefs.widget && legacyRefs.widget.el;
+ },
+ },
beforeLeave: () => legacyRefs.widget.canBeRemoved(),
getGlobalState: () => getGlobalState(legacyRefs.component.exportState()),
getLocalState: () => getLocalState(legacyRefs.component.exportState()),
@@ -100,7 +110,7 @@ function registerView(name, LegacyView) {
async onWillStart() {
const params = {
resModel: this.props.resModel,
- views: this.props.views,
+ views: this.props.views || this.env.config.views,
context: this.props.context,
};
const options = {
@@ -110,13 +120,13 @@ function registerView(name, LegacyView) {
};
const viewDescriptions = await this.vm.loadViews(params, options);
const result = viewDescriptions.__legacy__;
- const fieldsInfo = result.fields_views[this.props.type];
+ const fieldsInfo = result.fields_views[this.props.type || this.env.config.viewType];
const jsClass = getJsClassWidget(fieldsInfo);
this.View = jsClass || this.View;
this.viewInfo = Object.assign({}, fieldsInfo, {
fields: result.fields,
viewFields: fieldsInfo.fields,
- type: this.props.type,
+ type: this.props.type || this.env.config.viewType,
});
let controlPanelFieldsView;
if (result.fields_views.search) {
@@ -127,7 +137,8 @@ function registerView(name, LegacyView) {
});
}
const { viewSwitcherEntries = [] } = this.env.config;
- const views = this.viewParams.action.views
+ const _views = this.viewParams.action.views || this.env.config.views;
+ const views = _views
.filter(([, vtype]) => vtype !== "search")
.map(([vid, vtype]) => {
const view = viewSwitcherEntries.find((v) => v.type === vtype);
@@ -137,29 +148,32 @@ function registerView(name, LegacyView) {
return {
viewID: vid,
type: vtype,
- multiRecord: !this.constructor.multiRecord,
+ multiRecord: !legacyView.multiRecord,
};
}
});
this.viewParams.action = Object.assign({}, this.viewParams.action, {
controlPanelFieldsView,
- _views: this.viewParams.action.views,
+ _views,
views,
});
}
}
- Controller.template = legacyViewTemplate;
+ Controller.template = legacyViewTemplate;
Controller.components = { ViewAdapter };
- Controller.display_name = LegacyView.prototype.display_name;
- Controller.icon = LegacyView.prototype.icon;
- Controller.isMobileFriendly = LegacyView.prototype.mobile_friendly;
- Controller.multiRecord = LegacyView.prototype.multi_record;
- Controller.type = LegacyView.prototype.viewType;
- Controller.isLegacy = true;
- if (!viewRegistry.contains(name)) {
- viewRegistry.add(name, Controller);
- }
+ Controller.isLegacy = true; // for action dialog
+
+ const legacyView = {
+ type: LegacyView.prototype.viewType,
+ display_name: LegacyView.prototype.display_name,
+ icon: LegacyView.prototype.icon,
+ isMobileFriendly: LegacyView.prototype.mobile_friendly,
+ multiRecord: LegacyView.prototype.multi_record,
+ isLegacy: true,
+ Controller,
+ };
+ viewRegistry.add(name, legacyView);
}
// register views already in the legacy registry, and listens to future registrations
diff --git a/addons/web/static/src/legacy/scss/fields.scss b/addons/web/static/src/legacy/scss/fields.scss
index 91d9c21a069..68a3f368efa 100644
--- a/addons/web/static/src/legacy/scss/fields.scss
+++ b/addons/web/static/src/legacy/scss/fields.scss
@@ -322,7 +322,7 @@
}
// Stars
- &.o_priority {
+ &.o_priority, & .o_priority {
display: inline-block;
padding: 0;
margin: 0;
@@ -349,7 +349,7 @@
}
// Favorite
- &.o_favorite {
+ &.o_favorite, & .o_favorite {
i.fa {
font-size: 16px;
}
@@ -367,7 +367,7 @@
}
// Handle widget
- &.o_row_handle {
+ .o_row_handle {
@include o-grab-cursor;
color: #adb5bd;
text-align: center;
@@ -477,7 +477,7 @@
}
// Image
- &.o_field_image {
+ &.o_field_image, &.o_field_image_url {
position: relative;
.o_form_image_controls {
@@ -577,7 +577,6 @@
right: 0;
}
&.o_btn_char_copy {
- padding-top: 2px;
height: 100%;
}
}
@@ -596,18 +595,6 @@
display: inline-block;
}
- &.o_field_badge {
- border: 0;
- font-size: 12px;
- user-select: none;
- background-color: rgba(lightgray, 0.5);
- font-weight: 500;
- @include o-text-overflow;
- transition: none; // remove transition to prevent badges from flickering at reload
- &.o_field_empty {
- display: none !important;
- }
- }
}
span.o_field_copy:empty {
diff --git a/addons/web/static/src/legacy/scss/form_view.scss b/addons/web/static/src/legacy/scss/form_view.scss
index 24f0d23349f..162a0d1086a 100644
--- a/addons/web/static/src/legacy/scss/form_view.scss
+++ b/addons/web/static/src/legacy/scss/form_view.scss
@@ -886,7 +886,7 @@ $o-form-label-margin-right: 0px;
}
// One2Many Kanban views
- .o_field_widget .o_kanban_view.o_kanban_ungrouped {
+ .o_field_widget .o_legacy_kanban_view.o_kanban_ungrouped {
padding: 0;
.o_kanban_record {
box-shadow: none;
@@ -1061,7 +1061,7 @@ $o-form-label-margin-right: 0px;
}
// Settings form views
-.o_settings_container {
+.o_legacy_settings_container {
display: flex;
flex: 0 1 auto;
flex-flow: row wrap;
diff --git a/addons/web/static/src/legacy/scss/kanban_column_progressbar.scss b/addons/web/static/src/legacy/scss/kanban_column_progressbar.scss
index 36f3b508fe8..22ac8e1f4a8 100644
--- a/addons/web/static/src/legacy/scss/kanban_column_progressbar.scss
+++ b/addons/web/static/src/legacy/scss/kanban_column_progressbar.scss
@@ -41,20 +41,20 @@
}
// If columns has the progressbar, adapt the layout
-.o_kanban_view .o_kanban_group.o_kanban_has_progressbar {
+.o_legacy_kanban_view .o_kanban_group.o_kanban_has_progressbar {
> .o_kanban_header .o_kanban_header_title {
height: $o-kanban-header-title-height*0.6;
margin-top: 5px;
}
&.o_kanban_no_records {
- .o_kanban_counter {
+ .o_legacy_kanban_counter {
opacity: 0.3;
}
}
}
-.o_kanban_counter {
+.o_legacy_kanban_counter {
position: relative;
display: flex;
align-items: center;
@@ -102,11 +102,11 @@
}
}
}
-.o_column_folded .o_kanban_counter {
+.o_column_folded .o_legacy_kanban_counter {
display: none;
}
-.o_kanban_view .o_kanban_group {
+.o_legacy_kanban_view .o_kanban_group {
@include o-kanban-css-filter(success, theme-color('success'));
@include o-kanban-css-filter(warning, theme-color('warning'));
@include o-kanban-css-filter(danger, theme-color('danger'));
diff --git a/addons/web/static/src/legacy/scss/kanban_dashboard.scss b/addons/web/static/src/legacy/scss/kanban_dashboard.scss
index 40c2508f27c..587df3fa402 100644
--- a/addons/web/static/src/legacy/scss/kanban_dashboard.scss
+++ b/addons/web/static/src/legacy/scss/kanban_dashboard.scss
@@ -1,5 +1,5 @@
-.o_kanban_view.o_kanban_dashboard {
+.o_legacy_kanban_view.o_kanban_dashboard {
&:not(.o_kanban_grouped) {
// correctly display the no_content_helper in dashboards
flex-flow: row wrap;
@@ -31,8 +31,6 @@
}
.o_kanban_card_manage_pane {
- @include o-kanban-dropdown-menu;
-
// Arbitrary value to place the dropdown-menu exactly below the
// dropdown-toggle (height is forced so that it works on Firefox)
top: $o-kanban-manage-toggle-height;
diff --git a/addons/web/static/src/legacy/scss/kanban_examples_dialog.scss b/addons/web/static/src/legacy/scss/kanban_examples_dialog.scss
index 503cf4f0acb..5c17f6c7a5d 100644
--- a/addons/web/static/src/legacy/scss/kanban_examples_dialog.scss
+++ b/addons/web/static/src/legacy/scss/kanban_examples_dialog.scss
@@ -1,5 +1,5 @@
// container ghost card in modal
-.o_kanban_examples_dialog {
+.o_legacy_kanban_examples_dialog{
display: flex;
padding: 0px;
background: theme-color('light');
diff --git a/addons/web/static/src/legacy/scss/kanban_view.scss b/addons/web/static/src/legacy/scss/kanban_view.scss
index 399bcde90fe..e8097edcdf2 100644
--- a/addons/web/static/src/legacy/scss/kanban_view.scss
+++ b/addons/web/static/src/legacy/scss/kanban_view.scss
@@ -1,5 +1,5 @@
// ------- Kanban View Layout -------
-.o_kanban_view {
+.o_legacy_kanban_view {
display: flex;
align-content: stretch;
@@ -249,7 +249,6 @@
@include o-kanban-dropdown-open;
}
.dropdown-menu {
- @include o-kanban-dropdown-menu;
min-width: 11rem;
}
}
@@ -619,6 +618,7 @@
&.o_kanban_ghost {
height: 0;
+ border: none;
min-height: 0!important; // to prevent view writers to override this height
visibility: hidden;
margin-top: 0;
@@ -645,7 +645,7 @@
}
// ----------------- Set Cover Dialog -----------------
-.modal .o_kanban_cover_container .o_kanban_cover_image {
+.modal .o_legacy_kanban_cover_container .o_kanban_cover_image {
border: 1px solid $border-color;
height: 120px;
width: 120px;
diff --git a/addons/web/static/src/legacy/scss/list_view.scss b/addons/web/static/src/legacy/scss/list_view.scss
index 0a6a4888232..ebebeb8e1f6 100644
--- a/addons/web/static/src/legacy/scss/list_view.scss
+++ b/addons/web/static/src/legacy/scss/list_view.scss
@@ -372,7 +372,7 @@
}
// Buttons in ControlPanel
-.o_list_buttons {
+.o_legacy_list_buttons {
.o_list_button_save, .o_list_button_discard {
display: none;
}
diff --git a/addons/web/static/src/legacy/scss/utils.scss b/addons/web/static/src/legacy/scss/utils.scss
index 3fc0e09322b..ba314b269ef 100644
--- a/addons/web/static/src/legacy/scss/utils.scss
+++ b/addons/web/static/src/legacy/scss/utils.scss
@@ -348,12 +348,6 @@
border-color: $border-color;
z-index: $zindex-dropdown + 1;
}
-@mixin o-kanban-dropdown-menu {
- @include o-position-absolute($right: -1px);
- margin-top: -2px;
- border-color: $border-color;
- border-top-right-radius: 0;
-}
@mixin o-kanban-colorpicker {
max-width: 150px;
padding: 3px ($o-dropdown-hpadding - $o-kanban-inner-hmargin) 3px $o-dropdown-hpadding;
diff --git a/addons/web/static/src/legacy/scss/views.scss b/addons/web/static/src/legacy/scss/views.scss
index f5af47f0f1c..0f3bfa1b1a8 100644
--- a/addons/web/static/src/legacy/scss/views.scss
+++ b/addons/web/static/src/legacy/scss/views.scss
@@ -3,6 +3,10 @@
* all renderers. For field default rules, see the fields.scss file.
*/
+.o_debug .o_legacy_kanban_view {
+ border-top: 2px solid red;
+}
+
// Invisible modifier (can be inside the view, the button area, ...)
.o_invisible_modifier {
display: none!important;
diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml
index 40570509185..7417c09d2ef 100644
--- a/addons/web/static/src/legacy/xml/base.xml
+++ b/addons/web/static/src/legacy/xml/base.xml
@@ -396,7 +396,7 @@
-
+