[FW][MERGE][IMP] base, web: allow to use them in kanban view

Purpose
=======

Check the existence of the relational properties (many2one / many2many) in
batch and prefetch the values in batch as well to reduce the number of SQL
queries.

Allow to add the properties field in the kanban view. An option has been added
in the property definition, "View In Kanban", to decide which field must be
visible in the kanban view. This is useful when having several properties
and to avoid cluttering the UI.

This merge also contain some fixes, see sub commits for more details.

Task-2965523

closes odoo/odoo#102243

Forward-port-of: odoo/odoo#101417
Related: odoo/enterprise#32318
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Odoo's Mergebot
2022-10-06 00:37:11 +02:00
committed by GitHub
17 changed files with 357 additions and 54 deletions
@@ -0,0 +1,15 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { PropertiesField } from "./properties_field";
export class KanbanPropertiesField extends PropertiesField {
async _checkDefinitionAccess() {
// can not edit properties definition in the kanban view
this.state.canChangeDefinition = false;
}
}
KanbanPropertiesField.template = "web.KanbanPropertiesField";
registry.category("fields").add("kanban.properties", KanbanPropertiesField);
@@ -0,0 +1,3 @@
.o_kanban_property_field {
max-width: 300px;
}
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.KanbanPropertiesField" owl="1">
<div t-ref="properties" class="w-100">
<div class="o_kanban_property_field d-inline-flex flex-column justify-content-center align-items-start w-100 my-1"
t-foreach="propertiesList"
t-as="propertyConfiguration"
t-key="propertyConfiguration.name"
t-if="propertyConfiguration.value &amp;&amp; propertyConfiguration.view_in_kanban">
<div class="mw-100 text-truncate">
<PropertyValue
canChangeDefinition="state.canChangeDefinition"
comodel="propertyConfiguration.comodel || ''"
context="context"
domain="propertyConfiguration.domain || '[]'"
readonly="props.readonly"
selection="propertyConfiguration.selection"
string="propertyConfiguration.string"
tags="propertyConfiguration.tags"
type="propertyConfiguration.type"
value="propertyConfiguration.value"
onChange.bind="() => {}"
onTagsChange.bind="() => {}"
/>
</div>
</div>
</div>
</t>
</templates>
@@ -154,9 +154,10 @@ export class PropertiesField extends Component {
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;
this.props.update(propertiesValues).then(() => {
// move the popover once the DOM is updated
this.shouldUpdatePopoverPosition = true;
});
}
/**
@@ -320,6 +321,11 @@ export class PropertiesField extends Component {
popover,
{ position: "top", margin: 10 },
);
const arrow = popover.querySelector(".popover-arrow");
if (arrow) {
arrow.classList.add("d-none");
}
}
/**
@@ -280,6 +280,20 @@ export class PropertyDefinition extends Component {
this.state.propertyDefinition = propertyDefinition;
}
/**
* We activate / deactivate the property in the kanban view.
*
* @param {boolean} newValue
*/
onViewInKanbanChange(newValue) {
const propertyDefinition = {
...this.state.propertyDefinition,
view_in_kanban: newValue,
};
this.props.onChange(propertyDefinition);
this.state.propertyDefinition = propertyDefinition;
}
/* --------------------------------------------------------
* Private methods
* -------------------------------------------------------- */
@@ -44,6 +44,7 @@
td {
padding-bottom: 15px;
}
.o_field_property_definition_kanban,
.o_field_property_definition_value {
.form-check-input {
margin-left: 3px;
@@ -55,3 +56,20 @@
display: inline-block !important;
}
}
.o_field_property_definition {
.o_input_dropdown {
.o_properties_external_button,
.o_dropdown_button {
display: none;
}
&:hover,
&:focus-within {
.o_properties_external_button,
.o_dropdown_button {
display: block;
}
}
}
}
@@ -164,6 +164,18 @@
/>
</td>
</tr>
<tr class="o_field_property_definition_kanban">
<td class="o_td_label align-top pe-2">
<b>View In Kanban</b>
</td>
<td>
<CheckBox
value="props.propertyDefinition.view_in_kanban"
disabled="props.readonly"
onChange.bind="onViewInKanbanChange"
/>
</td>
</tr>
</table>
</div>
</t>
@@ -16,6 +16,10 @@ PropertyTagsColorListPopover.components = {
ColorList,
};
// property tags does not really need timeout because it does not make RPC calls
export class PropertyTagAutoComplete extends AutoComplete { };
Object.assign(PropertyTagAutoComplete, { timeout: 0 });
export class PropertyTags extends Component {
setup() {
this.notification = useService("notification");
@@ -26,6 +30,15 @@ export class PropertyTags extends Component {
* Public methods / Getters
* -------------------------------------------------------- */
/**
* Return true if we should display the badges or just the tag label.
*
* @returns {array}
*/
get displayBadge() {
return !this.env.config || this.env.config.viewType !== "kanban";
}
/**
* Return the list containing tags values and actions for the TagsList component.
*
@@ -38,7 +51,12 @@ export class PropertyTags extends Component {
// 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);
let value = this.props.tags.filter((tag) => this.props.selectedTags.indexOf(tag[0]) >= 0);
if (!this.displayBadge) {
// in kanban view e.g. to not show tag without color
value = value.filter(tag => tag[2]);
}
const canDeleteTag = !this.props.readonly && this.props.canChangeTags;
@@ -262,7 +280,7 @@ export class PropertyTags extends Component {
PropertyTags.template = "web.PropertyTags";
PropertyTags.components = {
AutoComplete,
AutoComplete: PropertyTagAutoComplete,
TagsList,
ColorList,
Popover: PropertyTagsColorListPopover,
@@ -5,8 +5,6 @@
}
.o_field_property_tag {
border: 1px solid var(--o-input-border-color);
.o_tag {
margin: 1px 2px 1px 0;
}
@@ -33,3 +31,8 @@
}
}
}
.o_field_property_tag:not(.readonly):hover,
.o_field_property_tag:not(.readonly):focus-within {
border-bottom: 1px solid var(--o-input-border-color);
}
@@ -2,9 +2,10 @@
<templates xml:space="preserve">
<t t-name="web.PropertyTags" owl="1">
<!-- Copy many2many tags style without duplicating all the CSS -->
<div class="o_field_property_tag o_field_widget o_field_many2manytags o_input py-1 d-flex flex-row align-items-center pt-2 w-100">
<div t-attf-class="o_field_property_tag o_field_widget o_field_many2many_tags py-1 d-flex flex-row align-items-center flex-wrap pt-2 w-100 {{props.readonly ? 'readonly' : ''}}">
<TagsList
tags="tagListItems"
displayBadge="displayBadge"
className="props.canChangeTags ? '' : 'o_field_property_tag_readonly' "/>
<div
t-if="!props.readonly"
@@ -104,7 +104,7 @@ export class PropertyValue extends Component {
return {
id: many2manyValue[0],
text: hasAccess ? many2manyValue[1] : _lt('No Access'),
onClick: hasAccess
onClick: hasAccess && this.clickableRelational
&& (async () => await this._openRecord(this.props.comodel, many2manyValue[0])),
onDelete:
!this.props.readonly && hasAccess
@@ -164,8 +164,19 @@ export class PropertyValue extends Component {
return value.toString();
}
/**
* Return true if the relational properties are clickable.
*
* @returns {boolean}
*/
get clickableRelational() {
return !this.env.config || this.env.config.viewType !== "kanban";
}
/**
* Return True if we need to display a avatar for the current property.
*
* @returns {boolean}
*/
get showAvatar() {
return ["many2one", "many2many"].includes(this.props.type)
@@ -39,10 +39,9 @@ input.o_field_property_input[type=number] {
}
.o_field_property_many2many_value {
.o_tag,
.o_tag_badge_text,
.o_delete {
color: $gray-400 !important;
.o_tag {
color: nth($o-colors, 1) !important;
box-shadow: inset 0 0 0 1px !important;
}
.o_delete {
line-height: 1;
@@ -56,6 +55,33 @@ input.o_field_property_input[type=number] {
}
}
.o_form_readonly .o_field_property_many2many_value {
border: 0px;
.o_field_property_many2many_value:not(.readonly):hover,
.o_field_property_many2many_value:not(.readonly):focus-within {
border-bottom: 1px solid var(--o-input-border-color);
}
.o_field_property_many2many_value.avatar .o_tag{
img {
margin: -0.3em;
margin-right: 0.3em;
}
.o_tag_badge_text {
padding: 0px !important;
}
}
.o_property_field_value {
.o_properties_external_button,
.o_dropdown_button {
display: none;
}
&:hover,
&:focus-within {
.o_properties_external_button,
.o_dropdown_button {
display: block;
}
}
}
@@ -70,14 +70,19 @@
<img t-if="showAvatar &amp;&amp; propertyValue[0] &amp;&amp; hasAccess"
t-attf-src="/web/image/{{props.comodel}}/{{propertyValue[0]}}/avatar_128"
class="rounded-circle me-1"/>
<a
t-if="props.readonly || !hasAccess"
t-attf-class="o_form_uri {{propertyValue &amp;&amp; propertyValue[1] ? '' : 'disabled'}}"
t-att-href="propertyValue[0] ? `#id=${propertyValue[0]}&amp;model=${props.comodel}` : '#'"
t-on-click.prevent="onMany2oneClick">
<t t-if="hasAccess" t-out="propertyValue[1]"/>
<t t-else="">No Access</t>
</a>
<t t-if="props.readonly || !hasAccess">
<a
t-if="clickableRelational || !hasAccess"
t-attf-class="o_form_uri {{propertyValue &amp;&amp; propertyValue[1] ? '' : 'disabled'}}"
t-att-href="propertyValue[0] ? `#id=${propertyValue[0]}&amp;model=${props.comodel}` : '#'"
t-on-click.prevent="onMany2oneClick">
<t t-if="hasAccess" t-out="propertyValue[1]"/>
<t t-else="">No Access</t>
</a>
<span
t-else=""
t-out="propertyValue &amp;&amp; propertyValue[1] || ''" />
</t>
<Many2XAutocomplete
t-else=""
value="propertyValue[1] || ''"
@@ -94,7 +99,7 @@
<button
t-if="!props.readonly &amp;&amp; propertyValue &amp;&amp; propertyValue[0] &amp;&amp; hasAccess"
type="button"
class="btn btn-secondary fa fa-external-link o_external_button o_properties_external_button py-0"
class="btn btn-secondary fa fa-arrow-right o_external_button o_properties_external_button py-0"
tabindex="-1"
draggable="false"
aria-label="External link"
@@ -104,8 +109,8 @@
</t>
</div>
<t t-elif="props.type === 'many2many'">
<div t-if="props.comodel" class="o_field_property_many2many_value py-1 d-flex flex-row pt-2 w-100"
t-attf-class="o_field_widget o_field_many2manytags o_input {{showAvatar ? 'avatar' : ''}}">
<div t-if="props.comodel" class="o_field_property_many2many_value d-flex flex-row ms-0 pe-1 w-100 flex-wrap"
t-attf-class="o_field_widget o_field_many2many_tags {{showAvatar ? 'avatar' : ''}} {{props.readonly ? 'readonly' : ''}}">
<!-- Copy many2many tags style without duplicating all CSS -->
<TagsList tags="propertyValue"/>
<Many2XAutocomplete
@@ -57,6 +57,7 @@ QUnit.module("Fields", (hooks) => {
string: "My Char",
type: "char",
value: "char value",
view_in_kanban: true,
},
{
name: "property_2",
@@ -69,6 +70,7 @@ QUnit.module("Fields", (hooks) => {
],
value: "b",
default: "c",
view_in_kanban: true,
},
],
company_id: 37,
@@ -82,6 +84,7 @@ QUnit.module("Fields", (hooks) => {
string: "My Char",
type: "char",
value: "char value",
view_in_kanban: true,
},
{
name: "property_2",
@@ -94,6 +97,7 @@ QUnit.module("Fields", (hooks) => {
],
value: "c",
default: "c",
view_in_kanban: true,
},
{
name: "property_3",
@@ -106,6 +110,7 @@ QUnit.module("Fields", (hooks) => {
string: "My Char 4",
type: "char",
value: "char value 4",
view_in_kanban: true,
},
],
company_id: 37,
@@ -1052,4 +1057,42 @@ QUnit.module("Fields", (hooks) => {
const propertyName6 = property.getAttribute("property-name");
assert.strictEqual(propertyName4, propertyName6);
});
/**
* Check the behavior of the properties field in the kanban view.
*/
QUnit.test("properties: kanban view", async function (assert) {
await makeView({
type: "kanban",
resModel: "partner",
serverData,
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="company_id"/> <hr/>
<field name="display_name"/> <hr/>
<field name="properties" widget="properties"/>
</div>
</t>
</templates>
</kanban>`,
});
// check second card
const property3 = target.querySelector(".o_kanban_record:nth-child(2) .o_kanban_property_field:nth-child(3) span");
assert.notEqual(property3.innerText, "char value 3",
"The third property should not be visible in the kanban view");
assert.equal(property3.innerText, "char value 4");
const property1 = target.querySelector(".o_kanban_record:nth-child(2) .o_kanban_property_field:nth-child(1) span");
assert.equal(property1.innerText, "char value");
const property2 = target.querySelector(".o_kanban_record:nth-child(2) .o_kanban_property_field:nth-child(2) span");
assert.equal(property2.innerText, "C");
// check first card
const items = target.querySelectorAll(".o_kanban_record:nth-child(1) .o_kanban_property_field");
assert.equal(items.length, 2);
});
});
@@ -161,6 +161,7 @@ class PropertiesCase(TransactionCase):
self.assertEqual(sql_values_1, {'discussion_color_code': 'orange', 'moderator_partner_id': self.partner_2.id, 'state': 'done'})
self.assertEqual(sql_values_3, {'discussion_color_code': 'orange', 'moderator_partner_id': self.partner_2.id, 'state': 'done'})
@mute_logger('odoo.models.unlink', 'odoo.fields')
def test_properties_field_read_batch(self):
values = self.message_1.read(['attributes'])[0]['attributes']
self.assertEqual(len(values), 2)
@@ -186,11 +187,35 @@ class PropertiesCase(TransactionCase):
with self.assertQueryCount(5), self.assertQueries(expected_queries):
self.message_1.read(['attributes'])
# read in batch a lot of records
discussions = [self.discussion_1, self.discussion_2]
partners = self.env['test_new_api.partner'].create([{'name': f'Test {i}'} for i in range(50)])
messages = self.env['test_new_api.message'].create([{
'name': f'Test Message {i}',
'discussion': discussions[i % 2].id,
'author': self.user.id,
'attributes': [{
'name': 'partner_id',
'type': 'many2one',
'comodel': 'test_new_api.partner',
'value': partner.id,
'definition_changed': True,
}]
} for i, partner in enumerate(partners)])
self.env.invalidate_all()
expected_queries += expected_queries[-2:]
with self.assertQueryCount(7), self.assertQueries(expected_queries):
# 2 more queries for message 2 to verify his partner existence / name_get
(self.message_1 | self.message_2).read(['attributes'])
with self.assertQueryCount(5), self.assertQueries(expected_queries):
values = messages.read(['attributes'])
# remove some partners in the list
partners[:20].unlink()
self.env.invalidate_all()
# 5 queries instead of 25 queries, thanks to the cache values that has been
# cleaned (the properties field can trust the cached value, the deleted ids
# are not in the cache even if they still exists in the database)
with self.assertQueryCount(5):
values = messages.read(['attributes'])
@mute_logger('odoo.fields')
def test_properties_field_delete(self):
@@ -824,7 +849,7 @@ class PropertiesCase(TransactionCase):
'comodel': 'test_new_api.partner',
}]
with self.assertQueryCount(4):
with self.assertQueryCount(2):
self.message_1.attributes = [
{
"name": "moderator_partner_ids",
@@ -837,7 +862,7 @@ class PropertiesCase(TransactionCase):
self.assertEqual(self.message_1.attributes[0]['value'], partners[:10].ids)
partners[:5].unlink()
with self.assertQueryCount(4):
with self.assertQueryCount(5):
self.assertEqual(self.message_1.attributes[0]['value'], partners[5:10].ids)
partners[5].unlink()
@@ -847,7 +872,9 @@ class PropertiesCase(TransactionCase):
# need to wait next write to clean data in database
# a single read won't clean the removed many2many
self.message_1.attributes = self.message_1.read(['attributes'])[0]['attributes']
attributes = self.message_1.read(['attributes'])[0]['attributes']
self.message_1.invalidate_recordset()
self.message_1.attributes = attributes
sql_values = self._get_sql_properties(self.message_1)
self.assertEqual(sql_values, {'moderator_partner_ids': partners[6:10].ids})
@@ -915,7 +942,7 @@ class PropertiesCase(TransactionCase):
}])
@users('test')
@mute_logger('odoo.addons.base.models.ir_rule')
@mute_logger('odoo.addons.base.models.ir_rule', 'odoo.fields')
def test_properties_field_many2many_filtering(self):
# a user read a properties with a many2many and he doesn't have access to all records
tags = self.env['test_new_api.multi.tag'].create(
@@ -949,14 +976,12 @@ class PropertiesCase(TransactionCase):
self.assertEqual(values, [(tag.id, None if i >= 5 else tag.name) for i, tag in enumerate(tags.sudo())])
def test_properties_field_performance(self):
with self.assertQueryCount(4):
self.message_1.attributes
self.env.invalidate_all()
with self.assertQueryCount(5):
# read to put the partner name in cache
self.message_1.read(['attributes'])
expected = ['SELECT "test_new_api_partner".id FROM "test_new_api_partner" WHERE "test_new_api_partner".id IN %s']
with self.assertQueryCount(1, msg='Must read value from cache'), self.assertQueries(expected):
# still cost 1 SQL query to check existence because the ORM stores
# the raw SQL response in cache (not the result of convert_to_cache)
# so the value in cache is not verified (see models.py@_read)
with self.assertQueryCount(0, msg='Must read value from cache'):
self.message_1.attributes
expected = ['UPDATE "test_new_api_message" SET "attributes" = %s, "write_date" = %s, "write_uid" = %s WHERE id IN %s']
@@ -1086,6 +1111,7 @@ class PropertiesCase(TransactionCase):
# change the definition record, change the definition and add default values
self.assertEqual(message.discussion, self.discussion_2)
with self.assertQueryCount(4):
message.discussion = self.discussion_1
self.assertEqual(
@@ -1265,8 +1291,10 @@ class PropertiesCase(TransactionCase):
"""Check the access right related to the Properties fields."""
MultiTag = type(self.env['test_new_api.multi.tag'])
def _mocked_check_access_rights(*args, **kwargs):
raise AccessError('')
def _mocked_check_access_rights(operation, raise_exception=True):
if raise_exception:
raise AccessError('')
return False
# a user read a properties with a many2one to a record he doesn't have access to
tag = self.env['test_new_api.multi.tag'].create({'name': 'Test Tag'})
+76 -11
View File
@@ -3257,7 +3257,7 @@ class Properties(Field):
if not value or not definition:
return definition or []
assert isinstance(value, dict)
assert isinstance(value, dict), f"Wrong type {value!r}"
value = self._dict_to_list(value, definition)
self._parse_json_types(value, record.env)
@@ -3299,6 +3299,79 @@ class Properties(Field):
self._add_display_name(value, record.env)
return value
def read(self, records):
"""Read everything needed in batch for the given records.
To retrieve relational properties names, or to check their existence,
we need to do some SQL queries. To reduce the number of queries when we read
in batch, we put in cache everything needed before calling
convert_to_record / convert_to_read.
"""
definition_records_map = {
record: record[self.definition_record][self.definition_record_field]
for record in records
}
# ids per model we need to fetch in batch to put in cache
ids_per_model = defaultdict(OrderedSet)
records_cached_values = list(records.env.cache.get_values(records, self))
for record, record_values in zip(records, records_cached_values):
definition = definition_records_map.get(record)
if not record_values or not definition:
continue
for property_definition in definition:
comodel = property_definition.get('comodel')
type_ = property_definition.get('type')
name = property_definition.get('name')
if not comodel or type_ not in ('many2one', 'many2many') or name not in record_values:
continue
default = property_definition.get('default') or []
property_value = record_values[name] or []
if type_ == 'many2one':
default = [default] if default else []
property_value = [property_value] if property_value else []
ids_per_model[comodel].update(default)
ids_per_model[comodel].update(property_value)
# check existence and pre-fetch in batch
existing_ids_per_model = {}
for model, ids in ids_per_model.items():
recs = records.env[model].browse(ids).exists()
existing_ids_per_model[model] = set(recs.ids)
for record in recs:
# read a field to pre-fetch the recordset
try:
record.display_name
except AccessError:
pass
# update the cache and remove non-existing ids
for record, record_values in zip(records, records_cached_values):
definition = definition_records_map.get(record)
if not record_values or not definition:
continue
for property_definition in definition:
comodel = property_definition.get('comodel')
type_ = property_definition.get('type')
name = property_definition.get('name')
if not comodel or type_ not in ('many2one', 'many2many') or not record_values.get(name):
continue
property_value = record_values[name]
if type_ == 'many2one':
record_values[name] = property_value if property_value in existing_ids_per_model[comodel] else False
else:
record_values[name] = [id_ for id_ in property_value if id_ in existing_ids_per_model[comodel]]
records.env.cache.update(record, self, [record_values], check_dirty=False)
def write(self, records, value):
"""Check if the properties definition has been changed.
@@ -3487,7 +3560,7 @@ class Properties(Field):
definition['name'] = str(uuid.uuid4()).replace('-', '')[:16]
@classmethod
def _parse_json_types(cls, values_list, env, check_existence=True):
def _parse_json_types(cls, values_list, env):
"""Parse the value stored in the JSON.
Check for records existence, if we removed a selection option, ...
@@ -3529,10 +3602,6 @@ class Properties(Field):
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 and res_model in env:
if not is_list_of(property_value, int):
raise ValueError(f'Wrong many2many value: {property_value!r}.')
@@ -3541,10 +3610,6 @@ class Properties(Field):
# 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
@@ -3634,7 +3699,7 @@ class PropertiesDefinition(Field):
REQUIRED_KEYS = ('name', 'type')
ALLOWED_KEYS = (
'name', 'string', 'type', 'comodel', 'default',
'selection', 'tags', 'domain',
'selection', 'tags', 'domain', 'view_in_kanban',
)
def convert_to_column(self, value, record, values=None, validate=True):
+6
View File
@@ -3185,6 +3185,12 @@ class BaseModel(metaclass=MetaModel):
if field.store and field.translate:
translated_field_names.append(field.name)
if field.type == 'properties':
# force calling fields.read for properties field because
# we want to read all relational properties in batch
# (and check their existence in batch as well)
other_fields.append(field)
if column_fields:
cr, context = self.env.cr, self.env.context