Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,131 @@
/** @odoo-module **/
/* global ace */
import { loadJS } from "@web/core/assets";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { formatText } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillStart, onWillUpdateProps, useEffect, useRef } from "@odoo/owl";
export class AceField extends Component {
setup() {
this.aceEditor = null;
this.editorRef = useRef("editor");
this.cookies = useService("cookie");
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);
});
onWillUpdateProps(this.updateAce);
useEffect(
() => {
this.setupAce();
this.updateAce(this.props);
return () => this.destroyAce();
},
() => [this.editorRef.el]
);
useBus(this.env.bus, "RELATIONAL_MODEL:WILL_SAVE_URGENTLY", () => this.commitChanges());
useBus(this.env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", ({ detail }) =>
detail.proms.push(this.commitChanges())
);
}
get aceSession() {
return this.aceEditor.getSession();
}
setupAce() {
this.aceEditor = ace.edit(this.editorRef.el);
this.aceEditor.setOptions({
maxLines: Infinity,
showPrintMargin: false,
theme: this.cookies.current.color_scheme === "dark" ? "ace/theme/monokai" : "",
});
this.aceEditor.$blockScrolling = true;
this.aceSession.setOptions({
useWorker: false,
tabSize: 2,
useSoftTabs: true,
});
this.aceEditor.on("blur", this.commitChanges.bind(this));
}
updateAce({ mode, readonly, value }) {
if (!this.aceEditor) {
return;
}
this.aceSession.setOptions({
mode: `ace/mode/${mode === "xml" ? "qweb" : mode}`,
});
this.aceEditor.setOptions({
readOnly: readonly,
highlightActiveLine: !readonly,
highlightGutterLine: !readonly,
});
this.aceEditor.renderer.setOptions({
displayIndentGuides: !readonly,
showGutter: !readonly,
});
this.aceEditor.renderer.$cursorLayer.element.style.display = readonly ? "none" : "block";
const formattedValue = formatText(value);
if (this.aceSession.getValue() !== formattedValue) {
this.aceSession.setValue(formattedValue);
}
}
destroyAce() {
if (this.aceEditor) {
this.aceEditor.destroy();
}
}
commitChanges() {
if (!this.props.readonly) {
const value = this.aceSession.getValue();
if ((this.props.value || "") !== value) {
return this.props.update(value);
}
}
}
}
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 = ({ attrs }) => {
return {
mode: attrs.options.mode,
};
};
registry.category("fields").add("ace", AceField);

View file

@ -0,0 +1,3 @@
.o_field_ace {
display: block !important;
}

View file

@ -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>

View file

@ -0,0 +1,15 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { Component } from "@odoo/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);

View file

@ -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>

View file

@ -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";
import { Component } from "@odoo/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 `text-bg-${decorationName}`;
}
}
return "";
}
}
BadgeField.template = "web.BadgeField";
BadgeField.props = {
...standardFieldProps,
};
BadgeField.displayName = _lt("Badge");
BadgeField.supportedTypes = ["selection", "many2one", "char"];
registry.category("fields").add("badge", BadgeField);

View file

@ -0,0 +1,14 @@
// TODO: remove second selector when we remove legacy badge field
.o_field_badge span, span.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
color: #444B5A;
&.o_field_empty {
display: none !important;
}
}

View file

@ -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 rounded-pill" t-att-class="classFromDecoration" t-esc="formattedValue" />
</t>
</templates>

View file

@ -0,0 +1,91 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class BadgeSelectionField extends Component {
get options() {
switch (this.props.record.fields[this.props.name].type) {
case "many2one":
// WOWL: conversion needed while we keep using the legacy model
return Object.values(this.props.record.preloadedData[this.props.name]).map((v) => {
return [v.id, v.display_name];
});
case "selection":
return this.props.record.fields[this.props.name].selection;
default:
return [];
}
}
get string() {
switch (this.props.type) {
case "many2one":
return this.props.value ? this.props.value[1] : "";
case "selection":
return this.props.value !== false
? this.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.options.find((option) => option[0] === value));
}
break;
case "selection":
if (value === this.props.value) {
this.props.update(false);
} else {
this.props.update(value);
}
break;
}
}
}
BadgeSelectionField.template = "web.BadgeSelectionField";
BadgeSelectionField.props = {
...standardFieldProps,
};
BadgeSelectionField.displayName = _lt("Badges");
BadgeSelectionField.supportedTypes = ["many2one", "selection"];
BadgeSelectionField.legacySpecialData = "_fetchSpecialMany2ones";
BadgeSelectionField.isEmpty = (record, fieldName) => record.data[fieldName] === false;
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,
});

View file

@ -0,0 +1,21 @@
<?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="options" t-as="option" t-key="option[0]">
<span
class="o_selection_badge"
t-att-class="{ active: value === option[0] }"
t-att-value="stringify(option[0])"
t-esc="option[1]"
t-on-click="() => this.onChange(option[0])"
/>
</t>
</t>
</t>
</templates>

View file

@ -0,0 +1,81 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { isBinarySize, toBase64Length } from "@web/core/utils/binary";
import { download } from "@web/core/network/download";
import { standardFieldProps } from "../standard_field_props";
import { FileUploader } from "../file_handler";
import { _lt } from "@web/core/l10n/translation";
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
export const MAX_FILENAME_SIZE_BYTES = 0xFF; // filenames do not exceed 255 bytes on Linux/Windows/MacOS
export class BinaryField extends Component {
setup() {
this.notification = useService("notification");
this.state = useState({
fileName: this.props.record.data[this.props.fileNameField] || "",
});
onWillUpdateProps((nextProps) => {
this.state.fileName = nextProps.record.data[nextProps.fileNameField] || "";
});
}
get fileName() {
let value = this.props.value;
value = value && typeof value === "string" ? value : false;
return (this.state.fileName || value || "").slice(0, toBase64Length(MAX_FILENAME_SIZE_BYTES));
}
update({ data, name }) {
this.state.fileName = name || "";
const { fileNameField, record } = this.props;
const changes = { [this.props.name]: data || false };
if (fileNameField in record.fields && record.data[fileNameField] !== name) {
changes[fileNameField] = name || false;
}
return this.props.record.update(changes);
}
async onFileDownload() {
await download({
data: {
model: this.props.record.resModel,
id: this.props.record.resId,
field: this.props.name,
filename_field: this.fileName,
filename: this.fileName || "",
download: true,
data: isBinarySize(this.props.value) ? null : this.props.value,
},
url: "/web/content",
});
}
}
BinaryField.template = "web.BinaryField";
BinaryField.components = {
FileUploader,
};
BinaryField.props = {
...standardFieldProps,
acceptedFileExtensions: { type: String, optional: true },
fileNameField: { type: String, optional: true },
};
BinaryField.defaultProps = {
acceptedFileExtensions: "*",
};
BinaryField.displayName = _lt("File");
BinaryField.supportedTypes = ["binary"];
BinaryField.extractProps = ({ attrs }) => {
return {
acceptedFileExtensions: attrs.options.accepted_file_extensions,
fileNameField: attrs.filename,
};
};
registry.category("fields").add("binary", BinaryField);

View file

@ -0,0 +1,59 @@
<?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="props.value">
<div class="w-100 d-inline-flex">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
file="{ data: props.value, name: fileName }"
onUploaded.bind="update"
>
<t t-if="props.record.resId and !props.record.isDirty">
<button
class="btn btn-secondary fa fa-download"
data-tooltip="Download"
aria-label="Download"
t-on-click="onFileDownload"
/>
</t>
<t t-set-slot="toggler">
<input type="text" class="o_input" t-att-value="fileName" readonly="readonly" />
<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="() => this.update({})"
/>
</FileUploader>
</div>
</t>
<t t-else="">
<label class="o_select_file_button btn btn-primary">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
onUploaded.bind="update"
>
<t t-set-slot="toggler">
Upload your file
</t>
</FileUploader>
</label>
</t>
</t>
<t t-elif="props.record.resId and props.value">
<a class="o_form_uri" href="#" t-on-click.prevent="onFileDownload">
<span class="fa fa-download me-2" />
<t t-if="state.fileName" t-esc="state.fileName" />
</a>
</t>
</t>
</templates>

View file

@ -0,0 +1,34 @@
/** @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";
import { Component } from "@odoo/owl";
export class BooleanField extends Component {
get isReadonly() {
return !(this.props.record.isInEdition && !this.props.record.isReadonly(this.props.name));
}
/**
* @param {boolean} newValue
*/
onChange(newValue) {
this.props.update(newValue);
}
}
BooleanField.template = "web.BooleanField";
BooleanField.components = { CheckBox };
BooleanField.props = {
...standardFieldProps,
};
BooleanField.displayName = _lt("Checkbox");
BooleanField.supportedTypes = ["boolean"];
BooleanField.isEmpty = () => false;
registry.category("fields").add("boolean", BooleanField);

View file

@ -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" className="'d-inline-block'" disabled="isReadonly" onChange.bind="onChange" />
</t>
</templates>

View file

@ -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";
import { Component } from "@odoo/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 = ({ attrs }) => {
return {
noLabel: archParseBoolean(attrs.nolabel),
};
};
registry.category("fields").add("boolean_favorite", BooleanFavoriteField);

View file

@ -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>

View file

@ -0,0 +1,29 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { BooleanField } from "../boolean/boolean_field";
export class BooleanToggleField extends BooleanField {
get isReadonly() {
return this.props.record.isReadonly(this.props.name);
}
onChange(newValue) {
this.props.update(newValue, { save: this.props.autosave });
}
}
BooleanToggleField.template = "web.BooleanToggleField";
BooleanToggleField.displayName = _lt("Toggle");
BooleanToggleField.props = {
...BooleanField.props,
autosave: { type: Boolean, optional: true },
};
BooleanToggleField.extractProps = ({ attrs }) => {
return {
autosave: "autosave" in attrs.options ? Boolean(attrs.options.autosave) : true,
};
};
registry.category("fields").add("boolean_toggle", BooleanToggleField);

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.BooleanToggleField" t-inherit="web.BooleanField" t-inherit-mode="primary" owl="1">
<xpath expr="//CheckBox" position="attributes">
<attribute name="className">'o_field_boolean o_boolean_toggle form-switch'</attribute>
</xpath>
<xpath expr="//CheckBox" position="inside">
&#8203; <!-- Zero width space needed to set height -->
</xpath>
</t>
</templates>

View file

@ -0,0 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { BooleanToggleField } from "./boolean_toggle_field";
export class ListBooleanToggleField extends BooleanToggleField {
onClick() {
if (!this.props.readonly) {
this.props.update(!this.props.value, { save: this.props.autosave });
}
}
}
ListBooleanToggleField.template = "web.ListBooleanToggleField";
registry.category("fields").add("list.boolean_toggle", ListBooleanToggleField);

View file

@ -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">
<t t-call="web.BooleanToggleField" />
</div>
</t>
</templates>

View file

@ -0,0 +1,106 @@
/** @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";
import { useDynamicPlaceholder } from "../dynamicplaceholder_hook";
import { Component, onMounted, onWillUnmount, useRef } from "@odoo/owl";
export class CharField extends Component {
setup() {
if (this.props.dynamicPlaceholder) {
this.dynamicPlaceholder = useDynamicPlaceholder();
}
this.input = useRef("input");
useInputField({ getValue: () => this.props.value || "", parse: (v) => this.parse(v) });
onMounted(this.onMounted);
onWillUnmount(this.onWillUnmount);
}
async onKeydownListener(ev) {
if (ev.key === this.dynamicPlaceholder.TRIGGER_KEY && ev.target === this.input.el) {
const baseModel = this.props.record.data.mailing_model_real;
if (baseModel) {
await this.dynamicPlaceholder.open(
this.input.el,
baseModel,
{
validateCallback: this.onDynamicPlaceholderValidate.bind(this),
closeCallback: this.onDynamicPlaceholderClose.bind(this)
}
);
}
}
}
onMounted() {
if (this.props.dynamicPlaceholder) {
this.keydownListenerCallback = this.onKeydownListener.bind(this);
document.addEventListener('keydown', this.keydownListenerCallback);
}
}
onWillUnmount() {
if (this.props.dynamicPlaceholder) {
document.removeEventListener('keydown', this.keydownListenerCallback);
}
}
onDynamicPlaceholderValidate(chain, defaultValue) {
if (chain) {
const triggerKeyReplaceRegex = new RegExp(`${this.dynamicPlaceholder.TRIGGER_KEY}$`);
let dynamicPlaceholder = "{{object." + chain.join('.');
dynamicPlaceholder += defaultValue && defaultValue !== '' ? ` or '''${defaultValue}'''}}` : '}}';
this.props.update(this.input.el.value.replace(triggerKeyReplaceRegex, '') + dynamicPlaceholder);
}
}
onDynamicPlaceholderClose() {
this.input.el.focus();
}
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.defaultProps = { dynamicPlaceholder: false };
CharField.props = {
...standardFieldProps,
autocomplete: { type: String, optional: true },
isPassword: { type: Boolean, optional: true },
placeholder: { type: String, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true},
shouldTrim: { type: Boolean, optional: true },
maxLength: { type: Number, optional: true },
isTranslatable: { type: Boolean, optional: true },
};
CharField.displayName = _lt("Text");
CharField.supportedTypes = ["char"];
CharField.extractProps = ({ attrs, field }) => {
return {
shouldTrim: field.trim && !archParseBoolean(attrs.password), // passwords shouldn't be trimmed
maxLength: field.size,
isTranslatable: field.translate,
dynamicPlaceholder: attrs.options.dynamic_placeholder,
autocomplete: attrs.autocomplete,
isPassword: archParseBoolean(attrs.password),
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("char", CharField);

View file

@ -0,0 +1,7 @@
// Heading element normally takes the full width of the parent,
// so the char_field wrapper should take the same width.
@include media-breakpoint-up(md) {
.o_field_char {
width: inherit;
}
}

View file

@ -0,0 +1,28 @@
<?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-class="{'o_field_translate': props.isTranslatable}"
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"
record="props.record"
/>
</t>
</t>
</t>
</templates>

View file

@ -0,0 +1,31 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component, useState, onWillUpdateProps } from "@odoo/owl";
export class ColorField extends Component {
setup() {
this.state = useState({
color: this.props.value || '',
});
onWillUpdateProps((nextProps) => {
this.state.color = nextProps.value || '';
});
}
get isReadonly() {
return this.props.record.isReadonly(this.props.name);
}
}
ColorField.template = "web.ColorField";
ColorField.props = {
...standardFieldProps,
};
ColorField.supportedTypes = ["char"];
registry.category("fields").add("color", ColorField);

View file

@ -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': isReadonly }" t-attf-style="background: #{state.color or 'url(/web/static/img/transparent.png)'}">
<input t-on-click.stop="" class="w-100 h-100 opacity-0" type="color" t-att-value="state.color" t-att-disabled="isReadonly" t-on-input="(ev) => this.state.color = ev.target.value" t-on-change="(ev) => this.props.update(ev.target.value)" />
</div>
</t>
</templates>

View file

@ -0,0 +1,34 @@
/** @odoo-module **/
import { ColorList } from "@web/core/colorlist/colorlist";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class ColorPickerField extends Component {
get canToggle() {
return this.props.record.activeFields[this.props.name].viewType !== "list";
}
get isExpanded() {
return !this.canToggle && !this.props.readonly;
}
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);

