mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 06:12:04 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_field_ace {
|
||||
display: block !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web.AceField" owl="1">
|
||||
<!-- TODO check if some classes are useless -->
|
||||
<div class="o_field_widget oe_form_field o_ace_view_editor oe_ace_open">
|
||||
<div class="ace-view-editor" t-ref="editor" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
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);
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web.AttachmentImageField" owl="1">
|
||||
<div class="o_attachment_image">
|
||||
<img t-if="props.value" t-attf-src="/web/image/{{ props.value[0] }}?unique=1" t-att-title="props.value[1]" alt="Image" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
|
||||
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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.BooleanFavoriteField" owl="1">
|
||||
<div class="o_favorite" t-on-click.prevent.stop="() => props.update(!props.value)">
|
||||
<a href="#">
|
||||
<i
|
||||
class="fa"
|
||||
role="img"
|
||||
t-att-class="props.value ? 'fa-star' : 'fa-star-o'"
|
||||
t-att-title="props.value ? 'Remove from Favorites' : 'Add to Favorites'"
|
||||
t-att-aria-label="props.value ? 'Remove from Favorites' : 'Add to Favorites'"
|
||||
/>
|
||||
<t t-if="!props.noLabel"> <t t-esc="props.value ? 'Remove from Favorites' : 'Add to Favorites'" /></t>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,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);
|
||||
|
|
@ -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">
|
||||
​ <!-- Zero width space needed to set height -->
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
body:not(.o_touch_device) .o_field_email {
|
||||
&:not(:hover):not(:focus-within) {
|
||||
& input:not(:hover) ~ a {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
303
odoo-bringout-oca-ocb-web/web/static/src/views/fields/field.js
Normal file
303
odoo-bringout-oca-ocb-web/web/static/src/views/fields/field.js
Normal 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: () => {} };
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web.FloatFactorField" owl="1">
|
||||
<FloatField t-props="floatFieldProps" />
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web.FloatToggleField" owl="1">
|
||||
<span t-if="props.readonly and !props.disableReadOnly" t-esc="formattedValue" />
|
||||
<button t-else="" class="o_field_float_toggle" t-on-click="onChange"><t t-esc="formattedValue" /></button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_field_widget.o_field_html {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.o_field_dashboard_graph {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
};
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue