diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index aaa5a2fab37..3a22a5a2558 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -716,6 +716,8 @@ + + @@ -1026,6 +1028,8 @@ + + diff --git a/addons/project/views/project_task_views.xml b/addons/project/views/project_task_views.xml index 62131f10958..cc24e38502c 100644 --- a/addons/project/views/project_task_views.xml +++ b/addons/project/views/project_task_views.xml @@ -79,6 +79,8 @@ + + diff --git a/addons/web/models/ir_model.py b/addons/web/models/ir_model.py index ade00f6ee89..44ce0e9c074 100644 --- a/addons/web/models/ir_model.py +++ b/addons/web/models/ir_model.py @@ -19,7 +19,7 @@ class IrModel(models.Model): accessible_models = [] not_accessible_models = [] for model in models: - if self._check_model_access(model): + if self._is_valid_for_model_selector(model): accessible_models.append(model) else: not_accessible_models.append({"display_name": model, "model": model}) @@ -34,9 +34,15 @@ class IrModel(models.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)) + def _is_valid_for_model_selector(self, model): + model = self.env.get(model) + return ( + self.env.user._is_internal() + and model is not None + and model.check_access_rights("read", raise_exception=False) + and not model._transient + and not model._abstract + ) @api.model def get_available_models(self): @@ -44,5 +50,5 @@ class IrModel(models.Model): 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)] + accessible_models = [model for model in self.pool if self._is_valid_for_model_selector(model)] return self._display_name_for(accessible_models) diff --git a/addons/web/models/models.py b/addons/web/models/models.py index 993da2a539d..61b7b7265ac 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -275,8 +275,9 @@ class Base(models.AbstractModel): :return a dictionnary mapping group_by values to dictionnaries mapping progress bar field values to the related number of records """ - group_by_fname = group_by.partition(':')[0] - field_type = self._fields[group_by_fname].type + group_by_fullname = group_by.partition(':')[0] + group_by_fieldname = group_by_fullname.split(".")[0] # split on "." in case we group on a property + field_type = self._fields[group_by_fieldname].type if field_type == 'selection': selection_labels = dict(self.fields_get()[group_by]['selection']) @@ -311,14 +312,22 @@ class Base(models.AbstractModel): # Workaround to match read_group's infrastructure # TO DO in master: harmonize this function and readgroup to allow factorization - group_by_name = group_by.partition(':')[0] + group_by_fullname = group_by.partition(':')[0] + group_by_fieldname = group_by_fullname.split(".")[0] # split on "." in case we group on a property group_by_modifier = group_by.partition(':')[2] or 'month' - records_values = self.search_read(domain or [], [progress_bar['field'], group_by_name]) - field_type = self._fields[group_by_name].type + records_values = self.search_read(domain or [], [progress_bar['field'], group_by_fieldname]) + field_type = self._fields[group_by_fieldname].type for record_values in records_values: - group_by_value = record_values.pop(group_by_name) + group_by_value = record_values.pop(group_by_fieldname) + property_name = group_by_fullname.partition('.')[2] + if field_type == "properties" and group_by_value: + group_by_value = next( + (definition['value'] for definition in group_by_value + if definition['name'] == property_name), + False, + ) # Again, imitating what _read_group_format_result and _read_group_prepare_data do if group_by_value and field_type in ['date', 'datetime']: diff --git a/addons/web/static/src/core/field_service.js b/addons/web/static/src/core/field_service.js index 4c405efa855..99555dd17a2 100644 --- a/addons/web/static/src/core/field_service.js +++ b/addons/web/static/src/core/field_service.js @@ -55,19 +55,22 @@ export const fieldService = { domain = Domain.and([[[definitionRecordField, "!=", false]], domain]).toList(); - const records = await orm.searchRead(definitionRecordModel, domain, [ - "display_name", - definitionRecordField, - ]); + const result = await orm.webSearchRead(definitionRecordModel, domain, { + specification: { + display_name: {}, + [definitionRecordField]: {}, + }, + }); const definitions = {}; - for (const record of records) { + for (const record of result.records) { for (const definition of record[definitionRecordField]) { definitions[definition.name] = { is_property: true, // for now, all properties are searchable but their definitions don't contain that info searchable: true, // differentiate definitions with same name but on different parent + record_id: record.id, record_name: record.display_name, ...definition, }; diff --git a/addons/web/static/src/model/relational_model/record.js b/addons/web/static/src/model/relational_model/record.js index 52afbb48d70..c9b76a69c8a 100644 --- a/addons/web/static/src/model/relational_model/record.js +++ b/addons/web/static/src/model/relational_model/record.js @@ -981,10 +981,22 @@ export class Record extends DataPoint { for (const [fieldName, value] of Object.entries(changes)) { const field = this.fields[fieldName]; if (field && field.relatedPropertyField) { - const propertyFieldName = field.relatedPropertyField.fieldName; - changes[propertyFieldName] = this.data[propertyFieldName].map((property) => - property.name === field.propertyName ? { ...property, value } : property + const [propertyFieldName, propertyName] = field.name.split("."); + const propertiesData = this.data[propertyFieldName] || []; + if (!propertiesData.find((property) => property.name === propertyName)) { + // try to change the value of a properties that has a different parent + this.model.notification.add( + _t( + "This record belongs to a different parent so you can not change this property." + ), + { type: "warning" } + ); + return; + } + changes[propertyFieldName] = propertiesData.map((property) => + property.name === propertyName ? { ...property, value } : property ); + delete changes[field.name]; } } const onChangeFields = Object.keys(changes).filter( diff --git a/addons/web/static/src/model/relational_model/relational_model.js b/addons/web/static/src/model/relational_model/relational_model.js index ed9468725ae..1b1af02b6dc 100644 --- a/addons/web/static/src/model/relational_model/relational_model.js +++ b/addons/web/static/src/model/relational_model/relational_model.js @@ -22,6 +22,7 @@ import { getBasicEvalContext, getFieldsSpec, isRelational, + makeActiveField, } from "./utils"; /** @@ -186,6 +187,30 @@ export class RelationalModel extends Model { // Protected // ------------------------------------------------------------------------- + /** + * If we group by default based on a property, the property might not be loaded in `fields`. + */ + async _getPropertyDefinition(config, propertyFullName) { + // dynamically load the property and add the definition in the fields attribute + const result = await this.orm.call( + config.resModel, + "get_property_definition", + [propertyFullName], + { context: config.context } + ); + if (!result) { + // the property might have been removed + config.groupBy = null; + } else { + result.propertyName = result.name; + result.name = propertyFullName; // "xxxxx" -> "property.xxxxx" + // needed for _applyChanges + result.relatedPropertyField = { fieldName: propertyFullName.split(".")[0] }; + result.relation = result.comodel; // match name on field + config.fields[propertyFullName] = result; + } + } + _askChanges() { const proms = []; this.bus.trigger("NEED_LOCAL_CHANGES", { proms }); @@ -330,6 +355,17 @@ export class RelationalModel extends Model { } config.groups = config.groups || {}; const firstGroupByName = config.groupBy[0].split(":")[0]; + if (firstGroupByName.includes(".")) { + if (!config.fields[firstGroupByName]) { + await this._getPropertyDefinition(config, firstGroupByName); + } + const propertiesFieldName = firstGroupByName.split(".")[0]; + if (!config.activeFields[propertiesFieldName]) { + // add the properties field so we load its data when reading the records + // so when we drag and drop we don't need to fetch the value of the record + config.activeFields[propertiesFieldName] = makeActiveField(); + } + } const orderBy = config.orderBy.filter( (o) => o.name === firstGroupByName || diff --git a/addons/web/static/src/model/relational_model/utils.js b/addons/web/static/src/model/relational_model/utils.js index 6e847445d8a..91f00ed6737 100644 --- a/addons/web/static/src/model/relational_model/utils.js +++ b/addons/web/static/src/model/relational_model/utils.js @@ -521,7 +521,7 @@ function getDisplayNameFromGroupData(field, rawValue) { if (field.type === "selection") { return Object.fromEntries(field.selection)[rawValue]; } - if (["many2one", "many2many"].includes(field.type)) { + if (["many2one", "many2many", "tags"].includes(field.type)) { return rawValue ? rawValue[1] : false; } return rawValue; diff --git a/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml b/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml index 51a41755b56..313b7d74374 100644 --- a/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml +++ b/addons/web/static/src/search/custom_group_by_item/custom_group_by_item.xml @@ -6,6 +6,7 @@ diff --git a/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.js b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.js new file mode 100644 index 00000000000..6b0fcc20f96 --- /dev/null +++ b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.js @@ -0,0 +1,71 @@ +/** @odoo-module **/ + +import { AccordionItem, ACCORDION } from "@web/core/dropdown/accordion_item"; +import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item"; +import { Component, useState, useChildSubEnv } from "@odoo/owl"; + +export class PropertiesGroupByItem extends Component { + static template = "web.PropertiesGroupByItem"; + static components = { AccordionItem, SearchDropdownItem }; + static props = { + item: Object, + onGroup: Function, + }; + + setup() { + this.state = useState({ groupByItems: [] }); + useChildSubEnv({ + [ACCORDION]: { + accordionStateChanged: this.beforeOpen.bind(this), + }, + }); + } + + /** + * The properties field is considered as active if one of its property is active. + */ + get isActive() { + return this.state.groupByItems.some((item) => item.isActive); + } + + /** + * True if all group items come from the same definition record. + */ + get isSingleParent() { + const uniqueNames = new Set(this.state.groupByItems.map((item) => item.definitionRecordId)); + return uniqueNames.size < 2; + } + + /** + * Dynamically load the definition, only when needed (if we open the dropdown). + */ + async beforeOpen() { + if (this.definitionLoaded) { + return; + } + this.definitionLoaded = true; + + await this.env.searchModel.fillSearchViewItemsProperty(); + this._updateGroupByItems(); + } + + /** + * Callback to group records per one property. + */ + onGroup(ids) { + this.props.onGroup(ids); + this._updateGroupByItems(); // isActive state might have changed + } + + /** + * Update the component state to sync it with the search model group item. + */ + _updateGroupByItems() { + this.state.groupByItems = this.env.searchModel.getSearchItems( + (searchItem) => + ["groupBy", "dateGroupBy"].includes(searchItem.type) && + searchItem.isProperty && + searchItem.propertyFieldName === this.props.item.fieldName + ); + } +} diff --git a/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.xml b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.xml new file mode 100644 index 00000000000..abb41b33f2a --- /dev/null +++ b/addons/web/static/src/search/properties_group_by_item/properties_group_by_item.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + No Properties + + + + diff --git a/addons/web/static/src/search/search_bar_menu/search_bar_menu.js b/addons/web/static/src/search/search_bar_menu/search_bar_menu.js index 75587be4637..43314badd6c 100644 --- a/addons/web/static/src/search/search_bar_menu/search_bar_menu.js +++ b/addons/web/static/src/search/search_bar_menu/search_bar_menu.js @@ -4,6 +4,7 @@ import { Component } from "@odoo/owl"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { DomainSelectorDialog } from "../../core/domain_selector_dialog/domain_selector_dialog"; import { Dropdown } from "@web/core/dropdown/dropdown"; +import { PropertiesGroupByItem } from "@web/search/properties_group_by_item/properties_group_by_item"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; import { registry } from "@web/core/registry"; import { sortBy } from "@web/core/utils/arrays"; @@ -25,6 +26,7 @@ export class SearchBarMenu extends Component { SearchDropdownItem, CustomGroupByItem, AccordionItem, + PropertiesGroupByItem, }; static props = { slots: { @@ -104,8 +106,9 @@ export class SearchBarMenu extends Component { * @returns {Object[]} */ get groupByItems() { - return this.env.searchModel.getSearchItems((searchItem) => - ["groupBy", "dateGroupBy"].includes(searchItem.type) + return this.env.searchModel.getSearchItems( + (searchItem) => + ["groupBy", "dateGroupBy"].includes(searchItem.type) && !searchItem.isProperty ); } diff --git a/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml b/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml index c44d2a66859..559f6b98c13 100644 --- a/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml +++ b/addons/web/static/src/search/search_bar_menu/search_bar_menu.xml @@ -66,7 +66,10 @@