View file

@ -0,0 +1,14 @@
.o_field_widget.o_field_color_picker > div {
// to compensate on the 2px margin used to space the elements relative to each other
// we keep the top one cause the widget is a bit to high anyway
margin-left: -2px;
margin-right: -2px;
margin-bottom: -2px;
> div {
display: inline-block;
border: 1px solid white;
box-shadow: 0 0 0 1px map-get($grays, '300');
margin: 2px;
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ColorPickerField" owl="1">
<ColorList canToggle="canToggle" colors="constructor.RECORD_COLORS" forceExpanded="isExpanded" onColorSelected.bind="switchColor" selectedColor="props.value || 0"/>
</t>
</templates>

View file

@ -0,0 +1,49 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { Tooltip } from "@web/core/tooltip/tooltip";
import { useService } from "@web/core/utils/hooks";
import { Component, useRef } from "@odoo/owl";
export class CopyButton extends Component {
setup() {
this.button = useRef("button");
this.popover = useService("popover");
}
showTooltip() {
const closeTooltip = this.popover.add(this.button.el, Tooltip, {
tooltip: this.props.successText,
});
browser.setTimeout(() => {
closeTooltip();
}, 800);
}
async onClick() {
if (!browser.navigator.clipboard) {
return browser.console.warn("This browser doesn't allow to copy to clipboard");
}
let write;
// any kind of content can be copied into the clipboard using
// the appropriate native methods
if (typeof this.props.content === "string" || this.props.content instanceof String) {
write = (value) => browser.navigator.clipboard.writeText(value);
} else {
write = (value) => browser.navigator.clipboard.write(value);
}
try {
await write(this.props.content);
} catch(error) {
return browser.console.warn(error);
}
this.showTooltip();
}
}
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 },
};

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CopyButton" owl="1">
<button
class="text-nowrap"
t-ref="button"
t-attf-class="btn btn-sm btn-primary o_clipboard_button {{ props.className || '' }}"
t-on-click.stop="onClick"
>
<span class="fa fa-clipboard mx-1"/>
<span t-esc="props.copyText"/>
</button>
</t>
</templates>

View file

@ -0,0 +1,60 @@
/** @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";
import { Component } from "@odoo/owl";
class CopyClipboardField extends Component {
setup() {
this.copyText = this.env._t("Copy");
this.successText = this.env._t("Copied");
}
get copyButtonClassName() {
return `o_btn_${this.props.type}_copy`;
}
}
CopyClipboardField.template = "web.CopyClipboardField";
CopyClipboardField.props = {
...standardFieldProps,
};
export class CopyClipboardButtonField extends CopyClipboardField {
get copyButtonClassName() {
const classNames = [super.copyButtonClassName];
classNames.push("rounded-2");
return classNames.join(" ");
}
}
CopyClipboardButtonField.template = "web.CopyClipboardButtonField";
CopyClipboardButtonField.components = { CopyButton };
CopyClipboardButtonField.displayName = _lt("Copy to Clipboard");
registry.category("fields").add("CopyClipboardButton", CopyClipboardButtonField);
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);

View file

@ -0,0 +1,22 @@
.o_field_CopyClipboardText, .o_field_CopyClipboardURL, .o_field_CopyClipboardChar {
> div {
grid-template-columns: auto min-content;
border: 1px solid $primary;
font-size: $font-size-sm;
color: $o-brand-primary;
font-weight: $badge-font-weight;
> span:first-child, a {
margin-left: 4px;
margin-right: 4px;
align-self: center;
text-align: center;
&:not(.o_field_CopyClipboardText) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.CopyClipboardField" owl="1">
<div class="d-grid rounded-2 overflow-hidden">
<Field t-props="props"/>
<CopyButton t-if="props.value" className="copyButtonClassName" content="props.value" copyText="copyText" successText="successText"/>
</div>
</t>
<t t-name="web.CopyClipboardButtonField" owl="1">
<CopyButton t-if="props.value" className="copyButtonClassName" content="props.value" copyText="copyText" successText="successText"/>
</t>
</templates>

View file

@ -0,0 +1,72 @@
/** @odoo-module **/
import { DatePicker } from "@web/core/datepicker/datepicker";
import { areDateEquals, formatDate, formatDateTime } from "@web/core/l10n/dates";
import { localization } from "@web/core/l10n/localization";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class DateField extends Component {
setup() {
/**
* The last value that has been commited to the model.
* Not changed in case of invalid field value.
*/
this.lastSetValue = null;
this.revId = 0;
}
get isDateTime() {
return this.props.record.fields[this.props.name].type === "datetime";
}
get date() {
return this.props.value && this.props.value.startOf("day");
}
get formattedValue() {
return this.isDateTime
? formatDateTime(this.props.value, { format: localization.dateFormat })
: formatDate(this.props.value);
}
onDateTimeChanged(date) {
if (!areDateEquals(this.date || "", date)) {
this.revId++;
this.props.update(date);
}
}
onDatePickerInput(ev) {
this.props.setDirty(ev.target.value !== this.lastSetValue);
}
onUpdateInput(date) {
this.props.setDirty(false);
this.lastSetValue = date;
}
}
DateField.template = "web.DateField";
DateField.components = {
DatePicker,
};
DateField.props = {
...standardFieldProps,
pickerOptions: { type: Object, optional: true },
placeholder: { type: String, optional: true },
};
DateField.defaultProps = {
pickerOptions: {},
};
DateField.displayName = _lt("Date");
DateField.supportedTypes = ["date", "datetime"];
DateField.extractProps = ({ attrs }) => {
return {
pickerOptions: attrs.options.datepicker,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("date", DateField);

View file

@ -0,0 +1,22 @@
<?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"
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(date) => this.onDateTimeChanged(date)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
revId="revId"
/>
</t>
</t>
</templates>

View file

@ -0,0 +1,173 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { loadJS } from "@web/core/assets";
import { luxonToMoment, momentToLuxon } from "@web/core/l10n/dates";
import { useService } from "@web/core/utils/hooks";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillStart, useExternalListener, useRef, useEffect } from "@odoo/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"),
},
startDate: this.startDate ? luxonToMoment(this.startDate) : window.moment(),
endDate: this.endDate ? luxonToMoment(this.endDate) : window.moment(),
drops: "auto",
});
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.addEventListener(
"click",
(ev) => {
ev.isFromDateRangePicker = true;
},
{ capture: true }
);
this.pickerContainer.dataset.name = this.props.name;
}
return () => {
if (el) {
this.pickerContainer.remove();
}
};
},
() => [this.root.el, this.props.value]
);
}
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.endDate);
}
get formattedStartDate() {
return this.formatValue(this.props.formatType, this.startDate);
}
get startDate() {
return this.props.record.data[this.props.relatedStartDateField || this.props.name];
}
get endDate() {
return this.props.record.data[this.props.relatedEndDateField || this.props.name];
}
get relatedDateRangeField() {
return this.props.relatedStartDateField
? this.props.relatedStartDateField
: this.props.relatedEndDateField;
}
formatValue(format, value) {
const formatter = formatters.get(format);
let formattedValue;
try {
formattedValue = formatter(value);
} catch {
this.props.record.setInvalidField(this.props.name);
}
return formattedValue;
}
updateRange(start, end) {
return this.props.record.update({
[this.props.relatedStartDateField || this.props.name]: start,
[this.props.relatedEndDateField || this.props.name]: end,
});
}
onChangeInput(ev) {
const parse = parsers.get(this.props.formatType);
let value;
try {
value = parse(ev.target.value);
} catch {
this.props.record.setInvalidField(this.props.name);
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) {
const start = this.isDateTime ? picker.startDate : picker.startDate.startOf("day");
const end = this.isDateTime ? picker.endDate : picker.endDate.startOf("day");
const dates = [start, end].map(momentToLuxon);
await this.updateRange(dates[0], dates[1]);
const input = document.querySelector(
`.o_field_daterange[name='${this.relatedDateRangeField}'] input`
);
if (!input) {
// Don't attempt to update the related daterange field if not present in the DOM
return;
}
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.props = {
...standardFieldProps,
relatedEndDateField: { type: String, optional: true },
relatedStartDateField: { type: String, optional: true },
formatType: { type: String, optional: true },
placeholder: { type: String, optional: true },
};
DateRangeField.supportedTypes = ["date", "datetime"];
DateRangeField.extractProps = ({ attrs, field }) => {
return {
relatedEndDateField: attrs.options.related_end_date,
relatedStartDateField: attrs.options.related_start_date,
formatType: attrs.options.format_type || field.type,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("daterange", DateRangeField);

View file

@ -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 class="o_input" style="width:100% !important" autocomplete="off" t-ref="root" type="text" t-att-id="props.id" t-att-value="formattedValue" t-att-placeholder="props.placeholder" t-on-change="onChangeInput" />
</t>
</t>
</templates>

View file

@ -0,0 +1,62 @@
/** @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";
import { Component } from "@odoo/owl";
export class DateTimeField extends Component {
setup() {
/**
* The last value that has been commited to the model.
* Not changed in case of invalid field value.
*/
this.lastSetValue = null;
this.revId = 0;
}
get formattedValue() {
return formatDateTime(this.props.value);
}
onDateTimeChanged(date) {
if (!areDateEquals(this.props.value || "", date)) {
this.revId++;
this.props.update(date);
}
}
onDatePickerInput(ev) {
this.props.setDirty(ev.target.value !== this.lastSetValue);
}
onUpdateInput(date) {
this.props.setDirty(false);
this.lastSetValue = date;
}
}
DateTimeField.template = "web.DateTimeField";
DateTimeField.components = {
DateTimePicker,
};
DateTimeField.props = {
...standardFieldProps,
pickerOptions: { type: Object, optional: true },
placeholder: { type: String, optional: true },
};
DateTimeField.defaultProps = {
pickerOptions: {},
};
DateTimeField.displayName = _lt("Date & Time");
DateTimeField.supportedTypes = ["datetime"];
DateTimeField.extractProps = ({ attrs }) => {
return {
pickerOptions: attrs.options.datepicker,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("datetime", DateTimeField);

View file

@ -0,0 +1,22 @@
<?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"
inputId="props.id"
placeholder="props.placeholder"
onDateTimeChanged="(datetime) => this.onDateTimeChanged(datetime)"
onInput.bind="onDatePickerInput"
onUpdateInput.bind="onUpdateInput"
revId="revId"
/>
</t>
</t>
</templates>

View file

@ -0,0 +1,179 @@
/** @odoo-module **/
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
import { DomainSelectorDialog } from "@web/core/domain_selector_dialog/domain_selector_dialog";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useBus, useService, useOwnedDialogs } 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";
import { Component, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
export class DomainField extends Component {
setup() {
this.rpc = useService("rpc");
this.orm = useService("orm");
this.state = useState({
recordCount: null,
isValid: true,
});
this.addDialog = useOwnedDialogs();
this.displayedDomain = null;
this.isDebugEdited = false;
onWillStart(() => {
this.displayedDomain = this.props.value;
this.loadCount(this.props);
});
onWillUpdateProps(async (nextProps) => {
this.isDebugEdited = this.isDebugEdited && this.props.readonly === nextProps.readonly;
// Check the manually edited domain and reflect it in the widget if its valid
if (this.isDebugEdited) {
const proms = [];
this.env.bus.trigger("RELATIONAL_MODEL:NEED_LOCAL_CHANGES", { proms });
await Promise.all([...proms]);
}
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 props = this.props;
const prom = this.quickValidityCheck(props);
ev.detail.proms.push(prom);
prom.then((isValid) => {
if (isValid) {
this.isDebugEdited = false; // will allow the count to be loaded if needed
} else {
this.state.isValid = false;
this.state.recordCount = 0;
this.props.record.setInvalidField(props.name);
}
});
}
});
}
async quickValidityCheck(p) {
const model = this.getResModel(p);
if (!model) {
return false;
}
try {
const domain = this.getDomain(p.value).toList(this.getContext(p));
return this.rpc("/web/domain/validate", { model, domain });
} catch (_) {
return false;
}
}
getContext(p) {
return p.record.getFieldContext(p.name);
}
getResModel(p) {
let resModel = p.resModel;
if (p.record.fieldNames.includes(resModel)) {
resModel = p.record.data[resModel];
}
return resModel;
}
onButtonClick() {
this.addDialog(
SelectCreateDialog,
{
title: this.env._t("Selected records"),
noCreate: true,
multiSelect: false,
resModel: this.getResModel(this.props),
domain: this.getDomain(this.props.value).toList(this.getContext(this.props)) || [],
context: this.getContext(this.props) || {},
},
{
// The counter is reloaded "on close" because some modal allows to modify data that can impact the counter
onClose: () => this.loadCount(this.props),
}
);
}
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 (!this.getResModel(props)) {
Object.assign(this.state, { recordCount: 0, isValid: true });
}
let recordCount;
try {
const domain = this.getDomain(props.value).toList(this.getContext(props));
recordCount = await this.orm.silent.call(
this.getResModel(props),
"search_count",
[domain],
{ context: this.getContext(props) }
);
} 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);
}
onEditDialogBtnClick() {
this.addDialog(DomainSelectorDialog, {
resModel: this.getResModel(this.props),
initialValue: this.props.value || "[]",
readonly: this.props.readonly,
isDebugMode: !!this.env.debug,
onSelected: this.props.update,
});
}
}
DomainField.template = "web.DomainField";
DomainField.components = {
DomainSelector,
};
DomainField.props = {
...standardFieldProps,
editInDialog: { type: Boolean, optional: true },
resModel: { type: String, optional: true },
};
DomainField.defaultProps = {
editInDialog: false,
};
DomainField.displayName = _lt("Domain");
DomainField.supportedTypes = ["char"];
DomainField.isEmpty = () => false;
DomainField.extractProps = ({ attrs }) => {
return {
editInDialog: attrs.options.in_dialog,
resModel: attrs.options.model,
};
};
registry.category("fields").add("domain", DomainField);

View file

@ -0,0 +1,54 @@
<?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: !props.editInDialog }">
<t t-if="getResModel(props)">
<DomainSelector
resModel="getResModel(props)"
value="displayedDomain || '[]'"
readonly="props.readonly or props.editInDialog"
update.bind="update"
isDebugMode="!!env.debug"
debugValue="props.value || '[]'"
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>
<t t-if="props.editInDialog and !props.readonly">
<button class="btn btn-sm btn-primary o_field_domain_dialog_button" t-on-click.prevent="onEditDialogBtnClick">Edit Domain</button>
</t>
</div>
</t>
<t t-else="">
<div>Select a model to add a filter.</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,61 @@
/** @odoo-module **/
import { useUniquePopover } from "@web/core/model_field_selector/unique_popover_hook";
import { useModelField } from "@web/core/model_field_selector/model_field_hook";
import { ModelFieldSelectorPopover } from "@web/core/model_field_selector/model_field_selector_popover";
export function useDynamicPlaceholder() {
const popover = useUniquePopover();
const modelField = useModelField();
let dynamicPlaceholderChain = [];
function update(chain) {
dynamicPlaceholderChain = chain;
}
return {
TRIGGER_KEY: '#',
/**
* Open a Model Field Selector which can select fields to create a dynamic
* placeholder string in the Input with or without a default text value.
*
* @public
* @param {HTMLElement} element
* @param {String} baseModel
* @param {Object} options
* @param {function} options.validateCallback
* @param {function} options.closeCallback
* @param {function} [options.positionCallback]
*/
async open(
element,
baseModel,
options = {}
) {
dynamicPlaceholderChain = await modelField.loadChain(baseModel, "");
popover.add(
element,
ModelFieldSelectorPopover,
{
chain: dynamicPlaceholderChain,
update: update,
validate: options.validateCallback,
showSearchInput: true,
isDebugMode: true,
needDefaultValue: true,
loadChain: modelField.loadChain,
filter: (model) => !["one2many", "boolean", "many2many"].includes(model.type),
},
{
closeOnClickAway: true,
onClose: options.closeCallback,
onPositioned: options.positionCallback,
}
);
}
};
}

