[REF] web: kanban, list and form views in owl

This commit introduces the new kanban, list and form views written
in owl. Even though it contains the implementation of those 3 views,
only the kanban view is activated for now (the list and form views
aren't 100% ready yet, so they aren't added to the view registry).

Alongside the views, the fields (<field name="..."/> in archs) and
widgets (<widget name="..." in archs) of web/ have been implemented
in owl as well. Legacy ones remaining in other addons are supported
in our new views, thanks to a compatibility layer. The goal is to convert
them asap though.

Legacy views, fields and widgets are kept for now, which explains the
number of added lines in this PR (around half of them concern tests).
They are still extended by custom code in other addons, that still need
to be converted (a lot of them are already on the way). Moreover,
they are still used in Studio as well. The plan is to lazy load them in
the Studio bundle when all custom code extending them will be
converted. Studio will be converted for v17.

Part-of: odoo/odoo#92475
Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Bruno Boi <boi@odoo.com>
Co-authored-by: Géry Debongnie <ged@odoo.com>
Co-authored-by: Samuel Degueldre <sad@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Simon Genin (ges) <ges@odoo.com>
Co-authored-by: Francois (fge) <fge@odoo.com>
Co-authored-by: Michael Mattiello (mcm) <mcm@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
Co-authored-by: Lucas Perais (lpe) <lpe@odoo.com>
Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
Co-authored-by: luvi <luvi@odoo.com>
This commit is contained in:
Aaron Bohy
2022-06-30 09:29:44 +02:00
co-authored by Bruno Boi Géry Debongnie Samuel Degueldre Mathieu Duckerts-Antoine Simon Genin Francois Michael Mattiello Julien Mougenot Lucas Perais Jorge Pinna Puissant luvi
parent 8697a136c3
commit 48ef812a63
389 changed files with 99597 additions and 2046 deletions
@@ -0,0 +1,34 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { FormRenderer } from "@web/views/form/form_renderer";
import { ChatterContainer } from "@mail/components/chatter_container/chatter_container";
import {
createElement,
} from "@web/core/utils/xml";
import { append } from "@web/views/view_compiler";
function compileChatter(node, params) {
node.classList.remove("oe_chatter");
const container = createElement("div");
container.classList.add("o_FormRenderer_chatterContainer");
const chatter = createElement("ChatterContainer");
chatter.setAttribute("threadModel", "props.record.resModel");
chatter.setAttribute("threadId", "props.record.resId");
// TODO: pass chatterFields equivalent in props
// chatter.setAttribute("record", "props.record"); // props.record.model.load() to reload the form
// in ChatterContainer setup:
// this.messagingService = useService("messaging");
append(container, chatter);
return container;
}
registry.category("form_compilers").add("chatter_compiler", {
selector: "div.oe_chatter",
fn: compileChatter,
});
FormRenderer.components.ChatterContainer = ChatterContainer;
+9 -3
View File
@@ -96,9 +96,10 @@ This module provides the core of the Odoo Web Client.
'web/static/src/core/utils/transitions.scss',
'web/static/src/core/**/*',
'web/static/src/fields/**/*',
'web/static/src/legacy/legacy_fields.scss',
'web/static/src/search/**/*',
'web/static/src/views/**/*',
('remove', 'web/static/src/views/form/button_box/*.scss'),
'web/static/src/webclient/**/*',
('remove', 'web/static/src/webclient/navbar/navbar.scss'), # already in _assets_common_styles
('remove', 'web/static/src/webclient/clickbot/clickbot.js'), # lazy loaded
@@ -141,6 +142,9 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/scss/profiling_qweb_view.scss',
'base/static/src/scss/res_partner.scss',
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/debug_manager.js',
'web/static/src/legacy/legacy_service_provider.js',
@@ -148,6 +152,8 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/legacy_dialog.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_views.js',
'web/static/src/legacy/legacy_fields.js',
'web/static/src/legacy/legacy_view_widgets.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
'web/static/src/legacy/legacy_rpc_error_handler.js',
'web/static/src/legacy/root_widget.js',
@@ -355,7 +361,6 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/scss/keyboard.scss',
'web/static/src/legacy/scss/name_and_signature.scss',
'web/static/src/legacy/scss/web.zoomodoo.scss',
'web/static/src/legacy/scss/color_picker.scss',
'web/static/src/legacy/scss/fontawesome_overridden.scss',
],
'web._assets_common_scripts': [
@@ -490,6 +495,7 @@ This module provides the core of the Odoo Web Client.
# 'web/static/tests/legacy/main_tests.js',
'web/static/tests/helpers/**/*.js',
'web/static/tests/views/helpers.js',
'web/static/tests/search/helpers.js',
'web/static/tests/webclient/**/helpers.js',
'web/static/tests/qunit.js',
@@ -511,10 +517,10 @@ This module provides the core of the Odoo Web Client.
'base/static/tests/base_settings_tests.js',
'web/static/tests/env_tests.js',
'web/static/tests/core/**/*.js',
'web/static/tests/fields/**/*.js',
'web/static/tests/search/**/*.js',
('remove', 'web/static/tests/search/helpers.js'),
'web/static/tests/views/**/*.js',
('remove', 'web/static/tests/views/helpers.js'),
'web/static/tests/webclient/**/*.js',
('remove', 'web/static/tests/webclient/**/helpers.js'),
'web/static/tests/legacy/**/*.js',
+23 -4
View File
@@ -2,17 +2,19 @@
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 });
}
}
Dialog.template = "web.Dialog";
@@ -24,6 +26,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: {
@@ -41,3 +44,19 @@ Dialog.defaultProps = {
technical: true,
title: "Odoo",
};
export class SimpleDialog extends Component {
setup() {
useActiveElement("modal");
useHotkey("escape", () => {
this.props.close();
});
useChildSubEnv({ inDialog: true });
}
}
SimpleDialog.template = "web.SimpleDialog";
SimpleDialog.props = {
close: Function,
isActive: { optional: true },
"*": true,
};
+9 -7
View File
@@ -2,11 +2,11 @@
<templates xml:space="preserve">
<t t-name="web.Dialog" owl="1">
<div class="o_dialog" t-att-class="{ o_inactive_modal: !data.isActive }">
<div class="o_dialog" t-att-id="id" t-att-class="{ o_inactive_modal: !data.isActive }">
<div role="dialog" class="modal d-block"
tabindex="-1"
t-att-class="{ o_technical_modal: props.technical, o_modal_full: props.fullscreen }"
t-ref="modal"
t-ref="modalRef"
>
<div class="modal-dialog" t-attf-class="modal-{{props.size}}">
<div class="modal-content" t-att-class="props.contentClass">
@@ -16,16 +16,18 @@
</h4>
<button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="data.close">×</button>
</header>
<main class="modal-body">
<t t-slot="default"/>
</main>
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1">
<!-- FIXME: WOWL there is a bug on t-portal on owl, in which t-portal don't work on multinode.
To avoid this we place the footer before the body -->
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1" style="order:2">
<t t-slot="footer">
<button class="btn btn-primary" t-on-click="data.close">
<button class="btn btn-primary o-default-button" t-on-click="data.close">
<t>Ok</t>
</button>
</t>
</footer>
<main class="modal-body">
<t t-slot="default"/>
</main>
</div>
</div>
</div>
@@ -3,7 +3,7 @@
import { registry } from "../registry";
import { DialogContainer } from "./dialog_container";
const { reactive } = owl;
const { markRaw, reactive } = owl;
/**
* @typedef {{
@@ -51,8 +51,8 @@ export const dialogService = {
const dialog = {
id,
class: dialogClass,
props: { ...props, close },
dialogData: { isActive: true, close },
props: markRaw({ ...props, close }),
dialogData: { isActive: true, close, id },
};
dialogs[id] = dialog;
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.SimpleDialog" owl="1">
<div class="o_dialog" t-att-class="{ o_inactive_modal: !props.isActive }" t-on-dialog-closed="props.close">
<div role="dialog" class="modal d-block"
tabindex="-1"
t-att-class="{ o_technical_modal: props.technical, o_modal_full: props.fullscreen }"
t-ref="modal"
>
<div class="modal-dialog" t-att-class="props.size">
<div class="modal-content" t-att-class="props.contentClass">
<header t-if="!props.hideHeader" class="modal-header">
<h4 class="modal-title text-break">
<t t-esc="props.title"/>
</h4>
<button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="props.close">×</button>
</header>
<main class="modal-body">
<t t-slot="default"/>
</main>
<footer t-if="props.slots.footer" class="modal-footer justify-content-start">
<t t-slot="footer" close="props.close"/>
</footer>
</div>
</div>
</div>
</div>
</t>
</templates>
@@ -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";
+63 -4
View File
@@ -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;
}
@@ -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.
+111 -7
View File
@@ -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("<templates/>", "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(/&amp;nbsp;/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<Element> | Record<string, string>)} 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<string, string>}
*/
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;
}
-113
View File
@@ -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 */ `
<t t-component="FieldComponent" t-props="props" class="o-field" t-key="props.record.id"/>
`;
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 */ `<t>
<t t-if="!isOwlLegacy and !isLegacy" t-call="${Field.template}" />
<t t-elif="isOwlLegacy" t-component="FieldComponent" t-props="legacyProps" />
<ComponentAdapter t-elif="isLegacy" widgetArgs="widgetArgs" Component="FieldComponent" t-key="renderId" />
</t>
`;
FieldSupportsLegacy.components = { ComponentAdapter };
export { FieldSupportsLegacy as Field };
+20 -17
View File
@@ -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();
@@ -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;
}
+17 -16
View File
@@ -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);
@@ -202,7 +202,7 @@ odoo.define('web.ActionMenus', function (require) {
},
},
};
ActionMenus.template = 'web.ActionMenus';
ActionMenus.template = 'web.Legacy.ActionMenus';
return ActionMenus;
});
@@ -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));
},
/**
@@ -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;
@@ -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, {
@@ -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',
@@ -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));
},
/**
@@ -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',
@@ -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("<p>%s</p>", _t("No records found!"))),
help: markup(_.str.sprintf("<p>%s</p>", _.escape(_t("No records found!")))),
},
action_buttons: false,
dynamicFilters: this.options.dynamicFilters,
@@ -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 Component="FieldWidget" fieldParams="fieldParams" update.bind="update" record="props.record"/>`;
// -----------------------------------------------------------------------------
// 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 = `<tree editable='${editable}'></tree>`;
}
// 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);
@@ -0,0 +1,3 @@
.o_legacy_field_widget > * {
background-color: rgba(255, 0, 0, 0.1);
}
@@ -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 Component="ViewWidget" viewWidgetParams="viewWidgetParams" />`;
// ----------------------------------------------------------------------------
// 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);
+35 -21
View File
@@ -27,11 +27,14 @@ function getJsClassWidget(fieldsInfo) {
const legacyViewTemplate = xml`
<ViewAdapter Component="Widget" View="View" viewInfo="viewInfo" viewParams="viewParams"
widget="widget" onReverseBreadcrumb="onReverseBreadcrumb" />`;
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
+4 -17
View File
@@ -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 {
@@ -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;
@@ -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'));
@@ -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;
@@ -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');
@@ -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;
@@ -372,7 +372,7 @@
}
// Buttons in ControlPanel
.o_list_buttons {
.o_legacy_list_buttons {
.o_list_button_save, .o_list_button_discard {
display: none;
}
@@ -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;
@@ -3,6 +3,10 @@
* all renderers. For field default rules, see the fields.scss file.
*/
.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;
+3 -3
View File
@@ -396,7 +396,7 @@
</div>
</t>
<t t-name="web.ActionMenus" owl="1">
<t t-name="web.Legacy.ActionMenus" owl="1">
<div class="o_cp_action_menus" t-on-item-selected.stop="_onItemSelected">
<DropdownMenu t-if="printItems.length"
title="printButtonStrings.title"
@@ -574,7 +574,7 @@
</div>
<t t-name="ListView.buttons">
<div class="o_list_buttons d-flex" role="toolbar" aria-label="Main actions">
<div class="o_legacy_list_buttons d-flex" role="toolbar" aria-label="Main actions">
<button type="button" class="btn btn-primary o_list_button_save" title="Save record" accesskey="s">
Save
</button>
@@ -996,7 +996,7 @@
<span class="o_datepicker_button"/>
</div>
</t>
<t t-name="FieldSelection">
<t t-name="web.Legacy.FieldSelection">
<span t-if="widget.mode === 'readonly'"/>
<select t-if="widget.mode !== 'readonly'"
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FieldBoolean" owl="1">
<t t-name="web.LegacyFieldBoolean" owl="1">
<CustomCheckbox disabled="hasReadonlyModifier"
value="value" class="'o_field_boolean'" onChange.bind="_onChange"/>
</t>
+4 -4
View File
@@ -80,7 +80,7 @@
</div>
</t>
<t t-name="KanbanColorPicker">
<t t-name="web.Legacy.KanbanColorPicker">
<t t-foreach="colors" t-as="color">
<li>
<a role="menuitem"
@@ -108,7 +108,7 @@
</t>
<t t-name="KanbanView.ColumnProgressBar">
<div class="o_kanban_counter">
<div class="o_legacy_kanban_counter">
<div class="o_kanban_counter_progress progress">
<t t-foreach="widget.colors" t-as="color">
<t t-set="count" t-value="widget.subgroupCounts and widget.subgroupCounts[color] or 0"/>
@@ -124,7 +124,7 @@
</t>
<t t-name="KanbanView.SetCoverModal">
<div t-if="attachmentIds.length" class="o_kanban_cover_container bg-100">
<div t-if="attachmentIds.length" class="o_legacy_kanban_cover_container bg-100">
<t t-foreach="attachmentIds" t-as="attachment">
<div t-attf-class="o_kanban_cover_image position-relative d-inline-block m-2 bg-white #{coverId === attachment.id ? ' o_selected' : ''}">
<img t-attf-src="/web/image/#{attachment.id}?unique=1" t-att-data-id="attachment.id" t-att-data-name="attachment.name" class="m-auto mw-100" alt="Attachment"/>
@@ -143,7 +143,7 @@
</t>
<!-- Note: this dialog isn't responsive, but it is not accessible on mobile -->
<div t-name="KanbanView.ExamplesDialog" class="o_kanban_examples_dialog">
<div t-name="KanbanView.ExamplesDialog" class="o_legacy_kanban_examples_dialog">
<div class="o_kanban_examples_dialog_nav">
<ul class="nav nav-pills flex-column">
<t t-foreach="examples" t-as="example">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
/** @odoo-module **/
/* global ace */
import { loadJS } from "@web/core/assets";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { formatText } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
const { Component, onWillStart, useEffect, useRef } = owl;
export class AceField extends Component {
setup() {
this.aceEditor = null;
this.editorRef = useRef("editor");
onWillStart(async () => {
await loadJS("/web/static/lib/ace/ace.js");
const jsLibs = [
"/web/static/lib/ace/mode-python.js",
"/web/static/lib/ace/mode-xml.js",
"/web/static/lib/ace/mode-qweb.js",
];
const proms = jsLibs.map((url) => loadJS(url));
return Promise.all(proms);
});
useEffect(() => {
this.setupAce();
return () => this.destroyAce();
});
}
get aceSession() {
return this.aceEditor.getSession();
}
setupAce() {
this.aceEditor = ace.edit(this.editorRef.el);
this.aceEditor.setOptions({
maxLines: Infinity,
showPrintMargin: false,
});
this.aceEditor.$blockScrolling = true;
this.aceSession.setOptions({
useWorker: false,
tabSize: 2,
useSoftTabs: true,
});
this.aceSession.setOptions({
mode: `ace/mode/${this.props.mode === "xml" ? "qweb" : this.props.mode}`,
});
this.aceEditor.setOptions({
readOnly: this.props.readonly,
highlightActiveLine: !this.props.readonly,
highlightGutterLine: !this.props.readonly,
});
this.aceEditor.renderer.setOptions({
displayIndentGuides: !this.props.readonly,
showGutter: !this.props.readonly,
});
this.aceEditor.renderer.$cursorLayer.element.style.display = this.props.readonly
? "none"
: "block";
const formattedValue = formatText(this.props.value);
if (this.aceSession.getValue() !== formattedValue) {
this.aceSession.setValue(formattedValue);
}
this.aceEditor.on("blur", this.onBlur.bind(this));
}
destroyAce() {
if (this.aceEditor) {
this.aceEditor.destroy();
}
}
onBlur() {
if (!this.props.readonly) {
this.props.update(this.aceSession.getValue());
}
}
}
AceField.template = "web.AceField";
AceField.props = {
...standardFieldProps,
mode: { type: String, optional: true },
};
AceField.defaultProps = {
mode: "qweb",
};
AceField.displayName = _lt("Ace Editor");
AceField.supportedTypes = ["text"];
AceField.extractProps = (fieldName, record, attrs) => {
return {
mode: attrs.options.mode,
};
};
registry.category("fields").add("ace", AceField);
@@ -0,0 +1,3 @@
.o_field_ace {
display: block !important;
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.AceField" owl="1">
<!-- TODO check if some classes are useless -->
<div class="o_field_widget oe_form_field o_ace_view_editor oe_ace_open">
<div class="ace-view-editor" t-ref="editor" />
</div>
</t>
</templates>
@@ -0,0 +1,15 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
const { Component } = owl;
export class AttachmentImageField extends Component {}
AttachmentImageField.template = "web.AttachmentImageField";
AttachmentImageField.displayName = _lt("Attachment Image");
AttachmentImageField.supportedTypes = ["many2one"];
registry.category("fields").add("attachment_image", AttachmentImageField);
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.AttachmentImageField" owl="1">
<div class="o_attachment_image">
<img t-if="props.value" t-attf-src="/web/image/{{ props.value[0] }}?unique=1" t-att-title="props.value[1]" alt="Image" />
</div>
</t>
</templates>
@@ -0,0 +1,36 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
const formatters = registry.category("formatters");
export class BadgeField extends Component {
get formattedValue() {
const formatter = formatters.get(this.props.type);
return formatter(this.props.value, {
selection: this.props.record.fields[this.props.name].selection,
});
}
get classFromDecoration() {
for (const decorationName in this.props.decorations) {
if (this.props.decorations[decorationName]) {
return `bg-${decorationName}-light`;
}
}
return "";
}
}
BadgeField.template = "web.BadgeField";
BadgeField.props = {
...standardFieldProps,
};
BadgeField.displayName = _lt("Badge");
BadgeField.supportedTypes = ["selection", "many2one", "char"];
registry.category("fields").add("badge", BadgeField);
@@ -0,0 +1,13 @@
.o_field_badge span {
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
color: #444B5A;
&.o_field_empty {
display: none !important;
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.BadgeField" owl="1">
<span t-if="props.value" class="badge badge-pill" t-att-class="classFromDecoration" t-esc="formattedValue" />
</t>
</templates>
@@ -0,0 +1,93 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class BadgeSelectionField extends Component {
get string() {
switch (this.props.type) {
case "many2one":
return this.props.value ? this.props.value[1] : "";
case "selection":
return this.props.value !== false
? this.props.options.find((o) => o[0] === this.props.value)[1]
: "";
default:
return "";
}
}
get value() {
const rawValue = this.props.value;
return this.props.type === "many2one" && rawValue ? rawValue[0] : rawValue;
}
stringify(value) {
return JSON.stringify(value);
}
/**
* @param {string | number | false} value
*/
onChange(value) {
switch (this.props.type) {
case "many2one":
if (value === false) {
this.props.update(false);
} else {
this.props.update(this.props.options.find((option) => option[0] === value));
}
break;
case "selection":
this.props.update(value);
break;
}
}
}
BadgeSelectionField.template = "web.BadgeSelectionField";
BadgeSelectionField.props = {
...standardFieldProps,
horizontal: { type: Boolean, optional: true },
options: Object,
placeholder: { type: String, optional: true },
};
BadgeSelectionField.displayName = _lt("Badges");
BadgeSelectionField.supportedTypes = ["many2one", "selection"];
BadgeSelectionField.isEmpty = (record, fieldName) => record.data[fieldName] === false;
BadgeSelectionField.extractProps = (fieldName, record, attrs) => {
const getOptions = () => {
switch (record.fields[fieldName].type) {
case "many2one":
// WOWL: conversion needed while we keep using the legacy model
return Object.values(record.preloadedData[fieldName]).map((v) => {
return [v.id, v.display_name];
});
case "selection":
return record.fields[fieldName].selection;
default:
return [];
}
};
return {
options: getOptions(),
};
};
registry.category("fields").add("selection_badge", BadgeSelectionField);
export function preloadSelection(orm, record, fieldName) {
const field = record.fields[fieldName];
const context = record.evalContext;
const domain = record.getFieldDomain(fieldName).toList(context);
return orm.call(field.relation, "name_search", ["", domain]);
}
registry.category("preloadedData").add("selection_badge", {
loadOnTypes: ["many2one"],
preload: preloadSelection,
});
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BadgeSelectionField" owl="1">
<t t-if="props.readonly">
<span t-esc="string" t-att-raw-value="value" />
</t>
<t t-else="">
<t t-foreach="props.options" t-as="option" t-key="option[0]">
<span
t-attf-class="o_selection_badge {{value === option[0] ? 'active' : ''}}"
t-att-value="stringify(option[0])"
t-esc="option[1]"
t-on-click="() => this.onChange(option[0])"
/>
</t>
</t>
</t>
</templates>
@@ -0,0 +1,107 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { download } from "@web/core/network/download";
import { standardFieldProps } from "../standard_field_props";
import { FileDownloader, FileUploader } from "../file_handler";
import { _lt } from "@web/core/l10n/translation";
const { Component, onWillUpdateProps, useState } = owl;
function isBinarySize(value) {
return /^\d+(\.\d*)? [^0-9]+$/.test(value);
}
export class BinaryField extends Component {
setup() {
this.notification = useService("notification");
this.state = useState({
fileName: this.props.fileName || "",
isValid: true,
});
onWillUpdateProps((nextProps) => {
if (nextProps.readonly) {
this.state.fileName = "";
}
});
}
get file() {
return {
data: this.props.fileData,
name: this.state.fileName || this.props.fileData || null,
};
}
async onFileDownload() {
await download({
data: {
model: this.props.resModel,
id: this.props.resId,
field: this.props.name,
filename_field: this.file.name,
filename: this.file.name || "",
download: true,
data: isBinarySize(this.props.fileData) ? null : this.props.fileData,
},
url: "/web/content",
});
}
onFileRemove() {
this.state.isValid = true;
this.props.update(false);
}
onFileUploaded(file) {
this.state.fileName = file.name;
this.state.isValid = true;
this.props.update(file);
}
onLoadFailed() {
this.state.isValid = false;
this.notification.add(this.env._t("Could not display the selected binary"), {
type: "danger",
});
}
}
BinaryField.template = "web.BinaryField";
BinaryField.components = {
FileDownloader,
FileUploader,
};
BinaryField.props = {
...standardFieldProps,
acceptedFileExtensions: { type: String, optional: true },
fileData: { type: String, optional: true },
fileName: { type: String, optional: true },
isDownloadable: { type: Boolean, optional: true },
resId: { type: [Number, Boolean], optional: true },
resModel: { type: String, optional: true },
};
BinaryField.defaultProps = {
acceptedFileExtensions: "*",
fileData: "",
isDownloadable: true,
};
BinaryField.displayName = _lt("File");
BinaryField.supportedTypes = ["binary"];
BinaryField.extractProps = (fieldName, record, attrs) => {
return {
acceptedFileExtensions: attrs.options.accepted_file_extensions,
fileData: record.data[fieldName] || "",
fileName: record.data[attrs.filename] || "",
isDownloadable: !(record.isReadonly(fieldName) && record.mode === "edit"),
resId: record.resId,
resModel: record.resModel,
update: (file) => {
const changes = { [fieldName]: file.data || false };
if (attrs.filename && attrs.filename !== fieldName) {
changes[attrs.filename] = file.name || false;
}
return record.update(changes);
},
};
};
registry.category("fields").add("binary", BinaryField);
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BinaryField" owl="1">
<t t-if="!props.readonly">
<t t-if="file.data">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
file="file"
onUploaded.bind="onFileUploaded"
>
<t t-set-slot="toggler">
<span class="mx-2" t-esc="file.name"/>
<button
class="btn btn-secondary fa fa-pencil o_select_file_button"
data-tooltip="Edit"
aria-label="Edit"
/>
</t>
<button
class="btn btn-secondary fa fa-trash o_clear_file_button"
data-tooltip="Clear"
aria-label="Clear"
t-on-click="onFileRemove"
/>
</FileUploader>
</t>
<t t-else="">
<label class="o_select_file_button btn btn-primary">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
onUploaded.bind="onFileUploaded"
>
<t t-set-slot="toggler">
<span t-esc="'Upload your file'"/>
</t>
</FileUploader>
</label>
</t>
</t>
<t t-elif="file.data and props.isDownloadable">
<FileDownloader file="file" onDownload.bind="onFileDownload"/>
</t>
</t>
</templates>
@@ -0,0 +1,39 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { CheckBox } from "@web/core/checkbox/checkbox";
const { Component } = owl;
export class BooleanField extends Component {
/**
* @param {boolean} newValue
*/
onChange(newValue) {
this.props.update(newValue);
}
}
BooleanField.template = "web.BooleanField";
BooleanField.components = { CheckBox };
BooleanField.props = {
...standardFieldProps,
isReadonlyEnabled: { type: Boolean, optional: true },
};
BooleanField.displayName = _lt("Checkbox");
BooleanField.supportedTypes = ["boolean"];
BooleanField.isEmpty = () => false;
BooleanField.extractProps = (fieldName, record) => {
return {
// should we make a list specialization?
readonly: !(record.isInEdition && !record.isReadonly(fieldName)),
isReadonlyEnabled:
record.activeFields[fieldName].viewType === "list" && !record.isReadonly(fieldName),
};
};
registry.category("fields").add("boolean", BooleanField);
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BooleanField" owl="1">
<CheckBox id="props.id" value="props.value or false" disabled="props.readonly" onChange.bind="onChange" />
</t>
</templates>
@@ -0,0 +1,31 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { archParseBoolean } from "@web/views/utils";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class BooleanFavoriteField extends Component {}
BooleanFavoriteField.template = "web.BooleanFavoriteField";
BooleanFavoriteField.props = {
...standardFieldProps,
noLabel: { type: Boolean, optional: true },
};
BooleanFavoriteField.defaultProps = {
noLabel: false,
};
BooleanFavoriteField.displayName = _lt("Favorite");
BooleanFavoriteField.supportedTypes = ["boolean"];
BooleanFavoriteField.isEmpty = () => false;
BooleanFavoriteField.extractProps = (fieldName, record, attrs) => {
return {
noLabel: archParseBoolean(attrs.nolabel),
};
};
registry.category("fields").add("boolean_favorite", BooleanFavoriteField);
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BooleanFavoriteField" owl="1">
<div class="o_favorite" t-on-click.prevent.stop="() => props.update(!props.value)">
<a href="#">
<i
class="fa"
role="img"
t-att-class="props.value ? 'fa-star' : 'fa-star-o'"
t-att-title="props.value ? 'Remove from Favorites' : 'Add to Favorites'"
t-att-aria-label="props.value ? 'Remove from Favorites' : 'Add to Favorites'"
/>
<t t-if="!props.noLabel"> <t t-esc="props.value ? 'Remove from Favorites' : 'Add to Favorites'" /></t>
</a>
</div>
</t>
</templates>
@@ -0,0 +1,28 @@
/** @odoo-module **/
import { CheckBox } from "@web/core/checkbox/checkbox";
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class BooleanToggleField extends Component {}
BooleanToggleField.template = "web.BooleanToggleField";
BooleanToggleField.components = { CheckBox };
BooleanToggleField.props = {
...standardFieldProps,
};
BooleanToggleField.displayName = _lt("Toggle");
BooleanToggleField.supportedTypes = ["boolean"];
BooleanToggleField.isEmpty = () => false;
BooleanToggleField.extractProps = (fieldName, record) => {
return {
readonly: record.isReadonly(fieldName),
};
};
registry.category("fields").add("boolean_toggle", BooleanToggleField);
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BooleanToggleField" owl="1">
<CheckBox
className="'o_field_boolean o_boolean_toggle'"
id="props.id"
value="props.value or false"
disabled="props.readonly"
onChange="props.update"
>
&#8203; <!-- Zero width space needed to set height -->
<i class="fa" t-att-class="props.value ? 'fa-check-circle' : 'fa-times-circle'" />
</CheckBox>
</t>
</templates>
@@ -0,0 +1,19 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { BooleanToggleField } from "./boolean_toggle_field";
const { Component } = owl;
export class ListBooleanToggleField extends Component {
onClick() {
if (!this.props.readonly) {
this.props.update(!this.props.value);
}
}
}
ListBooleanToggleField.template = "web.ListBooleanToggleField";
ListBooleanToggleField.components = { BooleanToggleField };
registry.category("fields").add("list.boolean_toggle", ListBooleanToggleField);
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ListBooleanToggleField" owl="1">
<div t-on-click="onClick">
<BooleanToggleField t-props="props" readonly="false" />
</div>
</t>
</templates>
@@ -0,0 +1,62 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { archParseBoolean } from "@web/views/utils";
import { formatChar } from "../formatters";
import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
import { TranslationButton } from "../translation_button";
const { Component } = owl;
export class CharField extends Component {
setup() {
useInputField({ getValue: () => this.props.value || "", parse: (v) => this.parse(v) });
}
get formattedValue() {
return formatChar(this.props.value, { isPassword: this.props.isPassword });
}
parse(value) {
if (this.props.shouldTrim) {
return value.trim();
}
return value;
}
}
CharField.template = "web.CharField";
CharField.components = {
TranslationButton,
};
CharField.props = {
...standardFieldProps,
autocomplete: { type: String, optional: true },
isPassword: { type: Boolean, optional: true },
placeholder: { type: String, optional: true },
shouldTrim: { type: Boolean, optional: true },
maxLength: { type: Number, optional: true },
isTranslatable: { type: Boolean, optional: true },
resId: { type: [Number, Boolean], optional: true },
resModel: { type: String, optional: true },
};
CharField.displayName = _lt("Text");
CharField.supportedTypes = ["char"];
CharField.extractProps = (fieldName, record, attrs) => {
return {
shouldTrim: record.fields[fieldName].trim,
maxLength: record.fields[fieldName].size,
isTranslatable: record.fields[fieldName].translate,
resId: record.resId,
resModel: record.resModel,
autocomplete: attrs.autocomplete,
isPassword: archParseBoolean(attrs.password),
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("char", CharField);
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CharField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<input
class="o_input"
t-att-id="props.id"
t-att-type="props.isPassword ? 'password' : 'text'"
t-att-autocomplete="props.autocomplete or (props.isPassword ? 'new-password' : 'off')"
t-att-maxlength="props.maxLength > 0 and props.maxLength"
t-att-placeholder="props.placeholder"
t-ref="input"
/>
<t t-if="props.isTranslatable">
<TranslationButton
fieldName="props.name"
resId="props.resId"
resModel="props.resModel"
value="props.value"
updateField="props.update"
/>
</t>
</t>
</t>
</templates>
@@ -0,0 +1,27 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
const { Component, useState } = owl;
export class ColorField extends Component {
setup() {
this.state = useState({
color: this.props.value || "#000000",
});
}
}
ColorField.template = "web.ColorField";
ColorField.props = {
...standardFieldProps,
};
ColorField.extractProps = (fieldName, record) => {
return {
readonly: record.isReadonly(fieldName),
};
};
ColorField.supportedTypes = ["char"];
registry.category("fields").add("color", ColorField);
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColorField" owl="1">
<div class="o_field_color d-flex" t-att-class="{ 'o_field_cursor_disabled': props.readonly }" t-attf-style="background-color: {{state.color}}">
<input t-on-click.stop="" class="w-100 h-100 opacity-0" type="color" t-att-value="props.value" t-att-disabled="props.readonly" t-on-input="(ev) => this.state.color = ev.target.value" t-on-change="(ev) => this.props.update(ev.target.value)" />
</div>
</t>
</templates>
@@ -0,0 +1,25 @@
/** @odoo-module **/
import { ColorList } from "@web/core/colorlist/colorlist";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class ColorPickerField extends Component {
switchColor(colorIndex) {
this.props.update(colorIndex);
}
}
ColorPickerField.template = "web.ColorPickerField";
ColorPickerField.components = {
ColorList,
};
ColorPickerField.props = {
...standardFieldProps,
};
ColorPickerField.supportedTypes = ["integer"];
ColorPickerField.RECORD_COLORS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
registry.category("fields").add("color_picker", ColorPickerField);
@@ -1,24 +1,29 @@
.o_field_color_picker {
.o_field_widget.o_field_color_picker > div {
display: flex;
float: right;
margin-right: 7px;
width: 100%;
ul {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
@include o-kanban-colorpicker;
@include o-kanban-record-color;
width: 100%;
max-width: unset;
margin: 0;
padding: 0;
> li {
border: 2px solid white;
box-shadow: 0 0 0 1px gray('300');
margin: $o-kanban-inner-hmargin 4px 0 0;
> a:focus {
outline: none;
flex-wrap: wrap;
justify-content: flex-start;
// to compensate on the 2px margin used to space the elements relative to each other
// we keep the top one cause the widget is a bit to high anyway
margin-left: -2px;
margin-right: -2px;
margin-bottom: -2px;
> div {
display: inline-block;
border: 1px solid white;
box-shadow: 0 0 0 1px gray('300');
margin: 2px;
> a {
display: block;
&::after {
content: "";
display: block;
width: 20px;
height: 15px;
}
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColorPickerField" owl="1">
<ColorList colors="constructor.RECORD_COLORS" onColorSelected.bind="switchColor" togglerColor="props.value || 0"/>
</t>
</templates>
@@ -0,0 +1,39 @@
/** @odoo-module **/
const { Component, useEffect, useState } = owl;
export class CopyButton extends Component {
setup() {
this.state = useState({
isCopied: false,
});
useEffect(
() => {
return () => clearTimeout(this.timeoutId);
},
() => [this.state.isCopied]
);
}
async onClick() {
// any kind of content can be copied into the clipboard using
// the appropriate native methods
if (typeof this.props.content === "string") {
navigator.clipboard.writeText(this.props.content).then(() => {
this.state.isCopied = true;
});
} else {
navigator.clipboard.write(this.props.content).then(() => {
this.state.isCopied = true;
});
}
this.timeoutId = setTimeout(() => (this.state.isCopied = false), 800);
}
}
CopyButton.template = "web.CopyButton";
CopyButton.props = {
className: { type: String, optional: true },
copyText: { type: String, optional: true },
successText: { type: String, optional: true },
content: { type: [String, Object], optional: true },
};
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CopyButton" owl="1">
<button
t-attf-class="btn btn-sm btn-primary o_clipboard_button {{ props.className || '' }} {{state.isCopied ? 'bg-success' : ''}}"
t-on-click.stop="onClick"
>
<t t-if="!state.isCopied">
<span class="fa fa-clipboard mx-1"/>
<span t-esc="props.copyText"/>
</t>
<t t-else="">
<span class="fa fa-check mx-1"/>
<span t-esc="props.successText"/>
</t>
</button>
</t>
</templates>
@@ -0,0 +1,47 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { CopyButton } from "./copy_button";
import { UrlField } from "../url/url_field";
import { CharField } from "../char/char_field";
import { TextField } from "../text/text_field";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
class CopyClipboardField extends Component {
setup() {
this.copyText = this.env._t("Copy");
this.successText = this.env._t("Copied");
}
}
CopyClipboardField.template = "web.CopyClipboardField";
CopyClipboardField.props = {
...standardFieldProps,
};
export class CopyClipboardCharField extends CopyClipboardField {}
CopyClipboardCharField.components = { Field: CharField, CopyButton };
CopyClipboardCharField.displayName = _lt("Copy Text to Clipboard");
CopyClipboardCharField.supportedTypes = ["char"];
registry.category("fields").add("CopyClipboardChar", CopyClipboardCharField);
export class CopyClipboardTextField extends CopyClipboardField {}
CopyClipboardTextField.components = { Field: TextField, CopyButton };
CopyClipboardTextField.displayName = _lt("Copy Multiline Text to Clipboard");
CopyClipboardTextField.supportedTypes = ["text"];
registry.category("fields").add("CopyClipboardText", CopyClipboardTextField);
export class CopyClipboardURLField extends CopyClipboardField {}
CopyClipboardURLField.components = { Field: UrlField, CopyButton };
CopyClipboardURLField.displayName = _lt("Copy URL to Clipboard");
CopyClipboardURLField.supportedTypes = ["char"];
registry.category("fields").add("CopyClipboardURL", CopyClipboardURLField);
@@ -0,0 +1,19 @@
.o_field_CopyClipboardText, .o_field_CopyClipboardURL, .o_field_CopyClipboardChar {
display: flex !important;
flex-wrap: nowrap;
width: fit-content !important;
border-radius: 5px;
border: 1px solid $primary;
font-size: $font-size-sm;
color: $o-brand-primary;
font-weight: $badge-font-weight;
&.o_field_empty {
display: none !important;
}
> span {
padding: 0 5px;
text-align: center;
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CopyClipboardField" owl="1">
<t t-if="props.value">
<Field t-props="props"/>
<CopyButton className="`o_btn_${props.type}_copy`" content="props.value" copyText="copyText" successText="successText"/>
</t>
</t>
</templates>
@@ -0,0 +1,54 @@
/** @odoo-module **/
import { DatePicker } from "@web/core/datepicker/datepicker";
import { areDateEquals, formatDate } from "@web/core/l10n/dates";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class DateField extends Component {
get date() {
return this.props.value && this.props.value.startOf("day");
}
get formattedValue() {
return formatDate(this.props.value, {
// get local date if field type is datetime
timezone: this.props.isDateTime,
});
}
onDateTimeChanged(date) {
if (!areDateEquals(this.date || "", date)) {
this.props.update(date);
}
}
}
DateField.template = "web.DateField";
DateField.components = {
DatePicker,
};
DateField.props = {
...standardFieldProps,
isDateTime: { type: Boolean, optional: true },
pickerOptions: { type: Object, optional: true },
};
DateField.defaultProps = {
isDateTime: false,
pickerOptions: {},
};
DateField.displayName = _lt("Date");
DateField.supportedTypes = ["date", "datetime"];
DateField.extractProps = (fieldName, record, attrs) => {
return {
isDateTime: record.fields[fieldName].type === "datetime",
pickerOptions: attrs.options.datepicker,
};
};
registry.category("fields").add("date", DateField);
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<DatePicker
t-props="props.pickerOptions"
date="date"
onDateTimeChanged="(date) => this.onDateTimeChanged(date)"
/>
</t>
</t>
</templates>
@@ -0,0 +1,155 @@
/** @odoo-module **/
import { localization } from "@web/core/l10n/localization";
import { registry } from "@web/core/registry";
import { loadJS } from "@web/core/assets";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillStart, useExternalListener, useRef, useEffect } = owl;
const formatters = registry.category("formatters");
const parsers = registry.category("parsers");
export class DateRangeField extends Component {
setup() {
this.notification = useService("notification");
this.root = useRef("root");
this.isPickerShown = false;
this.pickerContainer;
useExternalListener(window, "scroll", this.onWindowScroll, { capture: true });
onWillStart(() => loadJS("/web/static/lib/daterangepicker/daterangepicker.js"));
useEffect(
(el) => {
if (el) {
window.$(el).daterangepicker({
timePicker: this.isDateTime,
timePicker24Hour: true,
timePickerIncrement: 5,
autoUpdateInput: false,
locale: {
applyLabel: this.env._t("Apply"),
cancelLabel: this.env._t("Cancel"),
format: this.isDateTime
? localization.dateTimeFormat
: localization.dateFormat,
},
startDate: window.moment(this.formattedStartDate),
endDate: window.moment(this.formattedEndDate),
});
this.pickerContainer = window.$(el).data("daterangepicker").container[0];
window.$(el).on("apply.daterangepicker", this.onPickerApply.bind(this));
window.$(el).on("show.daterangepicker", this.onPickerShow.bind(this));
window.$(el).on("hide.daterangepicker", this.onPickerHide.bind(this));
this.pickerContainer.dataset.name = this.props.name;
}
return () => {
if (el) {
this.pickerContainer.remove();
}
};
},
() => [this.root.el]
);
}
get isDateTime() {
return this.props.formatType === "datetime";
}
get formattedValue() {
return this.formatValue(this.props.formatType, this.props.value);
}
get formattedEndDate() {
return this.formatValue(this.props.formatType, this.props.endDate);
}
get formattedStartDate() {
return this.formatValue(this.props.formatType, this.props.startDate);
}
formatValue(format, value) {
const formatter = formatters.get(format);
let formattedValue;
try {
formattedValue = formatter(value, { timezone: this.isDateTime });
} catch {
this.props.invalidate();
}
return formattedValue;
}
onChangeInput(ev) {
const parse = parsers.get(this.props.formatType);
let value;
try {
value = parse(ev.target.value, { timezone: this.isDateTime });
} catch {
this.props.invalidate();
return;
}
this.props.update(value);
}
onWindowScroll(ev) {
const target = ev.target;
if (
this.isPickerShown &&
!this.env.isSmall &&
(target === window || !this.pickerContainer.contains(target))
) {
window.$(this.root.el).data("daterangepicker").hide();
}
}
async onPickerApply(ev, picker) {
let start = this.isDateTime ? picker.startDate : picker.startDate.startOf("day");
let end = this.isDateTime ? picker.endDate : picker.endDate.startOf("day");
const format = this.isDateTime ? localization.dateTimeFormat : localization.dateFormat;
const parser = parsers.get(this.props.formatType);
const dates = [start, end].map((date) => {
return parser(date.format(format.toUpperCase()), {
timezone: this.isDateTime,
});
});
await this.props.updateRange(dates[0], dates[1]);
const input = document.querySelector(
`.o_field_daterange[name='${this.props.relatedDateRange}'] input`
);
const target = window.$(input).data("daterangepicker");
target.setStartDate(picker.startDate);
target.setEndDate(picker.endDate);
}
onPickerShow() {
this.isPickerShown = true;
}
onPickerHide() {
this.isPickerShown = false;
}
}
DateRangeField.template = "web.DateRangeField";
DateRangeField.supportedTypes = ["date", "datetime"];
DateRangeField.extractProps = (fieldName, record, attrs) => {
const relatedEndDate = attrs.options.related_end_date;
const relatedStartDate = attrs.options.related_start_date;
return {
relatedDateRange: relatedEndDate ? relatedEndDate : relatedStartDate,
endDate: record.data[relatedEndDate || fieldName],
formatType: attrs.options.format_type || record.fields[fieldName].type,
invalidate: () => record.setInvalidField(fieldName),
startDate: record.data[relatedStartDate || fieldName],
async updateRange(start, end) {
await record.update({
[relatedStartDate || fieldName]: start,
[relatedEndDate || fieldName]: end,
});
},
};
};
registry.category("fields").add("daterange", DateRangeField);
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateRangeField" owl="1">
<t t-if="props.readonly">
<t t-esc="formattedValue" />
</t>
<t t-else="">
<input t-ref="root" type="text" t-att-value="formattedValue" t-on-change="onChangeInput" />
</t>
</t>
</templates>
@@ -0,0 +1,44 @@
/** @odoo-module **/
import { DateTimePicker } from "@web/core/datepicker/datepicker";
import { areDateEquals, formatDateTime } from "@web/core/l10n/dates";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class DateTimeField extends Component {
get formattedValue() {
return formatDateTime(this.props.value, { timezone: true });
}
onDateTimeChanged(date) {
if (!areDateEquals(this.props.value || "", date)) {
this.props.update(date);
}
}
}
DateTimeField.template = "web.DateTimeField";
DateTimeField.components = {
DateTimePicker,
};
DateTimeField.props = {
...standardFieldProps,
pickerOptions: { type: Object, optional: true },
};
DateTimeField.defaultProps = {
pickerOptions: {},
};
DateTimeField.displayName = _lt("Date & Time");
DateTimeField.supportedTypes = ["datetime"];
DateTimeField.extractProps = (fieldName, record, attrs) => {
return {
pickerOptions: attrs.options.datepicker,
};
};
registry.category("fields").add("datetime", DateTimeField);
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.DateTimeField" owl="1">
<t t-if="props.readonly">
<span t-esc="formattedValue" />
</t>
<t t-else="">
<DateTimePicker
t-props="props.pickerOptions"
date="props.value"
onDateTimeChanged="(datetime) => this.onDateTimeChanged(datetime)"
/>
</t>
</t>
</templates>
@@ -0,0 +1,129 @@
/** @odoo-module **/
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { Domain } from "@web/core/domain";
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
import { standardFieldProps } from "../standard_field_props";
const { Component, onWillStart, onWillUpdateProps, useState } = owl;
export class DomainField extends Component {
setup() {
this.orm = useService("orm");
this.state = useState({
recordCount: null,
isValid: true,
});
this.dialog = useService("dialog");
this.displayedDomain = null;
this.isDebugEdited = false;
onWillStart(() => {
this.displayedDomain = this.props.value;
this.loadCount(this.props);
});
onWillUpdateProps((nextProps) => {
this.isDebugEdited = this.isDebugEdited && this.props.readonly === nextProps.readonly;
if (!this.isDebugEdited) {
this.displayedDomain = nextProps.value;
this.loadCount(nextProps);
}
});
useBus(this.env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", async (ev) => {
if (this.isDebugEdited) {
const prom = this.loadCount(this.props);
ev.detail.proms.push(prom);
await prom;
if (!this.state.isValid) {
this.props.invalidate();
}
}
});
}
onButtonClick() {
this.dialog.add(SelectCreateDialog, {
title: this.env._t("Selected records"),
noCreate: true,
multiSelect: false,
resModel: this.props.resModel,
domain: this.getDomain(this.props.value).toList(this.props.context) || [],
context: this.props.context || {},
});
}
get isValidDomain() {
try {
this.getDomain(this.props.value).toList();
return true;
} catch (_e) {
// WOWL TODO: rethrow error when not the expected type
return false;
}
}
getDomain(value) {
return new Domain(value || "[]");
}
async loadCount(props) {
if (!props.resModel) {
Object.assign(this.state, { recordCount: 0, isValid: true });
}
let recordCount;
try {
const domain = this.getDomain(props.value).toList(props.context);
recordCount = await this.orm.silent.call(props.resModel, "search_count", [domain], {
context: props.context,
});
} catch (_e) {
// WOWL TODO: rethrow error when not the expected type
Object.assign(this.state, { recordCount: 0, isValid: false });
return;
}
Object.assign(this.state, { recordCount, isValid: true });
}
update(domain, isDebugEdited) {
this.isDebugEdited = isDebugEdited;
return this.props.update(domain);
}
}
DomainField.template = "web.DomainField";
DomainField.components = {
DomainSelector,
};
DomainField.props = {
...standardFieldProps,
context: { type: Object, optional: true },
invalidate: { type: Function, optional: true },
resModel: { type: String, optional: true },
};
DomainField.defaultProps = {
context: {},
invalidate: () => {},
};
DomainField.displayName = _lt("Domain");
DomainField.supportedTypes = ["char"];
DomainField.isEmpty = () => false;
DomainField.extractProps = (fieldName, record, attrs) => {
let resModel = attrs.options.model;
if (record.fieldNames.includes(resModel)) {
resModel = record.data[resModel];
}
return {
context: record.getFieldContext(fieldName),
invalidate: () => record.setInvalidField(fieldName),
resModel,
};
};
registry.category("fields").add("domain", DomainField);
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.DomainField" owl="1">
<div t-att-class="{ o_inline_mode: !env.inDialog }">
<t t-if="props.resModel">
<DomainSelector
resModel="props.resModel"
value="displayedDomain || '[]'"
readonly="props.readonly"
update.bind="update"
isDebugMode="!!env.debug"
className="props.readonly ? 'o_read_mode' : 'o_edit_mode'"
/>
<div class="o_field_domain_panel">
<t t-if="state.recordCount !== null">
<i class="fa fa-arrow-right" role="img" aria-label="Domain" title="Domain" />
<t t-if="state.isValid and isValidDomain">
<button class="btn btn-sm btn-secondary o_domain_show_selection_button" type="button" t-on-click.stop="onButtonClick">
<t t-esc="state.recordCount" /> record(s)
</button>
</t>
<t t-else="">
<span class="text-warning" role="alert">
<i class="fa fa-exclamation-triangle" role="img" aria-label="Warning" title="Warning" /> Invalid domain
</span>
</t>
<t t-if="!!env.debug and !props.readonly">
<button
class="btn btn-sm btn-icon fa fa-refresh o_refresh_count"
role="img"
aria-label="Refresh"
title="Refresh"
t-on-click="() => this.loadCount(props)"
/>
</t>
</t>
<t t-else="">
<i class="fa fa-circle-o-notch fa-spin" role="img" aria-label="Loading" title="Loading" />
</t>
</div>
</t>
<t t-else="">
<div>Select a model to add a filter.</div>
</t>
</div>
</t>
</templates>
@@ -0,0 +1,24 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class EmailField extends Component {
setup() {
useInputField({ getValue: () => this.props.value || "" });
}
}
EmailField.template = "web.EmailField";
EmailField.props = {
...standardFieldProps,
};
EmailField.displayName = _lt("Email");
EmailField.supportedTypes = ["char"];
registry.category("fields").add("email", EmailField);
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.EmailField" owl="1">
<t t-if="props.readonly">
<a class="o_form_uri o_text_overflow" t-on-click.stop="" t-att-href="props.value ? 'mailto:'+props.value : undefined" t-esc="props.value || ''"/>
</t>
<t t-else="">
<input
class="o_input"
t-att-id="props.id"
type="email"
t-att-placeholder="props.placeholder"
t-att-required="props.required"
t-ref="input"
/>
</t>
</t>
</templates>
+262
View File
@@ -0,0 +1,262 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { archParseBoolean, evalDomain, X2M_TYPES } from "@web/views/utils";
import { getTooltipInfo } from "./field_tooltip";
const { Component, xml } = owl;
const viewRegistry = registry.category("views");
const fieldRegistry = registry.category("fields");
class DefaultField extends Component {}
DefaultField.template = xml``;
function getFieldClassFromRegistry(fieldType, widget, viewType, jsClass) {
if (jsClass && widget) {
const name = `${jsClass}.${widget}`;
if (fieldRegistry.contains(name)) {
return fieldRegistry.get(name);
}
}
if (viewType && widget) {
const name = `${viewType}.${widget}`;
if (fieldRegistry.contains(name)) {
return fieldRegistry.get(name);
}
}
if (widget) {
if (fieldRegistry.contains(widget)) {
return fieldRegistry.get(widget);
}
console.warn(`Missing widget: ${widget} for field of type ${fieldType}`);
}
if (viewType && fieldType) {
const name = `${viewType}.${fieldType}`;
if (fieldRegistry.contains(name)) {
return fieldRegistry.get(name);
}
}
if (fieldRegistry.contains(fieldType)) {
return fieldRegistry.get(fieldType);
}
return DefaultField;
}
export function fieldVisualFeedback(FieldComponent, record, fieldName, fieldInfo) {
const modifiers = fieldInfo.modifiers || {};
const readonly = evalDomain(modifiers.readonly, record.evalContext);
const inEdit = record.mode !== "readonly";
let empty = !record.isVirtual;
if ("isEmpty" in FieldComponent) {
empty = empty && FieldComponent.isEmpty(record, fieldName);
} else {
empty = empty && !record.data[fieldName];
}
empty = inEdit ? empty && readonly : empty;
return {
readonly,
required: evalDomain(modifiers.required, record.evalContext),
invalid: record.isInvalid(fieldName),
empty,
};
}
export class Field extends Component {
setup() {
this.FieldComponent = this.props.fieldInfo.FieldComponent;
if (!this.FieldComponent) {
const fieldType = this.props.record.fields[this.props.name].type;
this.FieldComponent = getFieldClassFromRegistry(fieldType, this.props.type);
}
}
get classNames() {
const { class: _class, fieldInfo, name, record } = this.props;
const { readonly, required, invalid, empty } = fieldVisualFeedback(
this.FieldComponent,
record,
name,
fieldInfo
);
const classNames = {
o_field_widget: true,
o_readonly_modifier: readonly,
o_required_modifier: required,
o_field_invalid: invalid,
o_field_empty: empty,
[`o_field_${this.type}`]: true,
[_class]: Boolean(_class),
};
// generate field decorations classNames (only if field-specific decorations
// have been defined in an attribute, e.g. decoration-danger="other_field = 5")
// only handle the text-decoration.
const { decorations } = fieldInfo;
const evalContext = record.evalContext;
for (const decoName in decorations) {
const value = evaluateExpr(decorations[decoName], evalContext);
classNames[`text-${decoName}`] = value;
}
return classNames;
}
get type() {
return this.props.type || this.props.record.fields[this.props.name].type;
}
get fieldComponentProps() {
const record = this.props.record;
const evalContext = record.evalContext;
const field = record.fields[this.props.name];
const fieldInfo = this.props.fieldInfo;
const modifiers = fieldInfo.modifiers || {};
const required = evalDomain(modifiers.required, evalContext);
const readonlyFromModifiers = evalDomain(modifiers.readonly, evalContext);
const readonlyFromViewMode = !this.props.record.isInEdition;
const emptyRequiredValue = required && !this.props.value;
// Decoration props
const decorationMap = {};
const { decorations } = fieldInfo;
for (const decoName in decorations) {
const value = evaluateExpr(decorations[decoName], evalContext);
decorationMap[decoName] = value;
}
let propsFromAttrs = {};
if (this.FieldComponent.extractProps) {
const attrs = Object.assign({}, this.props.attrs || fieldInfo.rawAttrs, {
options: fieldInfo.options || {},
});
propsFromAttrs = this.FieldComponent.extractProps(this.props.name, record, attrs);
}
const props = { ...this.props };
delete props.style;
delete props.class;
delete props.showTooltip;
delete props.fieldInfo;
delete props.attrs;
return {
...fieldInfo.props,
update: async (value) => {
await record.update({ [this.props.name]: value });
if (record.selected && record.model.multiEdit) {
return;
}
// We save only if we're on view mode readonly and no readonly field modifier
if (readonlyFromViewMode && !readonlyFromModifiers && !emptyRequiredValue) {
// TODO: maybe move this in the model
return record.save();
}
},
value: this.props.record.data[this.props.name],
decorations: decorationMap,
readonly: readonlyFromViewMode || readonlyFromModifiers || false,
...propsFromAttrs,
...props,
type: field.type,
};
}
get tooltip() {
if (this.props.showTooltip) {
const tooltip = getTooltipInfo({
field: this.props.record.fields[this.props.name],
fieldInfo: this.props.fieldInfo,
});
if (Boolean(odoo.debug) || (tooltip && JSON.parse(tooltip).field.help)) {
return tooltip;
}
}
return false;
}
}
Field.template = xml/* xml */ `
<div t-att-name="props.name" t-att-class="classNames" t-att-style="props.style" t-att-data-tooltip-template="tooltip and 'web.FieldTooltip'" t-att-data-tooltip-info="tooltip">
<t t-component="FieldComponent" t-props="fieldComponentProps"/>
</div>`;
Field.parseFieldNode = function (node, models, modelName, viewType, jsClass) {
const name = node.getAttribute("name");
const widget = node.getAttribute("widget");
const fields = models[modelName];
const field = fields[name];
const fieldInfo = {
name,
viewType,
context: node.getAttribute("context") || "{}",
domain: node.getAttribute("domain") || "[]",
string: node.getAttribute("string") || field.string,
widget,
modifiers: JSON.parse(node.getAttribute("modifiers") || "{}"),
onChange: archParseBoolean(node.getAttribute("on_change")),
FieldComponent: getFieldClassFromRegistry(fields[name].type, widget, viewType, jsClass),
decorations: {}, // populated below
noLabel: archParseBoolean(node.getAttribute("nolabel")),
props: {},
rawAttrs: {},
options: evaluateExpr(node.getAttribute("options") || "{}"),
};
for (const attribute of node.attributes) {
if (attribute.name in Field.forbiddenAttributeNames) {
throw new Error(Field.forbiddenAttributeNames[attribute.name]);
}
// prepare field decorations
if (attribute.name.startsWith("decoration-")) {
const decorationName = attribute.name.replace("decoration-", "");
fieldInfo.decorations[decorationName] = attribute.value;
continue;
}
fieldInfo.rawAttrs[attribute.name] = attribute.value;
}
if (fieldInfo.modifiers.invisible !== true && X2M_TYPES.includes(field.type)) {
const views = {};
for (const child of node.children) {
const viewType = child.tagName === "tree" ? "list" : child.tagName;
const { ArchParser } = viewRegistry.get(viewType);
const xmlSerializer = new XMLSerializer();
const subArch = xmlSerializer.serializeToString(child);
const archInfo = new ArchParser().parse(subArch, models, field.relation);
views[viewType] = {
...archInfo,
fields: models[field.relation],
};
fieldInfo.relatedFields = models[field.relation];
}
fieldInfo.viewMode =
(node.getAttribute("mode") === "tree" ? "list" : node.getAttribute("mode")) ||
Object.keys(views).find((v) => ["list", "kanban"].includes(v));
const fieldsToFetch = { ...fieldInfo.FieldComponent.fieldsToFetch }; // should become an array?
// special case for color field
// GES: this is not nice, we will look for something better.
const colorField = fieldInfo.options.color_field;
if (colorField) {
fieldsToFetch[colorField] = { name: colorField, type: "integer", active: true };
}
fieldInfo.fieldsToFetch = fieldsToFetch;
fieldInfo.relation = field.relation; // not really necessary
fieldInfo.views = views;
}
return fieldInfo;
};
Field.forbiddenAttributeNames = {
decorations: `You cannot use the "decorations" attribute name as it is used as generated prop name for the composite decoration-<something> attributes.`,
};
Field.defaultProps = { fieldInfo: {} };
@@ -0,0 +1,37 @@
/** @odoo-module **/
import fieldRegistry from "web.field_registry";
export function getTooltipInfo(params) {
let widgetDescription = undefined;
if (params.fieldInfo.widget) {
if (params.fieldInfo.FieldComponent.name === "LegacyField") {
const widget = fieldRegistry.get(params.fieldInfo.widget);
widgetDescription = widget.prototype.description;
} else {
widgetDescription = params.fieldInfo.FieldComponent.description;
}
}
const info = {
viewMode: params.viewMode,
resModel: params.resModel,
debug: Boolean(odoo.debug),
field: {
label: params.field.string,
name: params.field.name,
help: params.help || params.field.help,
type: params.field.type,
widget: params.fieldInfo.widget,
widgetDescription,
context: params.fieldInfo.context,
domain: params.fieldInfo.domain,
modifiers: JSON.stringify(params.fieldInfo.modifiers),
changeDefault: params.field.change_default,
relation: params.field.relation,
selection: params.field.selection,
default: params.field.default,
},
};
return JSON.stringify(info);
}
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FieldTooltip" owl="1">
<div t-if="info.field.label" class="o-tooltip--string" role="tooltip">
<t t-esc="info.field.label"/> <t t-if="info.field.noLabel and info.viewMode === 'form'">(nolabel)</t>
</div>
<p t-if="info.field.help" class="o-tooltip--help" role="tooltip">
<t t-esc="info.field.help"/>
</p>
<ul class="o-tooltip--technical" t-if="info.debug" role="tooltip">
<li data-item="field" t-if="info.field and info.field.name">
<span class="o-tooltip--technical--title">Field:</span>
<t t-esc="info.field.name"/>
</li>
<li data-item="object" t-if="info.resModel">
<span class="o-tooltip--technical--title">Model:</span>
<t t-esc="info.resModel"/>
</li>
<t t-if="info.field">
<li t-if="info.field.type" data-item="type" >
<span class="o-tooltip--technical--title">Type:</span>
<t t-esc="info.field.type"/>
</li>
<li t-if="info.field.widget" data-item="widget">
<span class="o-tooltip--technical--title">Widget:</span>
<t t-if="info.field.widgetDescription" t-esc="info.field.widgetDescription"/>
<t t-if="info.field.widgetDescription"> (</t>
<t t-esc="info.field.widget"/>
<t t-if="info.field.widgetDescription">)</t>
</li>
<li t-if="info.field.context" data-item="context">
<span class="o-tooltip--technical--title">Context:</span>
<t t-esc="info.field.context"/>
</li>
<li t-if="info.field.domain" data-item="domain">
<span class="o-tooltip--technical--title">Domain:</span>
<t t-esc="info.field.domain"/>
</li>
<li t-if="info.field.modifiers" data-item="modifiers">
<span class="o-tooltip--technical--title">Modifiers:</span>
<t t-esc="info.field.modifiers"/>
</li>
<li t-if="info.field.default" data-item="default">
<span class="o-tooltip--technical--title">Default:</span>
<t t-esc="info.field.default"/>
</li>
<li t-if="info.field.changeDefault" data-item="changeDefault">
<span class="o-tooltip--technical--title">Change default:</span>
Yes
</li>
<li t-if="info.field.relation" data-item="relation">
<span class="o-tooltip--technical--title">Relation:</span>
<t t-esc="info.field.relation"/>
</li>
<li t-if="info.field.selection" data-item="selection">
<span class="o-tooltip--technical--title">Selection:</span>
<ul class="o-tooltip--technical">
<li t-foreach="info.field.selection" t-as="option" t-key="option_index">
[<t t-esc="option[0]"/>]
<t t-if="option[1]"> - </t>
<t t-esc="option[1]"/>
</li>
</ul>
</li>
</t>
</ul>
</t>
</templates>
@@ -0,0 +1,3 @@
.o_field_cursor_disabled {
cursor: not-allowed;
}
@@ -0,0 +1,103 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { session } from "@web/session";
import { sprintf } from "@web/core/utils/strings";
import { formatFloat } from "./formatters";
const { Component, useRef, useState } = owl;
const DEFAULT_MAX_FILE_SIZE = 128 * 1024 * 1024;
/**
* Gets dataURL (base64 data) from the given file or blob.
* Technically wraps FileReader.readAsDataURL in Promise.
*
* @param {Blob | File} file
* @returns {Promise} resolved with the dataURL, or rejected if the file is
* empty or if an error occurs.
*/
function getDataURLFromFile(file) {
if (!file) {
return Promise.reject();
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(reader.result));
reader.addEventListener("abort", reject);
reader.addEventListener("error", reject);
reader.readAsDataURL(file);
});
}
export class FileUploader extends Component {
setup() {
this.notification = useService("notification");
this.id = `o_fileupload_${++FileUploader.nextId}`;
this.fileInputRef = useRef("fileInput");
this.state = useState({
isUploading: false,
});
}
get maxUploadSize() {
return session.max_file_upload_size || DEFAULT_MAX_FILE_SIZE;
}
/**
* @param {Event} ev
*/
async onFileChange(ev) {
if (!ev.target.files.length) return;
for (const file of ev.target.files) {
if (file.size > this.maxUploadSize) {
this.notification.add(
sprintf(
this.env._t("The selected file exceed the maximum file size of %s."),
formatFloat(this.maxUploadSize, { humanReadable: true })
),
{
title: this.env._t("File upload"),
type: "danger",
}
);
}
this.state.isUploading = true;
const data = await getDataURLFromFile(file);
if (!file.size) {
console.warn(`Error while uploading file : ${file.name}`);
this.notification.add(
this.env._t("There was a problem while uploading your file."),
{
type: "danger",
}
);
}
this.props.onUploaded({
name: file.name,
size: file.size,
type: file.type,
data: data.split(",")[1],
objectUrl: file.type === "application/pdf" ? URL.createObjectURL(file) : null,
});
this.state.isUploading = false;
}
}
onSelectFileButtonClick() {
this.fileInputRef.el.click();
}
}
FileUploader.template = "web.FileUploader";
FileUploader.nextId = 0;
export class FileDownloader extends Component {
onDownloadButtonClick() {
this.props.onDownload();
}
}
FileDownloader.props = {
file: { type: Object, optional: true },
fileDownloadAction: { type: String, optional: true },
onDownload: Function,
};
FileDownloader.template = "web.FileDownloader";
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FileUploader" owl="1">
<span>
<span t-if="state.isUploading" class="btn btn-primary" t-esc="'Uploading...'"/>
<span t-else="" t-on-click.prevent="onSelectFileButtonClick">
<t t-slot="toggler"/>
</span>
<t t-slot="default"/>
<input
type="file"
t-ref="fileInput"
t-attf-class="o_input_file o_hidden {{ props.fileUploadClass or '' }}"
t-att-multiple="props.multiUpload ? 'multiple' : false" t-att-accept="props.acceptedFileExtensions or '*'"
t-on-change="onFileChange"
/>
</span>
</t>
<t t-name="web.FileDownloader" owl="1">
<span t-esc="props.file.name"/>
<a class="o_form_uri mx-2" href="javascript:void(0)" t-on-click.prevent="onDownloadButtonClick">
<span class="fa fa-download"/>
</a>
</t>
</templates>
@@ -0,0 +1,79 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useInputField } from "../input_field_hook";
import { useNumpadDecimal } from "../numpad_decimal_hook";
import { formatFloat } from "../formatters";
import { parseFloat } from "../parsers";
import { standardFieldProps } from "../standard_field_props";
const { Component, onWillUpdateProps } = owl;
export class FloatField extends Component {
setup() {
this.defaultInputValue = this.getFormattedValue();
useInputField({
getValue: () => this.defaultInputValue,
refName: "numpadDecimal",
parse: (v) => this.parse(v),
});
useNumpadDecimal();
onWillUpdateProps((nextProps) => {
if (
nextProps.readonly !== this.props.readonly &&
!nextProps.readonly &&
nextProps.inputType !== "number"
) {
this.defaultInputValue = this.getFormattedValue(nextProps);
}
});
}
parse(value) {
return this.props.inputType === "number" ? Number(value) : parseFloat(value);
}
onChange(ev) {
this.defaultInputValue = ev.target.value;
}
getFormattedValue(props = this.props) {
if (props.inputType === "number" && !props.readonly && props.value) {
return props.value;
}
return formatFloat(props.value, { digits: props.digits });
}
}
FloatField.template = "web.FloatField";
FloatField.props = {
...standardFieldProps,
inputType: { type: String, optional: true },
step: { type: Number, optional: true },
digits: { type: Array, optional: true },
invalidate: { type: Function, optional: true },
};
FloatField.defaultProps = {
inputType: "text",
invalidate: () => {},
};
FloatField.displayName = _lt("Float");
FloatField.supportedTypes = ["float"];
FloatField.isEmpty = () => false;
FloatField.extractProps = (fieldName, record, attrs) => {
return {
invalidate: () => record.setInvalidField(fieldName),
inputType: attrs.options.type,
step: attrs.options.step,
// Sadly, digits param was available as an option and an attr.
// The option version could be removed with some xml refactoring.
digits:
(attrs.digits ? JSON.parse(attrs.digits) : attrs.options.digits) ||
record.fields[fieldName].digits,
};
};
registry.category("fields").add("float", FloatField);
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatField" owl="1">
<span t-if="props.readonly" t-esc="getFormattedValue()" />
<input t-else="" t-att-id="props.id" t-ref="numpadDecimal" t-att-type="props.inputType" inputmode="numeric" class="o_input" t-on-change="onChange" t-att-step="props.step" />
</t>
</templates>
@@ -0,0 +1,47 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { FloatField } from "../float/float_field";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class FloatFactorField extends Component {
get floatFieldProps() {
const result = {
...this.props,
value: this.props.value * this.props.factor,
update: (value) => this.props.update(value / this.props.factor),
};
delete result.factor;
return result;
}
}
FloatFactorField.template = "web.FloatFactorField";
FloatFactorField.components = { FloatField };
FloatFactorField.props = {
...standardFieldProps,
inputType: { type: String, optional: true },
digits: { type: Array, optional: true },
invalidate: { type: Function, optional: true },
factor: { type: Number, optional: true },
};
FloatFactorField.defaultProps = {
inputType: "text",
invalidate: () => {},
factor: 1,
};
FloatFactorField.supportedTypes = ["float"];
FloatFactorField.isEmpty = () => false;
FloatFactorField.extractProps = (fieldName, record, attrs) => {
return {
invalidate: record.setInvalidField.bind(record),
inputType: attrs.options.type,
digits: attrs.digits ? JSON.parse(attrs.digits) : attrs.options.digits,
factor: attrs.options.factor,
};
};
registry.category("fields").add("float_factor", FloatFactorField);
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatFactorField" owl="1">
<FloatField t-props="floatFieldProps" />
</t>
</templates>
@@ -0,0 +1,47 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { formatFloatTime } from "../formatters";
import { parseFloatTime } from "../parsers";
import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
import { useNumpadDecimal } from "../numpad_decimal_hook";
const { Component } = owl;
export class FloatTimeField extends Component {
setup() {
useInputField({
getValue: () => this.formattedValue,
refName: "numpadDecimal",
parse: (v) => parseFloatTime(v),
});
useNumpadDecimal();
}
get formattedValue() {
return formatFloatTime(this.props.value);
}
}
FloatTimeField.template = "web.FloatTimeField";
FloatTimeField.props = {
...standardFieldProps,
invalidate: { type: Function, optional: true },
};
FloatTimeField.defaultProps = {
invalidate: () => {},
};
FloatTimeField.displayName = _lt("Time");
FloatTimeField.supportedTypes = ["float"];
FloatTimeField.isEmpty = () => false;
FloatTimeField.extractProps = (fieldName, record) => {
return {
invalidate: () => record.setInvalidField(fieldName),
};
};
registry.category("fields").add("float_time", FloatTimeField);
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatTimeField" owl="1">
<span t-if="props.readonly" t-esc="formattedValue" />
<input t-else="" t-att-id="props.id" t-ref="numpadDecimal" inputmode="numeric" class="o_input" />
</t>
</templates>
@@ -0,0 +1,56 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { formatFloat } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class FloatToggleField extends Component {
// TODO perf issue (because of update round trip)
// we probably want to have a state and a useEffect or onWillUpateProps
onChange() {
let currentIndex = this.props.range.indexOf(this.props.value * this.props.factor);
currentIndex++;
if (currentIndex > this.props.range.length - 1) {
currentIndex = 0;
}
this.props.update(this.props.range[currentIndex] / this.props.factor);
}
get formattedValue() {
return formatFloat(this.props.value * this.props.factor, {
digits: this.props.digits,
});
}
}
FloatToggleField.template = "web.FloatToggleField";
FloatToggleField.props = {
...standardFieldProps,
digits: { type: Array, optional: true },
range: { type: Array, optional: true },
factor: { type: Number, optional: true },
disableReadOnly: { type: Boolean, optional: true },
};
FloatToggleField.defaultProps = {
range: [0.0, 0.5, 1.0],
factor: 1,
disableReadOnly: false,
};
FloatToggleField.supportedTypes = ["float"];
FloatToggleField.isEmpty = () => false;
FloatToggleField.extractProps = (fieldName, record, attrs) => {
return {
digits:
(attrs.digits ? JSON.parse(attrs.digits) : attrs.options.digits) ||
record.fields[fieldName].digits,
range: attrs.options.range,
factor: attrs.options.factor,
disableReadOnly: attrs.options.force_button || false,
};
};
registry.category("fields").add("float_toggle", FloatToggleField);
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.FloatToggleField" owl="1">
<span t-if="props.readonly and !props.disableReadOnly" t-esc="formattedValue" />
<button t-else="" class="o_field_float_toggle" t-on-click="onChange"><t t-esc="formattedValue" /></button>
</t>
</templates>
@@ -0,0 +1,50 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { formatSelection } from "../formatters";
const { Component } = owl;
export class FontSelectionField extends Component {
get string() {
return formatSelection(this.props.value, { selection: this.props.options });
}
stringify(value) {
return JSON.stringify(value);
}
/**
* @param {Event} ev
*/
onChange(ev) {
const value = JSON.parse(ev.target.value);
this.props.update(value);
}
}
FontSelectionField.template = "web.FontSelectionField";
FontSelectionField.props = {
...standardFieldProps,
options: Object,
placeholder: { type: String, optional: true },
required: { type: Boolean, optional: true },
};
FontSelectionField.defaultProps = {
required: false,
};
FontSelectionField.displayName = _lt("Font Selection");
FontSelectionField.supportedTypes = ["selection"];
FontSelectionField.extractProps = (fieldName, record, attrs) => {
return {
options: Array.from(record.fields[fieldName].selection),
required: record.isRequired(fieldName),
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("font", FontSelectionField);
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FontSelectionField" owl="1">
<t t-if="props.readonly">
<span t-esc="string" t-att-raw-value="props.value" t-attf-style="font-family:{{ props.value }}"/>
</t>
<t t-else="">
<select class="o_input" t-on-change="onChange" t-attf-style="font-family:{{ props.value }}">
<t t-if="!props.required">
<option
value="false"
t-att-selected="!props.value"
t-esc="props.placeholder"
/>
</t>
<t t-foreach="props.options" t-as="option" t-key="option[0]">
<option
t-att-selected="option[0] === value"
t-att-value="stringify(option[0])"
t-esc="option[1]"
t-attf-style="font-family:{{ option[1] }}"
/>
</t>
</select>
</t>
</t>
</templates>
@@ -2,9 +2,9 @@
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
import { localization as l10n } from "@web/core/l10n/localization";
import { _lt } from "@web/core/l10n/translation";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { intersperse } from "@web/core/utils/strings";
import { escape, intersperse, nbsp, sprintf } from "@web/core/utils/strings";
import { session } from "@web/session";
// -----------------------------------------------------------------------------
@@ -17,12 +17,12 @@ import { session } from "@web/session";
* @private
* @param {string} string representing integer number
* @param {string} [thousandsSep=","] the separator to insert
* @param {number[]} [grouping=[3,0]]
* @param {number[]} [grouping=[]]
* array of relative offsets at which to insert `thousandsSep`.
* See `strings.intersperse` method.
* @returns {string}
*/
function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) {
function insertThousandsSep(number, thousandsSep = ",", grouping = []) {
const negative = number[0] === "-";
number = negative ? number.slice(1) : number;
return (negative ? "-" : "") + intersperse(number, grouping, thousandsSep);
@@ -60,7 +60,7 @@ function humanNumber(number, options = { decimals: 0, minDigits: 1 }) {
}
// note: we need to call toString here to make sure we manipulate the resulting
// string, not an object with a toString method.
const unitSymbols = _lt("kMGTPE").toString();
const unitSymbols = _t("kMGTPE").toString();
const sign = Math.sign(number);
number = Math.abs(number);
let symbol = "";
@@ -89,16 +89,43 @@ function humanNumber(number, options = { decimals: 0, minDigits: 1 }) {
// Exports
// -----------------------------------------------------------------------------
/**
* @param {boolean} value
* @returns {string}
*/
export function formatBoolean(value) {
return value ? _t("True") : _t("False");
}
/**
* Returns a string representing a char. If the value is false, then we return
* an empty string.
*
* @param {string|false} value
* @param {Object} [options] additional options
* @param {boolean} [options.escape=false] if true, escapes the formatted value
* @param {boolean} [options.isPassword=false] if true, returns '********'
* instead of the formatted value
* @returns {string}
*/
export function formatChar(value, options) {
value = typeof value === "string" ? value : "";
if (options && options.isPassword) {
return "*".repeat(value ? value.length : 0);
}
if (options && options.escape) {
value = escape(value);
}
return value;
}
/**
* Returns a string representing a float. The result takes into account the
* user settings (to display the correct decimal separator).
*
* @param {float|false} value the value that should be formatted
* @param {number | false} value the value that should be formatted
* @param {Object} [options]
* @param {Object} [options.field] a description of the field (returned by
* fields_get for example). It may contain a description of the number of
* digits that should be used.
* @param {integer[]} [options.digits] the number of digits that should be used,
* @param {number[]} [options.digits] the number of digits that should be used,
* instead of the default digits precision in the field.
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
@@ -121,28 +148,25 @@ export function formatFloat(value, options = {}) {
const thousandsSep = "thousandsSep" in options ? options.thousandsSep : l10n.thousandsSep;
const decimalPoint = "decimalPoint" in options ? options.decimalPoint : l10n.decimalPoint;
let precision;
if (options.digits) {
if (options.digits && options.digits[1] !== undefined) {
precision = options.digits[1];
} else if (options.field && options.field.digits) {
precision = options.field.digits[1];
} else {
precision = 2;
}
const formatted = (value || 0).toFixed(precision || 2).split(".");
const formatted = (value || 0).toFixed(precision).split(".");
formatted[0] = insertThousandsSep(formatted[0], thousandsSep, grouping);
if (options.noTrailingZeros) {
formatted[1] = formatted[1].replace(/0+$/, "");
}
return formatted[1].length ? formatted.join(decimalPoint) : formatted[0];
return formatted[1] ? formatted.join(decimalPoint) : formatted[0];
}
/**
* Returns a string representing a float value, from a float converted with a
* factor.
*
* @param {number|false} value
* @param {number | false} value
* @param {Object} [options]
* @param {Object} [options.field] a description of the field
* @param {number} [options.factor=1.0] conversion factor
* @returns {string}
*/
@@ -159,9 +183,8 @@ export function formatFloatFactor(value, options = {}) {
* we sometimes want to display something like 1:45 instead of 1.75, or 0:15
* instead of 0.25.
*
* @param {number|false} value
* @param {number | false} value
* @param {Object} [options]
* @param {Object} [options.field] a description of the field
* @param {boolean} [options.noLeadingZeroHour] if true, format like 1:30
* otherwise, format like 01:30
* @returns {string}
@@ -191,9 +214,8 @@ export function formatFloatTime(value, options = {}) {
* Returns a string representing an integer. If the value is false, then we
* return an empty string.
*
* @param {integer|false} value
* @param {number | false | null} value
* @param {Object} [options]
* @param {Object} [options.field] a description of the field
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
* @param {boolean} [options.isPassword=false] if returns true, acts like
@@ -203,11 +225,11 @@ export function formatFloatTime(value, options = {}) {
* @returns {string}
*/
export function formatInteger(value, options = {}) {
if (value === false) {
if (value === false || value === null) {
return "";
}
if (options.isPassword) {
return new Array(value.length + 1).join("*");
return "*".repeat(value.length);
}
if (options.humanReadable) {
return humanNumber(value, options);
@@ -218,30 +240,21 @@ export function formatInteger(value, options = {}) {
}
/**
* Returns a string representing an many2one. If the value is false, then we
* return an empty string. Note that it accepts two types of input parameters:
* an array, in that case we assume that the many2one value is of the form
* [id, nameget], and we return the nameget, or it can be an object, and in that
* case, we assume that it is a record datapoint from a BasicModel.
* Returns a string representing a many2one value. The value is expected to be
* either `false` or an array in the form [id, display_name]. The returned
* value will then be the display name of the given value, or an empty string
* if the value is false.
*
* @param {Array|Object|false} value
* @param {[number, string] | false} value
* @param {Object} [options] additional options
* @param {Object} [options.field] a description of the field
* @param {boolean} [options.escape=false] if true, escapes the formatted value
* @returns {string}
*/
export function formatMany2one(value, options) {
if (!value) {
value = "";
} else if (Array.isArray(value)) {
// value is a pair [id, nameget]
value = value[1];
} else {
// value is a datapoint, so we read its display_name field, which
// may in turn be a datapoint (if the name field is a many2one)
while (value.data) {
value = value.data.display_name || "";
}
value = value[1];
}
if (options && options.escape) {
value = encodeURIComponent(value);
@@ -249,18 +262,34 @@ export function formatMany2one(value, options) {
return value;
}
/**
* Returns a string representing a one2many or many2many value. The value is
* expected to be either `false` or an array of ids. The returned value will
* then be the count of ids in the given value in the form "x record(s)".
*
* @param {number[] | false} value
* @returns {string}
*/
export function formatX2many(value) {
const count = value.currentIds.length;
if (count === 0) {
return _t("No records");
} else if (count === 1) {
return _t("1 record");
} else {
return sprintf(_t("%s records"), count);
}
}
/**
* Returns a string representing a monetary value. The result takes into account
* the user settings (to display the correct decimal separator, currency, ...).
*
* @param {float|false} value the value that should be formatted
* @param {Object} [field]
* a description of the field (returned by fields_get for example). It may
* contain a description of the number of digits that should be used.
* @param {number | false} value the value that should be formatted
* @param {Object} [options]
* additional options to override the values in the python description of the
* field.
* @param {integer} [options.currencyId] the id of the 'res.currency' to use
* @param {number} [options.currencyId] the id of the 'res.currency' to use
* @param {string} [options.currencyField] the name of the field whose value is
* the currency id (ignored if options.currency_id).
* Note: if not given it will default to the field "currency_field" value or
@@ -276,32 +305,36 @@ export function formatMany2one(value, options) {
* @returns {string}
*/
export function formatMonetary(value, options = {}) {
// Monetary fields want to display nothing when the value is unset.
// You wouldn't want a value of 0 euro if nothing has been provided.
if (value === false) {
return "";
}
let currencyId = options.currencyId;
if (!currencyId && options.data) {
const currencyField =
options.currencyField ||
(options.field && options.field.currency_field) ||
"currency_id";
currencyId = options.data[currencyField] && options.data[currencyField].res_id;
const currencyField = options.currencyField || "currency_id";
const dataValue = options.data[currencyField];
currencyId = Array.isArray(dataValue) ? dataValue[0] : dataValue;
}
const currency = session.currencies[currencyId];
const digits = (currency && currency.digits) || options.digits;
let formatted;
let formattedValue;
if (options.humanReadable) {
formatted = humanNumber(value, { decimals: digits ? digits[1] : 2 });
formattedValue = humanNumber(value, { decimals: digits ? digits[1] : 2 });
} else {
formatted = formatFloat(value, { digits });
formattedValue = formatFloat(value, { digits });
}
if (!currency || options.noSymbol) {
return formatted;
return formattedValue;
}
const formatted = [currency.symbol, formattedValue];
if (currency.position === "after") {
return `${formatted} ${currency.symbol}`;
} else {
return `${currency.symbol} ${formatted}`;
formatted.reverse();
}
return formatted.join(nbsp);
}
/**
@@ -310,7 +343,6 @@ export function formatMonetary(value, options = {}) {
*
* @param {number | false} value
* @param {Object} [options]
* @param {Object} [options.field] a description of the field
* @param {boolean} [options.noSymbol] if true, doesn't concatenate with "%"
* @returns {string}
*/
@@ -321,14 +353,57 @@ export function formatPercentage(value, options = {}) {
return `${formatted}${options.noSymbol ? "" : "%"}`;
}
/**
* Returns a string representing the value of the reference field.
*
* @param {Object|false} value Object with keys "resId" and "displayName"
* @param {Object} [options={}]
* @returns {string}
*/
export function formatReference(value, options) {
return formatMany2one(value ? [value.resId, value.displayName] : false, options);
}
/**
* Returns a string of the value of the selection.
*
* @param {Object} [options={}]
* @param {[string, string][]} [options.selection]
* @param {Object} [options.field]
* @returns {string}
*/
export function formatSelection(value, options = {}) {
const selection = options.selection || (options.field && options.field.selection) || [];
const option = selection.find((option) => option[0] === value);
return option ? option[1] : "";
}
/**
* Returns the value or an empty string if it's falsy.
*
* @param {string | false} value
* @returns {string}
*/
export function formatText(value) {
return value || "";
}
registry
.category("formatters")
.add("boolean", formatBoolean)
.add("char", formatChar)
.add("date", formatDate)
.add("datetime", formatDateTime)
.add("float", formatFloat)
.add("float_factor", formatFloatFactor)
.add("float_time", formatFloatTime)
.add("html", (value) => value)
.add("integer", formatInteger)
.add("many2one", formatMany2one)
.add("one2many", formatX2many)
.add("many2many", formatX2many)
.add("monetary", formatMonetary)
.add("percentage", formatPercentage);
.add("percentage", formatPercentage)
.add("reference", formatReference)
.add("selection", formatSelection)
.add("text", formatText);
@@ -0,0 +1,19 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
const { Component } = owl;
export class HandleField extends Component {}
HandleField.template = "web.HandleField";
HandleField.props = {
...standardFieldProps,
};
HandleField.displayName = _lt("Handle");
HandleField.supportedTypes = ["integer"];
HandleField.isEmpty = () => false;
registry.category("fields").add("handle", HandleField);
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.HandleField" owl="1">
<span class="o_row_handle fa fa-sort ui-sortable-handle" t-on-click.stop="" />
</t>
</templates>

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