[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:
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
@@ -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;
|
||||||
@@ -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/utils/transitions.scss',
|
||||||
'web/static/src/core/**/*',
|
'web/static/src/core/**/*',
|
||||||
'web/static/src/fields/**/*',
|
'web/static/src/legacy/legacy_fields.scss',
|
||||||
'web/static/src/search/**/*',
|
'web/static/src/search/**/*',
|
||||||
'web/static/src/views/**/*',
|
'web/static/src/views/**/*',
|
||||||
|
('remove', 'web/static/src/views/form/button_box/*.scss'),
|
||||||
'web/static/src/webclient/**/*',
|
'web/static/src/webclient/**/*',
|
||||||
('remove', 'web/static/src/webclient/navbar/navbar.scss'), # already in _assets_common_styles
|
('remove', 'web/static/src/webclient/navbar/navbar.scss'), # already in _assets_common_styles
|
||||||
('remove', 'web/static/src/webclient/clickbot/clickbot.js'), # lazy loaded
|
('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',
|
'web/static/src/legacy/scss/profiling_qweb_view.scss',
|
||||||
'base/static/src/scss/res_partner.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/action_adapters.js',
|
||||||
'web/static/src/legacy/debug_manager.js',
|
'web/static/src/legacy/debug_manager.js',
|
||||||
'web/static/src/legacy/legacy_service_provider.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_dialog.js',
|
||||||
'web/static/src/legacy/legacy_load_views.js',
|
'web/static/src/legacy/legacy_load_views.js',
|
||||||
'web/static/src/legacy/legacy_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_promise_error_handler.js',
|
||||||
'web/static/src/legacy/legacy_rpc_error_handler.js',
|
'web/static/src/legacy/legacy_rpc_error_handler.js',
|
||||||
'web/static/src/legacy/root_widget.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/keyboard.scss',
|
||||||
'web/static/src/legacy/scss/name_and_signature.scss',
|
'web/static/src/legacy/scss/name_and_signature.scss',
|
||||||
'web/static/src/legacy/scss/web.zoomodoo.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/static/src/legacy/scss/fontawesome_overridden.scss',
|
||||||
],
|
],
|
||||||
'web._assets_common_scripts': [
|
'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/legacy/main_tests.js',
|
||||||
'web/static/tests/helpers/**/*.js',
|
'web/static/tests/helpers/**/*.js',
|
||||||
|
'web/static/tests/views/helpers.js',
|
||||||
'web/static/tests/search/helpers.js',
|
'web/static/tests/search/helpers.js',
|
||||||
'web/static/tests/webclient/**/helpers.js',
|
'web/static/tests/webclient/**/helpers.js',
|
||||||
'web/static/tests/qunit.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',
|
'base/static/tests/base_settings_tests.js',
|
||||||
'web/static/tests/env_tests.js',
|
'web/static/tests/env_tests.js',
|
||||||
'web/static/tests/core/**/*.js',
|
'web/static/tests/core/**/*.js',
|
||||||
'web/static/tests/fields/**/*.js',
|
|
||||||
'web/static/tests/search/**/*.js',
|
'web/static/tests/search/**/*.js',
|
||||||
('remove', 'web/static/tests/search/helpers.js'),
|
('remove', 'web/static/tests/search/helpers.js'),
|
||||||
'web/static/tests/views/**/*.js',
|
'web/static/tests/views/**/*.js',
|
||||||
|
('remove', 'web/static/tests/views/helpers.js'),
|
||||||
'web/static/tests/webclient/**/*.js',
|
'web/static/tests/webclient/**/*.js',
|
||||||
('remove', 'web/static/tests/webclient/**/helpers.js'),
|
('remove', 'web/static/tests/webclient/**/helpers.js'),
|
||||||
'web/static/tests/legacy/**/*.js',
|
'web/static/tests/legacy/**/*.js',
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
|
||||||
import { useActiveElement } from "../ui/ui_service";
|
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 {
|
export class Dialog extends Component {
|
||||||
setup() {
|
setup() {
|
||||||
this.modalRef = useRef("modal");
|
this.modalRef = useForwardRefToParent("modalRef");
|
||||||
useActiveElement("modal");
|
useActiveElement("modalRef");
|
||||||
this.data = useState(this.env.dialogData);
|
this.data = useState(this.env.dialogData);
|
||||||
useHotkey("escape", () => {
|
useHotkey("escape", () => {
|
||||||
this.data.close();
|
this.data.close();
|
||||||
});
|
});
|
||||||
useChildSubEnv({ inDialog: true });
|
this.id = `dialog_${this.data.id}`;
|
||||||
|
useChildSubEnv({ inDialog: true, dialogId: this.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Dialog.template = "web.Dialog";
|
Dialog.template = "web.Dialog";
|
||||||
@@ -24,6 +26,7 @@ Dialog.props = {
|
|||||||
size: { type: String, optional: true, validate: (s) => ["sm", "md", "lg", "xl"].includes(s) },
|
size: { type: String, optional: true, validate: (s) => ["sm", "md", "lg", "xl"].includes(s) },
|
||||||
technical: { type: Boolean, optional: true },
|
technical: { type: Boolean, optional: true },
|
||||||
title: { type: String, optional: true },
|
title: { type: String, optional: true },
|
||||||
|
modalRef: { type: Function, optional: true },
|
||||||
slots: {
|
slots: {
|
||||||
type: Object,
|
type: Object,
|
||||||
shape: {
|
shape: {
|
||||||
@@ -41,3 +44,19 @@ Dialog.defaultProps = {
|
|||||||
technical: true,
|
technical: true,
|
||||||
title: "Odoo",
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="web.Dialog" owl="1">
|
<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"
|
<div role="dialog" class="modal d-block"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
t-att-class="{ o_technical_modal: props.technical, o_modal_full: props.fullscreen }"
|
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-dialog" t-attf-class="modal-{{props.size}}">
|
||||||
<div class="modal-content" t-att-class="props.contentClass">
|
<div class="modal-content" t-att-class="props.contentClass">
|
||||||
@@ -16,16 +16,18 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="data.close">×</button>
|
<button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="data.close">×</button>
|
||||||
</header>
|
</header>
|
||||||
<main class="modal-body">
|
<!-- FIXME: WOWL there is a bug on t-portal on owl, in which t-portal don't work on multinode.
|
||||||
<t t-slot="default"/>
|
To avoid this we place the footer before the body -->
|
||||||
</main>
|
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1" style="order:2">
|
||||||
<footer t-if="props.footer" class="modal-footer justify-content-around justify-content-sm-start flex-wrap gap-1">
|
|
||||||
<t t-slot="footer">
|
<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>
|
<t>Ok</t>
|
||||||
</button>
|
</button>
|
||||||
</t>
|
</t>
|
||||||
</footer>
|
</footer>
|
||||||
|
<main class="modal-body">
|
||||||
|
<t t-slot="default"/>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { registry } from "../registry";
|
import { registry } from "../registry";
|
||||||
import { DialogContainer } from "./dialog_container";
|
import { DialogContainer } from "./dialog_container";
|
||||||
|
|
||||||
const { reactive } = owl;
|
const { markRaw, reactive } = owl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
@@ -51,8 +51,8 @@ export const dialogService = {
|
|||||||
const dialog = {
|
const dialog = {
|
||||||
id,
|
id,
|
||||||
class: dialogClass,
|
class: dialogClass,
|
||||||
props: { ...props, close },
|
props: markRaw({ ...props, close }),
|
||||||
dialogData: { isActive: true, close },
|
dialogData: { isActive: true, close, id },
|
||||||
};
|
};
|
||||||
dialogs[id] = dialog;
|
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)) {
|
if (this.props.exceptionName && odooExceptionTitleMap.has(this.props.exceptionName)) {
|
||||||
return odooExceptionTitleMap.get(this.props.exceptionName).toString();
|
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";
|
WarningDialog.template = "web.WarningDialog";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { SERVICES_METADATA } from "@web/env";
|
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.
|
* This file contains various custom hooks.
|
||||||
@@ -31,7 +31,7 @@ const { status, useComponent, useEffect, useRef } = owl;
|
|||||||
*/
|
*/
|
||||||
export function useAutofocus() {
|
export function useAutofocus() {
|
||||||
const comp = useComponent();
|
const comp = useComponent();
|
||||||
let ref = useRef("autofocus");
|
const ref = useRef("autofocus");
|
||||||
// Prevent autofocus in mobile
|
// Prevent autofocus in mobile
|
||||||
if (comp.env.isSmall) {
|
if (comp.env.isSmall) {
|
||||||
return ref;
|
return ref;
|
||||||
@@ -110,7 +110,7 @@ export function useBus(bus, eventName, callback) {
|
|||||||
*
|
*
|
||||||
* @param {string} eventName the name of the event
|
* @param {string} eventName the name of the event
|
||||||
* @param {string} [querySelector] a JS native selector for event delegation
|
* @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.
|
* @param {Object} [options] to be passed to addEventListener as options.
|
||||||
* Useful for listening in the capture phase
|
* Useful for listening in the capture phase
|
||||||
*/
|
*/
|
||||||
@@ -190,7 +190,7 @@ export function useService(serviceName) {
|
|||||||
} else {
|
} else {
|
||||||
const methods = SERVICES_METADATA[serviceName];
|
const methods = SERVICES_METADATA[serviceName];
|
||||||
const result = Object.create(service);
|
const result = Object.create(service);
|
||||||
for (let method of methods) {
|
for (const method of methods) {
|
||||||
result[method] = _protectMethod(component, service, service[method]);
|
result[method] = _protectMethod(component, service, service[method]);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -198,3 +198,62 @@ export function useService(serviceName) {
|
|||||||
}
|
}
|
||||||
return service;
|
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 **/
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
export const nbsp = "\u00a0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escapes a string for HTML.
|
* Escapes a string for HTML.
|
||||||
* Note that it doesn't work for escaping node attributes.
|
* Note that it doesn't work for escaping node attributes.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
/** @odoo-module **/
|
/** @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) {
|
function hasParsingError(parsedDocument) {
|
||||||
return parsedDocument.getElementsByTagName("parsererror").length > 0;
|
return parsedDocument.getElementsByTagName("parsererror").length > 0;
|
||||||
}
|
}
|
||||||
@@ -11,29 +18,37 @@ export class XMLParser {
|
|||||||
*/
|
*/
|
||||||
parse() {}
|
parse() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Element | string} xml
|
||||||
|
* @param {(el: Element, visitChildren: () => any) => any} callback
|
||||||
|
*/
|
||||||
visitXML(xml, callback) {
|
visitXML(xml, callback) {
|
||||||
function visit(xml) {
|
const visit = (el) => {
|
||||||
if (xml) {
|
if (el) {
|
||||||
let didVisitChildren = false;
|
let didVisitChildren = false;
|
||||||
const visitChildren = () => {
|
const visitChildren = () => {
|
||||||
for (let child of xml.children) {
|
for (const child of el.children) {
|
||||||
visit(child);
|
visit(child);
|
||||||
}
|
}
|
||||||
didVisitChildren = true;
|
didVisitChildren = true;
|
||||||
};
|
};
|
||||||
const shouldVisitChildren = callback(xml, visitChildren);
|
const shouldVisitChildren = callback(el, visitChildren);
|
||||||
if (shouldVisitChildren !== false && !didVisitChildren) {
|
if (shouldVisitChildren !== false && !didVisitChildren) {
|
||||||
visitChildren();
|
visitChildren();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
const xmlDoc = typeof xml === "string" ? this.parseXML(xml) : xml;
|
const xmlDoc = typeof xml === "string" ? this.parseXML(xml) : xml;
|
||||||
visit(xmlDoc);
|
visit(xmlDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} arch
|
||||||
|
* @returns {Element}
|
||||||
|
*/
|
||||||
parseXML(arch) {
|
parseXML(arch) {
|
||||||
const parser = new DOMParser();
|
const cleanedArch = arch.replace(/&nbsp;/g, "");
|
||||||
const xml = parser.parseFromString(arch, "text/xml");
|
const xml = parser.parseFromString(cleanedArch, "text/xml");
|
||||||
if (hasParsingError(xml)) {
|
if (hasParsingError(xml)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`An error occured while parsing ${arch}: ${xml.getElementsByTagName("parsererror")}`
|
`An error occured while parsing ${arch}: ${xml.getElementsByTagName("parsererror")}`
|
||||||
@@ -42,3 +57,92 @@ export class XMLParser {
|
|||||||
return xml.documentElement;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -7,10 +7,9 @@ import { objectToQuery } from "../core/browser/router_service";
|
|||||||
import { useDebugCategory } from "../core/debug/debug_context";
|
import { useDebugCategory } from "../core/debug/debug_context";
|
||||||
import { Dialog } from "../core/dialog/dialog";
|
import { Dialog } from "../core/dialog/dialog";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
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 { cleanDomFromBootstrap, wrapSuccessOrFail, useLegacyRefs } from "./utils";
|
||||||
import { mapDoActionOptionAPI } from "./backend_utils";
|
import { mapDoActionOptionAPI } from "./backend_utils";
|
||||||
import { setScrollPosition } from "@web/webclient/actions/scrolling";
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
Component,
|
Component,
|
||||||
@@ -94,7 +93,11 @@ class ActionAdapter extends ComponentAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.onScrollTo = (payload) => {
|
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("action", debugContext);
|
||||||
useDebugCategory("view", debugContext);
|
useDebugCategory("view", debugContext);
|
||||||
if (this.props.viewInfo.type === "form") {
|
if (this.props.viewInfo.type === "form") {
|
||||||
useDebugCategory("form", debugContext);
|
useDebugCategory("form_legacy", debugContext);
|
||||||
}
|
}
|
||||||
this.env = Component.env;
|
this.env = Component.env;
|
||||||
}
|
}
|
||||||
@@ -376,21 +379,21 @@ export class ViewAdapter extends ActionAdapter {
|
|||||||
async _trigger_up(ev) {
|
async _trigger_up(ev) {
|
||||||
const payload = ev.data;
|
const payload = ev.data;
|
||||||
if (ev.name === "switch_view") {
|
if (ev.name === "switch_view") {
|
||||||
try {
|
if (payload.view_type === "form") {
|
||||||
const props = {};
|
if (payload.res_id) {
|
||||||
if (payload.mode) {
|
return this.props.selectRecord(payload.res_id, { mode: payload.mode });
|
||||||
props.mode = payload.mode;
|
} else {
|
||||||
|
return this.props.createRecord();
|
||||||
}
|
}
|
||||||
// if (payload.res_id) {
|
} else {
|
||||||
// if make 'open a record, come back, and create a new record' crash
|
try {
|
||||||
props.resId = payload.res_id;
|
await this.actionService.switchView(payload.view_type);
|
||||||
// }
|
} catch (e) {
|
||||||
await this.actionService.switchView(payload.view_type, props);
|
if (typeof e === "object" && e instanceof ViewNotFoundError) {
|
||||||
} catch (e) {
|
return;
|
||||||
if (e instanceof ViewNotFoundError) {
|
}
|
||||||
return;
|
throw e;
|
||||||
}
|
}
|
||||||
throw e;
|
|
||||||
}
|
}
|
||||||
} else if (ev.name === "execute_action") {
|
} else if (ev.name === "execute_action") {
|
||||||
const buttonContext = new Context(payload.action_data.context).eval();
|
const buttonContext = new Context(payload.action_data.context).eval();
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ function searchModelStateFromLegacy(state) {
|
|||||||
newState.sections = sections;
|
newState.sections = sections;
|
||||||
//! Can be undefined. See search_model.__legacyParseSearchPanelArchAnyway
|
//! Can be undefined. See search_model.__legacyParseSearchPanelArchAnyway
|
||||||
newState.searchPanelInfo = searchPanelInfo;
|
newState.searchPanelInfo = searchPanelInfo;
|
||||||
|
if (newState.searchPanelInfo) {
|
||||||
|
newState.searchPanelInfo.loaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, extension] of Object.entries(ActionModel.registry.entries())) {
|
for (const [key, extension] of Object.entries(ActionModel.registry.entries())) {
|
||||||
@@ -166,12 +169,15 @@ export function searchModelStateToLegacy(state) {
|
|||||||
filter.orderedBy = item.orderBy;
|
filter.orderedBy = item.orderBy;
|
||||||
delete filter.orderBy;
|
delete filter.orderBy;
|
||||||
break;
|
break;
|
||||||
case "filter":
|
case "filter": {
|
||||||
let context = item.context;
|
let context = item.context;
|
||||||
try {
|
try {
|
||||||
context = makeContext([context]);
|
context = makeContext([context]);
|
||||||
} catch (_e) {}
|
} catch (_e) {
|
||||||
|
// pass
|
||||||
|
}
|
||||||
filter.context = context;
|
filter.context = context;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
filters[item.id] = filter;
|
filters[item.id] = filter;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
|
|
||||||
import { _lt } from "@web/core/l10n/translation";
|
|
||||||
import { Dialog } from "@web/core/dialog/dialog";
|
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 { 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 { registry } from "@web/core/registry";
|
||||||
import { standaloneAdapter } from 'web.OwlCompatibility';
|
import { standaloneAdapter } from "web.OwlCompatibility";
|
||||||
|
|
||||||
const { Component, onWillStart, useState } = owl;
|
const { Component, onWillStart, useState } = owl;
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ const debugRegistry = registry.category("debug");
|
|||||||
|
|
||||||
class GetMetadataDialog extends Component {
|
class GetMetadataDialog extends Component {
|
||||||
setup() {
|
setup() {
|
||||||
super.setup();
|
this.title = this.env._t("View Metadata");
|
||||||
this.state = useState({});
|
this.state = useState({});
|
||||||
onWillStart(this.onWillStart);
|
onWillStart(this.onWillStart);
|
||||||
}
|
}
|
||||||
@@ -25,7 +24,7 @@ class GetMetadataDialog extends Component {
|
|||||||
|
|
||||||
async onClickCreateXmlid() {
|
async onClickCreateXmlid() {
|
||||||
const context = Object.assign({}, this.context, {
|
const context = Object.assign({}, this.context, {
|
||||||
default_module: '__custom__',
|
default_module: "__custom__",
|
||||||
default_res_id: this.state.id,
|
default_res_id: this.state.id,
|
||||||
default_model: this.props.res_model,
|
default_model: this.props.res_model,
|
||||||
});
|
});
|
||||||
@@ -34,9 +33,9 @@ class GetMetadataDialog extends Component {
|
|||||||
context: context,
|
context: context,
|
||||||
on_saved: () => this.getMetadata(),
|
on_saved: () => this.getMetadata(),
|
||||||
disable_multiple_selection: true,
|
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();
|
dialog.$el.find('[name="name"]').focus();
|
||||||
});
|
});
|
||||||
await dialog.open();
|
await dialog.open();
|
||||||
@@ -61,16 +60,20 @@ class GetMetadataDialog extends Component {
|
|||||||
this.state.creator = formatMany2one(metadata.create_uid);
|
this.state.creator = formatMany2one(metadata.create_uid);
|
||||||
this.state.lastModifiedBy = formatMany2one(metadata.write_uid);
|
this.state.lastModifiedBy = formatMany2one(metadata.write_uid);
|
||||||
this.state.noupdate = metadata.noupdate;
|
this.state.noupdate = metadata.noupdate;
|
||||||
this.state.create_date = formatDateTime(parseDateTime(metadata.create_date), { timezone: true });
|
this.state.createDate = formatDateTime(parseDateTime(metadata.create_date), {
|
||||||
this.state.write_date = formatDateTime(parseDateTime(metadata.write_date), { timezone: true });
|
timezone: true,
|
||||||
|
});
|
||||||
|
this.state.writeDate = formatDateTime(parseDateTime(metadata.write_date), {
|
||||||
|
timezone: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GetMetadataDialog.template = "web.DebugMenu.GetMetadataDialog";
|
GetMetadataDialog.template = "web.DebugMenu.GetMetadataDialog";
|
||||||
GetMetadataDialog.components = { Dialog };
|
GetMetadataDialog.components = { Dialog };
|
||||||
GetMetadataDialog.title = _lt("View Metadata");
|
|
||||||
class SetDefaultDialog extends Component {
|
class SetDefaultDialog extends Component {
|
||||||
setup() {
|
setup() {
|
||||||
super.setup();
|
this.title = this.env._t("Set Defaults");
|
||||||
this.state = {
|
this.state = {
|
||||||
fieldToSet: "",
|
fieldToSet: "",
|
||||||
condition: "",
|
condition: "",
|
||||||
@@ -212,11 +215,9 @@ class SetDefaultDialog extends Component {
|
|||||||
}
|
}
|
||||||
SetDefaultDialog.template = "web.DebugMenu.SetDefaultDialog";
|
SetDefaultDialog.template = "web.DebugMenu.SetDefaultDialog";
|
||||||
SetDefaultDialog.components = { Dialog };
|
SetDefaultDialog.components = { Dialog };
|
||||||
SetDefaultDialog.title = _lt("Set Default");
|
|
||||||
|
|
||||||
// Form view items
|
// Form view items
|
||||||
|
function setDefaults({ action, component, env }) {
|
||||||
export function setDefaults({ action, component, env }) {
|
|
||||||
return {
|
return {
|
||||||
type: "item",
|
type: "item",
|
||||||
description: env._t("Set Defaults"),
|
description: env._t("Set Defaults"),
|
||||||
@@ -282,7 +283,7 @@ function manageAttachments({ action, component, env }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debugRegistry
|
debugRegistry
|
||||||
.category("form")
|
.category("form_legacy")
|
||||||
.add("setDefaults", setDefaults)
|
.add("setDefaults", setDefaults)
|
||||||
.add("viewMetadata", viewMetadata)
|
.add("viewMetadata", viewMetadata)
|
||||||
.add("manageAttachments", manageAttachments);
|
.add("manageAttachments", manageAttachments);
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ odoo.define('web.ActionMenus', function (require) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
ActionMenus.template = 'web.ActionMenus';
|
ActionMenus.template = 'web.Legacy.ActionMenus';
|
||||||
|
|
||||||
return ActionMenus;
|
return ActionMenus;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1631,7 +1631,7 @@ var FieldText = InputField.extend(TranslatableFieldMixin, {
|
|||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
// Handlers
|
// Handlers
|
||||||
//--------------------------------------------------------------------------
|
//--------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
* @override
|
* @override
|
||||||
@@ -4155,7 +4155,7 @@ var FieldColorPicker = FieldInteger.extend({
|
|||||||
if (!$colorpicker.length) {
|
if (!$colorpicker.length) {
|
||||||
return;
|
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));
|
$colorpicker.on('click', 'a', this._onColorChanged.bind(this));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ odoo.define('web.basic_fields_owl', function (require) {
|
|||||||
FieldBoolean.components = { CustomCheckbox };
|
FieldBoolean.components = { CustomCheckbox };
|
||||||
FieldBoolean.description = _lt("Checkbox");
|
FieldBoolean.description = _lt("Checkbox");
|
||||||
FieldBoolean.supportedFieldTypes = ['boolean'];
|
FieldBoolean.supportedFieldTypes = ['boolean'];
|
||||||
FieldBoolean.template = 'web.FieldBoolean';
|
FieldBoolean.template = 'web.LegacyFieldBoolean';
|
||||||
FieldBoolean.isQuickEditable = true;
|
FieldBoolean.isQuickEditable = true;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1363,7 +1363,7 @@ var FieldX2Many = AbstractField.extend(WidgetAdapterMixin, {
|
|||||||
if (extraInfo.subFieldName) {
|
if (extraInfo.subFieldName) {
|
||||||
parts.push(`[name="${extraInfo.subFieldName}"]`);
|
parts.push(`[name="${extraInfo.subFieldName}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length) {
|
if (parts.length) {
|
||||||
const el = this.el.querySelector(parts.join(' '));
|
const el = this.el.querySelector(parts.join(' '));
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -2041,7 +2041,7 @@ var FieldOne2Many = FieldX2Many.extend({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
* @param {*} data
|
* @param {*} data
|
||||||
*/
|
*/
|
||||||
_addCreateRecordRow(data) {
|
_addCreateRecordRow(data) {
|
||||||
const self = this;
|
const self = this;
|
||||||
@@ -3308,7 +3308,7 @@ var FieldStatus = AbstractField.extend({
|
|||||||
*/
|
*/
|
||||||
var FieldSelection = AbstractField.extend({
|
var FieldSelection = AbstractField.extend({
|
||||||
description: _lt("Selection"),
|
description: _lt("Selection"),
|
||||||
template: 'FieldSelection',
|
template: 'web.Legacy.FieldSelection',
|
||||||
specialData: "_fetchSpecialRelation",
|
specialData: "_fetchSpecialRelation",
|
||||||
supportedFieldTypes: ['selection'],
|
supportedFieldTypes: ['selection'],
|
||||||
events: _.extend({}, AbstractField.prototype.events, {
|
events: _.extend({}, AbstractField.prototype.events, {
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ var KanbanController = BasicController.extend({
|
|||||||
const state = this.model.get(this.handle, {raw: true});
|
const state = this.model.get(this.handle, {raw: true});
|
||||||
if (!state.count || state.isSample) {
|
if (!state.count || state.isSample) {
|
||||||
const classesList = [
|
const classesList = [
|
||||||
'o_kanban_view',
|
'o_legacy_kanban_view',
|
||||||
'o_kanban_group',
|
'o_kanban_group',
|
||||||
'o_kanban_header',
|
'o_kanban_header',
|
||||||
'o_column_quick_create',
|
'o_column_quick_create',
|
||||||
|
|||||||
@@ -614,7 +614,7 @@ var KanbanRecord = Widget.extend(WidgetAdapterMixin, {
|
|||||||
if (!$colorpicker.length) {
|
if (!$colorpicker.length) {
|
||||||
return;
|
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));
|
$colorpicker.on('click', 'a', this._onColorChanged.bind(this));
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function transformQwebTemplate(node, fields) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var KanbanRenderer = BasicRenderer.extend({
|
var KanbanRenderer = BasicRenderer.extend({
|
||||||
className: 'o_kanban_view',
|
className: 'o_legacy_kanban_view',
|
||||||
config: { // the KanbanRecord and KanbanColumn classes to use (may be overridden)
|
config: { // the KanbanRecord and KanbanColumn classes to use (may be overridden)
|
||||||
KanbanColumn: KanbanColumn,
|
KanbanColumn: KanbanColumn,
|
||||||
KanbanRecord: KanbanRecord,
|
KanbanRecord: KanbanRecord,
|
||||||
@@ -99,7 +99,7 @@ var KanbanRenderer = BasicRenderer.extend({
|
|||||||
'keydown .o_kanban_record' : '_onRecordKeyDown'
|
'keydown .o_kanban_record' : '_onRecordKeyDown'
|
||||||
}),
|
}),
|
||||||
sampleDataTargets: [
|
sampleDataTargets: [
|
||||||
'.o_kanban_counter',
|
'.o_legacy_kanban_counter',
|
||||||
'.o_kanban_record',
|
'.o_kanban_record',
|
||||||
'.o_kanban_toggle_fold',
|
'.o_kanban_toggle_fold',
|
||||||
'.o_column_folded',
|
'.o_column_folded',
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ var SelectCreateDialog = ViewDialog.extend({
|
|||||||
var view = new ViewClass(fieldsViews[this.viewType], _.extend(viewOptions, {
|
var view = new ViewClass(fieldsViews[this.viewType], _.extend(viewOptions, {
|
||||||
action: {
|
action: {
|
||||||
controlPanelFieldsView: fieldsViews.search,
|
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,
|
action_buttons: false,
|
||||||
dynamicFilters: this.options.dynamicFilters,
|
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);
|
||||||
@@ -27,11 +27,14 @@ function getJsClassWidget(fieldsInfo) {
|
|||||||
|
|
||||||
const legacyViewTemplate = xml`
|
const legacyViewTemplate = xml`
|
||||||
<ViewAdapter Component="Widget" View="View" viewInfo="viewInfo" viewParams="viewParams"
|
<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
|
// registers a view from the legacy view registry to the wowl one, but wrapped
|
||||||
// into an Owl Component
|
// into an Owl Component
|
||||||
function registerView(name, LegacyView) {
|
function registerView(name, LegacyView) {
|
||||||
|
if (viewRegistry.contains(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
class Controller extends LegacyComponent {
|
class Controller extends LegacyComponent {
|
||||||
setup() {
|
setup() {
|
||||||
this.vm = useService("view");
|
this.vm = useService("view");
|
||||||
@@ -51,11 +54,13 @@ function registerView(name, LegacyView) {
|
|||||||
|
|
||||||
// always add user context to the action context
|
// always add user context to the action context
|
||||||
this.user = useService("user");
|
this.user = useService("user");
|
||||||
const action = Object.assign({}, this.props.action, {
|
const action = Object.assign({}, this.props.action);
|
||||||
context: Object.assign({}, this.user.context, this.props.action.context),
|
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, {
|
this.viewParams = Object.assign({}, actionFlags, {
|
||||||
action,
|
action,
|
||||||
// legacy views automatically add the last part of the breadcrumbs
|
// legacy views automatically add the last part of the breadcrumbs
|
||||||
@@ -90,6 +95,11 @@ function registerView(name, LegacyView) {
|
|||||||
this.onReverseBreadcrumb =
|
this.onReverseBreadcrumb =
|
||||||
this.props.state && this.props.state.__on_reverse_breadcrumb__;
|
this.props.state && this.props.state.__on_reverse_breadcrumb__;
|
||||||
useSetupAction({
|
useSetupAction({
|
||||||
|
rootRef: {
|
||||||
|
get el() {
|
||||||
|
return legacyRefs.widget && legacyRefs.widget.el;
|
||||||
|
},
|
||||||
|
},
|
||||||
beforeLeave: () => legacyRefs.widget.canBeRemoved(),
|
beforeLeave: () => legacyRefs.widget.canBeRemoved(),
|
||||||
getGlobalState: () => getGlobalState(legacyRefs.component.exportState()),
|
getGlobalState: () => getGlobalState(legacyRefs.component.exportState()),
|
||||||
getLocalState: () => getLocalState(legacyRefs.component.exportState()),
|
getLocalState: () => getLocalState(legacyRefs.component.exportState()),
|
||||||
@@ -100,7 +110,7 @@ function registerView(name, LegacyView) {
|
|||||||
async onWillStart() {
|
async onWillStart() {
|
||||||
const params = {
|
const params = {
|
||||||
resModel: this.props.resModel,
|
resModel: this.props.resModel,
|
||||||
views: this.props.views,
|
views: this.props.views || this.env.config.views,
|
||||||
context: this.props.context,
|
context: this.props.context,
|
||||||
};
|
};
|
||||||
const options = {
|
const options = {
|
||||||
@@ -110,13 +120,13 @@ function registerView(name, LegacyView) {
|
|||||||
};
|
};
|
||||||
const viewDescriptions = await this.vm.loadViews(params, options);
|
const viewDescriptions = await this.vm.loadViews(params, options);
|
||||||
const result = viewDescriptions.__legacy__;
|
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);
|
const jsClass = getJsClassWidget(fieldsInfo);
|
||||||
this.View = jsClass || this.View;
|
this.View = jsClass || this.View;
|
||||||
this.viewInfo = Object.assign({}, fieldsInfo, {
|
this.viewInfo = Object.assign({}, fieldsInfo, {
|
||||||
fields: result.fields,
|
fields: result.fields,
|
||||||
viewFields: fieldsInfo.fields,
|
viewFields: fieldsInfo.fields,
|
||||||
type: this.props.type,
|
type: this.props.type || this.env.config.viewType,
|
||||||
});
|
});
|
||||||
let controlPanelFieldsView;
|
let controlPanelFieldsView;
|
||||||
if (result.fields_views.search) {
|
if (result.fields_views.search) {
|
||||||
@@ -127,7 +137,8 @@ function registerView(name, LegacyView) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { viewSwitcherEntries = [] } = this.env.config;
|
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")
|
.filter(([, vtype]) => vtype !== "search")
|
||||||
.map(([vid, vtype]) => {
|
.map(([vid, vtype]) => {
|
||||||
const view = viewSwitcherEntries.find((v) => v.type === vtype);
|
const view = viewSwitcherEntries.find((v) => v.type === vtype);
|
||||||
@@ -137,29 +148,32 @@ function registerView(name, LegacyView) {
|
|||||||
return {
|
return {
|
||||||
viewID: vid,
|
viewID: vid,
|
||||||
type: vtype,
|
type: vtype,
|
||||||
multiRecord: !this.constructor.multiRecord,
|
multiRecord: !legacyView.multiRecord,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.viewParams.action = Object.assign({}, this.viewParams.action, {
|
this.viewParams.action = Object.assign({}, this.viewParams.action, {
|
||||||
controlPanelFieldsView,
|
controlPanelFieldsView,
|
||||||
_views: this.viewParams.action.views,
|
_views,
|
||||||
views,
|
views,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Controller.template = legacyViewTemplate;
|
|
||||||
|
|
||||||
|
Controller.template = legacyViewTemplate;
|
||||||
Controller.components = { ViewAdapter };
|
Controller.components = { ViewAdapter };
|
||||||
Controller.display_name = LegacyView.prototype.display_name;
|
Controller.isLegacy = true; // for action dialog
|
||||||
Controller.icon = LegacyView.prototype.icon;
|
|
||||||
Controller.isMobileFriendly = LegacyView.prototype.mobile_friendly;
|
const legacyView = {
|
||||||
Controller.multiRecord = LegacyView.prototype.multi_record;
|
type: LegacyView.prototype.viewType,
|
||||||
Controller.type = LegacyView.prototype.viewType;
|
display_name: LegacyView.prototype.display_name,
|
||||||
Controller.isLegacy = true;
|
icon: LegacyView.prototype.icon,
|
||||||
if (!viewRegistry.contains(name)) {
|
isMobileFriendly: LegacyView.prototype.mobile_friendly,
|
||||||
viewRegistry.add(name, Controller);
|
multiRecord: LegacyView.prototype.multi_record,
|
||||||
}
|
isLegacy: true,
|
||||||
|
Controller,
|
||||||
|
};
|
||||||
|
viewRegistry.add(name, legacyView);
|
||||||
}
|
}
|
||||||
|
|
||||||
// register views already in the legacy registry, and listens to future registrations
|
// register views already in the legacy registry, and listens to future registrations
|
||||||
|
|||||||
@@ -322,7 +322,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stars
|
// Stars
|
||||||
&.o_priority {
|
&.o_priority, & .o_priority {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Favorite
|
// Favorite
|
||||||
&.o_favorite {
|
&.o_favorite, & .o_favorite {
|
||||||
i.fa {
|
i.fa {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -367,7 +367,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle widget
|
// Handle widget
|
||||||
&.o_row_handle {
|
.o_row_handle {
|
||||||
@include o-grab-cursor;
|
@include o-grab-cursor;
|
||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Image
|
// Image
|
||||||
&.o_field_image {
|
&.o_field_image, &.o_field_image_url {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.o_form_image_controls {
|
.o_form_image_controls {
|
||||||
@@ -577,7 +577,6 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
&.o_btn_char_copy {
|
&.o_btn_char_copy {
|
||||||
padding-top: 2px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -596,18 +595,6 @@
|
|||||||
display: inline-block;
|
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 {
|
span.o_field_copy:empty {
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ $o-form-label-margin-right: 0px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// One2Many Kanban views
|
// One2Many Kanban views
|
||||||
.o_field_widget .o_kanban_view.o_kanban_ungrouped {
|
.o_field_widget .o_legacy_kanban_view.o_kanban_ungrouped {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
.o_kanban_record {
|
.o_kanban_record {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -1061,7 +1061,7 @@ $o-form-label-margin-right: 0px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Settings form views
|
// Settings form views
|
||||||
.o_settings_container {
|
.o_legacy_settings_container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
|
|||||||
@@ -41,20 +41,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If columns has the progressbar, adapt the layout
|
// 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 {
|
> .o_kanban_header .o_kanban_header_title {
|
||||||
height: $o-kanban-header-title-height*0.6;
|
height: $o-kanban-header-title-height*0.6;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.o_kanban_no_records {
|
&.o_kanban_no_records {
|
||||||
.o_kanban_counter {
|
.o_legacy_kanban_counter {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_kanban_counter {
|
.o_legacy_kanban_counter {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -102,11 +102,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.o_column_folded .o_kanban_counter {
|
.o_column_folded .o_legacy_kanban_counter {
|
||||||
display: none;
|
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(success, theme-color('success'));
|
||||||
@include o-kanban-css-filter(warning, theme-color('warning'));
|
@include o-kanban-css-filter(warning, theme-color('warning'));
|
||||||
@include o-kanban-css-filter(danger, theme-color('danger'));
|
@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) {
|
&:not(.o_kanban_grouped) {
|
||||||
// correctly display the no_content_helper in dashboards
|
// correctly display the no_content_helper in dashboards
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
@@ -31,8 +31,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_kanban_card_manage_pane {
|
.o_kanban_card_manage_pane {
|
||||||
@include o-kanban-dropdown-menu;
|
|
||||||
|
|
||||||
// Arbitrary value to place the dropdown-menu exactly below the
|
// Arbitrary value to place the dropdown-menu exactly below the
|
||||||
// dropdown-toggle (height is forced so that it works on Firefox)
|
// dropdown-toggle (height is forced so that it works on Firefox)
|
||||||
top: $o-kanban-manage-toggle-height;
|
top: $o-kanban-manage-toggle-height;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// container ghost card in modal
|
// container ghost card in modal
|
||||||
.o_kanban_examples_dialog {
|
.o_legacy_kanban_examples_dialog{
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
background: theme-color('light');
|
background: theme-color('light');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ------- Kanban View Layout -------
|
// ------- Kanban View Layout -------
|
||||||
.o_kanban_view {
|
.o_legacy_kanban_view {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-content: stretch;
|
align-content: stretch;
|
||||||
|
|
||||||
@@ -249,7 +249,6 @@
|
|||||||
@include o-kanban-dropdown-open;
|
@include o-kanban-dropdown-open;
|
||||||
}
|
}
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
@include o-kanban-dropdown-menu;
|
|
||||||
min-width: 11rem;
|
min-width: 11rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,6 +618,7 @@
|
|||||||
|
|
||||||
&.o_kanban_ghost {
|
&.o_kanban_ghost {
|
||||||
height: 0;
|
height: 0;
|
||||||
|
border: none;
|
||||||
min-height: 0!important; // to prevent view writers to override this height
|
min-height: 0!important; // to prevent view writers to override this height
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -645,7 +645,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------- Set Cover Dialog -----------------
|
// ----------------- 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;
|
border: 1px solid $border-color;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
|
|||||||
@@ -372,7 +372,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Buttons in ControlPanel
|
// Buttons in ControlPanel
|
||||||
.o_list_buttons {
|
.o_legacy_list_buttons {
|
||||||
.o_list_button_save, .o_list_button_discard {
|
.o_list_button_save, .o_list_button_discard {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,12 +348,6 @@
|
|||||||
border-color: $border-color;
|
border-color: $border-color;
|
||||||
z-index: $zindex-dropdown + 1;
|
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 {
|
@mixin o-kanban-colorpicker {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
padding: 3px ($o-dropdown-hpadding - $o-kanban-inner-hmargin) 3px $o-dropdown-hpadding;
|
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.
|
* 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, ...)
|
// Invisible modifier (can be inside the view, the button area, ...)
|
||||||
.o_invisible_modifier {
|
.o_invisible_modifier {
|
||||||
display: none!important;
|
display: none!important;
|
||||||
|
|||||||
@@ -396,7 +396,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</t>
|
</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">
|
<div class="o_cp_action_menus" t-on-item-selected.stop="_onItemSelected">
|
||||||
<DropdownMenu t-if="printItems.length"
|
<DropdownMenu t-if="printItems.length"
|
||||||
title="printButtonStrings.title"
|
title="printButtonStrings.title"
|
||||||
@@ -574,7 +574,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<t t-name="ListView.buttons">
|
<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">
|
<button type="button" class="btn btn-primary o_list_button_save" title="Save record" accesskey="s">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -996,7 +996,7 @@
|
|||||||
<span class="o_datepicker_button"/>
|
<span class="o_datepicker_button"/>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
<t t-name="FieldSelection">
|
<t t-name="web.Legacy.FieldSelection">
|
||||||
<span t-if="widget.mode === 'readonly'"/>
|
<span t-if="widget.mode === 'readonly'"/>
|
||||||
|
|
||||||
<select t-if="widget.mode !== 'readonly'"
|
<select t-if="widget.mode !== 'readonly'"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<templates id="template" xml:space="preserve">
|
<templates id="template" xml:space="preserve">
|
||||||
|
|
||||||
<t t-name="web.FieldBoolean" owl="1">
|
<t t-name="web.LegacyFieldBoolean" owl="1">
|
||||||
<CustomCheckbox disabled="hasReadonlyModifier"
|
<CustomCheckbox disabled="hasReadonlyModifier"
|
||||||
value="value" class="'o_field_boolean'" onChange.bind="_onChange"/>
|
value="value" class="'o_field_boolean'" onChange.bind="_onChange"/>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-name="KanbanColorPicker">
|
<t t-name="web.Legacy.KanbanColorPicker">
|
||||||
<t t-foreach="colors" t-as="color">
|
<t t-foreach="colors" t-as="color">
|
||||||
<li>
|
<li>
|
||||||
<a role="menuitem"
|
<a role="menuitem"
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-name="KanbanView.ColumnProgressBar">
|
<t t-name="KanbanView.ColumnProgressBar">
|
||||||
<div class="o_kanban_counter">
|
<div class="o_legacy_kanban_counter">
|
||||||
<div class="o_kanban_counter_progress progress">
|
<div class="o_kanban_counter_progress progress">
|
||||||
<t t-foreach="widget.colors" t-as="color">
|
<t t-foreach="widget.colors" t-as="color">
|
||||||
<t t-set="count" t-value="widget.subgroupCounts and widget.subgroupCounts[color] or 0"/>
|
<t t-set="count" t-value="widget.subgroupCounts and widget.subgroupCounts[color] or 0"/>
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
</t>
|
</t>
|
||||||
|
|
||||||
<t t-name="KanbanView.SetCoverModal">
|
<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">
|
<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' : ''}">
|
<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"/>
|
<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>
|
</t>
|
||||||
|
|
||||||
<!-- Note: this dialog isn't responsive, but it is not accessible on mobile -->
|
<!-- 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">
|
<div class="o_kanban_examples_dialog_nav">
|
||||||
<ul class="nav nav-pills flex-column">
|
<ul class="nav nav-pills flex-column">
|
||||||
<t t-foreach="examples" t-as="example">
|
<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"
|
||||||
|
>
|
||||||
|
​ <!-- 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);
|
||||||
+25
-20
@@ -1,24 +1,29 @@
|
|||||||
.o_field_color_picker {
|
.o_field_widget.o_field_color_picker > div {
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
float: right;
|
flex-wrap: wrap;
|
||||||
margin-right: 7px;
|
justify-content: flex-start;
|
||||||
width: 100%;
|
|
||||||
ul {
|
// to compensate on the 2px margin used to space the elements relative to each other
|
||||||
display: flex;
|
// we keep the top one cause the widget is a bit to high anyway
|
||||||
justify-content: flex-end;
|
margin-left: -2px;
|
||||||
flex-wrap: wrap;
|
margin-right: -2px;
|
||||||
@include o-kanban-colorpicker;
|
margin-bottom: -2px;
|
||||||
@include o-kanban-record-color;
|
|
||||||
width: 100%;
|
> div {
|
||||||
max-width: unset;
|
display: inline-block;
|
||||||
margin: 0;
|
border: 1px solid white;
|
||||||
padding: 0;
|
box-shadow: 0 0 0 1px gray('300');
|
||||||
> li {
|
margin: 2px;
|
||||||
border: 2px solid white;
|
|
||||||
box-shadow: 0 0 0 1px gray('300');
|
> a {
|
||||||
margin: $o-kanban-inner-hmargin 4px 0 0;
|
display: block;
|
||||||
> a:focus {
|
|
||||||
outline: none;
|
&::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>
|
||||||
@@ -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>
|
||||||
+132
-57
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
|
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
|
||||||
import { localization as l10n } from "@web/core/l10n/localization";
|
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 { 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";
|
import { session } from "@web/session";
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -17,12 +17,12 @@ import { session } from "@web/session";
|
|||||||
* @private
|
* @private
|
||||||
* @param {string} string representing integer number
|
* @param {string} string representing integer number
|
||||||
* @param {string} [thousandsSep=","] the separator to insert
|
* @param {string} [thousandsSep=","] the separator to insert
|
||||||
* @param {number[]} [grouping=[3,0]]
|
* @param {number[]} [grouping=[]]
|
||||||
* array of relative offsets at which to insert `thousandsSep`.
|
* array of relative offsets at which to insert `thousandsSep`.
|
||||||
* See `strings.intersperse` method.
|
* See `strings.intersperse` method.
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function insertThousandsSep(number, thousandsSep = ",", grouping = [3, 0]) {
|
function insertThousandsSep(number, thousandsSep = ",", grouping = []) {
|
||||||
const negative = number[0] === "-";
|
const negative = number[0] === "-";
|
||||||
number = negative ? number.slice(1) : number;
|
number = negative ? number.slice(1) : number;
|
||||||
return (negative ? "-" : "") + intersperse(number, grouping, thousandsSep);
|
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
|
// note: we need to call toString here to make sure we manipulate the resulting
|
||||||
// string, not an object with a toString method.
|
// string, not an object with a toString method.
|
||||||
const unitSymbols = _lt("kMGTPE").toString();
|
const unitSymbols = _t("kMGTPE").toString();
|
||||||
const sign = Math.sign(number);
|
const sign = Math.sign(number);
|
||||||
number = Math.abs(number);
|
number = Math.abs(number);
|
||||||
let symbol = "";
|
let symbol = "";
|
||||||
@@ -89,16 +89,43 @@ function humanNumber(number, options = { decimals: 0, minDigits: 1 }) {
|
|||||||
// Exports
|
// 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
|
* Returns a string representing a float. The result takes into account the
|
||||||
* user settings (to display the correct decimal separator).
|
* 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]
|
||||||
* @param {Object} [options.field] a description of the field (returned by
|
* @param {number[]} [options.digits] the number of digits that should be used,
|
||||||
* 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,
|
|
||||||
* instead of the default digits precision in the field.
|
* instead of the default digits precision in the field.
|
||||||
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
|
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
|
||||||
* to a human readable format.
|
* to a human readable format.
|
||||||
@@ -121,28 +148,25 @@ export function formatFloat(value, options = {}) {
|
|||||||
const thousandsSep = "thousandsSep" in options ? options.thousandsSep : l10n.thousandsSep;
|
const thousandsSep = "thousandsSep" in options ? options.thousandsSep : l10n.thousandsSep;
|
||||||
const decimalPoint = "decimalPoint" in options ? options.decimalPoint : l10n.decimalPoint;
|
const decimalPoint = "decimalPoint" in options ? options.decimalPoint : l10n.decimalPoint;
|
||||||
let precision;
|
let precision;
|
||||||
if (options.digits) {
|
if (options.digits && options.digits[1] !== undefined) {
|
||||||
precision = options.digits[1];
|
precision = options.digits[1];
|
||||||
} else if (options.field && options.field.digits) {
|
|
||||||
precision = options.field.digits[1];
|
|
||||||
} else {
|
} else {
|
||||||
precision = 2;
|
precision = 2;
|
||||||
}
|
}
|
||||||
const formatted = (value || 0).toFixed(precision || 2).split(".");
|
const formatted = (value || 0).toFixed(precision).split(".");
|
||||||
formatted[0] = insertThousandsSep(formatted[0], thousandsSep, grouping);
|
formatted[0] = insertThousandsSep(formatted[0], thousandsSep, grouping);
|
||||||
if (options.noTrailingZeros) {
|
if (options.noTrailingZeros) {
|
||||||
formatted[1] = formatted[1].replace(/0+$/, "");
|
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
|
* Returns a string representing a float value, from a float converted with a
|
||||||
* factor.
|
* factor.
|
||||||
*
|
*
|
||||||
* @param {number|false} value
|
* @param {number | false} value
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {Object} [options.field] a description of the field
|
|
||||||
* @param {number} [options.factor=1.0] conversion factor
|
* @param {number} [options.factor=1.0] conversion factor
|
||||||
* @returns {string}
|
* @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
|
* we sometimes want to display something like 1:45 instead of 1.75, or 0:15
|
||||||
* instead of 0.25.
|
* instead of 0.25.
|
||||||
*
|
*
|
||||||
* @param {number|false} value
|
* @param {number | false} value
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {Object} [options.field] a description of the field
|
|
||||||
* @param {boolean} [options.noLeadingZeroHour] if true, format like 1:30
|
* @param {boolean} [options.noLeadingZeroHour] if true, format like 1:30
|
||||||
* otherwise, format like 01:30
|
* otherwise, format like 01:30
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
@@ -191,9 +214,8 @@ export function formatFloatTime(value, options = {}) {
|
|||||||
* Returns a string representing an integer. If the value is false, then we
|
* Returns a string representing an integer. If the value is false, then we
|
||||||
* return an empty string.
|
* return an empty string.
|
||||||
*
|
*
|
||||||
* @param {integer|false} value
|
* @param {number | false | null} value
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {Object} [options.field] a description of the field
|
|
||||||
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
|
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
|
||||||
* to a human readable format.
|
* to a human readable format.
|
||||||
* @param {boolean} [options.isPassword=false] if returns true, acts like
|
* @param {boolean} [options.isPassword=false] if returns true, acts like
|
||||||
@@ -203,11 +225,11 @@ export function formatFloatTime(value, options = {}) {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function formatInteger(value, options = {}) {
|
export function formatInteger(value, options = {}) {
|
||||||
if (value === false) {
|
if (value === false || value === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (options.isPassword) {
|
if (options.isPassword) {
|
||||||
return new Array(value.length + 1).join("*");
|
return "*".repeat(value.length);
|
||||||
}
|
}
|
||||||
if (options.humanReadable) {
|
if (options.humanReadable) {
|
||||||
return humanNumber(value, options);
|
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
|
* Returns a string representing a many2one value. The value is expected to be
|
||||||
* return an empty string. Note that it accepts two types of input parameters:
|
* either `false` or an array in the form [id, display_name]. The returned
|
||||||
* an array, in that case we assume that the many2one value is of the form
|
* value will then be the display name of the given value, or an empty string
|
||||||
* [id, nameget], and we return the nameget, or it can be an object, and in that
|
* if the value is false.
|
||||||
* case, we assume that it is a record datapoint from a BasicModel.
|
|
||||||
*
|
*
|
||||||
* @param {Array|Object|false} value
|
* @param {[number, string] | false} value
|
||||||
* @param {Object} [options] additional options
|
* @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
|
* @param {boolean} [options.escape=false] if true, escapes the formatted value
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function formatMany2one(value, options) {
|
export function formatMany2one(value, options) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
value = "";
|
value = "";
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
// value is a pair [id, nameget]
|
|
||||||
value = value[1];
|
|
||||||
} else {
|
} else {
|
||||||
// value is a datapoint, so we read its display_name field, which
|
value = value[1];
|
||||||
// may in turn be a datapoint (if the name field is a many2one)
|
|
||||||
while (value.data) {
|
|
||||||
value = value.data.display_name || "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (options && options.escape) {
|
if (options && options.escape) {
|
||||||
value = encodeURIComponent(value);
|
value = encodeURIComponent(value);
|
||||||
@@ -249,18 +262,34 @@ export function formatMany2one(value, options) {
|
|||||||
return value;
|
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
|
* Returns a string representing a monetary value. The result takes into account
|
||||||
* the user settings (to display the correct decimal separator, currency, ...).
|
* the user settings (to display the correct decimal separator, currency, ...).
|
||||||
*
|
*
|
||||||
* @param {float|false} value the value that should be formatted
|
* @param {number | 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 {Object} [options]
|
* @param {Object} [options]
|
||||||
* additional options to override the values in the python description of the
|
* additional options to override the values in the python description of the
|
||||||
* field.
|
* 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
|
* @param {string} [options.currencyField] the name of the field whose value is
|
||||||
* the currency id (ignored if options.currency_id).
|
* the currency id (ignored if options.currency_id).
|
||||||
* Note: if not given it will default to the field "currency_field" value or
|
* 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}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function formatMonetary(value, options = {}) {
|
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;
|
let currencyId = options.currencyId;
|
||||||
if (!currencyId && options.data) {
|
if (!currencyId && options.data) {
|
||||||
const currencyField =
|
const currencyField = options.currencyField || "currency_id";
|
||||||
options.currencyField ||
|
const dataValue = options.data[currencyField];
|
||||||
(options.field && options.field.currency_field) ||
|
currencyId = Array.isArray(dataValue) ? dataValue[0] : dataValue;
|
||||||
"currency_id";
|
|
||||||
currencyId = options.data[currencyField] && options.data[currencyField].res_id;
|
|
||||||
}
|
}
|
||||||
const currency = session.currencies[currencyId];
|
const currency = session.currencies[currencyId];
|
||||||
const digits = (currency && currency.digits) || options.digits;
|
const digits = (currency && currency.digits) || options.digits;
|
||||||
|
|
||||||
let formatted;
|
let formattedValue;
|
||||||
if (options.humanReadable) {
|
if (options.humanReadable) {
|
||||||
formatted = humanNumber(value, { decimals: digits ? digits[1] : 2 });
|
formattedValue = humanNumber(value, { decimals: digits ? digits[1] : 2 });
|
||||||
} else {
|
} else {
|
||||||
formatted = formatFloat(value, { digits });
|
formattedValue = formatFloat(value, { digits });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currency || options.noSymbol) {
|
if (!currency || options.noSymbol) {
|
||||||
return formatted;
|
return formattedValue;
|
||||||
}
|
}
|
||||||
|
const formatted = [currency.symbol, formattedValue];
|
||||||
if (currency.position === "after") {
|
if (currency.position === "after") {
|
||||||
return `${formatted} ${currency.symbol}`;
|
formatted.reverse();
|
||||||
} else {
|
|
||||||
return `${currency.symbol} ${formatted}`;
|
|
||||||
}
|
}
|
||||||
|
return formatted.join(nbsp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -310,7 +343,6 @@ export function formatMonetary(value, options = {}) {
|
|||||||
*
|
*
|
||||||
* @param {number | false} value
|
* @param {number | false} value
|
||||||
* @param {Object} [options]
|
* @param {Object} [options]
|
||||||
* @param {Object} [options.field] a description of the field
|
|
||||||
* @param {boolean} [options.noSymbol] if true, doesn't concatenate with "%"
|
* @param {boolean} [options.noSymbol] if true, doesn't concatenate with "%"
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
@@ -321,14 +353,57 @@ export function formatPercentage(value, options = {}) {
|
|||||||
return `${formatted}${options.noSymbol ? "" : "%"}`;
|
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
|
registry
|
||||||
.category("formatters")
|
.category("formatters")
|
||||||
|
.add("boolean", formatBoolean)
|
||||||
|
.add("char", formatChar)
|
||||||
.add("date", formatDate)
|
.add("date", formatDate)
|
||||||
.add("datetime", formatDateTime)
|
.add("datetime", formatDateTime)
|
||||||
.add("float", formatFloat)
|
.add("float", formatFloat)
|
||||||
.add("float_factor", formatFloatFactor)
|
.add("float_factor", formatFloatFactor)
|
||||||
.add("float_time", formatFloatTime)
|
.add("float_time", formatFloatTime)
|
||||||
|
.add("html", (value) => value)
|
||||||
.add("integer", formatInteger)
|
.add("integer", formatInteger)
|
||||||
.add("many2one", formatMany2one)
|
.add("many2one", formatMany2one)
|
||||||
|
.add("one2many", formatX2many)
|
||||||
|
.add("many2many", formatX2many)
|
||||||
.add("monetary", formatMonetary)
|
.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
Reference in New Issue
Block a user