View file

@ -0,0 +1,34 @@
/** @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";
import { Component } from "@odoo/owl";
export class EmailField extends Component {
setup() {
useInputField({ getValue: () => this.props.value || "" });
}
}
EmailField.template = "web.EmailField";
EmailField.props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
};
EmailField.extractProps = ({ attrs }) => {
return {
placeholder: attrs.placeholder,
};
};
EmailField.displayName = _lt("Email");
EmailField.supportedTypes = ["char"];
class FormEmailField extends EmailField {}
FormEmailField.template = "web.FormEmailField";
registry.category("fields").add("email", EmailField);
registry.category("fields").add("form.email", FormEmailField);

View file

@ -0,0 +1,7 @@
body:not(.o_touch_device) .o_field_email {
&:not(:hover):not(:focus-within) {
& input:not(:hover) ~ a {
display: none !important;
}
}
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.EmailField" owl="1">
<t t-if="props.readonly">
<div class="d-grid">
<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 || ''"/>
</div>
</t>
<t t-else="">
<div class="d-inline-flex w-100">
<input
class="o_input"
t-att-id="props.id"
type="email"
autocomplete="off"
t-att-placeholder="props.placeholder"
t-att-required="props.required"
t-ref="input"
/>
</div>
</t>
</t>
<t t-name="web.FormEmailField" t-inherit="web.EmailField" t-inherit-mode="primary">
<xpath expr="//input" position="after">
<a
t-if="props.value"
t-att-href="'mailto:'+props.value"
class="ms-3 d-inline-flex align-items-center"
>
<i class="fa fa-envelope" data-tooltip="Send Email" aria-label="Send Email"></i>
</a>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,303 @@
/** @odoo-module **/
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import {
archParseBoolean,
evalDomain,
getClassNameFromDecoration,
X2M_TYPES,
} from "@web/views/utils";
import { getTooltipInfo } from "./field_tooltip";
import { Component, xml } from "@odoo/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.isInEdition;
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),
};
if (this.FieldComponent.additionalClasses) {
for (const cls of this.FieldComponent.additionalClasses) {
classNames[cls] = true;
}
}
// 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[getClassNameFromDecoration(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 readonly = this.props.readonly === true;
const modifiers = fieldInfo.modifiers || {};
const readonlyFromModifiers = evalDomain(modifiers.readonly, evalContext);
// Decoration props
const decorationMap = {};
const { decorations } = fieldInfo;
for (const decoName in decorations) {
const value = evaluateExpr(decorations[decoName], evalContext);
decorationMap[decoName] = value;
}
let propsFromAttrs = fieldInfo.propsFromAttrs;
if (this.props.attrs) {
const extractProps = this.FieldComponent.extractProps || (() => ({}));
propsFromAttrs = extractProps({
field,
attrs: {
...this.props.attrs,
options: evaluateExpr(this.props.attrs.options || "{}"),
},
});
}
const props = { ...this.props };
delete props.style;
delete props.class;
delete props.showTooltip;
delete props.fieldInfo;
delete props.attrs;
delete props.readonly;
return {
...fieldInfo.props,
update: async (value, options = {}) => {
const { save } = Object.assign({ save: false }, options);
await record.update({ [this.props.name]: value });
if (record.selected && record.model.multiEdit) {
return;
}
const rootRecord =
record.model.root instanceof record.constructor && record.model.root;
const isInEdition = rootRecord ? rootRecord.isInEdition : record.isInEdition;
if ((!isInEdition && !readonlyFromModifiers) || save) {
// TODO: maybe move this in the model
return record.save();
}
},
value: this.props.record.data[this.props.name],
decorations: decorationMap,
readonly: readonly || !record.isInEdition || 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 = "web.Field";
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 modifiers = JSON.parse(node.getAttribute("modifiers") || "{}");
const fieldInfo = {
name,
viewType,
context: node.getAttribute("context") || "{}",
string: node.getAttribute("string") || field.string,
help: node.getAttribute("help"),
widget,
modifiers,
onChange: archParseBoolean(node.getAttribute("on_change")),
FieldComponent: getFieldClassFromRegistry(fields[name].type, widget, viewType, jsClass),
forceSave: archParseBoolean(node.getAttribute("force_save")),
decorations: {}, // populated below
noLabel: archParseBoolean(node.getAttribute("nolabel")),
props: {},
rawAttrs: {},
options: evaluateExpr(node.getAttribute("options") || "{}"),
alwaysInvisible: modifiers.invisible === true || modifiers.column_invisible === true,
};
if (node.getAttribute("domain")) {
fieldInfo.domain = node.getAttribute("domain");
}
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;
}
if (!attribute.name.startsWith("t-att")) {
fieldInfo.rawAttrs[attribute.name] = attribute.value;
}
}
if (viewType !== "kanban") {
// FIXME WOWL: find a better solution
const extractProps = fieldInfo.FieldComponent.extractProps || (() => ({}));
fieldInfo.propsFromAttrs = extractProps({
field,
attrs: { ...fieldInfo.rawAttrs, options: fieldInfo.options },
});
}
if (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];
}
let viewMode = node.getAttribute("mode");
if (!viewMode) {
if (views.list && !views.kanban) {
viewMode = "list";
} else if (!views.list && views.kanban) {
viewMode = "kanban";
} else if (views.list && views.kanban) {
viewMode = "list,kanban";
}
} else {
viewMode = viewMode.replace("tree", "list");
}
fieldInfo.viewMode = viewMode;
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: {}, setDirty: () => {} };

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="web.Field" owl="1">
<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>
</t>
</templates>

View file

@ -0,0 +1,30 @@
/** @odoo-module **/
export function getTooltipInfo(params) {
let widgetDescription = undefined;
if (params.fieldInfo.widget) {
widgetDescription = params.fieldInfo.FieldComponent.displayName;
}
const info = {
viewMode: params.viewMode,
resModel: params.resModel,
debug: Boolean(odoo.debug),
field: {
label: params.field.string,
name: params.field.name,
help: params.fieldInfo.help !== null ? params.fieldInfo.help : params.field.help,
type: params.field.type,
widget: params.fieldInfo.widget,
widgetDescription,
context: params.fieldInfo.context,
domain: params.fieldInfo.domain || params.field.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);
}

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FieldTooltip" owl="1">
<p t-if="field.help" class="o-tooltip--help" role="tooltip">
<t t-esc="field.help"/>
</p>
<ul class="o-tooltip--technical" t-if="debug" role="tooltip">
<li data-item="field" t-if="field and field.name">
<span class="o-tooltip--technical--title">Field:</span>
<t t-esc="field.name"/>
</li>
<li data-item="object" t-if="resModel">
<span class="o-tooltip--technical--title">Model:</span>
<t t-esc="resModel"/>
</li>
<t t-if="field">
<li t-if="field.type" data-item="type" >
<span class="o-tooltip--technical--title">Type:</span>
<t t-esc="field.type"/>
</li>
<li t-if="field.widget" data-item="widget">
<span class="o-tooltip--technical--title">Widget:</span>
<t t-if="field.widgetDescription" t-esc="field.widgetDescription"/>
<t t-if="field.widget">
(<t t-esc="field.widget"/>)
</t>
</li>
<li t-if="field.context" data-item="context">
<span class="o-tooltip--technical--title">Context:</span>
<t t-esc="field.context"/>
</li>
<li t-if="field.domain" data-item="domain">
<span class="o-tooltip--technical--title">Domain:</span>
<t t-esc="field.domain.length === 0 ? '[]' : field.domain"/>
</li>
<li t-if="field.modifiers" data-item="modifiers">
<span class="o-tooltip--technical--title">Modifiers:</span>
<t t-esc="field.modifiers"/>
</li>
<li t-if="field.default" data-item="default">
<span class="o-tooltip--technical--title">Default:</span>
<t t-esc="field.default"/>
</li>
<li t-if="field.changeDefault" data-item="changeDefault">
<span class="o-tooltip--technical--title">Change default:</span>
Yes
</li>
<li t-if="field.relation" data-item="relation">
<span class="o-tooltip--technical--title">Relation:</span>
<t t-esc="field.relation"/>
</li>
<li t-if="field.selection" data-item="selection">
<span class="o-tooltip--technical--title">Selection:</span>
<ul class="o-tooltip--technical">
<li t-foreach="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>

View file

@ -0,0 +1,20 @@
.o_field_cursor_disabled {
cursor: not-allowed;
}
// .o_field_highlight is used in several types of view to force fields
// to be displayed with a bottom border even when not hovered (e.g. added
// by mobile detection, in several specific places such as spreadsheet or
// knowledge sidebars, settings view, kanban quick create (not a form view), etc.)
.o_field_highlight .o_field_widget .o_input, .o_field_highlight.o_field_widget .o_input {
border-color: var(--o-input-border-color);
}
// field decoration classes (e.g. text-danger) are set on the field root node,
// but bootstrap contextual classes do not work on input/textarea, so we have to
// explicitely set their color to the one of their parent
.o_field_widget {
input, textarea, select {
color: inherit;
}
}

View file

@ -0,0 +1,64 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { checkFileSize } from "@web/core/utils/files";
import { getDataURLFromFile } from "@web/core/utils/urls";
import { Component, useRef, useState } from "@odoo/owl";
export class FileUploader extends Component {
setup() {
this.notification = useService("notification");
this.fileInputRef = useRef("fileInput");
this.state = useState({
isUploading: false,
});
}
/**
* @param {Event} ev
*/
async onFileChange(ev) {
if (!ev.target.files.length) {
return;
}
const { target } = ev;
for (const file of ev.target.files) {
if (!checkFileSize(file.size, this.notification)) {
return null;
}
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",
}
);
}
try {
await 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,
});
} finally {
this.state.isUploading = false;
}
}
target.value = null;
if (this.props.multiUpload && this.props.onUploadComplete) {
this.props.onUploadComplete({});
}
}
onSelectFileButtonClick() {
this.fileInputRef.el.click();
}
}
FileUploader.template = "web.FileUploader";

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.FileUploader" owl="1">
<t t-if="state.isUploading">Uploading...</t>
<span t-else="" t-on-click.prevent="onSelectFileButtonClick" style="display:contents">
<t t-slot="toggler"/>
</span>
<t t-slot="default"/>
<input
type="file"
t-att-name="props.inputName"
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"
/>
</t>
</templates>

View file

