[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>
This commit is contained in:
Odoo's Mergebot
2022-08-29 23:46:08 +02:00
committed by GitHub
49 changed files with 5996 additions and 18 deletions
+1
View File
@@ -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
+48
View File
@@ -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) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 979 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

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 &amp;&amp; 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 &amp;&amp; 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 &amp;&amp; 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')
&amp;&amp; state.propertyDefinition.comodel &amp;&amp; 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)
&amp;&amp; (!['many2one', 'many2many'].includes(state.propertyDefinition.type)
|| (state.propertyDefinition.comodel &amp;&amp; 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 &amp;&amp; !['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]}&amp;model=${props.comodel}` : '#'"
t-on-click.prevent="onMany2oneClick">
<span t-out="propertyValue &amp;&amp; propertyValue[1] || ''" />
</a>
<Many2XAutocomplete
t-else=""
value="propertyValue &amp;&amp; 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 &amp;&amp; propertyValue &amp;&amp; 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,
+12
View File
@@ -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");
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
+65
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
55 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
56 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
57 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
58 access_test_new_api_partner access_test_new_api_partner test_new_api.model_test_new_api_partner 1 1 1 1
59 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
60 access_test_new_api_country access_test_new_api_country model_test_new_api_country base.group_user 1 1 1 1
61 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
File diff suppressed because it is too large Load Diff
@@ -214,6 +214,7 @@
<field name="name"/>
<field name="author"/>
<field name="size"/>
<field name="attributes"/>
</group>
<label for="body"/>
<field name="body"/>
+679 -2
View File
@@ -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
+36 -12
View File
@@ -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)
+22
View File
@@ -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))
)