[MERGE][IMP] base, web: add "Properties" fields for context-based model customization
Purpose
=======
Add a new field "Properties" to be able to light customization of workflows
based on a container model. Those properties acts in some ways like Odoo
fields without requiring specific columns e.g. add new properties on tasks
of a specific project.
Usage
=====
Define properties on a container model (e.g. project) with
```
attributes_definition = fields.PropertiesDefinition('Message Properties')
```
It defines properties available on subrecords: types, default, value, model for
relational properties,...
Use it on subrecords (e.g. task) with
```
attributes = fields.Properties(
string='Properties',
definition='container_id.attributes_definition',
)
```
Technical
=========
Container | Properties definition
------------------------------
The properties definition is stored on the container, on a JSON field.
This definition contains the type of the properties, the default value,
the model of the many2one,...
```
[
{
'name': 'name',
'string': 'Name',
'type': 'char',
'default': 'Default Name',
}, {
'name': 'partner_id',
'string': 'Partner',
'type': 'many2one',
'comodel': 'res.partner',
},
]
```
Child | Properties values
-------------------------
The value is stored on the child, using a Properties field.
```
{
'name': 'Mitchel',
'partner_id': 1337,
}
```
When we read this field, we will automatically read the definition on
the container, and merge both JSON into one, so the web client has the
value of each property, and their definition.
```
[
{
'name': 'name',
'string': 'Name',
'type': 'char',
'default': 'Default Name',
'value': 'Mitchel',
}, {
'name': 'partner_id',
'string': 'Partner',
'type': 'many2one',
'comodel': 'res.partner',
'value': 1337,
},
]
```
Integrity
---------
If we remove a property on the container, we won't update the child value.
Instead, when we read the child properties, we will filter them based
on the container. So the removed properties will be removed the next time
we write on the field.
In the same logic, the many2one existence is checked when we read the
field. There's no foreign key between the integer stored in the JSON
in the SQL row corresponding to the record in database.
Write
-----
We can write on the Properties field with a list of field definition
and value.
Some types are not JSONifiable (like the date, datetime), they are
stored as string in database and parsed when we read the value.
In order to update the container definition by writing on the child,
you need to add the dict key `definition_changed` or
`definition_deleted`. This is because we need to be able to know
if the definition has been changed without doing extra SQL queries.
Access rights
-------------
A user can add a many2one / many2many property to a model only if he
has the access rights to it.
Many2one / Many2many
--------------------
The model choice of a many2one / many2many properties was subject to
changes.
First implementation stored models in both container and subrecords to easily
spot changes and avoid complex queries when fetching records, trying to
synchronize them, ...
As this leads to storing a lot of duplicated content we choose to instead
reset the value on the child if the model has been change. We generate a
new name for the property. So it behaves like if we removed the property
and created a new one.
```
{
'name': 'Mitchel',
'partner_id': (1337, 'res.partner'),
}
```
To be able to restore the old value (e.g. if by mistake we changed the
model, and go back to the old model), we store the initial states.
Task-2852259
closes odoo/odoo#95184
Related: odoo/enterprise#29830
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
@@ -3,6 +3,7 @@
|
||||
|
||||
from . import ir_qweb_fields
|
||||
from . import ir_http
|
||||
from . import ir_model
|
||||
from . import ir_ui_menu
|
||||
from . import models
|
||||
from . import base_document_layout
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, api
|
||||
|
||||
|
||||
class IrModel(models.Model):
|
||||
_inherit = "ir.model"
|
||||
|
||||
@api.model
|
||||
def display_name_for(self, models):
|
||||
"""
|
||||
Returns the display names from provided models which the current user can access.
|
||||
The result is the same whether someone tries to access an inexistent model or a model they cannot access.
|
||||
:models list(str): list of technical model names to lookup (e.g. `["res.partner"]`)
|
||||
:return: list of dicts of the form `{ "model", "display_name" }` (e.g. `{ "model": "res_partner", "display_name": "Contact"}`)
|
||||
"""
|
||||
# Store accessible models in a temporary list in order to execute only one SQL query
|
||||
accessible_models = []
|
||||
not_accessible_models = []
|
||||
for model in models:
|
||||
if self._check_model_access(model):
|
||||
accessible_models.append(model)
|
||||
else:
|
||||
not_accessible_models.append({"display_name": model, "model": model})
|
||||
return self._display_name_for(accessible_models) + not_accessible_models
|
||||
|
||||
@api.model
|
||||
def _display_name_for(self, models):
|
||||
records = self.sudo().search_read([("model", "in", models)], ["name", "model"])
|
||||
return [{
|
||||
"display_name": model["name"],
|
||||
"model": model["model"],
|
||||
} for model in records]
|
||||
|
||||
@api.model
|
||||
def _check_model_access(self, model):
|
||||
return (self.env.user._is_internal() and model in self.env
|
||||
and self.env[model].check_access_rights("read", raise_exception=False))
|
||||
|
||||
@api.model
|
||||
def get_available_models(self):
|
||||
"""
|
||||
Return the list of models the current user has access to, with their
|
||||
corresponding display name.
|
||||
"""
|
||||
accessible_models = [model for model in self.pool.keys() if self._check_model_access(model)]
|
||||
return self._display_name_for(accessible_models)
|
||||
@@ -0,0 +1,93 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { fuzzyLookup } from "@web/core/utils/search";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
const { Component, onWillStart } = owl;
|
||||
|
||||
export class ModelSelector extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
|
||||
onWillStart(async () => {
|
||||
if (!this.props.models) {
|
||||
this.models = await this._fetchAvailableModels();
|
||||
} else {
|
||||
this.models = await this.orm.call("ir.model", "display_name_for", [this.props.models]);
|
||||
}
|
||||
|
||||
this.models = this.models.map((record) => ({
|
||||
label: record.display_name,
|
||||
technical: record.model,
|
||||
classList: {
|
||||
[`o_model_selector_${record.model}`]: 1,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
get placeholder() {
|
||||
return _t("Search a Model...");
|
||||
}
|
||||
|
||||
get sources() {
|
||||
return [this.optionsSource];
|
||||
}
|
||||
get optionsSource() {
|
||||
return {
|
||||
placeholder: _t("Loading..."),
|
||||
options: this.loadOptionsSource.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
onSelect(option) {
|
||||
this.props.onModelSelected({
|
||||
label: option.label,
|
||||
technical: option.technical,
|
||||
});
|
||||
}
|
||||
|
||||
filterModels(name) {
|
||||
if (!name) {
|
||||
return this.models.slice(0, 8);
|
||||
}
|
||||
return fuzzyLookup(name, this.models, (model) => model.technical + model.label).slice(0, 8);
|
||||
}
|
||||
|
||||
loadOptionsSource(request) {
|
||||
const options = this.filterModels(request);
|
||||
|
||||
if (!options.length) {
|
||||
options.push({
|
||||
label: _t("No records"),
|
||||
classList: "o_m2o_no_result",
|
||||
unselectable: true,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of the models that can be
|
||||
* selected for the relational properties.
|
||||
*/
|
||||
async _fetchAvailableModels() {
|
||||
const result = await this.orm.call(
|
||||
'ir.model',
|
||||
'get_available_models',
|
||||
);
|
||||
return result || [];
|
||||
}
|
||||
}
|
||||
|
||||
ModelSelector.template = "web.ModelSelector";
|
||||
ModelSelector.components = { AutoComplete };
|
||||
ModelSelector.props = {
|
||||
onModelSelected: Function,
|
||||
value: { type: String, optional: true },
|
||||
// list of models technical name, if not set
|
||||
// we will fetch all models we have access to
|
||||
models: { type: Array, optional: true },
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<templates>
|
||||
<t t-name="web.ModelSelector" owl="1">
|
||||
<div class="o_sp_input_dropdown" t-ref="autocomplete_container">
|
||||
<input t-if="env.isSmall"
|
||||
type="text"
|
||||
class="o_input"
|
||||
readonly=""
|
||||
t-att-value="props.value"
|
||||
/>
|
||||
<AutoComplete t-else=""
|
||||
value="props.value || ''"
|
||||
sources="sources"
|
||||
placeholder="placeholder"
|
||||
autoSelect="props.autoSelect"
|
||||
onSelect.bind="onSelect"
|
||||
/>
|
||||
<a role="button" class="o_dropdown_button" draggable="false" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -45,6 +45,10 @@ class PopoverController extends Component {
|
||||
if (this.target.contains(ev.target) || ev.target.closest(".o_popover")) {
|
||||
return;
|
||||
}
|
||||
if (this.props.preventClose && this.props.preventClose(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.closeOnClickAway) {
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function usePopover() {
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.closeOnClickAway=true]
|
||||
* @param {function()} [options.onClose]
|
||||
* @param {function()} [options.preventClose]
|
||||
* @param {string} [options.popoverClass]
|
||||
* @param {string} [options.position]
|
||||
* @returns {function()}
|
||||
|
||||
@@ -41,6 +41,7 @@ export const popoverService = {
|
||||
position: options.position,
|
||||
popoverClass: options.popoverClass,
|
||||
closeOnClickAway: options.closeOnClickAway,
|
||||
preventClose: options.preventClose,
|
||||
};
|
||||
popovers[id] = popover;
|
||||
bus.trigger("UPDATE");
|
||||
|
||||
@@ -416,6 +416,20 @@ function formatSelection(value, field, options) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing the value of the python properties field.
|
||||
*
|
||||
* @param {string|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(', ');
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Parse
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -749,6 +763,8 @@ return {
|
||||
monetary: formatMonetary,
|
||||
one2many: formatX2Many,
|
||||
percentage: formatPercentage,
|
||||
properties: formatProperties,
|
||||
properties_definition: formatProperties,
|
||||
reference: formatMany2one,
|
||||
selection: formatSelection,
|
||||
text: formatChar,
|
||||
|
||||
@@ -371,6 +371,11 @@ export class Record extends DataPoint {
|
||||
case "integer":
|
||||
case "monetary":
|
||||
continue;
|
||||
case "properties":
|
||||
if (!this.checkPropertiesValidity(fieldName)) {
|
||||
this.setInvalidField(fieldName);
|
||||
}
|
||||
break;
|
||||
case "one2many":
|
||||
case "many2many":
|
||||
if (!(await this.checkX2ManyValidity(fieldName))) {
|
||||
@@ -449,6 +454,23 @@ export class Record extends DataPoint {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The label and the id of the properties are always required.
|
||||
*
|
||||
* @param {string} fieldName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkPropertiesValidity(fieldName) {
|
||||
const value = this.data[fieldName];
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return value.every((propertyDefinition) =>
|
||||
!propertyDefinition.id ||
|
||||
(propertyDefinition.string && propertyDefinition.string.length)
|
||||
);
|
||||
}
|
||||
|
||||
setInvalidField(fieldName) {
|
||||
this._invalidFields.add(fieldName);
|
||||
const bm = this.model.__bm__;
|
||||
|
||||
@@ -23,7 +23,7 @@ class CreateConfirmationDialog extends Component {
|
||||
CreateConfirmationDialog.components = { Dialog };
|
||||
CreateConfirmationDialog.template = "web.Many2OneField.CreateConfirmationDialog";
|
||||
|
||||
function m2oTupleFromData(data) {
|
||||
export function m2oTupleFromData(data) {
|
||||
const id = data.id;
|
||||
let name;
|
||||
if ("display_name" in data) {
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 979 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 853 B |
|
After Width: | Height: | Size: 599 B |
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,457 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
import { uuid } from "../../utils";
|
||||
import { PropertyDefinition } from "./property_definition";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { PropertyValue } from "./property_value";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
const {
|
||||
Component,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
onWillStart,
|
||||
onWillUpdateProps,
|
||||
} = owl;
|
||||
|
||||
export class PropertiesField extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.dialogService = useService("dialog");
|
||||
this.popover = usePopover();
|
||||
this.propertiesRef = useRef("properties");
|
||||
|
||||
this.state = useState({
|
||||
canChangeDefinition: true,
|
||||
movedPropertyName: null,
|
||||
});
|
||||
|
||||
this._saveInitialPropertiesValues();
|
||||
|
||||
const field = this.props.record.fields[this.props.name];
|
||||
this.definitionRecordField = field.definition_record;
|
||||
|
||||
onWillStart(async () => {
|
||||
await this._checkDefinitionAccess();
|
||||
});
|
||||
|
||||
onWillUpdateProps(async () => {
|
||||
// reset the initials properties values if we saved the record
|
||||
if (this.props.record.mode === "readonly") {
|
||||
this._saveInitialPropertiesValues();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
this._movePopoverIfNeeded();
|
||||
|
||||
if (this.openLastPropertyDefinition) {
|
||||
this.openLastPropertyDefinition = null;
|
||||
const propertiesList = this.propertiesList;
|
||||
const lastPropertyName =
|
||||
propertiesList[propertiesList.length - 1].name;
|
||||
const labels = this.propertiesRef.el.querySelectorAll(
|
||||
`.o_property_field[property-name="${lastPropertyName}"] .o_field_property_open_popover`
|
||||
);
|
||||
const lastLabel = labels[labels.length - 1];
|
||||
lastLabel.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the current context
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
get context() {
|
||||
return this.props.record.getFieldContext(this.props.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current properties value.
|
||||
*
|
||||
* Make a deep copy of this properties values, so when we will modify it
|
||||
* in the events, we won't re-use same object (can lead to issue, e.g. if we
|
||||
* discard a form view, we should be able to restore the old props).
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get propertiesList() {
|
||||
const propertiesValues = JSON.parse(
|
||||
JSON.stringify(this.props.value || [])
|
||||
);
|
||||
return propertiesValues.filter(
|
||||
(definition) => !definition.definition_deleted
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return false if we should not close the popover containing the
|
||||
* properties definition based on the event received.
|
||||
*
|
||||
* If we edit the datetime, it will open a popover with the date picker
|
||||
* component, but this component won't be a child of the current popover.
|
||||
* So when we will click on it to select a date, it will close the definition
|
||||
* popover. It's the same for other similar components (many2one modal, etc).
|
||||
*
|
||||
* @param {event} event
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkPopoverClose(event) {
|
||||
if (document.activeElement.closest(".o_field_property_definition")) {
|
||||
// the focus is still on an element of the definition
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.target.closest(".bootstrap-datetimepicker-widget")) {
|
||||
// selected a datetime, do not close the definition popover
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.target.closest(".modal")) {
|
||||
// close a many2one modal
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Move the given property up or down in the list.
|
||||
*
|
||||
* @param {string} propertyName
|
||||
* @param {string} direction, either "up" or "down"
|
||||
*/
|
||||
onPropertyMove(propertyName, direction) {
|
||||
const propertiesValues = this.propertiesList || [];
|
||||
const propertyIndex = propertiesValues.findIndex(
|
||||
(property) => property.name === propertyName
|
||||
);
|
||||
|
||||
const targetIndex = propertyIndex + (direction === "down" ? 1 : -1);
|
||||
if (targetIndex < 0 || targetIndex >= propertiesValues.length) {
|
||||
this.notification.add(
|
||||
direction === "down"
|
||||
? _lt("This field is already last")
|
||||
: _lt("This field is already first"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.state.movedPropertyName = propertyName;
|
||||
|
||||
const prop = propertiesValues[targetIndex];
|
||||
propertiesValues[targetIndex] = propertiesValues[propertyIndex];
|
||||
propertiesValues[propertyIndex] = prop;
|
||||
propertiesValues[propertyIndex].definition_changed = true;
|
||||
this.props.update(propertiesValues);
|
||||
// move the popover once the DOM is updated
|
||||
this.shouldUpdatePopoverPosition = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value / definition of the given property has been changed.
|
||||
* `propertyValue` contains the definition of the property with the value.
|
||||
*
|
||||
* @param {string} propertyName
|
||||
* @param {object} propertyValue
|
||||
*/
|
||||
onPropertyValueChange(propertyName, propertyValue) {
|
||||
const propertiesValues = this.propertiesList;
|
||||
propertiesValues.find(
|
||||
(property) => property.name === propertyName
|
||||
).value = propertyValue;
|
||||
this.props.update(propertiesValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the popover with the property definition.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {string} propertyName
|
||||
*/
|
||||
onPropertyEdit(event, propertyName) {
|
||||
if (event.target.classList.contains("disabled")) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
// remove the glitch if we click on the edit button
|
||||
// while the popover is already opened
|
||||
return;
|
||||
}
|
||||
|
||||
event.target.classList.add("disabled");
|
||||
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
event.currentTarget,
|
||||
PropertyDefinition,
|
||||
{
|
||||
readonly:
|
||||
this.props.readonly || !this.state.canChangeDefinition,
|
||||
canChangeDefinition: this.state.canChangeDefinition,
|
||||
propertyDefinition: this.propertiesList.find(
|
||||
(property) => property.name === propertyName
|
||||
),
|
||||
context: this.context,
|
||||
onChange: this.onPropertyDefinitionChange.bind(this),
|
||||
onDelete: () => this.onPropertyDelete(propertyName),
|
||||
onPropertyMove: (direction) =>
|
||||
this.onPropertyMove(propertyName, direction),
|
||||
},
|
||||
{
|
||||
preventClose: this.checkPopoverClose,
|
||||
popoverClass: "o_property_field_popover",
|
||||
position: "top",
|
||||
onClose: () => {
|
||||
this.state.movedPropertyName = null;
|
||||
event.target.classList.remove("disabled");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The property definition or value has been changed.
|
||||
*
|
||||
* @param {object} propertyDefinition
|
||||
*/
|
||||
onPropertyDefinitionChange(propertyDefinition) {
|
||||
propertyDefinition["definition_changed"] = true;
|
||||
const propertiesValues = this.propertiesList;
|
||||
const propertyIndex = propertiesValues.findIndex(
|
||||
(property) => property.name === propertyDefinition.name
|
||||
);
|
||||
|
||||
// if the type / model are the same, restore the original name to not reset the children
|
||||
// otherwise, generate a new value so all value of the record are reset
|
||||
const initialValues = this.initialValues[propertyDefinition.name];
|
||||
if (
|
||||
initialValues &&
|
||||
propertyDefinition.type === initialValues.type &&
|
||||
propertyDefinition.comodel === initialValues.comodel
|
||||
) {
|
||||
// restore the original name
|
||||
propertyDefinition.name = initialValues.name;
|
||||
} else if (
|
||||
initialValues &&
|
||||
initialValues.name === propertyDefinition.name
|
||||
) {
|
||||
// generate a new new to reset all values on other records
|
||||
// store the new generated name to be able to restore it
|
||||
// if needed
|
||||
const newName = uuid();
|
||||
this.initialValues[newName] = initialValues;
|
||||
propertyDefinition.name = newName;
|
||||
}
|
||||
|
||||
propertiesValues[propertyIndex] = propertyDefinition;
|
||||
this.props.update(propertiesValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a property as "to delete".
|
||||
*
|
||||
* @param {string} propertyName
|
||||
*/
|
||||
onPropertyDelete(propertyName) {
|
||||
const dialogProps = {
|
||||
title: _lt("Delete Property Field"),
|
||||
body: sprintf(
|
||||
_lt(
|
||||
'Are you sure you want to delete this property field? It will be removed for everyone using the "%s" %s.'
|
||||
),
|
||||
this.parentName,
|
||||
this.parentString
|
||||
),
|
||||
confirm: () => {
|
||||
if (this.popoverCloseFn) {
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
const propertiesDefinitions = this.propertiesList;
|
||||
propertiesDefinitions.find(
|
||||
(property) => property.name === propertyName
|
||||
).definition_deleted = true;
|
||||
this.props.update(propertiesDefinitions);
|
||||
},
|
||||
cancel: () => {},
|
||||
};
|
||||
this.dialogService.add(ConfirmationDialog, dialogProps);
|
||||
}
|
||||
|
||||
onPropertyCreate() {
|
||||
const propertiesDefinitions = this.propertiesList || [];
|
||||
|
||||
if (
|
||||
propertiesDefinitions.length &&
|
||||
propertiesDefinitions.some(
|
||||
(prop) => !prop.string || !prop.string.length
|
||||
)
|
||||
) {
|
||||
// do not allow to add new field until we set a label on the previous one
|
||||
this.propertiesRef.el
|
||||
.closest(".o_field_properties")
|
||||
.classList.add("o_field_invalid");
|
||||
|
||||
this.notification.add(
|
||||
_lt("Please complete your properties before adding a new one"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.propertiesRef.el
|
||||
.closest(".o_field_properties")
|
||||
.classList.remove("o_field_invalid");
|
||||
|
||||
propertiesDefinitions.push({
|
||||
name: uuid(),
|
||||
string: sprintf(
|
||||
_lt("Property %s"),
|
||||
propertiesDefinitions.length + 1
|
||||
),
|
||||
type: "char",
|
||||
definition_changed: true,
|
||||
});
|
||||
this.openLastPropertyDefinition = true;
|
||||
this.props.update(propertiesDefinitions);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags list has been changed.
|
||||
* If `newValue` is given, update the property value as well.
|
||||
*
|
||||
* @param {string} propertyName
|
||||
* @param {array} newTags
|
||||
* @param {array | null} newValue
|
||||
*/
|
||||
onTagsChange(propertyName, newTags, newValue = null) {
|
||||
const propertyDefinition = this.propertiesList.find(
|
||||
(property) => property.name === propertyName
|
||||
);
|
||||
propertyDefinition.tags = newTags;
|
||||
if (newValue !== null) {
|
||||
propertyDefinition.value = newValue;
|
||||
}
|
||||
propertyDefinition.definition_changed = true;
|
||||
this.onPropertyDefinitionChange(propertyDefinition);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Private methods
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Move the popover to the given property id.
|
||||
* Used when we change the position of the properties.
|
||||
*
|
||||
* We change the popover position after the DOM has been updated (see @useEffect)
|
||||
* because if we update it after changing the component properties,
|
||||
*/
|
||||
_movePopoverIfNeeded() {
|
||||
if (!this.shouldUpdatePopoverPosition) {
|
||||
return;
|
||||
}
|
||||
this.shouldUpdatePopoverPosition = false;
|
||||
|
||||
const propertyName = this.state.movedPropertyName;
|
||||
const popover = document
|
||||
.querySelector(".o_field_property_definition")
|
||||
.closest(".o_popover");
|
||||
const targetElement = document.querySelector(
|
||||
`.o_property_field[property-name="${propertyName}"]`
|
||||
);
|
||||
const targetPosition = targetElement.getBoundingClientRect();
|
||||
const popoverPosition = popover.getBoundingClientRect();
|
||||
|
||||
popover.style.top =
|
||||
targetPosition.top - popoverPosition.height - 10 + "px";
|
||||
popover.style.left = targetPosition.left + "px";
|
||||
popover.style.position = "absolute";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that we can write on the parent record,
|
||||
* and therefor update the properties definition.
|
||||
*/
|
||||
async _checkDefinitionAccess() {
|
||||
const definitionRecordId =
|
||||
this.props.record.data[this.definitionRecordField][0];
|
||||
this.parentName = this.props.record.data[this.definitionRecordField][1];
|
||||
const definitionRecordModel =
|
||||
this.props.record.fields[this.definitionRecordField].relation;
|
||||
this.parentString =
|
||||
this.props.record.fields[this.definitionRecordField].string;
|
||||
|
||||
if (!definitionRecordId || !definitionRecordModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we can write on the definition record
|
||||
this.state.canChangeDefinition = await this.orm.call(
|
||||
definitionRecordModel,
|
||||
"check_access_rights",
|
||||
["write", false]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If we change the type / model of a property, we will regenerate it's name
|
||||
* (like if it was a new property) in order to reset the value of the children.
|
||||
*
|
||||
* But if we reset the old model / type, we want to be able to discard this
|
||||
* modification (even if we save) and restore the original name.
|
||||
*
|
||||
* For that purpose, we save the original properties values.
|
||||
*/
|
||||
_saveInitialPropertiesValues() {
|
||||
// initial properties values, if the type or the model changed, the
|
||||
// name will be regenerated in order to reset the value on the children
|
||||
this.initialValues = {};
|
||||
for (let propertiesValues of this.props.value || []) {
|
||||
this.initialValues[propertiesValues.name] = {
|
||||
name: propertiesValues.name,
|
||||
type: propertiesValues.type,
|
||||
comodel: propertiesValues.comodel,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PropertiesField.template = "web.PropertiesField";
|
||||
PropertiesField.components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
PropertyDefinition,
|
||||
PropertyValue,
|
||||
};
|
||||
PropertiesField.props = {
|
||||
...standardFieldProps,
|
||||
columns: { type: Number, optional: true },
|
||||
};
|
||||
PropertiesField.extractProps = ({ attrs, field }) => {
|
||||
const columns = parseInt(attrs.columns || "1");
|
||||
return { columns };
|
||||
};
|
||||
|
||||
PropertiesField.displayName = _lt("Properties");
|
||||
PropertiesField.supportedTypes = ["properties"];
|
||||
|
||||
registry.category("fields").add("properties", PropertiesField);
|
||||
@@ -0,0 +1,149 @@
|
||||
.o_field_properties {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// The web client will add a "o_field_invalid" class when the field is considered
|
||||
// as not valid (at least one empty label). In that case we want to have in red
|
||||
// only the empty label, and not all inputs / components.
|
||||
.o_field_properties,
|
||||
.o_field_properties.o_field_invalid,
|
||||
.o_property_field_popover {
|
||||
.o_input,
|
||||
.o_dropdown_button,
|
||||
.o_datepicker_button {
|
||||
@include print-variable(o-input-border-color, $o-form-lightsecondary);
|
||||
color: initial !important;
|
||||
* {
|
||||
color: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
& {
|
||||
.o_input:focus,
|
||||
.dropdown:focus ~ .o_dropdown_button,
|
||||
.dropdown:focus-within ~ .o_dropdown_button,
|
||||
.o_input:focus ~ .o_datepicker_button,
|
||||
.o_dropdown_button:focus {
|
||||
@include print-variable(o-input-border-color, $o-brand-primary);
|
||||
* {
|
||||
@include print-variable(o-input-border-color, $o-brand-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
// only the empty property label should be affected by `o_field_invalid`
|
||||
.o_field_property_empty_label {
|
||||
color: map-get($theme-colors, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
.o_property_field {
|
||||
.o_td_label,
|
||||
.o_property_field_value {
|
||||
padding: 0 45px 0 0;
|
||||
}
|
||||
.o_property_field_value {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.o_field_property_dropdown input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_input_dropdown {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.popover-body {
|
||||
min-width: 500px;
|
||||
}
|
||||
.o_td_label {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.o_field_property_label {
|
||||
max-width: 150px;
|
||||
width: fit-content;
|
||||
b {
|
||||
word-break: break-word;
|
||||
max-height: 100px;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
&:hover .o_field_property_open_popover,
|
||||
.o_field_property_open_popover:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
.o_field_property_open_popover {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
transition: 0.1s;
|
||||
}
|
||||
.o_field_property_open_popover:hover {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.o-dropdown {
|
||||
.dropdown-menu {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_property_field_highlight .o_field_property_empty_label,
|
||||
.o_property_field_highlight b, {
|
||||
border: 1px solid $primary;
|
||||
padding: 5px;
|
||||
margin: -5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_field_property_add {
|
||||
// cancel button padding to align the
|
||||
// text button with properties labels
|
||||
margin-left: -1rem;
|
||||
}
|
||||
|
||||
.o_property_field_popover {
|
||||
// put the popover behind datetime component
|
||||
z-index: 1000 !important;
|
||||
font-size: inherit;
|
||||
box-shadow: 0 3rem 5rem rgba($black, .18) !important;
|
||||
box-sizing: border-box;
|
||||
.end-auto {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_input_dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_xxs_form_view .o_field_properties {
|
||||
// mobile view
|
||||
.o_property_field {
|
||||
min-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
|
||||
.o_td_label,
|
||||
.o_property_field_value {
|
||||
min-width: 100%;
|
||||
padding: 0;
|
||||
&>* {
|
||||
max-width: 100%;
|
||||
}
|
||||
.o_input_dropdown {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_field_property_add,
|
||||
.o_field_property_open_popover {
|
||||
display: none;
|
||||
}
|
||||
.o_field_property_open_popover.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertiesField" owl="1">
|
||||
<div t-ref="properties" class="w-100">
|
||||
<div class="o_group w-100 d-flex flex-row flex-wrap mt-0">
|
||||
<div
|
||||
t-foreach="propertiesList"
|
||||
t-as="propertyConfiguration"
|
||||
t-key="propertyConfiguration.name"
|
||||
class="o_property_field d-flex flex-row justify-content-start"
|
||||
t-att-property-name="propertyConfiguration.name"
|
||||
t-attf-class="col-md-{{12 / props.columns}} col-sm-12">
|
||||
<div class="o_td_label">
|
||||
<label t-attf-class="o_field_property_label o_form_label {{state.movedPropertyName === propertyConfiguration.name ? 'o_property_field_highlight' : ''}} d-flex flex-row align-items-center">
|
||||
<b
|
||||
t-if="propertyConfiguration.string && propertyConfiguration.string.length"
|
||||
t-out="propertyConfiguration.string"
|
||||
class="d-inline-block mw-100"/>
|
||||
<i t-else="" class="o_field_property_empty_label">
|
||||
New Property
|
||||
</i>
|
||||
<i
|
||||
t-if="state.canChangeDefinition"
|
||||
class="o_field_property_open_popover fa fa-pencil ms-2"
|
||||
t-on-click="(event) => this.onPropertyEdit(event, propertyConfiguration.name)"/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="o_property_field_value align-top w-100">
|
||||
<PropertyValue
|
||||
readonly="props.readonly"
|
||||
canChangeDefinition="state.canChangeDefinition"
|
||||
type="propertyConfiguration.type"
|
||||
string="propertyConfiguration.string"
|
||||
value="propertyConfiguration.value"
|
||||
selection="propertyConfiguration.selection"
|
||||
tags="propertyConfiguration.tags"
|
||||
comodel="propertyConfiguration.comodel || ''"
|
||||
domain="propertyConfiguration.domain || '[]'"
|
||||
context="context"
|
||||
onChange.bind="(value) => this.onPropertyValueChange(propertyConfiguration.name, value)"
|
||||
onTagsChange.bind="(newTags, newValue) => this.onTagsChange(propertyConfiguration.name, newTags, newValue)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_field_property_add">
|
||||
<button
|
||||
t-if="!props.readonly && state.canChangeDefinition"
|
||||
class="btn btn-light text-muted m-0"
|
||||
t-on-click="onPropertyCreate">
|
||||
<i class="fa fa-plus"/>
|
||||
Add a Property
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,341 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { PropertyValue } from "./property_value";
|
||||
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { ModelSelector } from "@web/core/model_selector/model_selector";
|
||||
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
|
||||
import { useService, useOwnedDialogs } from "@web/core/utils/hooks";
|
||||
import { PropertyDefinitionSelection } from "./property_definition_selection";
|
||||
import { PropertyTags } from "./property_tags";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
|
||||
const { Component, useState, onWillUpdateProps, useEffect, useRef } = owl;
|
||||
|
||||
export class PropertyDefinition extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
|
||||
this.propertyDefinitionRef = useRef("propertyDefinition");
|
||||
this.addDialog = useOwnedDialogs();
|
||||
|
||||
const defaultDefinition = {
|
||||
name: false,
|
||||
string: "",
|
||||
type: "char",
|
||||
default: "",
|
||||
};
|
||||
const propertyDefinition = {
|
||||
...defaultDefinition,
|
||||
...this.props.propertyDefinition,
|
||||
};
|
||||
|
||||
this.state = useState({
|
||||
propertyDefinition: propertyDefinition,
|
||||
typeLabel: this._typeLabel(propertyDefinition.type),
|
||||
resModel: "",
|
||||
resModelDescription: "",
|
||||
matchingRecordsCount: undefined,
|
||||
});
|
||||
|
||||
this._syncStateWithProps(propertyDefinition);
|
||||
|
||||
// update the state and fetch needed information
|
||||
onWillUpdateProps((newProps) =>
|
||||
this._syncStateWithProps(newProps.value)
|
||||
);
|
||||
|
||||
useEffect((event) => {
|
||||
// focus the property label, when we open the property definition
|
||||
if (this.labelFocused) {
|
||||
// focus it only once
|
||||
return;
|
||||
}
|
||||
this.labelFocused = true;
|
||||
const labelInput =
|
||||
this.propertyDefinitionRef.el.querySelectorAll("input")[0];
|
||||
if (labelInput) {
|
||||
labelInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the list of property types with their labels.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get availablePropertyTypes() {
|
||||
return [
|
||||
["char", _lt("Text")],
|
||||
["boolean", _lt("Checkbox")],
|
||||
["integer", _lt("Integer")],
|
||||
["float", _lt("Decimal")],
|
||||
["date", _lt("Date")],
|
||||
["datetime", _lt("Date & Time")],
|
||||
["selection", _lt("Selection")],
|
||||
["tags", _lt("Tags")],
|
||||
["many2one", _lt("Many2one")],
|
||||
["many2many", _lt("Many2many")],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of tag values, that will be selected by the PropertyTags
|
||||
* component (all existing tags because we are editing the definition).
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get propertyTagValues() {
|
||||
return (this.state.propertyDefinition.tags || []).map((tag) => tag[0]);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* We changed the string of the property.
|
||||
*
|
||||
* @param {event} event
|
||||
*/
|
||||
onPropertyLabelChange(event) {
|
||||
const newString = event.target.value;
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
string: newString,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* We changed the default value of the property.
|
||||
*
|
||||
* @param {object} newDefault
|
||||
*/
|
||||
onDefaultChange(newDefault) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
default: newDefault,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* We selected a new property type.
|
||||
*
|
||||
* @param {string} newType
|
||||
*/
|
||||
onPropertyTypeChange(newType) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
type: newType,
|
||||
default: false,
|
||||
value: false,
|
||||
};
|
||||
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
this.state.typeLabel = this._typeLabel(newType);
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of the relational property (many2one / many2many) has been changed.
|
||||
*
|
||||
* @param {string} newModel
|
||||
*/
|
||||
async onModelChange(newModel) {
|
||||
const { label, technical } = newModel;
|
||||
|
||||
// if we change the model, we should reset the default value and the domain
|
||||
const modelChanged = technical !== this.state.resModel;
|
||||
|
||||
this.state.resModel = technical;
|
||||
this.state.resModelDescription = label;
|
||||
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
comodel: technical,
|
||||
default: modelChanged
|
||||
? false
|
||||
: this.state.propertyDefinition.default,
|
||||
value: modelChanged ? false : this.state.propertyDefinition.value,
|
||||
domain: modelChanged ? false : this.state.propertyDefinition.domain,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
await this._updateMatchingRecordsCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain of the relational property has been changed.
|
||||
*
|
||||
* @param {string} newDomain
|
||||
*/
|
||||
async onDomainChange(newDomain) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
domain: newDomain,
|
||||
default: false,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
await this._updateMatchingRecordsCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the list view of the records matching the current domain.
|
||||
*/
|
||||
onButtonDomainClick() {
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: this.env._t("Selected records"),
|
||||
noCreate: true,
|
||||
multiSelect: false,
|
||||
resModel: this.state.propertyDefinition.comodel,
|
||||
domain: new Domain(
|
||||
this.state.propertyDefinition.domain || "[]"
|
||||
).toList(),
|
||||
context: this.props.context || {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* We renamed / created / removed a selection option.
|
||||
*
|
||||
* @param {array} newOptions
|
||||
*/
|
||||
onSelectionOptionChange(newOptions) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
selection: newOptions,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* We renamed / created / removed tags.
|
||||
*
|
||||
* @param {array} newTags
|
||||
*/
|
||||
onTagsChange(newTags) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
tags: newTags,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Private methods
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The property value changed (e.g. we discard a form view editing).
|
||||
* Re-update the state with the new props.
|
||||
*
|
||||
* @param {object} propertyDefinition
|
||||
*/
|
||||
async _syncStateWithProps(propertyDefinition) {
|
||||
const newModel = propertyDefinition.comodel;
|
||||
const currentModel = this.state.resModel;
|
||||
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
this.state.resModel = propertyDefinition.comodel;
|
||||
this.state.typeLabel = this._typeLabel(propertyDefinition.type);
|
||||
this.state.resModel = newModel;
|
||||
|
||||
if (newModel && newModel !== currentModel) {
|
||||
// retrieve the model id and the model description from it's name
|
||||
// "res.partner" => (5, "Contact")
|
||||
try {
|
||||
const result = await this.orm.call(
|
||||
"ir.model",
|
||||
"display_name_for",
|
||||
[[newModel]],
|
||||
);
|
||||
if (!result || !result.length) {
|
||||
return;
|
||||
}
|
||||
this.state.resModelDescription = result[0].display_name;
|
||||
} catch (_) {
|
||||
// can not read the ir.model
|
||||
this.state.resModelDescription = sprintf(
|
||||
_lt('You do not have access to the model "%s".'),
|
||||
newModel
|
||||
);
|
||||
}
|
||||
|
||||
await this._updateMatchingRecordsCount();
|
||||
} else if (!newModel) {
|
||||
this.state.resModelDescription = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the number of records that match the current domain.
|
||||
*/
|
||||
async _updateMatchingRecordsCount() {
|
||||
if (this.state.resModel && this.state.resModel.length) {
|
||||
const domainList = new Domain(
|
||||
this.state.propertyDefinition.domain || "[]"
|
||||
).toList();
|
||||
|
||||
const result = await this.orm.call(
|
||||
this.state.propertyDefinition.comodel,
|
||||
"search_count",
|
||||
[domainList]
|
||||
);
|
||||
|
||||
this.state.matchingRecordsCount = result;
|
||||
} else {
|
||||
this.state.matchingRecordsCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the property label corresponding to the property type.
|
||||
*
|
||||
* @param {string} propertyType
|
||||
* @returns {string}
|
||||
*/
|
||||
_typeLabel(propertyType) {
|
||||
const allTypes = this.availablePropertyTypes;
|
||||
return allTypes.find((type) => type[0] === propertyType)[1];
|
||||
}
|
||||
}
|
||||
|
||||
PropertyDefinition.template = "web.PropertyDefinition";
|
||||
PropertyDefinition.components = {
|
||||
DomainSelector,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
PropertyValue,
|
||||
Many2XAutocomplete,
|
||||
ModelSelector,
|
||||
PropertyDefinitionSelection,
|
||||
PropertyTags,
|
||||
};
|
||||
PropertyDefinition.props = {
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeDefinition: { type: Boolean, optional: true },
|
||||
propertyDefinition: { optional: true },
|
||||
context: { type: Object },
|
||||
onChange: { type: Function, optional: true },
|
||||
onDelete: { type: Function, optional: true },
|
||||
onPropertyMove: { type: Function, optional: true },
|
||||
|
||||
// prop needed by the popover service
|
||||
close: { type: Function, optional: true },
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
.o_field_property_definition_delete {
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.o_field_property_definition {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
|
||||
table {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.o_td_label {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.o_field_property_definition_header {
|
||||
max-width: 500px;
|
||||
|
||||
input {
|
||||
font-size: 1.2rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_field_property_definition_type {
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.o_input_dropdown {
|
||||
input {
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
padding-left: 25px;
|
||||
background-position-x: 0px;
|
||||
background-position-y: 3px;
|
||||
background-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
.o_field_property_definition_value {
|
||||
.form-check-input {
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_domain_debug_container {
|
||||
width: 100%;
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyDefinition" owl="1">
|
||||
<div class="o_field_property_definition o_field_widget d-block" t-ref="propertyDefinition">
|
||||
<div class="o_field_property_definition_header d-flex flex-row align-items-center justify-content-between m-3">
|
||||
<h6 t-if="props.readonly" t-out="state.propertyDefinition.string" class="text-truncate"/>
|
||||
<input
|
||||
t-else=""
|
||||
class="o_input px-2 me-3"
|
||||
type="text"
|
||||
required=""
|
||||
placeholder="Property Name"
|
||||
t-model="state.propertyDefinition.string"
|
||||
t-on-change="onPropertyLabelChange"
|
||||
/>
|
||||
<div class="d-flex flex-row me-3">
|
||||
<button
|
||||
class="btn btn-link p-0 m-0 me-2"
|
||||
t-on-click="() => props.onPropertyMove('up')">
|
||||
<i class="fa fa-chevron-up"/>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-link p-0 m-0"
|
||||
t-on-click="() => props.onPropertyMove('down')">
|
||||
<i class="fa fa-chevron-down"/>
|
||||
</button>
|
||||
<button
|
||||
t-if="!props.readonly && props.canChangeDefinition"
|
||||
class="btn text-danger p-0 float-end o_field_property_definition_delete ms-3"
|
||||
title="Remove Property"
|
||||
t-on-click="props.onDelete">
|
||||
<i class="fa fa-trash"/> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="m-3">
|
||||
<tr>
|
||||
<td class="align-top pe-2">
|
||||
<b>Field Type</b>
|
||||
</td>
|
||||
<td class="o_field_property_definition_type">
|
||||
<div
|
||||
t-if="props.readonly"
|
||||
class="d-flex align-items-center">
|
||||
<img class="me-3"
|
||||
t-attf-src="/web/static/src/views/fields/properties/icons/{{state.propertyDefinition.type}}.png"/>
|
||||
<span t-out="state.typeLabel"/>
|
||||
</div>
|
||||
<Dropdown
|
||||
t-else=""
|
||||
title="state.typeLabel" togglerClass="'btn btn-link d-flex p-0 w-100'">
|
||||
<t t-set-slot="toggler">
|
||||
<div class="o_input_dropdown w-100 o_field_property_dropdown">
|
||||
<input type="text" class="dropdown text-start w-100 o_input py-1"
|
||||
t-att-value="state.typeLabel" readonly=""
|
||||
t-attf-style="background-image: url(/web/static/src/views/fields/properties/icons/{{state.propertyDefinition.type}}.png)"/>
|
||||
<a role="button" class="o_dropdown_button"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="availablePropertyTypes" t-as="option" t-key="option[0]">
|
||||
<DropdownItem onSelected.bind="() => this.onPropertyTypeChange(option[0])">
|
||||
<div class="d-flex align-items-center">
|
||||
<img class="me-2"
|
||||
t-attf-src="/web/static/src/views/fields/properties/icons/{{option[0]}}.png"/>
|
||||
<span t-out="option[1]"/>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Add / remove selection labels -->
|
||||
<tr t-if="state.propertyDefinition.type === 'selection'">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b>Values</b>
|
||||
</td>
|
||||
<td>
|
||||
<PropertyDefinitionSelection
|
||||
readonly="props.readonly || !props.canChangeDefinition"
|
||||
canChangeDefinition="props.canChangeDefinition"
|
||||
default="state.propertyDefinition.default || ''"
|
||||
options="state.propertyDefinition.selection || []"
|
||||
onOptionsChange.bind="onSelectionOptionChange"
|
||||
onDefaultOptionChange.bind="onDefaultChange"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="state.propertyDefinition.type === 'tags'">
|
||||
<td class="o_td_label align-top">
|
||||
<b>Tags</b>
|
||||
</td>
|
||||
<td>
|
||||
<PropertyTags
|
||||
selectedTags="propertyTagValues"
|
||||
tags="state.propertyDefinition.tags || []"
|
||||
readonly="props.readonly"
|
||||
canChangeTags="props.canChangeDefinition"
|
||||
deleteAction="'tags'"
|
||||
onTagsChange.bind="onTagsChange"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="state.propertyDefinition.type === 'many2one' || state.propertyDefinition.type === 'many2many'" class="o_field_property_definition_model">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b>Model</b>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="props.readonly">
|
||||
<t t-if="state.resModelDescription" t-out="state.resModelDescription"/>
|
||||
</span>
|
||||
<ModelSelector
|
||||
t-else=""
|
||||
value="state.resModelDescription"
|
||||
onModelSelected.bind="onModelChange"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="(state.propertyDefinition.type === 'many2one' || state.propertyDefinition.type === 'many2many')
|
||||
&& state.propertyDefinition.comodel && state.propertyDefinition.comodel.length">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b>Domain</b>
|
||||
</td>
|
||||
<td>
|
||||
<DomainSelector
|
||||
resModel="state.propertyDefinition.comodel"
|
||||
value="state.propertyDefinition.domain || '[]'"
|
||||
readonly="props.readonly"
|
||||
update.bind="onDomainChange"
|
||||
isDebugMode="!!env.debug"
|
||||
className="props.readonly ? 'o_read_mode d-inline-block me-2' : 'o_edit_mode d-inline-block me-2'"
|
||||
/>
|
||||
<div t-if="state.matchingRecordsCount !== undefined" class="d-inline-block">
|
||||
<i class="fa fa-arrow-right" role="img" title="Domain"/>
|
||||
<button class="btn btn-sm btn-secondary" type="button"
|
||||
t-on-click.stop="onButtonDomainClick">
|
||||
<t t-out="state.matchingRecordsCount"/> record(s)
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="!['tags', 'selection'].includes(state.propertyDefinition.type)
|
||||
&& (!['many2one', 'many2many'].includes(state.propertyDefinition.type)
|
||||
|| (state.propertyDefinition.comodel && state.propertyDefinition.comodel.length))"
|
||||
class="o_field_property_definition_value">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b t-if="state.propertyDefinition.type === 'boolean'">
|
||||
Default State
|
||||
</b>
|
||||
<b t-else="">
|
||||
Default Value
|
||||
</b>
|
||||
</td>
|
||||
<td>
|
||||
<PropertyValue
|
||||
readonly="props.readonly"
|
||||
canChangeDefinition="props.canChangeDefinition"
|
||||
type="state.propertyDefinition.type"
|
||||
string="state.propertyDefinition.string"
|
||||
value="state.propertyDefinition.default"
|
||||
comodel="state.propertyDefinition.comodel || ''"
|
||||
domain="state.propertyDefinition.domain || '[]'"
|
||||
context="props.context"
|
||||
onChange.bind="onDefaultChange"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,219 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { uuid } from "../../utils";
|
||||
|
||||
const { Component, useState, useRef, useEffect } = owl;
|
||||
|
||||
export class PropertyDefinitionSelection extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
|
||||
// when we create a new option, it's added in the state
|
||||
// when we have finished to edit it (blur / enter) we propagate
|
||||
// the new value in the props
|
||||
this.state = useState({ newOption: null });
|
||||
|
||||
this.propertyDefinitionSelectionRef = useRef(
|
||||
"propertyDefinitionSelection"
|
||||
);
|
||||
this.addButtonRef = useRef("addButton");
|
||||
|
||||
useEffect(() => {
|
||||
// automatically give the focus to the new option if it is empty
|
||||
const inputs =
|
||||
this.propertyDefinitionSelectionRef.el.querySelectorAll(
|
||||
".o_field_property_selection_option input"
|
||||
);
|
||||
if (inputs && inputs.length && !inputs[inputs.length - 1].value) {
|
||||
inputs[inputs.length - 1].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the current available options.
|
||||
*
|
||||
* Make a deep copy to not change original object to be able to restore
|
||||
* the old props if we discard the editing of the forma view.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get options() {
|
||||
return JSON.parse(JSON.stringify(this.props.options || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Options visible by the UI, include the newly created option if needed.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get optionsVisible() {
|
||||
const options = this.options || [];
|
||||
return this.state.newOption
|
||||
? [...options, this.state.newOption]
|
||||
: options;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new empty selection option.
|
||||
*/
|
||||
onOptionCreate() {
|
||||
this.state.newOption = [uuid(), ""];
|
||||
}
|
||||
|
||||
/**
|
||||
* We changed an option label.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionChange(event, optionIndex) {
|
||||
const target = event.target;
|
||||
const newLabel = target.value;
|
||||
|
||||
if (
|
||||
this.options[optionIndex] &&
|
||||
this.options[optionIndex][1] === newLabel
|
||||
) {
|
||||
// do not update the props if we are already up to date
|
||||
// e.g. we pressed enter already and lost focus
|
||||
return;
|
||||
}
|
||||
|
||||
const options = this.optionsVisible;
|
||||
|
||||
if (!newLabel || !newLabel.length) {
|
||||
// if the label is empty, remove the option
|
||||
options.splice(optionIndex, 1);
|
||||
} else {
|
||||
options[optionIndex][1] = newLabel;
|
||||
}
|
||||
|
||||
const nonEmptyOptions = options.filter(
|
||||
(option) => option[1] && option[1].length
|
||||
);
|
||||
this.props.onOptionsChange(nonEmptyOptions);
|
||||
|
||||
if (
|
||||
this.state.newOption &&
|
||||
this.state.newOption[1] &&
|
||||
this.state.newOption[1].length
|
||||
) {
|
||||
// the new option has been propagated in the props
|
||||
this.state.newOption = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loose focus on an option, should cancel the newly
|
||||
* created option if we didn't write on it.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionBlur(event, optionIndex) {
|
||||
if (event.target.value && event.target.value.length) {
|
||||
// losing the focus on an non-empty option should have no effect
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.relatedTarget === this.addButtonRef.el) {
|
||||
// lost the focus because we click on the add button
|
||||
// if the value is empty, just ignore and cancel the event
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
} else if (optionIndex >= this.options.length) {
|
||||
// we remove the focus from the new empty option, remove it
|
||||
this.state.newOption = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We pressed Enter on an option, add it if it's not
|
||||
* empty and automatically create a new one.
|
||||
*
|
||||
* Navigate using the up / down arrows.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionKeyDown(event, optionIndex) {
|
||||
if (event.key === "Enter") {
|
||||
const newLabel = event.target.value;
|
||||
|
||||
if (!newLabel || !newLabel.length) {
|
||||
// press enter on an empty option, just ignore it, nothing to save
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
this.onOptionChange(event, optionIndex);
|
||||
this.onOptionCreate();
|
||||
} else if (["ArrowUp", "ArrowDown"].includes(event.key)) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === "ArrowUp" && optionIndex > 0) {
|
||||
const previousInput = event.target
|
||||
.closest(".o_field_property_selection_option")
|
||||
.previousElementSibling.querySelector("input");
|
||||
previousInput.focus();
|
||||
} else if (
|
||||
event.key === "ArrowDown" &&
|
||||
optionIndex < this.optionsVisible.length - 1
|
||||
) {
|
||||
const nextInput = event.target
|
||||
.closest(".o_field_property_selection_option")
|
||||
.nextElementSibling.querySelector("input");
|
||||
nextInput.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the default selection option.
|
||||
*
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionSetDefault(optionIndex) {
|
||||
if (!this.props.canChangeDefinition) {
|
||||
return;
|
||||
}
|
||||
const newValue = this.optionsVisible[optionIndex][0];
|
||||
this.props.onDefaultOptionChange(
|
||||
newValue !== this.props.default ? newValue : false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to remove the selection option.
|
||||
*
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionDelete(optionIndex) {
|
||||
const options = this.optionsVisible;
|
||||
options.splice(optionIndex, 1);
|
||||
this.props.onOptionsChange(options);
|
||||
}
|
||||
}
|
||||
|
||||
PropertyDefinitionSelection.template = "web.PropertyDefinitionSelection";
|
||||
|
||||
PropertyDefinitionSelection.props = {
|
||||
default: { type: String, optional: true },
|
||||
options: {},
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeDefinition: { type: Boolean, optional: true },
|
||||
onOptionsChange: { type: Function, optional: true }, // we add / remove / rename an option
|
||||
onDefaultOptionChange: { type: Function, optional: true }, // we select a default value
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
.o_field_property_selection {
|
||||
input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.fa-star,
|
||||
.fa-star-o {
|
||||
color: gold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyDefinitionSelection" owl="1">
|
||||
<div class="d-flex flex-column o_field_property_selection"
|
||||
t-ref="propertyDefinitionSelection">
|
||||
<div class="w-100">
|
||||
<div
|
||||
t-foreach="optionsVisible" t-as="option" t-key="option[0]"
|
||||
class="w-100 card d-flex flex-row align-items-center justify-content-between my-2 px-3 rounded o_field_property_selection_option">
|
||||
<span t-if="props.readonly" t-out="option[1]"/>
|
||||
<input
|
||||
t-else=""
|
||||
class="o_input"
|
||||
type="text"
|
||||
placeholder="Option Name"
|
||||
t-att-value="option[1]"
|
||||
t-on-change="(event) => this.onOptionChange(event, option_index)"
|
||||
t-on-blur="(event) => this.onOptionBlur(event, option_index)"
|
||||
t-on-keydown="(event) => this.onOptionKeyDown(event, option_index)"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-link text-secondary p-0 m-0"
|
||||
title="Select Default"
|
||||
t-on-click="() => this.onOptionSetDefault(option_index)">
|
||||
<i t-att-class="option[0] === props.default ? 'fa fa-star' : 'fa fa-star-o'"/>
|
||||
</button>
|
||||
<button
|
||||
t-if="!props.readonly"
|
||||
class="btn btn-link p-0 m-0 ms-2"
|
||||
title="Remove Property"
|
||||
t-on-click="() => this.onOptionDelete(option_index)">
|
||||
<i class="text-danger fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
t-if="!props.readonly"
|
||||
class="btn btn-link btn-sm"
|
||||
t-on-click="onOptionCreate"
|
||||
t-ref="addButton">
|
||||
<i class="fa fa-plus"/>
|
||||
Add a Value
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,294 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
|
||||
import { ColorList } from "@web/core/colorlist/colorlist";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
class PropertyTagsColorListPopover extends Component {}
|
||||
PropertyTagsColorListPopover.template = "web.PropertyTagsColorListPopover";
|
||||
PropertyTagsColorListPopover.components = {
|
||||
ColorList,
|
||||
};
|
||||
|
||||
export class PropertyTags extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.popover = usePopover();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the list containing tags values and actions for the TagsList component.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get tagListItems() {
|
||||
if (!this.props.selectedTags || !this.props.selectedTags.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Retrieve the tags label and color
|
||||
// ['a', 'b'] => [['a', 'A', 5], ['b', 'B', 6]]
|
||||
const value = this.props.tags.filter(
|
||||
(tag) => this.props.selectedTags.indexOf(tag[0]) >= 0
|
||||
);
|
||||
|
||||
const canDeleteTag = !this.props.readonly && this.props.canChangeTags;
|
||||
|
||||
return value.map((tag) => {
|
||||
const [tagId, tagLabel, tagColorIndex] = tag;
|
||||
return {
|
||||
id: tagId,
|
||||
text: tagLabel,
|
||||
colorIndex: tagColorIndex || 0,
|
||||
onClick: (event) =>
|
||||
this.onTagClick(event, tagId, tagColorIndex),
|
||||
onDelete: canDeleteTag && (() => this.onTagDelete(tagId)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current selected tags.
|
||||
* Make a deep copy to not make change on the original object
|
||||
* and to be able to discard change.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get selectedTags() {
|
||||
return JSON.parse(JSON.stringify(this.props.selectedTags || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current tags that can be selected.
|
||||
* Make a deep copy to not make change on the original object
|
||||
* and to be able to discard change.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get availableTags() {
|
||||
return JSON.parse(JSON.stringify(this.props.tags || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Options available in the autocomplete component.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get autocompleteSources() {
|
||||
return [
|
||||
{
|
||||
options: (request) => {
|
||||
const tagsFiltered = this.props.tags.filter(
|
||||
(tag) =>
|
||||
(!this.props.selectedTags ||
|
||||
this.props.selectedTags.indexOf(tag[0]) < 0) &&
|
||||
(!request ||
|
||||
!request.length ||
|
||||
tag[1]
|
||||
.toLocaleLowerCase()
|
||||
.indexOf(request.toLocaleLowerCase()) >= 0)
|
||||
);
|
||||
if (!tagsFiltered || !tagsFiltered.length) {
|
||||
// no result, ask the user if he want to create a new tag
|
||||
if (!request || !request.length) {
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
label: _lt("Start typing..."),
|
||||
classList: "fst-italic",
|
||||
},
|
||||
];
|
||||
} else if (!this.props.canChangeTags) {
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
label: _lt("No result"),
|
||||
classList: "fst-italic",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: { toCreate: true, value: request },
|
||||
label: sprintf(_lt('Create "%s"'), request),
|
||||
classList: "o_field_property_dropdown_add",
|
||||
},
|
||||
];
|
||||
}
|
||||
return tagsFiltered.map((tag) => {
|
||||
return {
|
||||
value: tag[0],
|
||||
label: tag[1],
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add one value in the current tag list values.
|
||||
*
|
||||
* @param {string | object} tagValue
|
||||
* Either
|
||||
* - {toCreate: true, value: label}, to create a new value
|
||||
* - value, to select an existing value
|
||||
*/
|
||||
onOptionSelected(tagValue) {
|
||||
if (!tagValue) {
|
||||
// clicked on "Start typing..."
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagValue.toCreate) {
|
||||
this.onTagCreate(tagValue.value);
|
||||
} else {
|
||||
const selectedTags = this.selectedTags;
|
||||
const newValue = [...selectedTags, tagValue];
|
||||
this.props.onValueChange(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to create a new tag that will be added in
|
||||
* the definition and automatically selected.
|
||||
*
|
||||
* @param {string} newLabel
|
||||
*/
|
||||
onTagCreate(newLabel) {
|
||||
if (!newLabel || !newLabel.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = newLabel
|
||||
? newLabel.toLowerCase().replace(" ", "_")
|
||||
: "";
|
||||
|
||||
const existingTag = this.props.tags.find((tag) => tag[0] === newValue);
|
||||
if (existingTag) {
|
||||
this.notification.add(_lt("This tag is already available"), {
|
||||
type: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// cycle trough colors
|
||||
const tagColor =
|
||||
this.props.tags && this.props.tags.length
|
||||
? (this.props.tags[this.props.tags.length - 1][2] + 1) %
|
||||
ColorList.COLORS.length
|
||||
: parseInt(Math.random() * ColorList.COLORS.length);
|
||||
|
||||
const newTag = [newValue, newLabel, tagColor];
|
||||
const updatedTags = [...this.availableTags, newTag];
|
||||
// automatically select the newly created tag
|
||||
const newValues = [...this.props.selectedTags, newTag[0]];
|
||||
this.props.onTagsChange(updatedTags, newValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the delete button on the tag pill.
|
||||
* The behavior is defined by the prop "deleteAction".
|
||||
*
|
||||
* If we use the component for the tag configuration, clicking on "delete"
|
||||
* will remove the tags from the available tags. If we use the component
|
||||
* the the tag selection, it will unselect the tag.
|
||||
*
|
||||
* @param {string} deleteTag, ID of the tag to delete
|
||||
*/
|
||||
onTagDelete(deleteTag) {
|
||||
if (this.props.deleteAction === "value") {
|
||||
// remove the tag from the value (but keep it in the options list)
|
||||
const selectedTags = this.selectedTags;
|
||||
const newValue = selectedTags.filter((tag) => tag !== deleteTag);
|
||||
this.props.onValueChange(newValue);
|
||||
} else {
|
||||
// remove the tag from the options
|
||||
const availableTags = this.availableTags;
|
||||
this.props.onTagsChange(
|
||||
availableTags.filter((tag) => tag[0] !== deleteTag)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on a tag pill, open the color popover if we can change the tag definition.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {string} tagId
|
||||
* @param {integer} tagColor
|
||||
*/
|
||||
onTagClick(event, tagId, tagColor) {
|
||||
if (!this.props.canChangeTags) {
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
event.currentTarget,
|
||||
this.constructor.components.Popover,
|
||||
{
|
||||
colors: [...Array(ColorList.COLORS.length).keys()],
|
||||
tag: { id: tagId, colorIndex: tagColor },
|
||||
switchTagColor: this.onTagColorSwitch.bind(this),
|
||||
},
|
||||
{
|
||||
closeOnClickAway: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to change the color of a tag.
|
||||
*
|
||||
* @param {integer} colorIndex
|
||||
* @param {object} currentTag
|
||||
*/
|
||||
onTagColorSwitch(colorIndex, currentTag) {
|
||||
const availableTags = this.availableTags;
|
||||
availableTags.find((tag) => tag[0] === currentTag.id)[2] = colorIndex;
|
||||
this.props.onTagsChange(availableTags);
|
||||
|
||||
// close the color popover
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
}
|
||||
|
||||
PropertyTags.template = "web.PropertyTags";
|
||||
PropertyTags.components = {
|
||||
AutoComplete,
|
||||
TagsList,
|
||||
ColorList,
|
||||
Popover: PropertyTagsColorListPopover,
|
||||
};
|
||||
|
||||
PropertyTags.props = {
|
||||
selectedTags: {}, // Tags value visible in the tags list
|
||||
tags: {}, // Tags definition visible in the dropdown
|
||||
// Define the behavior of the delete button on the tags, either
|
||||
// "value" or "tags". If "value", the delete button will unselect
|
||||
// the value, if "tags" the value will be removed from the definition.
|
||||
deleteAction: { type: String },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeTags: { type: Boolean, optional: true },
|
||||
// Select a new value
|
||||
onValueChange: { type: Function, optional: true },
|
||||
// Change the tags definition (can also receive a second
|
||||
// argument to update the current selected value)
|
||||
onTagsChange: { type: Function, optional: true },
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
.o_field_property_dropdown_add {
|
||||
* {
|
||||
color: $primary !important;
|
||||
}
|
||||
}
|
||||
.o_field_property_tag_readonly {
|
||||
// deactivate all click interactions with tags
|
||||
// if we can't change their definition
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_field_property_tag {
|
||||
.o_tag {
|
||||
margin: 1px 2px 1px 0;
|
||||
.o_delete {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
.o_delete {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.o_badge_text {
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyTags" owl="1">
|
||||
<div class="o_field_property_tag">
|
||||
<TagsList
|
||||
tags="tagListItems"
|
||||
className="props.canChangeTags ? '' : 'o_field_property_tag_readonly' "/>
|
||||
<div
|
||||
t-if="!props.readonly"
|
||||
class="o_field_property_dropdown_menu o_input_dropdown mt-2">
|
||||
<AutoComplete
|
||||
value="''"
|
||||
sources="autocompleteSources"
|
||||
onSelect.bind="(event) => this.onOptionSelected(event.value)"/>
|
||||
<a role="button" class="o_dropdown_button" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="web.PropertyTagsColorListPopover" owl="1">
|
||||
<div class="o_tag_popover m-2">
|
||||
<ColorList
|
||||
colors="props.colors"
|
||||
forceExpanded="true"
|
||||
onColorSelected.bind="(id) => props.switchTagColor(id, props.tag)"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,365 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { CheckBox } from "@web/core/checkbox/checkbox";
|
||||
import { DateTimePicker, DatePicker } from "@web/core/datepicker/datepicker";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import {
|
||||
Many2XAutocomplete,
|
||||
useOpenMany2XRecord,
|
||||
} from "@web/views/fields/relational_utils";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
|
||||
import { m2oTupleFromData } from "@web/views/fields/many2one/many2one_field";
|
||||
import { PropertyTags } from "./property_tags";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import {
|
||||
formatFloat,
|
||||
formatInteger,
|
||||
formatMany2one,
|
||||
} from "@web/views/fields/formatters";
|
||||
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
|
||||
|
||||
const { Component } = owl;
|
||||
const { DateTime } = luxon;
|
||||
|
||||
// Formats to stringify the date / datetime in the JSON.
|
||||
// It's important to have the year first, then the day,
|
||||
// etc... in UTC, to be able to search on them.
|
||||
const DEFAULT_SERVER_DATETIME_FORMAT = "yyyy-LL-dd HH:mm:ss";
|
||||
const DEFAULT_SERVER_DATE_FORMAT = "yyyy-LL-dd";
|
||||
|
||||
/**
|
||||
* Represent one property value.
|
||||
* Supports many types and instantiates the appropriate component for it.
|
||||
* - Text
|
||||
* - Integer
|
||||
* - Boolean
|
||||
* - Selection
|
||||
* - Datetime & Date
|
||||
* - Many2one
|
||||
* - Many2many
|
||||
* - Tags
|
||||
* - ...
|
||||
*/
|
||||
export class PropertyValue extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
|
||||
this.openMany2X = useOpenMany2XRecord({
|
||||
resModel: this.props.model,
|
||||
activeActions: {
|
||||
canCreate: false,
|
||||
canCreateEdit: false,
|
||||
canWrite: true,
|
||||
},
|
||||
isToMany: false,
|
||||
onRecordSaved: async (record) => {
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
// maybe the record display name has changed
|
||||
await record.load();
|
||||
const recordData = m2oTupleFromData(record.data);
|
||||
await this.onValueChange([
|
||||
{ id: recordData[0], name: recordData[1] },
|
||||
]);
|
||||
},
|
||||
fieldString: this.props.string,
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the value of the current property,
|
||||
* that will be used by the sub-components.
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
get propertyValue() {
|
||||
const value = this.props.value;
|
||||
|
||||
if (this.props.type === "float") {
|
||||
// force to show at least 1 digit, even for integers
|
||||
return value;
|
||||
} else if (this.props.type === "datetime") {
|
||||
if (typeof value === "string") {
|
||||
// convert the datetime from the UTC format to the current timezone
|
||||
const datetimeValue = DateTime.fromFormat(
|
||||
value + " +00:00",
|
||||
DEFAULT_SERVER_DATETIME_FORMAT + " Z"
|
||||
);
|
||||
return datetimeValue.invalid ? false : datetimeValue;
|
||||
}
|
||||
return value instanceof DateTime ? value : false;
|
||||
} else if (this.props.type === "date") {
|
||||
if (typeof value === "string") {
|
||||
const datetimeValue = DateTime.fromFormat(
|
||||
value + " +00:00",
|
||||
DEFAULT_SERVER_DATE_FORMAT + " Z"
|
||||
);
|
||||
return datetimeValue.invalid ? false : datetimeValue;
|
||||
}
|
||||
return value instanceof DateTime ? value : false;
|
||||
} else if (this.props.type === "boolean") {
|
||||
return !!value;
|
||||
} else if (this.props.type === "selection") {
|
||||
const options = this.props.selection || [];
|
||||
const option = options.find((option) => option[0] === value);
|
||||
return option && option.length === 2 && option[0] ? option[0] : "";
|
||||
} else if (this.props.type === "many2one") {
|
||||
return !value || value.length !== 2 || !value[0] ? false : value;
|
||||
} else if (this.props.type === "many2many") {
|
||||
if (!value || !value.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert to TagList component format
|
||||
return value.map((many2manyValue) => {
|
||||
return {
|
||||
id: many2manyValue[0],
|
||||
text: many2manyValue[1],
|
||||
onClick: async () =>
|
||||
await this._openRecord(
|
||||
this.props.comodel,
|
||||
many2manyValue[0]
|
||||
),
|
||||
onDelete:
|
||||
!this.props.readonly &&
|
||||
(() => this.onMany2manyDelete(many2manyValue[0])),
|
||||
colorIndex: 0,
|
||||
};
|
||||
});
|
||||
} else if (this.props.type === "tags") {
|
||||
return value || [];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the model domain (related to many2one and many2many properties).
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get propertyDomain() {
|
||||
if (!this.props.domain || !this.props.domain.length) {
|
||||
return [];
|
||||
}
|
||||
return new Domain(this.props.domain).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted value displayed in readonly mode.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get displayValue() {
|
||||
const value = this.propertyValue;
|
||||
|
||||
if (this.props.type === "many2one" && value && value.length === 2) {
|
||||
return formatMany2one(value);
|
||||
} else if (!value) {
|
||||
return false;
|
||||
} else if (this.props.type === "datetime" && value) {
|
||||
return formatDateTime(value);
|
||||
} else if (this.props.type === "date" && value) {
|
||||
return formatDate(value);
|
||||
} else if (this.props.type === "selection") {
|
||||
return this.props.selection.find(
|
||||
(option) => option[0] === value
|
||||
)[1];
|
||||
} else if (this.props.type === "float") {
|
||||
return formatFloat(value);
|
||||
} else if (this.props.type === "integer") {
|
||||
return formatInteger(value);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse the value received by the sub-components and trigger an onChange event.
|
||||
*
|
||||
* @param {object} newValue
|
||||
*/
|
||||
async onValueChange(newValue) {
|
||||
if (this.props.type === "datetime") {
|
||||
if (typeof newValue === "string") {
|
||||
newValue = DateTime.fromISO(newValue);
|
||||
}
|
||||
newValue = newValue
|
||||
.toUTC()
|
||||
.toFormat(DEFAULT_SERVER_DATETIME_FORMAT);
|
||||
} else if (this.props.type === "date") {
|
||||
if (typeof newValue === "string") {
|
||||
newValue = DateTime.fromISO(newValue);
|
||||
}
|
||||
newValue = newValue.toFormat(DEFAULT_SERVER_DATE_FORMAT);
|
||||
} else if (this.props.type === "integer") {
|
||||
newValue = parseInt(newValue) || 0;
|
||||
} else if (this.props.type === "float") {
|
||||
newValue = parseFloat(newValue) || 0;
|
||||
} else if (["many2one", "many2many"].includes(this.props.type)) {
|
||||
// {id: 5, name: 'Demo'} => [5, 'Demo']
|
||||
newValue =
|
||||
newValue && newValue.length && newValue[0].id
|
||||
? [newValue[0].id, newValue[0].name]
|
||||
: false;
|
||||
|
||||
if (newValue && newValue[0] && newValue[1] === undefined) {
|
||||
// The "Search More" option in the Many2XAutocomplete component
|
||||
// only return the record ID, and not the name. But we need to name
|
||||
// in the component props to be able to display it.
|
||||
// Make a RPC call to resolve the display name of the record.
|
||||
newValue = await this._nameGet(newValue[0]);
|
||||
}
|
||||
|
||||
if (this.props.type === "many2many" && newValue) {
|
||||
// add the record in the current many2many list
|
||||
const currentValue = this.props.value || [];
|
||||
const recordId = newValue[0];
|
||||
const exists = currentValue.find((rec) => rec[0] === recordId);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
newValue = [...currentValue, newValue];
|
||||
}
|
||||
}
|
||||
|
||||
// trigger the onchange event to notify the parent component
|
||||
this.props.onChange(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the form view of the current record.
|
||||
*
|
||||
* @param {event} event
|
||||
*/
|
||||
async onMany2oneClick(event) {
|
||||
if (this.props.readonly) {
|
||||
event.stopPropagation();
|
||||
await this._openRecord(this.props.comodel, this.propertyValue[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the current many2one record form view in a modal.
|
||||
*/
|
||||
onExternalLinkClick() {
|
||||
return this.openMany2X({
|
||||
resId: this.propertyValue[0],
|
||||
forceModel: this.props.comodel,
|
||||
context: this.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removed a record from the many2many list.
|
||||
*
|
||||
* @param {integer} many2manyId
|
||||
*/
|
||||
onMany2manyDelete(many2manyId) {
|
||||
// deep copy
|
||||
const currentValue = JSON.parse(JSON.stringify(this.props.value || []));
|
||||
const newValue = currentValue.filter(
|
||||
(value) => value[0] !== many2manyId
|
||||
);
|
||||
this.props.onChange(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to create a record from a relational property.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {object} params
|
||||
*/
|
||||
async onQuickCreate(name, params = {}) {
|
||||
if (params.triggeredOnBlur) {
|
||||
this.onValueChange(false);
|
||||
return;
|
||||
}
|
||||
const result = await this.orm.call(
|
||||
this.props.comodel,
|
||||
"name_create",
|
||||
[name],
|
||||
{ context: this.props.context }
|
||||
);
|
||||
this.onValueChange([{ id: result[0], name: result[1] }]);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Private methods
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Open the form view of the given record id / model.
|
||||
*
|
||||
* @param {string} recordModel
|
||||
* @param {integer} recordId
|
||||
*/
|
||||
async _openRecord(recordModel, recordId) {
|
||||
const action = await this.orm.call(
|
||||
recordModel,
|
||||
"get_formview_action",
|
||||
[[recordId]],
|
||||
{ context: this.props.context }
|
||||
);
|
||||
|
||||
this.action.doAction(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name of the given record.
|
||||
* Model is taken from the current selected model.
|
||||
*
|
||||
* @param {string} recordId
|
||||
* @returns {array} [record id, record name]
|
||||
*/
|
||||
async _nameGet(recordId) {
|
||||
const result = await this.orm.call(
|
||||
this.props.comodel,
|
||||
"name_get",
|
||||
[[recordId]],
|
||||
{ context: this.props.context }
|
||||
);
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
|
||||
PropertyValue.template = "web.PropertyValue";
|
||||
|
||||
PropertyValue.components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
CheckBox,
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Many2XAutocomplete,
|
||||
TagsList,
|
||||
AutoComplete,
|
||||
PropertyTags,
|
||||
};
|
||||
|
||||
PropertyValue.props = {
|
||||
type: { type: String, optional: true },
|
||||
comodel: { type: String, optional: true },
|
||||
domain: { type: String, optional: true },
|
||||
string: { type: String, optional: true },
|
||||
value: { optional: true },
|
||||
context: { type: Object },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeDefinition: { type: Boolean, optional: true },
|
||||
selection: { type: Array, optional: true },
|
||||
tags: { type: Array, optional: true },
|
||||
onChange: { type: Function, optional: true },
|
||||
onTagsChange: { type: Function, optional: true },
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
.o_field_property_dropdown_menu {
|
||||
.o-autocomplete--dropdown-menu {
|
||||
max-height: 200px!important;
|
||||
}
|
||||
}
|
||||
|
||||
// remove the down / up arrow for number input
|
||||
input.o_field_property_input::-webkit-outer-spin-button,
|
||||
input.o_field_property_input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input.o_field_property_input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.o_field_property_dropdown_empty {
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.o_field_property_many2many_value {
|
||||
.o_tag {
|
||||
margin: 1px 2px 1px 0;
|
||||
.o_delete {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
.o_delete {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.o_badge_text {
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyValue" owl="1">
|
||||
<t t-if="props.readonly && !['boolean', 'many2one', 'many2many', 'tags'].includes(props.type)">
|
||||
<span t-if="displayValue" t-out="displayValue"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="props.type === 'boolean'">
|
||||
<CheckBox
|
||||
value="propertyValue"
|
||||
disabled="props.readonly"
|
||||
onChange.bind="onValueChange"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'datetime'">
|
||||
<DateTimePicker
|
||||
date="propertyValue"
|
||||
onDateTimeChanged.bind="onValueChange"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'date'">
|
||||
<DatePicker
|
||||
date="propertyValue"
|
||||
onDateTimeChanged.bind="onValueChange"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'integer'">
|
||||
<input
|
||||
class="o_input o_field_property_input"
|
||||
type="number"
|
||||
t-att-value="propertyValue"
|
||||
t-on-change="(event) => this.onValueChange(event.target.value)"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'float'">
|
||||
<input
|
||||
class="o_input o_field_property_input"
|
||||
type="number"
|
||||
t-att-value="propertyValue"
|
||||
t-on-change="(event) => this.onValueChange(event.target.value)"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'selection'">
|
||||
<select class="o_input" t-on-change="event => this.onValueChange(event.target.value)">
|
||||
<option t-att-value="false" />
|
||||
<t t-foreach="props.selection || []" t-as="option" t-key="option[0]">
|
||||
<option
|
||||
t-att-selected="option[0] === propertyValue"
|
||||
t-att-value="option[0]"
|
||||
t-out="option[1]"
|
||||
/>
|
||||
</t>
|
||||
</select>
|
||||
</t>
|
||||
<t t-elif="props.type === 'tags'">
|
||||
<PropertyTags
|
||||
selectedTags="propertyValue"
|
||||
tags="props.tags || []"
|
||||
deleteAction="'value'"
|
||||
readonly="props.readonly"
|
||||
canChangeTags="props.canChangeDefinition"
|
||||
onValueChange.bind="onValueChange"
|
||||
onTagsChange.bind="props.onTagsChange"/>
|
||||
</t>
|
||||
<div
|
||||
t-elif="props.type === 'many2one'"
|
||||
class="d-flex flex-row">
|
||||
<t t-if="props.comodel">
|
||||
<a
|
||||
t-if="props.readonly"
|
||||
class="o_form_uri"
|
||||
t-att-href="propertyValue[0] ? `#id=${propertyValue[0]}&model=${props.comodel}` : '#'"
|
||||
t-on-click.prevent="onMany2oneClick">
|
||||
<span t-out="propertyValue && propertyValue[1] || ''" />
|
||||
</a>
|
||||
<Many2XAutocomplete
|
||||
t-else=""
|
||||
value="propertyValue && propertyValue[1] || ''"
|
||||
id="propertyValue[0] ? propertyValue[0].toString() : ''"
|
||||
resModel="props.comodel"
|
||||
autoSelect="true"
|
||||
fieldString="props.string"
|
||||
update.bind="onValueChange"
|
||||
context="props.context"
|
||||
getDomain.bind="() => propertyDomain"
|
||||
quickCreate.bind="onQuickCreate"
|
||||
activeActions="{canCreate: true, canCreateEdit: true, canWrite: true}"
|
||||
/>
|
||||
<button
|
||||
t-if="!props.readonly && propertyValue && propertyValue[0]"
|
||||
type="button"
|
||||
class="btn btn-secondary fa fa-external-link o_external_button o_properties_external_button py-0"
|
||||
tabindex="-1"
|
||||
draggable="false"
|
||||
aria-label="External link"
|
||||
data-tooltip="External link"
|
||||
t-on-click="onExternalLinkClick"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
<t t-elif="props.type === 'many2many'">
|
||||
<div t-if="props.comodel" class="o_field_property_many2many_value">
|
||||
<TagsList tags="propertyValue"/>
|
||||
<Many2XAutocomplete
|
||||
t-if="!props.readonly"
|
||||
id="''"
|
||||
resModel="props.comodel"
|
||||
autoSelect="true"
|
||||
fieldString="props.string"
|
||||
update.bind="onValueChange"
|
||||
context="props.context"
|
||||
getDomain.bind="() => propertyDomain"
|
||||
quickCreate.bind="onQuickCreate"
|
||||
activeActions="{canCreate: true, canCreateEdit: true, canWrite: true}"
|
||||
isToMany="true"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<input
|
||||
class="o_input"
|
||||
type="text"
|
||||
t-att-value="propertyValue || ''"
|
||||
t-on-change="(event) => this.onValueChange(event.target.value)"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -352,10 +352,11 @@ export function useOpenMany2XRecord({
|
||||
const addDialog = useOwnedDialogs();
|
||||
const orm = useService("orm");
|
||||
|
||||
return async function openDialog({ resId = false, title, context }, immediate = false) {
|
||||
return async function openDialog({ resId = false, forceModel = null, title, context }, immediate = false) {
|
||||
const model = forceModel || resModel;
|
||||
let viewId;
|
||||
if (resId !== false) {
|
||||
viewId = await orm.call(resModel, "get_formview_id", [[resId]], {
|
||||
viewId = await orm.call(model, "get_formview_id", [[resId]], {
|
||||
context,
|
||||
});
|
||||
}
|
||||
@@ -379,7 +380,7 @@ export function useOpenMany2XRecord({
|
||||
context,
|
||||
mode,
|
||||
resId,
|
||||
resModel,
|
||||
resModel: model,
|
||||
viewId,
|
||||
onRecordSaved,
|
||||
isToMany,
|
||||
|
||||
@@ -252,3 +252,15 @@ export function stringToOrderBy(string) {
|
||||
export function toStringExpression(str) {
|
||||
return `\`${str.replaceAll("`", "\\`")}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique identifier.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function uuid() {
|
||||
const array = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(array);
|
||||
// Uint8Array to hex
|
||||
return [...array].map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { ModelSelector } from "@web/core/model_selector/model_selector";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { click, editInput, getFixture, mount, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
|
||||
registry
|
||||
.category("mock_server")
|
||||
.add("ir.model/display_name_for", function (route, args) {
|
||||
const models = args.args[0];
|
||||
const records = this.models["ir.model"].records.filter((record) =>
|
||||
models.includes(record.model)
|
||||
);
|
||||
return records.map((record) => ({
|
||||
model: record.model,
|
||||
display_name: record.name,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let env;
|
||||
let fixture;
|
||||
|
||||
async function mountModelSelector(models = [], value = undefined, onModelSelected = () => {}) {
|
||||
await mount(ModelSelector, fixture, {
|
||||
env,
|
||||
props: {
|
||||
models,
|
||||
value,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openAutocomplete(search = undefined) {
|
||||
await click(fixture, ".o-autocomplete--input");
|
||||
}
|
||||
|
||||
async function beforeEach() {
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
serviceRegistry.add("orm", ormService);
|
||||
env = await makeTestEnv({
|
||||
serverData: {
|
||||
models: {
|
||||
"ir.model": {
|
||||
fields: {
|
||||
name: { string: "Model Name", type: "char" },
|
||||
model: { string: "Model", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Model 1",
|
||||
model: "model_1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Model 2",
|
||||
model: "model_2",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Model 3",
|
||||
model: "model_3",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Model 4",
|
||||
model: "model_4",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Model 5",
|
||||
model: "model_5",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Model 6",
|
||||
model: "model_6",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Model 7",
|
||||
model: "model_7",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Model 8",
|
||||
model: "model_8",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Model 9",
|
||||
model: "model_9",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "Model 10",
|
||||
model: "model_10",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
fixture = getFixture();
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
});
|
||||
}
|
||||
|
||||
QUnit.module("web > model_selector", { beforeEach });
|
||||
|
||||
QUnit.test("model_selector: with no model", async function (assert) {
|
||||
await mountModelSelector();
|
||||
await openAutocomplete();
|
||||
assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item");
|
||||
assert.strictEqual(
|
||||
fixture.querySelector("li.o-autocomplete--dropdown-item").innerText,
|
||||
"No records"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: displays model display names", async function (assert) {
|
||||
await mountModelSelector(["model_1", "model_2", "model_3"]);
|
||||
await openAutocomplete();
|
||||
assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 3);
|
||||
const items = fixture.querySelectorAll("li.o-autocomplete--dropdown-item");
|
||||
assert.strictEqual(items[0].innerText, "Model 1");
|
||||
assert.strictEqual(items[1].innerText, "Model 2");
|
||||
assert.strictEqual(items[2].innerText, "Model 3");
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: with 8 models", async function (assert) {
|
||||
await mountModelSelector([
|
||||
"model_1",
|
||||
"model_2",
|
||||
"model_3",
|
||||
"model_4",
|
||||
"model_5",
|
||||
"model_6",
|
||||
"model_7",
|
||||
"model_8",
|
||||
]);
|
||||
await openAutocomplete();
|
||||
assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 8);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: with more than 8 models", async function (assert) {
|
||||
await mountModelSelector([
|
||||
"model_1",
|
||||
"model_2",
|
||||
"model_3",
|
||||
"model_4",
|
||||
"model_5",
|
||||
"model_6",
|
||||
"model_7",
|
||||
"model_8",
|
||||
"model_9",
|
||||
"model_10",
|
||||
]);
|
||||
await openAutocomplete();
|
||||
assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 8);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: search content is not applied when opening the autocomplete", async function (assert) {
|
||||
await mountModelSelector(["model_1", "model_2"], "_2");
|
||||
await openAutocomplete();
|
||||
assert.containsN(fixture, "li.o-autocomplete--dropdown-item", 2);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: with search matching some records on technical name", async function (assert) {
|
||||
await mountModelSelector(["model_1", "model_2"]);
|
||||
await openAutocomplete();
|
||||
await editInput(fixture, ".o-autocomplete--input", "_2");
|
||||
assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item");
|
||||
assert.strictEqual(
|
||||
fixture.querySelector("li.o-autocomplete--dropdown-item").innerText,
|
||||
"Model 2"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: with search matching some records on business name", async function (assert) {
|
||||
await mountModelSelector(["model_1", "model_2"]);
|
||||
await openAutocomplete();
|
||||
await editInput(fixture, ".o-autocomplete--input", " 2");
|
||||
assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item");
|
||||
assert.strictEqual(
|
||||
fixture.querySelector("li.o-autocomplete--dropdown-item").innerText,
|
||||
"Model 2"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: with search matching no record", async function (assert) {
|
||||
await mountModelSelector(["model_1", "model_2"]);
|
||||
await openAutocomplete("a random search query");
|
||||
await editInput(fixture, ".o-autocomplete--input", "a random search query");
|
||||
assert.containsOnce(fixture, "li.o-autocomplete--dropdown-item");
|
||||
assert.strictEqual(
|
||||
fixture.querySelector("li.o-autocomplete--dropdown-item").innerText,
|
||||
"No records"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: select a model", async function (assert) {
|
||||
await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1", (selected) => {
|
||||
assert.step("model selected");
|
||||
assert.deepEqual(selected, {
|
||||
label: "Model 2",
|
||||
technical: "model_2",
|
||||
});
|
||||
});
|
||||
await openAutocomplete();
|
||||
await click(fixture.querySelector(".o_model_selector_model_2"));
|
||||
assert.verifySteps(["model selected"]);
|
||||
});
|
||||
|
||||
QUnit.test("model_selector: with an initial value", async function (assert) {
|
||||
await mountModelSelector(["model_1", "model_2", "model_3"], "Model 1");
|
||||
assert.equal(fixture.querySelector(".o-autocomplete--input").value, "Model 1");
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
from . import test_db_manager
|
||||
from . import test_health
|
||||
from . import test_image
|
||||
from . import test_ir_model
|
||||
from . import test_js
|
||||
from . import test_menu
|
||||
from . import test_serving_base
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import new_test_user
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class IrModelAccessTest(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(IrModelAccessTest, cls).setUpClass()
|
||||
|
||||
cls.env['ir.model.access'].create({
|
||||
'name': "read",
|
||||
'model_id': cls.env['ir.model'].search([("model", "=", "res.company")]).id,
|
||||
'group_id': cls.env.ref("base.group_public").id,
|
||||
'perm_read': False,
|
||||
})
|
||||
|
||||
cls.env['ir.model.access'].create({
|
||||
'name': "read",
|
||||
'model_id': cls.env['ir.model'].search([("model", "=", "res.company")]).id,
|
||||
'group_id': cls.env.ref("base.group_portal").id,
|
||||
'perm_read': True,
|
||||
})
|
||||
|
||||
cls.env['ir.model.access'].create({
|
||||
'name': "read",
|
||||
'model_id': cls.env['ir.model'].search([("model", "=", "res.company")]).id,
|
||||
'group_id': cls.env.ref("base.group_user").id,
|
||||
'perm_read': True,
|
||||
})
|
||||
|
||||
cls.portal_user = new_test_user(
|
||||
cls.env, login="portalDude", groups="base.group_portal"
|
||||
)
|
||||
cls.public_user = new_test_user(
|
||||
cls.env, login="publicDude", groups="base.group_public"
|
||||
)
|
||||
cls.spreadsheet_user = new_test_user(
|
||||
cls.env, login="spreadsheetDude", groups="base.group_user"
|
||||
)
|
||||
|
||||
def test_display_name_for(self):
|
||||
# Internal User with access rights can access the business name
|
||||
result = self.env['ir.model'].with_user(self.spreadsheet_user).display_name_for(["res.company"])
|
||||
self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}])
|
||||
# external user with access rights cannot access business name
|
||||
result = self.env['ir.model'].with_user(self.portal_user).display_name_for(["res.company"])
|
||||
self.assertEqual(result, [{"display_name": "res.company", "model": "res.company"}])
|
||||
# external user without access rights cannot access business name
|
||||
result = self.env['ir.model'].with_user(self.public_user).display_name_for(["res.company"])
|
||||
self.assertEqual(result, [{"display_name": "res.company", "model": "res.company"}])
|
||||
# admin has all rights
|
||||
result = self.env['ir.model'].display_name_for(["res.company"])
|
||||
self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}])
|
||||
# non existent model yields same result as a lack of access rights
|
||||
result = self.env['ir.model'].display_name_for(["unexistent"])
|
||||
self.assertEqual(result, [{"display_name": "unexistent", "model": "unexistent"}])
|
||||
# non existent model comes after existent model
|
||||
result = self.env['ir.model'].display_name_for(["res.company", "unexistent"])
|
||||
self.assertEqual(result, [{"display_name": "Companies", "model": "res.company"}, {"display_name": "unexistent", "model": "unexistent"}])
|
||||
@@ -96,6 +96,8 @@ class Discussion(models.Model):
|
||||
important_emails = fields.One2many('test_new_api.emailmessage', 'discussion',
|
||||
domain=[('important', '=', True)])
|
||||
|
||||
attributes_definition = fields.PropertiesDefinition('Message Properties') # see message@attributes
|
||||
|
||||
def _domain_very_important(self):
|
||||
"""Ensure computed O2M domains work as expected."""
|
||||
return [("important", "=", True)]
|
||||
@@ -142,6 +144,11 @@ class Message(models.Model):
|
||||
label = fields.Char(translate=True)
|
||||
priority = fields.Integer()
|
||||
|
||||
attributes = fields.Properties(
|
||||
string='Properties',
|
||||
definition='discussion.attributes_definition',
|
||||
)
|
||||
|
||||
@api.constrains('author', 'discussion')
|
||||
def _check_author(self):
|
||||
for message in self.with_context(active_test=False):
|
||||
@@ -222,6 +229,18 @@ class EmailMessage(models.Model):
|
||||
email_to = fields.Char('To')
|
||||
|
||||
|
||||
class DiscussionPartner(models.Model):
|
||||
"""
|
||||
Simplified model for partners. Having a specific model avoids all the
|
||||
overrides from other modules that may change which fields are being read,
|
||||
how many queries it takes to use that model, etc.
|
||||
"""
|
||||
_name = 'test_new_api.partner'
|
||||
_description = 'Discussion Partner'
|
||||
|
||||
name = fields.Char(string='Name')
|
||||
|
||||
|
||||
class Multi(models.Model):
|
||||
""" Model for testing multiple onchange methods in cascade that modify a
|
||||
one2many field several times.
|
||||
|
||||
@@ -55,6 +55,7 @@ access_test_new_api_model_binary,access_test_new_api_model_binary,model_test_new
|
||||
access_test_new_api_model_child_m2o,access_test_new_api_model_child_m2o,model_test_new_api_model_child_m2o,,1,1,1,1
|
||||
access_test_new_api_model_parent_m2o,access_test_new_api_model_parent_m2o,model_test_new_api_model_parent_m2o,,1,1,1,1
|
||||
access_test_new_api_model_private_address_onchange,access_test_new_api_model_private_address_onchange,model_test_new_api_model_private_address_onchange,base.group_user,1,1,1,1
|
||||
access_test_new_api_partner,access_test_new_api_partner,test_new_api.model_test_new_api_partner,,1,1,1,1
|
||||
access_test_new_api_req_m2o_transient,access_test_new_api_req_m2o_transient,model_test_new_api_req_m2o_transient,,0,0,0,0
|
||||
access_test_new_api_country,access_test_new_api_country,model_test_new_api_country,base.group_user,1,1,1,1
|
||||
access_test_new_api_city,access_test_new_api_city,model_test_new_api_city,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -5,6 +5,7 @@ from . import test_onchange
|
||||
from . import test_attributes
|
||||
from . import test_one2many
|
||||
from . import test_many2many
|
||||
from . import test_properties
|
||||
from . import test_qweb_float
|
||||
from . import test_ui
|
||||
from . import test_domain
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
<field name="name"/>
|
||||
<field name="author"/>
|
||||
<field name="size"/>
|
||||
<field name="attributes"/>
|
||||
</group>
|
||||
<label for="body"/>
|
||||
<field name="body"/>
|
||||
|
||||
@@ -8,10 +8,13 @@ from datetime import date, datetime, time
|
||||
from lxml import etree, html
|
||||
from operator import attrgetter
|
||||
from xmlrpc.client import MAXINT
|
||||
import ast
|
||||
import base64
|
||||
import copy
|
||||
import binascii
|
||||
import enum
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
@@ -22,7 +25,7 @@ import pytz
|
||||
from .tools import (
|
||||
float_repr, float_round, float_compare, float_is_zero, html_sanitize, human_size,
|
||||
pg_varchar, ustr, OrderedSet, pycompat, sql, date_utils, unique,
|
||||
image_process, merge_sequences, SQL_ORDER_BY_TYPE,
|
||||
image_process, merge_sequences, SQL_ORDER_BY_TYPE, is_list_of, has_list_types,
|
||||
)
|
||||
from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT
|
||||
from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT
|
||||
@@ -30,6 +33,7 @@ from .tools.translate import html_translate, _
|
||||
from .tools.mimetypes import guess_mimetype
|
||||
|
||||
from odoo.exceptions import CacheMiss
|
||||
from odoo.osv import expression
|
||||
|
||||
DATE_LENGTH = len(date.today().strftime(DATE_FORMAT))
|
||||
DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT))
|
||||
@@ -45,6 +49,7 @@ IR_MODELS = (
|
||||
_logger = logging.getLogger(__name__)
|
||||
_schema = logging.getLogger(__name__[:-7] + '.schema')
|
||||
|
||||
NoneType = type(None)
|
||||
Default = object() # default value for __init__() methods
|
||||
|
||||
|
||||
@@ -1066,7 +1071,8 @@ class Field(MetaField('DummyField', (object,), {})):
|
||||
|
||||
def read(self, records):
|
||||
""" Read the value of ``self`` on ``records``, and store it in cache. """
|
||||
raise NotImplementedError("Method read() undefined on %s" % self)
|
||||
if not self.column_type:
|
||||
raise NotImplementedError("Method read() undefined on %s" % self)
|
||||
|
||||
def create(self, record_values):
|
||||
""" Write the value of ``self`` on the given records, which have just
|
||||
@@ -3079,6 +3085,677 @@ class Many2oneReference(Integer):
|
||||
return model_ids
|
||||
|
||||
|
||||
class Properties(Field):
|
||||
""" Field that contains a list of properties (aka "sub-field") based on
|
||||
a definition defined on a container. Properties are pseudo-fields, acting
|
||||
like Odoo fields but without being independently stored in database.
|
||||
|
||||
This field allows a light customization based on a container record. Used
|
||||
for relationships such as <project.project> / <project.task>,... New
|
||||
properties can be created on the fly without changing the structure of the
|
||||
database.
|
||||
|
||||
The "definition_record" define the field used to find the container of the
|
||||
current record. The container must have a :class:`~odoo.fields.PropertiesDefinition`
|
||||
field "definition_record_field" that contains the properties definition
|
||||
(type of each property, default value)...
|
||||
|
||||
Only the value of each property is stored on the child. When we read the
|
||||
properties field, we read the definition on the container and merge it with
|
||||
the value of the child. That way the web client has access to the full
|
||||
field definition (property type, ...).
|
||||
"""
|
||||
type = 'properties'
|
||||
column_type = ('jsonb', 'jsonb')
|
||||
copy = False
|
||||
prefetch = False
|
||||
write_sequence = 10 # because it must be written after the definition field
|
||||
|
||||
# the field is computed editable by design (see the compute method below)
|
||||
store = True
|
||||
readonly = False
|
||||
precompute = True
|
||||
|
||||
definition = None
|
||||
definition_record = None # field on the current model that point to the definition record
|
||||
definition_record_field = None # field on the definition record which defined the Properties field definition
|
||||
|
||||
_description_definition_record = property(attrgetter('definition_record'))
|
||||
_description_definition_record_field = property(attrgetter('definition_record_field'))
|
||||
|
||||
ALLOWED_TYPES = (
|
||||
'boolean', 'integer', 'float', 'char', 'date',
|
||||
'datetime', 'many2one', 'many2many', 'selection', 'tags',
|
||||
)
|
||||
|
||||
def _setup_attrs(self, model_class, name):
|
||||
super()._setup_attrs(model_class, name)
|
||||
if self.definition:
|
||||
# determine definition_record and definition_record_field
|
||||
assert self.definition.count(".") == 1
|
||||
self.definition_record, self.definition_record_field = self.definition.rsplit('.', 1)
|
||||
|
||||
# make the field computed, and set its dependencies
|
||||
self._depends = (self.definition_record, )
|
||||
self.compute = self._compute
|
||||
|
||||
# Database/cache format: a value is either None, or a dict mapping property
|
||||
# names to their corresponding value, like
|
||||
#
|
||||
# {
|
||||
# '3adf37f3258cfe40f0907d9cbdd7d091': 'red',
|
||||
# 'aa34746a6851ee4ea1f8d95746e45788': 1337,
|
||||
# }
|
||||
#
|
||||
def convert_to_column(self, value, record, values=None, validate=True):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, str):
|
||||
# assume already JSONified
|
||||
if not isinstance(json.loads(value), dict):
|
||||
raise ValueError(f"Wrong property value {value!r}")
|
||||
return value
|
||||
|
||||
if isinstance(value, dict):
|
||||
return json.dumps(value)
|
||||
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"Wrong property type {type(value)!r}")
|
||||
|
||||
# Convert the list with all definitions into a simple dict
|
||||
# {name: value} to store the strict minimum on the child
|
||||
self._remove_display_name(value)
|
||||
value = self._list_to_dict(value)
|
||||
|
||||
# the JSON value is sent as a string
|
||||
return json.dumps(value)
|
||||
|
||||
def convert_to_cache(self, value, record, validate=True):
|
||||
# any format -> cache format {name: value} or None
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, dict):
|
||||
# avoid accidental side effects from shared mutable data
|
||||
return copy.deepcopy(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"Wrong property value {value!r}")
|
||||
return value
|
||||
|
||||
if isinstance(value, list):
|
||||
# Convert the list with all definitions into a simple dict
|
||||
# {name: value} to store the strict minimum on the child
|
||||
self._remove_display_name(value)
|
||||
return self._list_to_dict(value)
|
||||
|
||||
raise ValueError(f"Wrong property type {type(value)!r}")
|
||||
|
||||
# Record format: the value is a list, where each element is a dict
|
||||
# containing the definition of a property, together with the property's
|
||||
# corresponding value, like
|
||||
#
|
||||
# [{
|
||||
# 'name': '3adf37f3258cfe40f0907d9cbdd7d091',
|
||||
# 'string': 'Color Code',
|
||||
# 'type': 'char',
|
||||
# 'default': 'blue',
|
||||
# 'value': 'red',
|
||||
# }, {
|
||||
# 'name': 'aa34746a6851ee4ea1f8d95746e45788',
|
||||
# 'string': 'Partner',
|
||||
# 'type': 'many2one',
|
||||
# 'comodel': 'test_new_api.partner',
|
||||
# 'value': 1337,
|
||||
# }]
|
||||
#
|
||||
def convert_to_record(self, value, record):
|
||||
# value is in cache format
|
||||
definition = self._get_properties_definition(record)
|
||||
if not value or not definition:
|
||||
return definition or []
|
||||
|
||||
assert isinstance(value, dict)
|
||||
value = self._dict_to_list(value, definition)
|
||||
self._parse_json_types(value, record.env)
|
||||
|
||||
return value
|
||||
|
||||
# Read format: almost identical to the record format, except that relational
|
||||
# field values have a display name.
|
||||
#
|
||||
# [{
|
||||
# 'name': '3adf37f3258cfe40f0907d9cbdd7d091',
|
||||
# 'string': 'Color Code',
|
||||
# 'type': 'char',
|
||||
# 'default': 'blue',
|
||||
# 'value': 'red',
|
||||
# }, {
|
||||
# 'name': 'aa34746a6851ee4ea1f8d95746e45788',
|
||||
# 'string': 'Partner',
|
||||
# 'type': 'many2one',
|
||||
# 'comodel': 'test_new_api.partner',
|
||||
# 'value': [1337, 'Bob'],
|
||||
# }]
|
||||
#
|
||||
def convert_to_read(self, value, record, use_name_get=True):
|
||||
# value is in record format
|
||||
if use_name_get:
|
||||
self._add_display_name(value, record.env)
|
||||
return value
|
||||
|
||||
def convert_to_write(self, value, record):
|
||||
"""If we write a list on the child, update the definition record."""
|
||||
if isinstance(value, list):
|
||||
# will update the definition record
|
||||
self._remove_display_name(value)
|
||||
return value
|
||||
|
||||
return super().convert_to_write(value, record)
|
||||
|
||||
def convert_to_onchange(self, value, record, names):
|
||||
self._add_display_name(value, record.env)
|
||||
return value
|
||||
|
||||
def write(self, records, value):
|
||||
"""Check if the properties definition has been changed.
|
||||
|
||||
To avoid extra SQL queries used to detect definition change, we add a
|
||||
flag in the properties list. Parent update is done only when this flag
|
||||
is present, delegating the check to the caller (generally web client).
|
||||
|
||||
For deletion, we need to keep the removed property definition in the
|
||||
list to be able to put the delete flag in it. Otherwise we have no way
|
||||
to know that a property has been removed.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
# don't need to write on the container definition
|
||||
return super().write(records, value)
|
||||
|
||||
definition_changed = any(
|
||||
definition.get('definition_changed')
|
||||
or definition.get('definition_deleted')
|
||||
for definition in value
|
||||
)
|
||||
if definition_changed:
|
||||
value = [
|
||||
definition for definition in value
|
||||
if not definition.get('definition_deleted')
|
||||
]
|
||||
for definition in value:
|
||||
definition.pop('definition_changed', None)
|
||||
|
||||
# update the properties definition on the container
|
||||
container = records[self.definition_record]
|
||||
if container:
|
||||
properties_definition = copy.deepcopy(value)
|
||||
for property_definition in properties_definition:
|
||||
property_definition.pop('value', None)
|
||||
container[self.definition_record_field] = properties_definition
|
||||
|
||||
return super().write(records, value)
|
||||
|
||||
def _compute(self, records):
|
||||
"""Add the default properties value when the container is changed."""
|
||||
for record in records:
|
||||
record[self.name] = self._add_default_values(
|
||||
record.env,
|
||||
{self.name: False, self.definition_record: record[self.definition_record]},
|
||||
)
|
||||
|
||||
def _add_default_values(self, env, values):
|
||||
"""Read the properties definition to add default values.
|
||||
|
||||
Default values are defined on the container in the 'default' key of
|
||||
the definition.
|
||||
|
||||
:param env: environment
|
||||
:param values: All values that will be written on the record
|
||||
:return: Return the default values in the "dict" format
|
||||
"""
|
||||
properties_values = values.get(self.name) or {}
|
||||
|
||||
if not values.get(self.definition_record):
|
||||
# container is not given in the value, can not find properties definition
|
||||
return properties_values
|
||||
|
||||
container_id = values[self.definition_record]
|
||||
if not isinstance(container_id, (int, BaseModel)):
|
||||
raise ValueError(f"Wrong container value {container_id!r}")
|
||||
|
||||
if isinstance(container_id, int):
|
||||
# retrieve the container record
|
||||
current_model = env[self.model_name]
|
||||
definition_record_field = current_model._fields[self.definition_record]
|
||||
container_model_name = definition_record_field.comodel_name
|
||||
container_id = env[container_model_name].browse(container_id)
|
||||
|
||||
properties_definition = container_id[self.definition_record_field]
|
||||
if not properties_definition:
|
||||
return properties_values
|
||||
|
||||
assert isinstance(properties_values, (list, dict))
|
||||
if isinstance(properties_values, list):
|
||||
self._remove_display_name(properties_values)
|
||||
properties_list_values = properties_values
|
||||
else:
|
||||
properties_list_values = self._dict_to_list(properties_values, properties_definition)
|
||||
|
||||
for properties_value in properties_list_values:
|
||||
if properties_value.get('value') is None:
|
||||
default = properties_value.get('default')
|
||||
if default:
|
||||
properties_value['value'] = default
|
||||
|
||||
return properties_list_values
|
||||
|
||||
def _get_properties_definition(self, record):
|
||||
"""Return the properties definition of the given record."""
|
||||
container = record[self.definition_record]
|
||||
if container:
|
||||
return container.sudo()[self.definition_record_field]
|
||||
|
||||
@classmethod
|
||||
def _add_display_name(cls, values_list, env, value_keys=('value', 'default')):
|
||||
"""Add the "name_get" for each many2one / many2many properties.
|
||||
|
||||
Modify in place "values_list".
|
||||
|
||||
:param values_list: List of properties definition and values
|
||||
:param env: environment
|
||||
"""
|
||||
for property_definition in values_list:
|
||||
property_type = property_definition.get('type')
|
||||
property_model = property_definition.get('comodel')
|
||||
if not property_model:
|
||||
continue
|
||||
|
||||
for value_key in value_keys:
|
||||
property_value = property_definition.get(value_key)
|
||||
|
||||
if property_type == 'many2one' and property_value and isinstance(property_value, int):
|
||||
try:
|
||||
display_name = env[property_model].browse(property_value).display_name
|
||||
property_definition[value_key] = (property_value, display_name)
|
||||
except AccessError:
|
||||
# protect from access error message, show an empty name
|
||||
property_definition[value_key] = (property_value, _("No Access"))
|
||||
except MissingError:
|
||||
property_definition[value_key] = None
|
||||
|
||||
elif property_type == 'many2many' and property_value and is_list_of(property_value, int):
|
||||
try:
|
||||
display_names = env[property_model].browse(property_value).mapped('display_name')
|
||||
property_definition[value_key] = [
|
||||
(record_id, display_name)
|
||||
for record_id, display_name, in zip(property_value, display_names)
|
||||
]
|
||||
except AccessError:
|
||||
# protect from access error message, show an empty name
|
||||
property_definition[value_key] = [
|
||||
(res_id, _("No Access"))
|
||||
for res_id in property_value
|
||||
]
|
||||
except MissingError:
|
||||
property_definition[value_key] = None
|
||||
|
||||
@classmethod
|
||||
def _remove_display_name(cls, values_list, value_key='value'):
|
||||
"""Remove the display name received by the web client for the relational properties.
|
||||
|
||||
Modify in place "values_list".
|
||||
|
||||
- many2one: (35, 'Bob') -> 35
|
||||
- many2many: [(35, 'Bob'), (36, 'Alice')] -> [35, 36]
|
||||
|
||||
:param values_list: List of properties definition with properties value
|
||||
:param value_key: In which dict key we need to remove the display name
|
||||
"""
|
||||
for property_definition in values_list:
|
||||
if not isinstance(property_definition, dict) or not property_definition.get('name'):
|
||||
continue
|
||||
|
||||
property_value = property_definition.get(value_key)
|
||||
if not property_value:
|
||||
continue
|
||||
|
||||
property_type = property_definition.get('type')
|
||||
|
||||
if property_type == 'many2one' and has_list_types(property_value, [int, (str, NoneType)]):
|
||||
property_definition[value_key] = property_value[0]
|
||||
|
||||
elif property_type == 'many2many':
|
||||
if is_list_of(property_value, (list, tuple)):
|
||||
# [(35, 'Admin'), (36, 'Demo')] -> [35, 36]
|
||||
property_definition[value_key] = [
|
||||
many2many_value[0]
|
||||
for many2many_value in property_value
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _parse_json_types(cls, values_list, env, check_existence=True):
|
||||
"""Parse the value stored in the JSON.
|
||||
|
||||
Check for records existence, if we removed a selection option, ...
|
||||
Modify in place "values_list".
|
||||
|
||||
:param values_list: List of properties definition and values
|
||||
:param env: environment
|
||||
"""
|
||||
for property_definition in values_list:
|
||||
property_value = property_definition.get('value')
|
||||
property_type = property_definition.get('type')
|
||||
res_model = property_definition.get('comodel')
|
||||
|
||||
if property_type not in cls.ALLOWED_TYPES:
|
||||
raise ValueError(f'Wrong property type {property_type!r}')
|
||||
|
||||
if property_type == 'boolean':
|
||||
# E.G. convert zero to False
|
||||
property_value = bool(property_value)
|
||||
|
||||
elif property_type == 'char' and not isinstance(property_value, str):
|
||||
property_value = False
|
||||
|
||||
elif property_value and property_type == 'selection':
|
||||
# check if the selection option still exists
|
||||
options = property_definition.get('selection') or []
|
||||
options = {option[0] for option in options if option or ()} # always length 2
|
||||
if property_value not in options:
|
||||
# maybe the option has been removed on the container
|
||||
property_value = False
|
||||
|
||||
elif property_value and property_type == 'tags':
|
||||
# remove all tags that are not defined on the container
|
||||
all_tags = {tag[0] for tag in property_definition.get('tags') or ()}
|
||||
property_value = [tag for tag in property_value if tag in all_tags]
|
||||
|
||||
elif property_type == 'many2one' and property_value:
|
||||
if not isinstance(property_value, int):
|
||||
raise ValueError(f'Wrong many2one value: {property_value!r}.')
|
||||
|
||||
if check_existence and property_value:
|
||||
# many2one might have been deleted
|
||||
property_value = env[res_model].browse(property_value).exists().id
|
||||
|
||||
elif property_type == 'many2many' and property_value:
|
||||
if not is_list_of(property_value, int):
|
||||
raise ValueError(f'Wrong many2many value: {property_value!r}.')
|
||||
|
||||
if len(property_value) != len(set(property_value)):
|
||||
# remove duplicated value and preserve order
|
||||
property_value = list(dict.fromkeys(property_value))
|
||||
|
||||
elif check_existence and property_value:
|
||||
# many2many might have been deleted
|
||||
property_value = env[res_model].browse(property_value).exists().ids
|
||||
|
||||
property_definition['value'] = property_value
|
||||
|
||||
@classmethod
|
||||
def _list_to_dict(cls, values_list):
|
||||
"""Convert a list of properties with definition into a dict {name: value}.
|
||||
|
||||
To not repeat data in database, we only store the value of each property on
|
||||
the child. The properties definition is stored on the container.
|
||||
|
||||
E.G.
|
||||
Input list:
|
||||
[{
|
||||
'name': '3adf37f3258cfe40f0907d9cbdd7d091',
|
||||
'string': 'Color Code',
|
||||
'type': 'char',
|
||||
'default': 'blue',
|
||||
'value': 'red',
|
||||
}, {
|
||||
'name': 'aa34746a6851ee4ea1f8d95746e45788',
|
||||
'string': 'Partner',
|
||||
'type': 'many2one',
|
||||
'comodel': 'test_new_api.partner',
|
||||
'value': [1337, 'Bob'],
|
||||
}]
|
||||
|
||||
Output dict:
|
||||
{
|
||||
'3adf37f3258cfe40f0907d9cbdd7d091': 'red',
|
||||
'aa34746a6851ee4ea1f8d95746e45788': 1337,
|
||||
}
|
||||
|
||||
:param values_list: List of properties definition and value
|
||||
:return: Generate a dict {name: value} from this definitions / values list
|
||||
"""
|
||||
if not is_list_of(values_list, dict):
|
||||
raise ValueError(f'Wrong properties value {values_list!r}')
|
||||
|
||||
dict_value = {}
|
||||
for property_definition in values_list:
|
||||
property_value = property_definition.get('value')
|
||||
property_type = property_definition.get('type')
|
||||
property_model = property_definition.get('comodel')
|
||||
|
||||
if property_type in ('many2one', 'many2many') and property_model and property_value:
|
||||
# check that value are correct before storing them in database
|
||||
if property_type == 'many2many' and property_value and not is_list_of(property_value, int):
|
||||
raise ValueError(f"Wrong many2many value {property_value!r}")
|
||||
|
||||
if property_type == 'many2one' and not isinstance(property_value, int):
|
||||
raise ValueError(f"Wrong many2one value {property_value!r}")
|
||||
|
||||
dict_value[property_definition['name']] = property_value
|
||||
|
||||
return dict_value
|
||||
|
||||
@classmethod
|
||||
def _dict_to_list(cls, values_dict, properties_definition):
|
||||
"""Convert a dict of {property: value} into a list of property definition with values.
|
||||
|
||||
:param values_dict: JSON value coming from the child table
|
||||
:param properties_definition: Properties definition coming from the container table
|
||||
:return: Merge both value into a list of properties with value
|
||||
Ignore every values in the child that is not defined on the container.
|
||||
"""
|
||||
if not is_list_of(properties_definition, dict):
|
||||
raise ValueError(f'Wrong properties value {properties_definition!r}')
|
||||
|
||||
values_list = copy.deepcopy(properties_definition)
|
||||
for property_definition in values_list:
|
||||
property_definition['value'] = values_dict.get(property_definition['name'])
|
||||
return values_list
|
||||
|
||||
|
||||
class PropertiesDefinition(Field):
|
||||
""" Field used to define the properties definition (see :class:`~odoo.fields.Properties`
|
||||
field). This field is used on the container record to define the structure
|
||||
of expected properties on subrecords. It is used to check the properties
|
||||
definition. """
|
||||
type = 'properties_definition'
|
||||
column_type = ('jsonb', 'jsonb')
|
||||
copy = True # containers may act like templates, keep definitions to ease usage
|
||||
readonly = False
|
||||
prefetch = False
|
||||
|
||||
REQUIRED_KEYS = ('name', 'type')
|
||||
ALLOWED_KEYS = (
|
||||
'name', 'string', 'type', 'comodel', 'default',
|
||||
'selection', 'tags', 'domain',
|
||||
)
|
||||
|
||||
def convert_to_column(self, value, record, values=None, validate=True):
|
||||
"""Convert the value before inserting it in database.
|
||||
|
||||
This method accepts a list properties definition.
|
||||
|
||||
The relational properties (many2one / many2many) default value
|
||||
might contain the name_get of those records (and will be removed).
|
||||
|
||||
[{
|
||||
'name': '3adf37f3258cfe40f0907d9cbdd7d091',
|
||||
'string': 'Color Code',
|
||||
'type': 'char',
|
||||
'default': 'blue',
|
||||
'default': 'red',
|
||||
}, {
|
||||
'name': 'aa34746a6851ee4ea1f8d95746e45788',
|
||||
'string': 'Partner',
|
||||
'type': 'many2one',
|
||||
'comodel': 'test_new_api.partner',
|
||||
'default': [1337, 'Bob'],
|
||||
}]
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f'Wrong properties definition type {type(value)!r}')
|
||||
|
||||
Properties._remove_display_name(value, value_key='default')
|
||||
|
||||
self._validate_properties_definition(value, record.env)
|
||||
|
||||
return json.dumps(value)
|
||||
|
||||
def convert_to_cache(self, value, record, validate=True):
|
||||
# any format -> cache format (list of dicts or None)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, list):
|
||||
# avoid accidental side effects from shared mutable data, and make
|
||||
# the value strict with respect to JSON (tuple -> list, etc)
|
||||
value = json.dumps(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
value = json.loads(value)
|
||||
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f'Wrong properties definition type {type(value)!r}')
|
||||
|
||||
Properties._remove_display_name(value, value_key='default')
|
||||
|
||||
self._validate_properties_definition(value, record.env)
|
||||
|
||||
return value
|
||||
|
||||
def convert_to_record(self, value, record):
|
||||
# cache format -> record format (list of dicts)
|
||||
if not value:
|
||||
return []
|
||||
|
||||
# return a copy of the definition in cache where all property
|
||||
# definitions have been cleaned up
|
||||
result = []
|
||||
|
||||
for property_definition in value:
|
||||
if not all(property_definition.get(key) for key in self.REQUIRED_KEYS):
|
||||
# some required keys are missing, ignore this property definition
|
||||
continue
|
||||
|
||||
# don't modify the value in cache
|
||||
property_definition = dict(property_definition)
|
||||
|
||||
# check if the model still exists in the environment, the module of the
|
||||
# model might have been uninstalled so the model might not exist anymore
|
||||
property_model = property_definition.get('comodel')
|
||||
if property_model and property_model not in record.env:
|
||||
property_definition['comodel'] = property_model = False
|
||||
|
||||
if not property_model and 'domain' in property_definition:
|
||||
del property_definition['domain']
|
||||
|
||||
property_domain = property_definition.get('domain')
|
||||
if property_domain:
|
||||
# some fields in the domain might have been removed
|
||||
# (e.g. if the module has been uninstalled)
|
||||
# check if the domain is still valid
|
||||
try:
|
||||
expression.expression(
|
||||
ast.literal_eval(property_domain),
|
||||
record.env[property_model],
|
||||
)
|
||||
except ValueError:
|
||||
del property_definition['domain']
|
||||
|
||||
result.append(property_definition)
|
||||
|
||||
return result
|
||||
|
||||
def convert_to_read(self, value, record, use_name_get=True):
|
||||
# record format -> read format (list of dicts with display names)
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if use_name_get:
|
||||
Properties._add_display_name(value, record.env, value_keys=('default',))
|
||||
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def _validate_properties_definition(cls, properties_definition, env):
|
||||
"""Raise an error if the property definition is not valid."""
|
||||
properties_names = set()
|
||||
|
||||
for property_definition in properties_definition:
|
||||
property_definition_keys = set(property_definition.keys())
|
||||
|
||||
invalid_keys = property_definition_keys - set(cls.ALLOWED_KEYS)
|
||||
if invalid_keys:
|
||||
raise ValueError(
|
||||
'Some key are not allowed for a properties definition [%s].' %
|
||||
', '.join(invalid_keys),
|
||||
)
|
||||
|
||||
required_keys = set(cls.REQUIRED_KEYS) - property_definition_keys
|
||||
if required_keys:
|
||||
raise ValueError(
|
||||
'Some key are missing for a properties definition [%s].' %
|
||||
', '.join(required_keys),
|
||||
)
|
||||
|
||||
property_name = property_definition.get('name')
|
||||
if not property_name or property_name in properties_names:
|
||||
raise ValueError(f'The property name {property_name!r} is not set or duplicated.')
|
||||
properties_names.add(property_name)
|
||||
|
||||
property_type = property_definition.get('type')
|
||||
if property_type and property_type not in Properties.ALLOWED_TYPES:
|
||||
raise ValueError(f'Wrong property type {property_type!r}.')
|
||||
|
||||
model = property_definition.get('comodel')
|
||||
if model and model not in env:
|
||||
raise ValueError(f'Invalid model name {model!r}')
|
||||
|
||||
property_selection = property_definition.get('selection')
|
||||
if property_selection:
|
||||
if (not is_list_of(property_selection, (list, tuple))
|
||||
or not all(len(selection) == 2 for selection in property_selection)):
|
||||
raise ValueError(f'Wrong options {property_selection!r}.')
|
||||
|
||||
all_options = [option[0] for option in property_selection]
|
||||
if len(all_options) != len(set(all_options)):
|
||||
duplicated = set(filter(lambda x: all_options.count(x) > 1, all_options))
|
||||
raise ValueError(f'Some options are duplicated: {", ".join(duplicated)}.')
|
||||
|
||||
property_tags = property_definition.get('tags')
|
||||
if property_tags:
|
||||
if (not is_list_of(property_tags, (list, tuple))
|
||||
or not all(len(tag) == 3 and isinstance(tag[2], int) for tag in property_tags)):
|
||||
raise ValueError(f'Wrong tags definition {property_tags!r}.')
|
||||
|
||||
all_tags = [tag[0] for tag in property_tags]
|
||||
if len(all_tags) != len(set(all_tags)):
|
||||
duplicated = set(filter(lambda x: all_tags.count(x) > 1, all_tags))
|
||||
raise ValueError(f'Some tags are duplicated: {", ".join(duplicated)}.')
|
||||
|
||||
|
||||
class Command(enum.IntEnum):
|
||||
"""
|
||||
:class:`~odoo.fields.One2many` and :class:`~odoo.fields.Many2many` fields
|
||||
|
||||
@@ -2049,19 +2049,26 @@ class BaseModel(metaclass=MetaModel):
|
||||
if not avoid(field)
|
||||
}
|
||||
|
||||
if not missing_defaults:
|
||||
return values
|
||||
if missing_defaults:
|
||||
# override defaults with the provided values, never allow the other way around
|
||||
defaults = self.default_get(list(missing_defaults))
|
||||
for name, value in defaults.items():
|
||||
if self._fields[name].type == 'many2many' and value and isinstance(value[0], int):
|
||||
# convert a list of ids into a list of commands
|
||||
defaults[name] = [Command.set(value)]
|
||||
elif self._fields[name].type == 'one2many' and value and isinstance(value[0], dict):
|
||||
# convert a list of dicts into a list of commands
|
||||
defaults[name] = [Command.create(x) for x in value]
|
||||
defaults.update(values)
|
||||
|
||||
else:
|
||||
defaults = values
|
||||
|
||||
# delegate the default properties to the properties field
|
||||
for field in self._fields.values():
|
||||
if field.type == 'properties':
|
||||
defaults[field.name] = field._add_default_values(self.env, defaults)
|
||||
|
||||
# override defaults with the provided values, never allow the other way around
|
||||
defaults = self.default_get(list(missing_defaults))
|
||||
for name, value in defaults.items():
|
||||
if self._fields[name].type == 'many2many' and value and isinstance(value[0], int):
|
||||
# convert a list of ids into a list of commands
|
||||
defaults[name] = [Command.set(value)]
|
||||
elif self._fields[name].type == 'one2many' and value and isinstance(value[0], dict):
|
||||
# convert a list of dicts into a list of commands
|
||||
defaults[name] = [Command.create(x) for x in value]
|
||||
defaults.update(values)
|
||||
return defaults
|
||||
|
||||
@classmethod
|
||||
@@ -4348,6 +4355,11 @@ class BaseModel(metaclass=MetaModel):
|
||||
else:
|
||||
other_fields.add(field)
|
||||
|
||||
if field.type == 'properties':
|
||||
# force calling fields.create for properties field because
|
||||
# we might want to update the parent definition
|
||||
other_fields.add(field)
|
||||
|
||||
if not columns:
|
||||
# manage the case where we create empty records
|
||||
columns = ['id']
|
||||
@@ -6756,6 +6768,18 @@ class BaseModel(metaclass=MetaModel):
|
||||
# make a snapshot based on the initial values of record
|
||||
snapshot0 = Snapshot(record, nametree, fetch=(not first_call))
|
||||
|
||||
for name in initial_values:
|
||||
# TODO: The parent field on "record" can be False, if it was changed,
|
||||
# (even if if was changed to a not Falsy value) because of
|
||||
# >>> initial_values = dict(values, **dict.fromkeys(names, False))
|
||||
# If it's the case when we will read the properties field on this record,
|
||||
# it will return False as well (no parent == no definition) but we need
|
||||
# "snapshot0" to have the old value to be able to compare it with the new one
|
||||
# and trigger the onchange if necessary.
|
||||
field = self._fields.get(name)
|
||||
if field and field.type == 'properties':
|
||||
snapshot0[name] = initial_values[name]
|
||||
|
||||
# store changed values in cache; also trigger recomputations based on
|
||||
# subfields (e.g., line.a has been modified, line.b is computed stored
|
||||
# and depends on line.a, but line.b is not in the form view)
|
||||
|
||||
@@ -1677,3 +1677,25 @@ def street_split(street):
|
||||
'street_number': results[1].strip(),
|
||||
'street_number2': results[2],
|
||||
}
|
||||
|
||||
|
||||
def is_list_of(values, type_):
|
||||
"""Return True if the given values is a list / tuple of the given type.
|
||||
|
||||
:param values: The values to check
|
||||
:param type_: The type of the elements in the list / tuple
|
||||
"""
|
||||
return isinstance(values, (list, tuple)) and all(isinstance(item, type_) for item in values)
|
||||
|
||||
|
||||
def has_list_types(values, types):
|
||||
"""Return True if the given values have the same types as
|
||||
the one given in argument, in the same order.
|
||||
|
||||
:param values: The values to check
|
||||
:param types: The types of the elements in the list / tuple
|
||||
"""
|
||||
return (
|
||||
isinstance(values, (list, tuple)) and len(values) == len(types)
|
||||
and all(isinstance(item, type_) for item, type_ in zip(values, types))
|
||||
)
|
||||
|
||||