@ -0,0 +1,70 @@
/** @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";
import { Component } from "@odoo/owl";
export class FloatField extends Component {
setup() {
this.inputRef = useInputField({
getValue: () => this.formattedValue,
refName: "numpadDecimal",
parse: (v) => this.parse(v),
});
useNumpadDecimal();
}
parse(value) {
return this.props.inputType === "number" ? Number(value) : parseFloat(value);
}
get formattedValue() {
if (this.props.inputType === "number" && !this.props.readonly && this.props.value) {
return this.props.value;
}
return formatFloat(this.props.value, { digits: this.props.digits });
}
}
FloatField.template = "web.FloatField";
FloatField.props = {
...standardFieldProps,
inputType: { type: String, optional: true },
step: { type: Number, optional: true },
digits: { type: Array, optional: true },
placeholder: { type: String, optional: true },
};
FloatField.defaultProps = {
inputType: "text",
};
FloatField.displayName = _lt("Float");
FloatField.supportedTypes = ["float"];
FloatField.isEmpty = () => false;
FloatField.extractProps = ({ attrs, field }) => {
// Sadly, digits param was available as an option and an attr.
// The option version could be removed with some xml refactoring.
let digits;
if (attrs.digits) {
digits = JSON.parse(attrs.digits);
} else if (attrs.options.digits) {
digits = attrs.options.digits;
} else if (Array.isArray(field.digits)) {
digits = field.digits;
}
return {
inputType: attrs.options.type,
step: attrs.options.step,
digits,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("float", FloatField);

View file

@ -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="formattedValue" />
<input t-else="" t-att-id="props.id" t-ref="numpadDecimal" autocomplete="off" t-att-placeholder="props.placeholder" t-att-type="props.inputType" inputmode="decimal" class="o_input" t-att-step="props.step" />
</t>
</templates>

View file

@ -0,0 +1,44 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { FloatField } from "../float/float_field";
import { Component } from "@odoo/owl";
export class FloatFactorField extends Component {
get factor() {
return this.props.factor;
}
get floatFieldProps() {
const result = {
...this.props,
value: this.props.value * this.factor,
update: (value) => this.props.update(value / this.factor),
};
delete result.factor;
return result;
}
}
FloatFactorField.template = "web.FloatFactorField";
FloatFactorField.components = { FloatField };
FloatFactorField.props = {
...FloatField.props,
factor: { type: Number, optional: true },
};
FloatFactorField.defaultProps = {
...FloatField.defaultProps,
factor: 1,
};
FloatFactorField.supportedTypes = ["float"];
FloatFactorField.isEmpty = () => false;
FloatFactorField.extractProps = ({ attrs, field }) => {
return {
...FloatField.extractProps({ attrs, field }),
factor: attrs.options.factor,
};
};
registry.category("fields").add("float_factor", FloatFactorField);

View file

@ -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>

View file

@ -0,0 +1,49 @@
/** @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";
import { Component } from "@odoo/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,
inputType: { type: String, optional: true },
placeholder: { type: String, optional: true },
};
FloatTimeField.defaultProps = {
inputType: "text",
};
FloatTimeField.displayName = _lt("Time");
FloatTimeField.supportedTypes = ["float"];
FloatTimeField.isEmpty = () => false;
FloatTimeField.extractProps = ({ attrs }) => {
return {
inputType: attrs.options.type,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("float_time", FloatTimeField);

View file

@ -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-att-type="props.inputType" t-ref="numpadDecimal" t-att-placeholder="props.placeholder" class="o_input" autocomplete="off" />
</t>
</templates>

View file

@ -0,0 +1,67 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { formatFloat } from "../formatters";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/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.factor);
currentIndex++;
if (currentIndex > this.props.range.length - 1) {
currentIndex = 0;
}
this.props.update(this.props.range[currentIndex] / this.factor);
}
// This property has been created in order to allow overrides in other modules.
get factor() {
return this.props.factor;
}
get formattedValue() {
return formatFloat(this.props.value * this.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 = ({ attrs, field }) => {
let digits;
if (attrs.digits) {
digits = JSON.parse(attrs.digits);
} else if (attrs.options.digits) {
digits = attrs.options.digits;
} else if (Array.isArray(field.digits)) {
digits = field.digits;
}
return {
digits,
range: attrs.options.range,
factor: attrs.options.factor,
disableReadOnly: attrs.options.force_button || false,
};
};
registry.category("fields").add("float_toggle", FloatToggleField);

View file

@ -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>

View file

@ -0,0 +1,52 @@
/** @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";
import { Component } from "@odoo/owl";
export class FontSelectionField extends Component {
get options() {
return this.props.record.fields[this.props.name].selection.filter(
(option) => option[0] !== false && option[1] !== ""
);
}
get isRequired() {
return this.props.record.isRequired(this.props.name);
}
get string() {
return formatSelection(this.props.value, { selection: this.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,
placeholder: { type: String, optional: true },
};
FontSelectionField.displayName = _lt("Font Selection");
FontSelectionField.supportedTypes = ["selection"];
FontSelectionField.legacySpecialData = "_fetchSpecialRelation";
FontSelectionField.extractProps = ({ attrs }) => {
return {
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("font", FontSelectionField);

View file

@ -0,0 +1,28 @@
<?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 }};">
<option
t-att-selected="false === value"
t-att-value="stringify(false)"
t-esc="this.props.placeholder || ''"
t-attf-style="{{ isRequired ? 'display:none' : '' }}"
/>
<t t-foreach="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>

View file

@ -0,0 +1,409 @@
/** @odoo-module **/
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
import { localization as l10n } from "@web/core/l10n/localization";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { escape, nbsp, sprintf } from "@web/core/utils/strings";
import { isBinarySize } from "@web/core/utils/binary";
import { session } from "@web/session";
import { humanNumber, insertThousandsSep } from "@web/core/utils/numbers";
import { markup } from "@odoo/owl";
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
function humanSize(value) {
if (!value) {
return "";
}
const suffix = value < 1024 ? " " + _t("Bytes") : "b";
return (
humanNumber(value, {
decimals: 2,
}) + suffix
);
}
// -----------------------------------------------------------------------------
// Exports
// -----------------------------------------------------------------------------
/**
* @param {string} [value] base64 representation of the binary
* @returns {string}
*/
export function formatBinary(value) {
if (!isBinarySize(value)) {
// Computing approximate size out of base64 encoded string
// http://en.wikipedia.org/wiki/Base64#MIME
return humanSize(value.length / 1.37);
}
// already bin_size
return value;
}
/**
* @param {boolean} value
* @returns {string}
*/
export function formatBoolean(value) {
return markup(`
<div class="o-checkbox d-inline-block me-2">
<input id="boolean_checkbox" type="checkbox" class="form-check-input" disabled ${
value ? "checked" : ""
}/>
<label for="boolean_checkbox" class="form-check-label"/>
</div>`);
}
/**
* Returns a string representing a char. If the value is false, then we return
* an empty string.
*
* @param {string|false} value
* @param {Object} [options] additional options
* @param {boolean} [options.escape=false] if true, escapes the formatted value
* @param {boolean} [options.isPassword=false] if true, returns '********'
* instead of the formatted value
* @returns {string}
*/
export function formatChar(value, options) {
value = typeof value === "string" ? value : "";
if (options && options.isPassword) {
return "*".repeat(value ? value.length : 0);
}
if (options && options.escape) {
value = escape(value);
}
return value;
}
/**
* Returns a string representing a float. The result takes into account the
* user settings (to display the correct decimal separator).
*
* @param {number | false} value the value that should be formatted
* @param {Object} [options]
* @param {number[]} [options.digits] the number of digits that should be used,
* instead of the default digits precision in the field.
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
* @param {string} [options.decimalPoint] decimal separating character
* @param {string} [options.thousandsSep] thousands separator to insert
* @param {number[]} [options.grouping] array of relative offsets at which to
* insert `thousandsSep`. See `insertThousandsSep` method.
* @param {boolean} [options.noTrailingZeros=false] if true, the decimal part
* won't contain unnecessary trailing zeros.
* @returns {string}
*/
export function formatFloat(value, options = {}) {
if (value === false) {
return "";
}
if (options.humanReadable) {
return humanNumber(value, options);
}
const grouping = options.grouping || l10n.grouping;
const thousandsSep = "thousandsSep" in options ? options.thousandsSep : l10n.thousandsSep;
const decimalPoint = "decimalPoint" in options ? options.decimalPoint : l10n.decimalPoint;
let precision;
if (options.digits && options.digits[1] !== undefined) {
precision = options.digits[1];
} else {
precision = 2;
}
const formatted = (value || 0).toFixed(precision).split(".");
formatted[0] = insertThousandsSep(formatted[0], thousandsSep, grouping);
if (options.noTrailingZeros) {
formatted[1] = formatted[1].replace(/0+$/, "");
}
return formatted[1] ? formatted.join(decimalPoint) : formatted[0];
}
/**
* Returns a string representing a float value, from a float converted with a
* factor.
*
* @param {number | false} value
* @param {Object} [options]
* @param {number} [options.factor=1.0] conversion factor
* @returns {string}
*/
export function formatFloatFactor(value, options = {}) {
if (value === false) {
return "";
}
const factor = options.factor || 1;
return formatFloat(value * factor, options);
}
/**
* Returns a string representing a time value, from a float. The idea is that
* we sometimes want to display something like 1:45 instead of 1.75, or 0:15
* instead of 0.25.
*
* @param {number | false} value
* @param {Object} [options]
* @param {boolean} [options.noLeadingZeroHour] if true, format like 1:30 otherwise, format like 01:30
* @param {boolean} [options.displaySeconds] if true, format like ?1:30:00 otherwise, format like ?1:30
* @returns {string}
*/
export function formatFloatTime(value, options = {}) {
if (value === false) {
return "";
}
const isNegative = value < 0;
value = Math.abs(value);
let hour = Math.floor(value);
const milliSecLeft = Math.round(value * 3600000) - hour * 3600000;
// Although looking quite overkill, the following lines ensures that we do
// not have float issues while still considering that 59s is 00:00.
let min = milliSecLeft / 60000;
if (options.displaySeconds) {
min = Math.floor(min);
} else {
min = Math.round(min);
}
if (min === 60) {
min = 0;
hour = hour + 1;
}
min = String(min).padStart(2, "0");
if (!options.noLeadingZeroHour) {
hour = String(hour).padStart(2, "0");
}
let sec = "";
if (options.displaySeconds) {
sec = ":" + String(Math.floor((milliSecLeft % 60000) / 1000)).padStart(2, "0");
}
return `${isNegative ? "-" : ""}${hour}:${min}${sec}`;
}
/**
* Returns a string representing an integer. If the value is false, then we
* return an empty string.
*
* @param {number | false | null} value
* @param {Object} [options]
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
* @param {boolean} [options.isPassword=false] if returns true, acts like
* @param {string} [options.thousandsSep] thousands separator to insert
* @param {number[]} [options.grouping] array of relative offsets at which to
* insert `thousandsSep`. See `insertThousandsSep` method.
* @returns {string}
*/
export function formatInteger(value, options = {}) {
if (value === false || value === null) {
return "";
}
if (options.isPassword) {
return "*".repeat(value.length);
}
if (options.humanReadable) {
return humanNumber(value, options);
}
const grouping = options.grouping || l10n.grouping;
const thousandsSep = "thousandsSep" in options ? options.thousandsSep : l10n.thousandsSep;
return insertThousandsSep(value.toFixed(0), thousandsSep, grouping);
}
/**
* Returns a string representing a many2one value. The value is expected to be
* either `false` or an array in the form [id, display_name]. The returned
* value will then be the display name of the given value, or an empty string
* if the value is false.
*
* @param {[number, string] | false} value
* @param {Object} [options] additional options
* @param {boolean} [options.escape=false] if true, escapes the formatted value
* @returns {string}
*/
export function formatMany2one(value, options) {
if (!value) {
value = "";
} else {
value = value[1] || "";
}
if (options && options.escape) {
value = encodeURIComponent(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
* the user settings (to display the correct decimal separator, currency, ...).
*
* @param {number | false} value the value that should be formatted
* @param {Object} [options]
* additional options to override the values in the python description of the
* field.
* @param {number} [options.currencyId] the id of the 'res.currency' to use
* @param {string} [options.currencyField] the name of the field whose value is
* the currency id (ignored if options.currency_id).
* Note: if not given it will default to the field "currency_field" value or
* on "currency_id".
* @param {Object} [options.data] a mapping of field names to field values,
* required with options.currencyField
* @param {boolean} [options.noSymbol] this currency has not a sympbol
* @param {boolean} [options.humanReadable] if true, large numbers are formatted
* to a human readable format.
* @param {[number, number]} [options.digits] the number of digits that should
* be used, instead of the default digits precision in the field. The first
* number is always ignored (legacy constraint)
* @returns {string}
*/
export function formatMonetary(value, options = {}) {
// Monetary fields want to display nothing when the value is unset.
// You wouldn't want a value of 0 euro if nothing has been provided.
if (value === false) {
return "";
}
let currencyId = options.currencyId;
if (!currencyId && options.data) {
const currencyField =
options.currencyField ||
(options.field && options.field.currency_field) ||
"currency_id";
const dataValue = options.data[currencyField];
currencyId = Array.isArray(dataValue) ? dataValue[0] : dataValue;
}
const currency = session.currencies[currencyId];
const digits = options.digits || (currency && currency.digits);
let formattedValue;
if (options.humanReadable) {
formattedValue = humanNumber(value, { decimals: digits ? digits[1] : 2 });
} else {
formattedValue = formatFloat(value, { digits });
}
if (!currency || options.noSymbol) {
return formattedValue;
}
const formatted = [currency.symbol, formattedValue];
if (currency.position === "after") {
formatted.reverse();
}
return formatted.join(nbsp);
}
/**
* Returns a string representing the given value (multiplied by 100)
* concatenated with '%'.
*
* @param {number | false} value
* @param {Object} [options]
* @param {boolean} [options.noSymbol] if true, doesn't concatenate with "%"
* @returns {string}
*/
export function formatPercentage(value, options = {}) {
value = value || 0;
options = Object.assign({ noTrailingZeros: true, thousandsSep: "" }, options);
const formatted = formatFloat(value * 100, options);
return `${formatted}${options.noSymbol ? "" : "%"}`;
}
/**
* Returns a string representing the value of the python properties field
* or a properties definition field (see fields.py@Properties).
*
* @param {array|false} value
* @param {Object} [field]
* a description of the field (note: this parameter is ignored)
*/
function formatProperties(value, field) {
if (!value || !value.length) {
return "";
}
return value.map((property) => property["string"]).join(", ");
}
/**
* 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 || "";
}
export function formatJson(value) {
return (value && JSON.stringify(value)) || "";
}
registry
.category("formatters")
.add("binary", formatBinary)
.add("boolean", formatBoolean)
.add("char", formatChar)
.add("date", formatDate)
.add("datetime", formatDateTime)
.add("float", formatFloat)
.add("float_factor", formatFloatFactor)
.add("float_time", formatFloatTime)
.add("html", (value) => value)
.add("integer", formatInteger)
.add("json", formatJson)
.add("many2one", formatMany2one)
.add("many2one_reference", formatInteger)
.add("one2many", formatX2many)
.add("many2many", formatX2many)
.add("monetary", formatMonetary)
.add("percentage", formatPercentage)
.add("properties", formatProperties)
.add("properties_definition", formatProperties)
.add("reference", formatReference)
.add("selection", formatSelection)
.add("text", formatText);

View file

@ -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";
import { Component } from "@odoo/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);

View file

@ -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>

View file

@ -0,0 +1,10 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { TextField } from "../text/text_field";
export class HtmlField extends TextField {}
HtmlField.template = "web.HtmlField";
registry.category("fields").add("html", HtmlField);

View file

@ -0,0 +1,3 @@
.o_field_widget.o_field_html {
display: block;
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.HtmlField" owl="1">
<t t-if="props.readonly">
<span t-out="props.value or ''" />
</t>
<t t-else="" t-call="web.TextField" />
</t>
</templates>

View file

@ -0,0 +1,42 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component, useEffect, useRef } from "@odoo/owl";
export class IframeWrapperField extends Component {
setup() {
this.iframeRef = useRef("iframe");
useEffect(
(value) => {
/**
* The document.write is not recommended. It is better to manipulate the DOM through $.appendChild and
* others. In our case though, we deal with an iframe without src attribute and with metadata to put in
* head tag. If we use the usual dom methods, the iframe is automatically created with its document
* component containing html > head & body. Therefore, if we want to make it work that way, we would
* need to receive each piece at a time to append it to this document (with this.record.data and extra
* model fields or with an rpc). It also cause other difficulties getting attribute on the most parent
* nodes, parsing to HTML complex elements, etc.
* Therefore, document.write makes it much more trivial in our situation.
*/
const iframeDoc = this.iframeRef.el.contentDocument;
iframeDoc.open();
iframeDoc.write(value);
iframeDoc.close();
},
() => [this.props.value]
);
}
}
IframeWrapperField.template = "web.IframeWrapperField";
IframeWrapperField.props = {
...standardFieldProps,
};
IframeWrapperField.displayName = _lt("Wrap raw html within an iframe");
// If HTML, don't forget to adjust the sanitize options to avoid stripping most of the metadata
IframeWrapperField.supportedTypes = ["text", "html"];
registry.category("fields").add("iframe_wrapper", IframeWrapperField);

View file

@ -0,0 +1,43 @@
$preview_height: 1123 + 32;
$preview_width: 794;
$preview_scale: 0.50;
@mixin o_preview_iframe_styling($scale) {
.o_preview_iframe_wrapper {
padding: 0;
overflow: hidden;
width: ($preview_width * $scale) + 0px;
height: ($preview_height * $scale) + 0px;
position: relative;
}
.o_preview_iframe {
width: $preview_width + 0px;
height: $preview_height + 0px;
border: 2px solid lightgrey;
overflow: hidden;
padding-top: 16px;
padding-bottom: 16px;
-ms-zoom: 0.5;
-moz-transform: scale($scale);
-moz-transform-origin: 0 0;
-o-transform: scale($scale);
-o-transform-origin: 0 0;
-webkit-transform: scale($scale);
-webkit-transform-origin: 0 0;
}
}
@include o_preview_iframe_styling($preview_scale)
@media (max-width: 1488px) {
@include o_preview_iframe_styling($preview_scale * 0.80)
}
@media (max-width: 600px) {
@include o_preview_iframe_styling($preview_scale * 0.60)
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.IframeWrapperField" owl="1">
<div class="o_preview_iframe_wrapper">
<iframe src="about:blank" class="o_preview_iframe" t-ref="iframe"/>
</div>
</t>
</templates>

View file

@ -0,0 +1,173 @@
/** @odoo-module **/
import { isMobileOS } from "@web/core/browser/feature_detection";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { url } from "@web/core/utils/urls";
import { isBinarySize } from "@web/core/utils/binary";
import { FileUploader } from "../file_handler";
import { standardFieldProps } from "../standard_field_props";
import { Component, useState, onWillRender } from "@odoo/owl";
const { DateTime } = luxon;
export const fileTypeMagicWordMap = {
"/": "jpg",
R: "gif",
i: "png",
P: "svg+xml",
};
const placeholder = "/web/static/img/placeholder.png";
/**
* Formats a value to be injected in the image's url in order for that url
* to be correctly cached and discarded by the browser (the browser caches
* fetch requests with the url as key).
*
* For records, a not-so-bad approximation is to compute that key on the basis
* of the record's __last_update field.
*/
export function imageCacheKey(value) {
if (value instanceof DateTime) {
return value.ts;
}
return "";
}
export class ImageField extends Component {
setup() {
this.notification = useService("notification");
this.isMobile = isMobileOS();
this.state = useState({
isValid: true,
});
this.lastURL = undefined;
if (this.props.record.fields[this.props.name].related) {
this.lastUpdate = DateTime.now();
let key = this.props.value;
onWillRender(() => {
const nextKey = this.props.value;
if (key !== nextKey) {
this.lastUpdate = DateTime.now();
}
key = nextKey;
});
}
}
get rawCacheKey() {
if (this.props.record.fields[this.props.name].related) {
return this.lastUpdate;
}
return this.props.record.data.__last_update;
}
get sizeStyle() {
let style = "";
if (this.props.width) {
style += `max-width: ${this.props.width}px;`;
if (!this.props.height) {
style += `height: auto; max-height: 100%;`;
}
}
if (this.props.height) {
style += `max-height: ${this.props.height}px;`;
if (!this.props.width) {
style += `width: auto; max-width: 100%;`;
}
}
return style;
}
get hasTooltip() {
return this.props.enableZoom && this.props.value;
}
get tooltipAttributes() {
return {
template: "web.ImageZoomTooltip",
info: JSON.stringify({ url: this.getUrl(this.props.name) }),
};
}
getUrl(previewFieldName) {
if (this.props.noReload && this.lastURL) {
return this.lastURL;
}
if (this.state.isValid && this.props.value) {
if (isBinarySize(this.props.value)) {
this.lastURL = url("/web/image", {
model: this.props.record.resModel,
id: this.props.record.resId,
field: previewFieldName,
unique: imageCacheKey(this.rawCacheKey),
});
} else {
// Use magic-word technique for detecting image type
const magic = fileTypeMagicWordMap[this.props.value[0]] || "png";
this.lastURL = `data:image/${magic};base64,${this.props.value}`;
}
return this.lastURL;
}
return placeholder;
}
onFileRemove() {
this.state.isValid = true;
this.props.update(false);
}
onFileUploaded(info) {
this.state.isValid = true;
this.props.update(info.data);
}
onLoadFailed() {
this.state.isValid = false;
}
}
ImageField.template = "web.ImageField";
ImageField.components = {
FileUploader,
};
ImageField.props = {
...standardFieldProps,
enableZoom: { type: Boolean, optional: true },
zoomDelay: { type: Number, optional: true },
previewImage: { type: String, optional: true },
acceptedFileExtensions: { type: String, optional: true },
width: { type: Number, optional: true },
height: { type: Number, optional: true },
noReload: { type: Boolean, optional: true },
};
ImageField.defaultProps = {
acceptedFileExtensions: "image/*",
};
ImageField.displayName = _lt("Image");
ImageField.supportedTypes = ["binary"];
ImageField.fieldDependencies = {
__last_update: { type: "datetime" },
};
ImageField.extractProps = ({ attrs }) => {
return {
enableZoom: attrs.options.zoom,
zoomDelay: attrs.options.zoom_delay,
previewImage: attrs.options.preview_image,
acceptedFileExtensions: attrs.options.accepted_file_extensions,
width:
attrs.options.size && Boolean(attrs.options.size[0])
? attrs.options.size[0]
: attrs.width,
height:
attrs.options.size && Boolean(attrs.options.size[1])
? attrs.options.size[1]
: attrs.height,
noReload: Boolean(attrs.options.no_reload),
};
};
registry.category("fields").add("image", ImageField);
registry.category("fields").add("kanban.image", ImageField); // FIXME WOWL: s.t. we don't use the legacy one

View file

@ -0,0 +1,28 @@
.o_field_image {
background-color: var(--ImageField-background-color, transparent);
button {
transition: opacity ease 400ms;
width: 26px;
height: 26px;
}
.o_mobile_controls {
button {
width: 30px;
height: 30px;
padding: 6px !important;
}
}
&.o_field_invalid img {
border: 1px solid map-get($theme-colors, 'danger');
}
}
.o_image_zoom {
img {
max-width: 100%;
max-height: 50vh;
}
}

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ImageField" owl="1">
<div class="d-inline-block position-relative opacity-trigger-hover">
<div t-attf-class="position-absolute d-flex justify-content-between w-100 bottom-0 opacity-0 opacity-100-hover {{isMobile ? 'o_mobile_controls' : ''}}" aria-atomic="true" t-att-style="sizeStyle">
<t t-if="!props.readonly">
<FileUploader
acceptedFileExtensions="props.acceptedFileExtensions"
t-key="props.record.resId"
onUploaded.bind="onFileUploaded"
type="'image'"
>
<t t-set-slot="toggler">
<button
class="o_select_file_button btn btn-light border-0 rounded-circle m-1 p-1"
data-tooltip="Edit"
aria-label="Edit">
<i class="fa fa-pencil fa-fw"/>
</button>
</t>
<button
t-if="props.value and state.isValid"
class="o_clear_file_button btn btn-light border-0 rounded-circle m-1 p-1"
data-tooltip="Clear"
aria-label="Clear"
t-on-click="onFileRemove">
<i class="fa fa-trash-o fa-fw"/>
</button>
</FileUploader>
</t>
</div>
<img
class="img img-fluid"
alt="Binary file"
t-att-src="this.getUrl(props.previewImage or props.name)"
t-att-name="props.name"
t-att-height="props.height"
t-att-width="props.width"
t-att-style="sizeStyle"
t-on-error.stop="onLoadFailed"
t-att-data-tooltip-template="hasTooltip and tooltipAttributes.template"
t-att-data-tooltip-info="hasTooltip and tooltipAttributes.info"
t-att-data-tooltip-delay="hasTooltip and props.zoomDelay"
/>
</div>
</t>
<t t-name="web.ImageZoomTooltip" owl="1">
<div class="o_image_zoom">
<img t-att-src="url" />
</div>
</t>
</templates>

View file

@ -0,0 +1,61 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillUpdateProps, useState } from "@odoo/owl";
export class ImageUrlField extends Component {
setup() {
this.notification = useService("notification");
this.state = useState({
src: this.props.value,
});
onWillUpdateProps((nextProps) => {
if (this.props.value !== nextProps.value) {
this.state.src = nextProps.value;
}
});
}
get sizeStyle() {
let style = "";
if (this.props.width) {
style += `max-width: ${this.props.width}px;`;
}
if (this.props.height) {
style += `max-height: ${this.props.height}px;`;
}
return style;
}
onLoadFailed() {
this.state.src = this.constructor.fallbackSrc;
}
}
ImageUrlField.fallbackSrc = "/web/static/img/placeholder.png";
ImageUrlField.template = "web.ImageUrlField";
ImageUrlField.props = {
...standardFieldProps,
width: { type: Number, optional: true },
height: { type: Number, optional: true },
};
ImageUrlField.displayName = _lt("Image");
ImageUrlField.supportedTypes = ["char"];
ImageUrlField.extractProps = ({ attrs }) => {
return {
width: attrs.options.size ? attrs.options.size[0] : attrs.width,
height: attrs.options.size ? attrs.options.size[1] : attrs.height,
};
};
registry.category("fields").add("image_url", ImageUrlField);
// TODO WOWL: remove below when old registry is removed.
registry.category("fields").add("kanban.image_url", ImageUrlField);

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.ImageUrlField" owl="1">
<img
t-if="props.value"
class="img img-fluid"
alt="Image"
t-att-src="state.src"
t-att-border="props.readonly ? 0 : 1"
t-att-name="props.name"
t-att-height="props.height"
t-att-width="props.width"
t-att-style="sizeStyle"
t-on-error="onLoadFailed"
/>
</t>
</templates>

View file

@ -0,0 +1,177 @@
/** @odoo-module **/
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { useBus } from "@web/core/utils/hooks";
import { useComponent, useEffect, useRef, useEnv } from "@odoo/owl";
/**
* This hook is meant to be used by field components that use an input or
* textarea to edit their value. Its purpose is to prevent that value from being
* erased by an update of the model (typically coming from an onchange) when the
* user is currently editing it.
*
* @param {() => string} getValue a function that returns the value to write in
* the input, if the user isn't currently editing it
* @param {string} [refName="input"] the ref of the input/textarea
*/
export function useInputField(params) {
const env = useEnv();
const inputRef = params.ref || useRef(params.refName || "input");
const component = useComponent();
/*
* A field is dirty if it is no longer sync with the model
* More specifically, a field is no longer dirty after it has *tried* to update the value in the model.
* An invalid value will thefore not be dirty even if the model will not actually store the invalid value.
*/
let isDirty = false;
/**
* The last value that has been commited to the model.
* Not changed in case of invalid field value.
*/
let lastSetValue = null;
/**
* Track the fact that there is a change sent to the model that hasn't been acknowledged yet
* (e.g. because the onchange is still pending). This is necessary if we must do an urgent save,
* as we have to re-send that change for the write that will be done directly.
* FIXME: this could/should be handled by the model itself, when it will be rewritten
*/
let pendingUpdate = false;
/**
* When a user types, we need to set the field as dirty.
*/
function onInput(ev) {
isDirty = ev.target.value !== lastSetValue;
if (component.props.setDirty) {
component.props.setDirty(isDirty);
}
if (component.props.record && !component.props.record.isValid) {
component.props.record.resetFieldValidity(component.props.name);
}
}
/**
* On blur, we consider the field no longer dirty, even if it were to be invalid.
* However, if the field is invalid, the new value will not be committed to the model.
*/
function onChange(ev) {
if (isDirty) {
isDirty = false;
let isInvalid = false;
let val = ev.target.value;
if (params.parse) {
try {
val = params.parse(val);
} catch (_e) {
if (component.props.record) {
component.props.record.setInvalidField(component.props.name);
}
isInvalid = true;
}
}
if (!isInvalid) {
pendingUpdate = true;
Promise.resolve(component.props.update(val)).then(() => {
pendingUpdate = false;
});
lastSetValue = ev.target.value;
}
if (component.props.setDirty) {
component.props.setDirty(isDirty);
}
}
}
function onKeydown(ev) {
const hotkey = getActiveHotkey(ev);
if (["enter", "tab", "shift+tab"].includes(hotkey)) {
commitChanges(false);
}
}
useEffect(
(inputEl) => {
if (inputEl) {
inputEl.addEventListener("input", onInput);
inputEl.addEventListener("change", onChange);
inputEl.addEventListener("keydown", onKeydown);
return () => {
inputEl.removeEventListener("input", onInput);
inputEl.removeEventListener("change", onChange);
inputEl.removeEventListener("keydown", onKeydown);
};
}
},
() => [inputRef.el]
);
/**
* Sometimes, a patch can happen with possible a new value for the field
* If the user was typing a new value (isDirty) or the field is still invalid,
* we need to do nothing.
* If it is not such a case, we update the field with the new value.
*/
useEffect(() => {
const isInvalid = component.props.record
? component.props.record.isInvalid(component.props.name)
: false;
if (inputRef.el && !isDirty && !isInvalid) {
inputRef.el.value = params.getValue();
lastSetValue = inputRef.el.value;
}
});
useBus(env.bus, "RELATIONAL_MODEL:WILL_SAVE_URGENTLY", () => commitChanges(true));
useBus(env.bus, "RELATIONAL_MODEL:NEED_LOCAL_CHANGES", (ev) =>
ev.detail.proms.push(commitChanges())
);
/**
* Roughly the same as onChange, but called at more specific / critical times. (See bus events)
*/
async function commitChanges(urgent) {
if (!inputRef.el) {
return;
}
isDirty = inputRef.el.value !== lastSetValue;
if (isDirty || (urgent && pendingUpdate)) {
let isInvalid = false;
isDirty = false;
let val = inputRef.el.value;
if (params.parse) {
try {
val = params.parse(val);
} catch (_e) {
isInvalid = true;
if (urgent) {
return;
} else if (component.props.record) {
component.props.record.setInvalidField(component.props.name);
}
}
}
if (isInvalid) {
return;
}
if ((val || false) !== (component.props.value || false)) {
lastSetValue = inputRef.el.value;
await component.props.update(val);
if (component.props.setDirty) {
component.props.setDirty(isDirty);
}
} else {
inputRef.el.value = params.getValue();
}
}
}
return inputRef;
}

View file

@ -0,0 +1,54 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { formatInteger } from "../formatters";
import { parseInteger } from "../parsers";
import { useInputField } from "../input_field_hook";
import { standardFieldProps } from "../standard_field_props";
import { useNumpadDecimal } from "../numpad_decimal_hook";
import { Component } from "@odoo/owl";
export class IntegerField extends Component {
setup() {
useInputField({
getValue: () => this.formattedValue,
refName: "numpadDecimal",
parse: (v) => parseInteger(v),
});
useNumpadDecimal();
}
get formattedValue() {
if (!this.props.readonly && this.props.inputType === "number") {
return this.props.value;
}
return formatInteger(this.props.value);
}
}
IntegerField.template = "web.IntegerField";
IntegerField.props = {
...standardFieldProps,
inputType: { type: String, optional: true },
step: { type: Number, optional: true },
placeholder: { type: String, optional: true },
};
IntegerField.defaultProps = {
inputType: "text",
};
IntegerField.displayName = _lt("Integer");
IntegerField.supportedTypes = ["integer"];
IntegerField.isEmpty = (record, fieldName) => (record.data[fieldName] === false ? true : false);
IntegerField.extractProps = ({ attrs }) => {
return {
inputType: attrs.options.type,
step: attrs.options.step,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("integer", IntegerField);

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.IntegerField" owl="1">
<span t-if="props.readonly" t-esc="formattedValue" />
<input t-ref="numpadDecimal" t-att-id="props.id" t-att-type="props.inputType" t-att-placeholder="props.placeholder" inputmode="numeric" t-else="" class="o_input" t-att-step="props.step" autocomplete="off" />
</t>
</templates>

View file

@ -0,0 +1,162 @@
/** @odoo-module **/
import { loadJS } from "@web/core/assets";
import { registry } from "@web/core/registry";
import { getColor, hexToRGBA } from "@web/views/graph/colors";
import { standardFieldProps } from "../standard_field_props";
import { useService } from "@web/core/utils/hooks";
import { Component, onWillStart, useEffect, useRef } from "@odoo/owl";
export class JournalDashboardGraphField extends Component {
setup() {
this.chart = null;
this.cookies = useService("cookie");
this.canvasRef = useRef("canvas");
this.data = JSON.parse(this.props.value);
onWillStart(() => loadJS("/web/static/lib/Chart/Chart.js"));
useEffect(() => {
this.renderChart();
return () => {
if (this.chart) {
this.chart.destroy();
}
};
});
}
/**
* Instantiates a Chart (Chart.js lib) to render the graph according to
* the current config.
*/
renderChart() {
if (this.chart) {
this.chart.destroy();
}
let config;
if (this.props.graphType === "line") {
config = this.getLineChartConfig();
} else if (this.props.graphType === "bar") {
config = this.getBarChartConfig();
}
this.chart = new Chart(this.canvasRef.el, config);
// To perform its animations, ChartJS will perform each animation
// step in the next animation frame. The initial rendering itself
// is delayed for consistency. We can avoid this by manually
// advancing the animation service.
Chart.animationService.advance();
}
getLineChartConfig() {
const labels = this.data[0].values.map(function (pt) {
return pt.x;
});
const color10 = getColor(10, this.cookies.current.color_scheme);
const borderColor = this.data[0].is_sample_data ? hexToRGBA(color10, 0.1) : color10;
const backgroundColor = this.data[0].is_sample_data
? hexToRGBA(color10, 0.05)
: hexToRGBA(color10, 0.2);
return {
type: "line",
data: {
labels,
datasets: [
{
backgroundColor,
borderColor,
data: this.data[0].values,
fill: "start",
label: this.data[0].key,
borderWidth: 2,
},
],
},
options: {
legend: { display: false },
scales: {
yAxes: [{ display: false }],
xAxes: [{ display: false }],
},
maintainAspectRatio: false,
elements: {
line: {
tension: 0.000001,
},
},
tooltips: {
intersect: false,
position: "nearest",
caretSize: 0,
},
},
};
}
getBarChartConfig() {
const data = [];
const labels = [];
const backgroundColor = [];
const color13 = getColor(13, this.cookies.current.color_scheme);
const color19 = getColor(19, this.cookies.current.color_scheme);
this.data[0].values.forEach((pt) => {
data.push(pt.value);
labels.push(pt.label);
if (pt.type === "past") {
backgroundColor.push(color13);
} else if (pt.type === "future") {
backgroundColor.push(color19);
} else {
backgroundColor.push("#ebebeb");
}
});
return {
type: "bar",
data: {
labels,
datasets: [
{
backgroundColor,
data,
fill: "start",
label: this.data[0].key,
},
],
},
options: {
legend: { display: false },
scales: {
yAxes: [{ display: false }],
},
maintainAspectRatio: false,
tooltips: {
intersect: false,
position: "nearest",
caretSize: 0,
},
elements: {
line: {
tension: 0.000001,
},
},
},
};
}
}
JournalDashboardGraphField.template = "web.JournalDashboardGraphField";
JournalDashboardGraphField.props = {
...standardFieldProps,
graphType: String,
};
JournalDashboardGraphField.supportedTypes = ["text"];
JournalDashboardGraphField.extractProps = ({ attrs }) => {
return {
graphType: attrs.graph_type,
};
};
registry.category("fields").add("dashboard_graph", JournalDashboardGraphField);

View file

@ -0,0 +1,3 @@
.o_field_dashboard_graph {
width: 100%;
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.JournalDashboardGraphField" owl="1">
<div class="o_dashboard_graph" t-att-class="props.className" t-attf-class="o_graph_{{ props.graphType }}chart">
<canvas t-ref="canvas"/>
</div>
</t>
</templates>

View file

@ -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 { formatSelection } from "../formatters";
import { Component } from "@odoo/owl";
export class LabelSelectionField extends Component {
get className() {
return this.props.classesObj[this.props.value] || "primary";
}
get string() {
return formatSelection(this.props.value, {
selection: Array.from(this.props.record.fields[this.props.name].selection),
});
}
}
LabelSelectionField.template = "web.LabelSelectionField";
LabelSelectionField.props = {
...standardFieldProps,
classesObj: { type: Object, optional: true },
};
LabelSelectionField.defaultProps = {
classesObj: {},
};
LabelSelectionField.displayName = _lt("Label Selection");
LabelSelectionField.supportedTypes = ["selection"];
LabelSelectionField.extractProps = ({ attrs }) => {
return {
classesObj: attrs.options.classes,
};
};
registry.category("fields").add("label_selection", LabelSelectionField);

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.LabelSelectionField" owl="1">
<span t-attf-class="badge text-bg-{{className}} " t-esc="string" t-att-raw-value="props.value"/>
</t>
</templates>

View file

@ -0,0 +1,73 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardFieldProps } from "../standard_field_props";
import { FileInput } from "@web/core/file_input/file_input";
import { useX2ManyCrud } from "@web/views/fields/relational_utils";
import { Component } from "@odoo/owl";
export class Many2ManyBinaryField extends Component {
setup() {
this.orm = useService("orm");
this.notification = useService("notification");
this.operations = useX2ManyCrud(() => this.props.value, true);
}
get files() {
return this.props.value.records.map((record) => record.data);
}
getUrl(id) {
return "/web/content/" + id + "?download=true";
}
getExtension(file) {
return file.name.replace(/^.*\./, "");
}
async onFileUploaded(files) {
for (const file of files) {
if (file.error) {
return this.notification.add(file.error, {
title: this.env._t("Uploading error"),
type: "danger",
});
}
await this.operations.saveRecord([file.id]);
}
}
async onFileRemove(deleteId) {
const record = this.props.value.records.find((record) => record.data.id === deleteId);
this.operations.removeRecord(record);
}
}
Many2ManyBinaryField.template = "web.Many2ManyBinaryField";
Many2ManyBinaryField.components = {
FileInput,
};
Many2ManyBinaryField.props = {
...standardFieldProps,
acceptedFileExtensions: { type: String, optional: true },
className: { type: String, optional: true },
uploadText: { type: String, optional: true },
};
Many2ManyBinaryField.supportedTypes = ["many2many"];
Many2ManyBinaryField.fieldsToFetch = {
name: { type: "char" },
mimetype: { type: "char" },
};
Many2ManyBinaryField.isEmpty = () => false;
Many2ManyBinaryField.extractProps = ({ attrs, field }) => {
return {
acceptedFileExtensions: attrs.options.accepted_file_extensions,
className: attrs.class,
uploadText: field.string,
};
};
registry.category("fields").add("many2many_binary", Many2ManyBinaryField);

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Many2ManyBinaryField" owl="1">
<div t-attf-class="oe_fileupload {{props.className ? props.className : ''}}" aria-atomic="true">
<div class="o_attachments">
<t t-foreach="files" t-as="file" t-key="file_index">
<t t-call="Many2ManyBinaryField.attachment_preview"/>
</t>
</div>
<div t-if="!props.readonly" class="oe_add">
<FileInput
acceptedFileExtensions="props.acceptedFileExtensions"
multiUpload="true"
onUpload.bind="onFileUploaded"
resModel="props.record.resModel"
resId="props.record.data.id or 0"
>
<button class="btn btn-secondary o_attach" data-tooltip="Attach">
<span class="fa fa-paperclip" aria-label="Attach"/> <t t-esc="props.uploadText"/>
</button>
</FileInput>
</div>
</div>
</t>
<t t-name="Many2ManyBinaryField.attachment_preview" owl="1">
<t t-set="editable" t-value="!props.readonly"/>
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' } #{upload ? 'o_attachment_uploading' : ''}" t-att-title="file.name">
<div class="o_attachment_wrap">
<t t-set="ext" t-value="getExtension(file)"/>
<div class="o_image_box float-start" t-att-data-tooltip="'Download ' + file.name">
<a t-att-href="getUrl(file.id)" aria-label="Download" download="">
<span class="o_image o_hover" t-att-data-mimetype="file.mimetype" t-att-data-ext="ext" role="img"/>
</a>
</div>
<div class="caption">
<a class="ml4" t-att-data-tooltip="'Download ' + file.name" t-att-href="getUrl(file.id)" download=""><t t-esc='file.name'/></a>
</div>
<div class="caption small">
<a class="ml4 small text-uppercase" t-att-href="getUrl(file.id)"><b><t t-esc='ext'/></b></a>
<div t-if="editable" class="progress o_attachment_progress_bar">
<div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div>
</div>
</div>
<div class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
<div t-if="editable" class="o_attachment_delete" t-on-click.stop="() => this.onFileRemove(file.id)"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,57 @@
/** @odoo-module **/
import { CheckBox } from "@web/core/checkbox/checkbox";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { Component } from "@odoo/owl";
export class Many2ManyCheckboxesField extends Component {
get items() {
return this.props.record.preloadedData[this.props.name];
}
isSelected(item) {
return this.props.value.currentIds.includes(item[0]);
}
onChange(resId, checked) {
if (checked) {
this.props.value.replaceWith([...this.props.value.currentIds, resId]);
} else {
const currentIds = [...this.props.value.currentIds];
const index = currentIds.indexOf(resId);
if (index > -1) {
currentIds.splice(index, 1);
}
this.props.value.replaceWith(currentIds);
}
}
}
Many2ManyCheckboxesField.template = "web.Many2ManyCheckboxesField";
Many2ManyCheckboxesField.components = { CheckBox };
Many2ManyCheckboxesField.props = {
...standardFieldProps,
};
Many2ManyCheckboxesField.legacySpecialData = "_fetchSpecialRelation";
Many2ManyCheckboxesField.displayName = _lt("Checkboxes");
Many2ManyCheckboxesField.supportedTypes = ["many2many"];
Many2ManyCheckboxesField.isEmpty = () => false;
registry.category("fields").add("many2many_checkboxes", Many2ManyCheckboxesField);
export function preloadMany2ManyCheckboxes(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("many2many_checkboxes", {
loadOnTypes: ["many2many"],
preload: preloadMany2ManyCheckboxes,
});

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Many2ManyCheckboxesField" owl="1">
<div aria-atomic="true">
<t t-foreach="items" t-as="item" t-key="item[0]">
<div>
<CheckBox
value="isSelected(item)"
disabled="props.readonly"
onChange="(ev) => this.onChange(item[0], ev)"
>
<t t-esc="item[1]" />
</CheckBox>
</div>
</t>
</div>
</t>
</templates>

View file

@ -0,0 +1,20 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2ManyTagsField } from "./many2many_tags_field";
export class KanbanMany2ManyTagsField extends Many2ManyTagsField {
get tags() {
return super.tags.reduce((kanbanTags, tag) => {
if (tag.colorIndex !== 0) {
delete tag.onClick;
kanbanTags.push(tag);
}
return kanbanTags;
}, []);
}
}
KanbanMany2ManyTagsField.template = "web.KanbanMany2ManyTagsField";
registry.category("fields").add("kanban.many2many_tags", KanbanMany2ManyTagsField);

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.KanbanMany2ManyTagsField" owl="1">
<TagsList className="'o_kanban_tags'" tags="tags" displayBadge="false" />
</t>
</templates>

View file

@ -0,0 +1,366 @@
/** @odoo-module **/
import { _lt } from "@web/core/l10n/translation";
import { CheckBox } from "@web/core/checkbox/checkbox";
import { ColorList } from "@web/core/colorlist/colorlist";
import { Domain } from "@web/core/domain";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import {
Many2XAutocomplete,
useActiveActions,
useX2ManyCrud,
} from "@web/views/fields/relational_utils";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "../standard_field_props";
import { TagsList } from "./tags_list";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { Component, useRef } from "@odoo/owl";
class Many2ManyTagsFieldColorListPopover extends Component {}
Many2ManyTagsFieldColorListPopover.template = "web.Many2ManyTagsFieldColorListPopover";
Many2ManyTagsFieldColorListPopover.components = {
CheckBox,
ColorList,
};
export class Many2ManyTagsField extends Component {
setup() {
this.orm = useService("orm");
this.previousColorsMap = {};
this.popover = usePopover();
this.dialog = useService("dialog");
this.dialogClose = [];
this.autoCompleteRef = useRef("autoComplete");
const { saveRecord, removeRecord } = useX2ManyCrud(() => this.props.value, true);
this.activeActions = useActiveActions({
fieldType: "many2many",
crudOptions: {
create: this.props.canCreate && this.props.createDomain,
createEdit: this.props.canCreateEdit,
onDelete: removeRecord,
},
getEvalParams: (props) => {
return {
evalContext: this.evalContext,
readonly: props.readonly,
};
},
});
this.update = (recordlist) => {
if (!recordlist) {
return;
}
if (Array.isArray(recordlist)) {
const resIds = recordlist.map((rec) => rec.id);
return saveRecord(resIds);
}
return saveRecord(recordlist);
};
if (this.props.canQuickCreate) {
this.quickCreate = async (name) => {
const created = await this.orm.call(this.props.relation, "name_create", [name], {
context: this.context,
});
return saveRecord([created[0]]);
};
}
}
get domain() {
return this.props.record.getFieldDomain(this.props.name);
}
get context() {
return this.props.record.getFieldContext(this.props.name);
}
get evalContext() {
return this.props.record.evalContext;
}
get string() {
return this.props.record.activeFields[this.props.name].string;
}
getTagProps(record) {
return {
id: record.id, // datapoint_X
resId: record.resId,
text: record.data.display_name,
colorIndex: record.data[this.props.colorField],
onDelete: !this.props.readonly ? () => this.deleteTag(record.id) : undefined,
onKeydown: this.onTagKeydown.bind(this),
};
}
get tags() {
return this.props.value.records.map((record) => this.getTagProps(record));
}
get showM2OSelectionField() {
return !this.props.readonly;
}
deleteTag(id) {
const tagRecord = this.props.value.records.find((record) => record.id === id);
const ids = this.props.value.currentIds.filter((id) => id !== tagRecord.resId);
this.props.value.replaceWith(ids);
}
getDomain() {
return Domain.and([
this.domain,
Domain.not([["id", "in", this.props.value.currentIds]]),
]).toList(this.context);
}
focusTag(index) {
const autoCompleteParent = this.autoCompleteRef.el.parentElement;
const tags = autoCompleteParent.getElementsByClassName("badge");
if (tags.length) {
if (index === undefined) {
tags[tags.length - 1].focus();
} else {
tags[index].focus();
}
}
}
onAutoCompleteKeydown(ev) {
if (ev.isComposing) {
// This case happens with an IME for example: we let it handle all key events.
return;
}
const hotkey = getActiveHotkey(ev);
const input = ev.target.closest(".o-autocomplete--input");
const autoCompleteMenuOpened = !!this.autoCompleteRef.el.querySelector(
".o-autocomplete--dropdown-menu"
);
switch (hotkey) {
case "arrowleft": {
if (input.selectionStart || autoCompleteMenuOpened) {
return;
}
// focus rightmost tag if any.
this.focusTag();
break;
}
case "arrowright": {
if (input.selectionStart !== input.value.length || autoCompleteMenuOpened) {
return;
}
// focus leftmost tag if any.
this.focusTag(0);
break;
}
case "backspace": {
if (input.value) {
return;
}
const tags = this.tags;
if (tags.length) {
const { id } = tags[tags.length - 1];
this.deleteTag(id);
}
break;
}
default:
return;
}
ev.preventDefault();
ev.stopPropagation();
}
onTagKeydown(ev) {
if (this.props.readonly) {
return;
}
const hotkey = getActiveHotkey(ev);
const autoCompleteParent = this.autoCompleteRef.el.parentElement;
const tags = [...autoCompleteParent.getElementsByClassName("badge")];
const closestTag = ev.target.closest(".badge");
const tagIndex = tags.indexOf(closestTag);
const input = this.autoCompleteRef.el.querySelector(".o-autocomplete--input");
switch (hotkey) {
case "arrowleft": {
if (tagIndex === 0) {
input.focus();
} else {
this.focusTag(tagIndex - 1);
}
break;
}
case "arrowright": {
if (tagIndex === tags.length - 1) {
input.focus();
} else {
this.focusTag(tagIndex + 1);
}
break;
}
case "backspace": {
input.focus();
const { id } = this.tags[tagIndex] || {};
this.deleteTag(id);
break;
}
default:
return;
}
ev.preventDefault();
ev.stopPropagation();
}
}
Many2ManyTagsField.RECORD_COLORS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
Many2ManyTagsField.SEARCH_MORE_LIMIT = 320;
Many2ManyTagsField.template = "web.Many2ManyTagsField";
Many2ManyTagsField.components = {
TagsList,
Many2XAutocomplete,
};
Many2ManyTagsField.props = {
...standardFieldProps,
canCreate: { type: Boolean, optional: true },
canQuickCreate: { type: Boolean, optional: true },
canCreateEdit: { type: Boolean, optional: true },
colorField: { type: String, optional: true },
createDomain: { type: [Array, Boolean], optional: true },
placeholder: { type: String, optional: true },
relation: { type: String },
nameCreateField: { type: String, optional: true },
};
Many2ManyTagsField.defaultProps = {
canCreate: true,
canQuickCreate: true,
canCreateEdit: true,
nameCreateField: "name",
};
Many2ManyTagsField.displayName = _lt("Tags");
Many2ManyTagsField.supportedTypes = ["many2many"];
Many2ManyTagsField.fieldsToFetch = {
display_name: { name: "display_name", type: "char" },
};
Many2ManyTagsField.isSet = (value) => value.count > 0;
Many2ManyTagsField.extractProps = ({ attrs, field }) => {
const hasCreatePermission = attrs.can_create ? Boolean(JSON.parse(attrs.can_create)) : true;
const noCreate = Boolean(attrs.options.no_create);
const canCreate = hasCreatePermission && !noCreate;
const noQuickCreate = Boolean(attrs.options.no_quick_create);
const noCreateEdit = Boolean(attrs.options.no_create_edit);
return {
colorField: attrs.options.color_field,
nameCreateField: attrs.options.create_name_field,
relation: field.relation,
canCreate,
canQuickCreate: canCreate && !noQuickCreate,
canCreateEdit: canCreate && !noCreateEdit,
createDomain: attrs.options.create,
placeholder: attrs.placeholder,
};
};
registry.category("fields").add("many2many_tags", Many2ManyTagsField);
/**
* A specialization that allows to edit the color with the colorpicker.
* Used in form view.
*/
export class Many2ManyTagsFieldColorEditable extends Many2ManyTagsField {
getTagProps(record) {
const props = super.getTagProps(record);
props.onClick = (ev) => this.onBadgeClick(ev, record);
return props;
}
closePopover() {
this.popoverCloseFn();
this.popoverCloseFn = null;
}
onBadgeClick(ev, record) {
if (!this.props.canEditColor) {
return;
}
const isClosed = !document.querySelector(".o_tag_popover");
if (isClosed) {
this.currentPopoverEl = null;
}
if (this.popoverCloseFn) {
this.closePopover();
}
if (isClosed || this.currentPopoverEl !== ev.currentTarget) {
this.currentPopoverEl = ev.currentTarget;
this.popoverCloseFn = this.popover.add(
ev.currentTarget,
this.constructor.components.Popover,
{
colors: this.constructor.RECORD_COLORS,
tag: {
id: record.id,
colorIndex: record.data[this.props.colorField],
},
switchTagColor: this.switchTagColor.bind(this),
onTagVisibilityChange: this.onTagVisibilityChange.bind(this),
}
);
}
}
onTagVisibilityChange(isHidden, tag) {
const tagRecord = this.props.value.records.find((record) => record.id === tag.id);
if (tagRecord.data[this.props.colorField] != 0) {
this.previousColorsMap[tagRecord.resId] = tagRecord.data[this.props.colorField];
}
tagRecord.update({
[this.props.colorField]: isHidden ? 0 : this.previousColorsMap[tagRecord.resId] || 1,
});
tagRecord.save();
this.closePopover();
}
switchTagColor(colorIndex, tag) {
const tagRecord = this.props.value.records.find((record) => record.id === tag.id);
tagRecord.update({ [this.props.colorField]: colorIndex });
tagRecord.save();
this.closePopover();
}
}
Many2ManyTagsFieldColorEditable.components = {
...Many2ManyTagsField.components,
Popover: Many2ManyTagsFieldColorListPopover,
};
Many2ManyTagsFieldColorEditable.props = {
...Many2ManyTagsField.props,
canEditColor: { type: Boolean, optional: true },
};
Many2ManyTagsFieldColorEditable.defaultProps = {
...Many2ManyTagsField.defaultProps,
canEditColor: true,
};
Many2ManyTagsFieldColorEditable.extractProps = (params) => {
const props = Many2ManyTagsField.extractProps(params);
const attrs = params.attrs;
const noEditColor = Boolean(attrs.options.no_edit_color);
const hasColorField = Boolean(attrs.options.color_field);
return {
...props,
canEditColor: !noEditColor && hasColorField,
};
};
registry.category("fields").add("form.many2many_tags", Many2ManyTagsFieldColorEditable);
registry.category("fields").add("calendar.one2many", Many2ManyTagsField);
registry.category("fields").add("calendar.many2many", Many2ManyTagsField);

View file

@ -0,0 +1,98 @@
.o_tag_popover {
max-width: 150px;
}
.o_tag_popover label {
line-height: $o-line-height-base;
}
.o_field_widget.o_field_many2many_tags {
flex-flow: row wrap;
.o_tags_input {
padding: 1px 0;
.o_tag {
padding-left: 0.6em;
padding-right: 0.6em;
}
}
.o_field_many2many_selection {
flex: 1 0 50px;
.o_input {
height: 100%;
border: none;
}
}
.badge {
flex: 0 0 auto;
margin: 1px 2px 1px 0;
border: none;
font-size: 12px;
user-select: none;
display: flex;
max-width: 100%;
&.dropdown {
cursor: pointer;
}
a {
color: inherit;
}
.o_badge_text, .o_tag_badge_text {
@include o-text-overflow(inline-block);
max-width: 200px;
color: inherit;
line-height: 1.1;
}
.o_delete {
color: inherit;
cursor: pointer;
padding-left: 4px;
line-height: 1;
}
}
}
.o_list_view .o_field_widget.o_field_many2many_tags {
.o_tags_input {
border: 0;
padding: 0;
}
.o_field_many2many_selection {
flex-basis: 40px;
}
}
.o_form_view {
.o_group, .o_inner_group {
.o_field_tags {
width: 100%;
}
}
&:not(.o_field_highlight) {
.o_field_many2many_selection {
.o_dropdown_button {
visibility: hidden;
}
&:hover, &:focus-within {
.o_dropdown_button {
visibility: visible;
}
}
}
}
}
.o_form_statusbar .o_field_tags {
align-self: center;
}

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Many2ManyTagsField" owl="1">
<div
class="o_field_tags d-inline-flex flex-wrap"
t-att-class="{'o_tags_input o_input': !props.readonly}"
>
<TagsList tags="tags"/>
<div t-if="showM2OSelectionField" class="o_field_many2many_selection d-inline-flex w-100" t-on-keydown="onAutoCompleteKeydown" t-ref="autoComplete">
<Many2XAutocomplete
id="props.id"
placeholder="tags.length ? '' : props.placeholder"
resModel="props.relation"
autoSelect="true"
fieldString="string"
activeActions="activeActions"
update="update"
quickCreate="activeActions.create ? quickCreate : null"
context="context"
getDomain.bind="getDomain"
isToMany="true"
nameCreateField="props.nameCreateField"
/>
</div>
</div>
</t>
<t t-name="web.Many2ManyTagsFieldColorListPopover" owl="1">
<div class="o_tag_popover m-2">
<ColorList colors="props.colors" forceExpanded="true" onColorSelected="(id) => props.switchTagColor(id, props.tag)"/>
<CheckBox className="'pt-2'" value="props.tag.colorIndex === 0" onChange.alike="(isChecked) => props.onTagVisibilityChange(isChecked, props.tag)">Hide in kanban</CheckBox>
</div>
</t>
</templates>

View file

@ -0,0 +1,40 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class TagsList extends Component {
get visibleTags() {
if (this.props.itemsVisible && this.props.tags.length > this.props.itemsVisible) {
return this.props.tags.slice(0, this.props.itemsVisible - 1);
}
return this.props.tags;
}
get otherTags() {
if (!this.props.itemsVisible || this.props.tags.length <= this.props.itemsVisible) {
return [];
}
return this.props.tags.slice(this.props.itemsVisible - 1);
}
get tooltipInfo() {
return JSON.stringify({
tags: this.otherTags.map((tag) => ({
text: tag.text,
id: tag.id,
})),
});
}
}
TagsList.template = "web.TagsList";
TagsList.defaultProps = {
className: "",
displayBadge: true,
displayText: true,
};
TagsList.props = {
className: { type: String, optional: true },
displayBadge: { type: Boolean, optional: true },
displayText: { type: Boolean, optional: true },
name: { type: String, optional: true },
itemsVisible: { type: Number, optional: true },
tags: { type: Object, optional: true },
};

View file

@ -0,0 +1,63 @@
.o_tag {
user-select: none;
.o_badge_text, a {
color: inherit;
line-height: 1.1;
}
.o_tag_badge_text {
font-size: 12px;
}
img {
height: 1.6em;
aspect-ratio: 1/1;
+ .o_tag_badge_text {
padding: 0.25em 0;
}
}
@for $size from 1 through length($o-colors) {
&.o_tag_color_#{$size - 1} {
@if $size == 1 {
& {
background-color: $o-view-background-color;
color: nth($o-colors, $size);
box-shadow: inset 0 0 0 1px;
}
&:focus-within {
color: darken(nth($o-colors, $size), 40%) ;
}
&::after {
background-color: nth($o-colors, $size);
}
} @else {
&, &::after {
background-color: nth($o-colors, $size);
color: color-contrast(nth($o-colors, $size));
}
}
}
}
}
.o_kanban_view .o_kanban_record .o_tag {
display: inline-block;
margin-right: 4px;
font-size: 11px;
font-weight: 500;
background-color: transparent;
color: inherit;
box-shadow: none;
@include o-kanban-tag-color;
span {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 4px;
border-radius: 100%;
}
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.TagsList" owl="1">
<t t-foreach="visibleTags" t-as="tag" t-key="tag.id or tag_index">
<span t-attf-class="{{`o_tag_color_${ tag.colorIndex or 0 } ${props.displayBadge ? 'badge rounded-pill' : ''}`}} o_tag d-inline-flex align-items-center mw-100" tabindex="-1" t-att-data-color="tag.colorIndex" t-att-title="tag.text" t-on-click="(ev) => tag.onClick and tag.onClick(ev)" t-on-keydown="tag.onKeydown">
<img t-if="tag.img" t-att-src="tag.img" class="o_m2m_avatar rounded-circle" t-att-class="tag.className"/>
<div t-if="props.displayBadge and props.displayText" class="o_tag_badge_text" t-esc="tag.text" />
<t t-elif="props.displayText">
<span />
<div t-esc="tag.text" class="text-truncate" />
</t>
<a tabIndex="-1" t-if="tag.onDelete" t-on-click.stop.prevent="(ev) => tag.onDelete and tag.onDelete(ev)" href="#" class="fa fa-times o_delete" title="Delete" aria-label="Delete"></a>
</span>
</t>
<span t-if="props.tags and otherTags.length" class="o_m2m_avatar_empty rounded-circle text-center fw-bold" data-tooltip-template="web.TagsList.Tooltip" data-tooltip-position="right" t-att-data-tooltip-info="tooltipInfo">
<span t-if="otherTags.length > 9" t-esc="'9+'" />
<span t-else="" t-esc="'+' + otherTags.length" />
</span>
</t>
<t t-name="web.TagsList.Tooltip" owl="1">
<t t-foreach="tags" t-as="tag" t-key="tag.id">
<div t-esc="tag.text"/>
</t>
</t>
</templates>

View file

@ -0,0 +1,44 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
import { Many2ManyTagsField } from "@web/views/fields/many2many_tags/many2many_tags_field";
import { TagsList } from "../many2many_tags/tags_list";
export class Many2ManyTagsAvatarField extends Many2ManyTagsField {
get tags() {
return super.tags.map((tag) => ({
...tag,
img: `/web/image/${this.relation}/${tag.resId}/avatar_128`,
onDelete: !this.props.readonly ? () => this.deleteTag(tag.id) : undefined,
}));
}
get relation() {
return this.props.relation;
}
}
Many2ManyTagsAvatarField.template = "web.Many2ManyTagsAvatarField";
Many2ManyTagsAvatarField.components = {
Many2XAutocomplete,
TagsList,
};
registry.category("fields").add("many2many_tags_avatar", Many2ManyTagsAvatarField);
export class ListKanbanMany2ManyTagsAvatarField extends Many2ManyTagsAvatarField {
get itemsVisible() {
return this.props.record.activeFields[this.props.name].viewType === "list" ? 5 : 3;
}
getTagProps(record) {
return {
...super.getTagProps(record),
img: `/web/image/${this.props.relation}/${record.resId}/avatar_128`,
};
}
}
registry.category("fields").add("list.many2many_tags_avatar", ListKanbanMany2ManyTagsAvatarField);
registry.category("fields").add("kanban.many2many_tags_avatar", ListKanbanMany2ManyTagsAvatarField);

View file

@ -0,0 +1,85 @@
.o_field_widget.o_field_many2many_tags_avatar {
flex-flow: row wrap;
.o_tags_input {
padding: 1px 0;
}
.o_field_many2many_selection {
flex: 1 0 50px;
.o_input {
height: 100%;
border: none;
}
}
.o_dropdown_button {
top: $o-input-padding-y;
}
.badge {
flex: 0 0 auto;
margin: 1px 2px 1px 0;
border: none;
font-size: 12px;
user-select: none;
display: flex;
max-width: 100%;
align-items: center;
padding: 0;
padding-right: 0.6rem;
box-shadow: inset 0 0 0 1px;
&.dropdown {
cursor: pointer;
}
a {
color: inherit;
}
.o_badge_text, .o_tag_badge_text {
@include o-text-overflow(inline-block);
max-width: 200px;
color: inherit;
}
.o_badge_text {
line-height: 1.1;
}
.o_delete {
color: inherit;
cursor: pointer;
padding-left: 4px;
line-height: 1;
}
img {
height: 1.4em;
width: 1.4em;
object-fit: cover;
}
.o_badge_text, .o_delete {
padding-top: 0.25em;
padding-bottom: 0.25em;
}
.o_tag_badge_text {
padding-left:2px;
}
}
}
.o_list_view .o_field_widget.o_field_many2many_tags_avatar {
.o_tags_input {
border: 0;
padding: 0;
}
.o_field_many2many_selection {
flex-basis: 40px;
}
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.Many2ManyTagsAvatarField" owl="1">
<div
class="o_field_tags d-inline-flex flex-wrap mw-100"
t-att-class="{'o_tags_input o_input': !props.readonly}"
>
<TagsList tags="tags" itemsVisible="itemsVisible"/>
<div t-if="showM2OSelectionField" class="o_field_many2many_selection d-inline-flex w-100" t-on-keydown="onAutoCompleteKeydown" t-ref="autoComplete">
<Many2XAutocomplete
id="props.id"
placeholder="tags.length ? '' : props.placeholder"
resModel="props.relation"
autoSelect="true"
fieldString="string"
activeActions="activeActions"
update="update"
quickCreate="activeActions.create ? quickCreate : null"
context="context"
getDomain.bind="getDomain"
isToMany="true"
/>
</div>
</div>
</t>
</templates>

View file

@ -0,0 +1,334 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { isMobileOS } from "@web/core/browser/feature_detection";
import { Dialog } from "@web/core/dialog/dialog";
import { _lt } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useChildRef, useOwnedDialogs, useService } from "@web/core/utils/hooks";
import { escape, sprintf } from "@web/core/utils/strings";
import { Many2XAutocomplete, useOpenMany2XRecord } from "@web/views/fields/relational_utils";
import * as BarcodeScanner from "@web/webclient/barcode/barcode_scanner";
import { standardFieldProps } from "../standard_field_props";
import { Component, onWillUpdateProps, useState, markup } from "@odoo/owl";
class CreateConfirmationDialog extends Component {
get title() {
return sprintf(this.env._t("New: %s"), this.props.name);
}
get dialogContent() {
return markup(
sprintf(
this.env._t("Create <strong>%s</strong> as a new %s?"),
escape(this.props.value),
escape(this.props.name)
)
);
}
async onCreate() {
await this.props.create();
this.props.close();
}
}
CreateConfirmationDialog.components = { Dialog };
CreateConfirmationDialog.template = "web.Many2OneField.CreateConfirmationDialog";
export function m2oTupleFromData(data) {
const id = data.id;
let name;
if ("display_name" in data) {
name = data.display_name;
} else {
const _name = data.name;
name = Array.isArray(_name) ? _name[1] : _name;
}
return [id, name];
}
export class Many2OneField extends Component {
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.dialog = useService("dialog");
this.notification = useService("notification");
this.autocompleteContainerRef = useChildRef();
this.addDialog = useOwnedDialogs();
this.focusInput = () => {
this.autocompleteContainerRef.el.querySelector("input").focus();
};
this.state = useState({
isFloating: !this.props.value,
});
this.computeActiveActions(this.props);
this.openMany2X = useOpenMany2XRecord({
resModel: this.relation,
activeActions: this.state.activeActions,
isToMany: false,
onRecordSaved: async (record) => {
const resId = this.props.value[0];
const fields = ["display_name"];
const context = this.props.record.getFieldContext(this.props.name);
const records = await this.orm.read(this.relation, [resId], fields, { context });
await this.props.update(m2oTupleFromData(records[0]));
},
onClose: () => this.focusInput(),
fieldString: this.props.string,
});
this.update = (value, params = {}) => {
if (value) {
value = m2oTupleFromData(value[0]);
}
this.state.isFloating = false;
return this.props.update(value);
};
if (this.props.canQuickCreate) {
this.quickCreate = (name) => {
return this.props.update([false, name]);
};
}
this.setFloating = (bool) => {
this.state.isFloating = bool;
};
onWillUpdateProps(async (nextProps) => {
this.state.isFloating = !nextProps.value;
this.computeActiveActions(nextProps);
});
}
get relation() {
return this.props.relation || this.props.record.fields[this.props.name].relation;
}
get context() {
return this.props.record.getFieldContext(this.props.name);
}
get domain() {
return this.props.record.getFieldDomain(this.props.name);
}
get hasExternalButton() {
return this.props.canOpen && !!this.props.value && !this.state.isFloating;
}
get classFromDecoration() {
for (const decorationName in this.props.decorations) {
if (this.props.decorations[decorationName]) {
return `text-${decorationName}`;
}
}
return "";
}
get displayName() {
return this.props.value ? this.props.value[1].split("\n")[0] : "";
}
get extraLines() {
return this.props.value
? this.props.value[1]
.split("\n")
.map((line) => line.trim())
.slice(1)
: [];
}
get resId() {
return this.props.value && this.props.value[0];
}
get value() {
return this.props.record.data[this.props.name];
}
get Many2XAutocompleteProps() {
return {
value: this.displayName,
id: this.props.id,
placeholder: this.props.placeholder,
resModel: this.relation,
autoSelect: true,
fieldString: this.props.string,
activeActions: this.state.activeActions,
update: this.update,
quickCreate: this.quickCreate,
context: this.context,
getDomain: this.getDomain.bind(this),
nameCreateField: this.props.nameCreateField,
setInputFloats: this.setFloating,
autocomplete_container: this.autocompleteContainerRef,
kanbanViewId: this.props.kanbanViewId,
};
}
computeActiveActions(props) {
this.state.activeActions = {
create: props.canCreate,
createEdit: props.canCreateEdit,
write: props.canWrite,
};
}
getDomain() {
return this.domain.toList(this.context);
}
async openAction() {
const action = await this.orm.call(this.relation, "get_formview_action", [[this.resId]], {
context: this.context,
});
await this.action.doAction(action);
}
async openDialog(resId) {
return this.openMany2X({ resId, context: this.context });
}
async openConfirmationDialog(request) {
return new Promise((resolve, reject) => {
this.addDialog(CreateConfirmationDialog, {
value: request,
name: this.props.string,
create: async () => {
try {
await this.quickCreate(request);
resolve();
} catch (e) {
reject(e);
}
},
});
});
}
onClick(ev) {
if (this.props.canOpen && this.props.readonly) {
ev.stopPropagation();
this.openAction();
}
}
onExternalBtnClick() {
if (this.props.openTarget === "current" && !this.env.inDialog) {
this.openAction();
} else {
this.openDialog(this.resId);
}
}
async onBarcodeBtnClick() {
const barcode = await BarcodeScanner.scanBarcode();
if (barcode) {
await this.onBarcodeScanned(barcode);
if ("vibrate" in browser.navigator) {
browser.navigator.vibrate(100);
}
} else {
this.notification.add(this.env._t("Please, scan again !"), {
type: "warning",
});
}
}
async search(barcode) {
const results = await this.orm.call(this.relation, "name_search", [], {
name: barcode,
args: this.getDomain(),
operator: "ilike",
limit: 2, // If one result we set directly and if more than one we use normal flow so no need to search more
context: this.context,
});
return results.map((result) => {
const [id, displayName] = result;
return {
id,
name: displayName,
};
});
}
async onBarcodeScanned(barcode) {
const results = await this.search(barcode);
const records = results.filter((r) => !!r.id);
if (records.length === 1) {
this.update([{ id: records[0].id, name: records[0].name }]);
} else {
const searchInput = this.autocompleteContainerRef.el.querySelector("input");
searchInput.value = barcode;
searchInput.dispatchEvent(new Event("input"));
if (this.env.isSmall) {
searchInput.dispatchEvent(new Event("barcode-search"));
}
}
}
get hasBarcodeButton() {
const canScanBarcode = this.props.canScanBarcode;
const supported = BarcodeScanner.isBarcodeScannerSupported();
return canScanBarcode && isMobileOS() && supported && !this.hasExternalButton;
}
}
Many2OneField.SEARCH_MORE_LIMIT = 320;
Many2OneField.template = "web.Many2OneField";
Many2OneField.components = {
Many2XAutocomplete,
};
Many2OneField.props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
canOpen: { type: Boolean, optional: true },
canCreate: { type: Boolean, optional: true },
canWrite: { type: Boolean, optional: true },
canQuickCreate: { type: Boolean, optional: true },
canCreateEdit: { type: Boolean, optional: true },
nameCreateField: { type: String, optional: true },
searchLimit: { type: Number, optional: true },
relation: { type: String, optional: true },
string: { type: String, optional: true },
canScanBarcode: { type: Boolean, optional: true },
openTarget: { type: String, validate: (v) => ["current", "new"].includes(v), optional: true },
kanbanViewId: { type: [Number, Boolean], optional: true },
};
Many2OneField.defaultProps = {
canOpen: true,
canCreate: true,
canWrite: true,
canQuickCreate: true,
canCreateEdit: true,
nameCreateField: "name",
searchLimit: 7,
string: "",
canScanBarcode: false,
openTarget: "current",
};
Many2OneField.displayName = _lt("Many2one");
Many2OneField.supportedTypes = ["many2one"];
Many2OneField.extractProps = ({ attrs, field }) => {
const hasCreatePermission = attrs.can_create ? Boolean(JSON.parse(attrs.can_create)) : true;
const hasWritePermission = attrs.can_write ? Boolean(JSON.parse(attrs.can_write)) : true;
const noOpen = Boolean(attrs.options.no_open);
const noCreate = Boolean(attrs.options.no_create);
const canCreate = hasCreatePermission && !noCreate;
const canWrite = hasWritePermission;
const noQuickCreate = Boolean(attrs.options.no_quick_create);
const noCreateEdit = Boolean(attrs.options.no_create_edit);
const canScanBarcode = Boolean(attrs.options.can_scan_barcode);
return {
placeholder: attrs.placeholder,
canOpen: !noOpen,
canCreate,
canWrite,
canQuickCreate: canCreate && !noQuickCreate,
canCreateEdit: canCreate && !noCreateEdit,
relation: field.relation,
string: attrs.string || field.string,
nameCreateField: attrs.options.create_name_field,
canScanBarcode: canScanBarcode,
openTarget: attrs.open_target,
kanbanViewId: attrs.kanban_view_ref ? JSON.parse(attrs.kanban_view_ref) : false,
};
};
registry.category("fields").add("many2one", Many2OneField);
// the two following lines are there to prevent the fallback on legacy widgets
registry.category("fields").add("list.many2one", Many2OneField);
registry.category("fields").add("kanban.many2one", Many2OneField);

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